diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 5b3f415e..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"` @@ -2675,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) @@ -4119,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 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"]) + } }