Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/server/service/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ func (mhs *MessageHandlerService) HandleMessage(ctx context.Context, msg port.Tr
// email linking operations
constants.EmailLinkingSendVerificationSubject: mhs.messageHandler.StartEmailLinking,
constants.EmailLinkingVerifySubject: mhs.messageHandler.VerifyEmailLinking,
// identity linking/unlinking operations
// identity linking/unlinking/listing operations
constants.UserIdentityLinkSubject: mhs.messageHandler.LinkIdentity,
constants.UserIdentityUnlinkSubject: mhs.messageHandler.UnlinkIdentity,
constants.UserIdentityListSubject: mhs.messageHandler.ListIdentities,
}

handler, ok := handlers[subject]
Expand Down
1 change: 1 addition & 0 deletions cmd/server/service/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ func QueueSubscriptions(ctx context.Context) error {
constants.EmailLinkingVerifySubject: messageHandlerService.HandleMessage,
constants.UserIdentityLinkSubject: messageHandlerService.HandleMessage,
constants.UserIdentityUnlinkSubject: messageHandlerService.HandleMessage,
constants.UserIdentityListSubject: messageHandlerService.HandleMessage,
// Add more subjects here as needed
}

Expand Down
62 changes: 60 additions & 2 deletions docs/identity_linking.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# User Identity Linking
# User Identity Operations

This document describes the NATS subjects for linking and unlinking identities (social providers, email, etc.) to and from user accounts.
This document describes the NATS subjects for listing, linking, and unlinking identities (social providers, email, etc.) to and from user accounts.

---

Expand All @@ -11,6 +11,64 @@ This document describes the NATS subjects for linking and unlinking identities (

---

## List Identities

Retrieves all identities linked to the authenticated user's account.

**Subject:** `lfx.auth-service.user_identity.list`
**Pattern:** Request/Reply

### Request Payload

```json
{
"user": {
"auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
```

### Reply

**Success Reply:**
```json
{
"success": true,
"data": [
{
"provider": "google-oauth2",
"user_id": "google123",
"isSocial": true,
"profileData": {
"email": "user@gmail.com",
"email_verified": true
}
},
{
"provider": "github",
"user_id": "gh456",
"isSocial": true
}
]
}
```

**Error Reply:**
```json
{
"success": false,
"error": "auth_token is required"
}
```

### Example using NATS CLI

```bash
nats request lfx.auth-service.user_identity.list '{"user":{"auth_token":"eyJhbG..."}}'
```

---

## Link Identity

Links a verified identity to the user's account. The identity can come from any provider (e.g. Google, LinkedIn, GitHub) or from the email verification flow — in which case the `identity_token` is the ID token received after successfully verifying an email address.
Expand Down
1 change: 1 addition & 0 deletions internal/domain/port/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type UserHandler interface {
type UserReaderHandler interface {
GetUserMetadata(ctx context.Context, msg TransportMessenger) ([]byte, error)
GetUserEmails(ctx context.Context, msg TransportMessenger) ([]byte, error)
ListIdentities(ctx context.Context, msg TransportMessenger) ([]byte, error)
}

// UserLookupHandler defines the behavior of the user lookup domain handlers
Expand Down
15 changes: 15 additions & 0 deletions internal/infrastructure/auth0/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth0

import (
"encoding/json"
"fmt"
"log/slog"

"github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/model"
Expand Down Expand Up @@ -90,11 +91,25 @@ func (u *Auth0User) ToUser() *model.User {
alternateEmails = append(alternateEmails, alternateEmail)
}

var identities []model.Identity
for _, auth0Id := range u.Identities {
identity := model.Identity{
Provider: auth0Id.Provider,
IdentityID: fmt.Sprintf("%v", auth0Id.UserID),
IsSocial: auth0Id.IsSocial,
}
if auth0Id.ProfileData != nil {
identity.Email = auth0Id.ProfileData.Email
}
identities = append(identities, identity)
}
Comment on lines +94 to +105
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new ToUser() mapping now includes Identities, but there’s no unit test covering this conversion (including edge cases like non-string Auth0Identity.UserID and presence/absence of profileData). Since this is a regression-prone mapping that impacts the new user_identity.list behavior, add a focused test around Auth0User.ToUser() to assert identities are populated and correctly converted.

Copilot uses AI. Check for mistakes.

return &model.User{
UserID: u.UserID,
Username: u.Username,
PrimaryEmail: u.Email,
AlternateEmails: alternateEmails,
Identities: identities,
UserMetadata: meta,
}
}
Expand Down
86 changes: 86 additions & 0 deletions internal/service/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,92 @@ func (m *messageHandlerOrchestrator) GetUserEmails(ctx context.Context, msg port
return responseJSON, nil
}

// identityListRequest represents the input for listing user identities
type identityListRequest struct {
User struct {
AuthToken string `json:"auth_token"`
} `json:"user"`
}

// identityResponse is the response DTO matching the UI's expected format
type identityResponse struct {
Provider string `json:"provider"`
UserID string `json:"user_id"`
IsSocial bool `json:"isSocial"`
ProfileData *identityProfileData `json:"profileData,omitempty"`
}

type identityProfileData struct {
Email string `json:"email,omitempty"`
EmailVerified bool `json:"email_verified,omitempty"`
}

// ListIdentities retrieves the user's linked identities
func (m *messageHandlerOrchestrator) ListIdentities(ctx context.Context, msg port.TransportMessenger) ([]byte, error) {

if m.userReader == nil {
return m.errorResponse("auth service unavailable"), nil
}

var request identityListRequest
if err := json.Unmarshal(msg.Data(), &request); err != nil {
return m.errorResponse("failed to unmarshal request"), nil
}

authToken := strings.TrimSpace(request.User.AuthToken)
if authToken == "" {
return m.errorResponse("auth_token is required"), nil
}

slog.DebugContext(ctx, "list identities",
"input", redaction.Redact(authToken),
)

user, err := m.userReader.MetadataLookup(ctx, authToken)
if err != nil {
slog.ErrorContext(ctx, "error looking up user for identity list",
"error", err,
)
return m.errorResponse(err.Error()), nil
}

fullUser, err := m.userReader.GetUser(ctx, user)
if err != nil {
slog.ErrorContext(ctx, "error getting user for identity list",
"error", err,
)
return m.errorResponse(err.Error()), nil
}

identities := make([]identityResponse, 0, len(fullUser.Identities))
for _, id := range fullUser.Identities {
resp := identityResponse{
Provider: id.Provider,
UserID: id.IdentityID,
IsSocial: id.IsSocial,
}
if id.Email != "" {
resp.ProfileData = &identityProfileData{
Email: id.Email,
EmailVerified: true,
Comment on lines +295 to +296
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListIdentities sets email_verified to true whenever an identity has a non-empty email, but there’s no source-of-truth for verification status in model.Identity (only Email is stored). This can return incorrect data (e.g., for unverified provider emails) and makes the response misleading. Consider either omitting email_verified entirely (don’t set it so it’s omitted via omitempty), or extending the domain identity model to carry verification status (populated from Auth0 profileData.email_verified) and mapping that through.

Suggested change
Email: id.Email,
EmailVerified: true,
Email: id.Email,

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +293 to +298
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if email_verified is used elsewhere in identity-related code
rg -n "email_verified|EmailVerified" --type=go -C2

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 8651


🏁 Script executed:

# Find the model.Identity structure definition
fd -n "models\|identity" --type=go | grep -E "model|pkg" | head -20

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 309


🏁 Script executed:

# Get full context around the problematic lines in message_handler.go
cat -n internal/service/message_handler.go | sed -n '280,310p'

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 1024


🏁 Script executed:

# Search for model.Identity definition
rg -n "type Identity struct" --type=go -A 15

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 1476


🏁 Script executed:

# Find where fullUser.Identities is populated
rg -n "Identities\s*=" --type=go -B 5 -A 5 | head -80

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 4356


🏁 Script executed:

# Check how Auth0 identities are mapped to model.Identity
rg -n "model\.Identity|Identity{" --type=go -B 3 -A 3 | grep -A 8 -B 8 "Email"

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 192


🏁 Script executed:

# Look at Auth0 integration to understand when emails are unverified
rg -n "EmailVerified.*false" --type=go -B 3 -A 3

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 1739


🏁 Script executed:

# Check API endpoint that returns this data
rg -n "GetIdentities|identities\|email_verified" --type=go | head -40

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 61


🏁 Script executed:

# Search for tests that verify the email_verified response field
rg -n "email_verified.*true|email_verified.*false" --type=go -B 3 -A 3

Repository: linuxfoundation/lfx-v2-auth-service

Length of output: 519


EmailVerified hardcoded as true due to missing field in model.Identity.

The issue is real: Auth0ProfileData includes EmailVerified (which can be false for passwordless flows), but model.Identity lacks this field entirely. When identities are constructed in authelia/user.go and mock/user.go, the EmailVerified status is discarded. The response then hardcodes true for any identity with an email, misrepresenting unverified emails as verified.

Consider extending model.Identity to include EmailVerified and propagating it from Auth0, or omit email_verified from the response if accurate status cannot be provided.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/message_handler.go` around lines 293 - 298, The code
currently hardcodes EmailVerified=true in identityProfileData inside
message_handler.go because model.Identity lacks an EmailVerified field; update
model.Identity to include EmailVerified bool, propagate that value when building
identities from Auth0 (use Auth0ProfileData.EmailVerified in authelia/user.go)
and in mock identities (mock/user.go), and then change the message_handler.go
block that sets resp.ProfileData to use id.EmailVerified instead of true (or
omit the field when unknown). Ensure constructors and tests are updated to set
or default EmailVerified appropriately.

identities = append(identities, resp)
}

response := UserDataResponse{
Success: true,
Data: identities,
}

responseJSON, err := json.Marshal(response)
if err != nil {
return m.errorResponse("failed to marshal response"), nil
}

return responseJSON, nil
}

// UpdateUser updates the user in the identity provider
func (m *messageHandlerOrchestrator) UpdateUser(ctx context.Context, msg port.TransportMessenger) ([]byte, error) {

Expand Down
163 changes: 163 additions & 0 deletions internal/service/message_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1476,3 +1476,166 @@ func TestMessageHandlerOrchestrator_LinkIdentity(t *testing.T) {
})
}
}

func TestMessageHandlerOrchestrator_ListIdentities(t *testing.T) {
ctx := context.Background()

tests := []struct {
name string
messageData []byte
mockReader *mockUserServiceReader
expectSuccess bool
expectError string
validateIdentities func(t *testing.T, result []byte)
}{
{
name: "successful list with identities",
messageData: []byte(`{"user":{"auth_token":"valid-token"}}`),
mockReader: &mockUserServiceReader{
metadataLookupFunc: func(ctx context.Context, input string) (*model.User, error) {
return &model.User{UserID: "auth0|123", Token: input}, nil
},
getUserFunc: func(ctx context.Context, user *model.User) (*model.User, error) {
return &model.User{
UserID: "auth0|123",
Identities: []model.Identity{
{Provider: "google-oauth2", IdentityID: "google123", Email: "user@gmail.com", IsSocial: true},
{Provider: "github", IdentityID: "gh456", IsSocial: true},
},
}, nil
},
},
expectSuccess: true,
validateIdentities: func(t *testing.T, result []byte) {
var response struct {
Success bool `json:"success"`
Data []identityResponse `json:"data"`
}
if err := json.Unmarshal(result, &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response.Data) != 2 {
t.Fatalf("expected 2 identities, got %d", len(response.Data))
}
if response.Data[0].Provider != "google-oauth2" {
t.Errorf("expected provider google-oauth2, got %s", response.Data[0].Provider)
}
if response.Data[0].UserID != "google123" {
t.Errorf("expected user_id google123, got %s", response.Data[0].UserID)
}
if !response.Data[0].IsSocial {
t.Error("expected isSocial true")
}
if response.Data[0].ProfileData == nil || response.Data[0].ProfileData.Email != "user@gmail.com" {
t.Error("expected profileData with email")
}
if response.Data[1].ProfileData != nil {
t.Error("expected nil profileData for identity without email")
}
},
},
{
name: "successful list with no identities",
messageData: []byte(`{"user":{"auth_token":"valid-token"}}`),
mockReader: &mockUserServiceReader{
metadataLookupFunc: func(ctx context.Context, input string) (*model.User, error) {
return &model.User{UserID: "auth0|123", Token: input}, nil
},
getUserFunc: func(ctx context.Context, user *model.User) (*model.User, error) {
return &model.User{UserID: "auth0|123", Identities: nil}, nil
},
},
expectSuccess: true,
validateIdentities: func(t *testing.T, result []byte) {
var response struct {
Success bool `json:"success"`
Data []identityResponse `json:"data"`
}
if err := json.Unmarshal(result, &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(response.Data) != 0 {
t.Errorf("expected 0 identities, got %d", len(response.Data))
}
},
},
{
name: "missing auth_token",
messageData: []byte(`{"user":{"auth_token":""}}`),
mockReader: &mockUserServiceReader{},
expectSuccess: false,
expectError: "auth_token is required",
},
{
name: "invalid json payload",
messageData: []byte(`not-json`),
mockReader: &mockUserServiceReader{},
expectSuccess: false,
expectError: "failed to unmarshal request",
},
{
name: "reader unavailable",
messageData: []byte(`{"user":{"auth_token":"token"}}`),
mockReader: nil, // handler created without WithUserReaderForMessageHandler
expectSuccess: false,
expectError: "auth service unavailable",
},
{
name: "metadata lookup failure",
messageData: []byte(`{"user":{"auth_token":"bad-token"}}`),
mockReader: &mockUserServiceReader{
metadataLookupFunc: func(ctx context.Context, input string) (*model.User, error) {
return nil, errors.NewValidation("invalid token")
},
},
expectSuccess: false,
expectError: "invalid token",
},
{
name: "get user failure",
messageData: []byte(`{"user":{"auth_token":"valid-token"}}`),
mockReader: &mockUserServiceReader{
metadataLookupFunc: func(ctx context.Context, input string) (*model.User, error) {
return &model.User{UserID: "auth0|123", Token: input}, nil
},
getUserFunc: func(ctx context.Context, user *model.User) (*model.User, error) {
return nil, errors.NewNotFound("user not found")
},
},
expectSuccess: false,
expectError: "user not found",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := &mockTransportMessenger{data: tt.messageData}

var opts []messageHandlerOrchestratorOption
if tt.mockReader != nil {
opts = append(opts, WithUserReaderForMessageHandler(tt.mockReader))
}
handler := NewMessageHandlerOrchestrator(opts...)

result, err := handler.ListIdentities(ctx, msg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var response UserDataResponse
if err := json.Unmarshal(result, &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}

if response.Success != tt.expectSuccess {
t.Errorf("success = %v, want %v (error: %s)", response.Success, tt.expectSuccess, response.Error)
}
if tt.expectError != "" && response.Error != tt.expectError {
t.Errorf("error = %q, want %q", response.Error, tt.expectError)
}
if tt.validateIdentities != nil {
tt.validateIdentities(t, result)
}
})
}
}
Loading
Loading