forked from baron/baron-sso
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping) - Implemented idempotency and visibility restoration for soft-deleted users - Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields - Added logic to reclaim identifiers (login_id) from colliding records - Added frontend E2E and backend unit tests for UUID integrity and conflict handling - Fixed i18n, formatting, and mock tests to satisfy code-check - Applied 'go fix' for 'omitzero' tags and general Go standards
905 lines
31 KiB
Go
905 lines
31 KiB
Go
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"])
|
|
}
|