package handler import ( "baron-sso-backend/internal/domain" "baron-sso-backend/internal/service" "baron-sso-backend/internal/testsupport" "bytes" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/gofiber/fiber/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) // Mock services type mockEmailService struct { lastTo string lastSubject string lastBody string } func (m *mockEmailService) SendEmail(to, subject, body string) error { m.lastTo = to m.lastSubject = subject m.lastBody = body return nil } type mockSmsService struct { lastTo string lastContent string } func (m *mockSmsService) SendSms(to, content string) error { m.lastTo = to m.lastContent = content return nil } func newHeadlessLinkTestApp(h *AuthHandler) *fiber.App { app := fiber.New() app.Post("/api/v1/auth/headless/link/init", h.HeadlessLinkInit) app.Post("/api/v1/auth/headless/link/poll", h.HeadlessLinkPoll) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) 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]any{ "id": "session-123", "authenticated_at": "2026-05-21T00:00:00Z", "identity": map[string]any{ "id": identityID, "traits": map[string]any{ "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 idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } h := &AuthHandler{ RedisService: redis, IdpProvider: idp, EmailService: &mockEmailService{}, SmsService: &mockSmsService{}, } app := fiber.New() app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink) app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) app.Post("/api/v1/auth/magic-link/verify", h.VerifyMagicLink) t.Setenv("USERFRONT_URL", "http://userfront.test") // 1. Init Enchanted Link (Email) body, _ := json.Marshal(map[string]string{ "loginId": "user@example.com", "method": "email", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var initResp map[string]any json.NewDecoder(resp.Body).Decode(&initResp) pendingRef := initResp["pendingRef"].(string) assert.NotEmpty(t, pendingRef) // Find the token key "enchanted_token:..." in mock redis var token string for k := range redis.data { if len(k) > 16 && k[:16] == "enchanted_token:" { token = k[16:] break } } assert.NotEmpty(t, token) // 2. Verify Magic Link verifyBody, _ := json.Marshal(map[string]any{ "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) // 3. Poll (Success) pollBody, _ := json.Marshal(map[string]string{"pendingRef": pendingRef}) req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(pollBody)) req.Header.Set("Content-Type", "application/json") resp, _ = app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var pollResp map[string]any json.NewDecoder(resp.Body).Decode(&pollResp) assert.Equal(t, "ok", pollResp["status"]) assert.Equal(t, "valid-jwt", pollResp["sessionJwt"]) } func TestEnchantedLinkFlow_Sms_Success(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } h := &AuthHandler{ RedisService: redis, IdpProvider: idp, SmsService: &mockSmsService{}, } app := fiber.New() app.Post("/api/v1/auth/enchanted-link/init", h.InitEnchantedLink) // 1. Init Enchanted Link (SMS) body, _ := json.Marshal(map[string]string{ "loginId": "010-1234-5678", "method": "sms", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var initResp map[string]any json.NewDecoder(resp.Body).Decode(&initResp) 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]any{ "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]any _ = 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]any{ "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]any _ = 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]any{ "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]any _ = 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") h := &AuthHandler{} app := fiber.New() app.Get("/probe", func(c *fiber.Ctx) error { return c.SendString(h.resolveUserfrontURL(c)) }) req := httptest.NewRequest(http.MethodGet, "http://localhost/probe", nil) resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) body, _ := io.ReadAll(resp.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]any{ "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]any _ = 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", prefixLoginCodePending + "su-@samaneng.com": "pending-123", prefixLoginCodeSmsLookup + "+821041585840": "su-@samaneng.com", prefixLoginCodeSmsTarget + "su-@samaneng.com": "+821041585840", prefixLoginCodeValue + "pending-123": "569765", }} h := &AuthHandler{ RedisService: redis, IdpProvider: &mockIdpProvider{}, } app := fiber.New() app.Post("/api/v1/auth/login/code/verify", h.VerifyLoginCode) body, _ := json.Marshal(map[string]any{ "loginId": "01041585840", "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") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, "approved", got["status"]) assert.Equal(t, "pending-123", got["pendingRef"]) } func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) { redis := &mockRedisRepo{data: make(map[string]string)} h := &AuthHandler{ RedisService: redis, } app := fiber.New() app.Post("/api/v1/auth/enchanted-link/poll", h.PollEnchantedLink) body, _ := json.Marshal(map[string]string{ "pendingRef": "missing-ref", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/enchanted-link/poll", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]any json.NewDecoder(resp.Body).Decode(&got) assert.Equal(t, "expired_token", got["error"]) 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]any _ = 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]any _ = 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", "") 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) 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() idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if 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]any{ "status": "active", "headless_login_enabled": true, "headless_token_endpoint_auth_method": "private_key_jwt", "headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json", }, }, }) return } http.NotFound(w, r) }) h := &AuthHandler{ RedisService: redis, IdpProvider: idp, SmsService: &mockSmsService{}, Hydra: &service.HydraAdminService{ AdminURL: "http://hydra.test", HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, }, } app := newHeadlessLinkTestApp(h) t.Setenv("USERFRONT_URL", "http://userfront.test") 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/link/init"), "loginId": "010-1234-5678", "login_challenge": "challenge-123", }) req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/link/init", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) var got map[string]any _ = json.NewDecoder(resp.Body).Decode(&got) assert.NotEmpty(t, got["pendingRef"]) _, hasUserCode := got["userCode"] assert.False(t, hasUserCode) } func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(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) idp := &mockIdpProvider{ userExists: true, initiateLinkErr: domain.ErrNotSupported, } 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]any{ "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: _ = 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-identity-id", 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: idp, 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]any _ = 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]any{ "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) 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.StatusOK, resp.StatusCode) var pollResp map[string]any _ = 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) if err != nil { t.Fatalf("failed to parse audit details: %v", err) } assert.Equal(t, "headless-login-client", details["client_id"]) assert.Equal(t, "local-demo-rp", details["client_name"]) 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]any{ "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]any _ = 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]any{ "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]any _ = 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]any{ "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]any _ = 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]any{ "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]any _ = 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"]) }