1
0
forked from baron/baron-sso

fix auth link session conflict policy

This commit is contained in:
2026-05-21 13:50:18 +09:00
parent 8dfe6fed82
commit f19b694c0b
3 changed files with 902 additions and 17 deletions

View File

@@ -2173,6 +2173,9 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
json.Unmarshal([]byte(val), &data) json.Unmarshal([]byte(val), &data)
if data["status"] == statusSuccess { 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) slog.Info("[Poll] Success", "pendingRef", req.PendingRef)
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"sessionJwt": data["jwt"], "sessionJwt": data["jwt"],
@@ -2185,6 +2188,9 @@ func (h *AuthHandler) PollEnchantedLink(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
if authInfo == nil {
return nil
}
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"sessionJwt": authInfo.SessionToken.JWT, "sessionJwt": authInfo.SessionToken.JWT,
@@ -2264,6 +2270,9 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
slog.Error("[Verify] IDP returned empty session") slog.Error("[Verify] IDP returned empty session")
return errorJSON(c, fiber.StatusInternalServerError, "Failed to issue 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 sessionToken := authInfo.SessionToken.JWT
c.Locals("login_id", loginID) c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -2281,6 +2290,12 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
if sessionID != "" { if sessionID != "" {
sessionData["session_id"] = sessionID sessionData["session_id"] = sessionID
} }
if authInfo.Subject != "" {
sessionData["subject"] = authInfo.Subject
}
if loginID != "" {
sessionData["loginId"] = loginID
}
sessionDataJSON, _ := json.Marshal(sessionData) sessionDataJSON, _ := json.Marshal(sessionData)
h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration) 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") return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
} }
authInfo.Subject = subject authInfo.Subject = subject
if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, lookupLoginID); blocked || err != nil {
return err
}
c.Locals("login_id", lookupLoginID) c.Locals("login_id", lookupLoginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -2400,8 +2418,10 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
} }
if pendingRef != "" { if pendingRef != "" {
sessionData, _ := json.Marshal(map[string]string{ sessionData, _ := json.Marshal(map[string]string{
"status": statusSuccess, "status": statusSuccess,
"jwt": authInfo.SessionToken.JWT, "jwt": authInfo.SessionToken.JWT,
"subject": subject,
"loginId": lookupLoginID,
}) })
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
h.RedisService.Delete(prefixLoginCodePending + lookupLoginID) 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") return errorJSONCode(c, fiber.StatusInternalServerError, "internal_error", "Failed to resolve user identity")
} }
authInfo.Subject = subject authInfo.Subject = subject
if blocked, err := h.rejectSessionSubjectOverwrite(c, subject, payload.LoginID); blocked || err != nil {
return err
}
c.Locals("login_id", payload.LoginID) c.Locals("login_id", payload.LoginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -2515,8 +2538,10 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
if payload.PendingRef != "" { if payload.PendingRef != "" {
sessionData, _ := json.Marshal(map[string]string{ sessionData, _ := json.Marshal(map[string]string{
"status": statusSuccess, "status": statusSuccess,
"jwt": authInfo.SessionToken.JWT, "jwt": authInfo.SessionToken.JWT,
"subject": subject,
"loginId": payload.LoginID,
}) })
h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration) h.RedisService.Set(prefixSession+payload.PendingRef, string(sessionData), loginCodeExpiration)
h.RedisService.Delete(prefixLoginCodePending + payload.LoginID) h.RedisService.Delete(prefixLoginCodePending + payload.LoginID)
@@ -3018,6 +3043,96 @@ func (h *AuthHandler) loadHeadlessLinkState(pendingRef string) (headlessLinkStat
return state, true 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) { func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string) (string, *domain.AuthInfo, error) {
val, err := h.RedisService.Get(prefixSession + pendingRef) val, err := h.RedisService.Get(prefixSession + pendingRef)
if err != nil || val == "" { 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 == "" { if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
return "", nil, errorJSON(c, fiber.StatusInternalServerError, "Failed to issue session") 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) c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -3099,6 +3217,12 @@ func (h *AuthHandler) completeApprovedLinkLogin(c *fiber.Ctx, pendingRef string)
if sessionID != "" { if sessionID != "" {
sessionData["session_id"] = sessionID sessionData["session_id"] = sessionID
} }
if authInfo.Subject != "" {
sessionData["subject"] = authInfo.Subject
}
if loginID != "" {
sessionData["loginId"] = loginID
}
sessionDataJSON, _ := json.Marshal(sessionData) sessionDataJSON, _ := json.Marshal(sessionData)
_ = h.RedisService.Set(prefixSession+pendingRef, string(sessionDataJSON), defaultExpiration) _ = 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) 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("user_id", authInfo.Subject)
c.Locals("login_id", loginID) c.Locals("login_id", loginID)
@@ -3420,25 +3554,35 @@ func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error {
}) })
} }
loginID := state.LoginID loginID := strings.TrimSpace(state.LoginID)
if session["status"] == "approved" { targetSubject := strings.TrimSpace(session["subject"])
completedLoginID, _, err := h.completeApprovedLinkLogin(c, pendingRef) if session["status"] == "approved" || session["status"] == statusSuccess {
if err != nil { if storedLoginID := strings.TrimSpace(session["loginId"]); storedLoginID != "" {
return err loginID = storedLoginID
} else if storedLoginID := strings.TrimSpace(session["login_id"]); storedLoginID != "" {
loginID = storedLoginID
} }
loginID = completedLoginID
} }
if loginID == "" { if loginID == "" {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve approved user identity") return errorJSON(c, fiber.StatusInternalServerError, "Failed to resolve approved user identity")
} }
subject, err := h.resolveKratosIdentityIDFromLoginID(c.Context(), loginID) if targetSubject == "" {
if err != nil || subject == "" { 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) 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") 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 { if err != nil {
slog.Error("failed to accept hydra login request in headless link poll", "error", err) 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") 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 state.RedirectTo = acceptResp.RedirectTo
h.storeHeadlessLinkState(pendingRef, state, defaultExpiration) h.storeHeadlessLinkState(pendingRef, state, defaultExpiration)
h.writeLinkAuditLog(loginID, pendingRef, nil, c)
h.clearLoginMeta(pendingRef)
logOidcRedirectSummary("headless_link_poll", acceptResp.RedirectTo) logOidcRedirectSummary("headless_link_poll", acceptResp.RedirectTo)
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"redirectTo": acceptResp.RedirectTo, "redirectTo": acceptResp.RedirectTo,
@@ -3532,6 +3678,12 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
ale.Status = fiber.StatusOK ale.Status = fiber.StatusOK
ale.LatencyMs = time.Since(startTime) 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("user_id", authInfo.Subject)
c.Locals("login_id", loginID) c.Locals("login_id", loginID)
setSessionIDLocal(c, authInfo.SessionToken) setSessionIDLocal(c, authInfo.SessionToken)
@@ -4676,8 +4828,10 @@ func extractSessionIDFromJWT(token string) string {
} }
type qrMeta struct { type qrMeta struct {
IPAddress string `json:"ip_address"` IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"` 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) { 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 { if h.RedisService == nil || pendingRef == "" || c == nil {
return return
} }
approverSubject, approverSessionID := h.resolveCurrentBrowserSessionEvidence(c)
meta := qrMeta{ meta := qrMeta{
IPAddress: extractClientIPFromHeaders(c), IPAddress: extractClientIPFromHeaders(c),
UserAgent: c.Get("User-Agent"), UserAgent: c.Get("User-Agent"),
ApproverSubject: approverSubject,
ApproverSessionID: approverSessionID,
} }
raw, err := json.Marshal(meta) raw, err := json.Marshal(meta)
if err != nil { if err != nil {

View File

@@ -50,6 +50,37 @@ func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App {
return 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) { func TestEnchantedLinkFlow_Email_Success(t *testing.T) {
redis := &mockRedisRepo{data: make(map[string]string)} redis := &mockRedisRepo{data: make(map[string]string)}
// Force "Not Supported" for InitiateLinkLogin only to trigger custom Enchanted Link logic // 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"]) 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) { func TestResolveUserfrontURL_DevLocalhostUsesConfiguredPort(t *testing.T) {
t.Setenv("APP_ENV", "dev") t.Setenv("APP_ENV", "dev")
t.Setenv("USERFRONT_URL", "http://localhost:5000") 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)) 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) { func TestVerifyLoginCode_MapsSmsPhoneBeforeFlowLookup(t *testing.T) {
redis := &mockRedisRepo{data: map[string]string{ redis := &mockRedisRepo{data: map[string]string{
prefixLoginCode + "su-@samaneng.com": "flow-123", prefixLoginCode + "su-@samaneng.com": "flow-123",
@@ -224,6 +389,70 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
assert.Equal(t, "expired_token", got["code"]) 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) { func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
t.Setenv("BACKEND_PUBLIC_URL", "") t.Setenv("BACKEND_PUBLIC_URL", "")
@@ -412,6 +641,9 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
_ = json.NewDecoder(resp.Body).Decode(&pollResp) _ = json.NewDecoder(resp.Body).Decode(&pollResp)
assert.Equal(t, "http://rp/cb", pollResp["redirectTo"]) assert.Equal(t, "http://rp/cb", pollResp["redirectTo"])
assert.Equal(t, "ok", pollResp["status"]) 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) { if assert.Len(t, auditRepo.logs, 1) {
assert.Contains(t, auditRepo.logs[0].EventType, "/api/v1/auth/") assert.Contains(t, auditRepo.logs[0].EventType, "/api/v1/auth/")
details, err := parseAuditDetails(auditRepo.logs[0].Details) 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"]) 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"])
}

View File

@@ -968,6 +968,176 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
if _, ok := got["sessionJwt"]; ok { if _, ok := got["sessionJwt"]; ok {
t.Fatalf("expected headless response to omit sessionJwt, got %v", got["sessionJwt"]) 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) { 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) { func TestPasswordLogin_ArchivedUserRejected(t *testing.T) {
mockIdp := new(MockIdentityProvider) mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{ mockIdp.On("SignIn", "archived@example.com", "password").Return(&domain.AuthInfo{