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)
|
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 {
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
Reference in New Issue
Block a user