Skip to content
Merged
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
5 changes: 3 additions & 2 deletions cmd/server/service/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ 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 operations
constants.UserIdentityLinkSubject: mhs.messageHandler.LinkIdentity,
// identity linking/unlinking operations
constants.UserIdentityLinkSubject: mhs.messageHandler.LinkIdentity,
constants.UserIdentityUnlinkSubject: mhs.messageHandler.UnlinkIdentity,
}

handler, ok := handlers[subject]
Expand Down
4 changes: 4 additions & 0 deletions cmd/server/service/providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ func QueueSubscriptions(ctx context.Context) error {
service.WithIdentityLinkerForMessageHandler(
userReaderWriter,
),
service.WithIdentityUnlinkerForMessageHandler(
userReaderWriter,
),
),
}

Expand All @@ -216,6 +219,7 @@ func QueueSubscriptions(ctx context.Context) error {
constants.EmailLinkingSendVerificationSubject: messageHandlerService.HandleMessage,
constants.EmailLinkingVerifySubject: messageHandlerService.HandleMessage,
constants.UserIdentityLinkSubject: messageHandlerService.HandleMessage,
constants.UserIdentityUnlinkSubject: messageHandlerService.HandleMessage,
// Add more subjects here as needed
}

Expand Down
111 changes: 78 additions & 33 deletions docs/identity_linking.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# User Identity Linking

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

---

## Link Verified Email Identity
## Important Notes

After successfully verifying an email address and receiving an ID token, link the verified email identity to the user's account.
- The `user_id` is automatically extracted from the `sub` claim of `user.auth_token` — it does not need to be provided explicitly
- The Auth Service uses the **user's token** (not the service's M2M credentials) to call the identity provider, ensuring the operation is scoped to the user's own permissions
- These subjects are **only supported for Auth0**. Authelia and mock implementations do not support this functionality yet.

**Subject:** `lfx.auth-service.user_identity.link`
---

## 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.

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

### Request Payload

The request payload must be a JSON object containing the user's JWT token and the ID token from the email verification step:

```json
{
"user": {
Expand All @@ -28,41 +34,30 @@ The request payload must be a JSON object containing the user's JWT token and th

### Required Fields

- `user.auth_token`: A JWT access token for the Auth0 Management API with the `update:current_user_identities` scope. The `user_id` will be automatically extracted from the `sub` claim of this token.
- `link_with.identity_token`: The ID token obtained from the email verification process that contains the verified email identity
- `user.auth_token`: A JWT access token with the `update:current_user_identities` scope.
- `link_with.identity_token`: The ID token representing the identity to be linked. For the email verification flow, this is the token received after completing the OTP verification step.

### Reply

The service links the verified email identity to the user account without changing the user's current global session:

**Success Reply:**
**Success:**
```json
{
"success": true,
"message": "identity linked successfully"
}
```

**Error Reply (Invalid Token):**
```json
{
"success": false,
"error": "jwt verify failed for link identity"
}
```

**Error Reply (Link Failed):**
**Error:**
```json
{
"success": false,
"error": "failed to link identity to user"
"error": "<error message>"
}
```

### Example using NATS CLI

```bash
# Link the verified email identity to the user account
nats request lfx.auth-service.user_identity.link '{
"user": {
"auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
Expand All @@ -71,21 +66,71 @@ nats request lfx.auth-service.user_identity.link '{
"identity_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}'
```

---

## Unlink Identity

Removes a secondary identity (e.g. Google, LinkedIn, GitHub) from the user's account.

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

### Request Payload

```json
{
"user": {
"auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
},
"unlink": {
"provider": "linkedin",
"identity_id": "QhNK44iR6W"
}
}
```

### Required Fields

- `user.auth_token`: A JWT access token with the `update:current_user_identities` scope.
- `unlink.provider`: The identity provider to unlink (e.g. `google-oauth2`, `linkedin`, `github`).
- `unlink.identity_id`: The identity's ID as returned by the identity provider. This must be retrieved directly from the identity provider since there is no dedicated subject for listing identities at this time.

### Reply

# Expected response: {"success":true,"message":"identity linked successfully"}
**Success:**
```json
{
"success": true,
"message": "identity unlinked successfully"
}
```

### Important Notes
**Error:**
```json
{
"success": false,
"error": "<error message>"
}
```

- The SSR application must provide the user's JWT token (`user.auth_token`) with the `update:current_user_identities` scope
- The Auth Service automatically extracts the `user_id` from the `sub` claim of the user's token
- The Auth Service verifies the JWT token signature and validates the required scope before processing
- The Auth Service uses the **user's token** (not the service's M2M credentials) to call the Auth0 Management API
- This ensures the operation is performed with the user's permissions and does not change their current global session
- The `link_with.identity_token` field contains the ID token from the email verification process with the verified email information that will be linked to the user account
- This feature is **only supported for Auth0**. Authelia and mock implementations do not support this functionality yet.
### Example using NATS CLI

### Complete Flow
```bash
nats request lfx.auth-service.user_identity.unlink '{
"user": {
"auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
},
"unlink": {
"provider": "linkedin",
"identity_id": "QhNK44iR6W"
}
}'
```

---

For a complete understanding of how this operation fits into the email verification and linking flow, see the [Email Verification Documentation](email_verification.md#complete-email-verification-and-linking-flow) which includes a comprehensive flow diagram showing all three steps (Steps 1-2: Email Verification, Step 3: Identity Linking).
## Email Verification Flow

When linking an email identity, `lfx.auth-service.user_identity.link` is used as the final step after completing the OTP verification. For the complete flow see [Email Verification Documentation](email_verification.md#complete-email-verification-and-linking-flow).
19 changes: 19 additions & 0 deletions internal/domain/model/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,22 @@ type LinkIdentity struct {
IdentityToken string `json:"identity_token"`
} `json:"link_with"`
}

// UnlinkIdentity represents a request to unlink a secondary identity from a user account.
type UnlinkIdentity struct {
// User contains the authenticated user's information needed to authorize the unlinking action.
User struct {
// UserID is the primary user's ID, populated from the auth_token sub claim.
UserID string `json:"user_id"`
// AuthToken is the JWT token with the update:current_user_identities scope.
AuthToken string `json:"auth_token"`
} `json:"user"`

// Unlink contains the secondary identity to be removed.
Unlink struct {
// Provider is the identity provider of the secondary account (e.g. "google-oauth2", "auth0").
Provider string `json:"provider"`
// IdentityID is the identity's user_id as returned by the identity provider (the part after the "|").
IdentityID string `json:"identity_id"`
} `json:"unlink"`
}
1 change: 1 addition & 0 deletions internal/domain/port/message_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type UserLinkHandler interface {
// IdentityLinkingHandler defines the behavior of the identity linking domain handlers
type IdentityLinkingHandler interface {
LinkIdentity(ctx context.Context, msg TransportMessenger) ([]byte, error)
UnlinkIdentity(ctx context.Context, msg TransportMessenger) ([]byte, error)
}

// EmailLinkingHandler defines the behavior of the email linking domain handlers
Expand Down
1 change: 1 addition & 0 deletions internal/domain/port/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type UserWriter interface {
// IdentityLinker defines the behavior of the identity linker
type IdentityLinker interface {
LinkIdentity(ctx context.Context, request *model.LinkIdentity) error
UnlinkIdentity(ctx context.Context, request *model.UnlinkIdentity) error
}

// EmailHandler defines the behavior of the email handler
Expand Down
70 changes: 69 additions & 1 deletion internal/infrastructure/auth0/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"

"github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors"
Expand Down Expand Up @@ -51,7 +52,7 @@ func (ilf *identityLinkingFlow) LinkIdentityToUser(ctx context.Context, userID,
// Call Auth0 Management API to link the identity
// IMPORTANT: Using the user's management API token (with update:current_user_identities scope)
// NOT the service's M2M credentials
url := fmt.Sprintf("https://%s/api/v2/users/%s/identities", ilf.domain, userID)
url := fmt.Sprintf("https://%s/api/v2/users/%s/identities", ilf.domain, url.PathEscape(userID))

apiRequest := httpclient.NewAPIRequest(
ilf.httpClient,
Expand Down Expand Up @@ -82,6 +83,73 @@ func (ilf *identityLinkingFlow) LinkIdentityToUser(ctx context.Context, userID,
return nil
}

// UnlinkIdentityFromUser removes a secondary identity from an existing user account.
// This uses the Auth0 Management API endpoint DELETE /api/v2/users/{id}/identities/{provider}/{user_id}
// with the user's JWT token (with update:current_user_identities scope), not the service's credentials.
func (ilf *identityLinkingFlow) UnlinkIdentityFromUser(ctx context.Context, primaryUserID, userToken, provider, secondaryUserID string) error {
if ilf == nil || ilf.httpClient == nil {
return errors.NewUnexpected("identity linking flow not configured")
}

if strings.TrimSpace(primaryUserID) == "" {
return errors.NewValidation("user_id is required")
}

if userToken == "" {
return errors.NewValidation("user_token is required")
}

if strings.TrimSpace(provider) == "" {
return errors.NewValidation("provider is required")
}

if strings.TrimSpace(secondaryUserID) == "" {
return errors.NewValidation("identity_id is required")
}

slog.DebugContext(ctx, "unlinking identity from user",
"user_id", redaction.Redact(primaryUserID),
"provider", provider,
)

// Call Auth0 Management API to unlink the identity
// IMPORTANT: Using the user's management API token (with update:current_user_identities scope)
// NOT the service's M2M credentials
url := fmt.Sprintf("https://%s/api/v2/users/%s/identities/%s/%s",
ilf.domain,
url.PathEscape(primaryUserID),
url.PathEscape(provider),
url.PathEscape(secondaryUserID),
)

apiRequest := httpclient.NewAPIRequest(
ilf.httpClient,
httpclient.WithMethod(http.MethodDelete),
httpclient.WithURL(url),
httpclient.WithToken(userToken),
httpclient.WithDescription("unlink identity from user"),
)

// The response is an array of remaining identities
var remainingIdentities []any
statusCode, errCall := apiRequest.Call(ctx, &remainingIdentities)
if errCall != nil {
slog.ErrorContext(ctx, "failed to unlink identity from user",
"error", errCall,
"status_code", statusCode,
"user_id", redaction.Redact(primaryUserID),
)
return errors.NewUnexpected("failed to unlink identity from user", errCall)
}

slog.DebugContext(ctx, "identity unlinked successfully",
"user_id", redaction.Redact(primaryUserID),
"status_code", statusCode,
)

return nil
}

// newIdentityLinkingFlow creates a new IdentityLinkingFlow with the provided configuration
func newIdentityLinkingFlow(domain string, httpClient *httpclient.Client) *identityLinkingFlow {
return &identityLinkingFlow{
Expand Down
3 changes: 2 additions & 1 deletion internal/infrastructure/auth0/jwt_parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/golang-jwt/jwt/v5"
"github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/model"
"github.com/linuxfoundation/lfx-v2-auth-service/pkg/constants"
"github.com/linuxfoundation/lfx-v2-auth-service/pkg/httpclient"
)

Expand Down Expand Up @@ -74,7 +75,7 @@ func TestJWTVerification(t *testing.T) {
Token: tt.token,
}

claims, err := jwtVerify.JWTVerify(ctx, user.Token, userUpdateRequiredScope)
claims, err := jwtVerify.JWTVerify(ctx, user.Token, constants.UserUpdateMetadataRequiredScope)

if tt.expectError {
if err == nil {
Expand Down
Loading
Loading