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

236 lines
8.0 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 (
"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)
}
})
}