jim800121chen c2f0b1549e feat: OIDC 登入修復(email fallback / prompt=login / logout 連動)+ 真轉檔鏈路 e2e
接 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>
2026-06-27 03:18:59 +08:00

277 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/jwxcaller 不受影響
// - 集中錯誤型別轉換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)
}
// A1ClientSecret 留空 → public PKCE-only client mode。
//
// oauth2 lib 的 token request 行為golang.org/x/oauth2 v0.36 internal/token.go
//
// - AuthStyleInParamsclientID / 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。
// - AuthStyleAutoDetectzero value第一輪試 InHeader4xx 後 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。
//
// A12026-05-01ClientSecret 為**選填**,留空時走 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 不檢查A1public 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 必須是合法 URLcoreos 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)
// audiencecoreos 已驗 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)
}
}