From f19b694c0b0dea37b914eef5e8e67f5b5ada4c0a Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 21 May 2026 13:50:18 +0900 Subject: [PATCH] fix auth link session conflict policy --- backend/internal/handler/auth_handler.go | 191 ++++++- .../handler/auth_handler_link_test.go | 479 ++++++++++++++++++ .../handler/auth_handler_login_test.go | 249 +++++++++ 3 files changed, 902 insertions(+), 17 deletions(-) diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index f35d5c24..6ae4c895 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -2173,6 +2173,9 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { json.Unmarshal([]byte(val), &data) if data["status"] == statusSuccess { + if blocked, err := h.rejectSessionSubjectOverwrite(c, data["subject"], data["loginId"]); blocked || err != nil { + return err + } slog.Info("[Poll] Success", "pendingRef", req.PendingRef) return c.JSON(fiber.Map{ "sessionJwt": data["jwt"], @@ -2185,6 +2188,9 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error { if err != nil { return err } + if authInfo == nil { + return nil + } return c.JSON(fiber.Map{ "sessionJwt": authInfo.SessionToken.JWT, @@ -2264,6 +2270,9 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { slog.Error("[Verify] IDP returned empty session") return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } + if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil { + return err + } sessionToken := authInfo.SessionToken.JWT c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -2281,6 +2290,12 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { if sessionID != "" { sessionData["session_id"] = sessionID } + if authInfo.Subject != "" { + sessionData["subject"] = authInfo.Subject + } + if loginID != "" { + sessionData["loginId"] = loginID + } sessionDataJSON, _ := json.Marshal(sessionData) h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration) @@ -2381,6 +2396,9 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } authInfo.Subject = subject + if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, lookupLoginID); blocked || err != nil { + return err + } c.Locals("login_id", lookupLoginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -2400,8 +2418,10 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error { } if pendingRef != "" { sessionData, _ := json.Marshal(map[string]string{ - "status": statusSuccess, - "jwt": authInfo.SessionToken.JWT, + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + "subject": subject, + "loginId": lookupLoginID, }) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Delete(prefixLoginCodePending + lookupLoginID) @@ -2505,6 +2525,9 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") } authInfo.Subject = subject + if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, payload.LoginID); blocked || err != nil { + return err + } c.Locals("login_id", payload.LoginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -2515,8 +2538,10 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error { if payload.PendingRef != "" { sessionData, _ := json.Marshal(map[string]string{ - "status": statusSuccess, - "jwt": authInfo.SessionToken.JWT, + "status": statusSuccess, + "jwt": authInfo.SessionToken.JWT, + "subject": subject, + "loginId": payload.LoginID, }) h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Delete(prefixLoginCodePending + payload.LoginID) @@ -3018,6 +3043,96 @@ func (h *AuthHandler) loadHeadlessLinkState(pendingRef string) (headlessLinkStat return state, true } +func (h *AuthHandler) resolveCurrentBrowserSessionEvidence(c *fiber.Ctx) (string, string) { + if h == nil || c == nil { + return "", "" + } + if token := h.getBearerToken(c); token != "" { + if identityID, err := h.resolveIdentityID(c, token); err == nil && strings.TrimSpace(identityID) != "" { + sessionID, _ := h.getKratosSessionID(token) + return strings.TrimSpace(identityID), strings.TrimSpace(sessionID) + } + } + if cookie := strings.TrimSpace(c.Get("Cookie")); cookie != "" { + if identityID, _, _, sessionID, err := h.getKratosIdentityWithCookieAndSession(cookie); err == nil && strings.TrimSpace(identityID) != "" { + return strings.TrimSpace(identityID), strings.TrimSpace(sessionID) + } + } + return "", "" +} + +func (h *AuthHandler) resolveCurrentBrowserSubject(c *fiber.Ctx) string { + identityID, _ := h.resolveCurrentBrowserSessionEvidence(c) + return identityID +} + +func (h *AuthHandler) resolveHeadlessOIDCSubjectEvidence(c *fiber.Ctx, loginReq *domain.HydraLoginRequest, pendingRef string) string { + if loginReq != nil && loginReq.Skip && strings.TrimSpace(loginReq.Subject) != "" { + return strings.TrimSpace(loginReq.Subject) + } + if currentSubject := h.resolveCurrentBrowserSubject(c); currentSubject != "" { + return currentSubject + } + if meta, ok := h.loadLoginApproverMeta(pendingRef); ok && strings.TrimSpace(meta.ApproverSubject) != "" { + return strings.TrimSpace(meta.ApproverSubject) + } + return "" +} + +func (h *AuthHandler) rejectSessionSubjectOverwrite(c *fiber.Ctx, targetSubject, targetLoginID string) (bool, error) { + currentSubject := h.resolveCurrentBrowserSubject(c) + if currentSubject == "" { + return false, nil + } + + targetSubject = strings.TrimSpace(targetSubject) + if targetSubject == "" && strings.TrimSpace(targetLoginID) != "" && h.KratosAdmin != nil { + if resolved, err := h.resolveKratosIdentityIDFromLoginID(c.Context(), targetLoginID); err == nil { + targetSubject = strings.TrimSpace(resolved) + } else { + slog.Warn( + "session-changing login target subject resolution failed", + "loginID", targetLoginID, + "error", err, + ) + } + } + if targetSubject == "" { + slog.Warn("session-changing login blocked because target subject is unknown", "current_subject", currentSubject) + return true, errorJSONCode(c, fiber.StatusConflict, "session_subject_conflict", "Current browser session must be signed out before signing in as another user") + } + if targetSubject != currentSubject { + slog.Warn( + "session-changing login blocked by subject conflict", + "target_subject", targetSubject, + "current_subject", currentSubject, + ) + return true, errorJSONCode(c, fiber.StatusConflict, "session_subject_conflict", "Current browser session must be signed out before signing in as another user") + } + return false, nil +} + +func (h *AuthHandler) rejectHeadlessOIDCSubjectConflict(c *fiber.Ctx, currentSubject, targetSubject string) (bool, error) { + currentSubject = strings.TrimSpace(currentSubject) + targetSubject = strings.TrimSpace(targetSubject) + if currentSubject == "" || targetSubject == "" || currentSubject == targetSubject { + return false, nil + } + slog.Warn( + "headless login blocked by OIDC/UserFront subject conflict", + "current_subject", currentSubject, + "target_subject", targetSubject, + ) + return true, c.Status(fiber.StatusConflict).JSON(fiber.Map{ + "error": "OIDC/UserFront subject conflicts with headless login target. Sign out of UserFront or restart through the standard UserFront login flow.", + "code": "oidc_subject_conflict", + "status": "oidc_subject_conflict", + "currentSubject": currentSubject, + "targetSubject": targetSubject, + "recommendedAction": "redirect_to_userfront_login", + }) +} + func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) (string, *domain.AuthInfo, error) { val, err := h.RedisService.Get(prefixSession + pendingRef) if err != nil || val == "" { @@ -3076,6 +3191,9 @@ func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" { return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") } + if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil { + return "", nil, err + } c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -3099,6 +3217,12 @@ func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) if sessionID != "" { sessionData["session_id"] = sessionID } + if authInfo.Subject != "" { + sessionData["subject"] = authInfo.Subject + } + if loginID != "" { + sessionData["loginId"] = loginID + } sessionDataJSON, _ := json.Marshal(sessionData) _ = h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration) @@ -3186,6 +3310,16 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error { ) return errorJSONCode(c, status, code, message) } + if authInfo == nil || strings.TrimSpace(authInfo.Subject) == "" { + return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity") + } + if blocked, err := h.rejectHeadlessOIDCSubjectConflict( + c, + h.resolveHeadlessOIDCSubjectEvidence(c, loginReq, ""), + authInfo.Subject, + ); blocked || err != nil { + return err + } c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) @@ -3420,25 +3554,35 @@ func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error { }) } - loginID := state.LoginID - if session["status"] == "approved" { - completedLoginID, _, err := h.completeApprovedLinkLogin(c, pendingRef) - if err != nil { - return err + loginID := strings.TrimSpace(state.LoginID) + targetSubject := strings.TrimSpace(session["subject"]) + if session["status"] == "approved" || session["status"] == statusSuccess { + if storedLoginID := strings.TrimSpace(session["loginId"]); storedLoginID != "" { + loginID = storedLoginID + } else if storedLoginID := strings.TrimSpace(session["login_id"]); storedLoginID != "" { + loginID = storedLoginID } - loginID = completedLoginID } if loginID == "" { return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve approved user identity") } - subject, err := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) - if err != nil || subject == "" { + if targetSubject == "" { + targetSubject, err = h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) + } + if err != nil || targetSubject == "" { slog.Error("failed to resolve kratos identity for headless link poll", "loginID", loginID, "error", err) return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve user identity") } + if blocked, err := h.rejectHeadlessOIDCSubjectConflict( + c, + h.resolveHeadlessOIDCSubjectEvidence(c, loginReq, pendingRef), + targetSubject, + ); blocked || err != nil { + return err + } - acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), state.LoginChallenge, subject) + acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), state.LoginChallenge, targetSubject) if err != nil { slog.Error("failed to accept hydra login request in headless link poll", "error", err) return fiber.NewError(fiber.StatusInternalServerError, "Failed to accept OIDC login request") @@ -3446,6 +3590,8 @@ func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error { state.RedirectTo = acceptResp.RedirectTo h.storeHeadlessLinkState(pendingRef, state, defaultExpiration) + h.writeLinkAuditLog(loginID, pendingRef, nil, c) + h.clearLoginMeta(pendingRef) logOidcRedirectSummary("headless_link_poll", acceptResp.RedirectTo) return c.JSON(fiber.Map{ "redirectTo": acceptResp.RedirectTo, @@ -3532,6 +3678,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error { ale.Status = fiber.StatusOK ale.LatencyMs = time.Since(startTime) + if blocked, err := h.rejectSessionSubjectOverwrite(c, authInfo.Subject, loginID); blocked || err != nil { + ale.Status = fiber.StatusConflict + ale.ProviderError = "session_subject_conflict" + ale.Log(slog.LevelWarn, "Login blocked by existing browser session") + return err + } c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) @@ -4676,8 +4828,10 @@ func extractSessionIDFromJWT(token string) string { } type qrMeta struct { - IPAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ApproverSubject string `json:"approver_subject,omitempty"` + ApproverSessionID string `json:"approver_session_id,omitempty"` } func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) { @@ -4714,9 +4868,12 @@ func (h *AuthHandler) storeLoginApproverMeta(pendingRef string, c *fiber.Ctx, tt if h.RedisService == nil || pendingRef == "" || c == nil { return } + approverSubject, approverSessionID := h.resolveCurrentBrowserSessionEvidence(c) meta := qrMeta{ - IPAddress: extractClientIPFromHeaders(c), - UserAgent: c.Get("User-Agent"), + IPAddress: extractClientIPFromHeaders(c), + UserAgent: c.Get("User-Agent"), + ApproverSubject: approverSubject, + ApproverSessionID: approverSessionID, } raw, err := json.Marshal(meta) if err != nil { diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 0c1286f0..cccc82a6 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -50,6 +50,37 @@ func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App { return app } +func newKratosWhoamiTestServer(t *testing.T, identityID string) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/sessions/whoami" { + http.NotFound(w, r) + return + } + if r.Header.Get("Cookie") == "" && r.Header.Get("X-Session-Token") == "" { + http.Error(w, "missing session", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "id": "session-123", + "authenticated_at": "2026-05-21T00:00:00Z", + "identity": map[string]interface{}{ + "id": identityID, + "traits": map[string]interface{}{ + "email": "user@example.com", + }, + }, + }) + })) + origDefaultClient := http.DefaultClient + http.DefaultClient = server.Client() + t.Cleanup(func() { + http.DefaultClient = origDefaultClient + }) + t.Cleanup(server.Close) + return server +} + func TestEnchantedLinkFlow_Email_Success(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} // Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic @@ -151,6 +182,102 @@ func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { assert.NotEmpty(t, initResp["userCode"]) } +func TestVerifyMagicLink_VerifyOnlyWithoutSharedBrowserSessionApprovesOnly(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, + }} + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + } + app := fiber.New() + app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) + + body, _ := json.Marshal(map[string]interface{}{ + "token": "token-123", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, resp.Cookies()) + + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "approved", got["status"]) + assert.Nil(t, got["sessionJwt"]) + assert.Nil(t, got["token"]) +} + +func TestVerifyMagicLink_VerifyOnlySharedBrowserSameSubjectApprovesOnly(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, + }} + kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + } + app := fiber.New() + app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) + + body, _ := json.Marshal(map[string]interface{}{ + "token": "token-123", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, resp.Cookies()) + + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "approved", got["status"]) + assert.Nil(t, got["sessionJwt"]) + assert.Nil(t, got["token"]) +} + +func TestVerifyMagicLink_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixToken + "token-123": `{"pendingRef":"pending-123","loginId":"user@example.com"}`, + }} + kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + } + app := fiber.New() + app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) + + body, _ := json.Marshal(map[string]interface{}{ + "token": "token-123", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, resp.Cookies()) + + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "approved", got["status"]) + assert.Nil(t, got["sessionJwt"]) + assert.Nil(t, got["token"]) + assert.Contains(t, redis.data[prefixSession+"pending-123"], "approved") +} + func TestResolveUserfrontURL_DevLocalhostUsesConfiguredPort(t *testing.T) { t.Setenv("APP_ENV", "dev") t.Setenv("USERFRONT_URL", "http://localhost:5000") @@ -169,6 +296,44 @@ func TestResolveUserfrontURL_DevLocalhostUsesConfiguredPort(t *testing.T) { assert.Equal(t, "http://localhost:5000", string(body)) } +func TestVerifyLoginCode_VerifyOnlySharedBrowserDifferentSubjectApprovesOnly(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixLoginCode + "user@example.com": "flow-123", + prefixLoginCodePending + "user@example.com": "pending-123", + prefixLoginCodeValue + "pending-123": "569765", + }} + kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{}, + } + app := fiber.New() + app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) + + body, _ := json.Marshal(map[string]interface{}{ + "loginId": "user@example.com", + "code": "569765", + "pendingRef": "pending-123", + "verifyOnly": true, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login/code/verify", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Empty(t, resp.Cookies()) + + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "approved", got["status"]) + assert.Nil(t, got["sessionJwt"]) + assert.Nil(t, got["token"]) + assert.Contains(t, redis.data[prefixSession+"pending-123"], "approved") +} + func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) { redis := &mockRedisRepo{data: map[string]string{ prefixLoginCode + "su-@samaneng.com": "flow-123", @@ -224,6 +389,70 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) { assert.Equal(t, "expired_token", got["code"]) } +func TestPollEnchantedLink_SharedBrowserSameSubjectIssuesSession(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`, + }} + kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{ + issueSession: &domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"}, + Subject: "kratos-user-1", + }, + }, + } + app := fiber.New() + app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) + + body, _ := json.Marshal(map[string]string{"pendingRef": "pending-123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "ok", got["status"]) + assert.Equal(t, "valid-jwt", got["sessionJwt"]) +} + +func TestPollEnchantedLink_SharedBrowserDifferentSubjectConflicts(t *testing.T) { + redis := &mockRedisRepo{data: map[string]string{ + prefixSession + "pending-123": `{"status":"approved","loginId":"user@example.com"}`, + }} + kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{ + issueSession: &domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"}, + Subject: "kratos-user-1", + }, + }, + } + app := fiber.New() + app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) + + body, _ := json.Marshal(map[string]string{"pendingRef": "pending-123"}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + resp, _ := app.Test(req, -1) + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "session_subject_conflict", got["code"]) + assert.NotContains(t, redis.data[prefixSession+"pending-123"], "valid-jwt") +} + func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) { t.Setenv("BACKEND_PUBLIC_URL", "") @@ -412,6 +641,9 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { _ = json.NewDecoder(resp.Body).Decode(&pollResp) assert.Equal(t, "http://rp/cb", pollResp["redirectTo"]) assert.Equal(t, "ok", pollResp["status"]) + assert.Nil(t, pollResp["sessionJwt"]) + assert.Nil(t, pollResp["token"]) + assert.Empty(t, resp.Cookies()) if assert.Len(t, auditRepo.logs, 1) { assert.Contains(t, auditRepo.logs[0].EventType, "/api/v1/auth/") details, err := parseAuditDetails(auditRepo.logs[0].Details) @@ -423,3 +655,250 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { assert.Equal(t, "challenge-123", details["login_challenge"]) } } + +func TestHeadlessLinkPoll_ApproverSubjectConflictBlocksMixedRP(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + + if !testsupport.PortBindingAvailable() { + t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") + } + + redis := &mockRedisRepo{data: make(map[string]string)} + privateKey, jwks := mustHeadlessRSAJWK(t) + jwksBody, _ := json.Marshal(jwks) + acceptCalled := false + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Client: domain.HydraClient{ + ClientID: "headless-login-client", + ClientName: "local-demo-rp", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + }, + }, + }) + return + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + acceptCalled = true + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + return + } + http.NotFound(w, r) + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-target-b", nil) + auditRepo := &mockAuditRepo{} + headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" { + return httpResponse(r, http.StatusOK, string(jwksBody)), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{ + userExists: true, + initiateLinkErr: domain.ErrNotSupported, + }, + SmsService: &mockSmsService{}, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient), + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := newHeadlessLinkTestApp(h) + t.Setenv("USERFRONT_URL", "http://userfront.test") + + initBody, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"), + "loginId": "010-1234-5678", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(initBody)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var initResp map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&initResp) + pendingRef := initResp["pendingRef"].(string) + assert.NotEmpty(t, pendingRef) + + var token string + for k := range redis.data { + if len(k) > 16 && k[:16] == "enchanted_token:" { + token = k[16:] + break + } + } + assert.NotEmpty(t, token) + + kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + verifyBody, _ := json.Marshal(map[string]interface{}{ + "token": token, + "verifyOnly": true, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=userfront-a-session") + resp, _ = app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + pollBody, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"), + "pendingRef": pendingRef, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + assert.False(t, acceptCalled) + assert.Empty(t, resp.Cookies()) + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "oidc_subject_conflict", got["code"]) + assert.Equal(t, "redirect_to_userfront_login", got["recommendedAction"]) + assert.Equal(t, "kratos-userfront-a", got["currentSubject"]) + assert.Equal(t, "kratos-target-b", got["targetSubject"]) + assert.Empty(t, auditRepo.logs) +} + +func TestHeadlessLinkPoll_RequestCookieSubjectConflictBlocksMixedRP(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + + if !testsupport.PortBindingAvailable() { + t.Skip("skipping headless link tests because this environment cannot bind local TCP listeners") + } + + redis := &mockRedisRepo{data: make(map[string]string)} + privateKey, jwks := mustHeadlessRSAJWK(t) + jwksBody, _ := json.Marshal(jwks) + acceptCalled := false + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Client: domain.HydraClient{ + ClientID: "headless-login-client", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + }, + }, + }) + return + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + acceptCalled = true + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + return + } + http.NotFound(w, r) + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "+821012345678").Return("kratos-target-b", nil) + headlessClient := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "rp.example.com" && r.URL.Path == "/.well-known/jwks.json" { + return httpResponse(r, http.StatusOK, string(jwksBody)), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })} + + h := &AuthHandler{ + RedisService: redis, + IdpProvider: &mockIdpProvider{ + userExists: true, + initiateLinkErr: domain.ErrNotSupported, + }, + SmsService: &mockSmsService{}, + KratosAdmin: mockKratos, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(nil, headlessClient), + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := newHeadlessLinkTestApp(h) + t.Setenv("USERFRONT_URL", "http://userfront.test") + + initBody, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/init"), + "loginId": "010-1234-5678", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(initBody)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var initResp map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&initResp) + pendingRef := initResp["pendingRef"].(string) + assert.NotEmpty(t, pendingRef) + + var token string + for k := range redis.data { + if len(k) > 16 && k[:16] == "enchanted_token:" { + token = k[16:] + break + } + } + assert.NotEmpty(t, token) + + verifyBody, _ := json.Marshal(map[string]interface{}{ + "token": token, + "verifyOnly": true, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/magic-link/verify", bytes.NewReader(verifyBody)) + req.Header.Set("Content-Type", "application/json") + resp, _ = app.Test(req, -1) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + kratosPublic := newKratosWhoamiTestServer(t, "kratos-userfront-a") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + pollBody, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/link/poll"), + "pendingRef": pendingRef, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/poll", bytes.NewReader(pollBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=userfront-a-session") + resp, _ = app.Test(req, -1) + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + assert.False(t, acceptCalled) + assert.Empty(t, resp.Cookies()) + var got map[string]interface{} + _ = json.NewDecoder(resp.Body).Decode(&got) + assert.Equal(t, "oidc_subject_conflict", got["code"]) + assert.Equal(t, "kratos-userfront-a", got["currentSubject"]) + assert.Equal(t, "kratos-target-b", got["targetSubject"]) +} diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index a484f2d1..155b3d51 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -968,6 +968,176 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { if _, ok := got["sessionJwt"]; ok { t.Fatalf("expected headless response to omit sessionJwt, got %v", got["sessionJwt"]) } + if len(resp.Cookies()) != 0 { + t.Fatalf("expected headless response to omit cookies, got %v", resp.Cookies()) + } +} + +func TestHeadlessPasswordLogin_OIDCSubjectConflictBlocksMixedRP(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + + if !testsupport.PortBindingAvailable() { + t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") + } + + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "employee002", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-target-b", + }, nil) + + privateKey, jwks := mustHeadlessRSAJWK(t) + jwksBody, _ := json.Marshal(jwks) + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksBody) + })) + defer jwksServer.Close() + + acceptCalled := false + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Skip: true, + Subject: "kratos-userfront-a", + Client: domain.HydraClient{ + ClientID: "headless-login-client", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + }, + }, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + acceptCalled = true + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee002").Return("kratos-target-b", nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + app := newHeadlessPasswordLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login"), + "loginId": "employee002", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusConflict, resp.StatusCode) + require.False(t, acceptCalled) + require.Empty(t, resp.Cookies()) + var got map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Equal(t, "oidc_subject_conflict", got["code"]) + require.Equal(t, "redirect_to_userfront_login", got["recommendedAction"]) + require.Equal(t, "kratos-userfront-a", got["currentSubject"]) + require.Equal(t, "kratos-target-b", got["targetSubject"]) +} + +func TestHeadlessPasswordLogin_OIDCSubjectSameAllowsMixedRP(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + + if !testsupport.PortBindingAvailable() { + t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") + } + + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt"}, + Subject: "kratos-userfront-a", + }, nil) + + privateKey, jwks := mustHeadlessRSAJWK(t) + jwksBody, _ := json.Marshal(jwks) + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(jwksBody) + })) + defer jwksServer.Close() + + hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode(domain.HydraLoginRequest{ + Challenge: "challenge-123", + Skip: true, + Subject: "kratos-userfront-a", + Client: domain.HydraClient{ + ClientID: "headless-login-client", + TokenEndpointAuthMethod: "none", + Metadata: map[string]interface{}{ + "status": "active", + "headless_login_enabled": true, + "headless_token_endpoint_auth_method": "private_key_jwt", + "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", + }, + }, + }) + case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut: + _ = json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"}) + default: + http.NotFound(w, r) + } + }) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-userfront-a", nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + app := newHeadlessPasswordLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": mustHeadlessClientAssertion(t, privateKey, "headless-login-client", "http://example.com/api/v1/auth/headless/password/login"), + "loginId": "employee001", + "password": "password", + "login_challenge": "challenge-123", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Empty(t, resp.Cookies()) + var got map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Equal(t, "http://rp/cb", got["redirectTo"]) + require.Nil(t, got["sessionJwt"]) } func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { @@ -2018,6 +2188,85 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) { } } +func TestPasswordLogin_SharedBrowserSameSubjectAllowed(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"}, + Subject: "kratos-user-1", + }, nil) + + kratosPublic := newKratosWhoamiTestServer(t, "kratos-user-1") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + } + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var got map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Equal(t, "valid-jwt", got["sessionJwt"]) + mockIdp.AssertExpectations(t) + mockKratos.AssertExpectations(t) +} + +func TestPasswordLogin_SharedBrowserDifferentSubjectConflicts(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "new-session-id"}, + Subject: "kratos-user-1", + }, nil) + + kratosPublic := newKratosWhoamiTestServer(t, "kratos-other-user") + t.Setenv("KRATOS_PUBLIC_URL", kratosPublic.URL) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-user-1", nil) + + h := &AuthHandler{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + } + app := newAuthLoginTestApp(h) + + body, _ := json.Marshal(map[string]string{ + "loginId": "user@example.com", + "password": "password", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", "ory_kratos_session=shared-browser-session") + + resp, err := app.Test(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusConflict, resp.StatusCode) + var got map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&got)) + require.Equal(t, "session_subject_conflict", got["code"]) + require.Empty(t, resp.Cookies()) + mockIdp.AssertExpectations(t) + mockKratos.AssertExpectations(t) +} + func TestPasswordLogin_ArchivedUserRejected(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{