Authentik OIDC/SSO: Complete Integration Guide
Authentik は、Okta や Auth0 のような複雑さ (またはコスト) を必要とせずに、エンタープライズ グレードの SSO、OIDC、SAML、OAuth2 を提供するオープンソースのアイデンティティ プロバイダーです。セルフホスト型アプリケーションの場合、Authentik の OIDC エンドポイント、ユーザー管理、グループベースのアクセス制御、エンタープライズ ポリシーがすべて独自のインフラストラクチャの下で提供されます。
このガイドでは、本番環境の Authentik と Next.js 16 および NestJS 11 の統合について説明します。OAuth2 プロバイダー構成、安全な認証コード フロー、ワンタイム交換コード パターン、JWT 検証、および認証実装の成否を分ける微妙な問題について説明します。
重要なポイント
- 認証トークンは HttpOnly Cookie 内に存在する必要があります。localStorage やクエリ パラメーター内には決して存在しないでください。
- ワンタイム交換コード (60 秒 TTL) を使用して、コールバックからフロントエンドにトークンを渡します
- サーバー間のトークン交換には常に
AUTHENTIK_INTERNAL_URLを使用します (ネットワーク ホップを回避します)- キャッシュから削除する前に交換コードの有効期限チェックを行う必要があります (競合状態の防止)
- API 経由で再生成された Authentik クライアント シークレットにはハッシュの不一致がある可能性があります - Django ORM を直接使用してください
- JWT クレームには
organizationIdが含まれている必要があります — Authentik プロパティ マッピングでエンコードします- Cookie からの JWT 抽出が機能する前に、NestJS で
cookie-parserミドルウェアが必要です- OIDC 検出エンドポイントはすべてのトークン/ユーザー情報 URL を提供します - ハードコーディングしないでください
認証設定
OAuth2 プロバイダーの作成
Authentik 管理パネル (/if/admin/):
- [アプリケーション] > [プロバイダ] > [作成] に移動します。
- OAuth2/OpenID プロバイダー を選択します
- 以下を設定します。
- 名前:
ECOSIRE Web OAuth2 - 認証フロー:
default-provider-authorization-implicit-consent - クライアント タイプ:
Confidential - クライアント ID:
ecosire-web(これを選択します) - リダイレクト URI:
https://api.ecosire.com/api/auth/callback https://ecosire.com/auth/callback - 署名キー: デフォルトの証明書を選択します
- スコープ:
openid、email、profile
- このプロバイダーを使用する アプリケーション を作成します
カスタム プロパティのマッピング
organizationId を JWT に含めるには、プロパティ マッピングを作成します。
Authentik 管理 > カスタマイズ > プロパティ マッピング > 作成 > スコープ マッピング で:
# Name: Organization ID Scope
# Scope name: organization
return {
"organizationId": request.user.attributes.get("organizationId", str(request.user.pk)),
"name": request.user.name,
}
このマッピングを OAuth2 プロバイダーの「スコープ」リストに追加し、アプリケーションによって要求されたスコープに organization を含めます。
バックエンド: NestJS 認証モジュール
JWT 戦略
// auth/strategies/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
// Support both Cookie and Bearer token
jwtFromRequest: ExtractJwt.fromExtractors([
// Cookie first (web app)
(request) => {
return request?.cookies?.ecosire_auth ?? null;
},
// Bearer fallback (API clients, mobile)
ExtractJwt.fromAuthHeaderAsBearerToken(),
]),
ignoreExpiration: false,
// Validate against Authentik's public JWKS endpoint
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${process.env.AUTHENTIK_INTERNAL_URL}/application/o/ecosire-web/jwks/`,
}),
audience: 'ecosire-web',
issuer: `${process.env.AUTHENTIK_URL}/application/o/ecosire-web/`,
});
}
async validate(payload: {
sub: string;
email: string;
name: string;
organizationId?: string;
groups?: string[];
}) {
if (!payload.sub) {
throw new UnauthorizedException('Invalid token');
}
// Map Authentik claims to your internal user type
return {
sub: payload.sub,
email: payload.email,
name: payload.name,
organizationId: payload.organizationId ?? payload.sub,
role: this.mapGroupsToRole(payload.groups ?? []),
};
}
private mapGroupsToRole(groups: string[]): 'admin' | 'support' | 'user' {
if (groups.includes('ecosire-admins')) return 'admin';
if (groups.includes('ecosire-support')) return 'support';
return 'user';
}
}
クッキーの設定
// main.ts
import cookieParser from 'cookie-parser';
const app = await NestFactory.create(AppModule);
// cookie-parser MUST be registered before JWT strategy runs
app.use(cookieParser(process.env.COOKIE_SECRET));
認証コントローラー
// auth/auth.controller.ts
import {
Controller,
Get,
Post,
Query,
Res,
Req,
Body,
UnauthorizedException,
} from '@nestjs/common';
import { Response, Request } from 'express';
import { Public } from './decorators/public.decorator';
import { AuthService } from './auth.service';
const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 60 * 60 * 24 * 7 * 1000, // 7 days
};
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Get('login')
@Public()
login(@Res() res: Response) {
const params = new URLSearchParams({
client_id: process.env.AUTHENTIK_CLIENT_ID!,
redirect_uri: `${process.env.API_URL}/auth/callback`,
response_type: 'code',
scope: 'openid email profile organization',
state: this.authService.generateState(),
});
const authUrl = `${process.env.AUTHENTIK_URL}/application/o/authorize/?${params}`;
return res.redirect(authUrl);
}
@Get('callback')
@Public()
async callback(
@Query('code') code: string,
@Query('state') state: string,
@Res() res: Response
) {
// Exchange authorization code for tokens
const { accessToken, refreshToken } = await this.authService.exchangeCode(code, state);
// Create a one-time exchange code for the frontend
// This avoids putting the token in the redirect URL
const exchangeCode = await this.authService.createExchangeCode(accessToken, refreshToken);
// Redirect to frontend with the one-time code (not the token itself)
return res.redirect(
`${process.env.FRONTEND_URL}/auth/callback?exchange=${exchangeCode}`
);
}
@Post('exchange')
@Public()
async exchangeTokens(
@Body() body: { code: string },
@Res() res: Response
) {
// Frontend exchanges the one-time code for HttpOnly cookies
const tokens = await this.authService.redeemExchangeCode(body.code);
if (!tokens) {
throw new UnauthorizedException('Invalid or expired exchange code');
}
// Set HttpOnly cookies — tokens never in response body
res.cookie('ecosire_auth', tokens.accessToken, COOKIE_OPTS);
res.cookie('ecosire_refresh', tokens.refreshToken, {
...COOKIE_OPTS,
path: '/auth/refresh', // Refresh token only sent to refresh endpoint
});
return res.json({ success: true });
}
@Get('session')
async getSession(@Req() req: Request) {
// JWT guard already validated the cookie, user attached to req
return req.user; // sub, email, name, role, organizationId
}
@Post('logout')
async logout(@Res() res: Response) {
// Clear cookies with the SAME options used to set them
res.clearCookie('ecosire_auth', COOKIE_OPTS);
res.clearCookie('ecosire_refresh', {
...COOKIE_OPTS,
path: '/auth/refresh',
});
// Optionally: End Authentik session
const logoutUrl = `${process.env.AUTHENTIK_URL}/application/o/ecosire-web/end-session/`;
return res.json({ logoutUrl });
}
}
認証サービス: コード交換パターン
ワンタイム交換コードは、Token-in-URL (ブラウザ履歴、サーバー ログ、リファラー ヘッダーに表示される) を回避するための鍵です。
// auth/auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { Redis } from 'ioredis';
import { nanoid } from 'nanoid';
import axios from 'axios';
@Injectable()
export class AuthService {
constructor(private redis: Redis) {}
async exchangeCode(code: string, state: string) {
// Verify state matches (CSRF protection)
const storedState = await this.redis.get(`auth:state:${state}`);
if (!storedState) {
throw new UnauthorizedException('Invalid state parameter');
}
await this.redis.del(`auth:state:${state}`);
// Exchange authorization code for tokens using internal URL
// AUTHENTIK_INTERNAL_URL avoids going through Nginx/Cloudflare for
// server-to-server calls inside the same network
const response = await axios.post(
`${process.env.AUTHENTIK_INTERNAL_URL}/application/o/token/`,
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${process.env.API_URL}/auth/callback`,
client_id: process.env.AUTHENTIK_CLIENT_ID!,
client_secret: process.env.AUTHENTIK_CLIENT_SECRET!,
}),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return {
accessToken: response.data.access_token,
refreshToken: response.data.refresh_token,
};
}
async createExchangeCode(
accessToken: string,
refreshToken: string
): Promise<string> {
const code = nanoid(32);
const payload = JSON.stringify({ accessToken, refreshToken });
// 60-second TTL — enough for the redirect to complete
await this.redis.setex(`auth:exchange:${code}`, 60, payload);
return code;
}
async redeemExchangeCode(code: string) {
const key = `auth:exchange:${code}`;
// Check expiry BEFORE deleting (prevents race condition)
const ttl = await this.redis.ttl(key);
if (ttl <= 0) {
return null; // Already expired or doesn't exist
}
const data = await this.redis.getdel(key); // Atomic get-and-delete
if (!data) return null;
return JSON.parse(data);
}
generateState(): string {
const state = nanoid(32);
// Store state with 10-minute TTL
this.redis.setex(`auth:state:${state}`, 600, '1');
return state;
}
async upsertUser(payload: {
sub: string;
email: string;
name: string;
organizationId: string;
}) {
const [user] = await db
.insert(users)
.values({
id: payload.sub,
email: payload.email,
name: payload.name,
organizationId: payload.organizationId,
})
.onConflictDoUpdate({
target: users.id,
set: {
email: payload.email,
name: payload.name,
lastLoginAt: new Date(),
},
})
.returning();
return user;
}
}
フロントエンド: コールバック ハンドラー
// app/auth/callback/page.tsx — Next.js page
'use client';
import { useEffect } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function AuthCallbackPage() {
const router = useRouter();
const params = useSearchParams();
useEffect(() => {
const exchangeCode = params.get('exchange');
const redirectTo = params.get('redirect') ?? '/dashboard';
if (!exchangeCode) {
router.push('/auth/login?error=no_code');
return;
}
// Prevent open redirect
const safeRedirect = redirectTo.startsWith('/') && !redirectTo.startsWith('//')
? redirectTo
: '/dashboard';
// Exchange one-time code for HttpOnly cookies
fetch('/api/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: exchangeCode }),
credentials: 'include', // Required for cookie setting
})
.then((res) => {
if (res.ok) {
router.push(safeRedirect);
} else {
router.push('/auth/login?error=exchange_failed');
}
})
.catch(() => router.push('/auth/login?error=network'));
}, []);
return (
<div className="flex items-center justify-center min-h-screen">
<p>Signing you in...</p>
</div>
);
}
クライアント シークレットのハッシュ問題の修正
Authentik の一般的な問題: REST API 経由で設定されたクライアント シークレットにハッシュの不一致があり、OIDC トークン交換が失敗することがあります。修正するには、Django ORM を介してシークレットを直接再生成します。
# Run through the authentik container's shell
cat > /tmp/regen_secret.py << 'EOF'
from authentik.providers.oauth2.models import OAuth2Provider
import secrets
provider = OAuth2Provider.objects.get(name="ECOSIRE Web OAuth2")
new_secret = secrets.token_urlsafe(64)
provider.client_secret = new_secret
provider.save()
print(f"New secret: {new_secret}")
EOF
docker exec -i authentik-worker /lifecycle/ak shell < /tmp/regen_secret.py
次に、.env.local の AUTHENTIK_CLIENT_SECRET を更新します。
本番環境変数
# Authentik
AUTHENTIK_URL=https://auth.ecosire.com
AUTHENTIK_INTERNAL_URL=http://localhost:9000 # Server-to-server
AUTHENTIK_CLIENT_ID=ecosire-web
AUTHENTIK_CLIENT_SECRET=your-generated-secret
# App URLs
API_URL=https://api.ecosire.com/api
FRONTEND_URL=https://ecosire.com
# Cookie security
COOKIE_SECRET=random-32-char-string-for-signing
NODE_ENV=production
よくある質問
NextAuth.js ではなく Authentik を使用する理由
NextAuth.js は単純なアプリケーションに最適ですが、認証を Next.js アプリに結合します。 Authentik は、NestJS、モバイル アプリ、サードパーティ ツールなど、あらゆるフレームワークで動作するスタンドアロンの ID プロバイダーです。複数のアプリケーション間で SSO が必要な場合、SAML エンタープライズ ログインをサポートしたい場合、またはアプリとは別にユーザーとグループを管理するための UI が必要な場合は、Authentik がより良い選択肢です。
OIDC と OAuth2 の違いは何ですか?
OAuth2 は認証フレームワークであり、資格情報を共有せずにアクセスを許可する方法を定義します。 OIDC (OpenID Connect) は OAuth2 の上に構築され、認証を追加します。これは、ユーザーが誰であるかを確認する方法を指定します。 Authentik は両方をサポートしています。アプリケーションのログインには、OIDC (ユーザー クレームを含む ID トークンを提供します) が必要です。 OAuth2 のみは、「このアプリに Google ドライブへのアクセスを許可する」などのサードパーティ認証シナリオに使用します。
トークンの更新はどのように処理すればよいですか?
制限されたパス (/auth/refresh など) を使用して、リフレッシュ トークンを HttpOnly Cookie に保存します。アクセス トークンの有効期限が切れると、API は 401 を返し、フロントエンドは /auth/refresh を呼び出して、リフレッシュ トークンを使用して新しいアクセス トークンを取得します。更新エンドポイントは、Authentik と更新トークンを交換して新しいトークンを取得し、新しい Cookie を設定します。更新後の自動再試行により、API クライアントで 401 を処理します。
Authentik はエンタープライズ SAML プロバイダーを処理できますか?
はい — Authentik は、サービス プロバイダーとアイデンティティ プロバイダーの両方として SAML 2.0 をサポートします。 Okta、Azure AD、または Ping Identity を使用している企業顧客の場合、ユーザーが企業の資格情報を使用してログインできるように SAML フェデレーションを構成できます。 Authentik は SAML アサーションを OIDC トークンに変換するため、アプリケーション コードで SAML を直接処理する必要はありません。
認証フローをローカルでテストするにはどうすればよいですか?
アプリケーションと一緒にローカルで Docker Compose を使用して Authentik を実行します。 http://localhost:3000/auth/callback を含むようにリダイレクト URI を構成します。ローカル ユーザーで Authentik のテスト モードを使用します。交換コード フローの場合、60 秒の TTL はローカル開発には十分な量です。 OIDC フローをデバッグする必要がある場合、Authentik の管理パネルには、すべてのトークン交換試行がイベント ログに表示されます。
次のステップ
安全で本番環境に対応した認証システムの実装は、あらゆるアプリケーションにとって最も重要なエンジニアリング課題の 1 つです。 ECOSIRE は、HttpOnly Cookie ベースの認証、ワンタイム交換コード、NestJS と Next.js 間の完全な OIDC 統合を使用して、複数のアプリケーションに SSO を提供する Authentik を実稼働環境で実行します。
認証システム アーキテクチャ、Authentik 導入、または完全なエンタープライズ プラットフォームが必要な場合でも、当社の開発サービスをご覧ください して、当社がどのようにお手伝いできるかをご確認ください。
執筆者
ECOSIRE Research and Development Team
ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。
関連記事
JWT Authentication: Security Best Practices in 2026
Secure your APIs with JWT best practices: RS256 vs HS256, HttpOnly cookies, token rotation, refresh patterns, and common vulnerabilities to avoid in 2026.
最新のアプリケーションの API ゲートウェイ パターンとベスト プラクティス
スケーラブルな Web アーキテクチャ向けに、レート制限、認証、リクエスト ルーティング、サーキット ブレーカー、API バージョン管理などの API ゲートウェイ パターンを実装します。
ゼロトラスト アーキテクチャの実装: ビジネス向け実践ガイド
ID 検証、ネットワークのセグメンテーション、デバイスの信頼性、継続的な監視をカバーする実践的な手順を備えたゼロトラスト アーキテクチャを実装します。