From 2482f48b11076ee2648f786442b82c1172db5fa3 Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Fri, 24 Oct 2025 09:56:06 -0300 Subject: [PATCH 1/8] feat: implement email OTP functionality for passwordless flows - Added a new KV bucket configuration for storing email OTPs in the Helm chart. - Implemented the email sending logic for OTP generation and verification in the Authelia flow. - Enhanced the user model to support alternate email linking and verification processes. - Updated the NATS storage to handle OTP creation and retrieval, ensuring secure storage and access. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Generated with [Cursor](https://cursor.com/) Signed-off-by: Mauricio Zanetti Salomao --- .../templates/nats-kv-buckets.yaml | 22 + charts/lfx-v2-auth-service/values.yaml | 22 + internal/domain/model/email.go | 39 +- internal/domain/model/email_test.go | 192 ++++-- internal/domain/model/identity.go | 2 + internal/domain/model/user.go | 28 +- internal/domain/port/email_sender.go | 16 + internal/infrastructure/auth0/filter.go | 6 +- internal/infrastructure/auth0/filter_test.go | 70 +- internal/infrastructure/auth0/models.go | 18 +- internal/infrastructure/auth0/user.go | 28 +- internal/infrastructure/authelia/email.go | 67 ++ internal/infrastructure/authelia/models.go | 38 +- internal/infrastructure/authelia/storage.go | 150 ++++- internal/infrastructure/authelia/sync_test.go | 27 + internal/infrastructure/authelia/user.go | 218 +++++- internal/infrastructure/nats/client.go | 1 + internal/infrastructure/smtp/client.go | 75 +++ internal/infrastructure/smtp/sender.go | 73 +++ internal/service/message_handler.go | 12 +- pkg/constants/global.go | 21 + pkg/constants/storage.go | 3 + pkg/jwt/README.md | 413 ++++++++++++ pkg/jwt/generator.go | 375 +++++++++++ pkg/jwt/generator_test.go | 620 ++++++++++++++++++ pkg/jwt/parser.go | 13 +- pkg/password/generate.go | 20 + pkg/password/generate_test.go | 313 +++++++++ 28 files changed, 2694 insertions(+), 188 deletions(-) create mode 100644 internal/domain/port/email_sender.go create mode 100644 internal/infrastructure/authelia/email.go create mode 100644 internal/infrastructure/smtp/client.go create mode 100644 internal/infrastructure/smtp/sender.go create mode 100644 pkg/jwt/README.md create mode 100644 pkg/jwt/generator.go create mode 100644 pkg/jwt/generator_test.go create mode 100644 pkg/password/generate_test.go diff --git a/charts/lfx-v2-auth-service/templates/nats-kv-buckets.yaml b/charts/lfx-v2-auth-service/templates/nats-kv-buckets.yaml index 74008d2..23b288e 100644 --- a/charts/lfx-v2-auth-service/templates/nats-kv-buckets.yaml +++ b/charts/lfx-v2-auth-service/templates/nats-kv-buckets.yaml @@ -1,5 +1,7 @@ # Copyright The Linux Foundation and each contributor to LFX. # SPDX-License-Identifier: MIT + +# The following buckets are only for the authelia flow --- {{- if and .Values.nats.authelia_users_kv_bucket.creation (eq .Values.app.environment.USER_REPOSITORY_TYPE.value "authelia") }} apiVersion: jetstream.nats.io/v1beta2 @@ -19,3 +21,23 @@ spec: maxBytes: {{ .Values.nats.authelia_users_kv_bucket.maxBytes }} compression: {{ .Values.nats.authelia_users_kv_bucket.compression }} {{- end }} +--- +{{- if and .Values.nats.authelia_email_otp_kv_bucket.creation (eq .Values.app.environment.USER_REPOSITORY_TYPE.value "authelia") }} +apiVersion: jetstream.nats.io/v1beta2 +kind: KeyValue +metadata: + name: {{ .Values.nats.authelia_email_otp_kv_bucket.name }} + namespace: {{ .Release.Namespace }} + {{- if .Values.nats.authelia_email_otp_kv_bucket.keep }} + annotations: + "helm.sh/resource-policy": keep + {{- end }} +spec: + bucket: {{ .Values.nats.authelia_email_otp_kv_bucket.name }} + history: {{ .Values.nats.authelia_email_otp_kv_bucket.history }} + storage: {{ .Values.nats.authelia_email_otp_kv_bucket.storage }} + maxValueSize: {{ .Values.nats.authelia_email_otp_kv_bucket.maxValueSize }} + maxBytes: {{ .Values.nats.authelia_email_otp_kv_bucket.maxBytes }} + compression: {{ .Values.nats.authelia_email_otp_kv_bucket.compression }} + ttl: {{ .Values.nats.authelia_email_otp_kv_bucket.ttl }} +{{- end }} \ No newline at end of file diff --git a/charts/lfx-v2-auth-service/values.yaml b/charts/lfx-v2-auth-service/values.yaml index 8c3cc75..64c575f 100644 --- a/charts/lfx-v2-auth-service/values.yaml +++ b/charts/lfx-v2-auth-service/values.yaml @@ -42,6 +42,28 @@ nats: # compression is a boolean to determine if the KV bucket should be compressed compression: true + authelia_email_otp_kv_bucket: + # creation is a boolean to determine if the KV bucket should be created via the helm chart. + # set it to false if you want to use an existing KV bucket. + creation: true + # keep is a boolean to determine if the KV bucket should be preserved during helm uninstall + # set it to false if you want the bucket to be deleted when the chart is uninstalled + keep: true + # name is the name of the KV bucket for storing projects + name: authelia-email-otp + # history is the number of history entries to keep for the KV bucket + history: 1 + # storage is the storage type for the KV bucket + storage: file + # maxValueSize is the maximum size of a value in the KV bucket + maxValueSize: 1024 # 1KB (sufficient for OTP data) + # maxBytes is the maximum number of bytes in the KV bucket + maxBytes: 524288 # 512KB (smaller for local dev) + # compression is a boolean to determine if the KV bucket should be compressed + compression: true + # ttl is the time-to-live for entries in the bucket (5 minutes for OTPs) + ttl: 5m + # serviceAccount is the configuration for the Kubernetes service account ## This will be used only if the USER_REPOSITORY_TYPE is authelia serviceAccount: diff --git a/internal/domain/model/email.go b/internal/domain/model/email.go index a0247b3..be705fe 100644 --- a/internal/domain/model/email.go +++ b/internal/domain/model/email.go @@ -7,9 +7,9 @@ import "net/mail" // Email represents an email type Email struct { - OTP string `json:"otp"` - Email string `json:"email"` - EmailVerified bool `json:"email_verified"` + OTP string `json:"otp,omitempty"` + Email string `json:"email"` + Verified bool `json:"verified"` } // IsValidEmail checks if the email is valid according to RFC 5322 @@ -20,3 +20,36 @@ func (e *Email) IsValidEmail() bool { _, err := mail.ParseAddress(e.Email) return err == nil } + +// EmailMessage represents an email message to be sent +type EmailMessage struct { + // From is the sender email address + From string + // FromName is the sender name (optional) + FromName string + // To is the recipient email address + To string + // Subject is the email subject + Subject string + // Body is the email body content + Body string + // IsHTML indicates if the body is HTML formatted + IsHTML bool +} + +// IsValid checks if the email message has all required fields +func (e *EmailMessage) IsValid() bool { + if e.To == "" || e.Subject == "" || e.Body == "" { + return false + } + // Validate email addresses + if _, err := mail.ParseAddress(e.To); err != nil { + return false + } + if e.From != "" { + if _, err := mail.ParseAddress(e.From); err != nil { + return false + } + } + return true +} diff --git a/internal/domain/model/email_test.go b/internal/domain/model/email_test.go index 7eb12d3..b218474 100644 --- a/internal/domain/model/email_test.go +++ b/internal/domain/model/email_test.go @@ -77,117 +77,117 @@ func TestEmail_IsValidEmail(t *testing.T) { { name: "valid email with quoted display name", email: &Email{ - Email: "\"John Doe\" ", - EmailVerified: true, - OTP: "", + Email: "\"John Doe\" ", + Verified: true, + OTP: "", }, expected: true, }, { name: "empty email", email: &Email{ - Email: "", - EmailVerified: false, - OTP: "", + Email: "", + Verified: false, + OTP: "", }, expected: false, }, { name: "email with only spaces", email: &Email{ - Email: " ", - EmailVerified: false, - OTP: "", + Email: " ", + Verified: false, + OTP: "", }, expected: false, }, { name: "email missing @", email: &Email{ - Email: "userexample.com", - EmailVerified: false, - OTP: "", + Email: "userexample.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email missing domain", email: &Email{ - Email: "user@", - EmailVerified: false, - OTP: "", + Email: "user@", + Verified: false, + OTP: "", }, expected: false, }, { name: "email missing local part", email: &Email{ - Email: "@example.com", - EmailVerified: false, - OTP: "", + Email: "@example.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email with double @", email: &Email{ - Email: "user@@example.com", - EmailVerified: false, - OTP: "", + Email: "user@@example.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email with spaces in the middle", email: &Email{ - Email: "user name@example.com", - EmailVerified: false, - OTP: "", + Email: "user name@example.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email with invalid characters", email: &Email{ - Email: "user<>@example.com", - EmailVerified: false, - OTP: "", + Email: "user<>@example.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email with missing TLD", email: &Email{ - Email: "user@example", - EmailVerified: false, - OTP: "", + Email: "user@example", + Verified: false, + OTP: "", }, expected: true, // RFC 5322 allows this }, { name: "email with consecutive dots in local part", email: &Email{ - Email: "user..name@example.com", - EmailVerified: false, - OTP: "", + Email: "user..name@example.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email starting with dot", email: &Email{ - Email: ".user@example.com", - EmailVerified: false, - OTP: "", + Email: ".user@example.com", + Verified: false, + OTP: "", }, expected: false, }, { name: "email ending with dot", email: &Email{ - Email: "user.@example.com", - EmailVerified: false, - OTP: "", + Email: "user.@example.com", + Verified: false, + OTP: "", }, expected: false, }, @@ -244,3 +244,117 @@ func TestEmail_IsValidEmail(t *testing.T) { }) } } + +func TestEmailMessage_IsValid(t *testing.T) { + tests := []struct { + name string + message *EmailMessage + expected bool + }{ + { + name: "valid message with all fields", + message: &EmailMessage{ + From: "sender@example.com", + FromName: "Sender Name", + To: "recipient@example.com", + Subject: "Test Subject", + Body: "Test Body", + IsHTML: false, + }, + expected: true, + }, + { + name: "valid message without from fields", + message: &EmailMessage{ + To: "recipient@example.com", + Subject: "Test Subject", + Body: "Test Body", + IsHTML: true, + }, + expected: true, + }, + { + name: "valid message without FromName", + message: &EmailMessage{ + From: "sender@example.com", + To: "recipient@example.com", + Subject: "Test Subject", + Body: "Test Body", + }, + expected: true, + }, + { + name: "invalid message - missing To", + message: &EmailMessage{ + From: "sender@example.com", + Subject: "Test Subject", + Body: "Test Body", + }, + expected: false, + }, + { + name: "invalid message - missing Subject", + message: &EmailMessage{ + To: "recipient@example.com", + Body: "Test Body", + }, + expected: false, + }, + { + name: "invalid message - missing Body", + message: &EmailMessage{ + To: "recipient@example.com", + Subject: "Test Subject", + }, + expected: false, + }, + { + name: "invalid message - invalid To email", + message: &EmailMessage{ + To: "invalid-email", + Subject: "Test Subject", + Body: "Test Body", + }, + expected: false, + }, + { + name: "invalid message - invalid From email", + message: &EmailMessage{ + From: "invalid-email", + To: "recipient@example.com", + Subject: "Test Subject", + Body: "Test Body", + }, + expected: false, + }, + { + name: "valid message - empty From", + message: &EmailMessage{ + From: "", + To: "recipient@example.com", + Subject: "Test Subject", + Body: "Test Body", + }, + expected: true, + }, + { + name: "valid message with HTML", + message: &EmailMessage{ + To: "recipient@example.com", + Subject: "Test Subject", + Body: "Test", + IsHTML: true, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.message.IsValid() + if result != tt.expected { + t.Errorf("IsValid() = %v, expected %v for message: %+v", result, tt.expected, tt.message) + } + }) + } +} diff --git a/internal/domain/model/identity.go b/internal/domain/model/identity.go index 95c238d..d200031 100644 --- a/internal/domain/model/identity.go +++ b/internal/domain/model/identity.go @@ -7,6 +7,8 @@ package model type LinkIdentity struct { // User contains the authenticated user's information needed to authorize the linking action. User struct { + // UserID is the ID of the user to be linked. + UserID string `json:"user_id"` // AuthToken is the JWT token with the proper scope to link an identity to a user account. AuthToken string `json:"auth_token"` } `json:"user"` diff --git a/internal/domain/model/user.go b/internal/domain/model/user.go index 6887dac..5d36926 100644 --- a/internal/domain/model/user.go +++ b/internal/domain/model/user.go @@ -17,18 +17,13 @@ import ( // User represents a user in the system type User struct { - Token string `json:"token" yaml:"token"` - UserID string `json:"user_id" yaml:"user_id"` - Sub string `json:"sub,omitempty" yaml:"sub,omitempty"` - Username string `json:"username" yaml:"username"` - PrimaryEmail string `json:"primary_email" yaml:"primary_email"` - AlternateEmail []AlternateEmail `json:"alternate_email,omitempty" yaml:"alternate_email,omitempty"` - UserMetadata *UserMetadata `json:"user_metadata,omitempty" yaml:"user_metadata,omitempty"` -} - -type AlternateEmail struct { - Email string `json:"email" yaml:"email"` - EmailVerified bool `json:"email_verified" yaml:"email_verified"` + Token string `json:"token" yaml:"token"` + UserID string `json:"user_id" yaml:"user_id"` + Sub string `json:"sub,omitempty" yaml:"sub,omitempty"` + Username string `json:"username" yaml:"username"` + PrimaryEmail string `json:"primary_email" yaml:"primary_email"` + AlternateEmails []Email `json:"alternate_emails,omitempty" yaml:"alternate_emails,omitempty"` + UserMetadata *UserMetadata `json:"user_metadata,omitempty" yaml:"user_metadata,omitempty"` } // UserMetadata represents the metadata of a user @@ -108,6 +103,15 @@ func (u User) BuildEmailIndexKey(ctx context.Context) string { return u.buildIndexKey(ctx, "email", data) } +// BuildAlternateEmailIndexKey builds the index key for the alternate email +func (u User) BuildAlternateEmailIndexKey(ctx context.Context, alternateEmail string) string { + data := strings.TrimSpace(strings.ToLower(alternateEmail)) + if data == "" { + return "" + } + return u.buildIndexKey(ctx, "alternate-email", data) +} + // BuildSubIndexKey builds the index key for the sub func (u User) BuildSubIndexKey(ctx context.Context) string { data := strings.TrimSpace(strings.ToLower(u.Sub)) diff --git a/internal/domain/port/email_sender.go b/internal/domain/port/email_sender.go new file mode 100644 index 0000000..e711432 --- /dev/null +++ b/internal/domain/port/email_sender.go @@ -0,0 +1,16 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package port + +import ( + "context" + + "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/model" +) + +// EmailSender defines the behavior for sending emails +type EmailSender interface { + // SendEmail sends an email message + SendEmail(ctx context.Context, message *model.EmailMessage) error +} diff --git a/internal/infrastructure/auth0/filter.go b/internal/infrastructure/auth0/filter.go index ad4e354..45642c8 100644 --- a/internal/infrastructure/auth0/filter.go +++ b/internal/infrastructure/auth0/filter.go @@ -121,16 +121,16 @@ func (a *alternateEmailFilter) Endpoint(ctx context.Context) string { } func (a *alternateEmailFilter) Args(ctx context.Context) []any { - if len(a.user.AlternateEmail) == 0 { + if len(a.user.AlternateEmails) == 0 { return []any{} } - return []any{url.QueryEscape(a.user.AlternateEmail[0].Email)} + return []any{url.QueryEscape(a.user.AlternateEmails[0].Email)} } func (a *alternateEmailFilter) Filter(ctx context.Context, auth0User *Auth0User) (bool, error) { for _, identity := range auth0User.Identities { if identity.Connection == emailAuthenticationFilter { - for _, alternateEmail := range a.user.AlternateEmail { + for _, alternateEmail := range a.user.AlternateEmails { if identity.ProfileData != nil && strings.EqualFold(alternateEmail.Email, identity.ProfileData.Email) { slog.DebugContext(ctx, "user found, and it's the correct identity", diff --git a/internal/infrastructure/auth0/filter_test.go b/internal/infrastructure/auth0/filter_test.go index 6f5ddf8..e51de0e 100644 --- a/internal/infrastructure/auth0/filter_test.go +++ b/internal/infrastructure/auth0/filter_test.go @@ -18,8 +18,8 @@ func Test_newUserFilterer(t *testing.T) { user := &model.User{ Username: "testuser", PrimaryEmail: "test@example.com", - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, } @@ -396,7 +396,7 @@ func Test_emailFilter_Filter(t *testing.T) { func Test_alternateEmailFilter_Endpoint(t *testing.T) { ctx := context.Background() user := &model.User{ - AlternateEmail: []model.AlternateEmail{ + AlternateEmails: []model.Email{ {Email: "alt@example.com"}, }, } @@ -413,43 +413,43 @@ func Test_alternateEmailFilter_Args(t *testing.T) { ctx := context.Background() tests := []struct { - name string - alternateEmail []model.AlternateEmail - wantLen int - wantFirst string + name string + alternateEmails []model.Email + wantLen int + wantFirst string }{ { name: "returns escaped alternate email", - alternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + alternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, wantLen: 1, wantFirst: "alt%40example.com", }, { name: "returns first email when multiple exist", - alternateEmail: []model.AlternateEmail{ - {Email: "first@example.com", EmailVerified: true}, - {Email: "second@example.com", EmailVerified: false}, + alternateEmails: []model.Email{ + {Email: "first@example.com", Verified: true}, + {Email: "second@example.com", Verified: false}, }, wantLen: 1, wantFirst: "first%40example.com", }, { - name: "returns empty array when no alternate emails", - alternateEmail: []model.AlternateEmail{}, - wantLen: 0, + name: "returns empty array when no alternate emails", + alternateEmails: []model.Email{}, + wantLen: 0, }, { - name: "returns empty array when alternate email is nil", - alternateEmail: nil, - wantLen: 0, + name: "returns empty array when alternate email is nil", + alternateEmails: nil, + wantLen: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - user := &model.User{AlternateEmail: tt.alternateEmail} + user := &model.User{AlternateEmails: tt.alternateEmails} filter := &alternateEmailFilter{user: user} args := filter.Args(ctx) @@ -475,8 +475,8 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "matches when alternate email and connection match", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, }, auth0User: &Auth0User{ @@ -496,8 +496,8 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "no match when connection type is different", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, }, auth0User: &Auth0User{ @@ -517,8 +517,8 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "no match when email doesn't match", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, }, auth0User: &Auth0User{ @@ -538,9 +538,9 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "matches with multiple alternate emails", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt1@example.com", EmailVerified: true}, - {Email: "alt2@example.com", EmailVerified: false}, + AlternateEmails: []model.Email{ + {Email: "alt1@example.com", Verified: true}, + {Email: "alt2@example.com", Verified: false}, }, }, auth0User: &Auth0User{ @@ -560,8 +560,8 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "checks multiple identities and finds match", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, }, auth0User: &Auth0User{ @@ -587,8 +587,8 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "no match when identities array is empty", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: true}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: true}, }, }, auth0User: &Auth0User{ @@ -600,7 +600,7 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "no match when user has no alternate emails", user: &model.User{ - AlternateEmail: []model.AlternateEmail{}, + AlternateEmails: []model.Email{}, }, auth0User: &Auth0User{ Identities: []Auth0Identity{ @@ -619,8 +619,8 @@ func Test_alternateEmailFilter_Filter(t *testing.T) { { name: "appends alternate email when match found", user: &model.User{ - AlternateEmail: []model.AlternateEmail{ - {Email: "alt@example.com", EmailVerified: false}, + AlternateEmails: []model.Email{ + {Email: "alt@example.com", Verified: false}, }, }, auth0User: &Auth0User{ diff --git a/internal/infrastructure/auth0/models.go b/internal/infrastructure/auth0/models.go index 0c0e6ad..473b11f 100644 --- a/internal/infrastructure/auth0/models.go +++ b/internal/infrastructure/auth0/models.go @@ -81,21 +81,21 @@ func (u *Auth0User) ToUser() *model.User { } } - var alternateEmails []model.AlternateEmail + var alternateEmails []model.Email for _, alternateEmail := range u.AlternateEmail { - alternateEmail := model.AlternateEmail{ - Email: alternateEmail.Email, - EmailVerified: alternateEmail.EmailVerified, + alternateEmail := model.Email{ + Email: alternateEmail.Email, + Verified: alternateEmail.EmailVerified, } alternateEmails = append(alternateEmails, alternateEmail) } return &model.User{ - UserID: u.UserID, - Username: u.Username, - PrimaryEmail: u.Email, - AlternateEmail: alternateEmails, - UserMetadata: meta, + UserID: u.UserID, + Username: u.Username, + PrimaryEmail: u.Email, + AlternateEmails: alternateEmails, + UserMetadata: meta, } } diff --git a/internal/infrastructure/auth0/user.go b/internal/infrastructure/auth0/user.go index 676d92d..cc37fb8 100644 --- a/internal/infrastructure/auth0/user.go +++ b/internal/infrastructure/auth0/user.go @@ -352,6 +352,10 @@ func (u *userReaderWriter) LinkIdentity(ctx context.Context, request *model.Link return errors.NewValidation("link identity request is required") } + if request.User.UserID == "" { + return errors.NewValidation("user_id is required") + } + if request.User.AuthToken == "" { return errors.NewValidation("user_token is required") } @@ -360,31 +364,13 @@ func (u *userReaderWriter) LinkIdentity(ctx context.Context, request *model.Link return errors.NewValidation("link_with is required") } - // Verify JWT token to extract user_id from the 'sub' claim - // and validate it has the required scope - if u.config.JWTVerificationConfig == nil { - return errors.NewValidation("JWT verification configuration is required") - } - - claims, errJwtVerifyAuthToken := u.config.JWTVerificationConfig.JWTVerify(ctx, request.User.AuthToken, userUpdateIdentityRequiredScope) - if errJwtVerifyAuthToken != nil { - slog.ErrorContext(ctx, "jwt verify failed for link identity", "error", errJwtVerifyAuthToken) - return errJwtVerifyAuthToken - } - - // Extract the user_id from the 'sub' claim - userID := claims.Subject - if userID == "" { - return errors.NewValidation("user_id could not be extracted from management_api_token") - } - slog.DebugContext(ctx, "linking identity to user", - "user_id", redaction.Redact(userID), + "user_id", redaction.Redact(request.User.UserID), ) errLinkIdentity := u.identityLinkingFlow.LinkIdentityToUser( ctx, - userID, + request.User.UserID, request.User.AuthToken, request.LinkWith.IdentityToken, ) @@ -393,7 +379,7 @@ func (u *userReaderWriter) LinkIdentity(ctx context.Context, request *model.Link } slog.DebugContext(ctx, "identity linked successfully via user reader writer", - "user_id", redaction.Redact(userID), + "user_id", redaction.Redact(request.User.UserID), ) return nil diff --git a/internal/infrastructure/authelia/email.go b/internal/infrastructure/authelia/email.go new file mode 100644 index 0000000..c4455c3 --- /dev/null +++ b/internal/infrastructure/authelia/email.go @@ -0,0 +1,67 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package authelia + +import ( + "context" + "fmt" + "log/slog" + + "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/model" + "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/port" + "github.com/linuxfoundation/lfx-v2-auth-service/internal/infrastructure/smtp" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/password" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/redaction" +) + +type passwordlessFlow interface { + SendEmail(ctx context.Context, email string) (string, error) + LoginWithEmail(ctx context.Context) error +} + +type autheliaPasswordlessFlow struct { + emailSender port.EmailSender +} + +func (a *autheliaPasswordlessFlow) SendEmail(ctx context.Context, email string) (string, error) { + + otp, err := password.OnlyNumbers(6) + if err != nil { + slog.ErrorContext(ctx, "failed to generate OTP", "error", err) + return "", errors.NewUnexpected("failed to generate OTP", err) + } + + message := &model.EmailMessage{ + From: "noreply@lfx.dev", + To: email, + Subject: "Welcome to Linux Foundation", + Body: fmt.Sprintf("Your verification code is: %s", otp), + } + + errSendEmail := a.emailSender.SendEmail(ctx, message) + if errSendEmail != nil { + slog.ErrorContext(ctx, "failed to send email", "error", errSendEmail) + return "", errors.NewUnexpected("failed to send email", errSendEmail) + } + + // Note: this is not a production flow, so the otp is not sensitive + // We're logging the otp here to help with debugging and testing + slog.InfoContext(ctx, "passwordless flow email sent", + "email", redaction.RedactEmail(email), + "otp", otp, + ) + + return otp, nil +} + +func (a *autheliaPasswordlessFlow) LoginWithEmail(ctx context.Context) error { + return nil +} + +func newEmailLinkingFlow() passwordlessFlow { + return &autheliaPasswordlessFlow{ + emailSender: smtp.NewSender(), + } +} diff --git a/internal/infrastructure/authelia/models.go b/internal/infrastructure/authelia/models.go index 9f8c411..2b61a0e 100644 --- a/internal/infrastructure/authelia/models.go +++ b/internal/infrastructure/authelia/models.go @@ -39,13 +39,14 @@ type AutheliaUser struct { // AutheliaUserStorage represents the storage format for Authelia users // This struct excludes sensitive fields like token, user_id, and primary_email type AutheliaUserStorage struct { - Username string `json:"username"` - Sub string `json:"sub"` // sub for Authelia - Email string `json:"email"` // email for Authelia - DisplayName string `json:"displayname"` // display name for Authelia - UserMetadata *model.UserMetadata `json:"user_metadata,omitempty"` // user metadata from domain model - CreatedAt time.Time `json:"created_at"` // creation timestamp - UpdatedAt time.Time `json:"updated_at"` // update timestamp + Username string `json:"username"` + Sub string `json:"sub"` // sub for Authelia + Email string `json:"email"` // email for Authelia + DisplayName string `json:"displayname"` // display name for Authelia + UserMetadata *model.UserMetadata `json:"user_metadata,omitempty"` // user metadata from domain model + AlternateEmail []model.Email `json:"alternate_email,omitempty"` // alternate email for Authelia + CreatedAt time.Time `json:"created_at"` // creation timestamp + UpdatedAt time.Time `json:"updated_at"` // update timestamp } // SetUsername sets the username for the user @@ -59,23 +60,26 @@ func (a *AutheliaUser) SetUsername(username string) { // ToStorage converts AutheliaUser to AutheliaUserStorage for storage operations func (a *AutheliaUser) ToStorage() *AutheliaUserStorage { var ( - username string - userMetadata *model.UserMetadata + username string + userMetadata *model.UserMetadata + alternateEmail []model.Email ) if a.User != nil { username = a.Username userMetadata = a.UserMetadata + alternateEmail = a.AlternateEmails } return &AutheliaUserStorage{ - Username: username, - Sub: a.Sub, - Email: a.Email, - DisplayName: a.DisplayName, - UserMetadata: userMetadata, - CreatedAt: a.CreatedAt, - UpdatedAt: a.UpdatedAt, + Username: username, + Sub: a.Sub, + Email: a.Email, + DisplayName: a.DisplayName, + UserMetadata: userMetadata, + AlternateEmail: alternateEmail, + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, } } @@ -86,9 +90,11 @@ func (a *AutheliaUser) FromStorage(storage *AutheliaUserStorage) { } a.Username = storage.Username a.UserMetadata = storage.UserMetadata + a.AlternateEmails = storage.AlternateEmail // for consistency in naming across implementations, // we use the unique identifier as the user_id a.UserID = storage.Sub + a.Sub = storage.Sub a.Email = storage.Email a.DisplayName = storage.DisplayName a.CreatedAt = storage.CreatedAt diff --git a/internal/infrastructure/authelia/storage.go b/internal/infrastructure/authelia/storage.go index b958079..988ad20 100644 --- a/internal/infrastructure/authelia/storage.go +++ b/internal/infrastructure/authelia/storage.go @@ -23,16 +23,32 @@ const ( ) type internalStorageReaderWriter interface { + internalStorageReader + internalStorageWriter + emailHandler +} + +type internalStorageReader interface { GetUser(ctx context.Context, key string) (*AutheliaUser, error) + GetUserWithRevision(ctx context.Context, key string) (*AutheliaUser, uint64, error) ListUsers(ctx context.Context) (map[string]*AutheliaUser, error) - SetUser(ctx context.Context, user *AutheliaUser) (any, error) BuildLookupKey(ctx context.Context, lookupKey, key string) string } +type internalStorageWriter interface { + SetUser(ctx context.Context, user *AutheliaUser) (any, error) + UpdateUserWithRevision(ctx context.Context, user *AutheliaUser, revision uint64) error +} + +type emailHandler interface { + CreateVerificationCode(ctx context.Context, email, otp string) error + GetVerificationCode(ctx context.Context, email string) (string, error) +} + // natsUserStorage implements UserStorage using NATS KV store type natsUserStorage struct { natsClient *nats.NATSClient - kvStore jetstream.KeyValue + kvStore map[string]jetstream.KeyValue } func (n *natsUserStorage) lookupUser(ctx context.Context, key string) (string, error) { @@ -41,7 +57,7 @@ func (n *natsUserStorage) lookupUser(ctx context.Context, key string) (string, e return key, nil } - entry, err := n.kvStore.Get(ctx, key) + entry, err := n.kvStore[constants.KVBucketNameAutheliaUsers].Get(ctx, key) if err != nil { if errors.Is(err, jetstream.ErrKeyNotFound) { return "", errs.NewNotFound("user not found") @@ -52,41 +68,49 @@ func (n *natsUserStorage) lookupUser(ctx context.Context, key string) (string, e } func (n *natsUserStorage) GetUser(ctx context.Context, key string) (*AutheliaUser, error) { + user, _, err := n.GetUserWithRevision(ctx, key) + if err != nil { + return nil, err + } + return user, nil +} + +func (n *natsUserStorage) GetUserWithRevision(ctx context.Context, key string) (*AutheliaUser, uint64, error) { if key == "" { - return nil, errs.NewUnexpected("key is required") + return nil, 0, errs.NewUnexpected("key is required") } username, errLookupUser := n.lookupUser(ctx, key) if errLookupUser != nil { - return nil, errLookupUser + return nil, 0, errLookupUser } - entry, err := n.kvStore.Get(ctx, username) + entry, err := n.kvStore[constants.KVBucketNameAutheliaUsers].Get(ctx, username) if err != nil { if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, errs.NewNotFound("user not found") + return nil, 0, errs.NewNotFound("user not found") } - return nil, errs.NewUnexpected("failed to get user from NATS KV", err) + return nil, 0, errs.NewUnexpected("failed to get user from NATS KV", err) } var storageUser AutheliaUserStorage if err := json.Unmarshal(entry.Value(), &storageUser); err != nil { - return nil, errs.NewUnexpected("failed to unmarshal user data", err) + return nil, 0, errs.NewUnexpected("failed to unmarshal user data", err) } // Convert storage format back to AutheliaUser var autheliaUser AutheliaUser autheliaUser.FromStorage(&storageUser) - return &autheliaUser, nil + return &autheliaUser, entry.Revision(), nil } func (n *natsUserStorage) ListUsers(ctx context.Context) (map[string]*AutheliaUser, error) { users := make(map[string]*AutheliaUser) // Get all keys from the KV store - keys, err := n.kvStore.Keys(ctx) + keys, err := n.kvStore[constants.KVBucketNameAutheliaUsers].Keys(ctx) if err != nil && !strings.Contains(err.Error(), "no keys found") { return nil, errs.NewUnexpected("failed to list keys from NATS KV", err) } @@ -130,7 +154,7 @@ func (n *natsUserStorage) SetUser(ctx context.Context, user *AutheliaUser) (any, } // user main data - _, errPut := n.kvStore.Put(ctx, user.Username, data) + _, errPut := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, user.Username, data) if errPut != nil { return nil, errs.NewUnexpected("failed to set user in NATS KV", errPut) } @@ -138,13 +162,13 @@ func (n *natsUserStorage) SetUser(ctx context.Context, user *AutheliaUser) (any, // lookup keys if user.Email != "" { user.PrimaryEmail = user.Email - _, errPutLookup := n.kvStore.Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)), []byte(user.Username)) + _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)), []byte(user.Username)) if errPutLookup != nil { return nil, errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) } } if user.Sub != "" { - _, errPutLookup := n.kvStore.Put(ctx, n.BuildLookupKey(ctx, "sub", user.BuildSubIndexKey(ctx)), []byte(user.Username)) + _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "sub", user.BuildSubIndexKey(ctx)), []byte(user.Username)) if errPutLookup != nil { return nil, errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) } @@ -153,6 +177,89 @@ func (n *natsUserStorage) SetUser(ctx context.Context, user *AutheliaUser) (any, return user, nil } +func (n *natsUserStorage) UpdateUserWithRevision(ctx context.Context, user *AutheliaUser, revision uint64) error { + + // Update timestamp + user.UpdatedAt = time.Now() + + // Convert to storage format (excludes sensitive fields) + storageUser := user.ToStorage() + + data, err := json.Marshal(storageUser) + if err != nil { + return errs.NewUnexpected("failed to marshal user data", err) + } + + // Use Update instead of Put to ensure optimistic locking with revision + _, errUpdate := n.kvStore[constants.KVBucketNameAutheliaUsers].Update(ctx, user.Username, data, revision) + if errUpdate != nil { + if errors.Is(errUpdate, jetstream.ErrKeyNotFound) { + return errs.NewNotFound("user not found for update") + } + // NATS returns an error if the revision doesn't match (concurrent modification) + return errs.NewConflict("user has been modified by another process, please retry", errUpdate) + } + + // lookup keys - these are not subject to revision control as they're separate keys + if user.Email != "" { + user.PrimaryEmail = user.Email + _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)), []byte(user.Username)) + if errPutLookup != nil { + return errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) + } + } + + return nil +} + +// CreateVerificationCode stores a verification code (OTP) for an email address in the email OTP bucket +// The key is the email address and the value is the OTP code as a string +func (n *natsUserStorage) CreateVerificationCode(ctx context.Context, email, otp string) error { + if email == "" { + return errs.NewUnexpected("email is required") + } + if otp == "" { + return errs.NewUnexpected("otp is required") + } + + // Store the OTP as a simple string value + // The TTL is configured in the bucket itself (5 minutes by default) + _, errPut := n.kvStore[constants.KVBucketNameAutheliaEmailOTP].Put(ctx, email, []byte(otp)) + if errPut != nil { + return errs.NewUnexpected("failed to store verification code in NATS KV", errPut) + } + + slog.InfoContext(ctx, "verification code stored successfully", + "email", email, + ) + + return nil +} + +// GetVerificationCode retrieves a verification code (OTP) for an email address from the email OTP bucket +// Returns the OTP as a string +func (n *natsUserStorage) GetVerificationCode(ctx context.Context, email string) (string, error) { + if email == "" { + return "", errs.NewUnexpected("email is required") + } + + entry, err := n.kvStore[constants.KVBucketNameAutheliaEmailOTP].Get(ctx, email) + if err != nil { + if errors.Is(err, jetstream.ErrKeyNotFound) { + return "", errs.NewNotFound("verification code not found or expired") + } + return "", errs.NewUnexpected("failed to get verification code from NATS KV", err) + } + + otp := string(entry.Value()) + + slog.InfoContext(ctx, "verification code retrieved successfully", + "email", email, + ) + + return otp, nil +} + // BuildLookupKey builds the lookup key for the given lookup key and key func (n *natsUserStorage) BuildLookupKey(ctx context.Context, lookupKey, key string) string { prefix := fmt.Sprintf(constants.KVLookupPrefixAuthelia, lookupKey) @@ -162,15 +269,18 @@ func (n *natsUserStorage) BuildLookupKey(ctx context.Context, lookupKey, key str // newNATSUserStorage creates a new NATS-based user storage func newNATSUserStorage(ctx context.Context, natsClient *nats.NATSClient) (internalStorageReaderWriter, error) { // Get the KV store for authelia users - kvStore, exists := natsClient.GetKVStore(constants.KVBucketNameAutheliaUsers) - if !exists { - return nil, errs.NewUnexpected("authelia users KV bucket not found in NATS client") + kvStores := make(map[string]jetstream.KeyValue) + for _, bucketName := range []string{constants.KVBucketNameAutheliaUsers, constants.KVBucketNameAutheliaEmailOTP} { + kvStore, exists := natsClient.GetKVStore(bucketName) + if !exists { + return nil, errs.NewUnexpected("KV bucket not found in NATS client") + } + kvStores[bucketName] = kvStore } - - slog.DebugContext(ctx, "created NATS user storage", "kvStore", kvStore) + slog.DebugContext(ctx, "created NATS user storage", "kvStores", kvStores) return &natsUserStorage{ natsClient: natsClient, - kvStore: kvStore, + kvStore: kvStores, }, nil } diff --git a/internal/infrastructure/authelia/sync_test.go b/internal/infrastructure/authelia/sync_test.go index 58a0acd..f701bae 100644 --- a/internal/infrastructure/authelia/sync_test.go +++ b/internal/infrastructure/authelia/sync_test.go @@ -56,6 +56,33 @@ func (m *mockStorageReaderWriter) SetUser(ctx context.Context, user *AutheliaUse return "success", nil } +func (m *mockStorageReaderWriter) GetUserWithRevision(ctx context.Context, key string) (*AutheliaUser, uint64, error) { + user, err := m.GetUser(ctx, key) + if err != nil { + return nil, 0, err + } + return user, 1, nil // Return revision 1 for testing +} + +func (m *mockStorageReaderWriter) UpdateUserWithRevision(ctx context.Context, user *AutheliaUser, revision uint64) error { + if m.setErr != nil { + return m.setErr + } + if m.users == nil { + m.users = make(map[string]*AutheliaUser) + } + m.users[user.Username] = user + return nil +} + +func (m *mockStorageReaderWriter) CreateVerificationCode(ctx context.Context, email, otp string) error { + return nil +} + +func (m *mockStorageReaderWriter) GetVerificationCode(ctx context.Context, email string) (string, error) { + return "123456", nil +} + type mockOrchestrator struct { users map[string]any loadErr error diff --git a/internal/infrastructure/authelia/user.go b/internal/infrastructure/authelia/user.go index 6fabf3c..34b2018 100644 --- a/internal/infrastructure/authelia/user.go +++ b/internal/infrastructure/authelia/user.go @@ -9,34 +9,37 @@ import ( "log/slog" "net/http" "strings" + "time" "github.com/google/uuid" "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/model" "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/port" "github.com/linuxfoundation/lfx-v2-auth-service/internal/infrastructure/nats" "github.com/linuxfoundation/lfx-v2-auth-service/pkg/constants" - "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" + errs "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" "github.com/linuxfoundation/lfx-v2-auth-service/pkg/httpclient" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/jwt" "github.com/linuxfoundation/lfx-v2-auth-service/pkg/redaction" ) // userReaderWriter implements UserReaderWriter with pluggable storage and ConfigMap sync type userReaderWriter struct { - oidcUserInfoURL string - sync *sync - storage internalStorageReaderWriter - orchestrator internalOrchestrator - httpClient *httpclient.Client + oidcUserInfoURL string + sync *sync + storage internalStorageReaderWriter + orchestrator internalOrchestrator + emailLinkingFlow passwordlessFlow + httpClient *httpclient.Client } // fetchOIDCUserInfo fetches user information from the OIDC userinfo endpoint func (a *userReaderWriter) fetchOIDCUserInfo(ctx context.Context, token string) (*OIDCUserInfo, error) { if strings.TrimSpace(token) == "" { - return nil, errors.NewValidation("token is required") + return nil, errs.NewValidation("token is required") } if strings.TrimSpace(a.oidcUserInfoURL) == "" { - return nil, errors.NewValidation("OIDC userinfo URL is not configured") + return nil, errs.NewValidation("OIDC userinfo URL is not configured") } // Create API request using the standard pattern @@ -66,7 +69,7 @@ func (a *userReaderWriter) fetchOIDCUserInfo(ctx context.Context, token string) func (a *userReaderWriter) SearchUser(ctx context.Context, user *model.User, criteria string) (*model.User, error) { if user == nil { - return nil, errors.NewValidation("user is required") + return nil, errs.NewValidation("user is required") } param := func(criteriaType string) string { @@ -80,6 +83,16 @@ func (a *userReaderWriter) SearchUser(ctx context.Context, user *model.User, cri return "" } return a.storage.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)) + case constants.CriteriaTypeAlternateEmail: + // only the first alternate email is supported + for _, alternateEmail := range user.AlternateEmails { + slog.DebugContext(ctx, "searching user", + "criteria", criteria, + "alternate_email", redaction.RedactEmail(alternateEmail.Email), + ) + return a.storage.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)) + } + return "" case constants.CriteriaTypeUsername: slog.DebugContext(ctx, "searching user", "criteria", criteria, @@ -92,7 +105,7 @@ func (a *userReaderWriter) SearchUser(ctx context.Context, user *model.User, cri key := param(criteria) if key == "" { - return nil, errors.NewValidation("invalid criteria type") + return nil, errs.NewValidation("invalid criteria type") } existingUser, err := a.storage.GetUser(ctx, key) @@ -111,7 +124,7 @@ func (a *userReaderWriter) SearchUser(ctx context.Context, user *model.User, cri func (a *userReaderWriter) GetUser(ctx context.Context, user *model.User) (*model.User, error) { if user == nil { - return nil, errors.NewValidation("user is required") + return nil, errs.NewValidation("user is required") } key := "" @@ -139,7 +152,7 @@ func (a *userReaderWriter) GetUser(ctx context.Context, user *model.User) (*mode func (u *userReaderWriter) MetadataLookup(ctx context.Context, input string) (*model.User, error) { if input == "" { - return nil, errors.NewValidation("input is required") + return nil, errs.NewValidation("input is required") } slog.DebugContext(ctx, "metadata lookup", "input", redaction.Redact(input)) @@ -182,7 +195,7 @@ func (u *userReaderWriter) MetadataLookup(ctx context.Context, input string) (*m // UpdateUser updates a user only in storage with patch-like behavior, updating only changed fields func (a *userReaderWriter) UpdateUser(ctx context.Context, user *model.User) (*model.User, error) { if user == nil { - return nil, errors.NewValidation("user is required") + return nil, errs.NewValidation("user is required") } if user.Token != "" { @@ -208,7 +221,7 @@ func (a *userReaderWriter) UpdateUser(ctx context.Context, user *model.User) (*m } if user.Sub == "" && user.Username == "" { - return nil, errors.NewValidation("username or sub is required") + return nil, errs.NewValidation("username or sub is required") } // First, get the existing user from storage to preserve Authelia-specific fields @@ -222,7 +235,7 @@ func (a *userReaderWriter) UpdateUser(ctx context.Context, user *model.User) (*m "error", err, "key", existingAutheliaUser.Username, ) - return nil, errors.NewUnexpected("failed to get existing user from storage", err) + return nil, errs.NewUnexpected("failed to get existing user from storage", err) } // Update Sub field if provided from OIDC userinfo @@ -253,7 +266,7 @@ func (a *userReaderWriter) UpdateUser(ctx context.Context, user *model.User) (*m "username", user.Username, "error", err, ) - return nil, errors.NewUnexpected("failed to update user in storage", err) + return nil, errs.NewUnexpected("failed to update user in storage", err) } } @@ -264,17 +277,173 @@ func (a *userReaderWriter) UpdateUser(ctx context.Context, user *model.User) (*m } func (a *userReaderWriter) SendVerificationAlternateEmail(ctx context.Context, alternateEmail string) error { - slog.DebugContext(ctx, "sending alternate email verification", "alternate_email", redaction.Redact(alternateEmail)) - return errors.NewValidation("send verification alternate email is not supported for Authelia yet") + slog.DebugContext(ctx, "sending alternate email verification", + "alternate_email", redaction.RedactEmail(alternateEmail), + ) + + if alternateEmail == "" { + return errs.NewValidation("alternate email is required") + } + + otp, errSendEmail := a.emailLinkingFlow.SendEmail(ctx, alternateEmail) + if errSendEmail != nil { + slog.ErrorContext(ctx, "failed to send email", "error", errSendEmail) + return errs.NewUnexpected("failed to send email", errSendEmail) + } + + user := &model.User{} + key := user.BuildAlternateEmailIndexKey(ctx, alternateEmail) + errCreateVerificationCode := a.storage.CreateVerificationCode(ctx, key, otp) + if errCreateVerificationCode != nil { + slog.ErrorContext(ctx, "failed to create verification code", "error", errCreateVerificationCode) + return errs.NewUnexpected("failed to create verification code", errCreateVerificationCode) + } + + slog.DebugContext(ctx, "alternate email verification initiated successfully", + "email", redaction.RedactEmail(alternateEmail), + ) + + return nil } func (a *userReaderWriter) VerifyAlternateEmail(ctx context.Context, email *model.Email) (*model.AuthResponse, error) { - slog.DebugContext(ctx, "verifying alternate email", "email", redaction.Redact(email.Email)) - return nil, errors.NewValidation("alternate email verification is not supported for Authelia yet") + + if email.Email == "" || email.OTP == "" { + return nil, errs.NewValidation("email and OTP are required") + } + + user := &model.User{} + + key := user.BuildAlternateEmailIndexKey(ctx, email.Email) + otp, errGetVerificationCode := a.storage.GetVerificationCode(ctx, key) + if errGetVerificationCode != nil { + return nil, errGetVerificationCode + } + + if otp != email.OTP { + return nil, errs.NewValidation("invalid verification code") + } + + expiresIn := 60 * time.Minute + + // Generate identity token with custom sub claim in format "email|provider" + idToken, errGenerateIDToken := jwt.GenerateSimpleTestIdentityTokenWithSubject( + email.Email, + fmt.Sprintf("email|%s", email.Email), + expiresIn, + ) + if errGenerateIDToken != nil { + return nil, errs.NewUnexpected("failed to generate ID token", errGenerateIDToken) + } + + accessToken, errGenerateAccessToken := jwt.GenerateSimpleTestAccessToken(email.Email, expiresIn) + if errGenerateAccessToken != nil { + return nil, errs.NewUnexpected("failed to generate access token", errGenerateAccessToken) + } + + return &model.AuthResponse{ + IDToken: idToken, + AccessToken: accessToken, + ExpiresIn: int(expiresIn.Seconds()), + TokenType: "Bearer", + }, nil } func (a *userReaderWriter) LinkIdentity(ctx context.Context, request *model.LinkIdentity) error { - return errors.NewValidation("link identity is not supported for Authelia yet") + if request == nil { + return errs.NewValidation("request is required") + } + + if request.User.UserID == "" { + return errs.NewValidation("user ID is required") + } + + if request.LinkWith.IdentityToken == "" { + return errs.NewValidation("identity token is required") + } + + // Parse the identity token to extract the email (from sub claim) + opts := &jwt.ParseOptions{ + RequireExpiration: false, // The token might be expired but we still want the email + AllowBearerPrefix: true, + RequireSubject: true, + } + + claims, err := jwt.ParseUnverified(ctx, request.LinkWith.IdentityToken, opts) + if err != nil { + slog.ErrorContext(ctx, "failed to parse identity token", + "error", err, + ) + return errs.NewValidation("invalid identity token") + } + + // Extract email + email := claims.Email + if email == "" { + return errs.NewValidation("identity token does not contain an email") + } + + slog.DebugContext(ctx, "linking identity", + "user_id", redaction.Redact(request.User.UserID), + "email", redaction.RedactEmail(email), + ) + + // Converting the sub to use the lookup key + user := &model.User{ + Sub: request.User.UserID, + } + key := a.storage.BuildLookupKey(ctx, "sub", user.BuildSubIndexKey(ctx)) + + // Get the user with revision for optimistic locking + existingUser, revision, err := a.storage.GetUserWithRevision(ctx, key) + if err != nil { + slog.ErrorContext(ctx, "failed to get user for linking identity", + "user_id", redaction.Redact(request.User.UserID), + "error", err, + ) + return err + } + + // Check if the email already exists in the alternate email list + emailExists := false + for _, altEmail := range existingUser.AlternateEmails { + if strings.EqualFold(altEmail.Email, email) { + emailExists = true + slog.InfoContext(ctx, "email already exists in alternate email list", + "user_id", redaction.Redact(request.User.UserID), + "email", redaction.RedactEmail(email), + ) + break + } + } + + // If email doesn't exist, add it to the list + if !emailExists { + if existingUser.AlternateEmails == nil { + existingUser.AlternateEmails = []model.Email{} + } + existingUser.AlternateEmails = append(existingUser.AlternateEmails, model.Email{ + Email: email, + Verified: true, // The email is verified because it came from a verified identity token + }) + + // Update the user with optimistic locking + err = a.storage.UpdateUserWithRevision(ctx, existingUser, revision) + if err != nil { + slog.ErrorContext(ctx, "failed to update user with alternate email", + "user_id", redaction.Redact(request.User.UserID), + "error", err, + ) + return err + } + + slog.InfoContext(ctx, "successfully linked alternate email to user", + "user_id", redaction.Redact(request.User.UserID), + "email", redaction.RedactEmail(email), + ) + } + + return nil } // NewUserReaderWriter creates a new Authelia User repository @@ -282,9 +451,10 @@ func NewUserReaderWriter(ctx context.Context, config map[string]string, natsClie // Set defaults in case of not set u := &userReaderWriter{ - sync: &sync{}, - oidcUserInfoURL: config["oidc-userinfo-url"], - httpClient: httpclient.NewClient(httpclient.DefaultConfig()), + sync: &sync{}, + oidcUserInfoURL: config["oidc-userinfo-url"], + emailLinkingFlow: newEmailLinkingFlow(), + httpClient: httpclient.NewClient(httpclient.DefaultConfig()), } // Initialize storage using NATS KV store diff --git a/internal/infrastructure/nats/client.go b/internal/infrastructure/nats/client.go index 143b19b..2ec0015 100644 --- a/internal/infrastructure/nats/client.go +++ b/internal/infrastructure/nats/client.go @@ -163,6 +163,7 @@ func NewClient(ctx context.Context, config Config) (*NATSClient, error) { // Check if Authelia is enabled by checking the environment variable directly if os.Getenv(constants.UserRepositoryTypeEnvKey) == constants.UserRepositoryTypeAuthelia { buckets = append(buckets, constants.KVBucketNameAutheliaUsers) + buckets = append(buckets, constants.KVBucketNameAutheliaEmailOTP) } for _, bucketName := range buckets { diff --git a/internal/infrastructure/smtp/client.go b/internal/infrastructure/smtp/client.go new file mode 100644 index 0000000..a4c23ce --- /dev/null +++ b/internal/infrastructure/smtp/client.go @@ -0,0 +1,75 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package smtp + +import ( + "context" + "fmt" + "log/slog" + "net/smtp" + "os" + + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/constants" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" +) + +// config holds the configuration for the SMTP client +type config struct { + // Host is the SMTP server host (e.g., "lfx-platform-mailpit-smtp.lfx.svc.cluster.local") + Host string + // Port is the SMTP server port (e.g., 1025 for Mailpit) + Port string + // FromEmail is the sender email address + Username string + // Password is the SMTP password (optional) + Password string +} + +// client is the SMTP client that implements port.EmailSender +type client struct { + config +} + +func (c *client) sendEmail(ctx context.Context, from, to string, emailBytes []byte) error { + + // Connect to SMTP server + addr := fmt.Sprintf("%s:%s", c.config.Host, c.config.Port) + + var auth smtp.Auth + if c.config.Username != "" && c.config.Password != "" { + auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.Host) + } + + err := smtp.SendMail(addr, auth, from, []string{to}, emailBytes) + if err != nil { + slog.ErrorContext(ctx, "failed to send email via SMTP", + "error", err, + "host", c.config.Host, + "port", c.config.Port, + ) + return errors.NewUnexpected("failed to send email", err) + } + return nil +} + +// newClient creates a new SMTP client from environment variables +func newClient() *client { + config := config{ + Host: os.Getenv(constants.EmailSMTPHostEnvKey), + Port: os.Getenv(constants.EmailSMTPPortEnvKey), + Username: os.Getenv(constants.EmailSMTPUsernameEnvKey), + Password: os.Getenv(constants.EmailSMTPPasswordEnvKey), + } + + if config.Host == "" { + config.Host = "lfx-platform-mailpit-smtp.lfx.svc.cluster.local" + } + if config.Port == "" { + config.Port = "25" + } + + return &client{ + config: config, + } +} diff --git a/internal/infrastructure/smtp/sender.go b/internal/infrastructure/smtp/sender.go new file mode 100644 index 0000000..9b62edf --- /dev/null +++ b/internal/infrastructure/smtp/sender.go @@ -0,0 +1,73 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package smtp + +import ( + "context" + "fmt" + "log/slog" + + "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/model" + "github.com/linuxfoundation/lfx-v2-auth-service/internal/domain/port" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" +) + +// Sender is the SMTP sender that implements port.EmailSender +type Sender struct { + client *client +} + +func (s *Sender) SendEmail(ctx context.Context, message *model.EmailMessage) error { + if message == nil { + return errors.NewValidation("email message is required") + } + + if !message.IsValid() { + return errors.NewValidation("invalid email message") + } + + // Use message's From/FromName if provided, otherwise use client config + from := message.From + fromName := message.FromName + + fromAddress := from + if fromName != "" { + fromAddress = fmt.Sprintf("%s <%s>", fromName, from) + } + + contentType := "text/plain" + if message.IsHTML { + contentType = "text/html" + } + + emailBytes := []byte( + fmt.Sprintf("From: %s\r\n", fromAddress) + + fmt.Sprintf("To: %s\r\n", message.To) + + fmt.Sprintf("Subject: %s\r\n", message.Subject) + + fmt.Sprintf("Content-Type: %s; charset=UTF-8\r\n", contentType) + + "\r\n" + + message.Body, + ) + + errSendEmail := s.client.sendEmail(ctx, fromAddress, message.To, emailBytes) + if errSendEmail != nil { + return errSendEmail + } + + slog.DebugContext(ctx, "email sent successfully via SMTP", + "host", s.client.config.Host, + "port", s.client.config.Port, + "to", message.To, + "subject", message.Subject, + ) + + return nil +} + +// NewSender creates a new SMTP sender +func NewSender() port.EmailSender { + return &Sender{ + client: newClient(), + } +} diff --git a/internal/service/message_handler.go b/internal/service/message_handler.go index 4068fa8..a3a3ff4 100644 --- a/internal/service/message_handler.go +++ b/internal/service/message_handler.go @@ -87,7 +87,7 @@ func (m *messageHandlerOrchestrator) searchByEmail(ctx context.Context, criteria PrimaryEmail: email, } if criteria == constants.CriteriaTypeAlternateEmail { - user.AlternateEmail = []model.AlternateEmail{{Email: email}} + user.AlternateEmails = []model.Email{{Email: email}} } // SearchUser is used to find “root” user emails, not linked email @@ -251,8 +251,8 @@ func (m *messageHandlerOrchestrator) checkEmailExists(ctx context.Context, email return errs.NewValidation("email already linked") } - for _, alternateEmail := range user.AlternateEmail { - if strings.EqualFold(alternateEmail.Email, email) && alternateEmail.EmailVerified { + for _, alternateEmail := range user.AlternateEmails { + if strings.EqualFold(alternateEmail.Email, email) && alternateEmail.Verified { return errs.NewValidation("email already linked") } } @@ -362,6 +362,12 @@ func (m *messageHandlerOrchestrator) LinkIdentity(ctx context.Context, msg port. return responseJSON, nil } + user, errMetadataLookup := m.userReader.MetadataLookup(ctx, linkRequest.User.AuthToken) + if errMetadataLookup != nil { + return m.errorResponse(errMetadataLookup.Error()), nil + } + linkRequest.User.UserID = user.UserID + errLinkIdentity := m.identityLinker.LinkIdentity(ctx, linkRequest) if errLinkIdentity != nil { return m.errorResponse(errLinkIdentity.Error()), nil diff --git a/pkg/constants/global.go b/pkg/constants/global.go index e5b50a1..7ea4155 100644 --- a/pkg/constants/global.go +++ b/pkg/constants/global.go @@ -64,3 +64,24 @@ const ( // Auth0RegularWebClientSecretEnvKey is the environment variable key for the regular web Auth0 client secret Auth0RegularWebClientSecretEnvKey = "AUTH0_REGULAR_WEB_CLIENT_SECRET" ) + +const ( + // Email/SMTP configuration (generic for any SMTP provider: Mailpit, SendGrid, AWS SES, etc.) + // EmailSMTPHostEnvKey is the environment variable key for the SMTP server host + EmailSMTPHostEnvKey = "EMAIL_SMTP_HOST" + + // EmailSMTPPortEnvKey is the environment variable key for the SMTP server port + EmailSMTPPortEnvKey = "EMAIL_SMTP_PORT" + + // EmailFromAddressEnvKey is the environment variable key for the sender email address + EmailFromAddressEnvKey = "EMAIL_FROM_ADDRESS" + + // EmailFromNameEnvKey is the environment variable key for the sender name + EmailFromNameEnvKey = "EMAIL_FROM_NAME" + + // EmailSMTPUsernameEnvKey is the environment variable key for SMTP username + EmailSMTPUsernameEnvKey = "EMAIL_SMTP_USERNAME" + + // EmailSMTPPasswordEnvKey is the environment variable key for SMTP password + EmailSMTPPasswordEnvKey = "EMAIL_SMTP_PASSWORD" +) diff --git a/pkg/constants/storage.go b/pkg/constants/storage.go index f642a49..a84cf63 100644 --- a/pkg/constants/storage.go +++ b/pkg/constants/storage.go @@ -8,6 +8,9 @@ const ( // KVBucketNameAutheliaUsers is the name of the KV bucket for authelia users. KVBucketNameAutheliaUsers = "authelia-users" + // KVBucketNameAutheliaEmailOTP is the name of the KV bucket for authelia email OTPs. + KVBucketNameAutheliaEmailOTP = "authelia-email-otp" + // KVLookupPrefixAuthelia is the prefix for lookup keys in the KV store. KVLookupPrefixAuthelia = "lookup/authelia-users/%s" ) diff --git a/pkg/jwt/README.md b/pkg/jwt/README.md new file mode 100644 index 0000000..824c34b --- /dev/null +++ b/pkg/jwt/README.md @@ -0,0 +1,413 @@ +# JWT Package - Identity Token Generator + +This package provides utilities for parsing and generating JWT identity tokens with email claims. + +## Features + +- **Parse JWT tokens** with or without signature verification +- **Generate identity tokens** with email claims +- RSA and HMAC signing support +- Flexible claims management +- Default test methods with singleton pattern (no key management needed!) +- Comprehensive validation options + +## Quick Start + +### Simple Identity Token (Testing) + +For testing and development, use the convenient default methods: + +```go +import ( + "time" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/jwt" +) + +// Super simple - just 2 parameters! +token, err := jwt.GenerateSimpleTestIdentityToken("user@example.com", time.Hour) +``` + +** WARNING:** Default test methods use a singleton test key and are **only for testing**. Never use in production! + +## Generating Identity Tokens + +### Production - With Your Own RSA Key + +```go +import ( + "crypto/rsa" + "time" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/jwt" +) + +func main() { + // Load or generate your RSA private key + var privateKey *rsa.PrivateKey + // ... (load your key) + + // Generate identity token + token, err := jwt.GenerateIdentityToken( + "user@example.com", // email + "https://yourapp.auth0.com/", // issuer + "https://yourapp.auth0.com/api/v2/", // audience + 30*time.Minute, // expires in + privateKey, // signing key + ) + if err != nil { + panic(err) + } + + fmt.Println("Generated token:", token) +} +``` + +### Testing - With Default Key + +```go +// Full control +token, err := jwt.GenerateTestIdentityToken( + "user@example.com", + "https://test.auth0.com/", + "https://test.auth0.com/api/v2/", + 30*time.Minute, +) + +// Minimal parameters (uses defaults) +token, err := jwt.GenerateSimpleTestIdentityToken("user@example.com", time.Hour) +``` + +### HMAC Signing (for testing) + +```go +secret := []byte("your-secret-key") + +// With custom config +token, err := jwt.GenerateHMACIdentityToken( + "user@example.com", + "test-issuer", + "test-audience", + 30*time.Minute, + secret, +) + +// With default key +token, err := jwt.GenerateTestHMACIdentityToken( + "user@example.com", + "test-issuer", + "test-audience", + time.Hour, +) +``` + +### Advanced - Custom Claims + +```go +import "github.com/lestrrat-go/jwx/v2/jwa" + +opts := &jwt.GeneratorOptions{ + Email: "user@example.com", + Subject: "auth0|123456789", // Optional subject + Issuer: "https://myapp.com/", + Audience: "https://verify.myapp.com/", + ExpiresIn: 15 * time.Minute, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: privateKey, + CustomClaims: map[string]any{ + "verification_code": "ABC123", + "purpose": "email-verification", + "tenant_id": "tenant-456", + }, +} + +token, err := jwt.Generate(opts) +``` + +## Parsing Tokens + +### Parse Without Verification + +```go +import "context" + +ctx := context.Background() +claims, err := jwt.ParseUnverified(ctx, tokenString, jwt.DefaultParseOptions()) +if err != nil { + // Handle error +} + +// Get email from claims +email, ok := claims.GetStringClaim("email") +fmt.Println("Email:", email) +``` + +### Parse With Verification + +```go +var publicKey *rsa.PublicKey +// ... (load your public key) + +opts := &jwt.ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: "https://yourapp.auth0.com/", + ExpectedAudience: "https://yourapp.auth0.com/api/v2/", + RequireExpiration: true, + RequireSubject: false, // Identity tokens may not have subject +} + +claims, err := jwt.ParseVerified(ctx, tokenString, opts) +``` + +### Extract Custom Claims + +```go +// Get email +email, ok := claims.GetStringClaim("email") + +// Get verification code +code, ok := claims.GetStringClaim("verification_code") + +// Get any custom claim +value, exists := claims.GetClaim("custom_field") +``` + +## Default Test Methods + +Perfect for unit tests - no need to manage keys! + +### Available Methods + +```go +// RSA-signed with singleton test key +jwt.GenerateTestIdentityToken(email, issuer, audience, expiresIn) +jwt.GenerateSimpleTestIdentityToken(email, expiresIn) + +// HMAC-signed with default secret +jwt.GenerateTestHMACIdentityToken(email, issuer, audience, expiresIn) + +// Get the public key for verification +publicKey, err := jwt.GetDefaultTestPublicKey() +``` + +### Example: Unit Test + +```go +func TestEmailVerification(t *testing.T) { + // Generate test token in one line! + token, err := jwt.GenerateSimpleTestIdentityToken("test@example.com", time.Hour) + require.NoError(t, err) + + // Use it in your test + result, err := emailService.VerifyEmail(token) + require.NoError(t, err) + assert.True(t, result.Verified) +} +``` + +### Example: Verify Test Tokens + +```go +func TestTokenVerification(t *testing.T) { + // Generate token + token, _ := jwt.GenerateSimpleTestIdentityToken("user@test.com", time.Hour) + + // Get the public key + publicKey, _ := jwt.GetDefaultTestPublicKey() + + // Verify + ctx := context.Background() + parseOpts := &jwt.ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + RequireSubject: false, + } + + claims, err := jwt.ParseVerified(ctx, token, parseOpts) + require.NoError(t, err) +} +``` + +## Use Cases + +### Email Verification + +```go +// Generate verification token with short expiration +token, err := jwt.GenerateIdentityToken( + "newuser@example.com", + "https://myapp.com/", + "https://myapp.com/verify-email", + 15*time.Minute, // Short expiration for security + privateKey, +) + +// Send in verification email +emailLink := fmt.Sprintf("https://myapp.com/verify?token=%s", token) +``` + +### Password Reset + +```go +// Generate reset token +opts := &jwt.GeneratorOptions{ + Email: "user@example.com", + Subject: "user-id-123", + Issuer: "https://myapp.com/", + Audience: "https://myapp.com/reset-password", + ExpiresIn: 30 * time.Minute, + SigningKey: privateKey, + SigningMethod: jwa.RS256, + CustomClaims: map[string]any{ + "reset_code": generateSecureCode(), + }, +} + +token, err := jwt.Generate(opts) +``` + +### Account Linking + +```go +// Generate token for linking alternate email +token, err := jwt.GenerateIdentityToken( + "alternate@example.com", + "https://myapp.com/", + "https://myapp.com/link-email", + 1*time.Hour, + privateKey, +) +``` + +## Helper Functions + +### Option Builders + +```go +// Create options with defaults +opts := jwt.IdentityTokenOptions("user@example.com", privateKey) +opts.Issuer = "https://myapp.com/" +opts.ExpiresIn = 15 * time.Minute + +token, err := jwt.Generate(opts) +``` + +```go +// HMAC options +opts := jwt.HMACIdentityTokenOptions("user@example.com", secret) +token, err := jwt.Generate(opts) +``` + +## Complete Example + +```go +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "fmt" + "time" + + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/jwt" +) + +func main() { + // Generate RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + publicKey := &privateKey.PublicKey + + // Generate identity token + tokenString, err := jwt.GenerateIdentityToken( + "user@example.com", + "https://myapp.com/", + "https://myapp.com/verify-email", + 15*time.Minute, + privateKey, + ) + if err != nil { + panic(err) + } + + fmt.Println("Generated token!") + + // Parse and verify + ctx := context.Background() + parseOpts := &jwt.ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: "https://myapp.com/", + ExpectedAudience: "https://myapp.com/verify-email", + RequireExpiration: true, + RequireSubject: false, + } + + claims, err := jwt.ParseVerified(ctx, tokenString, parseOpts) + if err != nil { + panic(err) + } + + email, _ := claims.GetStringClaim("email") + fmt.Println("Verified email:", email) +} +``` + +## API Reference + +### Generation Functions + +| Function | Description | Use Case | +|----------|-------------|----------| +| `Generate(opts)` | Generate with full control | Custom claims, advanced config | +| `GenerateIdentityToken(...)` | Generate with RSA | Production use | +| `GenerateHMACIdentityToken(...)` | Generate with HMAC | Testing HMAC signatures | +| `GenerateTestIdentityToken(...)` | Generate with default RSA key | Testing with custom issuer | +| `GenerateSimpleTestIdentityToken(...)` | Generate with defaults | Quick testing | +| `GenerateTestHMACIdentityToken(...)` | Generate with default HMAC | Testing | + +### Helper Functions + +| Function | Returns | Description | +|----------|---------|-------------| +| `GetDefaultTestPublicKey()` | `*rsa.PublicKey` | Get singleton test public key | +| `IdentityTokenOptions(email, key)` | `*GeneratorOptions` | Create options with defaults | +| `HMACIdentityTokenOptions(email, secret)` | `*GeneratorOptions` | Create HMAC options | +| `DefaultGeneratorOptions()` | `*GeneratorOptions` | Get default options | + +## Error Handling + +```go +token, err := jwt.GenerateIdentityToken(...) +if err != nil { + switch err.(type) { + case errors.Validation: + // Invalid input (missing email, bad key, etc.) + case errors.Unexpected: + // Signing/building failure + } +} +``` + +## Important Notes + +**Default test methods are for testing only!** +- Keys are generated once and reused +- Not secure for production +- Clearly marked with "Test" in function names + +## Testing + +```bash +# Run all tests +go test ./pkg/jwt/... + +# Run with verbose output +go test -v ./pkg/jwt/... + +# Run specific test +go test -v ./pkg/jwt/... -run TestGenerateSimpleTestIdentityToken +``` diff --git a/pkg/jwt/generator.go b/pkg/jwt/generator.go new file mode 100644 index 0000000..dfe4cec --- /dev/null +++ b/pkg/jwt/generator.go @@ -0,0 +1,375 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package jwt + +import ( + "crypto/rand" + "crypto/rsa" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/linuxfoundation/lfx-v2-auth-service/pkg/errors" +) + +var ( + // defaultTestKey is a singleton RSA key pair for testing purposes only + defaultTestKey *rsa.PrivateKey + defaultTestKeyOnce sync.Once + defaultTestKeyErr error + + // defaultHMACSecret is a default HMAC secret for testing purposes only + defaultHMACSecret = []byte("test-hmac-secret-key-for-development-only-do-not-use-in-production") +) + +// getDefaultTestKey returns a singleton RSA private key for testing purposes. +// WARNING: This key is generated once and reused. DO NOT use in production! +func getDefaultTestKey() (*rsa.PrivateKey, error) { + defaultTestKeyOnce.Do(func() { + defaultTestKey, defaultTestKeyErr = rsa.GenerateKey(rand.Reader, 2048) + }) + return defaultTestKey, defaultTestKeyErr +} + +// GetDefaultTestPublicKey returns the public key corresponding to the default test private key. +// This is useful for verifying tokens generated with the default test key. +// WARNING: For testing purposes only! +func GetDefaultTestPublicKey() (*rsa.PublicKey, error) { + key, err := getDefaultTestKey() + if err != nil { + return nil, err + } + return &key.PublicKey, nil +} + +// TokenType represents the type of token being generated +type TokenType string + +const ( + // TokenTypeAccess represents an access token with a subject claim + TokenTypeAccess TokenType = "access" + // TokenTypeIdentity represents an identity token with an email claim + TokenTypeIdentity TokenType = "identity" +) + +// GeneratorOptions configures JWT token generation +type GeneratorOptions struct { + // TokenType specifies the type of token (access or identity) + TokenType TokenType + // Subject is the 'sub' claim (required for access tokens) + Subject string + // Email is the 'email' claim (required for identity tokens) + Email string + // Scope is the 'scope' claim (space-separated permissions, for access tokens) + Scope string + // Issuer is the 'iss' claim + Issuer string + // Audience is the 'aud' claim + Audience string + // ExpiresIn is the duration until the token expires (defaults to 1 hour) + ExpiresIn time.Duration + // IssuedAt is the 'iat' claim (defaults to now) + IssuedAt time.Time + // NotBefore is the 'nbf' claim (optional) + NotBefore *time.Time + // CustomClaims allows adding additional custom claims + CustomClaims map[string]any + // SigningMethod is the algorithm to use for signing (RS256, HS256, etc.) + SigningMethod jwa.SignatureAlgorithm + // SigningKey is the key used to sign the token (RSA private key or HMAC secret) + SigningKey any +} + +// DefaultGeneratorOptions returns sensible defaults for token generation +func DefaultGeneratorOptions() *GeneratorOptions { + return &GeneratorOptions{ + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + } +} + +// AccessTokenOptions creates options for generating an access token +func AccessTokenOptions(subject string, signingKey *rsa.PrivateKey) *GeneratorOptions { + return &GeneratorOptions{ + TokenType: TokenTypeAccess, + Subject: subject, + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: signingKey, + } +} + +// IdentityTokenOptions creates options for generating an identity token +func IdentityTokenOptions(email string, signingKey *rsa.PrivateKey) *GeneratorOptions { + return &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: email, + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: signingKey, + } +} + +// HMACAccessTokenOptions creates options for generating an HMAC-signed access token (useful for testing) +func HMACAccessTokenOptions(subject string, secret []byte) *GeneratorOptions { + return &GeneratorOptions{ + TokenType: TokenTypeAccess, + Subject: subject, + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.HS256, + SigningKey: secret, + } +} + +// HMACIdentityTokenOptions creates options for generating an HMAC-signed identity token (useful for testing) +func HMACIdentityTokenOptions(email string, secret []byte) *GeneratorOptions { + return &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: email, + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.HS256, + SigningKey: secret, + } +} + +// Generate creates and signs a JWT token based on the provided options +func Generate(opts *GeneratorOptions) (string, error) { + if opts == nil { + return "", errors.NewValidation("generator options are required") + } + + if opts.SigningKey == nil { + return "", errors.NewValidation("signing key is required") + } + + // Validate token type specific requirements + switch opts.TokenType { + case TokenTypeAccess: + if opts.Subject == "" { + return "", errors.NewValidation("subject is required for access tokens") + } + case TokenTypeIdentity: + if opts.Email == "" { + return "", errors.NewValidation("email is required for identity tokens") + } + default: + return "", errors.NewValidation("token type is required") + } + + // Create a new JWT builder + builder := jwt.NewBuilder() + + // Set issued at time + if opts.IssuedAt.IsZero() { + opts.IssuedAt = time.Now() + } + builder = builder.IssuedAt(opts.IssuedAt) + + // Set expiration + if opts.ExpiresIn > 0 { + builder = builder.Expiration(opts.IssuedAt.Add(opts.ExpiresIn)) + } + + // Set not before if specified + if opts.NotBefore != nil { + builder = builder.NotBefore(*opts.NotBefore) + } + + // Set issuer if specified + if opts.Issuer != "" { + builder = builder.Issuer(opts.Issuer) + } + + // Set audience if specified + if opts.Audience != "" { + builder = builder.Audience([]string{opts.Audience}) + } + + // Set token type specific claims + switch opts.TokenType { + case TokenTypeAccess: + builder = builder.Subject(opts.Subject) + if opts.Scope != "" { + builder = builder.Claim("scope", opts.Scope) + } + + case TokenTypeIdentity: + builder = builder.Claim("email", opts.Email) + // Identity tokens can also have a subject + if opts.Subject != "" { + builder = builder.Subject(opts.Subject) + } + } + + // Add any custom claims + for key, value := range opts.CustomClaims { + builder = builder.Claim(key, value) + } + + // Build the token + token, err := builder.Build() + if err != nil { + return "", errors.NewUnexpected("failed to build JWT token", err) + } + + // Sign the token + signed, err := jwt.Sign(token, jwt.WithKey(opts.SigningMethod, opts.SigningKey)) + if err != nil { + return "", errors.NewUnexpected("failed to sign JWT token", err) + } + + return string(signed), nil +} + +// GenerateAccessToken is a convenience function to generate an access token with a subject +func GenerateAccessToken(subject, issuer, audience, scope string, expiresIn time.Duration, signingKey *rsa.PrivateKey) (string, error) { + opts := &GeneratorOptions{ + TokenType: TokenTypeAccess, + Subject: subject, + Issuer: issuer, + Audience: audience, + Scope: scope, + ExpiresIn: expiresIn, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: signingKey, + } + return Generate(opts) +} + +// GenerateIdentityToken is a convenience function to generate an identity token with an email +func GenerateIdentityToken(email, issuer, audience string, expiresIn time.Duration, signingKey *rsa.PrivateKey) (string, error) { + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: email, + Issuer: issuer, + Audience: audience, + ExpiresIn: expiresIn, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: signingKey, + } + return Generate(opts) +} + +// GenerateHMACAccessToken is a convenience function to generate an HMAC-signed access token (useful for testing) +func GenerateHMACAccessToken(subject, issuer, audience, scope string, expiresIn time.Duration, secret []byte) (string, error) { + opts := &GeneratorOptions{ + TokenType: TokenTypeAccess, + Subject: subject, + Issuer: issuer, + Audience: audience, + Scope: scope, + ExpiresIn: expiresIn, + IssuedAt: time.Now(), + SigningMethod: jwa.HS256, + SigningKey: secret, + } + return Generate(opts) +} + +// GenerateHMACIdentityToken is a convenience function to generate an HMAC-signed identity token (useful for testing) +func GenerateHMACIdentityToken(email, issuer, audience string, expiresIn time.Duration, secret []byte) (string, error) { + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: email, + Issuer: issuer, + Audience: audience, + ExpiresIn: expiresIn, + IssuedAt: time.Now(), + SigningMethod: jwa.HS256, + SigningKey: secret, + } + return Generate(opts) +} + +// GenerateTestAccessToken generates an access token using the default test signing key. +// This is a convenience method for testing that doesn't require providing a signing key. +// WARNING: For testing purposes only! +func GenerateTestAccessToken(subject, issuer, audience, scope string, expiresIn time.Duration) (string, error) { + key, err := getDefaultTestKey() + if err != nil { + return "", errors.NewUnexpected("failed to get default test key", err) + } + return GenerateAccessToken(subject, issuer, audience, scope, expiresIn, key) +} + +// GenerateTestIdentityToken generates an identity token using the default test signing key. +// This is a convenience method for testing that doesn't require providing a signing key. +// WARNING: For testing purposes only! +func GenerateTestIdentityToken(email, issuer, audience string, expiresIn time.Duration) (string, error) { + key, err := getDefaultTestKey() + if err != nil { + return "", errors.NewUnexpected("failed to get default test key", err) + } + return GenerateIdentityToken(email, issuer, audience, expiresIn, key) +} + +// GenerateTestHMACAccessToken generates an HMAC-signed access token using the default HMAC secret. +// This is a convenience method for testing that doesn't require providing a secret. +// WARNING: For testing purposes only! +func GenerateTestHMACAccessToken(subject, issuer, audience, scope string, expiresIn time.Duration) (string, error) { + return GenerateHMACAccessToken(subject, issuer, audience, scope, expiresIn, defaultHMACSecret) +} + +// GenerateTestHMACIdentityToken generates an HMAC-signed identity token using the default HMAC secret. +// This is a convenience method for testing that doesn't require providing a secret. +// WARNING: For testing purposes only! +func GenerateTestHMACIdentityToken(email, issuer, audience string, expiresIn time.Duration) (string, error) { + return GenerateHMACIdentityToken(email, issuer, audience, expiresIn, defaultHMACSecret) +} + +// GenerateSimpleTestAccessToken generates an access token with minimal configuration for quick testing. +// Uses default test issuer, audience, and scope. Only requires a subject and expiration. +// WARNING: For testing purposes only! +func GenerateSimpleTestAccessToken(subject string, expiresIn time.Duration) (string, error) { + return GenerateTestAccessToken( + subject, + "https://test.any.com/", + "https://test.any.com/api/v2/", + "read:current_user", + expiresIn, + ) +} + +// GenerateSimpleTestIdentityToken generates an identity token with minimal configuration for quick testing. +// Uses default test issuer and audience. Only requires an email and expiration. +// WARNING: For testing purposes only! +func GenerateSimpleTestIdentityToken(email string, expiresIn time.Duration) (string, error) { + return GenerateTestIdentityToken( + email, + "https://test.any.com/", + "https://test.any.com/api/v2/", + expiresIn, + ) +} + +// GenerateSimpleTestIdentityTokenWithSubject generates an identity token with a custom subject claim. +// Uses default test issuer and audience. Requires an email, subject, and expiration. +// WARNING: For testing purposes only! +func GenerateSimpleTestIdentityTokenWithSubject(email, subject string, expiresIn time.Duration) (string, error) { + key, err := getDefaultTestKey() + if err != nil { + return "", errors.NewUnexpected("failed to get default test key", err) + } + + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: email, + Subject: subject, + Issuer: "https://test.any.com/", + Audience: "https://test.any.com/api/v2/", + ExpiresIn: expiresIn, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: key, + } + return Generate(opts) +} diff --git a/pkg/jwt/generator_test.go b/pkg/jwt/generator_test.go new file mode 100644 index 0000000..7add73d --- /dev/null +++ b/pkg/jwt/generator_test.go @@ -0,0 +1,620 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package jwt + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "testing" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateAccessToken(t *testing.T) { + // Generate test RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey := &privateKey.PublicKey + + t.Run("access token with RSA signing", func(t *testing.T) { + opts := &GeneratorOptions{ + TokenType: TokenTypeAccess, + Subject: "auth0|123456789", + Issuer: "https://test.auth0.com/", + Audience: "https://test.auth0.com/api/v2/", + Scope: "read:current_user update:current_user_metadata", + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Verify the token can be parsed and validated + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: opts.Issuer, + ExpectedAudience: opts.Audience, + RequireExpiration: true, + RequireSubject: true, + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + assert.Equal(t, opts.Subject, claims.Subject) + assert.Equal(t, opts.Issuer, claims.Issuer) + assert.Equal(t, opts.Audience, claims.Audience) + assert.Equal(t, opts.Scope, claims.Scope) + assert.NotNil(t, claims.ExpiresAt) + assert.NotNil(t, claims.IssuedAt) + }) + + t.Run("access token with HMAC signing", func(t *testing.T) { + secret := []byte("test-secret-key-for-hmac-signing") + opts := &GeneratorOptions{ + TokenType: TokenTypeAccess, + Subject: "user123", + Issuer: "test-issuer", + Scope: "read write", + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.HS256, + SigningKey: secret, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Parse without verification + ctx := context.Background() + parseOpts := &ParseOptions{ + RequireExpiration: true, + RequireSubject: true, + } + claims, err := ParseUnverified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + assert.Equal(t, opts.Subject, claims.Subject) + assert.Equal(t, opts.Issuer, claims.Issuer) + assert.Equal(t, opts.Scope, claims.Scope) + }) + + t.Run("missing subject for access token", func(t *testing.T) { + opts := &GeneratorOptions{ + TokenType: TokenTypeAccess, + ExpiresIn: time.Hour, + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + _, err := Generate(opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "subject is required") + }) +} + +func TestGenerateIdentityToken(t *testing.T) { + // Generate test RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey := &privateKey.PublicKey + + t.Run("identity token with RSA signing", func(t *testing.T) { + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: "user@example.com", + Issuer: "https://test.auth0.com/", + Audience: "https://test.auth0.com/api/v2/", + ExpiresIn: 30 * time.Minute, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Verify the token can be parsed + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: opts.Issuer, + ExpectedAudience: opts.Audience, + RequireExpiration: true, + RequireSubject: false, // Identity tokens may not have subject + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + // Check email claim + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, opts.Email, email) + assert.Equal(t, opts.Issuer, claims.Issuer) + assert.Equal(t, opts.Audience, claims.Audience) + }) + + t.Run("identity token with HMAC signing", func(t *testing.T) { + secret := []byte("test-secret-key-for-hmac-signing") + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: "user@example.com", + Issuer: "test-issuer", + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.HS256, + SigningKey: secret, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Parse without verification + ctx := context.Background() + parseOpts := &ParseOptions{ + RequireExpiration: true, + RequireSubject: false, + } + claims, err := ParseUnverified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, opts.Email, email) + assert.Equal(t, opts.Issuer, claims.Issuer) + }) + + t.Run("identity token with additional claims", func(t *testing.T) { + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: "user@example.com", + Issuer: "https://custom.issuer.com/", + ExpiresIn: 2 * time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: privateKey, + CustomClaims: map[string]any{ + "verification_code": "ABC123", + "purpose": "email-verification", + }, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Verify custom claims are present + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: opts.Issuer, + RequireSubject: false, + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + code, ok := claims.GetStringClaim("verification_code") + assert.True(t, ok) + assert.Equal(t, "ABC123", code) + + purpose, ok := claims.GetStringClaim("purpose") + assert.True(t, ok) + assert.Equal(t, "email-verification", purpose) + }) + + t.Run("identity token with NotBefore claim", func(t *testing.T) { + // Set NotBefore to past time so token is already valid + notBefore := time.Now().Add(-1 * time.Minute) + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: "user@example.com", + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + NotBefore: ¬Before, + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Parse and check NotBefore + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + RequireExpiration: true, + RequireSubject: false, + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + assert.NotNil(t, claims.NotBefore) + assert.WithinDuration(t, notBefore, *claims.NotBefore, time.Second) + }) + + t.Run("identity token with subject", func(t *testing.T) { + // Identity tokens can also have a subject along with email + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: "user@example.com", + Subject: "auth0|123456789", + Issuer: "https://test.auth0.com/", + ExpiresIn: time.Hour, + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + + // Parse and verify both email and subject are present + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: opts.Issuer, + RequireSubject: true, + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + assert.Equal(t, "auth0|123456789", claims.Subject) + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, "user@example.com", email) + }) + + t.Run("missing signing key", func(t *testing.T) { + opts := &GeneratorOptions{ + Email: "user@example.com", + ExpiresIn: time.Hour, + } + + _, err := Generate(opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "signing key is required") + }) + + t.Run("missing email", func(t *testing.T) { + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + ExpiresIn: time.Hour, + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + _, err := Generate(opts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "email is required") + }) + + t.Run("nil options", func(t *testing.T) { + _, err := Generate(nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "generator options are required") + }) +} + +func TestGenerateAccessTokenConvenience(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey := &privateKey.PublicKey + + tokenString, err := GenerateAccessToken( + "auth0|123456789", + "https://test.auth0.com/", + "https://test.auth0.com/api/v2/", + "read:current_user update:current_user_metadata", + time.Hour, + privateKey, + ) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Verify the token + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: "https://test.auth0.com/", + ExpectedAudience: "https://test.auth0.com/api/v2/", + RequireExpiration: true, + RequireSubject: true, + RequiredScopes: []string{"read:current_user"}, + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + assert.Equal(t, "auth0|123456789", claims.Subject) + assert.True(t, claims.HasScope("update:current_user_metadata")) +} + +func TestGenerateIdentityTokenConvenience(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + publicKey := &privateKey.PublicKey + + tokenString, err := GenerateIdentityToken( + "user@example.com", + "https://test.auth0.com/", + "https://test.auth0.com/api/v2/", + 30*time.Minute, + privateKey, + ) + require.NoError(t, err) + assert.NotEmpty(t, tokenString) + + // Verify the token + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + ExpectedIssuer: "https://test.auth0.com/", + ExpectedAudience: "https://test.auth0.com/api/v2/", + RequireExpiration: true, + RequireSubject: false, + } + claims, err := ParseVerified(ctx, tokenString, parseOpts) + require.NoError(t, err) + + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, "user@example.com", email) +} + +func TestGenerateTestAccessToken(t *testing.T) { + t.Run("generate with default test key", func(t *testing.T) { + token, err := GenerateTestAccessToken( + "test-user-123", + "https://test.auth0.com/", + "https://test.auth0.com/api/v2/", + "read:current_user", + time.Hour, + ) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Verify token can be parsed + ctx := context.Background() + claims, err := ParseUnverified(ctx, token, DefaultParseOptions()) + require.NoError(t, err) + assert.Equal(t, "test-user-123", claims.Subject) + }) + + t.Run("tokens use same key (singleton)", func(t *testing.T) { + token1, err := GenerateTestAccessToken("user1", "https://test.com/", "https://api.test.com/", "read", time.Hour) + require.NoError(t, err) + + token2, err := GenerateTestAccessToken("user2", "https://test.com/", "https://api.test.com/", "read", time.Hour) + require.NoError(t, err) + + // Both tokens should be verifiable with the same public key + publicKey, err := GetDefaultTestPublicKey() + require.NoError(t, err) + + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + } + + _, err = ParseVerified(ctx, token1, parseOpts) + require.NoError(t, err) + + _, err = ParseVerified(ctx, token2, parseOpts) + require.NoError(t, err) + }) +} + +func TestGenerateTestIdentityToken(t *testing.T) { + t.Run("generate with default test key", func(t *testing.T) { + token, err := GenerateTestIdentityToken( + "test@example.com", + "https://test.auth0.com/", + "https://test.auth0.com/api/v2/", + 30*time.Minute, + ) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Verify token can be parsed + ctx := context.Background() + parseOpts := &ParseOptions{ + RequireExpiration: true, + RequireSubject: false, + } + claims, err := ParseUnverified(ctx, token, parseOpts) + require.NoError(t, err) + + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, "test@example.com", email) + }) + + t.Run("tokens use same key (singleton)", func(t *testing.T) { + token1, err := GenerateTestIdentityToken("user1@test.com", "https://test.com/", "https://api.test.com/", time.Hour) + require.NoError(t, err) + + token2, err := GenerateTestIdentityToken("user2@test.com", "https://test.com/", "https://api.test.com/", time.Hour) + require.NoError(t, err) + + // Both tokens should be verifiable with the same public key + publicKey, err := GetDefaultTestPublicKey() + require.NoError(t, err) + + ctx := context.Background() + parseOpts := &ParseOptions{ + VerifySignature: true, + SigningKey: publicKey, + RequireSubject: false, + } + + _, err = ParseVerified(ctx, token1, parseOpts) + require.NoError(t, err) + + _, err = ParseVerified(ctx, token2, parseOpts) + require.NoError(t, err) + }) +} + +func TestGenerateTestHMACIdentityToken(t *testing.T) { + token, err := GenerateTestHMACIdentityToken( + "hmac-identity@example.com", + "test-issuer", + "test-audience", + time.Hour, + ) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Parse token + ctx := context.Background() + parseOpts := &ParseOptions{ + RequireSubject: false, + } + claims, err := ParseUnverified(ctx, token, parseOpts) + require.NoError(t, err) + + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, "hmac-identity@example.com", email) +} + +func TestGenerateSimpleTestAccessToken(t *testing.T) { + // Super simple - just subject and expiration + token, err := GenerateSimpleTestAccessToken("simple-user", time.Hour) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Verify it has default values + ctx := context.Background() + claims, err := ParseUnverified(ctx, token, DefaultParseOptions()) + require.NoError(t, err) + + assert.Equal(t, "simple-user", claims.Subject) + assert.Equal(t, "https://test.any.com/", claims.Issuer) + assert.Equal(t, "https://test.any.com/api/v2/", claims.Audience) + assert.Equal(t, "read:current_user", claims.Scope) +} + +func TestGenerateSimpleTestIdentityToken(t *testing.T) { + // Super simple - just email and expiration + token, err := GenerateSimpleTestIdentityToken("simple@example.com", 30*time.Minute) + require.NoError(t, err) + assert.NotEmpty(t, token) + + // Verify it has default values + ctx := context.Background() + parseOpts := &ParseOptions{ + RequireSubject: false, + } + claims, err := ParseUnverified(ctx, token, parseOpts) + require.NoError(t, err) + + email, ok := claims.GetStringClaim("email") + assert.True(t, ok) + assert.Equal(t, "simple@example.com", email) + assert.Equal(t, "https://test.any.com/", claims.Issuer) + assert.Equal(t, "https://test.any.com/api/v2/", claims.Audience) +} + +func TestGetDefaultTestPublicKey(t *testing.T) { + // Get public key multiple times + key1, err := GetDefaultTestPublicKey() + require.NoError(t, err) + assert.NotNil(t, key1) + + key2, err := GetDefaultTestPublicKey() + require.NoError(t, err) + assert.NotNil(t, key2) + + // Should be the same key (pointer equality) + assert.Equal(t, key1, key2) + + // Verify it matches the private key + privateKey, err := getDefaultTestKey() + require.NoError(t, err) + assert.Equal(t, &privateKey.PublicKey, key1) +} + +func TestIdentityTokenOptions(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + opts := IdentityTokenOptions("user@example.com", privateKey) + + assert.Equal(t, "user@example.com", opts.Email) + assert.Equal(t, time.Hour, opts.ExpiresIn) + assert.Equal(t, jwa.RS256, opts.SigningMethod) + assert.Equal(t, privateKey, opts.SigningKey) + assert.False(t, opts.IssuedAt.IsZero()) +} + +func TestHMACIdentityTokenOptions(t *testing.T) { + secret := []byte("test-secret") + + opts := HMACIdentityTokenOptions("identity@example.com", secret) + + assert.Equal(t, "identity@example.com", opts.Email) + assert.Equal(t, time.Hour, opts.ExpiresIn) + assert.Equal(t, jwa.HS256, opts.SigningMethod) + assert.Equal(t, secret, opts.SigningKey) + assert.False(t, opts.IssuedAt.IsZero()) +} + +func TestDefaultGeneratorOptions(t *testing.T) { + opts := DefaultGeneratorOptions() + + assert.Equal(t, time.Hour, opts.ExpiresIn) + assert.Equal(t, jwa.RS256, opts.SigningMethod) + assert.False(t, opts.IssuedAt.IsZero()) +} + +func TestTokenExpiration(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + t.Run("token expires correctly", func(t *testing.T) { + opts := &GeneratorOptions{ + TokenType: TokenTypeIdentity, + Email: "user@example.com", + ExpiresIn: 100 * time.Millisecond, // Very short expiration + IssuedAt: time.Now(), + SigningMethod: jwa.RS256, + SigningKey: privateKey, + } + + tokenString, err := Generate(opts) + require.NoError(t, err) + + // Wait for token to expire + time.Sleep(200 * time.Millisecond) + + // Try to parse - should fail due to expiration + ctx := context.Background() + parseOpts := &ParseOptions{ + RequireExpiration: true, + RequireSubject: false, + } + _, err = ParseUnverified(ctx, tokenString, parseOpts) + assert.Error(t, err) + assert.Contains(t, err.Error(), "exp") + }) +} diff --git a/pkg/jwt/parser.go b/pkg/jwt/parser.go index e2e1c6e..cd85c96 100644 --- a/pkg/jwt/parser.go +++ b/pkg/jwt/parser.go @@ -8,6 +8,7 @@ import ( "crypto/rsa" "fmt" "log/slog" + "maps" "slices" "strings" "time" @@ -22,6 +23,7 @@ import ( // Claims represents the parsed JWT claims with commonly used fields type Claims struct { Subject string `json:"sub"` + Email string `json:"email,omitempty"` ExpiresAt *time.Time `json:"exp,omitempty"` IssuedAt *time.Time `json:"iat,omitempty"` NotBefore *time.Time `json:"nbf,omitempty"` @@ -215,6 +217,13 @@ func extractClaimsFromJWT(token jwt.Token) (*Claims, error) { claims.Audience = audience[0] // Take the first audience } + // Extract email from private claims + if email, ok := token.Get("email"); ok { + if emailStr, ok := email.(string); ok { + claims.Email = emailStr + } + } + // Extract scope from private claims if scope, ok := token.Get("scope"); ok { if scopeStr, ok := scope.(string); ok { @@ -239,9 +248,7 @@ func extractClaimsFromJWT(token jwt.Token) (*Claims, error) { } // Store all raw claims - for key, value := range token.PrivateClaims() { - claims.Raw[key] = value - } + maps.Copy(claims.Raw, token.PrivateClaims()) return claims, nil } diff --git a/pkg/password/generate.go b/pkg/password/generate.go index c18f524..d9093af 100644 --- a/pkg/password/generate.go +++ b/pkg/password/generate.go @@ -32,6 +32,26 @@ func AlphaNum(length int) (string, error) { return string(result), nil } +// OnlyNumbers generates a random alphanumeric string of the specified length +func OnlyNumbers(length int) (string, error) { + if length <= 0 { + return "", errors.NewValidation("length must be positive") + } + + const charset = "0123456789" + result := make([]byte, length) + + for i := range result { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) + if err != nil { + return "", err + } + result[i] = charset[num.Int64()] + } + + return string(result), nil +} + // GeneratePasswordPair generates a random password and returns both plain text and bcrypt hash func GeneratePasswordPair(length int) (plainPassword, bcryptHash string, err error) { // Generate random password of specified length diff --git a/pkg/password/generate_test.go b/pkg/password/generate_test.go new file mode 100644 index 0000000..6ec409a --- /dev/null +++ b/pkg/password/generate_test.go @@ -0,0 +1,313 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package password + +import ( + "regexp" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestAlphaNum(t *testing.T) { + tests := []struct { + name string + length int + expectError bool + }{ + { + name: "valid length 10", + length: 10, + expectError: false, + }, + { + name: "valid length 1", + length: 1, + expectError: false, + }, + { + name: "valid length 100", + length: 100, + expectError: false, + }, + { + name: "invalid length 0", + length: 0, + expectError: true, + }, + { + name: "invalid negative length", + length: -5, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := AlphaNum(tt.length) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + if result != "" { + t.Errorf("expected empty string on error, got: %s", result) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Check length + if len(result) != tt.length { + t.Errorf("expected length %d, got %d", tt.length, len(result)) + } + + // Check that string only contains alphanumeric characters + matched, _ := regexp.MatchString("^[a-zA-Z0-9]+$", result) + if !matched { + t.Errorf("result contains non-alphanumeric characters: %s", result) + } + }) + } +} + +func TestAlphaNum_Randomness(t *testing.T) { + // Generate multiple strings and ensure they're different + const iterations = 10 + const length = 20 + results := make(map[string]bool) + + for i := 0; i < iterations; i++ { + result, err := AlphaNum(length) + if err != nil { + t.Fatalf("unexpected error on iteration %d: %v", i, err) + } + results[result] = true + } + + // With a length of 20 and 62 possible characters (26 lowercase + 26 uppercase + 10 digits), + // it's extremely unlikely to get duplicates in 10 iterations + if len(results) < iterations { + t.Errorf("expected %d unique results, got %d (possible collision or weak randomness)", iterations, len(results)) + } +} + +func TestOnlyNumbers(t *testing.T) { + tests := []struct { + name string + length int + expectError bool + }{ + { + name: "valid length 6", + length: 6, + expectError: false, + }, + { + name: "valid length 1", + length: 1, + expectError: false, + }, + { + name: "valid length 50", + length: 50, + expectError: false, + }, + { + name: "invalid length 0", + length: 0, + expectError: true, + }, + { + name: "invalid negative length", + length: -10, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := OnlyNumbers(tt.length) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + if result != "" { + t.Errorf("expected empty string on error, got: %s", result) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Check length + if len(result) != tt.length { + t.Errorf("expected length %d, got %d", tt.length, len(result)) + } + + // Check that string only contains digits + matched, _ := regexp.MatchString("^[0-9]+$", result) + if !matched { + t.Errorf("result contains non-numeric characters: %s", result) + } + }) + } +} + +func TestOnlyNumbers_Randomness(t *testing.T) { + // Generate multiple strings and ensure they're different + const iterations = 10 + const length = 10 + results := make(map[string]bool) + + for i := 0; i < iterations; i++ { + result, err := OnlyNumbers(length) + if err != nil { + t.Fatalf("unexpected error on iteration %d: %v", i, err) + } + results[result] = true + } + + // With a length of 10 and 10 possible digits, we should get mostly unique results + // Allow for some collisions but ensure we have reasonable uniqueness + if len(results) < iterations-2 { + t.Errorf("expected at least %d unique results, got %d (possible weak randomness)", iterations-2, len(results)) + } +} + +func TestGeneratePasswordPair(t *testing.T) { + tests := []struct { + name string + length int + expectError bool + }{ + { + name: "valid length 12", + length: 12, + expectError: false, + }, + { + name: "valid length 8", + length: 8, + expectError: false, + }, + { + name: "valid length 20", + length: 20, + expectError: false, + }, + { + name: "invalid length 0", + length: 0, + expectError: true, + }, + { + name: "invalid negative length", + length: -1, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plainPassword, bcryptHash, err := GeneratePasswordPair(tt.length) + + if tt.expectError { + if err == nil { + t.Error("expected error but got none") + } + if plainPassword != "" || bcryptHash != "" { + t.Errorf("expected empty strings on error, got plainPassword: %s, bcryptHash: %s", plainPassword, bcryptHash) + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + // Check plain password length + if len(plainPassword) != tt.length { + t.Errorf("expected plain password length %d, got %d", tt.length, len(plainPassword)) + } + + // Check that plain password only contains alphanumeric characters + matched, _ := regexp.MatchString("^[a-zA-Z0-9]+$", plainPassword) + if !matched { + t.Errorf("plain password contains non-alphanumeric characters: %s", plainPassword) + } + + // Check that bcrypt hash is not empty + if bcryptHash == "" { + t.Error("bcrypt hash is empty") + } + + // Verify that the hash matches the plain password + err = bcrypt.CompareHashAndPassword([]byte(bcryptHash), []byte(plainPassword)) + if err != nil { + t.Errorf("bcrypt hash does not match plain password: %v", err) + } + + // Check that the hash starts with bcrypt prefix + if len(bcryptHash) < 7 || bcryptHash[:4] != "$2a$" && bcryptHash[:4] != "$2b$" { + t.Errorf("bcrypt hash doesn't have expected format: %s", bcryptHash) + } + }) + } +} + +func TestGeneratePasswordPair_Uniqueness(t *testing.T) { + // Generate multiple password pairs and ensure they're unique + const iterations = 5 + const length = 16 + + plainPasswords := make(map[string]bool) + bcryptHashes := make(map[string]bool) + + for i := 0; i < iterations; i++ { + plainPassword, bcryptHash, err := GeneratePasswordPair(length) + if err != nil { + t.Fatalf("unexpected error on iteration %d: %v", i, err) + } + + plainPasswords[plainPassword] = true + bcryptHashes[bcryptHash] = true + } + + // Plain passwords should all be unique + if len(plainPasswords) != iterations { + t.Errorf("expected %d unique plain passwords, got %d", iterations, len(plainPasswords)) + } + + // Bcrypt hashes should all be unique (even for the same password, bcrypt generates different hashes) + if len(bcryptHashes) != iterations { + t.Errorf("expected %d unique bcrypt hashes, got %d", iterations, len(bcryptHashes)) + } +} + +func TestGeneratePasswordPair_VerifyBcryptCost(t *testing.T) { + _, bcryptHash, err := GeneratePasswordPair(10) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check that bcrypt cost is DefaultCost (10) + cost, err := bcrypt.Cost([]byte(bcryptHash)) + if err != nil { + t.Fatalf("failed to get bcrypt cost: %v", err) + } + + if cost != bcrypt.DefaultCost { + t.Errorf("expected bcrypt cost %d, got %d", bcrypt.DefaultCost, cost) + } +} From 23c749eeb4f37b002cb3b9356de45e75ffb4d76e Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Fri, 24 Oct 2025 13:20:44 -0300 Subject: [PATCH 2/8] feat: add user email retrieval functionality and update documentation - Introduced a new feature to retrieve user email addresses (primary and alternate) via the NATS subject `lfx.auth-service.user_emails.read`. - Updated the message handler to support user email retrieval operations. - Enhanced the README and added a new documentation file for user email operations, detailing request formats and response structures. - Incremented the chart version to 0.3.1 to reflect the new feature addition. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-501 Generated with [Cursor](https://cursor.com/) Signed-off-by: Mauricio Zanetti Salomao --- README.md | 10 ++ charts/lfx-v2-auth-service/Chart.yaml | 2 +- cmd/server/service/message_handler.go | 1 + cmd/server/service/providers.go | 1 + docs/identity_linking.md | 24 ++- docs/user_emails.md | 190 ++++++++++++++++++++ internal/domain/port/message_handler.go | 10 +- internal/infrastructure/authelia/models.go | 1 + internal/infrastructure/authelia/storage.go | 51 ++++-- internal/service/message_handler.go | 47 ++++- pkg/constants/subjects.go | 19 ++ 11 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 docs/user_emails.md diff --git a/README.md b/README.md index 8506295..106fedb 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,16 @@ Retrieve and update user profile metadata using various input types (JWT tokens, --- +#### User Emails Operations +Retrieve user email addresses (primary and alternate emails) using various input types (JWT tokens, subject identifiers, or usernames). + +**Subjects:** +- `lfx.auth-service.user_emails.read` - Retrieve user email addresses + +**[View User Emails Documentation](docs/user_emails.md)** - **Note:** Currently only supported for Authelia + +--- + #### Email Verification Flow Two-step verification flow for verifying ownership of alternate email addresses. diff --git a/charts/lfx-v2-auth-service/Chart.yaml b/charts/lfx-v2-auth-service/Chart.yaml index 9143579..4298477 100644 --- a/charts/lfx-v2-auth-service/Chart.yaml +++ b/charts/lfx-v2-auth-service/Chart.yaml @@ -5,5 +5,5 @@ apiVersion: v2 name: lfx-v2-auth-service description: LFX Platform V2 Auth Service chart type: application -version: 0.3.0 +version: 0.3.1 appVersion: "latest" diff --git a/cmd/server/service/message_handler.go b/cmd/server/service/message_handler.go index 49bf76c..165edef 100644 --- a/cmd/server/service/message_handler.go +++ b/cmd/server/service/message_handler.go @@ -29,6 +29,7 @@ func (mhs *MessageHandlerService) HandleMessage(ctx context.Context, msg port.Tr // user read/write operations constants.UserMetadataUpdateSubject: mhs.messageHandler.UpdateUser, constants.UserMetadataReadSubject: mhs.messageHandler.GetUserMetadata, + constants.UserEmailReadSubject: mhs.messageHandler.GetUserEmails, // lookup operations constants.UserEmailToUserSubject: mhs.messageHandler.EmailToUsername, constants.UserEmailToSubSubject: mhs.messageHandler.EmailToSub, diff --git a/cmd/server/service/providers.go b/cmd/server/service/providers.go index 71d4003..92d9233 100644 --- a/cmd/server/service/providers.go +++ b/cmd/server/service/providers.go @@ -212,6 +212,7 @@ func QueueSubscriptions(ctx context.Context) error { constants.UserEmailToUserSubject: messageHandlerService.HandleMessage, constants.UserEmailToSubSubject: messageHandlerService.HandleMessage, constants.UserMetadataReadSubject: messageHandlerService.HandleMessage, + constants.UserEmailReadSubject: messageHandlerService.HandleMessage, constants.EmailLinkingSendVerificationSubject: messageHandlerService.HandleMessage, constants.EmailLinkingVerifySubject: messageHandlerService.HandleMessage, constants.UserIdentityLinkSubject: messageHandlerService.HandleMessage, diff --git a/docs/identity_linking.md b/docs/identity_linking.md index 1d1f226..8403a25 100644 --- a/docs/identity_linking.md +++ b/docs/identity_linking.md @@ -17,15 +17,19 @@ The request payload must be a JSON object containing the user's JWT token and th ```json { - "user_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "link_with": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + "user": { + "auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "link_with": { + "identity_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + } } ``` ### Required Fields -- `user_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`: The ID token obtained from the email verification process that contains the verified email identity +- `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 ### Reply @@ -60,8 +64,12 @@ The service links the verified email identity to the user account without changi ```bash # Link the verified email identity to the user account nats request lfx.auth-service.user_identity.link '{ - "user_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "link_with": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + "user": { + "auth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + }, + "link_with": { + "identity_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + } }' # Expected response: {"success":true,"message":"identity linked successfully"} @@ -69,12 +77,12 @@ nats request lfx.auth-service.user_identity.link '{ ### Important Notes -- The SSR application must provide the user's JWT token (`user_token`) with the `update:current_user_identities` scope +- 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` field contains the ID token from the email verification process with the verified email information that will be linked to the user account +- 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. ### Complete Flow diff --git a/docs/user_emails.md b/docs/user_emails.md new file mode 100644 index 0000000..c0c04ec --- /dev/null +++ b/docs/user_emails.md @@ -0,0 +1,190 @@ +# User Emails Operations + +This document describes the NATS subject for retrieving user email addresses. + +--- + +## User Emails Retrieval + +To retrieve user email addresses (both primary and alternate emails), send a NATS request to the following subject: + +**Subject:** `lfx.auth-service.user_emails.read` +**Pattern:** Request/Reply + +The service supports a **hybrid approach** for user email retrieval, accepting multiple input types and automatically determining the appropriate lookup strategy based on the input format. + +### Hybrid Input Support + +The service intelligently handles different input types: + +1. **JWT Tokens** (Auth0) or **Authelia Tokens** (Authelia) +2. **Subject Identifiers** (canonical user IDs) +3. **Usernames** + +### Request Payload + +The request payload can be any of the following formats (no JSON wrapping required): + +**JWT Token (Auth0):** +``` +eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +**Subject Identifier:** +``` +auth0|123456789 +``` + +**Username:** +``` +john.doe +``` + +### Lookup Strategy + +The service automatically determines the lookup strategy based on input format: + +- **Token Strategy**: If input is a JWT/Authelia token, validates the token and extracts the subject identifier +- **Canonical Lookup**: If input contains `|` (pipe character) or is a UUID, treats as subject identifier for direct lookup +- **Username Search**: If input doesn't match above patterns, treats as username for search lookup + +### Reply + +The service returns a structured reply with user email information: + +**Success Reply:** +```json +{ + "success": true, + "data": { + "primary_email": "john.doe@example.com", + "alternate_emails": [ + { + "email": "john.doe@personal.com", + "verified": true + }, + { + "email": "j.doe@company.com", + "verified": false + } + ] + } +} +``` + +**Success Reply (No Alternate Emails):** +```json +{ + "success": true, + "data": { + "primary_email": "john.doe@example.com", + "alternate_emails": [] + } +} +``` + +**Error Reply (User Not Found):** +```json +{ + "success": false, + "error": "user not found" +} +``` + +**Error Reply (Invalid Token):** +```json +{ + "success": false, + "error": "invalid token" +} +``` + +### Response Fields + +- `primary_email` (string): The user's primary email address registered with the identity provider +- `alternate_emails` (array): List of alternate email addresses linked to the user account + - `email` (string): The alternate email address + - `verified` (boolean): Whether the alternate email has been verified + +### Example using NATS CLI + +```bash +# Retrieve user emails using JWT token +nats request lfx.auth-service.user_emails.read "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." + +# Retrieve user emails using subject identifier +nats request lfx.auth-service.user_emails.read "auth0|123456789" + +# Retrieve user emails using username +nats request lfx.auth-service.user_emails.read "john.doe" +``` + +### Example Response Processing + +```bash +# Get and format the response +nats request lfx.auth-service.user_emails.read "john.doe" | jq '.' + +# Extract only the primary email +nats request lfx.auth-service.user_emails.read "john.doe" | jq -r '.data.primary_email' + +# List all verified alternate emails +nats request lfx.auth-service.user_emails.read "john.doe" | jq -r '.data.alternate_emails[] | select(.verified == true) | .email' + +# Count total email addresses (primary + alternates) +nats request lfx.auth-service.user_emails.read "john.doe" | jq '.data.alternate_emails | length + 1' +``` + +**Important Notes:** +- The service automatically detects input type and applies the appropriate lookup strategy +- JWT tokens are validated for signature and expiration before extracting subject information +- The target identity provider is determined by the `USER_REPOSITORY_TYPE` environment variable +- Primary email is always present if the user exists +- Alternate emails array may be empty if the user has not linked any additional email addresses +- Only verified alternate emails should be considered as confirmed user identities +- For detailed Auth0-specific behavior and limitations, see: [`../internal/infrastructure/auth0/README.md`](../internal/infrastructure/auth0/README.md) +- For detailed Authelia-specific behavior and SUB management, see: [`../internal/infrastructure/authelia/README.md`](../internal/infrastructure/authelia/README.md) + +--- + +## Use Cases + +### Identity Verification +When you need to verify if a user owns a specific email address: +```bash +# Get all user emails +nats request lfx.auth-service.user_emails.read "john.doe" +``` + +### Email Communication +When you need to send notifications to all verified user email addresses: +```bash +# Extract all verified emails (primary + verified alternates) +nats request lfx.auth-service.user_emails.read "john.doe" | \ + jq -r '(.data.primary_email, (.data.alternate_emails[] | select(.verified == true) | .email))' +``` + +### Account Recovery +When displaying email options for account recovery: +```bash +# Show all verified email addresses for recovery selection +nats request lfx.auth-service.user_emails.read "auth0|123456789" | \ + jq '.data.alternate_emails[] | select(.verified == true)' +``` + +### Email Uniqueness Check +To check if an email is already associated with a user account, use the email lookup subjects: +- `lfx.auth-service.email_to_username` - Get username from email +- `lfx.auth-service.email_to_sub` - Get user ID from email + +See [`email_lookups.md`](email_lookups.md) for more details on these subjects. + +--- + +## Related Subjects + +- **Email Lookup**: [`email_lookups.md`](email_lookups.md) +- **Email Verification**: [`email_verification.md`](email_verification.md) +- **User Metadata**: [`user_metadata.md`](user_metadata.md) +- **Identity Linking**: [`identity_linking.md`](identity_linking.md) + diff --git a/internal/domain/port/message_handler.go b/internal/domain/port/message_handler.go index 967def9..06d8ac0 100644 --- a/internal/domain/port/message_handler.go +++ b/internal/domain/port/message_handler.go @@ -13,13 +13,19 @@ type MessageHandler interface { // UserHandler defines the behavior of the user domain handlers type UserHandler interface { UserWriteHandler - UserReadHandler + UserReaderHandler + UserLookupHandler UserLinkHandler } // UserReadHandler defines the behavior of the user read/lookup domain handlers -type UserReadHandler interface { +type UserReaderHandler interface { GetUserMetadata(ctx context.Context, msg TransportMessenger) ([]byte, error) + GetUserEmails(ctx context.Context, msg TransportMessenger) ([]byte, error) +} + +// UserLookupHandler defines the behavior of the user lookup domain handlers +type UserLookupHandler interface { EmailToUsername(ctx context.Context, msg TransportMessenger) ([]byte, error) EmailToSub(ctx context.Context, msg TransportMessenger) ([]byte, error) } diff --git a/internal/infrastructure/authelia/models.go b/internal/infrastructure/authelia/models.go index 2b61a0e..a4cf884 100644 --- a/internal/infrastructure/authelia/models.go +++ b/internal/infrastructure/authelia/models.go @@ -90,6 +90,7 @@ func (a *AutheliaUser) FromStorage(storage *AutheliaUserStorage) { } a.Username = storage.Username a.UserMetadata = storage.UserMetadata + a.PrimaryEmail = storage.Email a.AlternateEmails = storage.AlternateEmail // for consistency in naming across implementations, // we use the unique identifier as the user_id diff --git a/internal/infrastructure/authelia/storage.go b/internal/infrastructure/authelia/storage.go index 988ad20..4fdc2e1 100644 --- a/internal/infrastructure/authelia/storage.go +++ b/internal/infrastructure/authelia/storage.go @@ -135,6 +135,32 @@ func (n *natsUserStorage) ListUsers(ctx context.Context) (map[string]*AutheliaUs return users, nil } +func (n *natsUserStorage) setLookupKeys(ctx context.Context, user *AutheliaUser) error { + if user.Email != "" { + _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)), []byte(user.Username)) + if errPutLookup != nil { + return errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) + } + } + + if len(user.AlternateEmails) > 0 { + for _, alternateEmail := range user.AlternateEmails { + _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildAlternateEmailIndexKey(ctx, alternateEmail.Email)), []byte(user.Username)) + if errPutLookup != nil { + return errs.NewUnexpected("failed to set alternate email lookup key in NATS KV", errPutLookup) + } + } + } + + if user.Sub != "" { + _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "sub", user.BuildSubIndexKey(ctx)), []byte(user.Username)) + if errPutLookup != nil { + return errs.NewUnexpected("failed to set sub lookup key in NATS KV", errPutLookup) + } + } + return nil +} + func (n *natsUserStorage) SetUser(ctx context.Context, user *AutheliaUser) (any, error) { // Update timestamp @@ -160,18 +186,9 @@ func (n *natsUserStorage) SetUser(ctx context.Context, user *AutheliaUser) (any, } // lookup keys - if user.Email != "" { - user.PrimaryEmail = user.Email - _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)), []byte(user.Username)) - if errPutLookup != nil { - return nil, errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) - } - } - if user.Sub != "" { - _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "sub", user.BuildSubIndexKey(ctx)), []byte(user.Username)) - if errPutLookup != nil { - return nil, errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) - } + errSetLookupKeys := n.setLookupKeys(ctx, user) + if errSetLookupKeys != nil { + return nil, errs.NewUnexpected("failed to set lookup keys in NATS KV", errSetLookupKeys) } return user, nil @@ -201,12 +218,10 @@ func (n *natsUserStorage) UpdateUserWithRevision(ctx context.Context, user *Auth } // lookup keys - these are not subject to revision control as they're separate keys - if user.Email != "" { - user.PrimaryEmail = user.Email - _, errPutLookup := n.kvStore[constants.KVBucketNameAutheliaUsers].Put(ctx, n.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)), []byte(user.Username)) - if errPutLookup != nil { - return errs.NewUnexpected("failed to set lookup key in NATS KV", errPutLookup) - } + // lookup keys + errSetLookupKeys := n.setLookupKeys(ctx, user) + if errSetLookupKeys != nil { + return errs.NewUnexpected("failed to set lookup keys in NATS KV", errSetLookupKeys) } return nil diff --git a/internal/service/message_handler.go b/internal/service/message_handler.go index a3a3ff4..4087b41 100644 --- a/internal/service/message_handler.go +++ b/internal/service/message_handler.go @@ -132,15 +132,14 @@ func (m *messageHandlerOrchestrator) EmailToSub(ctx context.Context, msg port.Tr return []byte(user.UserID), nil } -// GetUserMetadata retrieves user metadata based on the input strategy -func (m *messageHandlerOrchestrator) GetUserMetadata(ctx context.Context, msg port.TransportMessenger) ([]byte, error) { +func (m *messageHandlerOrchestrator) getUserByInput(ctx context.Context, msg port.TransportMessenger) (*model.User, error) { if m.userReader == nil { - return m.errorResponse("user service unavailable"), nil + return nil, errs.NewUnexpected("user service unavailable") } input := strings.TrimSpace(string(msg.Data())) if input == "" { - return m.errorResponse("input is required"), nil + return nil, errs.NewValidation("input is required") } slog.DebugContext(ctx, "get user metadata", @@ -153,7 +152,7 @@ func (m *messageHandlerOrchestrator) GetUserMetadata(ctx context.Context, msg po "error", errMetadataLookup, "input", redaction.Redact(input), ) - return m.errorResponse(errMetadataLookup.Error()), nil + return nil, errMetadataLookup } search := func() (*model.User, error) { @@ -163,11 +162,17 @@ func (m *messageHandlerOrchestrator) GetUserMetadata(ctx context.Context, msg po return m.userReader.SearchUser(ctx, user, constants.CriteriaTypeUsername) } - userRetrieved, errGetUser := search() + return search() +} + +// GetUserMetadata retrieves user metadata based on the input strategy +func (m *messageHandlerOrchestrator) GetUserMetadata(ctx context.Context, msg port.TransportMessenger) ([]byte, error) { + + userRetrieved, errGetUser := m.getUserByInput(ctx, msg) if errGetUser != nil { slog.ErrorContext(ctx, "error getting user metadata", "error", errGetUser, - "input", redaction.Redact(input), + "input", redaction.Redact(string(msg.Data())), ) return m.errorResponse(errGetUser.Error()), nil } @@ -187,6 +192,32 @@ func (m *messageHandlerOrchestrator) GetUserMetadata(ctx context.Context, msg po return responseJSON, nil } +// GetUserEmails retrieves the user emails based on the input strategy +func (m *messageHandlerOrchestrator) GetUserEmails(ctx context.Context, msg port.TransportMessenger) ([]byte, error) { + + user, errGetUser := m.getUserByInput(ctx, msg) + if errGetUser != nil { + slog.ErrorContext(ctx, "error getting user emails", + "error", errGetUser, + "input", redaction.Redact(string(msg.Data())), + ) + return m.errorResponse(errGetUser.Error()), nil + } + + response := UserDataResponse{ + Success: true, + Data: map[string]any{"primary_email": user.PrimaryEmail, "alternate_emails": user.AlternateEmails}, + } + + responseJSON, err := json.Marshal(response) + if err != nil { + errorResponseJSON := m.errorResponse("failed to marshal response") + return errorResponseJSON, nil + } + + return responseJSON, nil +} + // UpdateUser updates the user in the identity provider func (m *messageHandlerOrchestrator) UpdateUser(ctx context.Context, msg port.TransportMessenger) ([]byte, error) { @@ -244,7 +275,7 @@ func (m *messageHandlerOrchestrator) checkEmailExists(ctx context.Context, email if errSearch != nil && !errors.As(errSearch, ¬Found) { return errSearch } - if user != nil && user.UserID != "" { + if user != nil && (user.UserID != "" || user.Username != "") { slog.DebugContext(ctx, "user found", "user_id", redaction.Redact(user.UserID)) if strings.EqualFold(user.PrimaryEmail, email) { diff --git a/pkg/constants/subjects.go b/pkg/constants/subjects.go index 5354cd1..8e76ea8 100644 --- a/pkg/constants/subjects.go +++ b/pkg/constants/subjects.go @@ -7,6 +7,11 @@ const ( // AuthServiceQueue is the queue for the auth service. // The queue is of the form: lfx.auth-service.queue AuthServiceQueue = "lfx.auth-service.queue" +) + +const ( + + // Lookup subjects // UserEmailToUserSubject is the subject for the user email to username event. // The subject is of the form: lfx.auth-service.email_to_username @@ -15,6 +20,11 @@ const ( // UserEmailToSubSubject is the subject for the user email to sub event. // The subject is of the form: lfx.auth-service.email_to_sub UserEmailToSubSubject = "lfx.auth-service.email_to_sub" +) + +const ( + + // User read/write subjects // UserMetadataUpdateSubject is the subject for the user metadata update event. // The subject is of the form: lfx.auth-service.user_metadata.update @@ -24,6 +34,15 @@ const ( // The subject is of the form: lfx.auth-service.user_metadata.read UserMetadataReadSubject = "lfx.auth-service.user_metadata.read" + // UserEmaiReadSubject is the subject for the user email read event. + // The subject is of the form: lfx.auth-service.user_email.read + UserEmailReadSubject = "lfx.auth-service.user_emails.read" +) + +const ( + + // Email and Identity linking subjects + // EmailLinkingSendVerificationSubject is the subject for the email linking start event. // The subject is of the form: lfx.auth-service.email_linking.send_verification EmailLinkingSendVerificationSubject = "lfx.auth-service.email_linking.send_verification" From 4d680809f9d9c175be3cd3f44557ba733dd2e84f Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Fri, 24 Oct 2025 13:34:03 -0300 Subject: [PATCH 3/8] feat: add OTP verification flow for alternate email linking - Introduced a new section in the README detailing the OTP verification process for linking alternate email addresses to user accounts. - Documented the architecture, flow, and storage implementation of the OTP system, including NATS KV bucket configuration and token generation. - Enhanced integration details with the identity linking system, outlining the steps for verification and linking phases. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Generated with [Cursor](https://cursor.com/) Signed-off-by: Mauricio Zanetti Salomao --- internal/infrastructure/authelia/README.md | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/internal/infrastructure/authelia/README.md b/internal/infrastructure/authelia/README.md index 792a139..b018d10 100644 --- a/internal/infrastructure/authelia/README.md +++ b/internal/infrastructure/authelia/README.md @@ -179,6 +179,115 @@ This process ensures that: - User data consistency is maintained across the system - The canonical user identifier is properly established for future lookups +## Alternate Email Linking with OTP Verification + +The Authelia integration supports linking alternate email addresses to user accounts through a secure OTP (One-Time Password) verification flow. This feature enables users to add and verify additional email addresses without requiring a full authentication flow. + +### OTP Flow Architecture + +The OTP verification system uses a dedicated NATS Key-Value bucket with TTL (Time-To-Live) for secure and temporary storage of verification codes. This ensures that: + +- OTP codes are automatically expired after a configured time period (default: 5 minutes) +- No manual cleanup is required - NATS handles expiration automatically +- Storage is isolated from user data in a separate KV bucket +- Verification codes are ephemeral and cannot be reused after expiration + +### OTP Verification Flow + +```mermaid +sequenceDiagram + participant User + participant AuthService + participant SMTP + participant NATSKV as NATS KV
(authelia-email-otp) + participant Storage as NATS KV
(authelia-users) + + Note over User,Storage: Step 1: Send Verification Code + + User->>AuthService: Send verification (alternate email) + AuthService->>AuthService: Check email not already linked + AuthService->>AuthService: Generate 6-digit OTP + AuthService->>SMTP: Send OTP email + SMTP-->>User: Email with OTP code + + AuthService->>NATSKV: Store OTP with TTL
Key: email
Value: OTP code
TTL: 5 minutes + NATSKV-->>AuthService: Success + AuthService-->>User: Verification sent + + Note over User,Storage: Step 2: Verify OTP Code + + User->>AuthService: Verify OTP (email + code) + AuthService->>AuthService: Check email not already linked + AuthService->>NATSKV: Get OTP by email key + + alt OTP Found and Valid + NATSKV-->>AuthService: OTP code + AuthService->>AuthService: Compare submitted vs stored OTP + alt OTP Matches + AuthService->>AuthService: Generate identity tokens
(ID token + Access token) + AuthService-->>User: Success + tokens + else OTP Mismatch + AuthService-->>User: Error: Invalid code + end + else OTP Not Found or Expired + NATSKV-->>AuthService: KeyNotFound error + AuthService-->>User: Error: Code expired + end + + Note over User,Storage: Step 3: Link Identity to User + + User->>AuthService: Link identity (user token + identity token) + AuthService->>AuthService: Parse identity token
Extract email from claims + AuthService->>Storage: Get user with revision + Storage-->>AuthService: User data + revision + AuthService->>AuthService: Add email to AlternateEmails + AuthService->>Storage: Update user with revision
(optimistic locking) + Storage-->>AuthService: Success + AuthService-->>User: Identity linked +``` + +### OTP Storage Implementation + +**NATS KV Bucket Configuration:** +- **Bucket Name**: `authelia-email-otp` (`constants.KVBucketNameAutheliaEmailOTP`) +- **TTL**: 5 minutes (configurable at bucket creation) +- **Key Format**: Email address (used as alternate email index key) +- **Value Format**: 6-digit numeric OTP code (stored as plain string) +- **Auto-Expiration**: NATS automatically removes expired entries after TTL + +### Token Generation After Verification + +Upon successful OTP verification, the system generates two tokens: + +**ID Token:** +- Contains verified email as subject claim (`sub: "email|{email}"`) +- Used for identity linking operation +- Validity: 60 minutes +- Format: JWT with custom claims + +**Access Token:** +- Standard OAuth2 access token +- Same validity period as ID token +- Used for authenticated operations + +### Integration with Identity Linking + +The OTP verification flow integrates with the identity linking system: + +1. **Verification Phase**: User verifies email ownership via OTP → receives identity token +2. **Linking Phase**: User links identity token to account → email added to `AlternateEmails` array +3. **Storage Update**: User record updated with optimistic locking to prevent race conditions + +For complete flow details, see the [Email Verification Documentation](../../docs/email_verification.md). + +### NATS Subjects + +The OTP verification flow exposes the following NATS subjects: + +- `lfx.auth-service.email_linking.send_verification` - Initiates OTP verification flow +- `lfx.auth-service.email_linking.verify` - Validates OTP and returns identity token +- `lfx.auth-service.user_identity.link` - Links verified identity to user account + ## Security Considerations - User passwords are automatically generated and stored as bcrypt hashes in ConfigMaps From 71f7cac12b6c427ae68afc2221cf4bcff6ffe8b8 Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Fri, 24 Oct 2025 13:42:59 -0300 Subject: [PATCH 4/8] fix: update configuration references in SMTP client and documentation - Refactored the SMTP client to use direct struct fields for host and port instead of nested config fields, improving clarity and consistency. - Updated the README to correct the path for the Email Verification Documentation, ensuring accurate navigation for users. - Minor adjustment in the NATS KV bucket configuration in the Helm chart for better readability. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Reviewed with [GitHub Copilot](https://github.com/features/copilot) Signed-off-by: Mauricio Zanetti Salomao --- charts/lfx-v2-auth-service/values.yaml | 2 +- internal/infrastructure/authelia/README.md | 2 +- internal/infrastructure/smtp/client.go | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/charts/lfx-v2-auth-service/values.yaml b/charts/lfx-v2-auth-service/values.yaml index 64c575f..7407ad3 100644 --- a/charts/lfx-v2-auth-service/values.yaml +++ b/charts/lfx-v2-auth-service/values.yaml @@ -62,7 +62,7 @@ nats: # compression is a boolean to determine if the KV bucket should be compressed compression: true # ttl is the time-to-live for entries in the bucket (5 minutes for OTPs) - ttl: 5m + ttl: 5m # serviceAccount is the configuration for the Kubernetes service account ## This will be used only if the USER_REPOSITORY_TYPE is authelia diff --git a/internal/infrastructure/authelia/README.md b/internal/infrastructure/authelia/README.md index b018d10..43b437a 100644 --- a/internal/infrastructure/authelia/README.md +++ b/internal/infrastructure/authelia/README.md @@ -278,7 +278,7 @@ The OTP verification flow integrates with the identity linking system: 2. **Linking Phase**: User links identity token to account → email added to `AlternateEmails` array 3. **Storage Update**: User record updated with optimistic locking to prevent race conditions -For complete flow details, see the [Email Verification Documentation](../../docs/email_verification.md). +For complete flow details, see the [Email Verification Documentation](../../../docs/email_verification.md). ### NATS Subjects diff --git a/internal/infrastructure/smtp/client.go b/internal/infrastructure/smtp/client.go index a4c23ce..f28adc4 100644 --- a/internal/infrastructure/smtp/client.go +++ b/internal/infrastructure/smtp/client.go @@ -34,19 +34,19 @@ type client struct { func (c *client) sendEmail(ctx context.Context, from, to string, emailBytes []byte) error { // Connect to SMTP server - addr := fmt.Sprintf("%s:%s", c.config.Host, c.config.Port) + addr := fmt.Sprintf("%s:%s", c.Host, c.Port) var auth smtp.Auth - if c.config.Username != "" && c.config.Password != "" { - auth = smtp.PlainAuth("", c.config.Username, c.config.Password, c.config.Host) + if c.Username != "" && c.Password != "" { + auth = smtp.PlainAuth("", c.Username, c.Password, c.Host) } err := smtp.SendMail(addr, auth, from, []string{to}, emailBytes) if err != nil { slog.ErrorContext(ctx, "failed to send email via SMTP", "error", err, - "host", c.config.Host, - "port", c.config.Port, + "host", c.Host, + "port", c.Port, ) return errors.NewUnexpected("failed to send email", err) } From 7bc67ba3a7d9a2760e58554a967f7d3495d5b04b Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Fri, 24 Oct 2025 13:48:41 -0300 Subject: [PATCH 5/8] fix: update SMTP sender to use direct client fields for host and port - Refactored the SendEmail method in the SMTP sender to access the host and port directly from the client struct, enhancing code clarity and consistency. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Reviewed with [GitHub Copilot](https://github.com/features/copilot) Signed-off-by: Mauricio Zanetti Salomao --- internal/infrastructure/smtp/sender.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/infrastructure/smtp/sender.go b/internal/infrastructure/smtp/sender.go index 9b62edf..ad10ac9 100644 --- a/internal/infrastructure/smtp/sender.go +++ b/internal/infrastructure/smtp/sender.go @@ -56,8 +56,8 @@ func (s *Sender) SendEmail(ctx context.Context, message *model.EmailMessage) err } slog.DebugContext(ctx, "email sent successfully via SMTP", - "host", s.client.config.Host, - "port", s.client.config.Port, + "host", s.client.Host, + "port", s.client.Port, "to", message.To, "subject", message.Subject, ) From a1194def8817180ebad67c4968e01ca668c8f3cd Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Fri, 24 Oct 2025 14:46:14 -0300 Subject: [PATCH 6/8] fix: update comments and references in configuration files - Revised comments in the Helm chart and SMTP client to clarify the purpose of the KV bucket and SMTP username. - Adjusted the user email retrieval logic to use the correct method for building the lookup key. - Improved documentation in the README regarding the test methods for identity tokens and corrected the function description for generating numeric strings. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Reviewed with [GitHub Copilot](https://github.com/features/copilot) Signed-off-by: Mauricio Zanetti Salomao --- charts/lfx-v2-auth-service/values.yaml | 2 +- internal/infrastructure/authelia/email.go | 3 --- internal/infrastructure/authelia/user.go | 2 +- internal/infrastructure/smtp/client.go | 2 +- internal/service/message_handler.go | 4 ++++ pkg/constants/subjects.go | 4 ++-- pkg/jwt/README.md | 2 +- pkg/password/generate.go | 2 +- 8 files changed, 11 insertions(+), 10 deletions(-) diff --git a/charts/lfx-v2-auth-service/values.yaml b/charts/lfx-v2-auth-service/values.yaml index 7407ad3..5d240bd 100644 --- a/charts/lfx-v2-auth-service/values.yaml +++ b/charts/lfx-v2-auth-service/values.yaml @@ -49,7 +49,7 @@ nats: # keep is a boolean to determine if the KV bucket should be preserved during helm uninstall # set it to false if you want the bucket to be deleted when the chart is uninstalled keep: true - # name is the name of the KV bucket for storing projects + # name is the name of the KV bucket for storing email OTP codes name: authelia-email-otp # history is the number of history entries to keep for the KV bucket history: 1 diff --git a/internal/infrastructure/authelia/email.go b/internal/infrastructure/authelia/email.go index c4455c3..35f78c5 100644 --- a/internal/infrastructure/authelia/email.go +++ b/internal/infrastructure/authelia/email.go @@ -46,11 +46,8 @@ func (a *autheliaPasswordlessFlow) SendEmail(ctx context.Context, email string) return "", errors.NewUnexpected("failed to send email", errSendEmail) } - // Note: this is not a production flow, so the otp is not sensitive - // We're logging the otp here to help with debugging and testing slog.InfoContext(ctx, "passwordless flow email sent", "email", redaction.RedactEmail(email), - "otp", otp, ) return otp, nil diff --git a/internal/infrastructure/authelia/user.go b/internal/infrastructure/authelia/user.go index 34b2018..71f7756 100644 --- a/internal/infrastructure/authelia/user.go +++ b/internal/infrastructure/authelia/user.go @@ -90,7 +90,7 @@ func (a *userReaderWriter) SearchUser(ctx context.Context, user *model.User, cri "criteria", criteria, "alternate_email", redaction.RedactEmail(alternateEmail.Email), ) - return a.storage.BuildLookupKey(ctx, "email", user.BuildEmailIndexKey(ctx)) + return a.storage.BuildLookupKey(ctx, "email", user.BuildAlternateEmailIndexKey(ctx, alternateEmail.Email)) } return "" case constants.CriteriaTypeUsername: diff --git a/internal/infrastructure/smtp/client.go b/internal/infrastructure/smtp/client.go index f28adc4..73ce344 100644 --- a/internal/infrastructure/smtp/client.go +++ b/internal/infrastructure/smtp/client.go @@ -20,7 +20,7 @@ type config struct { Host string // Port is the SMTP server port (e.g., 1025 for Mailpit) Port string - // FromEmail is the sender email address + // Username is the SMTP username for authentication Username string // Password is the SMTP password (optional) Password string diff --git a/internal/service/message_handler.go b/internal/service/message_handler.go index 4087b41..d3976af 100644 --- a/internal/service/message_handler.go +++ b/internal/service/message_handler.go @@ -386,6 +386,10 @@ func (m *messageHandlerOrchestrator) LinkIdentity(ctx context.Context, msg port. return m.errorResponse("user service unavailable"), nil } + if m.userReader == nil { + return m.errorResponse("user service unavailable"), nil + } + linkRequest := &model.LinkIdentity{} err := json.Unmarshal(msg.Data(), linkRequest) if err != nil { diff --git a/pkg/constants/subjects.go b/pkg/constants/subjects.go index 8e76ea8..1fad811 100644 --- a/pkg/constants/subjects.go +++ b/pkg/constants/subjects.go @@ -34,8 +34,8 @@ const ( // The subject is of the form: lfx.auth-service.user_metadata.read UserMetadataReadSubject = "lfx.auth-service.user_metadata.read" - // UserEmaiReadSubject is the subject for the user email read event. - // The subject is of the form: lfx.auth-service.user_email.read + // UserEmailReadSubject is the subject for the user email read event. + // The subject is of the form: lfx.auth-service.user_emails.read UserEmailReadSubject = "lfx.auth-service.user_emails.read" ) diff --git a/pkg/jwt/README.md b/pkg/jwt/README.md index 824c34b..2a35d87 100644 --- a/pkg/jwt/README.md +++ b/pkg/jwt/README.md @@ -27,7 +27,7 @@ import ( token, err := jwt.GenerateSimpleTestIdentityToken("user@example.com", time.Hour) ``` -** WARNING:** Default test methods use a singleton test key and are **only for testing**. Never use in production! +**WARNING:** Default test methods use a singleton test key and are **only for testing**. Never use in production! ## Generating Identity Tokens diff --git a/pkg/password/generate.go b/pkg/password/generate.go index d9093af..43965c0 100644 --- a/pkg/password/generate.go +++ b/pkg/password/generate.go @@ -32,7 +32,7 @@ func AlphaNum(length int) (string, error) { return string(result), nil } -// OnlyNumbers generates a random alphanumeric string of the specified length +// OnlyNumbers generates a random numeric string of the specified length func OnlyNumbers(length int) (string, error) { if length <= 0 { return "", errors.NewValidation("length must be positive") From e6c0c36146ac5c8fa636092a5ba40096078ff155 Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Mon, 27 Oct 2025 11:30:12 -0300 Subject: [PATCH 7/8] fix: modify error handling in NewRegularWebAuthConfig function - Updated the NewRegularWebAuthConfig function to return nil instead of an error when the AUTH0_REGULAR_WEB_CLIENT_ID is not set, with a TODO comment to implement the AUTH0 flow in the future. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Signed-off-by: Mauricio Zanetti Salomao --- internal/infrastructure/auth0/token.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/infrastructure/auth0/token.go b/internal/infrastructure/auth0/token.go index f7b01fe..737b85a 100644 --- a/internal/infrastructure/auth0/token.go +++ b/internal/infrastructure/auth0/token.go @@ -222,7 +222,9 @@ func NewM2MTokenManager(ctx context.Context, config Config) (*TokenManager, erro func NewRegularWebAuthConfig(ctx context.Context, domain string) (*authentication.Authentication, error) { clientID := os.Getenv(constants.Auth0RegularWebClientIDEnvKey) if clientID == "" { - return nil, errors.NewUnexpected("AUTH0_REGULAR_WEB_CLIENT_ID is required for email linking flow") + return nil, nil + // TODO - implement the AUTH0 flow, including secrets + //return nil, errors.NewUnexpected("AUTH0_REGULAR_WEB_CLIENT_ID is required for email linking flow") } clientSecret := os.Getenv(constants.Auth0RegularWebClientSecretEnvKey) From 0e1129c0d93a8cbd25fafb9ac2cd8176dc9f3049 Mon Sep 17 00:00:00 2001 From: Mauricio Zanetti Salomao Date: Mon, 27 Oct 2025 11:37:17 -0300 Subject: [PATCH 8/8] fix: update comment formatting in NewRegularWebAuthConfig function - Adjusted the comment in the NewRegularWebAuthConfig function to improve readability by adding a space before the comment text. This change enhances code clarity without altering functionality. Jira Ticket: https://linuxfoundation.atlassian.net/browse/LFXV2-502 Signed-off-by: Mauricio Zanetti Salomao --- internal/infrastructure/auth0/token.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/infrastructure/auth0/token.go b/internal/infrastructure/auth0/token.go index 737b85a..cdd12e4 100644 --- a/internal/infrastructure/auth0/token.go +++ b/internal/infrastructure/auth0/token.go @@ -224,7 +224,7 @@ func NewRegularWebAuthConfig(ctx context.Context, domain string) (*authenticatio if clientID == "" { return nil, nil // TODO - implement the AUTH0 flow, including secrets - //return nil, errors.NewUnexpected("AUTH0_REGULAR_WEB_CLIENT_ID is required for email linking flow") + // return nil, errors.NewUnexpected("AUTH0_REGULAR_WEB_CLIENT_ID is required for email linking flow") } clientSecret := os.Getenv(constants.Auth0RegularWebClientSecretEnvKey)