Files
alterminal/internal/oidc/logout_test.go
T
2026-10-03 12:37:38 +08:00

442 lines
15 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.
// 外部測試套件(與 helpers_test.go 同理):oidc 模型由 store 遷移,內部
// 測試套件匯入 testdb 會形成循環。涵蓋 RP-Initiated Logout 1.0 的
// /logout:id_token_hint 驗證、確認頁、post_logout_redirect_uri 註冊比對
// 與 state 回填,及不帶 RP 參數時對既有登出行為的委派。
package oidc_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"alterminal/internal/application"
"alterminal/internal/auth"
"alterminal/internal/oidc"
)
// postLogoutURI 為測試應用程式註冊的登出後返回 URI。
const postLogoutURI = "https://rp.example/logged-out"
// newLogoutEnv 建立已註冊登出後返回 URI 的測試環境。
func newLogoutEnv(t *testing.T) *testEnv {
t.Helper()
e := newTestEnv(t)
e.app.PostLogoutRedirectURIs = application.RedirectURIs{postLogoutURI}
if err := e.db.Save(e.app).Error; err != nil {
t.Fatal("註冊登出後返回 URI: ", err)
}
return e
}
// idTokenHint 為環境使用者簽發 ID token 供 id_token_hint 用。
func idTokenHint(t *testing.T, e *testEnv) string {
t.Helper()
tok, err := oidc.IssueIDToken(e.db, testIssuer, e.user, e.app, "openid", "", e.session.CreatedAt)
if err != nil {
t.Fatal("簽發 ID token: ", err)
}
return tok
}
// getLogout 對 GET /logout 發出請求(query 含前導 ?,可選帶 Cookie)。
func getLogout(h http.HandlerFunc, query string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/logout"+query, nil)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// postLogoutForm 以表單送出 POST /logout(可選帶 Cookie)。
func postLogoutForm(h http.HandlerFunc, form url.Values, cookies ...*http.Cookie) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
// sessionCookieCleared 檢查回應是否清除 Session Cookie(Max-Age<0)。
func sessionCookieCleared(rec *httptest.ResponseRecorder) bool {
for _, c := range rec.Result().Cookies() {
if c.Name == auth.CookieName && c.MaxAge < 0 {
return true
}
}
return false
}
// sessionAlive 回傳環境 Session 是否仍有效。
func sessionAlive(t *testing.T, e *testEnv) bool {
t.Helper()
_, err := auth.GetSession(e.db, e.session.ID)
return err == nil
}
// logoutQuery 組出 RP-Initiated Logout 的 GET query(含前導 ?)。
func logoutQuery(hint, redirectURI, clientID, state string) string {
v := url.Values{}
set := func(k, s string) {
if s != "" {
v.Set(k, s)
}
}
set("id_token_hint", hint)
set("post_logout_redirect_uri", redirectURI)
set("client_id", clientID)
set("state", state)
return "?" + v.Encode()
}
func TestLogoutDiscoveryEndSessionEndpoint(t *testing.T) {
rec := httptest.NewRecorder()
oidc.DiscoveryHandler(testIssuer)(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
// RP-Initiated Logout 1.0 §2.1:支援 Discovery 時須發佈 end_session_endpoint。
if !strings.Contains(rec.Body.String(), `"end_session_endpoint":"`+testIssuer+`/logout"`) {
t.Fatalf("Discovery 應發佈 end_session_endpoint: %s", rec.Body.String())
}
}
func TestRPLogoutHintMatchingSessionRedirects(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
hint := idTokenHint(t, e)
rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", "st-123"), e.sessionCookie())
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
// RP-Initiated Logout 1.0 §2:重導 post_logout_redirect_uri 並以 state
// 回填原值。
loc := redirectLocation(t, rec)
if loc.String() != postLogoutURI+"?state=st-123" {
t.Fatalf("Location = %q, want %q?state=st-123", loc.String(), postLogoutURI)
}
if !sessionCookieCleared(rec) {
t.Fatal("應清除 Session Cookie")
}
if sessionAlive(t, e) {
t.Fatal("登出後 Session 應已刪除")
}
}
func TestRPLogoutExpiredHintAccepted(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
// 過期的 ID token:登出提示常在效期外送達,RP-Initiated Logout 1.0
// §2 要求 RP 對應 session 存在(或近期存在)時應接受。
past := time.Now().Add(-time.Hour).Unix()
hint := forgeJWT(t, e.key,
map[string]string{"alg": "RS256", "kid": e.key.Kid, "typ": "JWT"},
map[string]any{"iss": testIssuer, "sub": subjectOf(e.user.ID), "aud": e.app.ClientID, "exp": past, "iat": past})
rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", ""), e.sessionCookie())
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if got := redirectLocation(t, rec).String(); got != postLogoutURI {
t.Fatalf("Location = %q, want %q", got, postLogoutURI)
}
}
func TestRPLogoutHintOfOtherUserRequiresConfirmation(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
other := &auth.User{Username: "logout-other", Email: "logout-other@example.com", Name: "他人"}
if err := e.db.Create(other).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
e.db.Delete(&auth.Session{}, "user_id = ?", other.ID)
e.db.Delete(&auth.User{}, other.ID)
})
otherHint, err := oidc.IssueIDToken(e.db, testIssuer, other, e.app, "openid", "", time.Now())
if err != nil {
t.Fatal(err)
}
rec := getLogout(h, logoutQuery(otherHint, postLogoutURI, "", ""), 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(), "您確定要登出嗎") {
t.Fatal("應顯示登出確認頁")
}
if !sessionAlive(t, e) {
t.Fatal("未確認前不應登出")
}
}
func TestRPLogoutWithoutHintConfirmationFlow(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
// 無 id_token_hint:RP-Initiated Logout 1.0 §2 MUST 先詢問 End-User。
rec := getLogout(h, logoutQuery("", postLogoutURI, e.app.ClientID, "st-9"), e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200(確認頁), body = %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "您確定要登出嗎") || !strings.Contains(body, e.app.Name) {
t.Fatalf("確認頁應顯示標題與應用程式名稱: %s", body)
}
// 原始參數以隱藏欄位帶回。
if got := hiddenFieldValue(t, body, "post_logout_redirect_uri"); got != postLogoutURI {
t.Fatalf("隱藏欄位 post_logout_redirect_uri = %q, want %q", got, postLogoutURI)
}
if got := hiddenFieldValue(t, body, "state"); got != "st-9" {
t.Fatalf("隱藏欄位 state = %q, want st-9", got)
}
csrf := csrfCookieOf(t, rec)
form := url.Values{
"decision": {"logout"},
"csrf_token": {csrf.Value},
"client_id": {e.app.ClientID},
"post_logout_redirect_uri": {postLogoutURI},
"state": {"st-9"},
}
rec = postLogoutForm(h, form, e.sessionCookie(), csrf)
if rec.Code != http.StatusSeeOther {
t.Fatalf("確認後 status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
loc := redirectLocation(t, rec)
if loc.String() != postLogoutURI+"?state=st-9" {
t.Fatalf("Location = %q, want %q?state=st-9", loc.String(), postLogoutURI)
}
if sessionAlive(t, e) {
t.Fatal("確認後 Session 應已刪除")
}
// 取消:維持登入,返回帳號首頁。
s, err := auth.CreateSession(e.db, e.user.ID)
if err != nil {
t.Fatal(err)
}
e.session = s
rec2 := getLogout(h, logoutQuery("", postLogoutURI, e.app.ClientID, ""), e.sessionCookie())
csrf2 := csrfCookieOf(t, rec2)
form2 := url.Values{
"decision": {"cancel"},
"csrf_token": {csrf2.Value},
"client_id": {e.app.ClientID},
"post_logout_redirect_uri": {postLogoutURI},
}
rec2 = postLogoutForm(h, form2, e.sessionCookie(), csrf2)
if rec2.Code != http.StatusSeeOther {
t.Fatalf("取消 status = %d, want 303", rec2.Code)
}
if loc := redirectLocation(t, rec2).String(); loc != "/" {
t.Fatalf("取消後 Location = %q, want /", loc)
}
if !sessionAlive(t, e) {
t.Fatal("取消登出後 Session 應保持有效")
}
}
func TestRPLogoutConfirmCSRFRequired(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
form := url.Values{
"decision": {"logout"},
"csrf_token": {"wrong"},
"client_id": {e.app.ClientID},
"post_logout_redirect_uri": {postLogoutURI},
}
rec := postLogoutForm(h, form, e.sessionCookie(), &http.Cookie{Name: auth.CSRFCookieName, Value: "right"})
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403, body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "表單驗證失敗") {
t.Fatal("應重繪確認頁並顯示錯誤")
}
if !sessionAlive(t, e) {
t.Fatal("CSRF 失敗時不應登出")
}
}
func TestRPLogoutUnregisteredRedirectRejected(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
hint := idTokenHint(t, e)
// 未註冊的返回 URI(含湊巧是 redirect URI 者):RP-Initiated Logout
// 1.0 §3 MUST NOT 重導;登出仍完成並顯示說明。
for _, uri := range []string{"https://evil.example/gotcha", e.app.RedirectURIs[0]} {
rec := getLogout(h, logoutQuery(hint, uri, "", ""), e.sessionCookie())
if rec.Code != http.StatusOK {
t.Fatalf("uri = %s: status = %d, want 200(已登出頁), body = %s", uri, rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "" {
t.Fatalf("uri = %s: 不應重導,得到 Location = %s", uri, loc)
}
if !strings.Contains(rec.Body.String(), "您已登出") || !strings.Contains(rec.Body.String(), "未經應用程式註冊") {
t.Fatalf("uri = %s: 應顯示已登出頁與說明: %s", uri, rec.Body.String())
}
if sessionAlive(t, e) {
t.Fatal("登出仍應執行")
}
// 下一輪以新 Session 測試(前輪已登出)。
s, err := auth.CreateSession(e.db, e.user.ID)
if err != nil {
t.Fatal(err)
}
e.session = s
}
}
func TestRPLogoutInvalidHintRejected(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
// 以未註冊於本服務的金鑰簽署:kid 查無 → hint 無效(§2 OP MUST 驗證
// 為本 OP 簽發;§4 錯誤時 MUST 不重導)。
other := mustNewKey(t, false)
forged := forgeJWT(t, other,
map[string]string{"alg": "RS256", "kid": other.Kid, "typ": "JWT"},
map[string]any{"iss": testIssuer, "sub": subjectOf(e.user.ID), "aud": e.app.ClientID})
rec := getLogout(h, logoutQuery(forged, postLogoutURI, "", ""), e.sessionCookie())
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "" {
t.Fatalf("錯誤時不應重導,得到 Location = %s", loc)
}
if !sessionAlive(t, e) {
t.Fatal("無效 hint 不應執行登出")
}
// client_id 與 hint 的 aud 不符(§2 MUST 驗證相符)。
hint := idTokenHint(t, e)
rec = getLogout(h, logoutQuery(hint, postLogoutURI, e.pub.ClientID, ""), e.sessionCookie())
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body = %s", rec.Code, rec.Body.String())
}
if !sessionAlive(t, e) {
t.Fatal("client_id 不符時不應執行登出")
}
}
func TestRPLogoutIdempotentWithoutSession(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
hint := idTokenHint(t, e)
// 無 Session:冪等完成,仍重導至已註冊的返回 URI。
rec := getLogout(h, logoutQuery(hint, postLogoutURI, "", "s"))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if got := redirectLocation(t, rec).String(); got != postLogoutURI+"?state=s" {
t.Fatalf("Location = %q, want %q?state=s", got, postLogoutURI)
}
if !sessionCookieCleared(rec) {
t.Fatal("仍應清除(失效的)Session Cookie")
}
}
func TestLogoutDirectVisit(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
// 已登入直接造訪:無 hint,須先確認。
rec := getLogout(h, "", e.sessionCookie())
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "您確定要登出嗎") {
t.Fatalf("已登入直接造訪應顯示確認頁, status = %d", rec.Code)
}
if !sessionAlive(t, e) {
t.Fatal("未確認前不應登出")
}
// 未登入直接造訪:冪等,導向 /login(與既有登出行為一致)。
rec = getLogout(h, "")
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if loc := redirectLocation(t, rec).String(); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
}
func TestLogoutPostDelegatesLegacyBehavior(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
// 側欄登出表單(僅 csrf_token):委由 auth.LogoutHandler,303 /login。
rec := postLogoutForm(h, url.Values{"csrf_token": {"t"}},
e.sessionCookie(), &http.Cookie{Name: auth.CSRFCookieName, Value: "t"})
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303, body = %s", rec.Code, rec.Body.String())
}
if loc := redirectLocation(t, rec).String(); loc != "/login" {
t.Fatalf("Location = %q, want /login", loc)
}
if sessionAlive(t, e) {
t.Fatal("表單登出應刪除 Session")
}
// JSON API 登出:204。
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("Content-Type", "application/json")
rec2 := httptest.NewRecorder()
h(rec2, req)
if rec2.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec2.Code)
}
if !sessionCookieCleared(rec2) {
t.Fatal("JSON 登出應清除 Session Cookie")
}
// 不支援的 Content-Type:415(沿 auth.LogoutHandler 的檢查)。
req = httptest.NewRequest(http.MethodPost, "/logout", strings.NewReader("x=1"))
req.Header.Set("Content-Type", "text/plain")
rec3 := httptest.NewRecorder()
h(rec3, req)
if rec3.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status = %d, want 415", rec3.Code)
}
}
func TestRPLogoutClientIDWithoutHintSoftFailsRedirect(t *testing.T) {
e := newLogoutEnv(t)
h := oidc.LogoutHandler(e.db, testIssuer)
// 僅帶查無對應的 client_id:請求仍可進行(經確認頁),但不接受重導
// ——無法確認返回網址的歸屬(RP-Initiated Logout 1.0 §3)。
rec := getLogout(h, logoutQuery("", postLogoutURI, "no-such-client", ""), e.sessionCookie())
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "您確定要登出嗎") {
t.Fatalf("應顯示確認頁, status = %d, body = %s", rec.Code, rec.Body.String())
}
csrf := csrfCookieOf(t, rec)
form := url.Values{
"decision": {"logout"},
"csrf_token": {csrf.Value},
"client_id": {"no-such-client"},
"post_logout_redirect_uri": {postLogoutURI},
}
rec = postLogoutForm(h, form, e.sessionCookie(), csrf)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200(已登出頁), body = %s", rec.Code, rec.Body.String())
}
if loc := rec.Header().Get("Location"); loc != "" {
t.Fatalf("查無 client 不得重導,得到 Location = %s", loc)
}
if sessionAlive(t, e) {
t.Fatal("確認後應完成登出")
}
}