From 74d87678e8431d1cfeaab39ffeadcca12b612888 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 30 May 2026 19:36:42 +0200 Subject: [PATCH 1/2] refactor(api): requireAuth returns resolved Principal (closes #194) authenticatePrincipal resolves the caller's identity once across all three credential kinds (admin API key, user API key, bearer session) and returns a *Principal. requireAuth now delegates to it instead of calling authenticate() which threw the resolved identity away. Router updated to _, err := r.h.requireAuth(...). Nil-auth guard moved before the API-key path so a missing auth service fails closed (401) rather than falling through. Dead userIface/userFields locals removed. TestRequireAuth_* tests extended to assert the returned Principal (Kind, Role, UserID, Session pointer) for each credential type. --- internal/api/middleware.go | 93 ++++++++++++++++++++++++++-- internal/api/router.go | 2 +- internal/api/router_authuser_test.go | 22 +++++-- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 21468c31..e94c085e 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -41,6 +41,33 @@ func (h *Handler) isPublicEndpoint(path string) bool { return false } +// PrincipalKind identifies which credential type was used to authenticate. +type PrincipalKind string + +const ( + // PrincipalAdminAPIKey is set when the request authenticated with the + // shared admin API key (X-API-Key header matching h.apiKey). + PrincipalAdminAPIKey PrincipalKind = "admin-api-key" + // PrincipalUserAPIKey is set when the request authenticated with a + // per-user API key issued via /api/api-keys. + PrincipalUserAPIKey PrincipalKind = "user-api-key" + // PrincipalSession is set when the request authenticated with a + // bearer-token session (X-Authorization / Authorization header). + PrincipalSession PrincipalKind = "session" +) + +// Principal carries the resolved caller identity returned by +// authenticatePrincipal and requireAuth. Handlers that need the caller's +// identity read it from here rather than re-resolving it through a second +// ValidateSession / ValidateUserAPIKeyAPI call. +type Principal struct { + Kind PrincipalKind + UserID string // empty for PrincipalAdminAPIKey + Email string // empty for PrincipalAdminAPIKey; populated for session/user-api-key + Role string // "admin" for admin-api-key; user's role otherwise + Session *Session // non-nil only for PrincipalSession +} + // authenticate checks authentication via admin API key, user API key, or Bearer token func (h *Handler) authenticate(ctx context.Context, req *events.LambdaFunctionURLRequest) bool { apiKey := extractAPIKey(req) @@ -56,6 +83,60 @@ func (h *Handler) authenticate(ctx context.Context, req *events.LambdaFunctionUR return h.checkBearerToken(ctx, req) } +// authenticatePrincipal performs the same three-path credential check as +// authenticate but returns the fully resolved Principal so callers do not +// need to repeat the lookup. Returns a non-nil Principal on success; returns +// nil and a 401 ClientError when no valid credential is present. +func (h *Handler) authenticatePrincipal(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Principal, error) { + apiKey := extractAPIKey(req) + + if h.checkAdminAPIKey(apiKey) { + return &Principal{Kind: PrincipalAdminAPIKey, Role: "admin"}, nil + } + + if h.auth == nil { + return nil, NewClientError(401, "authentication required") + } + + if apiKey != "" { + _, userRaw, err := h.auth.ValidateUserAPIKeyAPI(ctx, apiKey) + if err == nil && userRaw != nil { + p := &Principal{Kind: PrincipalUserAPIKey, Role: "user"} + // userRaw is returned as any from the interface. Extract fields + // via a locally-scoped interface to avoid an import cycle. + if uf, ok := userRaw.(interface { + GetID() string + GetEmail() string + GetRole() string + }); ok { + p.UserID = uf.GetID() + p.Email = uf.GetEmail() + p.Role = uf.GetRole() + } + return p, nil + } + if err != nil { + logging.Debugf("User API key validation failed: %v", err) + } + } + + token := h.extractBearerToken(req) + if token != "" { + session, err := h.auth.ValidateSession(ctx, token) + if err == nil && session != nil { + return &Principal{ + Kind: PrincipalSession, + UserID: session.UserID, + Email: session.Email, + Role: session.Role, + Session: session, + }, nil + } + } + + return nil, NewClientError(401, "authentication required") +} + func extractAPIKey(req *events.LambdaFunctionURLRequest) string { apiKey := req.Headers["x-api-key"] if apiKey == "" { @@ -216,12 +297,12 @@ func logMissingCSRFToken(req *events.LambdaFunctionURLRequest, csrfToken string) // validateSecurity → authenticate already runs before dispatch, but if a // future refactor reorders middleware or a new route bypasses // validateSecurity, this check still rejects unauthenticated requests at -// the router level. Returns nil on success, a 401 ClientError otherwise. -func (h *Handler) requireAuth(ctx context.Context, req *events.LambdaFunctionURLRequest) error { - if h.authenticate(ctx, req) { - return nil - } - return NewClientError(401, "authentication required") +// the router level. Returns the resolved Principal on success, a 401 +// ClientError otherwise. Callers should use the returned Principal rather +// than re-resolving the caller's identity through a second ValidateSession +// or ValidateUserAPIKeyAPI call. +func (h *Handler) requireAuth(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Principal, error) { + return h.authenticatePrincipal(ctx, req) } // requireAdmin checks if the current user has admin role. diff --git a/internal/api/router.go b/internal/api/router.go index 1438f78f..4304ce31 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -341,7 +341,7 @@ func (r *Router) Route(ctx context.Context, method, path string, req *events.Lam return nil, err } case AuthUser: - if err := r.h.requireAuth(ctx, req); err != nil { + if _, err := r.h.requireAuth(ctx, req); err != nil { return nil, err } case AuthPublic: diff --git a/internal/api/router_authuser_test.go b/internal/api/router_authuser_test.go index eb0858fa..8f06a4aa 100644 --- a/internal/api/router_authuser_test.go +++ b/internal/api/router_authuser_test.go @@ -100,18 +100,22 @@ func TestRouterAuthPublic_NoCredentials_Accepts(t *testing.T) { require.NoError(t, err) } -// TestRequireAuth_AdminAPIKey verifies the new requireAuth helper accepts -// the admin API key. +// TestRequireAuth_AdminAPIKey verifies that requireAuth accepts the admin +// API key and returns a Principal of kind PrincipalAdminAPIKey. func TestRequireAuth_AdminAPIKey(t *testing.T) { h := &Handler{apiKey: "admin-secret"} req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"X-API-Key": "admin-secret"}, } - require.NoError(t, h.requireAuth(context.Background(), req)) + p, err := h.requireAuth(context.Background(), req) + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, PrincipalAdminAPIKey, p.Kind) + assert.Equal(t, "admin", p.Role) } // TestRequireAuth_UserSession verifies requireAuth accepts a valid -// non-admin user session. +// non-admin user session and returns a populated Principal. func TestRequireAuth_UserSession(t *testing.T) { ctx := context.Background() mockAuth := new(MockAuthService) @@ -121,7 +125,13 @@ func TestRequireAuth_UserSession(t *testing.T) { req := &events.LambdaFunctionURLRequest{ Headers: map[string]string{"Authorization": "Bearer user-token"}, } - require.NoError(t, h.requireAuth(ctx, req)) + p, err := h.requireAuth(ctx, req) + require.NoError(t, err) + require.NotNil(t, p) + assert.Equal(t, PrincipalSession, p.Kind) + assert.Equal(t, "uid", p.UserID) + assert.Equal(t, "user", p.Role) + assert.Equal(t, userSession, p.Session) } // TestRequireAuth_NoCredential_Rejects verifies requireAuth returns a 401 @@ -130,7 +140,7 @@ func TestRequireAuth_NoCredential_Rejects(t *testing.T) { mockAuth := new(MockAuthService) h := &Handler{auth: mockAuth} req := &events.LambdaFunctionURLRequest{Headers: map[string]string{}} - err := h.requireAuth(context.Background(), req) + _, err := h.requireAuth(context.Background(), req) require.Error(t, err) ce, ok := IsClientError(err) require.True(t, ok) From c21bb0d9a2121f505355f7414004dd3093d7dece Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 1 Jun 2026 19:25:26 +0200 Subject: [PATCH 2/2] fix(ci): resolve failing gocyclo check on PR #864 Extract principalFromUserAPIKey and principalFromBearerToken helpers from authenticatePrincipal to bring its cyclomatic complexity from 11 down to 4, passing the gocyclo <=10 gate. --- internal/api/middleware.go | 86 ++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index e94c085e..8d8860c3 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -98,45 +98,67 @@ func (h *Handler) authenticatePrincipal(ctx context.Context, req *events.LambdaF return nil, NewClientError(401, "authentication required") } - if apiKey != "" { - _, userRaw, err := h.auth.ValidateUserAPIKeyAPI(ctx, apiKey) - if err == nil && userRaw != nil { - p := &Principal{Kind: PrincipalUserAPIKey, Role: "user"} - // userRaw is returned as any from the interface. Extract fields - // via a locally-scoped interface to avoid an import cycle. - if uf, ok := userRaw.(interface { - GetID() string - GetEmail() string - GetRole() string - }); ok { - p.UserID = uf.GetID() - p.Email = uf.GetEmail() - p.Role = uf.GetRole() - } - return p, nil - } - if err != nil { - logging.Debugf("User API key validation failed: %v", err) - } + if p := h.principalFromUserAPIKey(ctx, apiKey); p != nil { + return p, nil } - token := h.extractBearerToken(req) - if token != "" { - session, err := h.auth.ValidateSession(ctx, token) - if err == nil && session != nil { - return &Principal{ - Kind: PrincipalSession, - UserID: session.UserID, - Email: session.Email, - Role: session.Role, - Session: session, - }, nil - } + if p := h.principalFromBearerToken(ctx, req); p != nil { + return p, nil } return nil, NewClientError(401, "authentication required") } +// principalFromUserAPIKey resolves a Principal from a user API key. +// Returns nil when the key is empty, validation fails, or the user record +// cannot be retrieved. +func (h *Handler) principalFromUserAPIKey(ctx context.Context, apiKey string) *Principal { + if apiKey == "" { + return nil + } + _, userRaw, err := h.auth.ValidateUserAPIKeyAPI(ctx, apiKey) + if err != nil { + logging.Debugf("User API key validation failed: %v", err) + return nil + } + if userRaw == nil { + return nil + } + p := &Principal{Kind: PrincipalUserAPIKey, Role: "user"} + // userRaw is returned as any from the interface. Extract fields + // via a locally-scoped interface to avoid an import cycle. + if uf, ok := userRaw.(interface { + GetID() string + GetEmail() string + GetRole() string + }); ok { + p.UserID = uf.GetID() + p.Email = uf.GetEmail() + p.Role = uf.GetRole() + } + return p +} + +// principalFromBearerToken resolves a Principal from a session bearer token. +// Returns nil when no token is present or the session is invalid. +func (h *Handler) principalFromBearerToken(ctx context.Context, req *events.LambdaFunctionURLRequest) *Principal { + token := h.extractBearerToken(req) + if token == "" { + return nil + } + session, err := h.auth.ValidateSession(ctx, token) + if err != nil || session == nil { + return nil + } + return &Principal{ + Kind: PrincipalSession, + UserID: session.UserID, + Email: session.Email, + Role: session.Role, + Session: session, + } +} + func extractAPIKey(req *events.LambdaFunctionURLRequest) string { apiKey := req.Headers["x-api-key"] if apiKey == "" {