// 外部測試套件:見 jwks_test.go 開頭說明。 package oidc_test import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "alterminal/internal/oidc" ) // getUserinfo 以 Bearer token 呼叫 /userinfo。 func getUserinfo(h http.HandlerFunc, token string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "/userinfo", nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } rec := httptest.NewRecorder() h(rec, req) return rec } // obtainAccessToken 走完授權碼流程(PKCE)並回傳 access token。 func obtainAccessToken(t *testing.T, e *testEnv, scope string) string { t.Helper() _, code := consentAllow(t, e, authorizeQuery(e.app, scope, "", "", testChallenge)) form := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {e.app.RedirectURIs[0]}, "code_verifier": {testVerifier}, } rec := postToken(oidc.TokenHandler(e.db, testIssuer), form, 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) return body.AccessToken } // 有效 token 回依 scope 的 claims(OIDC Core §5.4)。 func TestUserInfoClaims(t *testing.T) { e := newTestEnv(t) h := oidc.UserInfoHandler(e.db, testIssuer) t.Run("profile 與 email scope", func(t *testing.T) { token := obtainAccessToken(t, e, "openid profile email") rec := getUserinfo(h, token) if rec.Code != http.StatusOK { t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) } var u userInfoBody if err := json.Unmarshal(rec.Body.Bytes(), &u); err != nil { t.Fatal("解析回應: ", err) } if u.Sub != subjectOf(e.user.ID) { t.Errorf("sub = %q, want %q", u.Sub, subjectOf(e.user.ID)) } if u.Name != e.user.Name || u.PreferredUsername != e.user.Username { t.Errorf("profile claims = %q/%q", u.Name, u.PreferredUsername) } if u.Email != e.user.Email || u.EmailVerified == nil || !*u.EmailVerified { t.Errorf("email claims = %q/%v", u.Email, u.EmailVerified) } }) t.Run("僅 openid 不含個人資料 claims", func(t *testing.T) { token := obtainAccessToken(t, e, "openid") rec := getUserinfo(h, token) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } body := rec.Body.String() if strings.Contains(body, `"email"`) || strings.Contains(body, `"name"`) { t.Errorf("未授權的 scope 不應輸出對應 claim: %s", body) } }) } // token 缺失、無效或授權不足時的錯誤回應(RFC 6750 §3)。 func TestUserInfoErrors(t *testing.T) { e := newTestEnv(t) h := oidc.UserInfoHandler(e.db, testIssuer) t.Run("缺少 token 回 401 與 WWW-Authenticate", func(t *testing.T) { rec := getUserinfo(h, "") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if wa := rec.Header().Get("WWW-Authenticate"); !strings.HasPrefix(wa, "Bearer") { t.Errorf("WWW-Authenticate = %q", wa) } }) t.Run("無效 token 回 401 invalid_token", func(t *testing.T) { rec := getUserinfo(h, "not-a-token") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, `error="invalid_token"`) { t.Errorf("WWW-Authenticate = %q", wa) } }) t.Run("scope 無 openid 回 403", func(t *testing.T) { // 手造僅 profile scope 的 token(正式流程必含 openid,此處模擬 // 其他來源的 token)。 token := forgeJWT(t, e.key, map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"}, oidc.AccessTokenClaims{ Iss: testIssuer, Sub: subjectOf(e.user.ID), Aud: e.app.ClientID, Exp: time.Now().Add(time.Hour).Unix(), Iat: time.Now().Unix(), Scope: "profile", ClientID: e.app.ClientID, }) rec := getUserinfo(h, token) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String()) } if wa := rec.Header().Get("WWW-Authenticate"); !strings.Contains(wa, "insufficient_scope") { t.Errorf("WWW-Authenticate = %q", wa) } }) }