forked from alterminal/alterminal
129 lines
4.1 KiB
Go
129 lines
4.1 KiB
Go
// 外部測試套件:見 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)
|
||
}
|
||
})
|
||
}
|