Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,8 @@ SRouter supports three authentication levels, specified in `RouteConfig` or `Rou
2. **AuthOptional**: Authentication is attempted (e.g., by middleware). If successful, user info is added to the context. The request proceeds regardless.
3. **AuthRequired**: Authentication is required (e.g., by middleware). If authentication fails, the middleware should reject the request (e.g., with 401 Unauthorized). If successful, user info is added to the context.

When using the built-in `AuthOptional`/`AuthRequired` middleware, the token is extracted from the configured auth token source (`common.RouteOverrides.AuthToken`). The default source is the `Authorization` header. Cookie-based auth is supported by setting `AuthToken` to a cookie source on a sub-router or route.

```go
// Example route configurations
routePublic := router.RouteConfigBase{ AuthLevel: router.Ptr(router.NoAuth), ... }
Expand Down
26 changes: 22 additions & 4 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ SRouter defines three authentication levels using the `router.AuthLevel` type. Y
Setting `AuthLevel` to `AuthOptional` or `AuthRequired` activates **built-in middleware** within the router. This middleware performs the following based on the level:

1. **`router.NoAuth`**: No authentication is required or attempted by the built-in middleware. The request proceeds directly to the next middleware or handler. This is the default if `AuthLevel` is not set.
2. **`router.AuthOptional`**: The built-in authentication middleware is activated. It attempts to validate credentials (currently expects a Bearer token in the `Authorization` header) using the `authFunction` provided to `NewRouter`.
2. **`router.AuthOptional`**: The built-in authentication middleware is activated. It attempts to validate credentials using the `authFunction` provided to `NewRouter`. The token is extracted from the configured auth token source (see "Auth Token Source" below); the default is the `Authorization` header.
* If authentication succeeds, the middleware populates the user ID (using the `userIdFromUserFunction` from `NewRouter`) and optionally the user object into the request context using `scontext.WithUserID` and `scontext.WithUser`. Storing the user object requires `RouterConfig.AddUserObjectToCtx` to be `true`. The request then proceeds to the next middleware or handler.
* If authentication fails (or no `Authorization` header is provided), the request *still proceeds* to the next middleware or handler, but without user information in the context. The handler must check for the presence of user information using `scontext.GetUserIDFromRequest` or `scontext.GetUserFromRequest`.
* If authentication fails (or no token is provided from the configured source), the request *still proceeds* to the next middleware or handler, but without user information in the context. The handler must check for the presence of user information using `scontext.GetUserIDFromRequest` or `scontext.GetUserFromRequest`.
3. **`router.AuthRequired`**: The built-in authentication middleware is activated and authentication is mandatory. It attempts validation as described for `AuthOptional`.
* If authentication succeeds, the middleware populates the context (as above) and proceeds to the next middleware or handler.
* If authentication fails, the built-in middleware **rejects** the request by sending an HTTP `401 Unauthorized` response and stops the middleware chain. The handler is not called.
Expand Down Expand Up @@ -51,7 +51,7 @@ The core of the built-in authentication mechanism relies on two functions you **

1. **`authFunction func(ctx context.Context, token string) (*UserObjectType, bool)`**:
* This function is called by the built-in middleware when `AuthLevel` is `AuthOptional` or `AuthRequired`.
* It receives the request context and the token string extracted from the `Authorization: Bearer <token>` header.
* It receives the request context and the token string extracted from the configured auth token source (header or cookie).
* It should validate the token (e.g., check a database, validate a JWT signature).
* It must return the corresponding `UserObjectType` (your application's user struct/type) and `true` if the token is valid, or a zero-value `UserObjectType` and `false` if invalid.

Expand Down Expand Up @@ -83,9 +83,27 @@ r := router.NewRouter[string, MyUserType](routerConfig, myAuthValidator, myGetID

**If you do not intend to use the built-in `AuthLevel` mechanism** (e.g., you rely solely on custom authentication middleware), you must still provide non-nil functions to `NewRouter`. These can be simple dummy functions that always return `false` or zero values.

## Auth Token Source

By default, the built-in middleware reads the token from the `Authorization` header and trims a `Bearer ` prefix if present. You can override the source per sub-router or per route via `common.RouteOverrides.AuthToken`:

```go
Overrides: common.RouteOverrides{
AuthToken: &common.AuthTokenConfig{
Source: common.AuthTokenSourceCookie,
CookieName: "auth_token",
},
},
```

Notes:
- Only the configured source is honored (no fallback to other sources).
- If `Source` is `AuthTokenSourceHeader` and `HeaderName` is empty, it defaults to `Authorization`.
- If `Source` is `AuthTokenSourceCookie` and `CookieName` is empty, the built-in middleware logs a warning at registration time.

## Custom Authentication Middleware

While the `AuthLevel` setting provides convenient Bearer token authentication via the built-in mechanism, you can implement **custom authentication middleware** for other schemes (Cookies, API Keys, Basic Auth, etc.) or more complex logic.
While the `AuthLevel` setting provides convenient token authentication via the built-in mechanism, you can implement **custom authentication middleware** for other schemes (Cookies, API Keys, Basic Auth, etc.) or more complex logic.

Your custom middleware is responsible for:

Expand Down
51 changes: 46 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ type SubRouterConfig struct {
// within this group (e.g., "/api/v1").
PathPrefix string

// Overrides allows this sub-router to specify timeout, body size, or rate limit
// Overrides allows this sub-router to specify timeout, body size, rate limit,
// or auth token source settings that override the global configuration.
// settings that override the global configuration. Zero values mean no override.
Overrides common.RouteOverrides

Expand Down Expand Up @@ -174,8 +175,9 @@ type RouteConfigBase struct {
// Nil inherits from parent sub-router or defaults to NoAuth.
AuthLevel *AuthLevel

// Overrides allows this route to specify timeout, body size, or rate limit
// settings. Zero values mean inherit from the sub-router or global configuration.
// Overrides allows this route to specify timeout, body size, rate limit,
// or auth token source settings. Zero values mean inherit from the sub-router
// or global configuration.
Overrides common.RouteOverrides

// Handler is the standard Go HTTP handler function. Required.
Expand All @@ -187,6 +189,44 @@ type RouteConfigBase struct {
}
```

## `common.RouteOverrides` and Auth Token Source

Route overrides control per-route and per-sub-router settings, including the auth token source used by the built-in authentication middleware.

```go
package common

import "time"

type AuthTokenSource int

const (
// AuthTokenSourceHeader reads the token from a request header.
AuthTokenSourceHeader AuthTokenSource = iota
// AuthTokenSourceCookie reads the token from a request cookie.
AuthTokenSourceCookie
)

type AuthTokenConfig struct {
// Source determines where to look for the token.
Source AuthTokenSource

// HeaderName is used when Source is AuthTokenSourceHeader.
// If empty, defaults to "Authorization".
HeaderName string

// CookieName is used when Source is AuthTokenSourceCookie.
CookieName string
}

type RouteOverrides struct {
Timeout time.Duration
MaxBodySize int64
RateLimit *RateLimitConfig[any, any]
AuthToken *AuthTokenConfig
}
```

## `RouteConfig[T, U]`

Used for defining generic routes with type-safe request (`T`) and response (`U`) handling.
Expand Down Expand Up @@ -214,7 +254,8 @@ type RouteConfig[T any, U any] struct {
// Nil inherits.
AuthLevel *AuthLevel

// Overrides allows this route to specify timeout, body size, or rate limit
// Overrides allows this route to specify timeout, body size, rate limit,
// or auth token source settings.
// settings. Zero values mean inherit from the sub-router or global configuration.
Overrides common.RouteOverrides

Expand Down Expand Up @@ -308,4 +349,4 @@ type CORSConfig struct {
AllowCredentials bool // Whether to allow credentials (cookies, authorization headers).
MaxAge time.Duration // How long the results of a preflight request can be cached.
}
```
```
4 changes: 2 additions & 2 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func main() {
### Key Components

- **`RouterConfig`**: Holds global settings like logger, timeouts, body size limits, and global middleware.
- **`authFunction`**: A function `func(ctx context.Context, token string) (UserObjectType, bool)` that validates an authentication token (currently expects Bearer token) and returns the user object and a boolean indicating success. Used by the built-in middleware when `AuthLevel` is set.
- **`authFunction`**: A function `func(ctx context.Context, token string) (UserObjectType, bool)` that validates an authentication token and returns the user object and a boolean indicating success. The token is extracted from the configured auth token source (default is the `Authorization` header). Used by the built-in middleware when `AuthLevel` is set.
- **`userIdFromUserFunction`**: A function `func(user UserObjectType) UserIDType` that extracts the comparable User ID from the user object returned by `authFunction`. Used by the built-in middleware.
- **`NewRouter[UserIDType, UserObjectType]`**: The constructor for the router. The type parameters define the type used for user IDs (`UserIDType`, must be comparable) and the type used for the user object (`UserObjectType`, can be any type) potentially stored in the context.
- **`RouterConfig.SubRouters`**: A slice of `SubRouterConfig` where routes are defined. Each `SubRouterConfig` has a `PathPrefix` and a `Routes` slice.
Expand All @@ -124,4 +124,4 @@ func main() {
- Learn about [Authentication](./authentication.md) to secure your routes
- Explore the [Configuration Reference](./configuration.md) for all available options
- Check out [Routing](./routing.md) for advanced routing features
- See [Examples](./examples.md) for more complex use cases
- See [Examples](./examples.md) for more complex use cases
9 changes: 5 additions & 4 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This guide covers the routing system in SRouter, including sub-routers for organ

## Sub-Routers

Sub-routers allow you to group related routes under a common path prefix and apply shared configuration like middleware, timeouts, body size limits, and rate limits.
Sub-routers allow you to group related routes under a common path prefix and apply shared configuration like middleware, timeouts, body size limits, rate limits, and auth token source settings.

### Defining Sub-Routers

Expand All @@ -18,6 +18,7 @@ apiV1SubRouter := router.SubRouterConfig{
Timeout: 3 * time.Second, // Overrides GlobalTimeout
MaxBodySize: 2 << 20, // 2 MB, overrides GlobalMaxBodySize
// RateLimit: &common.RateLimitConfig[any, any]{...},
// AuthToken: &common.AuthTokenConfig{Source: common.AuthTokenSourceCookie, CookieName: "auth_token"},
},
// Middlewares specific to /api/v1 routes can be added here
// Middlewares: []common.Middleware{ myV1Middleware },
Expand Down Expand Up @@ -80,7 +81,7 @@ r := router.NewRouter[string, string](routerConfig, authFunction, userIdFromUser
Key points:

- `PathPrefix`: Defines the base path for all routes within the sub-router.
- `Overrides`: `common.RouteOverrides` allowing timeout, body size, or rate limit overrides specific to this sub-router.
- `Overrides`: `common.RouteOverrides` allowing timeout, body size, rate limit, or auth token source overrides specific to this sub-router.
- `Routes`: A slice of `router.RouteDefinition` that can contain `RouteConfigBase` or `GenericRouteDefinition`. Paths within these routes are relative to the `PathPrefix`.
- `Middlewares`: Middleware applied to routes within this sub-router. These are **added to** (not replacing) any global middlewares defined in RouterConfig.
- `AuthLevel`: Default authentication level for all routes in this sub-router (can be overridden at the route level).
Expand Down Expand Up @@ -140,7 +141,7 @@ r := router.NewRouter[string, string](routerConfig, authFunction, userIdFromUser
```

**Configuration precedence:**
- **Overrides** (timeouts, body size, rate limits, auth level): The most specific setting wins (Route > Sub-Router > Global). Each level must explicitly set overrides; they are not inherited.
- **Overrides** (timeouts, body size, rate limits, auth token source): The most specific setting wins (Route > Sub-Router > Global). Each level must explicitly set overrides; they are not inherited.
- **Middlewares**: These are combined additively in order: Global → Outer Sub-Router → Inner Sub-Router → Route-specific. All applicable middlewares run in this sequence.

### Imperative Route Registration
Expand Down Expand Up @@ -360,4 +361,4 @@ Generally, `router.GetParam` is more convenient when you know the specific param
- **`router.GetParam(r *http.Request, name string) string`**: Retrieves a specific parameter by name from the request context. Returns an empty string if the parameter is not found.
- **`router.GetParams(r *http.Request) httprouter.Params`**: Retrieves all parameters from the request context as an `httprouter.Params` slice.
- **`scontext.GetPathParamsFromRequest[T, U](r *http.Request) (httprouter.Params, bool)`**: Returns all parameters from the generic `scontext` wrapper along with a boolean indicating presence.
- **`scontext.GetRouteTemplateFromRequest[T, U](r *http.Request) (string, bool)`**: Retrieves the original route pattern from the context for metrics or logging.
- **`scontext.GetRouteTemplateFromRequest[T, U](r *http.Request) (string, bool)`**: Retrieves the original route pattern from the context for metrics or logging.
32 changes: 32 additions & 0 deletions pkg/common/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@ package common

import "time"

// AuthTokenSource defines where to extract authentication tokens from.
type AuthTokenSource int

const (
// AuthTokenSourceHeader reads the token from a request header.
AuthTokenSourceHeader AuthTokenSource = iota
// AuthTokenSourceCookie reads the token from a request cookie.
AuthTokenSourceCookie
)

// AuthTokenConfig defines how to extract authentication tokens from requests.
type AuthTokenConfig struct {
// Source determines where to look for the token.
Source AuthTokenSource

// HeaderName is used when Source is AuthTokenSourceHeader.
// If empty, defaults to "Authorization".
HeaderName string

// CookieName is used when Source is AuthTokenSourceCookie.
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

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

The documentation for CookieName should clarify that it's required when Source is AuthTokenSourceCookie. Consider updating the comment to: "CookieName is used when Source is AuthTokenSourceCookie. Required when using cookie-based authentication."

Suggested change
// CookieName is used when Source is AuthTokenSourceCookie.
// CookieName is used when Source is AuthTokenSourceCookie.
// Required when using cookie-based authentication.

Copilot uses AI. Check for mistakes.
CookieName string
}

// RouteOverrides contains settings that can be overridden at different levels (global, sub-router, route).
// These overrides follow a hierarchy where the most specific setting takes precedence.
type RouteOverrides struct {
Expand All @@ -16,6 +39,10 @@ type RouteOverrides struct {
// RateLimit overrides the rate limiting configuration.
// A nil value means no override is set.
RateLimit *RateLimitConfig[any, any]

// AuthToken overrides the authentication token source.
// A nil value means no override is set.
AuthToken *AuthTokenConfig
}

// HasTimeout returns true if a timeout override is set (non-zero).
Expand All @@ -32,3 +59,8 @@ func (ro *RouteOverrides) HasMaxBodySize() bool {
func (ro *RouteOverrides) HasRateLimit() bool {
return ro.RateLimit != nil
}

// HasAuthToken returns true if an auth token override is set (non-nil).
func (ro *RouteOverrides) HasAuthToken() bool {
return ro.AuthToken != nil
}
27 changes: 27 additions & 0 deletions pkg/common/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package common

import "testing"

func TestRouteOverridesHasRateLimit(t *testing.T) {
overrides := RouteOverrides{}
if overrides.HasRateLimit() {
t.Fatal("expected HasRateLimit to be false when rate limit is nil")
}

overrides.RateLimit = &RateLimitConfig[any, any]{}
if !overrides.HasRateLimit() {
t.Fatal("expected HasRateLimit to be true when rate limit is set")
}
}

func TestRouteOverridesHasAuthToken(t *testing.T) {
overrides := RouteOverrides{}
if overrides.HasAuthToken() {
t.Fatal("expected HasAuthToken to be false when auth token is nil")
}

overrides.AuthToken = &AuthTokenConfig{}
if !overrides.HasAuthToken() {
t.Fatal("expected HasAuthToken to be true when auth token is set")
}
}
42 changes: 42 additions & 0 deletions pkg/router/auth_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http/httptest"
"testing"

"github.com/Suhaibinator/SRouter/pkg/common"
// Keep middleware alias if needed for other types
"github.com/Suhaibinator/SRouter/pkg/router/internal/mocks" // Use centralized mocks
"github.com/Suhaibinator/SRouter/pkg/scontext" // Added scontext import
Expand Down Expand Up @@ -313,6 +314,47 @@ func TestAuthRequiredMiddlewareWithUserObject(t *testing.T) {
}
}

func TestAuthRequiredMiddlewareWithCookieSource(t *testing.T) {
logger := zap.NewNop()
r := NewRouter(RouterConfig{Logger: logger}, mocks.MockAuthFunction, mocks.MockUserIDFromUser)

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := scontext.GetUserIDFromRequest[string, string](r)
if !ok {
t.Error("Expected user ID to be in context")
}
if userID != "user123" {
t.Errorf("Expected user ID %q, got %q", "user123", userID)
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("OK"))
})

authTokenConfig := common.AuthTokenConfig{
Source: common.AuthTokenSourceCookie,
CookieName: "auth_token",
}
wrappedHandler := r.authRequiredMiddlewareWithConfig(authTokenConfig)(handler)

// Test with valid auth cookie
req, _ := http.NewRequest("GET", "/test", nil)
req.AddCookie(&http.Cookie{Name: "auth_token", Value: "valid-token"})
rr := httptest.NewRecorder()
wrappedHandler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("Expected status code %d, got %d", http.StatusOK, rr.Code)
}

// Test with valid Authorization header but no cookie (should not fallback)
req, _ = http.NewRequest("GET", "/test", nil)
req.Header.Set("Authorization", "Bearer valid-token")
rr = httptest.NewRecorder()
wrappedHandler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("Expected status code %d, got %d", http.StatusUnauthorized, rr.Code)
}
}
Comment on lines +317 to +356
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

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

Test coverage for cookie-based authentication is only provided for authRequiredMiddleware but not for authOptionalMiddleware. Consider adding a similar test for authOptionalMiddleware with cookie source to ensure both authentication modes work correctly with cookies.

Copilot uses AI. Check for mistakes.

// TestAuthRequiredMiddlewareWithTraceID tests the authRequiredMiddleware function with trace ID
// (from auth_required_middleware_test.go)
func TestAuthRequiredMiddlewareWithTraceID(t *testing.T) {
Expand Down
Loading