first commit

This commit is contained in:
2026-10-03 10:44:29 +08:00
parent f373cb8d37
commit bcf3d3769c
58 changed files with 4313 additions and 487 deletions
+378
View File
@@ -0,0 +1,378 @@
// 外部測試套件:見 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)
}
}