Skip to content

Commit 6dc9591

Browse files
authored
refactor: reduce mid-tier oversized functions across services (#2028)
* refactor(current-account): reduce oversized functions Extract helpers from 15 oversized functions in the current-account service layer including saga handlers, valuation engine, orchestrators, and gRPC endpoints. * refactor(payment-order): reduce oversized functions Extract helpers from saga handlers, orchestrator ledger/lien/saga, gRPC lifecycle and update endpoints, and invoice generator. * refactor(position-keeping): reduce oversized functions Extract helpers from persistence repositories, balance service, gRPC log endpoints, and initiation handlers. * refactor(reconciliation): reduce oversized functions Extract helpers from gRPC endpoints, pipeline service, balance assertor, variance detector, settlement ingestor, and snapshot capturer. * refactor(control-plane): reduce oversized functions Extract helpers from applier, generator, manifest handler, validators, staff service, stripe webhook/reconciliation, and balance sheet. * refactor(internal-account): reduce oversized functions Extract helpers from persistence mappers/repository, gRPC endpoints, lien service, valuation engine, and valuation feature service. * refactor(tenant): reduce oversized functions Extract helpers from provisioner, gRPC endpoints, alerting worker, and provisioning worker. * refactor(shared): reduce oversized functions Extract helpers from saga executor, step execution, causation tree, starlark runner, valuation runtime, audit, and auth config. * refactor(services): reduce oversized functions in smaller services Extract helpers from mcp-server, audit-worker, event-router, financial-accounting, financial-gateway, forecasting, identity, and party services. * refactor(control-plane): fix build errors and reduce more oversized functions Fix type references in export.go and webhook.go, add missing fmt import, and reduce additional oversized functions in validators and generators. * refactor: reduce oversized functions in remaining services Extract helpers from payment-order, position-keeping, reference-data, party, and shared/platform packages. * fix: resolve lint errors and additional oversized function reductions - Fix gofmt formatting in 3 files - Fix unused parameters (revive) - Fix context-as-argument ordering (revive) - Replace dynamic errors with sentinels (err113) - Add nolint directives for intentional nil returns (nilerr, nilnil) - Fix unchecked error returns (errcheck) - Add nolint:contextcheck for false positives from helper extraction - Remove stale nolint directives (nolintlint) - Fix indent-error-flow (revive) - Fix syntax error in api-gateway (func type) - Fix type mismatch in api-gateway health init - Additional function size reductions across services Violations reduced from 284 to 196 (additional 88 eliminated). * fix: resolve test failures and remaining lint issues Test fixes (restore original behavior after helper extraction): - payment-order: restore per-posting error message prefixes - operational-gateway: add nil guard after route resolution failure - position-keeping: propagate account validation errors properly - tenant: preserve capitalized alert type names in log messages - financial-accounting: use distinct error for idempotency re-check - kafka: check context cancellation before DLQ on retry failure Lint fixes: - Add sentinel errors for dynamic error strings (err113) - Fix context-as-argument parameter ordering (revive) - Add missing switch case for StatusFailed (exhaustive) - Fix defer-in-loop pattern (gocritic) - Apply De Morgan's law and loop condition lift (staticcheck) - Add nolint directives for intentional nil returns (nilnil) - Fix nolint placement and gofmt formatting * fix: resolve remaining lint issues - Add nolint:nilnil for intentional nil,nil returns in payment-order - Fix context-as-argument ordering in api-gateway auth handler - Rename unused 'key' parameter to '_' in position-keeping migration * fix: add nolint:nilnil for remaining intentional nil returns * fix: restore review-critical behavioral regressions in refactored code - Fix CancelFunc leak in buildFreshContext: return CancelFunc so callers can defer cancel(), preventing distributed locks held until timeout expiry - Restore per-flow RECONCILIATION_REQUIRED log fields lost during capturePosting unification: debtor_account, contra_account, clearing_account, and has_*_posting progress booleans needed for incident response - Fix LedgerEntryCount semantic change in reconciliation: use raw entry count (pre-dedup) instead of deduplicated map length to match original * fix: preallocate log field slices to satisfy prealloc lint * chore: trigger re-review after thread resolution * docs: clarify buildFreshContext godoc on why fresh context is needed --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent b441ed3 commit 6dc9591

210 files changed

Lines changed: 13800 additions & 11898 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

services/api-gateway/auth_handler.go

Lines changed: 48 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package gateway
22

33
import (
4+
"context"
45
"encoding/json"
56
"errors"
7+
"fmt"
68
"log/slog"
79
"net/http"
10+
"strings"
811
"time"
912

1013
"github.com/meridianhub/meridian/services/identity/connector"
@@ -116,28 +119,35 @@ func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
116119
return
117120
}
118121

119-
// Validate credentials via the identity connector.
120-
identity, valid, err := h.connector.Login(ctx, nil, req.Email, req.Password)
122+
// Validate credentials and sign JWT.
123+
tokenStr, identity, err := h.authenticateAndSign(ctx, tenantID, req)
121124
if err != nil {
122-
h.logger.ErrorContext(ctx, "auth: login error",
123-
"tenant_id", tenantID,
124-
"error", err)
125-
writeJSON(w, http.StatusInternalServerError, map[string]string{
126-
"error": "authentication service error",
127-
})
125+
h.handleLoginError(ctx, w, tenantID, err)
128126
return
129127
}
128+
129+
h.logger.InfoContext(ctx, "auth: login successful",
130+
"tenant_id", tenantID,
131+
"identity_id", identity.UserID)
132+
133+
writeJSON(w, http.StatusOK, loginResponse{
134+
AccessToken: tokenStr,
135+
TokenType: "Bearer",
136+
ExpiresIn: int(h.tokenTTL.Seconds()),
137+
})
138+
}
139+
140+
// authenticateAndSign validates credentials and returns a signed JWT token.
141+
func (h *AuthHandler) authenticateAndSign(ctx context.Context, tenantID tenant.TenantID, req loginRequest) (string, connector.Identity, error) {
142+
identity, valid, err := h.connector.Login(ctx, nil, req.Email, req.Password)
143+
if err != nil {
144+
return "", connector.Identity{}, fmt.Errorf("login: %w", err)
145+
}
130146
if !valid {
131-
writeJSON(w, http.StatusUnauthorized, map[string]string{
132-
"error": "invalid email or password",
133-
})
134-
return
147+
return "", connector.Identity{}, errInvalidCredentials
135148
}
136149

137-
// Build claims and sign JWT.
138150
claims := connector.BuildClaims(identity, tenantID)
139-
// Include the tenant slug for frontend subdomain routing. The slug differs
140-
// from the tenant ID (e.g. slug "volterra-energy" vs ID "volterra_energy").
141151
if slug, ok := tenant.SlugFromContext(ctx); ok && slug != "" {
142152
claims[tenant.TenantSlugKey] = slug
143153
}
@@ -146,24 +156,35 @@ func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
146156
}
147157
tokenStr, err := h.signer.SignClaims(claims, h.tokenTTL)
148158
if err != nil {
159+
return "", identity, fmt.Errorf("sign: %w", err)
160+
}
161+
162+
return tokenStr, identity, nil
163+
}
164+
165+
// errInvalidCredentials is a sentinel for invalid email/password.
166+
var errInvalidCredentials = errors.New("invalid credentials")
167+
168+
// handleLoginError maps authenticateAndSign errors to HTTP responses.
169+
func (h *AuthHandler) handleLoginError(ctx context.Context, w http.ResponseWriter, tenantID tenant.TenantID, err error) {
170+
if errors.Is(err, errInvalidCredentials) {
171+
writeJSON(w, http.StatusUnauthorized, map[string]string{
172+
"error": "invalid email or password",
173+
})
174+
return
175+
}
176+
if strings.HasPrefix(err.Error(), "sign:") {
149177
h.logger.ErrorContext(ctx, "auth: failed to sign token",
150-
"tenant_id", tenantID,
151-
"identity_id", identity.UserID,
152-
"error", err)
178+
"tenant_id", tenantID, "error", err)
153179
writeJSON(w, http.StatusInternalServerError, map[string]string{
154180
"error": "failed to create session",
155181
})
156182
return
157183
}
158-
159-
h.logger.InfoContext(ctx, "auth: login successful",
160-
"tenant_id", tenantID,
161-
"identity_id", identity.UserID)
162-
163-
writeJSON(w, http.StatusOK, loginResponse{
164-
AccessToken: tokenStr,
165-
TokenType: "Bearer",
166-
ExpiresIn: int(h.tokenTTL.Seconds()),
184+
h.logger.ErrorContext(ctx, "auth: login error",
185+
"tenant_id", tenantID, "error", err)
186+
writeJSON(w, http.StatusInternalServerError, map[string]string{
187+
"error": "authentication service error",
167188
})
168189
}
169190

services/api-gateway/auth_sso_handler.go

Lines changed: 127 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ var (
4747
ErrSSOCallbackURLInvalid = errors.New("sso handler: callback URL must be a valid absolute URL")
4848
// ErrSSODexIssuerInvalid is returned when the Dex issuer URL is not a valid absolute URL.
4949
ErrSSODexIssuerInvalid = errors.New("sso handler: dex issuer URL must be an absolute URL with scheme and host")
50+
51+
// errNoMatchingAccount is returned when SSO identity resolution finds no matching account.
52+
errNoMatchingAccount = errors.New("no matching account")
5053
)
5154

5255
// IdentityResolver resolves an identity by email without password validation.
@@ -200,18 +203,34 @@ func (h *SSOHandler) HandleInitiate(w http.ResponseWriter, r *http.Request) {
200203
return
201204
}
202205

203-
// Generate PKCE code verifier (43-128 chars of unreserved URI chars per RFC 7636).
204-
codeVerifier, err := generateCodeVerifier()
206+
stateKey, codeChallenge, err := h.generatePKCEState(ctx, r, tenantID)
205207
if err != nil {
206-
h.logger.ErrorContext(ctx, "sso: failed to generate code verifier", "error", err)
208+
h.logger.ErrorContext(ctx, "sso: failed to generate PKCE state", "error", err)
207209
writeJSON(w, http.StatusInternalServerError, map[string]string{
208210
"error": "failed to initiate SSO",
209211
})
210212
return
211213
}
212214

213-
codeChallenge := computeCodeChallenge(codeVerifier)
215+
tenantSlug, _ := tenant.SlugFromContext(ctx)
216+
redirectURL := h.buildInitiateRedirectURL(tenantSlug, connectorID, stateKey, codeChallenge)
214217

218+
h.logger.InfoContext(ctx, "sso: initiating SSO flow",
219+
"tenant_id", tenantID,
220+
"connector_id", connectorID)
221+
222+
http.Redirect(w, r, redirectURL, http.StatusFound)
223+
}
224+
225+
// generatePKCEState generates PKCE parameters and stores the SSO state.
226+
// Returns the state key, code challenge, or an error.
227+
func (h *SSOHandler) generatePKCEState(ctx context.Context, r *http.Request, tenantID tenant.TenantID) (string, string, error) {
228+
codeVerifier, err := generateCodeVerifier()
229+
if err != nil {
230+
return "", "", fmt.Errorf("generate code verifier: %w", err)
231+
}
232+
233+
codeChallenge := computeCodeChallenge(codeVerifier)
215234
returnURL := sanitizeReturnURL(r.URL.Query().Get("return_url"))
216235

217236
tenantSlug, _ := tenant.SlugFromContext(ctx)
@@ -224,17 +243,15 @@ func (h *SSOHandler) HandleInitiate(w http.ResponseWriter, r *http.Request) {
224243
ReturnURL: returnURL,
225244
})
226245
if err != nil {
227-
h.logger.ErrorContext(ctx, "sso: failed to store state", "error", err)
228-
writeJSON(w, http.StatusInternalServerError, map[string]string{
229-
"error": "failed to initiate SSO",
230-
})
231-
return
246+
return "", "", fmt.Errorf("store state: %w", err)
232247
}
233248

234-
// Build Dex authorization URL. When baseDomain is configured, the redirect
235-
// uses a tenant-scoped URL so that the Dex MeridianConnector receives tenant
236-
// context via the subdomain. Path-escape the connector ID to prevent
237-
// path traversal (e.g., "../../admin" → "%2E%2E%2Fadmin").
249+
return stateKey, codeChallenge, nil
250+
}
251+
252+
// buildInitiateRedirectURL constructs the full Dex authorization redirect URL
253+
// with PKCE parameters, state, and scopes.
254+
func (h *SSOHandler) buildInitiateRedirectURL(tenantSlug, connectorID, stateKey, codeChallenge string) string {
238255
authURL := h.buildDexAuthURL(tenantSlug, connectorID)
239256
params := url.Values{
240257
"client_id": {h.clientID},
@@ -245,14 +262,7 @@ func (h *SSOHandler) HandleInitiate(w http.ResponseWriter, r *http.Request) {
245262
"code_challenge": {codeChallenge},
246263
"code_challenge_method": {"S256"},
247264
}
248-
249-
redirectURL := authURL + "?" + params.Encode()
250-
251-
h.logger.InfoContext(ctx, "sso: initiating SSO flow",
252-
"tenant_id", tenantID,
253-
"connector_id", connectorID)
254-
255-
http.Redirect(w, r, redirectURL, http.StatusFound)
265+
return authURL + "?" + params.Encode()
256266
}
257267

258268
// HandleCallback handles GET /api/auth/callback.
@@ -298,69 +308,10 @@ func (h *SSOHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
298308
return
299309
}
300310

301-
// Exchange authorization code for tokens with Dex.
302-
idToken, err := h.exchangeCode(ctx, code, stateData.CodeVerifier)
303-
if err != nil {
304-
h.logger.ErrorContext(ctx, "sso: token exchange failed",
305-
"tenant_id", stateData.TenantID,
306-
"error", err)
307-
writeJSON(w, http.StatusBadGateway, map[string]string{
308-
"error": "SSO token exchange failed",
309-
})
310-
return
311-
}
312-
313-
// Parse the email from Dex's ID token (we trust the claims since we got them
314-
// directly from the token endpoint over a server-to-server connection).
315-
email, err := extractEmailFromIDToken(idToken)
316-
if err != nil {
317-
h.logger.ErrorContext(ctx, "sso: failed to extract email from ID token",
318-
"tenant_id", stateData.TenantID,
319-
"error", err)
320-
writeJSON(w, http.StatusBadGateway, map[string]string{
321-
"error": "failed to process SSO identity",
322-
})
323-
return
324-
}
325-
326-
// Resolve the identity in Meridian's identity store to get roles and tenant context.
327-
tenantCtx := tenant.WithTenant(ctx, stateData.TenantID)
328-
identity, found, err := h.resolver.Resolve(tenantCtx, email)
329-
if err != nil {
330-
h.logger.ErrorContext(ctx, "sso: identity resolution error",
331-
"tenant_id", stateData.TenantID,
332-
"error", err)
333-
writeJSON(w, http.StatusInternalServerError, map[string]string{
334-
"error": "failed to resolve identity",
335-
})
336-
return
337-
}
338-
if !found {
339-
h.logger.WarnContext(ctx, "sso: no matching identity for SSO email",
340-
"tenant_id", stateData.TenantID)
341-
writeJSON(w, http.StatusForbidden, map[string]string{
342-
"error": "no matching account for this SSO identity",
343-
})
344-
return
345-
}
346-
347-
// Sign Meridian JWT.
348-
claims := connector.BuildClaims(identity, stateData.TenantID)
349-
if stateData.TenantSlug != "" {
350-
claims[tenant.TenantSlugKey] = stateData.TenantSlug
351-
}
352-
if stateData.TenantDisplayName != "" {
353-
claims[tenant.TenantDisplayNameKey] = stateData.TenantDisplayName
354-
}
355-
tokenStr, err := h.signer.SignClaims(claims, h.tokenTTL)
311+
// Exchange code, resolve identity, and sign JWT.
312+
identity, tokenStr, err := h.exchangeAndResolve(ctx, code, stateData)
356313
if err != nil {
357-
h.logger.ErrorContext(ctx, "sso: failed to sign token",
358-
"tenant_id", stateData.TenantID,
359-
"identity_id", identity.UserID,
360-
"error", err)
361-
writeJSON(w, http.StatusInternalServerError, map[string]string{
362-
"error": "failed to create session",
363-
})
314+
h.handleCallbackError(ctx, w, stateData, err)
364315
return
365316
}
366317

@@ -387,6 +338,99 @@ func (h *SSOHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
387338
http.Redirect(w, r, redirectURL, http.StatusFound)
388339
}
389340

341+
// callbackStage identifies which step of the SSO callback failed.
342+
type callbackStage int
343+
344+
const (
345+
callbackStageTokenExchange callbackStage = iota
346+
callbackStageEmailExtract
347+
callbackStageIdentityResolve
348+
callbackStageIdentityNotFound
349+
callbackStageSignToken
350+
)
351+
352+
// callbackError wraps an error with the stage at which the callback failed.
353+
type callbackError struct {
354+
stage callbackStage
355+
err error
356+
}
357+
358+
func (e *callbackError) Error() string { return e.err.Error() }
359+
func (e *callbackError) Unwrap() error { return e.err }
360+
361+
// exchangeAndResolve exchanges the authorization code for tokens, extracts the
362+
// email from the ID token, resolves the identity, and signs a Meridian JWT.
363+
func (h *SSOHandler) exchangeAndResolve(ctx context.Context, code string, state StateData) (connector.Identity, string, error) {
364+
idToken, err := h.exchangeCode(ctx, code, state.CodeVerifier)
365+
if err != nil {
366+
return connector.Identity{}, "", &callbackError{stage: callbackStageTokenExchange, err: err}
367+
}
368+
369+
email, err := extractEmailFromIDToken(idToken)
370+
if err != nil {
371+
return connector.Identity{}, "", &callbackError{stage: callbackStageEmailExtract, err: err}
372+
}
373+
374+
tenantCtx := tenant.WithTenant(ctx, state.TenantID)
375+
identity, found, err := h.resolver.Resolve(tenantCtx, email)
376+
if err != nil {
377+
return connector.Identity{}, "", &callbackError{stage: callbackStageIdentityResolve, err: err}
378+
}
379+
if !found {
380+
return connector.Identity{}, "", &callbackError{stage: callbackStageIdentityNotFound, err: errNoMatchingAccount}
381+
}
382+
383+
tokenStr, err := h.signCallbackToken(identity, state)
384+
if err != nil {
385+
return connector.Identity{}, "", &callbackError{stage: callbackStageSignToken, err: err}
386+
}
387+
388+
return identity, tokenStr, nil
389+
}
390+
391+
// signCallbackToken builds claims and signs a Meridian JWT for the SSO callback.
392+
func (h *SSOHandler) signCallbackToken(identity connector.Identity, state StateData) (string, error) {
393+
claims := connector.BuildClaims(identity, state.TenantID)
394+
if state.TenantSlug != "" {
395+
claims[tenant.TenantSlugKey] = state.TenantSlug
396+
}
397+
if state.TenantDisplayName != "" {
398+
claims[tenant.TenantDisplayNameKey] = state.TenantDisplayName
399+
}
400+
return h.signer.SignClaims(claims, h.tokenTTL)
401+
}
402+
403+
// handleCallbackError maps a callbackError to the appropriate HTTP response.
404+
func (h *SSOHandler) handleCallbackError(ctx context.Context, w http.ResponseWriter, state StateData, err error) {
405+
var cbErr *callbackError
406+
if !errors.As(err, &cbErr) {
407+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
408+
return
409+
}
410+
switch cbErr.stage {
411+
case callbackStageTokenExchange:
412+
h.logger.ErrorContext(ctx, "sso: token exchange failed",
413+
"tenant_id", state.TenantID, "error", cbErr.err)
414+
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "SSO token exchange failed"})
415+
case callbackStageEmailExtract:
416+
h.logger.ErrorContext(ctx, "sso: failed to extract email from ID token",
417+
"tenant_id", state.TenantID, "error", cbErr.err)
418+
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "failed to process SSO identity"})
419+
case callbackStageIdentityResolve:
420+
h.logger.ErrorContext(ctx, "sso: identity resolution error",
421+
"tenant_id", state.TenantID, "error", cbErr.err)
422+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to resolve identity"})
423+
case callbackStageIdentityNotFound:
424+
h.logger.WarnContext(ctx, "sso: no matching identity for SSO email",
425+
"tenant_id", state.TenantID)
426+
writeJSON(w, http.StatusForbidden, map[string]string{"error": "no matching account for this SSO identity"})
427+
case callbackStageSignToken:
428+
h.logger.ErrorContext(ctx, "sso: failed to sign token",
429+
"tenant_id", state.TenantID, "error", cbErr.err)
430+
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
431+
}
432+
}
433+
390434
// buildDexAuthURL constructs the Dex authorization URL. When baseDomain is
391435
// configured and a slug is provided, the URL includes the tenant slug as a
392436
// subdomain prefix so that the MeridianConnector can resolve tenant context

0 commit comments

Comments
 (0)