Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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)
}

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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I tend to avoid models in the service/use case layer because if I need to perform business logic on them, I would do it in the domain layer, like in internal/domain/model/identity.go. But it’s just a design perspective, so you can go ahead as is.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

👏 Nice work here, because there’s a fallback to use an M2M token in GetUser, but in this case we need to make sure the user is using their own token with the read:current_user scope.

If you want to validate this a bit more, you can also add this here:

-- pkg/constants/user.go

const (
	...
	UserReadRequiredScope = "read:current_user"
)
...

user, err := m.userReader.MetadataLookup(ctx, authToken, constants.UserReadRequiredScope)

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,
}
}
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