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 注入)。 func (p *provider) AuthorizationURL(state, nonce, codeChallenge string) string { return p.oauth2Cfg.AuthCodeURL( state, oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), oauth2.SetAuthURLParam("nonce", nonce), ) } // 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) } }