jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 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>
2026-05-01 11:21:20 +08:00

271 lines
9.7 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 注入)。
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)
// 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)
}
}