diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 0622bf74..5fd7a986 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -109,6 +109,7 @@ type signupState struct { type headlessLinkState struct { ClientID string `json:"clientId"` + ClientName string `json:"clientName,omitempty"` LoginChallenge string `json:"loginChallenge"` LoginID string `json:"loginId"` RedirectTo string `json:"redirectTo,omitempty"` @@ -2520,6 +2521,8 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error { c.Locals("user_id", authInfo.Subject) c.Locals("login_id", loginID) setSessionIDLocal(c, authInfo.SessionToken) + attachAuditClientDetails(c, loginReq.Client) + appendAuditDetail(c, "login_challenge", loginChallenge) acceptResp, err := h.Hydra.AcceptLoginRequest(c.Context(), loginChallenge, authInfo.Subject) if err != nil { @@ -2673,6 +2676,7 @@ func (h *AuthHandler) HeadlessLinkInit(c *fiber.Ctx) error { } h.storeHeadlessLinkState(pendingRef, headlessLinkState{ ClientID: clientID, + ClientName: strings.TrimSpace(loginReq.Client.ClientName), LoginChallenge: loginChallenge, LoginID: resolvedLoginID, }, ttl) @@ -2903,6 +2907,19 @@ func attachAuditClientDetails(c *fiber.Ctx, client domain.HydraClient) { }) } +func appendAuditDetail(c *fiber.Ctx, key string, value any) { + if c == nil || strings.TrimSpace(key) == "" || value == nil { + return + } + + extra, _ := c.Locals("audit_details_extra").(map[string]any) + if extra == nil { + extra = make(map[string]any) + } + extra[key] = value + c.Locals("audit_details_extra", extra) +} + // InitiatePasswordReset - 사용자가 비밀번호 재설정을 시작하면, loginID 유형에 따라 이메일 또는 SMS를 보냅니다. func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { startTime := time.Now() @@ -4104,6 +4121,21 @@ func (h *AuthHandler) writeLinkAuditLog(loginID, pendingRef string, sessionToken if rawLoginID != "" && rawLoginID != loginID { details["login_id_effective"] = loginID } + if state, ok := h.loadHeadlessLinkState(pendingRef); ok { + if strings.TrimSpace(state.ClientID) != "" { + details["client_id"] = strings.TrimSpace(state.ClientID) + } + clientName := strings.TrimSpace(state.ClientName) + if clientName == "" && strings.TrimSpace(state.ClientID) != "" { + clientName = strings.TrimSpace(state.ClientID) + } + if clientName != "" { + details["client_name"] = clientName + } + if strings.TrimSpace(state.LoginChallenge) != "" { + details["login_challenge"] = strings.TrimSpace(state.LoginChallenge) + } + } if approverMeta, ok := h.loadLoginApproverMeta(pendingRef); ok { if approverMeta.IPAddress != "" { details["approved_ip"] = approverMeta.IPAddress @@ -4423,7 +4455,8 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { path := strings.ToLower(extractAuditPath(log)) isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept") - isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") + isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login") || + strings.Contains(path, "/api/v1/auth/headless/password/login") // 우선 audit details의 client 정보를 사용 if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil { @@ -5696,7 +5729,8 @@ func deriveAuthMethod(log domain.AuditLog) string { } switch { - case strings.Contains(path, "/api/v1/auth/password/login"): + case strings.Contains(path, "/api/v1/auth/password/login"), + strings.Contains(path, "/api/v1/auth/headless/password/login"): if kind == "email" { return "비밀번호(Email)" } @@ -7363,6 +7397,7 @@ func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) "consent.granted", "POST /api/v1/auth/oidc/login/accept", "POST /api/v1/auth/password/login", + "POST /api/v1/auth/headless/password/login", "POST /api/v1/auth/magic-link/verify", "POST /api/v1/auth/login/code/verify", "POST /api/v1/auth/qr/approve", @@ -7476,7 +7511,8 @@ func deriveSessionClientInfo(log domain.AuditLog) (string, string) { appName = "코드 로그인" case strings.Contains(path, "/api/v1/auth/magic-link/verify"): appName = "링크 로그인" - case strings.Contains(path, "/api/v1/auth/password/login"): + case strings.Contains(path, "/api/v1/auth/password/login"), + strings.Contains(path, "/api/v1/auth/headless/password/login"): appName = "비밀번호 로그인" } } @@ -7547,6 +7583,7 @@ func (h *AuthHandler) loadSessionClientBindings(ctx context.Context, userID stri "consent.granted", "POST /api/v1/auth/oidc/login/accept", "POST /api/v1/auth/password/login", + "POST /api/v1/auth/headless/password/login", "password_login_success", "login_success", }, 200) diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 06619d4f..d82f1d26 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -243,11 +243,6 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { 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, @@ -261,12 +256,13 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { 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": jwksServer.URL + "/.well-known/jwks.json", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", }, }, }) @@ -280,12 +276,21 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { 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)}, @@ -343,4 +348,14 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { _ = json.NewDecoder(resp.Body).Decode(&pollResp) assert.Equal(t, "http://rp/cb", pollResp["redirectTo"]) assert.Equal(t, "ok", pollResp["status"]) + 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"]) + } } diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index d523c42b..08d06e2b 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -879,6 +879,129 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { } } +func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { + mockIdp := new(MockIdentityProvider) + mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ + SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"}, + Subject: "kratos-identity-id", + }, nil) + + privateKey, jwks := mustHeadlessRSAJWK(t) + jwksBody, _ := json.Marshal(jwks) + + 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: "Headless Login Portal", + 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", + }, + }, + }) + 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-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{ + IdpProvider: mockIdp, + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + HeadlessJWKS: service.NewHeadlessJWKSCacheService( + nil, + headlessClient, + ), + Hydra: &service.HydraAdminService{ + AdminURL: "http://hydra.test", + HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, + }, + } + + app := fiber.New() + app.Use(middleware.AuditMiddleware(middleware.AuditConfig{ + Repo: auditRepo, + BodyDump: true, + })) + app.Post("/api/v1/auth/headless/password/login", h.HeadlessPasswordLogin) + + clientAssertion := mustHeadlessClientAssertion( + t, + privateKey, + "headless-login-client", + "http://example.com/api/v1/auth/headless/password/login", + ) + body, _ := json.Marshal(map[string]string{ + "client_id": "headless-login-client", + "client_assertion": clientAssertion, + "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") + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36") + + resp, err := app.Test(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes)) + } + + if len(auditRepo.logs) != 1 { + t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs)) + } + + log := auditRepo.logs[0] + if log.EventType != "POST /api/v1/auth/headless/password/login" { + t.Fatalf("expected headless password login audit event, got %q", log.EventType) + } + if log.UserID != "kratos-identity-id" { + t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID) + } + if log.SessionID != "session-123" { + t.Fatalf("expected audit session_id session-123, got %q", log.SessionID) + } + + details, err := parseAuditDetails(log.Details) + if err != nil { + t.Fatalf("failed to parse audit details: %v", err) + } + if got, _ := details["client_id"].(string); got != "headless-login-client" { + t.Fatalf("expected client_id headless-login-client, got %v", details["client_id"]) + } + if got, _ := details["client_name"].(string); got != "Headless Login Portal" { + t.Fatalf("expected client_name Headless Login Portal, got %v", details["client_name"]) + } + if got, _ := details["login_challenge"].(string); got != "challenge-123" { + t.Fatalf("expected login_challenge challenge-123, got %v", details["login_challenge"]) + } +} + func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) { mockIdp := new(MockIdentityProvider) mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{ diff --git a/backend/internal/handler/auth_handler_sessions_test.go b/backend/internal/handler/auth_handler_sessions_test.go index 817daf86..8a12de2c 100644 --- a/backend/internal/handler/auth_handler_sessions_test.go +++ b/backend/internal/handler/auth_handler_sessions_test.go @@ -683,3 +683,158 @@ func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) { assert.Equal(t, "oathkeeper", body.Items[0].Source) } } + +func TestGetAuthTimeline_IncludesHeadlessPasswordLogin(t *testing.T) { + now := time.Date(2026, 4, 7, 5, 10, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + h := &AuthHandler{ + AuditRepo: &mockAuditRepo{ + logs: []domain.AuditLog{ + { + EventID: "audit-1", + Timestamp: now, + UserID: "user-123", + SessionID: "headless-session-1", + EventType: "POST /api/v1/auth/headless/password/login", + Status: "success", + IPAddress: "203.0.113.20", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", + Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1","login_id":"user@example.com","login_challenge":"challenge-123"}`, + }, + }, + }, + } + + app := fiber.New() + app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + ClientID string `json:"client_id"` + AppName string `json:"app_name"` + AuthMethod string `json:"auth_method"` + EventType string `json:"event_type"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 1) { + assert.Equal(t, "headless-session-1", body.Items[0].SessionID) + assert.Equal(t, "headless-login-client", body.Items[0].ClientID) + assert.Equal(t, "Headless Login Portal", body.Items[0].AppName) + assert.Equal(t, "비밀번호(Email)", body.Items[0].AuthMethod) + assert.Equal(t, "POST /api/v1/auth/headless/password/login", body.Items[0].EventType) + } +} + +func TestListMySessions_UsesHeadlessPasswordLoginForClientBinding(t *testing.T) { + now := time.Date(2026, 4, 7, 5, 35, 0, 0, time.UTC) + setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/sessions/whoami" { + return httpJSONAny(r, http.StatusOK, map[string]any{ + "id": "current-sid", + "authenticated_at": now.Format(time.RFC3339), + "identity": map[string]any{ + "id": "user-123", + "traits": map[string]any{ + "email": "user@example.com", + "name": "User", + "role": "user", + }, + }, + }), nil + } + return httpResponse(r, http.StatusNotFound, "not found"), nil + })) + + mockKratos := new(MockKratosAdminService) + mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{ + { + ID: "current-sid", + Active: true, + AuthenticatedAt: now, + ExpiresAt: now.Add(24 * time.Hour), + }, + { + ID: "headless-session-1", + Active: true, + AuthenticatedAt: now.Add(-10 * time.Minute), + ExpiresAt: now.Add(23*time.Hour + 50*time.Minute), + }, + }, nil).Once() + + auditRepo := &mockAuditRepo{ + logs: []domain.AuditLog{ + { + UserID: "user-123", + EventType: "POST /api/v1/auth/headless/password/login", + SessionID: "headless-session-1", + Timestamp: now, + IPAddress: "203.0.113.20", + UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", + Details: `{"client_id":"headless-login-client","client_name":"Headless Login Portal","session_id":"headless-session-1"}`, + }, + }, + } + + h := &AuthHandler{ + KratosAdmin: mockKratos, + AuditRepo: auditRepo, + } + + app := fiber.New() + app.Get("/api/v1/user/sessions", h.ListMySessions) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil) + req.Header.Set("Cookie", "ory_kratos_session=valid") + + resp, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Items []struct { + SessionID string `json:"session_id"` + AppName string `json:"app_name"` + ClientID string `json:"client_id"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + } `json:"items"` + } + err = json.NewDecoder(resp.Body).Decode(&body) + assert.NoError(t, err) + if assert.Len(t, body.Items, 2) { + assert.Equal(t, "headless-session-1", body.Items[1].SessionID) + assert.Equal(t, "Headless Login Portal", body.Items[1].AppName) + assert.Equal(t, "headless-login-client", body.Items[1].ClientID) + assert.Equal(t, "203.0.113.20", body.Items[1].IPAddress) + assert.Equal(t, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36", body.Items[1].UserAgent) + } + + mockKratos.AssertExpectations(t) +} diff --git a/userfront/lib/features/dashboard/presentation/audit_device_utils.dart b/userfront/lib/features/dashboard/presentation/audit_device_utils.dart new file mode 100644 index 00000000..415eb865 --- /dev/null +++ b/userfront/lib/features/dashboard/presentation/audit_device_utils.dart @@ -0,0 +1,31 @@ +import 'package:userfront/features/dashboard/domain/models.dart'; + +const headlessServerUserAgentSentinel = '__headless_server__'; + +bool looksLikeInternalAuditUserAgent(String userAgent) { + final lower = userAgent.trim().toLowerCase(); + return lower.startsWith('go-http-client/') || + lower.startsWith('fasthttp') || + lower.startsWith('fiber') || + lower.startsWith('undici') || + lower.startsWith('node'); +} + +String preferredAuditLogUserAgent(AuditLogEntry log) { + final userAgent = log.userAgent.trim(); + final path = log.path.toLowerCase(); + + final isHeadlessLinkLog = + path.contains('/api/v1/auth/magic-link/verify') || + path.contains('/api/v1/auth/login/code/verify'); + final isHeadlessPasswordLog = path.contains( + '/api/v1/auth/headless/password/login', + ); + + if ((isHeadlessLinkLog || isHeadlessPasswordLog) && + looksLikeInternalAuditUserAgent(userAgent)) { + return headlessServerUserAgentSentinel; + } + + return userAgent; +} diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 09649860..d9d7f8fb 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -25,6 +25,7 @@ import '../../../../core/ui/toast_service.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; import '../domain/models.dart' hide LinkedRp; +import 'audit_device_utils.dart'; import 'package:userfront/i18n.dart'; class DashboardScreen extends ConsumerStatefulWidget { @@ -690,6 +691,9 @@ class _DashboardScreenState extends ConsumerState { if (userAgent.isEmpty) { return tr('ui.common.hyphen', fallback: '-'); } + if (userAgent == headlessServerUserAgentSentinel) { + return 'Headless(Server)'; + } final ua = userAgent.toLowerCase(); if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) { return tr('ui.userfront.device.ios', fallback: 'Mobile(iOS)'); @@ -1234,6 +1238,9 @@ class _DashboardScreenState extends ConsumerState { } String _sessionBrowserLabel(String userAgent) { + if (userAgent == headlessServerUserAgentSentinel) { + return ''; + } final lower = userAgent.toLowerCase(); if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) { return ''; @@ -2164,10 +2171,15 @@ class _DashboardScreenState extends ConsumerState { final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel(); - final deviceLabel = _deviceLabelFromUserAgent( - log.userAgent, + final preferredUserAgent = preferredAuditLogUserAgent( + log, + ); + final deviceLabel = _deviceLabelFromUserAgent( + preferredUserAgent, + ); + final browserLabel = _sessionBrowserLabel( + preferredUserAgent, ); - final browserLabel = _sessionBrowserLabel(log.userAgent); return DataRow( cells: [ DataCell( @@ -2387,7 +2399,9 @@ class _DashboardScreenState extends ConsumerState { tr( 'msg.userfront.audit.device', params: { - 'value': _deviceLabelFromUserAgent(log.userAgent), + 'value': _deviceLabelFromUserAgent( + preferredAuditLogUserAgent(log), + ), }, ), ), @@ -2395,9 +2409,14 @@ class _DashboardScreenState extends ConsumerState { tr( 'msg.userfront.audit.browser', params: { - 'value': _sessionBrowserLabel(log.userAgent).isEmpty + 'value': + _sessionBrowserLabel( + preferredAuditLogUserAgent(log), + ).isEmpty ? tr('ui.common.hyphen', fallback: '-') - : _sessionBrowserLabel(log.userAgent), + : _sessionBrowserLabel( + preferredAuditLogUserAgent(log), + ), }, ), ), diff --git a/userfront/test/audit_device_utils_test.dart b/userfront/test/audit_device_utils_test.dart new file mode 100644 index 00000000..a8ed345e --- /dev/null +++ b/userfront/test/audit_device_utils_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:userfront/features/dashboard/domain/models.dart'; +import 'package:userfront/features/dashboard/presentation/audit_device_utils.dart'; + +AuditLogEntry _log({ + required String eventType, + String userAgent = '', + Map? details, +}) { + return AuditLogEntry.fromJson({ + 'event_id': 'audit-1', + 'timestamp': '2026-04-14T00:00:00Z', + 'user_id': 'user-123', + 'event_type': eventType, + 'status': 'success', + 'user_agent': userAgent, + 'details': details == null ? '' : details.toString(), + }); +} + +void main() { + test('headless link login maps internal client user agent to sentinel', () { + final log = AuditLogEntry.fromJson({ + 'event_id': 'audit-1', + 'timestamp': '2026-04-14T00:00:00Z', + 'user_id': 'user-123', + 'event_type': 'POST /api/v1/auth/login/code/verify', + 'status': 'success', + 'user_agent': 'undici', + 'details': + '{"approved_user_agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0.0.0 Safari/537.36"}', + }); + + expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel); + }); + + test( + 'headless password login maps internal client user agent to sentinel', + () { + final log = _log( + eventType: 'POST /api/v1/auth/headless/password/login', + userAgent: 'undici', + ); + + expect(preferredAuditLogUserAgent(log), headlessServerUserAgentSentinel); + }, + ); + + test('non-headless login preserves original browser user agent', () { + const browserUa = + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36'; + final log = _log( + eventType: 'POST /api/v1/auth/password/login', + userAgent: browserUa, + ); + + expect(preferredAuditLogUserAgent(log), browserUa); + }); +}