Skip to content
Closed
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
31 changes: 31 additions & 0 deletions internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,37 @@ func logMissingCSRFToken(req *events.LambdaFunctionURLRequest, csrfToken string)
req.RequestContext.HTTP.Method, req.RequestContext.HTTP.Path, req.RequestContext.HTTP.SourceIP)
}

// requireUser checks that the request carries a valid authentication
// (admin API key OR any valid Bearer-token session) and returns the
// resolved Session. Returns 401 on absence/invalid credentials. Used by
// Router.Route to enforce AuthUser routes at the router layer rather
// than relying solely on validateSecurity middleware ordering — see #60.
//
// Unlike requireAdmin, this does NOT require admin role. Any
// authenticated identity passes.
func (h *Handler) requireUser(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) {
// Admin API key first (stateless).
apiKey := extractAPIKey(req)
if h.checkAdminAPIKey(apiKey) {
return &Session{Role: "admin", UserID: "admin-api-key"}, nil
}

if h.auth == nil {
return nil, fmt.Errorf("authentication service not configured")
}

token := h.extractBearerToken(req)
if token == "" {
return nil, NewClientError(401, "no authorization token provided")
}

session, err := h.auth.ValidateSession(ctx, token)
if err != nil || session == nil {
return nil, NewClientError(401, "invalid session")
}
Comment on lines +220 to +239
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

AuthUser router enforcement is missing the user API key path.

requireUser currently allows only admin API key or Bearer session, while authenticate also accepts user API keys. This creates inconsistent auth semantics and can reject valid AuthUser requests that authenticate via user API key.

Suggested fix
func (h *Handler) requireUser(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) {
	// Admin API key first (stateless).
	apiKey := extractAPIKey(req)
	if h.checkAdminAPIKey(apiKey) {
		return &Session{Role: "admin", UserID: "admin-api-key"}, nil
	}
+
+	// User API key (stateless).
+	if h.checkUserAPIKey(ctx, apiKey) {
+		return &Session{Role: "user", UserID: "user-api-key"}, nil
+	}

	if h.auth == nil {
		return nil, fmt.Errorf("authentication service not configured")
	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/middleware.go` around lines 220 - 239, The requireUser function
only handles admin API keys and bearer tokens but must also accept user API keys
like authenticate does; after calling extractAPIKey and checkAdminAPIKey, add
the same user-API-key branch authenticate uses (e.g., call the existing helper
used by authenticate such as h.checkUserAPIKey or h.auth.ValidateAPIKey) and if
it validates return a Session with Role "user" and the associated UserID, then
continue to the bearer token path; ensure this user-API-key check runs before
falling back to extractBearerToken/ValidateSession so valid user-key requests
are accepted.

return session, nil
}

// requireAdmin checks if the current user has admin role.
// Accepts both admin API-key auth and Bearer token auth.
func (h *Handler) requireAdmin(ctx context.Context, req *events.LambdaFunctionURLRequest) (*Session, error) {
Expand Down
46 changes: 37 additions & 9 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,31 @@ import (
// RouteHandler is a function that handles a matched route
type RouteHandler func(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error)

// AuthLevel controls how Router.Route() enforces authentication.
// The zero value is AuthAdmin — secure by default.
// Any Route without an explicit Auth field requires admin access.
// AuthLevel controls how Router.Route() enforces authentication. Each
// non-AuthPublic level is enforced AT THE ROUTER LAYER (in Route()
// below) — not solely via validateSecurity / authenticate middleware,
// so a future refactor that reorders middleware or adds a path that
// bypasses validateSecurity cannot silently expose AuthUser routes
// (see #60).
//
// The zero value is AuthAdmin — secure by default. Any Route without
// an explicit Auth field requires admin access.
type AuthLevel int

const (
// AuthAdmin requires admin role (API key or admin bearer token).
// Zero value — any Route without an explicit Auth field gets this.
// Enforced via h.requireAdmin in Router.Route — returns 401/403
// before the handler runs. Zero value — any Route without an
// explicit Auth field gets this.
AuthAdmin AuthLevel = iota
// AuthUser requires any authenticated user. Use for self-service
// AuthUser requires any authenticated user (admin API key OR a
// valid session). Enforced via h.requireUser in Router.Route —
// returns 401 before the handler runs. Use for self-service
// endpoints (logout, profile, API key management).
AuthUser
// AuthPublic requires no authentication. Must also be listed in
// isPublicEndpoint() for middleware bypass.
// AuthPublic requires no authentication. The router does NOT call
// any auth helper for these routes; the handler runs directly.
// Must also be listed in isPublicEndpoint() for middleware bypass.
AuthPublic
)

Expand Down Expand Up @@ -217,14 +228,31 @@ func (r *Router) registerRoutes() {
}

// Route finds and executes the matching route handler.
// Routes with Auth == AuthAdmin (the default) require admin access before the handler is called.
//
// Auth enforcement happens here at the router layer so a future
// refactor that reorders or removes validateSecurity middleware cannot
// silently expose AuthUser/AuthAdmin routes (see #60):
//
// - AuthAdmin → r.h.requireAdmin (admin API key OR admin-role session)
// - AuthUser → r.h.requireUser (admin API key OR any valid session)
// - AuthPublic → no router-level auth check; the handler runs directly
//
// Both helpers return ClientError-wrapped 401/403 which the transport
// layer converts to the matching HTTP response.
func (r *Router) Route(ctx context.Context, method, path string, req *events.LambdaFunctionURLRequest) (any, error) {
for _, route := range r.routes {
if r.matches(route, method, path) {
if route.Auth == AuthAdmin {
switch route.Auth {
case AuthAdmin:
if _, err := r.h.requireAdmin(ctx, req); err != nil {
return nil, err
}
case AuthUser:
if _, err := r.h.requireUser(ctx, req); err != nil {
return nil, err
}
case AuthPublic:
// no router-level auth — handler runs directly.
}
Comment on lines +245 to 256
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Auth switch should fail closed on unknown auth levels.

The switch route.Auth has no default, so unsupported values currently execute handlers with no auth guard. For an auth boundary, this should deny by default.

Suggested fix
			switch route.Auth {
			case AuthAdmin:
				if _, err := r.h.requireAdmin(ctx, req); err != nil {
					return nil, err
				}
			case AuthUser:
				if _, err := r.h.requireUser(ctx, req); err != nil {
					return nil, err
				}
			case AuthPublic:
				// no router-level auth — handler runs directly.
+			default:
+				return nil, fmt.Errorf("unsupported auth level: %d", route.Auth)
			}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
switch route.Auth {
case AuthAdmin:
if _, err := r.h.requireAdmin(ctx, req); err != nil {
return nil, err
}
case AuthUser:
if _, err := r.h.requireUser(ctx, req); err != nil {
return nil, err
}
case AuthPublic:
// no router-level auth — handler runs directly.
}
switch route.Auth {
case AuthAdmin:
if _, err := r.h.requireAdmin(ctx, req); err != nil {
return nil, err
}
case AuthUser:
if _, err := r.h.requireUser(ctx, req); err != nil {
return nil, err
}
case AuthPublic:
// no router-level auth — handler runs directly.
default:
return nil, fmt.Errorf("unsupported auth level: %d", route.Auth)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/router.go` around lines 245 - 256, The switch over route.Auth
(AuthAdmin/AuthUser/AuthPublic) currently lacks a default so unknown auth values
fall through and run the handler; update the switch in the router to include a
default branch that fails closed by returning an authorization error (e.g., a
descriptive error like "unsupported auth level" or a forbidden error) instead of
allowing execution, and ensure this default returns from the surrounding
function the same way r.h.requireAdmin/ r.h.requireUser errors are returned so
no handler runs for unknown auth values.

params := r.extractParams(route, path)
return route.Handler(ctx, req, params)
Expand Down
127 changes: 127 additions & 0 deletions internal/api/router_auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package api

import (
"context"
"errors"
"testing"

"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// userSession returns a session for a non-admin authenticated user.
func userSession() *Session {
return &Session{
UserID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
Email: "user@example.com",
Role: "user",
}
}

// stubHandler is a no-op route handler — its presence proves the router
// reached the handler stage rather than short-circuiting on auth.
func stubHandler(_ context.Context, _ *events.LambdaFunctionURLRequest, _ map[string]string) (any, error) {
return map[string]string{"status": "ok"}, nil
}

// TestRouter_AuthUser_RejectsMissingAuth pins the issue-#60 regression:
// a route declared as Auth: AuthUser must be rejected by Router.Route at
// the router layer when no Authorization header is present, regardless
// of whether validateSecurity middleware ran beforehand.
func TestRouter_AuthUser_RejectsMissingAuth(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{}, // NO Authorization header
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err, "router must reject AuthUser routes without auth at the router layer")
ce, ok := IsClientError(err)
require.True(t, ok, "expected ClientError, got %T", err)
assert.Equal(t, 401, ce.code)
}

// TestRouter_AuthUser_RejectsInvalidSession pins that an invalid Bearer
// token (session not found / expired) returns 401 from the router layer.
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer stale-token"},
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}

// TestRouter_AuthUser_AcceptsValidSession confirms the router-layer
// check passes through to the handler when a valid session is present.
// This is the positive case for the new requireUser helper.
func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer user-token"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}
Comment on lines +53 to +92
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mock expectations are not asserted in the ValidateSession tests.

mockAuth.On(...) is configured, but AssertExpectations is never called. That can allow false positives if the call path changes.

Suggested fix
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
	ctx := context.Background()
	mockAuth := new(MockAuthService)
+	t.Cleanup(func() { mockAuth.AssertExpectations(t) })
	mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
	...
}

func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
	ctx := context.Background()
	mockAuth := new(MockAuthService)
+	t.Cleanup(func() { mockAuth.AssertExpectations(t) })
	mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
	...
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer stale-token"},
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}
// TestRouter_AuthUser_AcceptsValidSession confirms the router-layer
// check passes through to the handler when a valid session is present.
// This is the positive case for the new requireUser helper.
func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer user-token"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}
func TestRouter_AuthUser_RejectsInvalidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
mockAuth.On("ValidateSession", ctx, "stale-token").Return(nil, errors.New("session not found"))
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer stale-token"},
}
_, err := r.Route(ctx, "GET", "/api/test/user", req)
require.Error(t, err)
ce, ok := IsClientError(err)
require.True(t, ok)
assert.Equal(t, 401, ce.code)
}
// TestRouter_AuthUser_AcceptsValidSession confirms the router-layer
// check passes through to the handler when a valid session is present.
// This is the positive case for the new requireUser helper.
func TestRouter_AuthUser_AcceptsValidSession(t *testing.T) {
ctx := context.Background()
mockAuth := new(MockAuthService)
t.Cleanup(func() { mockAuth.AssertExpectations(t) })
mockAuth.On("ValidateSession", ctx, "user-token").Return(userSession(), nil)
h := &Handler{auth: mockAuth}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}
req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"Authorization": "Bearer user-token"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/router_auth_test.go` around lines 53 - 92, In both
TestRouter_AuthUser_RejectsInvalidSession and
TestRouter_AuthUser_AcceptsValidSession, ensure the mock expectations on
MockAuth are asserted after exercising the Router: call
mockAuth.AssertExpectations(t) (or mockAuth.AssertExpectations) at the end of
each test so the ValidateSession expectation set with
mockAuth.On("ValidateSession", ...) is verified; this ensures MockAuth's
ValidateSession call actually occurred for the Handler/Router code paths.


// TestRouter_AuthUser_AcceptsAdminAPIKey confirms that an admin API key
// also satisfies the AuthUser requirement (admins are users).
func TestRouter_AuthUser_AcceptsAdminAPIKey(t *testing.T) {
ctx := context.Background()
h := &Handler{apiKey: "admin-key"} // checkAdminAPIKey compares against this
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/user", Method: "GET", Handler: stubHandler, Auth: AuthUser},
}

req := &events.LambdaFunctionURLRequest{
Headers: map[string]string{"x-api-key": "admin-key"},
}
result, err := r.Route(ctx, "GET", "/api/test/user", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}

// TestRouter_AuthPublic_NoAuthCheck confirms AuthPublic routes still
// run with no auth at all — guard against accidentally tightening to
// AuthUser/AuthAdmin via the new switch's default branch.
func TestRouter_AuthPublic_NoAuthCheck(t *testing.T) {
ctx := context.Background()
h := &Handler{}
r := &Router{h: h}
r.routes = []Route{
{ExactPath: "/api/test/public", Method: "GET", Handler: stubHandler, Auth: AuthPublic},
}

req := &events.LambdaFunctionURLRequest{Headers: map[string]string{}}
result, err := r.Route(ctx, "GET", "/api/test/public", req)
require.NoError(t, err)
assert.Equal(t, map[string]string{"status": "ok"}, result)
}