Cognitoを永続化層として割り切る — 認証基盤の差し替え可能性を設計する

自前OIDCプロバイダーを持つGo認証サービスで、Cognitoへの依存を永続化層として抽象化した。全経路のJWT発行を自前に統一し、usecase層にCredentialStore/ProviderStore/UserRepository interfaceを切ることで、Cognitoを透過的に差し替え可能にするまでの設計と実装の記録。

背景

Go + Lambda + Cognito で構築した認証サービス (rellf-auth) がある。このサービスは Cognito をユーザーストアとして使いつつ、自前の OIDC プロバイダーも実装している。つまりトークン発行経路が2本ある:

経路発行元消費者
POST /auth/loginCognito JWTフロントエンド
POST /oidc/token自前 JWT (RSA署名)外部 Relying Party
POST /admin/loginCognito JWT (cookie)Admin UI

将来的にユーザーストアをコスト面等の理由で載せ替える可能性があるため、Cognito への依存を抽象化して差し替え可能にしたい。

「永続化層として割り切る」とは

Cognito は認証エンジン + ユーザーストア + メール送信を兼ねたフルマネージドサービスだが、自前 OIDC プロバイダーが既にある以上、Cognito が担うべき責務は:

トークン発行は Cognito の仕事ではなく、自前 OIDC プロバイダーの仕事。Cognito JWT を外部に露出する理由がない。

Step 1: JWT 発行の統一

まず全経路のトークン発行を自前 OIDC に統一した。

AuthUseCase の導入

Cognito の InitiateAuthAuthTokens (access + id + refresh) を返す。この中の ID トークンをパースして identity を取り出すのがこれまで各 handler にベタ書きされていた。これを AuthUseCase.Authenticate に局所化する。

type CredentialVerifier interface {
    LoginIDToken(ctx context.Context, email, password string) (idTokenRaw string, err error)
}

type AuthUseCase struct {
    verifier CredentialVerifier
}

func (uc *AuthUseCase) Authenticate(ctx context.Context, email, password string) (*domain.AuthenticatedUser, error) {
    idTokenRaw, err := uc.verifier.LoginIDToken(ctx, email, password)
    if err != nil {
        return nil, err
    }
    return uc.parseIdentity(idTokenRaw)
}

parseIdentity は Cognito ID トークンの cognito:usernamecognito:groups といった固有の claim 名を解釈し、ドメイン型に変換する。Cognito の語彙がここに閉じ込められる。

各 handler の変更

// Before: Cognito JWT をそのまま返す
tokens, err := h.auth.Login(ctx, email, password)
c.JSON(http.StatusOK, tokens)

// After: AuthUseCase で認証 → 自前 TokenIssuer で JWT 発行
user, err := h.authUC.Authenticate(ctx, email, password)
idToken, _ := h.issuer.SignIDToken(user.Sub, user.Email, user.Groups, ...)
accessToken, _ := h.issuer.SignAccessToken(user.Sub, scopes, ...)
c.JSON(http.StatusOK, AuthTokensResponse{...})

同じパターンを /auth/login, /auth/oauth/callback, /admin/login, /oidc/authorize の全4経路に適用。

JWT middleware の統一

JWT 検証の middleware も Cognito の JWKS URL から自前の JWKS に変更。

// Before: Cognito の JWKS をフェッチしてキャッシュ
jwtMw, _ = middleware.NewJWTMiddleware(region, poolID, clientID)

// After: TokenIssuer の公開鍵を直接渡す
jwtMw = middleware.NewJWTMiddleware(tokenIssuer.JWKS(), oidcIssuer, clientID)

admin cookie の middleware も cognito:groups ではなく groups claim を見るように変更。

Step 2: 永続化層の抽象化

JWT 統一後、Cognito が内部的にだけ使われるようになったので、残りの依存を interface で切る。

ドメイン型の導入

Cognito 固有の型 (cognito.SignUpOutput, cognito.LinkedProvider) をドメイン層に移す。

// domain/auth.go
type SignUpResult struct {
    UserConfirmed bool
    UserSub       string
}

type LinkedProvider struct {
    ProviderName string
    ProviderUID  string
}

type AuthenticatedUser struct {
    Sub      string
    Email    string
    Username string
    Groups   []string
}

Interface 設計

usecase 層に3つの interface を定義:

// CredentialStore: ユーザー登録 + パスワード認証 + パスワードリセット
type CredentialStore interface {
    CredentialVerifier  // LoginIDToken
    SignUp(ctx context.Context, email, password string) (*domain.SignUpResult, error)
    ConfirmSignUp(ctx context.Context, email, code string) error
    ForgotPassword(ctx context.Context, email string) error
    ConfirmForgotPassword(ctx context.Context, email, code, newPassword string) error
}

// ProviderStore: 外部プロバイダーリンク
type ProviderStore interface {
    LinkProvider(ctx context.Context, username, providerName, providerUID string) error
    UnlinkProvider(ctx context.Context, username, providerName, providerUID string) error
    GetLinkedProviders(ctx context.Context, username string) ([]domain.LinkedProvider, error)
}

// UserRepository: ユーザー CRUD + ライフサイクル (admin 操作)
type UserRepository interface {
    GetUser(ctx context.Context, username string) (*UserDetail, error)
    ListUsers(ctx context.Context, filter string, limit int32, token *string) (*UserListResult, error)
    CreateUser(ctx context.Context, email, tempPassword string) (*UserDetail, error)
    ConfirmUser(ctx context.Context, username string) error
    ResetPassword(ctx context.Context, username string) error
    DisableUser(ctx context.Context, username string) error
    EnableUser(ctx context.Context, username string) error
    DeleteUser(ctx context.Context, username string) error
}

Cognito Client の adapter

cognito.Client がこれらの interface を暗黙的に満たすようにラッパーメソッドを追加:

// cognito/user_repository.go
func (c *Client) GetUser(ctx context.Context, username string) (*usecase.UserDetail, error) {
    d, err := c.AdminGetUser(ctx, username)
    if err != nil {
        return nil, err
    }
    return &usecase.UserDetail{
        Username: d.Username,
        Email:    d.Email,
        Status:   d.Status,
        // ...
    }, nil
}

Handler の依存変更

// Before
type Handler struct {
    auth cognito.Service
    cfg  *config.Config
}

// After
type Handler struct {
    creds     usecase.CredentialStore
    providers usecase.ProviderStore
    authUC    *usecase.AuthUseCase
    issuer    *oidc.TokenIssuer
    cfg       *config.Config
}

結果

依存方向

cmd/ (wire-up)

cognito.Client ──implements──→ usecase.CredentialStore
                               usecase.ProviderStore
                               usecase.UserRepository

handler/  → usecase interfaces のみ
admin/    → usecase interfaces のみ
oidc/     → usecase interfaces のみ
usecase/  → domain/ のみ

internal/ 配下から cognito パッケージの import がゼロになった。

差し替えの実際

将来 DynamoDB + argon2 に差し替える場合:

  1. internal/dynamodb/ パッケージを作り、usecase.UserRepository + usecase.CredentialStore を実装
  2. cmd/ の wire-up で cognito.Client の代わりに渡す

既存のビジネスロジック、ドメインモデル、handler、OIDC プロバイダーには一切触らない。

トレードオフ

まとめ

← Back to all posts