Files
alterminal/internal/oidc/token_test.go
T
2026-10-03 10:44:29 +08:00

379 lines
14 KiB
Go
Raw 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.
// 外部測試套件:見 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)
}
}