接 DB 後真人 OIDC 登入暴露 MC OIDC provider 實作不完整,visionA 端逐項繞過, 讓登入/換帳號可用;另補真轉檔服務的整合 e2e。 OIDC 登入修復(MC 端根因另有交接檔,visionA 先繞過): - email fallback:MC id_token 不發 email claim(ASP.NET Identity 預設 factory 只發 sub/name)→ A7 email 必填擋住登入。callback email 空時用 <sub>@noemail.visiona.local placeholder,不污染 schema,MC 修好發真 email 後 ON CONFLICT 自動覆寫 - prompt=login:authorize 帶 prompt=login(config VISIONA_OIDC_PROMPT_LOGIN,預設關) - logout 連動 MC:logout 回 idp_logout(MC Web :7880 /account/logout,GET),前端用 隱藏 iframe 觸發清 MC session(Web/Api 共享 DataProtection)→ 能換帳號。 config VISIONA_OIDC_LOGOUT_URL、向下相容(未設則只清本地) 真轉檔鏈路 e2e(//go:build realconv,按需對 stage 跑、不污染主測試集): - real_converter_e2e:give 真轉檔服務 contract(init→poll→completed/promote/result) - real_chain_e2e:真轉檔→PromoteToModels→model 進 PG→冪等 全鏈路(對 stage 跑 PASS) 交接檔(給對應團隊根治): - mc-email-claim-handoff:MC 加 email claim(自訂 UserClaimsPrincipalFactory) - converter-promote-oauth-handoff:轉檔服務 OAuth 用 form body 非 Basic Auth 全程 Reviewer 審查 + 對 stage 真環境驗證。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package oidc
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
|
||
"golang.org/x/oauth2"
|
||
)
|
||
|
||
// provider 是 Provider interface 的預設實作,底層用 coreos/go-oidc/v3。
|
||
//
|
||
// 為什麼選 coreos/go-oidc/v3:
|
||
// - 業界事實標準,廣泛採用、長期維護
|
||
// - 自動處理 discovery(/.well-known/openid-configuration)
|
||
// - 自動處理 JWKS 抓取與快取(內建 1h refresh)
|
||
// - 與 golang.org/x/oauth2 標準 OAuth2 lib 整合無縫
|
||
// - id_token 驗證涵蓋 iss / aud / exp / 簽章;nonce 與額外 claim 由我們補上
|
||
//
|
||
// 為什麼仍包一層 wrapper:
|
||
// - 公開 API 在我們手上,未來若要換 lib(例如自刻或換 lestrrat-go/jwx)caller 不受影響
|
||
// - 集中錯誤型別轉換(coreos 各種錯誤 → 我們的 sentinel errors)
|
||
// - 集中 nonce 比對(coreos 預設不驗 nonce,留給 caller 處理)
|
||
type provider struct {
|
||
cfg ProviderConfig
|
||
oauth2Cfg *oauth2.Config
|
||
idTokenVerif *coreosoidc.IDTokenVerifier
|
||
}
|
||
|
||
// NewProvider 以 cfg 建立一個 Provider 實例。
|
||
//
|
||
// 過程:
|
||
// 1. 驗 cfg 必填欄位
|
||
// 2. 用 coreos lib 抓 discovery(含 jwks_uri、authorization_endpoint、token_endpoint)
|
||
// — 此步驟有網路 I/O,會以 ctx 控制 timeout
|
||
// 3. 建 oauth2.Config(後續 ExchangeCode / AuthorizationURL 會用到)
|
||
// 4. 建 IDTokenVerifier(內部會持有 JWKS 快取,自動 refresh)
|
||
//
|
||
// caller 通常在程式啟動時呼叫一次,存在 long-lived 的 Deps 容器中重複使用。
|
||
// 若 IdP 不可達會回 ErrDiscoveryFetch(包 inner error)— 啟動時 fail-fast。
|
||
func NewProvider(ctx context.Context, cfg ProviderConfig) (Provider, error) {
|
||
if err := validateConfig(&cfg); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
coreosProv, err := coreosoidc.NewProvider(ctx, cfg.IssuerURL)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%w: %v", ErrDiscoveryFetch, err)
|
||
}
|
||
|
||
// A1:ClientSecret 留空 → public PKCE-only client mode。
|
||
//
|
||
// oauth2 lib 的 token request 行為(golang.org/x/oauth2 v0.36 internal/token.go):
|
||
//
|
||
// - AuthStyleInParams:clientID / clientSecret 寫進 POST form。空 secret 時
|
||
// `if clientSecret != ""` 判斷成立 → **完全不送 client_secret 欄位**,
|
||
// 符合 RFC 6749 §2.3.1 對 public client 的規範。
|
||
// - AuthStyleInHeader:永遠 SetBasicAuth(clientID, clientSecret) → 即使空
|
||
// secret 也會送 `Authorization: Basic base64(clientID:)`,多數 IdP 會把這個
|
||
// 視為「confidential client 但 secret 錯」而 401。
|
||
// - AuthStyleAutoDetect(zero value):第一輪試 InHeader,4xx 後 fallback 到
|
||
// InParams。對 public client 多了一次失敗 round-trip。
|
||
//
|
||
// 所以 public client mode 強制 InParams,跳過 InHeader 探測;
|
||
// confidential client mode 維持 AutoDetect(沿用 lib 預設行為,與 OB1 一致)。
|
||
endpoint := coreosProv.Endpoint()
|
||
if cfg.ClientSecret == "" {
|
||
endpoint.AuthStyle = oauth2.AuthStyleInParams
|
||
}
|
||
|
||
oauth2Cfg := &oauth2.Config{
|
||
ClientID: cfg.ClientID,
|
||
ClientSecret: cfg.ClientSecret, // 空字串 → token endpoint 不送 client_secret
|
||
RedirectURL: cfg.RedirectURL,
|
||
Endpoint: endpoint,
|
||
Scopes: cfg.Scopes,
|
||
}
|
||
|
||
verifier := coreosProv.Verifier(&coreosoidc.Config{
|
||
ClientID: cfg.ClientID,
|
||
})
|
||
|
||
return &provider{
|
||
cfg: cfg,
|
||
oauth2Cfg: oauth2Cfg,
|
||
idTokenVerif: verifier,
|
||
}, nil
|
||
}
|
||
|
||
// validateConfig 檢查 ProviderConfig 必填欄位,並套用預設 Scopes。
|
||
//
|
||
// A1(2026-05-01):ClientSecret 為**選填**,留空時走 public PKCE-only client mode。
|
||
// 必填欄位剩 IssuerURL / ClientID / RedirectURL。
|
||
//
|
||
// 注意:cfg 是 *指標*,會被就地修改(套預設 Scopes)。這是有意為之 —
|
||
// caller 通常從 env 載入 ProviderConfig 一次性傳入,套預設後立刻被 NewProvider 拷貝進
|
||
// internal struct,不會有別名問題。
|
||
func validateConfig(cfg *ProviderConfig) error {
|
||
missing := make([]string, 0, 3)
|
||
if strings.TrimSpace(cfg.IssuerURL) == "" {
|
||
missing = append(missing, "IssuerURL")
|
||
}
|
||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||
missing = append(missing, "ClientID")
|
||
}
|
||
// ClientSecret 不檢查(A1:public PKCE-only client 留空合法)。
|
||
if strings.TrimSpace(cfg.RedirectURL) == "" {
|
||
missing = append(missing, "RedirectURL")
|
||
}
|
||
if len(missing) > 0 {
|
||
return fmt.Errorf("%w: missing required fields: %s",
|
||
ErrInvalidConfig, strings.Join(missing, ", "))
|
||
}
|
||
|
||
// IssuerURL 必須是合法 URL;coreos lib 會再驗一次但訊息較不友善。
|
||
if _, err := url.Parse(cfg.IssuerURL); err != nil {
|
||
return fmt.Errorf("%w: IssuerURL invalid: %v", ErrInvalidConfig, err)
|
||
}
|
||
if _, err := url.Parse(cfg.RedirectURL); err != nil {
|
||
return fmt.Errorf("%w: RedirectURL invalid: %v", ErrInvalidConfig, err)
|
||
}
|
||
|
||
if len(cfg.Scopes) == 0 {
|
||
// 套預設值;不深拷貝,因為 DefaultScopes 不會被修改。
|
||
cfg.Scopes = DefaultScopes
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// AuthorizationURL 實作 Provider.AuthorizationURL。
|
||
//
|
||
// 用 oauth2.Config.AuthCodeURL 組 URL,加上 PKCE 與 nonce 兩個額外參數
|
||
// (oauth2 lib 原生不知道這兩個東西,需以 oauth2.SetAuthURLParam 注入)。
|
||
//
|
||
// 若 cfg.PromptLogin 為 true,額外帶 OIDC `prompt=login`(Core §3.1.2.1):
|
||
// 讓 IdP 忽略既有 SSO session、每次都強制使用者重新認證。預設不帶(沿用 SSO session)。
|
||
func (p *provider) AuthorizationURL(state, nonce, codeChallenge string) string {
|
||
opts := []oauth2.AuthCodeOption{
|
||
oauth2.SetAuthURLParam("code_challenge", codeChallenge),
|
||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||
oauth2.SetAuthURLParam("nonce", nonce),
|
||
}
|
||
if p.cfg.PromptLogin {
|
||
opts = append(opts, oauth2.SetAuthURLParam("prompt", "login"))
|
||
}
|
||
return p.oauth2Cfg.AuthCodeURL(state, opts...)
|
||
}
|
||
|
||
// ExchangeCode 實作 Provider.ExchangeCode。
|
||
//
|
||
// 把 code_verifier 注入 token request,由 IdP 驗 PKCE proof。
|
||
//
|
||
// 錯誤分類邏輯:
|
||
// - oauth2 回的 *oauth2.RetrieveError 如果 ErrorCode == "invalid_grant"
|
||
// → ErrInvalidGrant(典型情境:code 已用過、過期、verifier 不符)
|
||
// - 其他 → ErrTokenExchange + 包 inner error(如 IdP 5xx、connection refused)
|
||
func (p *provider) ExchangeCode(ctx context.Context, code, codeVerifier string) (*TokenResponse, error) {
|
||
tok, err := p.oauth2Cfg.Exchange(
|
||
ctx, code,
|
||
oauth2.SetAuthURLParam("code_verifier", codeVerifier),
|
||
)
|
||
if err != nil {
|
||
return nil, classifyExchangeError(err)
|
||
}
|
||
|
||
rawIDToken, ok := tok.Extra("id_token").(string)
|
||
if !ok || rawIDToken == "" {
|
||
// IdP 回了 200 但沒給 id_token — 違反 OIDC spec
|
||
return nil, fmt.Errorf("%w: id_token missing from token response", ErrTokenExchange)
|
||
}
|
||
|
||
expiresIn := 0
|
||
if !tok.Expiry.IsZero() {
|
||
expiresIn = int(time.Until(tok.Expiry).Seconds())
|
||
}
|
||
|
||
return &TokenResponse{
|
||
AccessToken: tok.AccessToken,
|
||
IDToken: rawIDToken,
|
||
RefreshToken: tok.RefreshToken,
|
||
TokenType: tok.TokenType,
|
||
ExpiresIn: expiresIn,
|
||
}, nil
|
||
}
|
||
|
||
// classifyExchangeError 把 oauth2 lib 的錯誤對應到我們的 sentinel error。
|
||
//
|
||
// oauth2.RetrieveError 在新版 lib 中是公開型別;它的 ErrorCode 對應 RFC 6749 §5.2 的
|
||
// error 欄位,invalid_grant 是 PKCE/code 失敗最常見的錯誤碼。
|
||
func classifyExchangeError(err error) error {
|
||
var retrieveErr *oauth2.RetrieveError
|
||
if errors.As(err, &retrieveErr) {
|
||
if retrieveErr.ErrorCode == "invalid_grant" {
|
||
return fmt.Errorf("%w: %v", ErrInvalidGrant, err)
|
||
}
|
||
}
|
||
return fmt.Errorf("%w: %v", ErrTokenExchange, err)
|
||
}
|
||
|
||
// VerifyIDToken 實作 Provider.VerifyIDToken。
|
||
//
|
||
// coreos verifier 自動驗:簽章、iss、aud、exp(含預設 leeway)。
|
||
// 我們在外層補:
|
||
// - nonce 比對(caller 帶 expectedNonce)
|
||
// - claim 解析成我們自己的 Claims struct
|
||
// - 錯誤型別轉換(coreos 訊息 → 我們的 sentinel)
|
||
func (p *provider) VerifyIDToken(ctx context.Context, rawIDToken, expectedNonce string) (*Claims, error) {
|
||
if rawIDToken == "" {
|
||
return nil, fmt.Errorf("%w: empty id_token", ErrInvalidIDToken)
|
||
}
|
||
if expectedNonce == "" {
|
||
// nonce 是 OIDC replay 防護的核心,caller 必須提供 — 強制 fail 而非 silently skip
|
||
return nil, fmt.Errorf("%w: expectedNonce is required", ErrInvalidNonce)
|
||
}
|
||
|
||
idToken, err := p.idTokenVerif.Verify(ctx, rawIDToken)
|
||
if err != nil {
|
||
return nil, classifyVerifyError(err)
|
||
}
|
||
|
||
// 解出標準 + 自定 claim。coreos IDToken 已驗 iss/aud/exp/簽章,
|
||
// 我們再補 nonce 比對。
|
||
var raw map[string]any
|
||
if err := idToken.Claims(&raw); err != nil {
|
||
return nil, fmt.Errorf("%w: parse claims: %v", ErrInvalidIDToken, err)
|
||
}
|
||
|
||
// nonce 比對(coreos 不會驗,因為它無法知道 expected 值)
|
||
tokenNonce, _ := raw["nonce"].(string)
|
||
if tokenNonce != expectedNonce {
|
||
return nil, ErrInvalidNonce
|
||
}
|
||
|
||
email, _ := raw["email"].(string)
|
||
name, _ := raw["name"].(string)
|
||
|
||
// audience:coreos 已驗 aud 包含 ClientID,我們選 ClientID 作為「使用中的 audience」
|
||
// 而非從 raw 取第一個 — 後者在 multi-aud 場景會誤導。
|
||
audience := p.cfg.ClientID
|
||
|
||
return &Claims{
|
||
Subject: idToken.Subject,
|
||
Email: email,
|
||
Name: name,
|
||
Issuer: idToken.Issuer,
|
||
Audience: audience,
|
||
IssuedAt: idToken.IssuedAt,
|
||
ExpiresAt: idToken.Expiry,
|
||
Nonce: tokenNonce,
|
||
Raw: raw,
|
||
}, nil
|
||
}
|
||
|
||
// classifyVerifyError 把 coreos verifier 的錯誤轉成我們的 sentinel error。
|
||
//
|
||
// coreos lib 沒有 typed error(除了少數例外),所以以字串 contains 判斷。
|
||
// 這雖然脆弱(lib 升級可能改訊息),但符合事實上的慣例;
|
||
// 真要嚴謹可以改用 errors.As 看 coreos 內部 type,但訊息穩定性目前 OK。
|
||
func classifyVerifyError(err error) error {
|
||
msg := err.Error()
|
||
switch {
|
||
case strings.Contains(msg, "expired"):
|
||
return fmt.Errorf("%w: %v", ErrTokenExpired, err)
|
||
case strings.Contains(msg, "issuer"):
|
||
return fmt.Errorf("%w: %v", ErrInvalidIssuer, err)
|
||
case strings.Contains(msg, "audience"):
|
||
return fmt.Errorf("%w: %v", ErrInvalidAudience, err)
|
||
default:
|
||
return fmt.Errorf("%w: %v", ErrInvalidIDToken, err)
|
||
}
|
||
}
|