從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:
- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
(tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
- internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
- internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
防 session fixation, OWASP ASVS V3.2.1)
- 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
- 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
- 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
- OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
(AuthStyleInParams 強制 token endpoint 不送 client_secret)
- 預留 ServiceClient* 欄位給未來 client_credentials grant
- 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
(Audit C1:multi-tenant 隔離破口)
- Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
- 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)
驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
271 lines
9.7 KiB
Go
271 lines
9.7 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 注入)。
|
||
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)
|
||
}
|
||
}
|