forked from baron/baron-sso
fix auth link session conflict policy
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user