自社マルチプロダクトで OIDC を使う — 具体的なフローとトークンの中身

自社で複数プロダクトを運営する際に、独自 OIDC Provider を立てて SSO を実現する具体的な方法。Authorization Code Flow の各ステップ、トークンの中身、クライアント登録、セッション管理まで実務レベルで整理。

はじめに

自社で複数のプロダクト(業務システム、CMS、管理ツール等)を運営していると、「1 つのアカウントで全プロダクトにログインしたい」という要件が出てきます。

これを実現するのが **自社 OIDC Provider による SSO(Single Sign-On)**です。Google や GitHub が外部サービスへのログインを提供しているのと同じ仕組みを、自社内に構築します。

この記事では、抽象的な説明ではなく 具体的なHTTPリクエスト、トークンの中身、実装時の判断ポイント を整理します。

全体像

プロダクト A (cases.example.com)    ─┐
プロダクト B (docs.example.com)     ─┼─ OIDC ──→ 自社 OIDC Provider
プロダクト C (admin.example.com)    ─┘          (auth.example.com)


                                                ユーザーストア
                                                (Cognito / DB)

各プロダクトは OIDC Provider を「外部の認証サービス」として扱います。プロダクト側は OIDC のクライアントライブラリを使うだけで、ユーザー管理の詳細を知る必要がありません。

Authorization Code Flow(具体的な HTTP)

OIDC で最も一般的な Authorization Code Flow を、実際の HTTP リクエストで追います。

ステップ 1: プロダクトが認可リクエストを送る

ユーザーが「ログイン」ボタンを押すと、プロダクトは OIDC Provider にリダイレクトします。

GET https://auth.example.com/oidc/authorize?
  response_type=code
  &client_id=cases-app
  &redirect_uri=https://cases.example.com/callback
  &scope=openid email profile
  &state=random-csrf-token
  &nonce=random-replay-prevention
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256
パラメータ意味
response_type=codeAuthorization Code を返してほしい
client_idプロダクトの識別子(事前登録)
redirect_uri認証後の戻り先(事前登録と一致必須)
scope要求する情報の範囲
stateCSRF 防止用のランダム値
nonceID Token のリプレイ攻撃防止
code_challengePKCE(認可コード横取り防止)

ステップ 2: OIDC Provider がログイン画面を表示

ユーザーがまだ OIDC Provider にログインしていなければ、ログイン画面を表示します。

┌─────────────────────────────────┐
│  auth.example.com               │
│                                 │
│  メールアドレス: [            ]  │
│  パスワード:     [            ]  │
│                                 │
│  [Google でログイン]             │
│                                 │
│         [ログイン]               │
└─────────────────────────────────┘

既にログイン済み(セッションが有効)なら、この画面はスキップされます。これが SSO の体験です。

ステップ 3: 認可コードを返す

認証成功後、OIDC Provider はプロダクトの redirect_uri に認可コードを返します。

HTTP/1.1 302 Found
Location: https://cases.example.com/callback?
  code=SplxlOBeZQQYbYS6WxSbIA
  &state=random-csrf-token

認可コードは短命(通常 5 分)で、1 回しか使えません。

ステップ 4: プロダクトが認可コードをトークンに交換

プロダクトのバックエンドが、認可コードを OIDC Provider のトークンエンドポイントに送信します。

POST https://auth.example.com/oidc/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://cases.example.com/callback
&client_id=cases-app
&client_secret=cases-app-secret
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

ステップ 5: トークンが返る

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "id_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

トークンの中身

ID Token

「このユーザーは誰か」 を証明するトークン。OIDC の核心。

{
  "iss": "https://auth.example.com",
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "aud": "cases-app",
  "exp": 1712800000,
  "iat": 1712796400,
  "auth_time": 1712796400,
  "nonce": "random-replay-prevention",
  "email": "taishi@example.com",
  "email_verified": true,
  "groups": ["admin", "lawyer"],
  "amr": ["pwd"]
}
クレーム意味誰が使う
iss発行者(OIDC Provider の URL)プロダクトが検証に使う
subユーザーの一意 IDプロダクトがユーザーを識別
aud対象のクライアント ID自分宛のトークンか検証
exp / iat有効期限 / 発行時刻トークンの鮮度確認
auth_time実際に認証した時刻ステップアップ認証の判断
nonceリプレイ防止リクエスト時の値と一致確認
emailメールアドレス表示・通知
groupsロール / グループ粗い認可判定
amr認証手段認証強度の確認

Access Token

「このユーザーは何にアクセスできるか」 を表すトークン。UserInfo エンドポイントや API 呼び出しに使います。

{
  "iss": "https://auth.example.com",
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "aud": "cases-app",
  "exp": 1712800000,
  "iat": 1712796400,
  "scope": ["openid", "email", "profile"],
  "token_use": "access"
}

ID Token との違い:

ID TokenAccess Token
目的ユーザーの身元証明リソースへのアクセス許可
送信先プロダクト(クライアント)API / UserInfo エンドポイント
フォーマットJWT 必須(OIDC 仕様)任意(JWT でなくてもいい)
含む情報ユーザー属性スコープ

Refresh Token(任意)

Access Token の有効期限が切れたときに、新しい Access Token を取得するためのトークン。

POST https://auth.example.com/oidc/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=cases-app
&client_secret=cases-app-secret

Refresh Token を発行するかは OIDC Provider の設計判断です。セキュリティとのトレードオフ:

Refresh Token ありなし
UXセッションが長持ちする1 時間ごとに再ログイン
リスクトークン漏洩時の影響が長期化被害が限定的
推奨モバイルアプリ、長時間作業セキュリティ重視の管理ツール

クライアント登録

各プロダクトは OIDC Provider に「クライアント」として事前登録が必要です。

クライアントの種類

種類秘密鍵を安全に保持できるか
Confidentialはい(バックエンドがある)SSR の Web アプリ、API サーバー
Publicいいえ(コードが露出する)SPA、モバイルアプリ

Public クライアントは client_secret を持てないので、PKCE 必須です。

登録情報

{
  "client_id": "cases-app",
  "client_secret": "cases-app-secret",
  "client_type": "confidential",
  "redirect_uris": [
    "https://cases.example.com/callback",
    "http://localhost:3000/callback"
  ],
  "allowed_scopes": ["openid", "email", "profile"],
  "token_endpoint_auth_method": "client_secret_basic"
}

プロダクトごとのスコープ制御

プロダクトによって返す情報を変えられます。

cases-app:  scope=openid email profile groups  → フルアクセス
docs-app:   scope=openid email                 → メールだけ
admin-app:  scope=openid email profile groups   → フルアクセス + admin グループ必須

プロダクト側の実装

トークン検証(バックエンド)

プロダクトのバックエンドは、リクエストごとに JWT を検証します。

// 1. JWKS を取得(キャッシュ推奨)
jwks := fetchJWKS("https://auth.example.com/oidc/jwks.json")

// 2. JWT の署名を検証
token := jwt.Parse(idToken, jwt.WithKeySet(jwks))

// 3. クレームを検証
assert(token.Issuer() == "https://auth.example.com")
assert(token.Audience() == "cases-app")
assert(token.Expiration().After(time.Now()))

// 4. ユーザー情報を取得
sub := token.Subject()
email := token.Get("email")
groups := token.Get("groups")

SSO の体験

ユーザーがプロダクト A でログイン済みなら、プロダクト B にアクセスしたとき:

1. プロダクト B → auth.example.com/oidc/authorize にリダイレクト
2. OIDC Provider: セッション cookie あり → ログイン画面スキップ
3. → 認可コードを発行して即リダイレクト
4. プロダクト B: コードをトークンに交換
5. → ログイン完了(ユーザーはログイン画面を見ていない)

ユーザーからは「プロダクト B にアクセスしたら勝手にログインされた」ように見えます。これが SSO です。

セッション管理

3 種類のセッション

マルチプロダクト OIDC では、セッションが 3 箇所に存在します。

セッション場所寿命
OIDC Provider セッションauth.example.com の cookie長め(8 時間〜24 時間)
プロダクトセッションcases.example.com の cookieプロダクトが決める
トークンの有効期限JWT の exp1 時間程度

ログアウトの複雑さ

SSO のログアウトは「どこまで消すか」が問題になります。

レベル内容影響
プロダクトだけログアウトプロダクト B の cookie を消す他のプロダクトはログインしたまま
OIDC Provider もログアウトauth.example.com のセッションも消す次に別プロダクトにアクセスしたら再ログイン
全プロダクトからログアウト全プロダクトに通知してセッション無効化OIDC Back-Channel Logout が必要(複雑)

実務的には 「OIDC Provider もログアウト」 が一般的です。RP-Initiated Logout(OIDC の仕様)で実現できます。

GET https://auth.example.com/oidc/logout?
  id_token_hint=eyJhbGciOiJSUzI1NiIs...
  &post_logout_redirect_uri=https://cases.example.com
  &state=random

全プロダクト同時ログアウトが必要な場合は Back-Channel Logout を実装しますが、初期段階では不要です。

Discovery エンドポイント

OIDC Provider は /.well-known/openid-configuration で自分の情報を公開します。プロダクト側はこれを読むだけで接続先を把握できます。

// GET https://auth.example.com/.well-known/openid-configuration
{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/oidc/authorize",
  "token_endpoint": "https://auth.example.com/oidc/token",
  "userinfo_endpoint": "https://auth.example.com/oidc/userinfo",
  "jwks_uri": "https://auth.example.com/oidc/jwks.json",
  "scopes_supported": ["openid", "email", "profile"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "claims_supported": [
    "sub", "iss", "aud", "exp", "iat", "auth_time",
    "email", "email_verified", "groups", "amr"
  ],
  "code_challenge_methods_supported": ["S256"]
}

自社 OIDC Provider を立てる利点

外部 IdP(Auth0、Firebase Auth 等)との比較

自社 OIDC Provider外部 IdP
コストインフラ費のみMAU 課金(規模で高額に)
カスタマイズ完全自由プランによる制限
データの所在自社 AWS アカウント外部サービス
JWT クレームの自由度任意のクレームを追加可能制限あり
運用負荷自分で運用ほぼゼロ
ベンダーロックインなしあり

小〜中規模なら外部 IdP が楽ですが、「JWT のクレームを自由に設計したい」「ユーザーデータを外部に出したくない」「MAU 課金を避けたい」場合は自社構築が有利です。

Cognito を直接使わず Proxy IdP にする理由

Cognito の JWT をそのままプロダクトに返すと、iss に Cognito の URL が露出します。将来 Cognito から別のユーザーストアに乗り換えたいとき、全プロダクトの JWT 検証ロジックを書き換える必要が出てきます。

Proxy IdP(自社 OIDC Provider)を挟むことで:

プロダクト → auth.example.com(不変)→ Cognito(交換可能)

プロダクト側は iss: https://auth.example.com だけを信頼すればよく、裏のユーザーストアが何であるかを知る必要がありません。

まとめ

← Back to all posts