forked from alterminal/alterminal
442 lines
15 KiB
Go
442 lines
15 KiB
Go
// 外部測試套件(與 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("確認後應完成登出")
|
||
}
|
||
}
|