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

738 lines
24 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"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeOIDC 是一個用 httptest 起來的最小化 OIDC 模擬器:
// - GET /.well-known/openid-configuration → discovery doc
// - GET /jwks → JWKS含 1 把 RSA public key
// - POST /token → 接 authorization_code回 token set
//
// 簽 id_token 用 go-josecoreos/go-oidc 的內部依賴,已在 go.sum 中),不需引額外 lib。
type fakeOIDC struct {
server *httptest.Server
signingKey *rsa.PrivateKey
keyID string
clientID string
// 可由各測試修改的「下一個 token 行為」控制旗標
mu chan struct{} // 簡單以 buffered chan 當 mutex避免 import sync
expectVerifier string // POST /token 時驗 code_verifier 是否相符;空字串=不驗
respondCode int // POST /token 回應 status code0 = 200
respondBody string // 非空時直接回此 body 取代正常 token response
idTokenClaims jwt.Claims // 自訂簽 token 的 standard claimszero = 預設)
idTokenExtra map[string]any
idTokenAlg jose.SignatureAlgorithm // 預設 RS256
skipIDToken bool // true 時 token response 不含 id_token
// 觀測:最後一次 /token 收到的 form / Authorization headerA1 加:驗 public client mode
lastTokenForm url.Values
lastTokenAuthHdr string
}
func newFakeOIDC(t *testing.T, clientID string) *fakeOIDC {
t.Helper()
priv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err, "產 RSA key 失敗")
f := &fakeOIDC{
signingKey: priv,
keyID: "test-key-1",
clientID: clientID,
mu: make(chan struct{}, 1),
idTokenAlg: jose.RS256,
}
f.mu <- struct{}{} // 初始化「鎖可用」
mux := http.NewServeMux()
// discovery doc 必須在 server 起來後才知道 issuer URL所以用 closure 延遲組
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
base := "http://" + r.Host
doc := map[string]any{
"issuer": base,
"authorization_endpoint": base + "/authorize",
"token_endpoint": base + "/token",
"jwks_uri": base + "/jwks",
"response_types_supported": []string{
"code",
},
"id_token_signing_alg_values_supported": []string{"RS256"},
"subject_types_supported": []string{"public"},
"scopes_supported": []string{"openid", "email", "profile"},
}
writeJSON(w, doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
jwks := map[string]any{
"keys": []map[string]any{
rsaPublicKeyToJWK(&priv.PublicKey, f.keyID),
},
}
writeJSON(w, jwks)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
f.handleToken(t, w, r)
})
f.server = httptest.NewServer(mux)
t.Cleanup(f.server.Close)
return f
}
func (f *fakeOIDC) issuer() string { return f.server.URL }
// withState 是 helper在 chan-as-mutex 保護下安全修改控制旗標。
func (f *fakeOIDC) withState(fn func()) {
<-f.mu
defer func() { f.mu <- struct{}{} }()
fn()
}
// snapshot 是 helper原子讀取所有控制旗標。
func (f *fakeOIDC) snapshot() (verifier string, code int, body string, claims jwt.Claims, extra map[string]any, alg jose.SignatureAlgorithm, skipID bool) {
<-f.mu
defer func() { f.mu <- struct{}{} }()
return f.expectVerifier, f.respondCode, f.respondBody, f.idTokenClaims, f.idTokenExtra, f.idTokenAlg, f.skipIDToken
}
func (f *fakeOIDC) handleToken(t *testing.T, w http.ResponseWriter, r *http.Request) {
t.Helper()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
// 觀測:抄一份 form / Authorization header 給測試驗 public vs confidential mode
f.withState(func() {
f.lastTokenForm = make(url.Values, len(r.PostForm))
for k, vv := range r.PostForm {
f.lastTokenForm[k] = append([]string(nil), vv...)
}
f.lastTokenAuthHdr = r.Header.Get("Authorization")
})
expectVerifier, code, body, claims, extra, alg, skipID := f.snapshot()
// caller 可指定強制錯誤 / 自訂 body
if body != "" {
if code == 0 {
code = http.StatusBadRequest
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_, _ = w.Write([]byte(body))
return
}
if expectVerifier != "" && r.Form.Get("code_verifier") != expectVerifier {
// PKCE proof 失敗:回 RFC 6749 §5.2 規範的 invalid_grant
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant","error_description":"PKCE verifier mismatch"}`))
return
}
// 簽 id_token
now := time.Now()
if claims.Issuer == "" {
claims = jwt.Claims{
Issuer: f.issuer(),
Subject: "sub-fake-user-001",
Audience: jwt.Audience{f.clientID},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
NotBefore: jwt.NewNumericDate(now),
}
}
if extra == nil {
extra = map[string]any{
"email": "fake-user@example.com",
"name": "Fake User",
"nonce": r.Form.Get("__test_nonce_passthrough"), // 不會由真 OIDC server 帶;測試以另路徑注入
}
}
rawIDToken, err := signIDToken(f.signingKey, f.keyID, alg, claims, extra)
if err != nil {
http.Error(w, "sign error: "+err.Error(), http.StatusInternalServerError)
return
}
resp := map[string]any{
"access_token": "fake-access-token",
"token_type": "Bearer",
"expires_in": 3600,
}
if !skipID {
resp["id_token"] = rawIDToken
}
writeJSON(w, resp)
}
// signIDToken 用 RSA private key 簽一個 OIDC id_tokenJWS / RS256
func signIDToken(priv *rsa.PrivateKey, kid string, alg jose.SignatureAlgorithm, std jwt.Claims, extra map[string]any) (string, error) {
signerOpts := (&jose.SignerOptions{}).WithType("JWT")
signerOpts.WithHeader("kid", kid)
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: priv}, signerOpts)
if err != nil {
return "", err
}
builder := jwt.Signed(signer).Claims(std)
if len(extra) > 0 {
builder = builder.Claims(extra)
}
return builder.Serialize()
}
// rsaPublicKeyToJWK 把 RSA public key 編成 JWKS spec 的 key 物件。
func rsaPublicKeyToJWK(pub *rsa.PublicKey, kid string) map[string]any {
return map[string]any{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": kid,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
}
}
func bigIntBytes(e int) []byte {
// RSA exponent 通常是 655370x010001= 3 bytes。手動編碼。
out := []byte{}
for e > 0 {
out = append([]byte{byte(e & 0xff)}, out...)
e >>= 8
}
if len(out) == 0 {
out = []byte{0}
}
return out
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// =====================================================================
// 測試
// =====================================================================
const (
testClientID = "visiona-backend-test"
testClientSecret = "test-secret"
testRedirect = "http://localhost:8080/api/auth/callback"
)
func newProviderForTest(t *testing.T, fake *fakeOIDC) Provider {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := NewProvider(ctx, ProviderConfig{
IssuerURL: fake.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
RedirectURL: testRedirect,
})
require.NoError(t, err)
return p
}
func TestNewProvider_Discovery(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
require.NotNil(t, p)
}
func TestNewProvider_RejectInvalidConfig(t *testing.T) {
// A12026-05-01ClientSecret 為選填,因此 "missing secret" case 已移除。
cases := []struct {
name string
cfg ProviderConfig
}{
{"missing issuer", ProviderConfig{ClientID: "x", ClientSecret: "y", RedirectURL: "http://z"}},
{"missing client id", ProviderConfig{IssuerURL: "http://x", ClientSecret: "y", RedirectURL: "http://z"}},
{"missing redirect", ProviderConfig{IssuerURL: "http://x", ClientID: "y", ClientSecret: "z"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := NewProvider(context.Background(), c.cfg)
assert.ErrorIs(t, err, ErrInvalidConfig)
})
}
}
func TestNewProvider_DiscoveryFailure(t *testing.T) {
// 用一個立刻關掉的 server 模擬 IdP 不可達
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
_, err := NewProvider(context.Background(), ProviderConfig{
IssuerURL: srv.URL,
ClientID: "c",
ClientSecret: "s",
RedirectURL: "http://r",
})
require.Error(t, err)
assert.ErrorIs(t, err, ErrDiscoveryFetch)
}
func TestAuthorizationURL_Format(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
state, _ := GenerateState()
nonce, _ := GenerateNonce()
verifier, _ := GenerateCodeVerifier()
challenge := CodeChallenge(verifier)
raw := p.AuthorizationURL(state, nonce, challenge)
u, err := url.Parse(raw)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "code", q.Get("response_type"))
assert.Equal(t, testClientID, q.Get("client_id"))
assert.Equal(t, testRedirect, q.Get("redirect_uri"))
assert.Equal(t, state, q.Get("state"))
assert.Equal(t, nonce, q.Get("nonce"))
assert.Equal(t, challenge, q.Get("code_challenge"))
assert.Equal(t, "S256", q.Get("code_challenge_method"))
// scope 應含 openid email profile順序由 oauth2 lib 決定,用 contains 驗)
scope := q.Get("scope")
for _, s := range []string{"openid", "email", "profile"} {
assert.Truef(t, strings.Contains(scope, s), "scope 應含 %q得 %q", s, scope)
}
// authorization_endpoint 應指向 fake server 的 /authorize
assert.Equal(t, fake.issuer()+"/authorize", u.Scheme+"://"+u.Host+u.Path)
}
func TestExchangeCode_Success(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
verifier, _ := GenerateCodeVerifier()
fake.withState(func() { fake.expectVerifier = verifier })
tok, err := p.ExchangeCode(context.Background(), "fake-code", verifier)
require.NoError(t, err)
assert.Equal(t, "fake-access-token", tok.AccessToken)
assert.NotEmpty(t, tok.IDToken, "id_token 應有值")
assert.Equal(t, "Bearer", tok.TokenType)
assert.Greater(t, tok.ExpiresIn, 0)
}
func TestExchangeCode_PKCEMismatch(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
fake.withState(func() { fake.expectVerifier = "the-real-verifier" })
_, err := p.ExchangeCode(context.Background(), "fake-code", "wrong-verifier")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidGrant, "PKCE 不符應對應到 ErrInvalidGrant")
}
func TestExchangeCode_ServerError(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
fake.withState(func() {
fake.respondCode = http.StatusInternalServerError
fake.respondBody = `{"error":"server_error"}`
})
_, err := p.ExchangeCode(context.Background(), "fake-code", "any")
require.Error(t, err)
assert.ErrorIs(t, err, ErrTokenExchange)
assert.NotErrorIs(t, err, ErrInvalidGrant, "5xx 不應被分類為 invalid_grant")
}
func TestExchangeCode_MissingIDToken(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
fake.withState(func() { fake.skipIDToken = true })
_, err := p.ExchangeCode(context.Background(), "fake-code", "any")
require.Error(t, err)
assert.ErrorIs(t, err, ErrTokenExchange, "缺 id_token 應視為 token_exchange 失敗")
}
func TestVerifyIDToken_HappyPath(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
const expectedNonce = "nonce-happy-path"
now := time.Now()
fake.withState(func() {
fake.idTokenClaims = jwt.Claims{
Issuer: fake.issuer(),
Subject: "user-123",
Audience: jwt.Audience{testClientID},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
}
fake.idTokenExtra = map[string]any{
"email": "alice@example.com",
"name": "Alice",
"nonce": expectedNonce,
}
})
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
require.NoError(t, err)
claims, err := p.VerifyIDToken(context.Background(), tok.IDToken, expectedNonce)
require.NoError(t, err)
assert.Equal(t, "user-123", claims.Subject)
assert.Equal(t, "alice@example.com", claims.Email)
assert.Equal(t, "Alice", claims.Name)
assert.Equal(t, fake.issuer(), claims.Issuer)
assert.Equal(t, testClientID, claims.Audience)
assert.Equal(t, expectedNonce, claims.Nonce)
assert.False(t, claims.ExpiresAt.IsZero())
assert.NotEmpty(t, claims.Raw)
}
func TestVerifyIDToken_WrongNonce(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
now := time.Now()
fake.withState(func() {
fake.idTokenClaims = jwt.Claims{
Issuer: fake.issuer(),
Subject: "user-x",
Audience: jwt.Audience{testClientID},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
}
fake.idTokenExtra = map[string]any{"nonce": "actual-nonce"}
})
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
require.NoError(t, err)
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "expected-different-nonce")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidNonce)
}
func TestVerifyIDToken_WrongAudience(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
now := time.Now()
fake.withState(func() {
fake.idTokenClaims = jwt.Claims{
Issuer: fake.issuer(),
Subject: "user-x",
Audience: jwt.Audience{"some-other-client"}, // 故意錯
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
}
fake.idTokenExtra = map[string]any{"nonce": "n1"}
})
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
require.NoError(t, err)
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "n1")
require.Error(t, err)
// 涵蓋 audience 失敗 → 應映射到 ErrInvalidAudience 或至少 ErrInvalidIDToken
assert.True(t, errors.Is(err, ErrInvalidAudience) || errors.Is(err, ErrInvalidIDToken),
"audience 錯誤應對應到 ErrInvalidAudience或 fallback ErrInvalidIDToken得 %v", err)
}
func TestVerifyIDToken_Expired(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
past := time.Now().Add(-1 * time.Hour)
fake.withState(func() {
fake.idTokenClaims = jwt.Claims{
Issuer: fake.issuer(),
Subject: "user-x",
Audience: jwt.Audience{testClientID},
IssuedAt: jwt.NewNumericDate(past.Add(-5 * time.Minute)),
Expiry: jwt.NewNumericDate(past), // 已過期
}
fake.idTokenExtra = map[string]any{"nonce": "n1"}
})
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
require.NoError(t, err)
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "n1")
require.Error(t, err)
assert.True(t, errors.Is(err, ErrTokenExpired) || errors.Is(err, ErrInvalidIDToken),
"過期應對應到 ErrTokenExpired或 fallback得 %v", err)
}
func TestVerifyIDToken_BadSignature(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
now := time.Now()
fake.withState(func() {
fake.idTokenClaims = jwt.Claims{
Issuer: fake.issuer(),
Subject: "u",
Audience: jwt.Audience{testClientID},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
}
fake.idTokenExtra = map[string]any{"nonce": "n1"}
})
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
require.NoError(t, err)
// 把簽章部分整段替換成「保證無效」的 base64url 字串。
//
// 為什麼不用「翻最後一個字元」base64url 末字元只承載部分原始 bit取決於整段
// 長度對 3 取餘的結果),翻轉某些字元時 padding bit 不變,仍然會解碼出相同的
// 原始 bytes → 簽章值不變 → test 偶發 fail。改翻「中間字元」雖大幅降低風險
// 仍非 0最穩定的作法是直接替換成完全不同的合法 base64url 字串,確保解碼出
// 來的 bytes 與原簽章不同。
parts := strings.Split(tok.IDToken, ".")
require.Len(t, parts, 3)
// 用相同長度的 'A' 串覆寫base64url('A' * n) 解碼結果與原簽章 bytes 不同的機率近乎 100%
// (唯有原簽章本身就剛好全 0 才會碰撞RSA 簽章機率為 0
parts[2] = strings.Repeat("A", len(parts[2]))
tampered := strings.Join(parts, ".")
_, err = p.VerifyIDToken(context.Background(), tampered, "n1")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidIDToken)
}
func TestVerifyIDToken_EmptyInputs(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake)
_, err := p.VerifyIDToken(context.Background(), "", "n1")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidIDToken)
// 空 nonce 也應拒絕caller 必須提供)
now := time.Now()
fake.withState(func() {
fake.idTokenClaims = jwt.Claims{
Issuer: fake.issuer(),
Subject: "u",
Audience: jwt.Audience{testClientID},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(5 * time.Minute)),
}
fake.idTokenExtra = map[string]any{"nonce": "actual"}
})
tok, err := p.ExchangeCode(context.Background(), "code", "verifier")
require.NoError(t, err)
_, err = p.VerifyIDToken(context.Background(), tok.IDToken, "")
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidNonce)
}
// 確保 NewProvider 套用預設 Scopescaller 沒填時)
func TestProviderConfig_DefaultScopes(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
ctx := context.Background()
cfg := ProviderConfig{
IssuerURL: fake.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret,
RedirectURL: testRedirect,
// 不填 Scopes
}
p, err := NewProvider(ctx, cfg)
require.NoError(t, err)
state, _ := GenerateState()
nonce, _ := GenerateNonce()
verifier, _ := GenerateCodeVerifier()
raw := p.AuthorizationURL(state, nonce, CodeChallenge(verifier))
u, _ := url.Parse(raw)
scope := u.Query().Get("scope")
for _, s := range DefaultScopes {
assert.Truef(t, strings.Contains(scope, s), "預設 scope 應含 %q得 %q", s, scope)
}
}
// =====================================================================
// A1Public PKCE-only client mode 測試
// =====================================================================
// TestNewProvider_PublicClient 驗 ClientSecret 留空時能成功初始化 Provider
// 且行為與 confidential client 等價(除了 token request 的 auth method 不同)。
func TestNewProvider_PublicClient(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := NewProvider(ctx, ProviderConfig{
IssuerURL: fake.issuer(),
ClientID: testClientID,
// ClientSecret 故意留空 — A1public PKCE-only client mode
RedirectURL: testRedirect,
})
require.NoError(t, err)
require.NotNil(t, p)
// AuthorizationURL 應含 PKCE 參數但**不含** client_secretOAuth 規格上 authorize 端點本來就不該帶)
state, _ := GenerateState()
nonce, _ := GenerateNonce()
verifier, _ := GenerateCodeVerifier()
authURL := p.AuthorizationURL(state, nonce, CodeChallenge(verifier))
u, err := url.Parse(authURL)
require.NoError(t, err)
assert.Empty(t, u.Query().Get("client_secret"), "authorize URL 不應出現 client_secret")
assert.Equal(t, "S256", u.Query().Get("code_challenge_method"))
}
// TestNewProvider_ConfidentialClient 是 baseline確認既有 confidential mode 仍能初始化。
// 既有 TestNewProvider_Discovery 其實已涵蓋此情境,這個測試明示「兩種 mode 共存」。
func TestNewProvider_ConfidentialClient(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := NewProvider(ctx, ProviderConfig{
IssuerURL: fake.issuer(),
ClientID: testClientID,
ClientSecret: testClientSecret, // 有值 → confidential
RedirectURL: testRedirect,
})
require.NoError(t, err)
require.NotNil(t, p)
}
// TestExchangeCode_PublicClientNoSecretSent 驗 public client mode 下,
// /token request 的 form 不含 client_secret 欄位、且 Authorization header 不是 Basic auth。
//
// 這是 A1 改造的核心驗證oauth2 lib 在 ClientSecret="" + AuthStyleInParams 時,
// 完全不送 client_secret符合 RFC 6749 §2.3.1 對 public client 的規範)。
func TestExchangeCode_PublicClientNoSecretSent(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
p, err := NewProvider(ctx, ProviderConfig{
IssuerURL: fake.issuer(),
ClientID: testClientID,
// ClientSecret 留空 → public PKCE-only client
RedirectURL: testRedirect,
})
require.NoError(t, err)
verifier, _ := GenerateCodeVerifier()
fake.withState(func() { fake.expectVerifier = verifier })
tok, err := p.ExchangeCode(context.Background(), "fake-code", verifier)
require.NoError(t, err)
assert.NotEmpty(t, tok.IDToken)
// 取出 fake server 觀察到的 token request
var (
form url.Values
auth string
ok bool
)
fake.withState(func() {
form = fake.lastTokenForm
auth = fake.lastTokenAuthHdr
ok = form != nil
})
require.True(t, ok, "fake server 應該已收到一次 token request")
// public client 應該:
// - form 帶 client_id 但**不帶** client_secret
// - 不送 Authorization: Basic headerAuthorization 應為空字串)
// - 帶 code_verifierPKCE proof
assert.Equal(t, testClientID, form.Get("client_id"), "public client 仍需在 form 帶 client_id")
assert.Empty(t, form.Get("client_secret"), "public client 不應送 client_secret form 欄位")
assert.Empty(t, auth, "public client 不應送 Authorization header更不應是 Basic")
assert.Equal(t, verifier, form.Get("code_verifier"), "PKCE verifier 必須帶")
}
// TestExchangeCode_ConfidentialClientSendsSecret 對照組:
// confidential client mode 下,/token request 必須帶 client_secret或 Basic auth
func TestExchangeCode_ConfidentialClientSendsSecret(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
p := newProviderForTest(t, fake) // 用既有 helper帶 testClientSecret
verifier, _ := GenerateCodeVerifier()
fake.withState(func() { fake.expectVerifier = verifier })
_, err := p.ExchangeCode(context.Background(), "fake-code", verifier)
require.NoError(t, err)
var (
form url.Values
auth string
)
fake.withState(func() {
form = fake.lastTokenForm
auth = fake.lastTokenAuthHdr
})
// confidential 可以走兩種Basic auth header或 form 帶 client_secret。
// oauth2 lib 預設先試 InHeader → 第一輪通常是 Basic。
// 我們不挑路徑,只要「至少一邊」帶 secret 就算正確。
hasFormSecret := form.Get("client_secret") == testClientSecret
hasBasicAuth := strings.HasPrefix(auth, "Basic ")
assert.Truef(t, hasFormSecret || hasBasicAuth,
"confidential client 應透過 form 或 Basic auth 帶 secretform=%v auth=%q",
form, auth)
}
// sanity checkfakeOIDC 自身沒寫錯(簽出來的 token coreos 能驗)
func TestFakeOIDC_SelfSignedTokenIsValid(t *testing.T) {
fake := newFakeOIDC(t, testClientID)
now := time.Now()
std := jwt.Claims{
Issuer: fake.issuer(),
Subject: "self-test",
Audience: jwt.Audience{testClientID},
IssuedAt: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(now.Add(time.Minute)),
}
tok, err := signIDToken(fake.signingKey, fake.keyID, jose.RS256, std,
map[string]any{"nonce": "test-nonce"})
require.NoError(t, err)
assert.True(t, strings.Count(tok, ".") == 2, "JWT 應有 3 段")
// 確保非空 payload
parts := strings.SplitN(tok, ".", 3)
require.Len(t, parts, 3)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
require.NoError(t, err)
assert.Contains(t, string(payload), "self-test")
assert.Contains(t, string(payload), "test-nonce")
// 也驗證 fmt 可印(避免 unused import 'fmt' 在小幅 refactor 後消失)
_ = fmt.Sprintf("%s", tok)
}