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-jose(coreos/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 code(0 = 200) respondBody string // 非空時直接回此 body 取代正常 token response idTokenClaims jwt.Claims // 自訂簽 token 的 standard claims(zero = 預設) idTokenExtra map[string]any idTokenAlg jose.SignatureAlgorithm // 預設 RS256 skipIDToken bool // true 時 token response 不含 id_token // 觀測:最後一次 /token 收到的 form / Authorization header(A1 加:驗 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_token(JWS / 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 通常是 65537(0x010001)= 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) { // A1(2026-05-01):ClientSecret 為選填,因此 "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 套用預設 Scopes(caller 沒填時) 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) } } // ===================================================================== // A1:Public 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 故意留空 — A1:public PKCE-only client mode RedirectURL: testRedirect, }) require.NoError(t, err) require.NotNil(t, p) // AuthorizationURL 應含 PKCE 參數但**不含** client_secret(OAuth 規格上 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 header(Authorization 應為空字串) // - 帶 code_verifier(PKCE 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 帶 secret;form=%v auth=%q", form, auth) } // sanity check:fakeOIDC 自身沒寫錯(簽出來的 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) }