Authentik OIDC/SSO: Complete Integration Guide

Complete Authentik OIDC and SSO integration guide: OAuth2 provider setup, Next.js callback handling, NestJS JWT validation, user provisioning, and production configuration.

E
ECOSIRE Research and Development Team
|2026年3月19日6 分で読める1.4k 語数|

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/):

  1. [アプリケーション] > [プロバイダ] > [作成] に移動します。
  2. OAuth2/OpenID プロバイダー を選択します
  3. 以下を設定します。
  • 名前: 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
    
  • 署名キー: デフォルトの証明書を選択します
  • スコープ: openidemailprofile
  1. このプロバイダーを使用する アプリケーション を作成します

カスタム プロパティのマッピング

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.localAUTHENTIK_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 導入、または完全なエンタープライズ プラットフォームが必要な場合でも、当社の開発サービスをご覧ください して、当社がどのようにお手伝いできるかをご確認ください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット