Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 109 additions & 6 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 == "" {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 16 additions & 6 deletions internal/api/router_authuser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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)
Expand Down
Loading