// 外部測試套件(與 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("確認後應完成登出") } }