// Package oidctest 的自我驗證測試。 // // 這個檔案只測 oidctest package「自己」的行為,不涉及 visionA-backend 任何 production code。 // 目的:在 oidctest 被 e2e test 大量依賴之前,先單獨確保它每個 endpoint 都符合預期 — // 否則 e2e test 失敗時很難區分「OIDC client 寫錯」還是「fake server 寫錯」。 // // 不重複測 OB1 的 internal/oidc/provider_test.go 已涵蓋的「provider 串接 fake server」場景, // 這邊純粹從 HTTP wire 層驗 fake server 的對外 contract。 package oidctest import ( "context" "crypto/rsa" "encoding/base64" "encoding/json" "io" "math/big" "net/http" "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" ) const ( testRedirectURI = "http://localhost:8080/api/auth/callback" ) // ───────────────────────── Discovery ───────────────────────── func TestServer_Discovery_Endpoints(t *testing.T) { srv := NewServer(t) resp, err := http.Get(srv.URL + "/.well-known/openid-configuration") require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var doc map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&doc)) // issuer 必須等於 server URL(除非 caller 用 WithIssuer 覆蓋) assert.Equal(t, srv.URL, doc["issuer"]) // 各 endpoint 都應指向 server URL assert.Equal(t, srv.URL+"/authorize", doc["authorization_endpoint"]) assert.Equal(t, srv.URL+"/oauth/token", doc["token_endpoint"]) assert.Equal(t, srv.URL+"/jwks", doc["jwks_uri"]) // 必要支援列表 assert.Contains(t, doc["response_types_supported"], "code") assert.Contains(t, doc["id_token_signing_alg_values_supported"], "RS256") assert.Contains(t, doc["code_challenge_methods_supported"], "S256") } func TestServer_Discovery_RespectsWithIssuer(t *testing.T) { const customIssuer = "https://example.com/custom-issuer" srv := NewServer(t, WithIssuer(customIssuer)) resp, err := http.Get(srv.URL + "/.well-known/openid-configuration") require.NoError(t, err) defer resp.Body.Close() var doc map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&doc)) assert.Equal(t, customIssuer, doc["issuer"]) } // ───────────────────────── JWKS ───────────────────────── // TestServer_JWKS_CanVerifyServerSignedToken 確認 JWKS endpoint 公佈的 public key // 真的能驗 fake server 用 IssueIDToken 簽出來的 token。這是 fake server 整體 contract // 中「最關鍵」的一環 — 一旦這條路斷掉,所有 e2e test 都會失敗。 func TestServer_JWKS_CanVerifyServerSignedToken(t *testing.T) { srv := NewServer(t) // 1. 從 JWKS 取 public key resp, err := http.Get(srv.URL + "/jwks") require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var jwks struct { Keys []map[string]any `json:"keys"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&jwks)) require.Len(t, jwks.Keys, 1) jwk := jwks.Keys[0] assert.Equal(t, "RSA", jwk["kty"]) assert.Equal(t, "RS256", jwk["alg"]) assert.Equal(t, srv.KeyID, jwk["kid"]) // 2. 把 JWK reconstruct 成 *rsa.PublicKey pub := jwkToRSAPublicKey(t, jwk) // 3. 請 server 簽一個 token tok, err := srv.IssueIDToken(map[string]any{ "sub": "verify-test", "iss": srv.URL, "aud": srv.ClientID, "exp": time.Now().Add(time.Minute).Unix(), }) require.NoError(t, err) // 4. 用 jose 驗簽 parsed, err := jwt.ParseSigned(tok, []jose.SignatureAlgorithm{jose.RS256}) require.NoError(t, err, "簽出的 token 應為合法 JWS") var out map[string]any require.NoError(t, parsed.Claims(pub, &out), "JWKS 公開的 public key 應能驗 server 自己簽的 token") assert.Equal(t, "verify-test", out["sub"]) } // ───────────────────────── /oauth/token ───────────────────────── func TestServer_Token_RejectsWrongClientSecret(t *testing.T) { srv := NewServer(t) // 先模擬 authorize 拿一個 code(避免 invalid_grant 先擋掉) verifier := "verifier-xyz-1234567890123456789012345" challenge := pkceS256(verifier) code, err := srv.IssueAuthCode(challenge, "S256", "n-1", testRedirectURI) require.NoError(t, err) // 用「錯的 client_secret」打 token endpoint resp := postToken(t, srv, url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {verifier}, "client_id": {srv.ClientID}, "client_secret": {"wrong-secret"}, }) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) var errBody map[string]string require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) assert.Equal(t, "invalid_client", errBody["error"]) } func TestServer_Token_AcceptsBasicAuth(t *testing.T) { srv := NewServer(t) verifier := "verifier-basicauth-12345678901234567890123" challenge := pkceS256(verifier) code, err := srv.IssueAuthCode(challenge, "S256", "n-2", testRedirectURI) require.NoError(t, err) form := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {verifier}, } req, err := http.NewRequest(http.MethodPost, srv.URL+"/oauth/token", strings.NewReader(form.Encode())) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(srv.ClientID, srv.ClientSecret) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode, "Basic auth 應被接受") var tokResp map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokResp)) assert.NotEmpty(t, tokResp["id_token"]) assert.NotEmpty(t, tokResp["access_token"]) } func TestServer_Token_PKCEMatch(t *testing.T) { srv := NewServer(t) verifier := "verifier-good-1234567890abcdefghij1234567" challenge := pkceS256(verifier) code, err := srv.IssueAuthCode(challenge, "S256", "nonce-good", testRedirectURI) require.NoError(t, err) resp := postToken(t, srv, url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {verifier}, "client_id": {srv.ClientID}, "client_secret": {srv.ClientSecret}, }) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var tokResp map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokResp)) assert.NotEmpty(t, tokResp["id_token"]) // nonce 應被灌到 id_token claims 裡 idToken := tokResp["id_token"].(string) claims := decodeJWTPayload(t, idToken) assert.Equal(t, "nonce-good", claims["nonce"]) } func TestServer_Token_PKCEMismatch(t *testing.T) { srv := NewServer(t) correct := "verifier-A-1234567890abcdefghij12345678" wrong := "verifier-B-1234567890abcdefghij12345678" code, err := srv.IssueAuthCode(pkceS256(correct), "S256", "n-3", testRedirectURI) require.NoError(t, err) resp := postToken(t, srv, url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {wrong}, // 故意錯 "client_id": {srv.ClientID}, "client_secret": {srv.ClientSecret}, }) defer resp.Body.Close() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) var errBody map[string]string require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody)) assert.Equal(t, "invalid_grant", errBody["error"]) } func TestServer_Token_CodeIsOneTimeUse(t *testing.T) { srv := NewServer(t) verifier := "verifier-once-1234567890abcdefghij12345" code, err := srv.IssueAuthCode(pkceS256(verifier), "S256", "n-4", testRedirectURI) require.NoError(t, err) form := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {verifier}, "client_id": {srv.ClientID}, "client_secret": {srv.ClientSecret}, } // 第一次:成功 r1 := postToken(t, srv, form) require.Equal(t, http.StatusOK, r1.StatusCode) r1.Body.Close() // 第二次:同個 code,invalid_grant r2 := postToken(t, srv, form) defer r2.Body.Close() assert.Equal(t, http.StatusBadRequest, r2.StatusCode) var errBody map[string]string require.NoError(t, json.NewDecoder(r2.Body).Decode(&errBody)) assert.Equal(t, "invalid_grant", errBody["error"]) } func TestServer_Token_AppliesNextIDTokenClaims(t *testing.T) { srv := NewServer(t) srv.SetNextIDTokenClaims(map[string]any{ "sub": "user-overridden", "email": "override@example.com", "name": "Override User", }) verifier := "verifier-claims-1234567890abcdefghij1234" code, err := srv.IssueAuthCode(pkceS256(verifier), "S256", "nonce-claims", testRedirectURI) require.NoError(t, err) resp := postToken(t, srv, url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {verifier}, "client_id": {srv.ClientID}, "client_secret": {srv.ClientSecret}, }) defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) var tokResp map[string]any require.NoError(t, json.NewDecoder(resp.Body).Decode(&tokResp)) claims := decodeJWTPayload(t, tokResp["id_token"].(string)) assert.Equal(t, "user-overridden", claims["sub"]) assert.Equal(t, "override@example.com", claims["email"]) assert.Equal(t, "Override User", claims["name"]) assert.Equal(t, "nonce-claims", claims["nonce"], "nonce 仍會自動補入") } // ───────────────────────── /authorize ───────────────────────── func TestServer_Authorize_RedirectsWithCodeAndState(t *testing.T) { srv := NewServer(t) authorizeURL := srv.URL + "/authorize?" + url.Values{ "response_type": {"code"}, "client_id": {srv.ClientID}, "redirect_uri": {testRedirectURI}, "scope": {"openid email profile"}, "state": {"state-abc"}, "code_challenge": {pkceS256("any-verifier")}, "code_challenge_method": {"S256"}, "nonce": {"nonce-abc"}, }.Encode() cb := srv.SimulateAuthorizationFlow(t, authorizeURL) u, err := url.Parse(cb) require.NoError(t, err) // callback 應為 redirect_uri,帶 code & state assert.Equal(t, "http", u.Scheme) assert.Equal(t, "localhost:8080", u.Host) assert.Equal(t, "/api/auth/callback", u.Path) assert.NotEmpty(t, u.Query().Get("code")) assert.Equal(t, "state-abc", u.Query().Get("state")) // LastAuthorizeQuery 應記下 caller 帶的 PKCE / nonce q := srv.LastAuthorizeQuery() assert.Equal(t, "state-abc", q.Get("state")) assert.Equal(t, "nonce-abc", q.Get("nonce")) assert.Equal(t, "S256", q.Get("code_challenge_method")) } func TestServer_Authorize_MissingRedirectURIReturns400(t *testing.T) { srv := NewServer(t) // 沒帶 redirect_uri resp, err := http.Get(srv.URL + "/authorize?client_id=" + srv.ClientID) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } // ───────────────────────── WithClientCredentials ───────────────────────── func TestServer_WithClientCredentials(t *testing.T) { srv := NewServer(t, WithClientCredentials("custom-client", "custom-secret"), ) assert.Equal(t, "custom-client", srv.ClientID) assert.Equal(t, "custom-secret", srv.ClientSecret) // 用預設 secret 應失敗 verifier := "verifier-cc-1234567890abcdefghij12345678" code, err := srv.IssueAuthCode(pkceS256(verifier), "S256", "n", testRedirectURI) require.NoError(t, err) resp := postToken(t, srv, url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {testRedirectURI}, "code_verifier": {verifier}, "client_id": {"custom-client"}, "client_secret": {"test-secret"}, // 預設值,不是 custom-secret }) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) } // ───────────────────────── helpers ───────────────────────── func postToken(t *testing.T, srv *Server, form url.Values) *http.Response { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) t.Cleanup(cancel) req, err := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/oauth/token", strings.NewReader(form.Encode())) require.NoError(t, err) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := http.DefaultClient.Do(req) require.NoError(t, err) return resp } // jwkToRSAPublicKey 把 JWK map reconstruct 成 *rsa.PublicKey。 // 這是 OB1 fakeOIDC 沒做的(OB1 直接用 coreos lib 內部把 JWKS 解出來), // 我們用最少程式碼自己 decode 一次驗證 server 的 JWKS contract。 func jwkToRSAPublicKey(t *testing.T, jwk map[string]any) *rsa.PublicKey { t.Helper() nB64, _ := jwk["n"].(string) eB64, _ := jwk["e"].(string) require.NotEmpty(t, nB64) require.NotEmpty(t, eB64) nBytes, err := base64.RawURLEncoding.DecodeString(nB64) require.NoError(t, err) eBytes, err := base64.RawURLEncoding.DecodeString(eB64) require.NoError(t, err) // e 是大端 byte slice → int e := 0 for _, b := range eBytes { e = e<<8 | int(b) } n := new(big.Int).SetBytes(nBytes) return &rsa.PublicKey{N: n, E: e} } // decodeJWTPayload 取 JWT 中間段的 payload 解 JSON 出 claims。 // 不驗簽(呼叫者已在別處驗過簽章)。 func decodeJWTPayload(t *testing.T, tok string) map[string]any { t.Helper() parts := strings.Split(tok, ".") require.Len(t, parts, 3, "JWT 應為 3 段 (header.payload.signature)") raw, err := base64.RawURLEncoding.DecodeString(parts[1]) require.NoError(t, err) var out map[string]any require.NoError(t, json.Unmarshal(raw, &out)) return out } // 確保 io 沒被當成 unused(某些版本 lint 嚴苛)— 故意使用一次。 var _ = io.EOF