diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 21468c31..8d8860c3 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,82 @@ 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 p := h.principalFromUserAPIKey(ctx, apiKey); p != nil { + return p, 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 == "" { @@ -216,12 +319,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)