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
+235
View File
@@ -0,0 +1,235 @@
// 外部測試套件:見 jwks_test.go 開頭說明。
package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"alterminal/internal/oidc"
)
// client_id 或 redirect_uri 無法確認時不得重導(RFC 6749 §4.1.2.1),
// 直接回 400 錯誤頁。
func TestAuthorizeRejectsWithoutRedirect(t *testing.T) {
e := newTestEnv(t)
h := oidc.AuthorizeHandler(e.db)
t.Run("未知 client_id", func(t *testing.T) {
q := authorizeQuery(e.app, "openid", "", "", "")
q = strings.Replace(q, url.QueryEscape(e.app.ClientID), url.QueryEscape("no-such-client"), 1)
rec := getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if rec.Header().Get("Location") != "" {
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
}
})
t.Run("redirect_uri 未註冊", func(t *testing.T) {
q := authorizeQuery(e.app, "openid", "", "", "")
q = strings.Replace(q, url.QueryEscape(e.app.RedirectURIs[0]), url.QueryEscape("https://evil.example/cb"), 1)
rec := getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if rec.Header().Get("Location") != "" {
t.Fatalf("不得重導: %s", rec.Header().Get("Location"))
}
})
}
// redirect_uri 可確認後,其餘請求錯誤以 302 重導回 RP,附 error 與
// 原 state(RFC 6749 §4.1.2.1)。
func TestAuthorizeRedirectsParameterErrors(t *testing.T) {
e := newTestEnv(t)
h := oidc.AuthorizeHandler(e.db)
redirectURI := e.app.RedirectURIs[0]
tests := []struct {
name string
query string
wantErrCode string
wantRedirect string
}{
{
"response_type 不支援",
"response_type=token&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) + "&scope=openid&state=xyz",
"unsupported_response_type",
redirectURI,
},
{
"scope 缺 openid",
authorizeQuery(e.app, "profile email", "xyz", "", ""),
"invalid_scope",
redirectURI,
},
{
"scope 超出註冊範圍",
authorizeQuery(e.app, "openid profile email offline_access unknown-scope", "xyz", "", ""),
"invalid_scope",
redirectURI,
},
{
"code_challenge_method=plain",
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
"&scope=openid&state=xyz&code_challenge=whatever&code_challenge_method=plain",
"invalid_request",
redirectURI,
},
{
"有 challenge 未指定 method",
"response_type=code&client_id=" + e.app.ClientID + "&redirect_uri=" + url.QueryEscape(redirectURI) +
"&scope=openid&state=xyz&code_challenge=whatever",
"invalid_request",
redirectURI,
},
{
"公開式 Client 未使用 PKCE",
authorizeQuery(e.pub, "openid", "xyz", "", ""),
"invalid_request",
e.pub.RedirectURIs[0],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := getAuthorize(h, tt.query, e.sessionCookie())
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
loc := redirectLocation(t, rec)
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != tt.wantRedirect {
t.Fatalf("Location 基準 URL = %q, want %q", base, tt.wantRedirect)
}
if got := loc.Query().Get("error"); got != tt.wantErrCode {
t.Errorf("error = %q, want %q", got, tt.wantErrCode)
}
if got := loc.Query().Get("state"); got != "xyz" {
t.Errorf("state 應原樣回填, got %q", got)
}
})
}
}
// 未登入時導向 /login,next 攜帶完整授權請求(OIDC Core §3.1.2.2)。
func TestAuthorizeRedirectsToLoginWhenNotLoggedIn(t *testing.T) {
e := newTestEnv(t)
q := authorizeQuery(e.app, "openid profile", "xyz", "n-1", "")
rec := getAuthorize(oidc.AuthorizeHandler(e.db), q) // 不帶 Session Cookie
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
loc := redirectLocation(t, rec)
if loc.Path != "/login" {
t.Fatalf("應導向 /login, got %q", loc)
}
next, err := url.QueryUnescape(loc.Query().Get("next"))
if err != nil {
t.Fatalf("next 未編碼: %v", err)
}
if !strings.HasPrefix(next, "/authorize?") || !strings.Contains(next, "state=xyz") || !strings.Contains(next, "nonce=n-1") {
t.Fatalf("next 應為完整 /authorize URL: %q", next)
}
}
// 首次授權顯示同意頁;同意後記住,同範圍的後續請求靜默通過;範圍
// 擴大時再次詢問。
func TestAuthorizeConsentFlow(t *testing.T) {
e := newTestEnv(t)
h := oidc.AuthorizeHandler(e.db)
t.Run("首次顯示同意頁", func(t *testing.T) {
rec := getAuthorize(h, authorizeQuery(e.app, "openid profile", "xyz", "", ""), e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"授權存取", "機密式測試應用", "openid", "profile", `value="allow"`, `value="deny"`} {
if !strings.Contains(body, want) {
t.Errorf("同意頁缺少 %q", want)
}
}
// offline_access 未請求,不應出現於說明清單。
if strings.Count(body, "offline_access") != 0 {
t.Error("未請求的 scope 不應顯示")
}
})
t.Run("同意後發碼並記住", func(t *testing.T) {
loc, code := consentAllow(t, e, authorizeQuery(e.app, "openid profile", "xyz", "", ""))
if base := loc.Scheme + "://" + loc.Host + loc.Path; base != e.app.RedirectURIs[0] {
t.Fatalf("Location 基準 URL = %q, want %q", base, e.app.RedirectURIs[0])
}
if loc.Query().Get("state") != "xyz" {
t.Errorf("state 應原樣回填, got %q", loc.Query().Get("state"))
}
if len(code) < 20 {
t.Errorf("code 長度 %d 過短", len(code))
}
})
t.Run("同範圍再請求靜默通過", func(t *testing.T) {
rec := getAuthorize(h, authorizeQuery(e.app, "openid", "s2", "", ""), e.sessionCookie())
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302, body = %s", rec.Code, rec.Body.String())
}
loc := redirectLocation(t, rec)
if loc.Query().Get("code") == "" || loc.Query().Get("state") != "s2" {
t.Fatalf("應直接發碼: %s", loc)
}
})
t.Run("範圍擴大再次詢問", func(t *testing.T) {
rec := getAuthorize(h, authorizeQuery(e.app, "openid email", "s3", "", ""), e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200(應再顯示同意頁), body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "email") {
t.Fatal("同意頁應顯示新請求的 scope")
}
})
t.Run("拒絕授權回 access_denied", func(t *testing.T) {
q := authorizeQuery(e.pub, "openid", "xyz", "", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
rec := getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("公開式首授權也應先顯示同意頁, status = %d", rec.Code)
}
rec = postAuthorize(h, q, "deny", e.sessionCookie(), csrfCookieOf(t, rec))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
loc := redirectLocation(t, rec)
if loc.Query().Get("error") != "access_denied" || loc.Query().Get("state") != "xyz" {
t.Fatalf("應回 access_denied 與原 state: %s", loc)
}
// 拒絕不應記錄同意:再次請求仍顯示同意頁。
rec = getAuthorize(h, q, e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("拒絕後不應記住, status = %d, want 200", rec.Code)
}
})
t.Run("CSRF 不符回 403", func(t *testing.T) {
q := authorizeQuery(e.app, "openid email", "", "", "") // email 尚未同意
rec := getAuthorize(h, q, e.sessionCookie())
csrf := csrfCookieOf(t, rec)
// Cookie 保持正確值,但表單送出不符的 token。
form, _ := url.ParseQuery(q)
form.Set("decision", "allow")
form.Set("csrf_token", "wrong-token")
req := httptest.NewRequest(http.MethodPost, "/authorize", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(e.sessionCookie())
req.AddCookie(csrf)
rec = httptest.NewRecorder()
h(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
})
}