AWS Cognito を裏側に隠した独自 OIDC Provider を Go で作った

Cognito をユーザーストアとして内部利用しつつ、外部には独自署名の JWT を返す OIDC Provider(Proxy IdP)を Go + Lambda で構築した話。設計判断とステートレスな実装の詳細を解説します。

やりたかったこと

同人グループ「rellf」の認証基盤として rellf-auth を作っています。将来的に複数のアプリ(Web サイト、制作管理ツールなど)で共通のアカウントを使いたいので、認証基盤をマイクロサービスとして独立させたい。

最初は AWS Cognito の薄いラッパーとして作りました。Cognito の InitiateAuth を呼んで、Cognito が発行した JWT をそのままクライアントに返す構成です。

クライアント → rellf-auth API → Cognito

              ← Cognito の JWT をそのまま返す ←

これで動くけど、問題がありました。

Cognito 直接返しの問題

クライアントが Cognito を知ってしまう

Cognito の JWT には iss (issuer) として https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxx が入ります。クライアント側で JWT を検証するには、この Cognito の JWKS エンドポイントを直接叩く必要があります。

つまり、クライアントアプリが「裏で Cognito を使っている」ことを知っている状態。将来 IdP を変えたくなったら、全クライアントの JWT 検証ロジックを書き換える必要があります。

カスタムログイン画面が作れない

Cognito の Hosted UI は見た目のカスタマイズに限界があります。ブランドに合わせたログイン画面を作りたいけど、Cognito の OAuth フローを使うと Cognito のドメインにリダイレクトされてしまいます。

トークンのクレームを自由に制御できない

Cognito の JWT に含まれるクレーム(cognito:groups など)は Cognito 固有の形式です。標準的な OIDC クレーム形式で返したい。

解決策: Proxy IdP パターン

rellf-auth 自身を OIDC Provider にしました。Cognito はユーザーストア(認証バックエンド)として裏側で使い続けるけど、外部には rellf-auth が署名した JWT を返します。

クライアントアプリ

    │ 標準 OIDC (Authorization Code + PKCE)

rellf-auth (OIDC Provider)
    │  /.well-known/openid-configuration
    │  /oidc/authorize  → カスタムログイン UI
    │  /oidc/token      → 自前署名 JWT 発行
    │  /oidc/userinfo
    │  /oidc/jwks.json

    │ Cognito SDK (内部)

AWS Cognito User Pool (ユーザーストア)

クライアントから見ると rellf-auth は普通の OIDC Provider です。Cognito の存在は完全に隠蔽されます。

技術スタック

要素技術
言語Go (Gin)
デプロイ先AWS Lambda (arm64, provided.al2023)
API GatewayHTTP API v2
ユーザーストアAWS Cognito User Pool
秘密管理SSM Parameter Store
IaCTerraform
ローカル開発floci(Cognito エミュレータ)
JWT ライブラリlestrrat-go/jwx/v2

OIDC エンドポイントの実装

Discovery (/.well-known/openid-configuration)

OIDC クライアントが最初にアクセスするエンドポイント。自分が提供するエンドポイント一覧を JSON で返すだけです。

func (h *OIDCHandler) Discovery(c *gin.Context) {
    iss := h.issuer.Issuer()
    c.JSON(http.StatusOK, gin.H{
        "issuer":                 iss,
        "authorization_endpoint": iss + "/oidc/authorize",
        "token_endpoint":         iss + "/oidc/token",
        "userinfo_endpoint":      iss + "/oidc/userinfo",
        "jwks_uri":               iss + "/oidc/jwks.json",
        "response_types_supported": []string{"code"},
        "grant_types_supported":    []string{"authorization_code"},
        "id_token_signing_alg_values_supported": []string{"RS256"},
        "scopes_supported":        []string{"openid", "email", "profile"},
        "code_challenge_methods_supported": []string{"S256"},
    })
}

JWKS (/oidc/jwks.json)

RSA 公開鍵を JWK Set 形式で返します。クライアントはこれを使って JWT の署名を検証します。

鍵の管理は lestrrat-go/jwx/v2 に任せています。秘密鍵から公開鍵を抽出して JWK Set を構築するだけ。

func NewTokenIssuer(privateKeyPEM, keyID, issuer string) (*TokenIssuer, error) {
    privKey, err := jwk.ParseKey([]byte(privateKeyPEM), jwk.WithPEM(true))
    if err != nil {
        return nil, err
    }
    privKey.Set(jwk.KeyIDKey, keyID)
    privKey.Set(jwk.AlgorithmKey, jwa.RS256)

    pubKey, _ := privKey.PublicKey()
    pubSet := jwk.NewSet()
    pubSet.AddKey(pubKey)

    return &TokenIssuer{
        privateKey: privKey,
        issuer:     issuer,
        publicJWKS: pubSet,
    }, nil
}

本番では RSA 秘密鍵を SSM Parameter Store に保存し、OIDC_SIGNING_KEY=ssm:/rellf-auth/oidc-signing-key で参照します。ローカル開発時は起動時に自動生成。

Authorization (/oidc/authorize)

ここが Proxy IdP の核心。カスタムログインページを表示して、ユーザーの認証を Cognito に委任し、成功したら認可コードを発行してリダイレクトします。

GET でログインフォームを表示:

func (h *OIDCHandler) Authorize(c *gin.Context) {
    // client_id, redirect_uri を検証
    if _, err := h.clients.Validate(clientID, redirectURI); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
        return
    }
    // OIDC パラメータを hidden フィールドに埋め込んでログインページ描画
    h.templates.ExecuteTemplate(c.Writer, "login.html", data)
}

POST でフォーム送信を処理:

func (h *OIDCHandler) AuthorizeSubmit(c *gin.Context) {
    // 1. Cognito で認証
    tokens, err := h.cognito.Login(ctx, email, password)
    if err != nil {
        // ログインページ再表示(エラーメッセージ付き)
        return
    }

    // 2. Cognito の ID トークンからユーザー情報を抽出
    idToken, _ := jwt.Parse([]byte(tokens.IDToken), jwt.WithVerify(false))
    sub := idToken.Subject()

    // 3. 認可コードを生成(AES-GCM 暗号化)
    code, _ := h.codec.Encode(&AuthCodePayload{
        Sub: sub, Email: email, ClientID: clientID,
        CodeChallenge: codeChallenge, // PKCE
        ExpiresAt: time.Now().Add(5 * time.Minute).Unix(),
    })

    // 4. クライアントにリダイレクト
    c.Redirect(http.StatusFound, redirectURI+"?code="+code+"&state="+state)
}

ログインページは Go の html/template + embed で作っています。SPA フレームワークは不要で、シンプルな HTML フォームです。

ステートレス認可コード

ここが一番工夫したポイントです。

OIDC の Authorization Code Flow では、認可コードを一時的に保存して、トークンエンドポイントで引き換える必要があります。通常は Redis や DynamoDB に保存しますが、Lambda はステートレスなので外部ストレージが必要になります。

外部ストレージなしで実現する方法: 認可コードそのものにペイロードを暗号化して埋め込む。

type AuthCodePayload struct {
    Sub                 string   `json:"sub"`
    Email               string   `json:"email"`
    ClientID            string   `json:"cid"`
    RedirectURI         string   `json:"ruri"`
    Scopes              []string `json:"scp"`
    Nonce               string   `json:"nonce,omitempty"`
    CodeChallenge       string   `json:"cc,omitempty"`
    CodeChallengeMethod string   `json:"ccm,omitempty"`
    ExpiresAt           int64    `json:"exp"`
}

これを AES-256-GCM で暗号化し、base64url エンコードして認可コードとして返します。

func (c *AuthCodeCodec) Encode(payload *AuthCodePayload) (string, error) {
    plaintext, _ := json.Marshal(payload)

    nonce := make([]byte, c.aead.NonceSize())
    rand.Read(nonce)

    ciphertext := c.aead.Seal(nonce, nonce, plaintext, nil)
    return base64.RawURLEncoding.EncodeToString(ciphertext), nil
}

トークンエンドポイントでデコードするときは、復号して有効期限をチェックするだけ。

func (c *AuthCodeCodec) Decode(code string) (*AuthCodePayload, error) {
    data, _ := base64.RawURLEncoding.DecodeString(code)

    nonce, ciphertext := data[:nonceSize], data[nonceSize:]
    plaintext, err := c.aead.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        return nil, fmt.Errorf("failed to decrypt auth code")
    }

    var payload AuthCodePayload
    json.Unmarshal(plaintext, &payload)

    if time.Now().Unix() > payload.ExpiresAt {
        return nil, fmt.Errorf("auth code expired")
    }
    return &payload, nil
}

メリット:

デメリット:

有効期限を 5 分に設定しているので、実用上は問題ありません。

PKCE サポート

Public client(SPA やモバイルアプリ)は PKCE 必須にしています。

func verifyPKCE(challenge, method, verifier string) bool {
    if method != "S256" {
        return false
    }
    h := sha256.Sum256([]byte(verifier))
    computed := base64.RawURLEncoding.EncodeToString(h[:])
    return computed == challenge
}

Public client が PKCE なしでトークン交換しようとするとエラーになります。Confidential client は client_secret で認証するので PKCE はオプショナル。

JWT 署名

ID Token と Access Token を RSA-256 で署名して返します。

func (ti *TokenIssuer) SignIDToken(sub, email string, groups []string, aud, nonce string) (string, error) {
    token, _ := jwt.NewBuilder().
        Issuer(ti.issuer).          // "https://auth.rellf.com"
        Subject(sub).
        Audience([]string{aud}).
        IssuedAt(time.Now()).
        Expiration(time.Now().Add(1 * time.Hour)).
        Claim("email", email).
        Claim("email_verified", true).
        Claim("nonce", nonce).
        Build()

    return jwt.Sign(token, jwt.WithKey(jwa.RS256, ti.privateKey))
}

返される JWT の isshttps://auth.rellf.com(rellf-auth の URL)であり、Cognito の URL ではありません。クライアントは rellf-auth の JWKS だけ見ればよく、Cognito の存在を知る必要がありません。

クライアント登録

Phase 1 では環境変数で静的に定義しています。

OIDC_CLIENTS=my-app::public:http://localhost:3000/callback

フォーマットは client_id:secret:type:redirect_uris

type OIDCClient struct {
    ClientID     string
    ClientSecret string   // public client は空
    ClientType   string   // "public" or "confidential"
    RedirectURIs []string
}

動的登録(管理画面から追加)は将来の拡張として残しています。

ローカル開発

floci(LocalStack ベースの Cognito エミュレータ)を使っています。make local 一発で floci 起動 → Cognito リソース作成 → ローカルサーバー起動まで完了します。

ローカルでは RSA 鍵を自動生成するので、鍵の事前準備は不要です。

# .env.local に自動設定される
OIDC_SIGNING_KEY=auto          # 起動時に RSA 鍵を自動生成
OIDC_AUTH_CODE_KEY=01234...    # 固定のテスト用 AES 鍵
OIDC_CLIENTS=test-client::public:http://localhost:3000/callback

テスト

モックは使わず、floci に対する結合テストを書いています。OIDC フロー全体を通しでテストできます。

func TestOIDC_AuthorizationCodeFlow(t *testing.T) {
    ts := setupOIDCTestServer(t)  // floci 接続のテストサーバー

    // ユーザー作成・確認
    ensureOIDCTestUser(t, ts.URL, email, password)

    // PKCE ペア生成
    hash := sha256.Sum256([]byte(codeVerifier))
    codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])

    // 1. POST /oidc/authorize → 認可コード取得
    resp := postForm(ts.URL+"/oidc/authorize", form)
    code := parseRedirectCode(resp)

    // 2. POST /oidc/token → JWT 取得
    tokens := exchangeCode(ts.URL, code, codeVerifier)

    // 3. GET /oidc/userinfo → ユーザー情報確認
    userInfo := getUserInfo(ts.URL, tokens.AccessToken)
    assert(userInfo["sub"] != "")
}

テストケースは 13 件:

Terraform / インフラ

Lambda の環境変数で秘密情報は ssm: プレフィックスを使います。起動時に SSM Parameter Store から自動取得されます。

environment {
  variables = {
    COGNITO_CLIENT_SECRET = "ssm:/${var.project_name}/cognito-client-secret"
    OIDC_SIGNING_KEY      = "ssm:/${var.project_name}/oidc-signing-key"
    OIDC_AUTH_CODE_KEY    = "ssm:/${var.project_name}/oidc-auth-code-key"
    OIDC_ISSUER           = aws_apigatewayv2_stage.main.invoke_url
    OIDC_KEY_ID           = var.oidc_key_id
    OIDC_CLIENTS          = var.oidc_clients
  }
}

既存 API との共存

既存の /auth/*(サインアップ、ログイン等)と /api/*(ユーザー情報等)はそのまま残しています。管理画面(/admin/*)も健在。

GET  /.well-known/openid-configuration  ← NEW
GET  /oidc/jwks.json                    ← NEW
GET  /oidc/authorize                    ← NEW
POST /oidc/authorize                    ← NEW
POST /oidc/token                        ← NEW
GET  /oidc/userinfo                     ← NEW

POST /auth/signup                       ← 既存
POST /auth/login                        ← 既存
GET  /api/me                            ← 既存
GET  /admin/users                       ← 既存

既存クライアントは段階的に OIDC に移行できます。

ディレクトリ構成

internal/
  oidc/
    handler.go          # 全 OIDC エンドポイント
    token.go            # RSA 鍵管理 + JWT 署名
    authcode.go         # ステートレス認可コード (AES-GCM)
    client.go           # クライアント登録・検証
    embed.go            # go:embed
    templates/
      login.html        # カスタムログインページ
    static/
      login.css
  cognito/
    client.go           # Cognito SDK ラッパー(既存)
    admin.go            # 管理 API(既存)
  config/
    config.go           # 環境変数 + SSM 解決
  handler/              # 既存 API ハンドラー
  middleware/            # JWT 検証ミドルウェア
  admin/                # 管理画面
  router/               # ルーティング

まとめ

設計判断選択理由
Proxy IdP パターンCognito 隠蔽 + カスタム UI + クレーム制御
ステートレス認可コードAES-GCM 暗号化Lambda で外部ストレージ不要
PKCE 必須(public client)S256SPA/モバイル対応のセキュリティ要件
JWT 署名RS256標準的、JWKS で公開鍵配布可能
鍵管理SSM Parameter StoreAWS ネイティブ、Terraform 管理可能
テストfloci 結合テストモック不使用、実際の Cognito API を叩く

コードは GitHub で公開しています: rikukaInoue/rellf-auth

次のステップは refresh_token 対応と、Google OAuth を OIDC フロー経由で動かすことです。

← Back to all posts