|
| 1 | +// Package connector implements a local Dex password connector that validates |
| 2 | +// credentials directly against the identity domain layer without a network hop. |
| 3 | +// |
| 4 | +// Dex supports pluggable connectors via the connector.PasswordConnector interface. |
| 5 | +// Since Meridian runs Dex in the same process (or tightly coupled), this connector |
| 6 | +// bypasses HTTP/gRPC overhead by calling the domain repository directly. |
| 7 | +// |
| 8 | +// The connector: |
| 9 | +// - Resolves the tenant from context metadata (set by the gateway from subdomain) |
| 10 | +// - Looks up the identity by email within that tenant scope |
| 11 | +// - Validates the password using bcrypt via the credentials package |
| 12 | +// - Checks account status (locked, suspended, pending invite → reject) |
| 13 | +// - Queries active role assignments and maps them to Dex groups |
| 14 | +// - Returns a connector.Identity with groups populated for JWT claim injection |
| 15 | +package connector |
| 16 | + |
| 17 | +import ( |
| 18 | + "context" |
| 19 | + "errors" |
| 20 | + "fmt" |
| 21 | + "log/slog" |
| 22 | + "os" |
| 23 | + |
| 24 | + "github.com/meridianhub/meridian/services/identity/domain" |
| 25 | + "github.com/meridianhub/meridian/shared/pkg/credentials" |
| 26 | + "github.com/meridianhub/meridian/shared/platform/tenant" |
| 27 | +) |
| 28 | + |
| 29 | +// Identity represents the result of a successful authentication, compatible with |
| 30 | +// Dex's connector.Identity shape. Groups are populated with the identity's active roles |
| 31 | +// and are injected into the JWT as the "groups" claim by Dex. |
| 32 | +type Identity struct { |
| 33 | + // UserID is the stable identifier for the user (UUID string). |
| 34 | + UserID string |
| 35 | + // Username is the display name, defaulting to email if not set. |
| 36 | + Username string |
| 37 | + // Email is the verified email address. |
| 38 | + Email string |
| 39 | + // EmailVerified indicates whether the email has been verified. |
| 40 | + EmailVerified bool |
| 41 | + // Groups contains active role assignments, used to populate JWT group claims. |
| 42 | + Groups []string |
| 43 | + // ConnectorData is opaque bytes stored by Dex for refresh token support. |
| 44 | + ConnectorData []byte |
| 45 | +} |
| 46 | + |
| 47 | +// LoginResult is returned by Login to convey both success state and the identity. |
| 48 | +type LoginResult struct { |
| 49 | + Identity Identity |
| 50 | + // Valid is true when authentication succeeded. |
| 51 | + Valid bool |
| 52 | +} |
| 53 | + |
| 54 | +// PasswordConnector validates username/password credentials. |
| 55 | +// This interface mirrors Dex's connector.PasswordConnector to keep the implementation |
| 56 | +// decoupled from the Dex library (which is not a declared Go module dependency). |
| 57 | +type PasswordConnector interface { |
| 58 | + // Login validates credentials and returns the identity on success. |
| 59 | + // valid is false when credentials are incorrect without an underlying error. |
| 60 | + Login(ctx context.Context, scopes []string, username, password string) (identity Identity, valid bool, err error) |
| 61 | +} |
| 62 | + |
| 63 | +// ErrRepositoryNil is returned by New when a nil repository is provided. |
| 64 | +var ErrRepositoryNil = errors.New("connector: repository must not be nil") |
| 65 | + |
| 66 | +// Connector is the local implementation of PasswordConnector. |
| 67 | +// It performs credential validation and role resolution directly against the |
| 68 | +// identity domain repository, avoiding any network hop. |
| 69 | +type Connector struct { |
| 70 | + repo domain.Repository |
| 71 | + logger *slog.Logger |
| 72 | +} |
| 73 | + |
| 74 | +// New creates a Connector with the given repository. If logger is nil a default |
| 75 | +// JSON logger writing to stdout is used. |
| 76 | +func New(repo domain.Repository, logger *slog.Logger) (*Connector, error) { |
| 77 | + if repo == nil { |
| 78 | + return nil, ErrRepositoryNil |
| 79 | + } |
| 80 | + if logger == nil { |
| 81 | + logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) |
| 82 | + } |
| 83 | + return &Connector{repo: repo, logger: logger}, nil |
| 84 | +} |
| 85 | + |
| 86 | +// Login validates the supplied username (email) and password against the identity |
| 87 | +// domain within the tenant derived from ctx. |
| 88 | +// |
| 89 | +// Returns: |
| 90 | +// - (identity, true, nil) on success |
| 91 | +// - (zero, false, nil) when credentials are simply wrong (no programming error) |
| 92 | +// - (zero, false, err) only for unexpected infrastructure errors |
| 93 | +func (c *Connector) Login(ctx context.Context, _ []string, username, password string) (Identity, bool, error) { |
| 94 | + tenantID, err := tenant.RequireFromContext(ctx) |
| 95 | + if err != nil { |
| 96 | + c.logger.ErrorContext(ctx, "connector: tenant context missing during login", |
| 97 | + "error", err) |
| 98 | + return Identity{}, false, fmt.Errorf("connector: %w", err) |
| 99 | + } |
| 100 | + |
| 101 | + identity, err := c.repo.FindByEmail(ctx, username) |
| 102 | + if err != nil { |
| 103 | + if errors.Is(err, domain.ErrIdentityNotFound) { |
| 104 | + c.logger.InfoContext(ctx, "connector: identity not found", |
| 105 | + "tenant_id", tenantID) |
| 106 | + return Identity{}, false, nil |
| 107 | + } |
| 108 | + c.logger.ErrorContext(ctx, "connector: repository error looking up identity", |
| 109 | + "tenant_id", tenantID, |
| 110 | + "username", username, |
| 111 | + "error", err) |
| 112 | + return Identity{}, false, fmt.Errorf("connector: lookup identity: %w", err) |
| 113 | + } |
| 114 | + |
| 115 | + // Reject non-active accounts before any password check. |
| 116 | + switch identity.Status() { |
| 117 | + case domain.IdentityStatusLocked: |
| 118 | + c.logger.InfoContext(ctx, "connector: login rejected — account locked", |
| 119 | + "tenant_id", tenantID, |
| 120 | + "identity_id", identity.ID()) |
| 121 | + return Identity{}, false, nil |
| 122 | + case domain.IdentityStatusSuspended: |
| 123 | + c.logger.InfoContext(ctx, "connector: login rejected — account suspended", |
| 124 | + "tenant_id", tenantID, |
| 125 | + "identity_id", identity.ID()) |
| 126 | + return Identity{}, false, nil |
| 127 | + case domain.IdentityStatusPendingInvite: |
| 128 | + c.logger.InfoContext(ctx, "connector: login rejected — account not yet activated", |
| 129 | + "tenant_id", tenantID, |
| 130 | + "identity_id", identity.ID()) |
| 131 | + return Identity{}, false, nil |
| 132 | + case domain.IdentityStatusActive: |
| 133 | + // valid — proceed to password verification |
| 134 | + default: |
| 135 | + c.logger.WarnContext(ctx, "connector: login rejected — unknown account status", |
| 136 | + "tenant_id", tenantID, |
| 137 | + "identity_id", identity.ID(), |
| 138 | + "status", identity.Status()) |
| 139 | + return Identity{}, false, nil |
| 140 | + } |
| 141 | + |
| 142 | + if err := credentials.ValidatePassword(password, identity.PasswordHash()); err != nil { |
| 143 | + // Record the failed attempt; best-effort — do not surface save errors to caller. |
| 144 | + _ = identity.RecordLoginAttempt(false) |
| 145 | + if saveErr := c.repo.Save(ctx, identity); saveErr != nil { |
| 146 | + c.logger.ErrorContext(ctx, "connector: failed to persist failed login attempt", |
| 147 | + "identity_id", identity.ID(), |
| 148 | + "error", saveErr) |
| 149 | + } |
| 150 | + c.logger.InfoContext(ctx, "connector: invalid password", |
| 151 | + "tenant_id", tenantID, |
| 152 | + "identity_id", identity.ID()) |
| 153 | + return Identity{}, false, nil |
| 154 | + } |
| 155 | + |
| 156 | + // Record successful login; best-effort. |
| 157 | + _ = identity.RecordLoginAttempt(true) |
| 158 | + if saveErr := c.repo.Save(ctx, identity); saveErr != nil { |
| 159 | + c.logger.ErrorContext(ctx, "connector: failed to persist successful login", |
| 160 | + "identity_id", identity.ID(), |
| 161 | + "error", saveErr) |
| 162 | + } |
| 163 | + |
| 164 | + // Resolve active role assignments → Dex groups. |
| 165 | + groups, err := c.activeRoles(ctx, identity) |
| 166 | + if err != nil { |
| 167 | + // Non-fatal: log and proceed with empty groups rather than denying login. |
| 168 | + c.logger.ErrorContext(ctx, "connector: failed to load role assignments", |
| 169 | + "identity_id", identity.ID(), |
| 170 | + "error", err) |
| 171 | + groups = []string{} |
| 172 | + } |
| 173 | + |
| 174 | + connIdentity := Identity{ |
| 175 | + UserID: identity.ID().String(), |
| 176 | + Username: identity.Email(), |
| 177 | + Email: identity.Email(), |
| 178 | + EmailVerified: true, |
| 179 | + Groups: groups, |
| 180 | + } |
| 181 | + |
| 182 | + c.logger.InfoContext(ctx, "connector: login successful", |
| 183 | + "tenant_id", tenantID, |
| 184 | + "identity_id", identity.ID(), |
| 185 | + "roles", groups) |
| 186 | + |
| 187 | + return connIdentity, true, nil |
| 188 | +} |
| 189 | + |
| 190 | +// activeRoles returns the string role names for all non-revoked, non-expired |
| 191 | +// role assignments associated with the given identity. |
| 192 | +func (c *Connector) activeRoles(ctx context.Context, identity *domain.Identity) ([]string, error) { |
| 193 | + assignments, err := c.repo.FindRoleAssignments(ctx, identity.ID()) |
| 194 | + if err != nil { |
| 195 | + return nil, fmt.Errorf("find role assignments: %w", err) |
| 196 | + } |
| 197 | + |
| 198 | + roles := make([]string, 0, len(assignments)) |
| 199 | + for _, a := range assignments { |
| 200 | + if a.IsActive() { |
| 201 | + roles = append(roles, string(a.Role())) |
| 202 | + } |
| 203 | + } |
| 204 | + return roles, nil |
| 205 | +} |
0 commit comments