メインコンテンツまでスキップ

Authorization Code Flow実装ガイド

このドキュメントの目的​

Authorization Code Flow(OAuth 2.0で最も一般的な認可フロー)の実装を理解することが目標です。

所要時間​

⏱️ 約45分

前提知識​


Authorization Code Flowとは​

3つのステップでアクセストークンを取得する認可フロー。

1. Authorization Request(認可リクエスト)
↓
2. User Authentication(ユーザー認証)
↓
3. Token Request(トークンリクエスト)

RFC 6749 Section 4.1準拠

なぜこのフローが必要なのか?​

セキュリティ上の理由:

  • ✅ クライアントシークレットをブラウザに露出しない
  • ✅ アクセストークンがURLに含まれない(ブラウザ履歴に残らない)
  • ✅ 認可コードは1回限り使用可能(リプレイ攻撃防止)

使用場面:

  • Webアプリケーション(サーバーサイドで動作)
  • SPAアプリケーション(PKCE併用)
  • モバイルアプリ(PKCE必須)

実装アーキテクチャ全体像​

30秒で理解する全体像​

HTTPリクエスト
↓
Controller (OAuthV1Api) - HTTP処理
↓
EntryService (OAuthFlowEntryService) - トランザクション管理
↓
Core層 (OAuthProtocol) - OAuth仕様準拠ロジック
↓
Repository - データ永続化

主要クラスの責務​

クラス役割主な処理
OAuthV1ApiHTTPエンドポイントパラメータ受け取り、レスポンス返却
OAuthFlowEntryServiceオーケストレーショントランザクション、イベント発行
OAuthProtocolOAuth仕様実装検証、AuthorizationRequest作成、Code発行
OAuthRequestHandlerリクエスト処理Validator、Verifier実行
OAuthAuthorizeHandlerCode発行処理認証確認、Authorization Code生成

主要ドメインオブジェクト​

オブジェクト説明保存場所有効期限
AuthorizationRequest認可リクエスト情報DB認証完了後削除
AuthenticationTransaction認証トランザクションDB認証完了後削除
AuthorizationCodeGrant認可コードDB10分(使用後即削除)
OAuthSessionセッション情報(SSO用)Redismax_age設定による

Core層の詳細アーキテクチャ​

Phase 1(Authorization Request)のCore層処理:

OAuthProtocol.request()
↓
┌─────────────────────────────────────────────────────┐
│ OAuthRequestHandler │
├─────────────────────────────────────────────────────┤
│ 1. OAuthRequestValidator(入力形式チェック) │
│ - client_id必須 │
│ - パラメータ重複禁止 │
│ │
│ 2. 設定取得 │
│ - AuthorizationServerConfiguration取得 │
│ - ClientConfiguration取得 │
│ │
│ 3. OAuthRequestContext作成 │
│ - パラメータ + 設定を統合 │
│ │
│ 4. OAuthRequestVerifier(ビジネスルール検証) │
│ ├─ Base Verifier(OAuth2/OIDC) │
│ │ - response_type検証 │
│ │ - redirect_uri検証 │
│ │ - scope検証 │
│ │ - nonce必須チェック(OIDC) │
│ │ │
│ └─ Extension Verifiers(Plugin) │
│ - RequestObjectVerifier(JWT署名検証) │
│ - AuthorizationDetailsVerifier │
│ - JarmVerifier │
│ │
│ 5. AuthorizationRequest生成 │
│ - AuthorizationRequestIdentifier(UUID) │
│ - response_type, client_id, scope等 │
│ │
│ 6. AuthorizationRequest保存(DB) │
│ - AuthorizationRequestRepository.register() │
└─────────────────────────────────────────────────────┘

実装: OAuthRequestHandler.java


Phase 3(Authorization Approve)のCore層処理:

OAuthProtocol.authorize()
↓
┌─────────────────────────────────────────────────────┐
│ OAuthAuthorizeHandler │
├─────────────────────────────────────────────────────┤
│ 1. OAuthAuthorizeRequestValidator │
│ - AuthorizationRequestIdentifier必須 │
│ - User存在確認 │
│ - Authentication存在確認 │
│ │
│ 2. AuthorizationRequest取得 │
│ - AuthorizationRequestRepository.get() │
│ │
│ 3. 設定取得 │
│ - AuthorizationServerConfiguration取得 │
│ - ClientConfiguration取得 │
│ │
│ 4. OAuthAuthorizeContext作成 │
│ - AuthorizationRequest + User + Authentication │
│ │
│ 5. AuthorizationResponseCreator選択(Plugin) │
│ - response_type="code" → CodeResponseCreator │
│ - response_type="token" → TokenResponseCreator │
│ │
│ 6. AuthorizationResponse生成 │
│ - Authorization Code生成(UUID) │
│ - redirect_uri + "?code=xxx&state=yyy" │
│ │
│ 7. AuthorizationCodeGrant保存 │
│ - code, expiresAt(10分) │
│ - authorizationGrant(user, scope等) │
│ - DB保存 │
│ │
│ 8. OAuthSession保存 │
│ - user, authentication │
│ - sessionKey: tenant-id:client-id │
│ - SSO用(次回の自動ログイン) │
└─────────────────────────────────────────────────────┘

実装: OAuthAuthorizeHandler.java


3つのフェーズの処理フロー​

Phase 1: Authorization Request(認可リクエスト)​

目的: ユーザーをログイン画面にリダイレクト

GET /{tenant-id}/v1/authorizations?response_type=code&client_id=xxx&redirect_uri=...
↓
OAuthFlowEntryService.request()
├─ Tenant取得
├─ OAuthProtocol.request() 呼び出し
│ ├─ パラメータ検証(Validator)
│ ├─ クライアント検証(Verifier)
│ ├─ AuthorizationRequest生成
│ └─ DB保存
│
└─ AuthenticationTransaction作成
└─ 認証ポリシー設定(MFA必須?等)

→ レスポンス: ログイン画面URL

保存されるデータ:

  • AuthorizationRequest: client_id, redirect_uri, scope等
  • AuthenticationTransaction: 認証状態管理(status: PENDING)

View Data API(認可画面データ取得)​

目的: 認可画面(SPA)がクライアント情報・スコープ情報等を取得し、UIを構築する

Phase 1完了後、ログイン画面にリダイレクトされたSPAが認可画面を描画するために呼び出します。

GET /{tenant-id}/v1/authorizations/{authReqId}/view-data
↓
OAuthFlowEntryService.getViewData()
├─ AuthorizationRequest取得
├─ ClientConfiguration取得
├─ OPSession取得(SSO判定用)
└─ OAuthViewDataCreator.create()
├─ クライアント情報抽出(client_id, client_name, logo_uri等)
├─ スコープ一覧
├─ セッション有効判定
├─ 利用可能フェデレーション一覧
├─ カスタムパラメータ
├─ client_custom_properties(設定時のみ)
├─ login_hint(リクエストで指定時のみ)
└─ ui_locales(リクエストで指定時のみ)

→ レスポンス: 認可画面描画用データ(JSON)

レスポンス例:

{
"client_id": "client123",
"client_name": "サンプルアプリケーション",
"client_uri": "https://client.example.com",
"logo_uri": "https://client.example.com/logo.png",
"contacts": ["support@client.example.com"],
"tos_uri": "https://client.example.com/terms",
"policy_uri": "https://client.example.com/privacy",
"scopes": ["openid", "profile", "email"],
"session_enabled": false,
"available_federations": [],
"custom_params": {},
"client_custom_properties": {
"app_label": "my-custom-app",
"feature_flags": { "dark_mode": true }
}
}

主要フィールド:

フィールド説明
client_id / client_nameクライアント識別情報
logo_uri / tos_uri / policy_uri認可画面に表示するクライアントメタデータ
scopes要求されているスコープ一覧
session_enabled既存セッションで認証スキップ可能か(SSO判定)
available_federations利用可能な外部IdP連携(Google、Azure AD等)
custom_params認可リクエストのカスタムパラメータ。リクエストオブジェクトの独自クレームも含み、クエリと重なるキーはリクエストオブジェクトの値を使う(FAPI Advanced はリクエストオブジェクトの値だけ)
client_custom_propertiesクライアントのextension.custom_propertiesに設定した任意データ(未設定時は含まれない)
login_hint認可リクエストのlogin_hint(未指定時は含まれない)
ui_locales認可リクエストのui_locales。優先順位順の配列(未指定時は含まれない)

session_enabledがtrueの場合、SPAは再認証をスキップして直接/authorizeを呼び出すことができます。

ui_localesは先頭から順に、表示可能な言語が見つかるまで走査してください。ui_locales_supportedによるフィルタは行わず、要求された値をそのまま返します(仕様上、サポート外のlocaleが来てもエラーにはしないため)。

ui_locales の値は検証していません

BCP47として妥当かも確認せず、クライアントの指定をそのまま返します。既知のlocale一覧と照合してから使用してください。値をパスやURLに直接埋め込む実装(fetch('/locales/' + tag + '.json')等)は避けてください。

なおui_localesはサインイン画面へのリダイレクトURLにも空白区切りで付与されるため、view-dataの往復を待たずに初回描画の言語を確定できます。

/signin/index.html?id=abc-123-def&tenant_id=18ffff8d-...&ui_locales=fr-CA+fr+en

実装: OAuthViewDataCreator.java


Phase 2: User Authentication(ユーザー認証)​

目的: ユーザー本人確認

POST /{tenant-id}/v1/authorizations/{authReqId}/password
{
"username": "user@example.com",
"password": "secret"
}
↓
OAuthFlowEntryService.interact()
├─ AuthorizationRequest取得
├─ AuthenticationTransaction取得
├─ AuthenticationInteractor選択
│ └─ PasswordAuthenticationInteractor等
│
├─ AuthenticationInteractor.interact()
│ ├─ パスワード検証
│ └─ AuthenticationResult生成
│
├─ AuthenticationTransaction更新
│ └─ status: AUTHENTICATED
│ └─ subject: userId
│
└─ OAuthSession更新
└─ 認証情報保存

→ レスポンス: 認証完了

詳細: 04-authentication.md

実装: OAuthFlowEntryService.java:164


Authentication Status API(認証状態確認)​

目的: SPAが認証フロー全体の状態(進行中/成功/失敗/ロック)を確認する

認証インタラクションAPI(/password等)はハンドラ固有のレスポンスしか返さないため、「フローのどの段階にいるか」を知るには本APIを使用します。読み取り専用で副作用はありません(DBは更新しません)。

GET /{tenant-id}/v1/authorizations/{authReqId}/authentication-status
↓
OAuthFlowEntryService.getAuthenticationStatus()
├─ AuthenticationTransaction取得
├─ validateAuthSession()(AUTH_SESSION cookie検証)
└─ AuthenticationTransaction.authenticationStatus() で判定
├─ isLocked() → "locked"
├─ isFailure() → "failure"
├─ isSuccess() → "success"
└─ それ以外 → "in_progress"

→ レスポンス: status + interaction_results + authentication_methods

レスポンス例(パスワード認証成功後):

{
"status": "success",
"interaction_results": {
"password-authentication": {
"operation_type": "AUTHENTICATION",
"method": "password",
"call_count": 1,
"success_count": 1,
"failure_count": 0,
"interaction_time": "2025-01-15T10:30:00"
}
},
"authentication_methods": ["password"]
}

認証前は status: "in_progress"、interaction_results は空オブジェクト {} です。

status値:

status意味
in_progress認証フロー進行中(未完了)
success認証成功(認証ポリシーの success_conditions 充足)
failure認証失敗(failure_conditions 充足)
lockedロック状態(lock_conditions 充足)

success / failure / locked の判定はいずれも認証ポリシーの条件設定に基づきます。認証不要なのはview-data APIと同様で、認可リクエストIDが推測困難なトークンとして機能し、加えてAUTH_SESSION cookieを検証します。

主要フィールド:

フィールド説明
status認証トランザクションの全体ステータス(上表)
interaction_results実行済み各インタラクションの結果(キー=インタラクションタイプ、未実行時は空)
authentication_methods成功した認証インタラクションの認証方式一覧(ID Tokenのamrの元データ)

実装:


Phase 3: Authorization Approve(認可承認)​

目的: Authorization Code発行とユーザー登録

POST /{tenant-id}/v1/authorizations/{authReqId}/authorize
↓
OAuthFlowEntryService.authorize()
├─ Tenant取得
├─ OAuthProtocol取得
├─ AuthorizationRequest取得
├─ AuthenticationTransaction取得
│ ├─ user取得(既にAuthenticationTransactionに含まれる)
│ ├─ deniedScopes取得
│ └─ isSuccess()判定 → authentication or null
│
├─ OAuthAuthorizeRequest作成
│
└─ OAuthProtocol.authorize() 呼び出し
└─ OAuthAuthorizeHandler.handle()
├─ Validation(AuthorizationRequestIdentifier・User・Authentication)
├─ AuthorizationRequest取得(DB)
├─ ClientConfiguration・AuthorizationServerConfiguration取得
├─ OAuthAuthorizeContext作成
├─ AuthorizationResponseCreator選択
│ ├─ response_type=code → CodeResponseCreator
│ ├─ response_type=token → TokenResponseCreator
│ └─ response_type=id_token token → HybridResponseCreator
│
├─ AuthorizationResponse生成
│ ├─ Authorization Code生成(UUID)
│ └─ redirect_uri + "?code=abc123&state=xyz"
│
├─ hasAuthorizationCode() → AuthorizationCodeGrant保存(DB)
│ - code: "abc123..."
│ - expiresAt: 現在時刻+10分
│ - authorizationRequestIdentifier
│ - authorizationGrant(user, scope, clientId等)
│
├─ hasAccessToken() → OAuthToken保存(DB)
│ ※ Implicit/Hybrid Flowの場合
│
└─ OAuthSession作成・登録(SSO用)
- user, authentication
- sessionKey: tenant-id:client-id

├─ (authorize成功の場合)
│ ├─ userRegistrator.registerOrUpdate() - ユーザー登録/更新
│ ├─ 招待完了イベント発行(invitation_idがあれば)
│ ├─ authenticationTransaction削除(DB)
│ └─ oauth_authorizeイベント発行
│
└─ (authorize失敗の場合)
└─ authorize_failureイベント発行

→ レスポンス: 302 Redirect

実装:

保存されるデータ:

  • AuthorizationCodeGrant: code, expiresAt, authorizationRequestIdentifier, authorizationGrant
    • 注意: usedフィールドは存在しない(使用後即削除する設計)

リクエストパラメータとバリデーション​

必須パラメータ​

パラメータ必須説明例
response_type✅レスポンスタイプ(code固定)code
client_id✅クライアントIDmy-web-app
redirect_uri条件付きリダイレクトURI(複数登録時は必須)https://app.example.com/callback
scope✅アクセススコープopenid profile email
state推奨CSRF対策用ランダム値random-xyz-123
nonce推奨リプレイ攻撃対策(OIDC)nonce-abc-456

RFC 6749 Section 4.1.1準拠


バリデーションアーキテクチャ​

実装: OAuthRequestHandler.java:115-139

OAuthRequest(パラメータ)
↓
┌──────────────────────────────────────┐
│ OAuthRequestValidator │ ステップ1: 入力形式チェック
│ - client_id必須チェック │
│ - パラメータ重複禁止 │
└──────────────────────────────────────┘
↓ OK
設定取得
├─ AuthorizationServerConfiguration (サーバー設定)
└─ ClientConfiguration (クライアント設定)
↓
OAuthRequestContext作成
↓
┌──────────────────────────────────────┐
│ OAuthRequestVerifier │ ステップ2: ビジネスルール検証
│ ├─ Base Verifier選択 │
│ │ ├─ OAuth2RequestVerifier │ ← OAuth 2.0用
│ │ └─ OidcRequestVerifier │ ← OIDC用
│ │ │
│ └─ Extension Verifiers(順次実行) │
│ ├─ RequestObjectVerifier │ ← Request Object (JWT)
│ ├─ AuthorizationDetailsVerifier │ ← Authorization Details
│ └─ JarmVerifier │ ← JARM
└──────────────────────────────────────┘
↓ OK
AuthorizationRequest保存

Validator: 入力形式チェック​

実装: OAuthRequestValidator.java

検証内容:

  1. client_id必須チェック
  2. パラメータ重複禁止(RFC 6749 Section 3.1)
// client_id必須
if (!oAuthRequestParameters.hasClientId()) {
throw new OAuthBadRequestException(
"invalid_request",
"authorization request must contains client_id"
);
}

// 同じパラメータが複数回指定されていないか
List<String> duplicateKeys = oAuthRequestParameters.multiValueKeys();
if (!duplicateKeys.isEmpty()) {
throw new OAuthBadRequestException(
"invalid_request",
"authorization request must not contains duplicate value"
);
}

例外: OAuthBadRequestException → エラーページ表示(リダイレクトしない)


Verifier: プラグインによる段階的検証​

実装: OAuthRequestVerifier.java

プラグインアーキテクチャ:

OAuthRequestVerifier.verify(context)
↓
┌─────────────────────────────────────────────┐
│ 1. Base Verifier選択(プロファイル別) │
├─────────────────────────────────────────────┤
│ AuthorizationProfile.OAUTH2 │
│ → OAuth2RequestVerifier │
│ ├─ redirect_uri検証 │
│ └─ OAuthRequestBaseVerifier │
│ ├─ response_type検証 │
│ ├─ サーバー対応チェック │
│ ├─ クライアント対応チェック │
│ └─ scope検証 │
│ │
│ AuthorizationProfile.OIDC │
│ → OidcRequestVerifier │
│ ├─ nonce必須チェック │
│ └─ OidcRequestBaseVerifier │
│ └─ OIDC固有の検証 │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ 2. Extension Verifiers(該当時のみ実行) │
├─────────────────────────────────────────────┤
│ RequestObjectVerifier │
│ 条件: request または request_uri 存在 │
│ 検証: JWT署名検証、パラメータ整合性 │
│ │
│ AuthorizationDetailsVerifier │
│ 条件: authorization_details パラメータ存在 │
│ 検証: JSON形式、必須フィールド │
│ │
│ JarmVerifier │
│ 条件: response_mode=jwt │
│ 検証: JARM設定の妥当性 │
└─────────────────────────────────────────────┘

プラグインローダー:

  • AuthorizationRequestVerifierPluginLoader.load() - Base Verifier拡張
  • AuthorizationRequestExtensionVerifierPluginLoader.load() - Extension Verifier拡張

OAuth2RequestVerifier の検証詳細​

実装: OAuth2RequestVerifier.java

OAuth2RequestVerifier.verify(context)
↓
┌────────────────────────────────────────────┐
│ redirect_uri検証(RFC 6749 Section 3.1.2) │
├────────────────────────────────────────────┤
│ redirect_uri指定あり? │
│ YES │
│ ├─ フラグメント(#)含む? │
│ │ YES → エラー │
│ │ │
│ └─ 登録URIと完全一致? │
│ NO → エラー │
│ │
│ NO │
│ └─ 登録URIが複数? │
│ YES → エラー(redirect_uri必須) │
└────────────────────────────────────────────┘
↓
┌────────────────────────────────────────────┐
│ OAuthRequestBaseVerifier(共通検証) │
├────────────────────────────────────────────┤
│ 1. response_type必須? │
│ 2. response_typeが既知の値? │
│ 3. サーバーがサポート? │
│ 4. クライアントが使用可能? │
│ 5. scopeが有効? │
└────────────────────────────────────────────┘

検証ロジックのポイント:

  • redirect_uri検証が最優先(RFC 6749 Section 3.1.2.4準拠)
  • redirect_uri不正 → OAuthBadRequestException → リダイレクトしない
  • その他のエラー → OAuthRedirectableBadRequestException → エラーをリダイレクト

Validator vs Verifier の違い(実装ベース)​

観点OAuthRequestValidatorOAuthRequestVerifier
実行タイミングリクエスト受信直後Context作成後
必要な情報リクエストパラメータのみTenant設定・クライアント設定
検証内容形式チェック(client_id存在、重複なし)ビジネスルール(redirect_uri一致、scope有効性)
例外型OAuthBadRequestExceptionOAuthRedirectableBadRequestException
エラー処理エラーページ表示リダイレクトURIの検討でエラーの場合はエラーページ
リダイレクトURIの検証後はリダイレクトにエラーを返却

RFC 6749 Section 3.1.2.4の実装:

redirect_uriが無効な場合は、リダイレクトしてはいけない(セキュリティ理由)


SSO(シングルサインオン)可能タイミング​

SSO(prompt=none)が利用可能になるタイミング​

重要: idp-serverでは、SSOは認可承認時点(Phase 3完了時)から利用可能になります。

Phase 1: Authorization Request
↓
Phase 2: User Authentication
↓
Phase 3: Authorization Approve ← この時点でSSO可能になる
↓
Phase 4: Token Request(SSOにはこのステップは不要)

技術的な仕組み​

AuthorizationGranted登録タイミング:

  • AuthorizationGranted(認可同意記録)は認可承認時(OAuthAuthorizeHandler)に登録される
  • これにより、Token Request前でもprompt=noneによるSSO認可が可能
認可承認完了
↓
AuthorizationGranted登録(DB)← SSO可能に
↓
Token Request(省略可能)
↓
別のクライアントから prompt=none でSSO認可可能

SSO利用条件​

prompt=noneでSSO認可が成功するための条件:

  1. ✅ 同一ユーザーで過去に認可完了済み(AuthorizationGranted存在)
  2. ✅ 有効なOAuthSession存在(セッション期限内)
  3. ✅ 要求スコープが既認可スコープに含まれる

他IdPとの比較​

IdPSSO可能タイミング
idp-server認可承認時(Token Request前)
Keycloak認可承認時(Token Request前)
Auth0認可承認時(Token Request前)

設計理由: Token Request前にSSOを可能にすることで、ユーザー体験を向上させる。ユーザーが認可を完了した時点で、別のクライアントからもスムーズにログインできる。

実装: OAuthAuthorizeHandler.java


別サイト認可画面モード(cross_site)​

認可画面が idp-server と別サイトにあると、認可画面からの XHR はサードパーティになり、Safari は Cookie を送りも保存もしません。このモードでは、Cookie に頼っていた「ブラウザの識別」と「OP セッションの保存」を置き換えます。仕組みと設定は 認可画面を別サイトに置く を参照してください。ここでは実装の読み方を説明します。

モードの判定​

OAuthFlowEntryService#bindingFor が、認可リクエスト(または認証トランザクション)のクライアントで判定し、CrossSiteAuthorizationBinding を作ります。判定は 1 リクエストにつき 1 回で、以降の判断はすべてこのオブジェクトに聞きます。判定そのものは ClientConfiguration#crossSiteAuthorizationView(UIConfiguration) にあり、クライアントの extension.cross_site_authorization_view があればそれ、無ければテナントの ui_config.cross_site です。

状態の置き場所​

auth_proof と、紐づけた OP セッションは、認証トランザクションの attributes(DB)に置きます。キャッシュには置きません。キャッシュは落ちても動く前提のものなので、ログインに必須の状態を置くと、Redis が止まったときにログインできなくなるためです。

attributes のキー中身書く場所消える場所
auth_proof① のハッシュと sub認証ステップ、フェデレーションのコールバックauthorize で ② に置き換え
completion_proof② のハッシュ、sub、遷移先(code 付き。AesCipher で暗号化)authorize、authorize-with-session/complete でトランザクションごと削除
op_session_id紐づけた OP セッションの ID認可リクエスト、認証成功時/complete でトランザクションごと削除
view_binding認可画面に渡した値のハッシュ認可リクエスト(訪れるたびに作り直す)/complete でトランザクションごと削除

値そのものは保存しません。AuthenticationProof が値を生成して SHA-256 のハッシュだけを storeOn で書き、照合(authorizes / completes)は固定時間の比較です。各段は最新の 1 つだけが有効です。

authorize と /complete はトランザクションを getForUpdate(行ロック)で読むため、同じ proof を同時に使っても通るのは 1 つだけです。completion_proof が既にあるトランザクションへの authorize / authorize-with-session は拒否します(CrossSiteAuthorizationBinding#handedOffForCompletion)。proof を求めないフローでは、リクエスト ID を知る第三者が呼び直して ② を差し替え、正規のブラウザを締め出せるためです。トランザクションの UPDATE は attributes も書きます(PostgreSQL / MySQL の両方)。

処理の流れ​

束縛の判断は core の CrossSiteAuthorizationBinding にまとめてあります。値についての判断だけを持ち、トランザクション・Cookie・セッションの読み書きは呼び出し側(OAuthFlowEntryService)が行います。そのため、判断のすべての分岐を単体テストで確かめられます。

段階CrossSiteAuthorizationBinding のメソッドやること
認可リクエストbindSession / issueViewBindingブラウザに OP セッションの Cookie があれば、トランザクションの op_session_id に入れる(トップレベル遷移なので読める)。認可画面に渡す値(view_binding)を作り、ハッシュをトランザクションに、値を認可画面の URL の fragment に入れる
認証ステップproofForStep / proofForFederation / carryブラウザから来て、本人しか通せない種類(OperationType#provesPossession)のステップが成功したら ① を発行。認証が成功して作った OP セッションも紐づける
authorizegateAuthorize / handOff進めてよいかを判定する(② を渡し済み・① が無い・同一サイトなので Cookie を照合・進める)。進めたら ① を消費して ② を発行し、応答に載せる。code は ② の中に入れ、応答のボディに出さない
authorize-with-sessiongateAuthorizeWithSession / handOff認可リクエストで紐づけた OP セッションで続ける。この認可でサインインが成功していたら拒否する(下記)
/completecheckCompletionIDP_AUTH_SESSION を照合し、② を照合し、② の中の遷移先をリクエストの redirect_uri と照合する(この順)。OP セッションの Cookie を書き、② の遷移先へ 302。失敗時は RP に戻さず、テナントのエラー画面へ 302(OAuthCompleteResponse.errorPage)

/complete の照合は、IDP_AUTH_SESSION を先に見ます。先に ② を照合して消費すると、Cookie を持たない呼び出しが ② を使い切り、正規のブラウザを締め出せるためです(② はトランザクションの削除で消えます)。

checkCompletion は、トランザクションに authSessionId が無ければ拒否します。proof を求めないフロー(デバイスだけの認証、既存の OP セッションでの続行)では、これが唯一のブラウザの束縛なので、欠けを「照合するものが無い」とは扱いません。認証ポリシーが auth_session_binding_required: false なら照合は行いませんが、警告をログに出します。

認可画面からの呼び出しの照合​

同一サイト構成では、認可画面からの呼び出しを IDP_AUTH_SESSION で照合します(OAuthFlowEntryService#validateViewCall)。別サイト構成ではこの Cookie が届かないので、認可リクエストで渡した view_binding を x-view-binding ヘッダーで受け取り、CrossSiteAuthorizationBinding#verifyViewCall でハッシュと照合します(同一サイト構成の Cookie の照合もここにまとめています)。対象は view-data、authentication-status、認証ステップ(ブラウザから呼ぶもの)、フェデレーション、authorize、authorize-with-session、deny です。値が無い・違う・トランザクションに値が無い場合は 401 です。

認可リクエスト ID は束縛の根拠にしません。PAR では request_uri から ID が分かるためです。view_binding は認可リクエストを訪れるたびに作り直すので、有効なのは最後に IDP_AUTH_SESSION を受け取ったブラウザの値だけです。

proof を求めない場合​

ブラウザでの入力が一度も無いフロー(login_hint + デバイスのプッシュ承認など)では ① が発行されません。authorize は AuthenticationTransaction#browserProvedPossession(AuthenticationInteractors) で、成功したステップにブラウザ由来で本人しか通せない種類のものがあったかを判断し、無ければ ① を求めません。このとき始めたブラウザには、authorize の view_binding と /complete の IDP_AUTH_SESSION で束縛します。

判断は DB のトランザクションから読みます。インタラクターの無い種類(フェデレーション)はブラウザのステップとして数え、分からないものは ① を求める側に倒します。

サインインのあとの authorize-with-session​

op_session_id には、認可リクエストで紐づけたセッション(始めたブラウザの Cookie から読んだもの)と、サインインの成功で作ったセッション(carry)の両方が入ります。後者は、サインインした人を示しますが、呼び出し元がそのブラウザだとは言えません。そのため gateAuthorizeWithSession は、この認可でサインインが成功していれば(isSuccess、または本人しか通せない種類のステップの成功)authorize-with-session を拒否し、① を求める authorize だけが code を出すようにしています。正規の SSO(サインインのステップが無い)は影響を受けません。

OP セッションの引き当て​

OAuthFlowEntryService#authenticatingSession は、同一サイト構成では Cookie から、別サイト認可画面モードでは CrossSiteAuthorizationBinding#boundSessionId(トランザクションの op_session_id)から OP セッションを引きます。view-data(session_enabled)、authorize(sid の発行)、authorize-with-session、/complete(セッションの再利用)がこれを使います。

認可画面側(app-view)​

ファイル役割
app-view/src/auth/viewBinding.tsURL の fragment から view_binding を取り出して sessionStorage に置き、アドレスバーから消す
app-view/src/auth/authProof.tsfetch をラップし、idp-server の呼び出しに x-view-binding を付ける。応答の auth_proof を認可リクエスト ID ごとに sessionStorage へ保持し、authorize のボディに載せる
app-view/src/auth/completion.tsauth_proof があれば /complete へ、無ければ redirect_uri へ遷移する
app-view/src/auth/useSessionAuthorize.tsauthorize-with-session の応答も completion.ts に渡す

テスト​

テスト確かめていること
e2e/src/tests/scenario/application/scenario-17-cross-site-auth-proof.test.js発行条件、使い捨て、段の取り違え、別のユーザー・別のリクエスト、/complete の二重照合、デバイスで終わる / デバイスだけのフロー、SSO
CrossSiteAuthorizationBindingTestproof の発行条件、authorize と authorize-with-session の関門、② の受け渡し、/complete の照合(束縛の欠け・opt-out・別のブラウザ・段の取り違え・遷移先の不一致)
AuthenticationProofTest / AuthenticationTransactionBrowserPossessionTestproof の保存と照合、proof を求めるかの判断
e2e/src/tests/browser/safari_cross_site_demo.test.jsSafari 実機での一周と SSO(SAFARI_E2E=1 のときだけ動く)

データのライフサイクル​

Authorization Code の一生​

1. 発行 (authorize時)
└─ DB保存、expiresAt=現在時刻+10分(デフォルト)
- 設定: AuthorizationServerConfiguration.authorizationCodeValidDuration()
- デフォルト値: 600秒(10分)

2. 使用 (Token Request時)
├─ 存在確認 ✅
├─ 有効期限チェック ✅
├─ クライアント一致チェック ✅
├─ redirect_uri一致チェック ✅
└─ Access Token発行

3. 削除
└─ Token Request成功後に即座に削除(使用後即削除)

⚠️ 重要:
- 1回限り使用可能(使用後即削除)
- usedフィールドは存在しない(削除による実装)
- 10分以内に使用しないと期限切れ→invalid_grant

実装: AuthorizationServerExtensionConfiguration.java - authorizationCodeValidDuration = 600(秒)

AuthorizationRequest の一生​

1. 作成 (request時)
└─ PostgreSQL/MySQLの`authorization_request`テーブルに保存
- expires_at: 現在時刻 + 30分(デフォルト、設定変更可能)
- 設定: AuthorizationServerConfiguration.oauthAuthorizationRequestExpiresIn()

2. 認証中
└─ AuthenticationTransactionで状態管理
- status: PENDING → AUTHENTICATED

3. Token Request
└─ Authorization Code交換時に即座に削除
- AuthorizationCodeGrantRepository.delete()
- AuthorizationRequestRepository.delete()
- 実装: AuthorizationCodeGrantService.java:200-201

4. 期限切れデータのクリーンアップ
└─ 手動クリーンアップAPIで削除
POST /v1/admin/operations/cleanup
{"max_deletion_number": 10000}

⚠️ 注意: 自動削除機能は未実装
期限切れデータは手動でクリーンアップする必要あり

実装:


実際に動かしてみる​

前提条件​

  1. テナント作成済み
TENANT_ID="18ffff8d-8d97-460f-a71b-33f2e8afd41e"
  1. クライアント登録済み
CLIENT_ID="my-web-app"
CLIENT_SECRET="secret-12345"
REDIRECT_URI="http://localhost:3000/callback"
  1. ユーザー登録済み
USERNAME="test@example.com"
PASSWORD="password123"

Step-by-Step実行​

1. Authorization Request(ブラウザで実行)​

http://localhost:8080/${TENANT_ID}/v1/authorizations?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=openid%20profile%20email&state=random-state-xyz

期待される動作:

  • ログイン画面にリダイレクトされる
  • URLにauthorization_request_idが含まれる

実際のURL例:

http://localhost:8080/signin/index.html?id=abc-123-def&tenant_id=18ffff8d-8d97-460f-a71b-33f2e8afd41e

2. User Authentication(curlで実行)​

AUTH_REQUEST_ID="abc-123-def"  # 上記URLから取得

curl -X POST "http://localhost:8080/${TENANT_ID}/v1/authorizations/${AUTH_REQUEST_ID}/password" \
-H "Content-Type: application/json" \
-d '{
"username": "test@example.com",
"password": "password123"
}'

成功レスポンス:

{
"status": "authenticated",
"next_step": "authorize"
}

3. Authorization Approve(curlで実行)​

curl -X POST "http://localhost:8080/${TENANT_ID}/v1/authorizations/${AUTH_REQUEST_ID}/authorize" \
-H "Content-Type: application/json" \
-d '{}' \
-i # ヘッダーも表示

成功レスポンス:

HTTP/1.1 302 Found
Location: http://localhost:3000/callback?code=eyJhbGc...&state=random-state-xyz

4. Authorization Code抽出​

# Locationヘッダーから codeパラメータを抽出
CODE="eyJhbGc..."

5. Token Request​

curl -X POST "http://localhost:8080/${TENANT_ID}/v1/tokens" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&code=${CODE}&client_id=${CLIENT_ID}&client_secret=${CLIENT_SECRET}&redirect_uri=${REDIRECT_URI}"

成功レスポンス:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"scope": "openid profile email"
}

デバッグのヒント​

AuthorizationRequestの確認:

# データベースで直接確認(開発環境のみ)
docker exec -it postgres psql -U idp_user -d idp_db -c \
"SELECT id, client_id, redirect_uri, scope, created_at FROM authorization_request WHERE tenant_id='${TENANT_ID}' ORDER BY created_at DESC LIMIT 5;"

ログ確認:

# idp-serverのログを監視
docker logs -f idp-server | grep -E "OAuthFlowEntryService|AuthorizationRequest"

PAR(Pushed Authorization Request)​

RFC 9126準拠の拡張機能 - セキュリティ強化版Authorization Request

通常のAuthorization Requestとの違い​

項目通常のGETPAR(POST)
送信方法URLパラメータPOSTボディ
エンドポイントGET /v1/authorizationsPOST /v1/authorizations/push
セキュリティパラメータがURL露出バックチャネルで安全
サイズ制限URLサイズ制限あり制限なし
FAPI要件-FAPI 2.0で必須

PAR使用フロー​

Step 1: PAR実行
POST /{tenant-id}/v1/authorizations/push
{
"response_type": "code",
"client_id": "xxx",
"redirect_uri": "https://...",
"scope": "openid profile"
}
↓
レスポンス:
{
"request_uri": "urn:ietf:params:oauth:request_uri:abc123",
"expires_in": 90
}

Step 2: request_uriで認可リクエスト
GET /{tenant-id}/v1/authorizations?client_id=xxx&request_uri=urn:ietf:params:oauth:request_uri:abc123
↓
(通常のフローと同じ)

E2Eテスト例​

参考ファイル: e2e/src/tests/scenario/application/scenario-02-sso-oidc.test.js

以下は教育用の簡略化した例です。実際のテストコードはより複雑な設定を含みます。

const { v4: uuidv4 } = require('uuid');
const axios = require('axios');

describe('Authorization Code Flow', () => {
let tenantId = '18ffff8d-8d97-460f-a71b-33f2e8afd41e';
let clientId = 'test-client';
let redirectUri = 'https://client.example.com/callback';

test('should complete authorization code flow', async () => {
// 1. Authorization Request
const authResponse = await axios.get(
`http://localhost:8080/${tenantId}/v1/authorizations`,
{
params: {
response_type: 'code',
client_id: clientId,
redirect_uri: redirectUri,
scope: 'openid profile email',
state: 'random-state'
},
maxRedirects: 0,
validateStatus: (status) => status === 302
}
);

// ログイン画面URLにリダイレクト
expect(authResponse.status).toBe(302);
expect(authResponse.headers.location).toContain('/signin/index.html');

// authorization_request_id抽出
const authReqId = new URL(authResponse.headers.location, 'http://localhost').searchParams.get('id');

// 2. User Authentication(別テストで実施)
await axios.post(
`http://localhost:8080/${tenantId}/v1/authentications/${authReqId}`,
{
type: 'password',
username: 'user@example.com',
password: 'password123'
}
);

// 3. Authorization Approve
const approveResponse = await axios.post(
`http://localhost:8080/${tenantId}/v1/authorizations/${authReqId}/approve`,
{},
{
maxRedirects: 0,
validateStatus: (status) => status === 302
}
);

// クライアントにリダイレクト(Authorization Code付き)
expect(approveResponse.status).toBe(302);
const redirectUrl = new URL(approveResponse.headers.location);
expect(redirectUrl.origin + redirectUrl.pathname).toBe(redirectUri);

const code = redirectUrl.searchParams.get('code');
expect(code).toBeTruthy();

// 4. Token Request(次のガイド参照)
const tokenResponse = await axios.post(
`http://localhost:8080/${tenantId}/v1/tokens`,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: clientId,
client_secret: 'client-secret',
redirect_uri: redirectUri
}),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);

expect(tokenResponse.status).toBe(200);
expect(tokenResponse.data).toHaveProperty('access_token');
expect(tokenResponse.data).toHaveProperty('id_token');
expect(tokenResponse.data).toHaveProperty('token_type', 'Bearer');
});
});

よくあるエラーと対処法​

エラー1: invalid_request - redirect_uri不一致​

実際のエラー:

GET /{tenant-id}/v1/authorizations?response_type=code&client_id=my-app&redirect_uri=https://wrong-domain.com/callback
↓
302 Redirect to エラー画面
または
https://registered-uri.com/callback?error=invalid_request&error_description=redirect_uri+does+not+match+registered+URIs

原因: クライアント登録時のredirect_urisと一致しない

解決策:

# 1. 登録済みredirect_uriを確認
curl -X GET "http://localhost:8080/v1/management/tenants/${TENANT_ID}/clients/${CLIENT_ID}" \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
| jq '.redirect_uris'

# 出力例:
# ["https://app.example.com/callback", "http://localhost:3000/callback"]

# 2. 完全一致するURIを使用(パス、ポート、プロトコルすべて一致必須)
redirect_uri=https://app.example.com/callback # ✅
redirect_uri=https://app.example.com/callback/ # ❌ 末尾スラッシュ違い
redirect_uri=http://app.example.com/callback # ❌ httpとhttpsの違い

エラー2: unsupported_response_type - 未サポートのresponse_type​

実際のエラー:

{
"error": "unsupported_response_type",
"error_description": "authorization server is unsupported response_type (token)"
}

原因:

  • response_type=token(Implicit Flow)はセキュリティ理由で無効化されている
  • または、クライアントタイプが許可していない

解決策:

# Authorization Code Flowを使用
response_type=code # ✅ 推奨

# サーバー設定でサポートされているresponse_typeを確認
curl -X GET "http://localhost:8080/${TENANT_ID}/.well-known/openid-configuration" \
| jq '.response_types_supported'

# 出力例:
# ["code", "code id_token", "code token", "code id_token token"]

エラー3: invalid_scope - 無効なscope​

実際のエラー:

302 Redirect to:
https://app.example.com/callback?error=invalid_scope&error_description=authorization+request+does+not+contains+valid+scope

原因:

  • scopeパラメータが空
  • サポートされていないscopeを指定

解決策:

# 1. 最低限必須のscope
scope=openid # ✅ OIDCの場合は必須

# 2. 追加情報が必要な場合
scope=openid profile email # ✅ スペース区切り

# 3. カスタムscopeの確認(テナント設定依存)
curl -X GET "http://localhost:8080/${TENANT_ID}/.well-known/openid-configuration" \
| jq '.scopes_supported'

エラー4: 認証未完了で/authorize実行​

実際のエラー:

{
"error": "authentication_required",
"error_description": "User authentication is not completed"
}

原因: Phase 2(認証)をスキップしてPhase 3(Code発行)を実行

正しい順序:

# ❌ 間違い
curl -X POST "http://localhost:8080/${TENANT_ID}/v1/authorizations/${AUTH_REQUEST_ID}/authorize"
# → エラー: authentication_required

# ✅ 正しい
# Step 1: 認証実行
curl -X POST "http://localhost:8080/${TENANT_ID}/v1/authorizations/${AUTH_REQUEST_ID}/password" \
-d '{"username": "user@example.com", "password": "secret"}'

# Step 2: 認証完了後にauthorize実行
curl -X POST "http://localhost:8080/${TENANT_ID}/v1/authorizations/${AUTH_REQUEST_ID}/authorize"

エラー5: Authorization Code期限切れ​

実際のエラー(Token Request時):

{
"error": "invalid_grant",
"error_description": "authorization code is expired"
}

原因: Authorization Code発行から10分以上経過

解決策:

# Authorization Code発行後、すぐにToken Request実行
# 有効期限: 10分(デフォルト)

# タイムアウトした場合は、最初からやり直し
# → 再度Authorization Requestから実行

エラー6: Authorization Code再利用​

実際のエラー:

{
"error": "invalid_grant",
"error_description": "not found authorization code"
}

原因: 同じAuthorization Codeで2回Token Request実行

動作:

1回目のToken Request: ✅ 成功(認可コード削除)
2回目のToken Request: ❌ エラー(invalid_grant)

対処:

# Authorization Codeは1回限り使用可能
# 再度必要な場合は、最初からAuthorization Flowを実行

次のステップ​

✅ Authorization Code Flowの実装を理解した!

📖 次に読むべきドキュメント​

  1. 03. Token Flow実装 - トークン発行・検証
  2. 04. Authentication実装 - ユーザー認証

🔗 詳細情報​


情報源: OAuthFlowEntryService.java 最終更新: 2025-10-12