// 外部測試套件:見 jwks_test.go 開頭說明。 package oidc_test import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "testing" "time" "alterminal/internal/oidc" "alterminal/internal/testdb" ) // RFC 7636 附錄 B 的官方測試向量:code_verifier 與其 S256 challenge。 const ( testVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" testChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" wrongVerifier = "wJ-B4LdB4kNOXK32ONwPccn9YMHcGgnbHDB1jXtsCXc" // 格式合法但與 challenge 不符 ) // tokenBody 為成功回應的斷言結構。 type tokenBody struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` Scope string `json:"scope"` IDToken string `json:"id_token"` RefreshToken string `json:"refresh_token"` } // exchangeCode 兌換授權碼,回傳記錄器。basic=true 時以 HTTP Basic 認證 // (表單不帶 client 欄位),否則以 client_secret_post 送出。 func exchangeCode(h http.HandlerFunc, code, redirectURI, clientID, clientSecret, verifier string, basic bool) *httptest.ResponseRecorder { form := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, } if verifier != "" { form.Set("code_verifier", verifier) } if basic { return postToken(h, form, clientID, clientSecret) } form.Set("client_id", clientID) if clientSecret != "" { form.Set("client_secret", clientSecret) } return postToken(h, form, "", "") } // 完整兌換:機密式 Client + PKCE + Basic 認證,核發 Access/ID/Refresh // Token,ID token 各 claim 依授權內容簽入(OIDC Core §3.1.3.3、§5.4)。 func TestTokenAuthorizationCodeFull(t *testing.T) { e := newTestEnv(t) h := oidc.TokenHandler(e.db, testIssuer) _, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile email offline_access", "xyz", "nonce-42", testChallenge)) rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) } if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { t.Errorf("Cache-Control = %q, want no-store", cc) } var body tokenBody if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatal("解析回應: ", err) } if body.AccessToken == "" || body.TokenType != "Bearer" || body.ExpiresIn != 900 { t.Errorf("access token 欄位不符: %+v", body) } if body.Scope != "email offline_access openid profile" { t.Errorf("scope = %q(應為正規化排序形式)", body.Scope) } if body.IDToken == "" { t.Fatal("scope 含 openid 應核發 id_token") } if body.RefreshToken == "" { t.Fatal("scope 含 offline_access 應核發 refresh_token") } _, payload := jwtParts(t, body.IDToken) var idc idTokenClaims if err := json.Unmarshal(payload, &idc); err != nil { t.Fatal("解析 ID token: ", err) } if idc.Iss != testIssuer || idc.Aud != e.app.ClientID { t.Errorf("iss/aud = %q/%q", idc.Iss, idc.Aud) } if idc.Sub != subjectOf(e.user.ID) { t.Errorf("sub = %q, want %q", idc.Sub, subjectOf(e.user.ID)) } if idc.Nonce != "nonce-42" { t.Errorf("nonce = %q, want nonce-42", idc.Nonce) } if idc.AuthTime == 0 { t.Error("auth_time 應簽入 Session 建立時間") } if idc.Name != e.user.Name || idc.Email != e.user.Email || idc.EmailVerf == nil || !*idc.EmailVerf { t.Errorf("profile/email claims 不符: %+v", idc) } // 無 offline_access 的 scope 不應拿到 refresh token。 _, code2 := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge)) rec = exchangeCode(h, code2, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, false) if rec.Code != http.StatusOK { t.Fatalf("第二次兌換 status = %d, body = %s", rec.Code, rec.Body.String()) } var body2 tokenBody json.Unmarshal(rec.Body.Bytes(), &body2) if body2.RefreshToken != "" { t.Error("未請求 offline_access 不應核發 refresh_token") } if body2.IDToken == "" { t.Error("scope 含 openid 應核發 id_token") } } // 公開式 Client 無 secret,以 PKCE 兌換(client_secret_post 欄位不送)。 func TestTokenPublicClientPKCE(t *testing.T) { e := newTestEnv(t) h := oidc.TokenHandler(e.db, testIssuer) _, code := consentAllow(t, e, authorizeQuery(e.pub, "openid", "", "", testChallenge)) rec := exchangeCode(h, code, e.pub.RedirectURIs[0], e.pub.ClientID, "", testVerifier, false) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200, body = %s", rec.Code, rec.Body.String()) } var body tokenBody json.Unmarshal(rec.Body.Bytes(), &body) if body.AccessToken == "" { t.Fatal("應核發 access_token") } } // client 認證失敗與參數錯誤。 func TestTokenClientAuthentication(t *testing.T) { e := newTestEnv(t) h := oidc.TokenHandler(e.db, testIssuer) t.Run("client secret 錯誤回 401 invalid_client", func(t *testing.T) { _, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge)) rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, "wrong-secret", testVerifier, true) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if got := decodeTokenError(t, rec).Error; got != "invalid_client" { t.Errorf("error = %q, want invalid_client", got) } if rec.Header().Get("WWW-Authenticate") == "" { t.Error("Basic 認證失敗應附 WWW-Authenticate") } }) t.Run("未知 client_id 回 401", func(t *testing.T) { rec := exchangeCode(h, "any", e.app.RedirectURIs[0], "no-such", "x", "", false) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } }) t.Run("Basic 與表單 client_id 不一致", func(t *testing.T) { form := url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {e.app.ClientID}} rec := postToken(h, form, "no-such", "secret") if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } if got := decodeTokenError(t, rec).Error; got != "invalid_request" { t.Errorf("error = %q, want invalid_request", got) } }) t.Run("不支援的 grant_type", func(t *testing.T) { form := url.Values{"grant_type": {"password"}, "client_id": {e.app.ClientID}, "client_secret": {e.secret}} rec := postToken(h, form, "", "") if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } if got := decodeTokenError(t, rec).Error; got != "unsupported_grant_type" { t.Errorf("error = %q", got) } }) t.Run("Content-Type 非 form 回 400", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/token", nil) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() h(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400", rec.Code) } }) } // 授權碼兌換的條件比對與一次性(RFC 6749 §4.1.3)。 func TestTokenCodeRedemptionErrors(t *testing.T) { e := newTestEnv(t) h := oidc.TokenHandler(e.db, testIssuer) redirectURI := e.app.RedirectURIs[0] mustCode := func(t *testing.T) string { _, code := consentAllow(t, e, authorizeQuery(e.app, "openid", "", "", testChallenge)) return code } t.Run("code_verifier 不符回 invalid_grant", func(t *testing.T) { rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, wrongVerifier, true) if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String()) } }) t.Run("code_verifier 格式無效回 invalid_request", func(t *testing.T) { rec := exchangeCode(h, mustCode(t), redirectURI, e.app.ClientID, e.secret, "short", true) if got := decodeTokenError(t, rec).Error; got != "invalid_request" { t.Fatalf("error = %q, want invalid_request", got) } }) t.Run("redirect_uri 與發碼時不符回 invalid_grant", func(t *testing.T) { rec := exchangeCode(h, mustCode(t), "https://rp.example/other", e.app.ClientID, e.secret, testVerifier, true) if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("error = %q, want invalid_grant", got) } }) t.Run("換別的 client 也回 invalid_grant", func(t *testing.T) { rec := exchangeCode(h, mustCode(t), redirectURI, e.pub.ClientID, "", testVerifier, false) if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("error = %q, want invalid_grant", got) } }) t.Run("不存在的 code", func(t *testing.T) { rec := exchangeCode(h, "no-such-code", redirectURI, e.app.ClientID, e.secret, "", true) if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("error = %q, want invalid_grant", got) } }) t.Run("重用撤銷其 refresh token", func(t *testing.T) { code := mustCode(t) form := url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {redirectURI}, "code_verifier": {testVerifier}} rec := postToken(h, form, e.app.ClientID, e.secret) if rec.Code != http.StatusOK { t.Fatalf("首次兌換失敗: %s", rec.Body.String()) } var first tokenBody json.Unmarshal(rec.Body.Bytes(), &first) // 同一碼再兌換:invalid_grant,且首次拿到的 refresh token 應被撤銷。 rec = postToken(h, form, e.app.ClientID, e.secret) if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("重用 error = %q, want invalid_grant", got) } refreshForm := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {first.RefreshToken}} rec = postToken(h, refreshForm, e.app.ClientID, e.secret) if rec.Code != http.StatusBadRequest { t.Fatalf("被撤銷的 refresh token 不應可用: %s", rec.Body.String()) } }) } // Refresh token 輪替與重用整鏈撤銷(OAuth 2.0 Security BCP §4.14.2)。 func TestTokenRefreshRotationAndReuse(t *testing.T) { e := newTestEnv(t) h := oidc.TokenHandler(e.db, testIssuer) // 取得一組含 offline_access 的權杖。 _, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", testChallenge)) rec := exchangeCode(h, code, e.app.RedirectURIs[0], e.app.ClientID, e.secret, testVerifier, true) if rec.Code != http.StatusOK { t.Fatalf("兌換失敗: %s", rec.Body.String()) } var first tokenBody json.Unmarshal(rec.Body.Bytes(), &first) refresh := func(token, scope string) *httptest.ResponseRecorder { form := url.Values{"grant_type": {"refresh_token"}, "refresh_token": {token}} if scope != "" { form.Set("scope", scope) } return postToken(h, form, e.app.ClientID, e.secret) } t.Run("輪替發新權杖組", func(t *testing.T) { rec := refresh(first.RefreshToken, "") if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } var second tokenBody json.Unmarshal(rec.Body.Bytes(), &second) if second.AccessToken == "" || second.RefreshToken == "" || second.RefreshToken == first.RefreshToken { t.Fatalf("應核發新的 access 與 refresh token: %+v", second) } if second.Scope != "offline_access openid profile" { t.Errorf("scope 應沿用原授權: %q", second.Scope) } if second.IDToken == "" { t.Error("原 scope 含 openid 應續發 id_token") } // 舊 token 重用:invalid_grant,且整鏈(含新 token)撤銷。 rec = refresh(first.RefreshToken, "") if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("重用 error = %q, body = %s", got, rec.Body.String()) } rec = refresh(second.RefreshToken, "") if rec.Code != http.StatusBadRequest { t.Fatalf("重用偵測後整鏈應撤銷(新 token 亦不可用): %s", rec.Body.String()) } }) t.Run("scope 僅可縮小", func(t *testing.T) { // 取一組原授權為「openid profile offline_access」的鏈。 _, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile offline_access", "", "", "")) rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret) if rec.Code != http.StatusOK { t.Fatalf("兌換失敗: %s", rec.Body.String()) } var body tokenBody json.Unmarshal(rec.Body.Bytes(), &body) // 縮小為不含 profile:成功,新鏈的授權範圍即縮小後的值。 rec = refresh(body.RefreshToken, "openid offline_access") if rec.Code != http.StatusOK { t.Fatalf("縮小 scope 應成功: %s", rec.Body.String()) } var narrowed tokenBody json.Unmarshal(rec.Body.Bytes(), &narrowed) if narrowed.Scope != "offline_access openid" { t.Errorf("縮小後 scope = %q", narrowed.Scope) } // 對縮小後的鏈再請求原範圍(含 profile)即為擴大:invalid_scope。 rec = refresh(narrowed.RefreshToken, "openid profile offline_access") if got := decodeTokenError(t, rec).Error; got != "invalid_scope" { t.Fatalf("擴大 scope error = %q, want invalid_scope, body = %s", got, rec.Body.String()) } }) t.Run("過期 refresh token 回 invalid_grant", func(t *testing.T) { _, code := consentAllow(t, e, authorizeQuery(e.app, "openid offline_access", "", "", "")) rec := postToken(h, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}}, e.app.ClientID, e.secret) var body tokenBody json.Unmarshal(rec.Body.Bytes(), &body) // 直接把最新一筆 refresh token 的效期改為過去。 if err := e.db.Model(&oidc.RefreshToken{}). Where("id = (SELECT MAX(id) FROM refresh_tokens)"). Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { t.Fatal(err) } rec = refresh(body.RefreshToken, "") if got := decodeTokenError(t, rec).Error; got != "invalid_grant" { t.Fatalf("error = %q, want invalid_grant, body = %s", got, rec.Body.String()) } }) t.Run("未啟用 refresh grant 的應用回 unauthorized_client", func(t *testing.T) { rec := postToken(h, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {"x"}}, e.pub.ClientID, "") if got := decodeTokenError(t, rec).Error; got != "unauthorized_client" { t.Fatalf("error = %q, want unauthorized_client", got) } }) } // 空資料庫時 token 端點仍應正常拒絕(不 panic)。 func TestTokenHandlerEmptyDB(t *testing.T) { db := testdb.New(t) rec := postToken(oidc.TokenHandler(db, testIssuer), url.Values{"grant_type": {"authorization_code"}, "code": {"x"}, "client_id": {"nobody"}, "client_secret": {"s"}}, "", "") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } }