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
353 changes: 341 additions & 12 deletions README.md

Large diffs are not rendered by default.

98 changes: 53 additions & 45 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,24 +88,30 @@ func RequireResourceAccess(c fiber.Ctx, authService AuthorizationService, resour

// BearerTokenMiddleware creates a JWT/Bearer middleware
func BearerTokenMiddleware(validator AuthorizationService) fiber.Handler {
return bearerTokenMiddleware(validator, nil)
}

// bearerTokenMiddleware is BearerTokenMiddleware with an optional Config whose
// error handling it follows (see authFailure).
func bearerTokenMiddleware(validator AuthorizationService, config *Config) fiber.Handler {
return func(c fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(401).JSON(fiber.Map{
return authFailure(c, config, &AuthError{StatusCode: 401, Message: "Authorization header required"}, 401, fiber.Map{
"error": "Authorization header required",
})
}

if !strings.HasPrefix(authHeader, "Bearer ") {
return c.Status(401).JSON(fiber.Map{
return authFailure(c, config, &AuthError{StatusCode: 401, Message: "Bearer token required"}, 401, fiber.Map{
"error": "Bearer token required",
})
}

token := strings.TrimPrefix(authHeader, "Bearer ")
authCtx, err := validator.ValidateToken(token)
authCtx, err := validateToken(c, validator, token)
if err != nil {
return c.Status(401).JSON(fiber.Map{
return authFailure(c, config, authErrorFrom(err), 401, fiber.Map{
"error": "Invalid token",
"details": err.Error(),
})
Expand Down Expand Up @@ -140,44 +146,36 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha
}
}

// validateAuthorization validates permissions based on configured security schemes.
// authenticate validates the credentials of the request against the
// configured security schemes and returns the resulting AuthContext. It does
// not need the request input, so it runs before the body is read, in
// authenticateHandler. routeSecurity, when non-empty, replaces
// Config.DefaultSecurity for the route.
// It returns (nil, nil) when no AuthService is configured.
// When SecuritySchemes is empty, it falls back to Bearer-only validation for backward compatibility.
func validateAuthorization(c fiber.Ctx, input interface{}, authService AuthorizationService, config *Config, requiredRoles []string, requireAllRoles bool) error {
func authenticate(c fiber.Ctx, config *Config, routeSecurity []map[string][]string) (*AuthContext, error) {
authService := config.AuthService
if authService == nil {
if len(requiredRoles) > 0 {
return &AuthError{StatusCode: 500, Message: "authorization service not configured"}
}
return nil
return nil, nil
}

// Backward compatibility: if no SecuritySchemes are configured,
// fall back to Bearer-only validation (original behavior).
if config == nil || len(config.SecuritySchemes) == 0 {
if len(config.SecuritySchemes) == 0 {
authCtx, err := validateBearerToken(c, authService)
if err != nil {
// Preserve typed AuthError (including 5xx) and map ScopeError to 403,
// falling back to 401 only for untyped errors.
var authErr *AuthError
if errors.As(err, &authErr) {
return err
}
var scopeErr *ScopeError
if errors.As(err, &scopeErr) {
return &AuthError{StatusCode: 403, Message: err.Error()}
}
return &AuthError{StatusCode: 401, Message: err.Error()}
}
c.Locals("auth", authCtx)

// Check roles before resource access
if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil {
return err
// falling back to 401 only for untyped errors. The cause is kept.
return nil, authErrorFrom(err)
}
return validateResourceAccess(c, authCtx, input, authService)
return authCtx, nil
}

// Multi-scheme validation path
securityReqs := config.DefaultSecurity
securityReqs := routeSecurity
if len(securityReqs) == 0 {
securityReqs = config.DefaultSecurity
}
if len(securityReqs) == 0 {
securityReqs = buildDefaultFromSchemes(config.SecuritySchemes)
}
Expand All @@ -189,31 +187,41 @@ func validateAuthorization(c fiber.Ctx, input interface{}, authService Authoriza
for _, requirement := range securityReqs {
authCtx, err := validateSecurityRequirement(c, requirement, config.SecuritySchemes, authService)
if err == nil {
c.Locals("auth", authCtx)

// Check roles before resource access
if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil {
return err
}
return validateResourceAccess(c, authCtx, input, authService)
return authCtx, nil
}
var authErr *AuthError
if errors.As(err, &authErr) && authErr.StatusCode >= 500 {
return err
return nil, err
}
lastErr = err
}
if lastErr == nil {
return nil, &AuthError{StatusCode: 500, Message: "no security requirements configured"}
}

// Propagate typed errors (AuthError, ScopeError) without re-wrapping
var existingAuthErr *AuthError
if errors.As(lastErr, &existingAuthErr) {
return lastErr
return nil, authErrorFrom(lastErr)
}

// authorize checks the required roles of the route, then the resource
// permissions declared on the input (resource / action tags). It needs the
// parsed input, so it always runs after the request has been read.
func authorize(c fiber.Ctx, authCtx *AuthContext, input interface{}, authService AuthorizationService, requiredRoles []string, requireAllRoles bool) error {
if authService == nil {
if len(requiredRoles) > 0 {
return &AuthError{StatusCode: 500, Message: "authorization service not configured"}
}
return nil
}
if authCtx == nil {
return &AuthError{StatusCode: 401, Message: "authentication required"}
}
var scopeErr *ScopeError
if errors.As(lastErr, &scopeErr) {
return &AuthError{StatusCode: 403, Message: lastErr.Error()}

// Check roles before resource access
if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil {
return err
}
return &AuthError{StatusCode: 401, Message: lastErr.Error()}
return validateResourceAccess(c, authCtx, input, authService)
}

// checkRequiredRoles checks the authenticated user's roles against the required roles.
Expand Down Expand Up @@ -270,7 +278,7 @@ func validateResourceAccess(c fiber.Ctx, authCtx *AuthContext, input interface{}

canAccess, err := authService.CanAccessResource(authCtx, resourceTag, resourceID, actionTag)
if err != nil {
return &AuthError{StatusCode: 500, Message: fmt.Sprintf("authorization check failed: %v", err)}
return &AuthError{StatusCode: 500, Message: fmt.Sprintf("authorization check failed: %v", err), Err: err}
}

if !canAccess {
Expand Down
188 changes: 188 additions & 0 deletions auth_error_cause_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package fiberoapi

import (
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/gofiber/fiber/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var errTokenExpired = errors.New("token expired")

// deviceRevokedError carries data the AuthErrorHandler can extract with
// errors.As, and its own HTTP status.
type deviceRevokedError struct{ DeviceID string }

func (e *deviceRevokedError) Error() string { return "device " + e.DeviceID + " revoked" }
func (e *deviceRevokedError) HTTPStatus() int { return fiber.StatusForbidden }

// causeAuthService maps a few well-known tokens onto typed failures.
type causeAuthService struct{}

func (causeAuthService) ValidateToken(token string) (*AuthContext, error) {
switch token {
case "expired":
return nil, errTokenExpired
case "revoked":
return nil, &deviceRevokedError{DeviceID: "d-42"}
case "ok":
return &AuthContext{UserID: "u-1"}, nil
}
return nil, errors.New("unknown token")
}
func (causeAuthService) HasRole(*AuthContext, string) bool { return false }
func (causeAuthService) HasScope(*AuthContext, string) bool { return false }
func (causeAuthService) CanAccessResource(*AuthContext, string, string, string) (bool, error) {
return false, nil
}
func (causeAuthService) GetUserPermissions(*AuthContext, string, string) (*ResourcePermission, error) {
return nil, nil
}

// classifyingAuthErrorHandler answers with a stable code derived from the cause.
func classifyingAuthErrorHandler(c fiber.Ctx, err *AuthError) error {
code := "unauthorized"
var revoked *deviceRevokedError
switch {
case errors.Is(err, errTokenExpired):
code = "token_expired"
case errors.As(err, &revoked):
code = "device_revoked:" + revoked.DeviceID
}
return c.Status(err.StatusCode).JSON(fiber.Map{"code": code})
}

func newBearerRequest(method, path, token string) *http.Request {
req := httptest.NewRequest(method, path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
return req
}

func doBearer(t *testing.T, app *fiber.App, path, token string) (int, map[string]any) {
t.Helper()
resp, err := app.Test(newBearerRequest("GET", path, token))
require.NoError(t, err)
raw, _ := io.ReadAll(resp.Body)
var body map[string]any
_ = json.Unmarshal(raw, &body)
return resp.StatusCode, body
}

func TestAuthErrorCause_ReachableFromAuthErrorHandler(t *testing.T) {
for _, tc := range []struct {
name string
schemes map[string]SecurityScheme
}{
{name: "bearer only", schemes: nil},
{name: "security schemes", schemes: map[string]SecurityScheme{
"bearerAuth": {Type: "http", Scheme: "bearer"},
}},
} {
t.Run(tc.name, func(t *testing.T) {
app := fiber.New()
oapi := New(app, Config{
EnableValidation: true,
EnableAuthorization: true,
AuthService: causeAuthService{},
SecuritySchemes: tc.schemes,
AuthErrorHandler: classifyingAuthErrorHandler,
})
Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) {
return fiber.Map{"ok": true}, struct{}{}
}, OpenAPIOptions{})

status, body := doBearer(t, app, "/me", "expired")
assert.Equal(t, 401, status)
assert.Equal(t, "token_expired", body["code"])

status, body = doBearer(t, app, "/me", "revoked")
assert.Equal(t, 403, status, "the status comes from the cause's HTTPStatus()")
assert.Equal(t, "device_revoked:d-42", body["code"])

status, _ = doBearer(t, app, "/me", "ok")
assert.Equal(t, 200, status)
})
}
}

func TestAuthErrorCause_StatusFromHTTPStatusInDefaultEnvelope(t *testing.T) {
app := fiber.New()
oapi := New(app, Config{
EnableValidation: true,
EnableAuthorization: true,
AuthService: causeAuthService{},
})
Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) {
return fiber.Map{"ok": true}, struct{}{}
}, OpenAPIOptions{})

status, body := doBearer(t, app, "/me", "revoked")
assert.Equal(t, 403, status)
entry := body["errors"].([]any)[0].(map[string]any)
assert.Equal(t, errTypeAuthZ, entry["type"])
assert.Equal(t, "device d-42 revoked", entry["msg"])
}

func TestAuthErrorFrom(t *testing.T) {
authErr := authErrorFrom(errTokenExpired)
assert.Equal(t, 401, authErr.StatusCode)
assert.ErrorIs(t, authErr, errTokenExpired)

authErr = authErrorFrom(&ScopeError{Scope: "write"})
assert.Equal(t, 403, authErr.StatusCode)
_, ok := errors.AsType[*ScopeError](authErr)
assert.True(t, ok)

typed := &AuthError{StatusCode: 500, Message: "misconfigured"}
assert.Same(t, typed, authErrorFrom(typed), "a typed AuthError is returned as-is")
}

func TestAuthMiddlewares_HonourAuthErrorHandler(t *testing.T) {
multi := Config{
SecuritySchemes: map[string]SecurityScheme{"bearerAuth": {Type: "http", Scheme: "bearer"}},
AuthErrorHandler: classifyingAuthErrorHandler,
}
bearerOnly := Config{AuthErrorHandler: classifyingAuthErrorHandler}

for name, mw := range map[string]fiber.Handler{
"MultiSchemeAuthMiddleware": MultiSchemeAuthMiddleware(causeAuthService{}, multi),
"SmartAuthMiddleware multi-scheme": SmartAuthMiddleware(causeAuthService{}, multi),
"SmartAuthMiddleware bearer only": SmartAuthMiddleware(causeAuthService{}, bearerOnly),
} {
t.Run(name, func(t *testing.T) {
app := fiber.New()
app.Use(mw)
app.Get("/me", func(c fiber.Ctx) error { return c.SendString("ok") })

status, body := doBearer(t, app, "/me", "expired")
assert.Equal(t, 401, status)
assert.Equal(t, "token_expired", body["code"])

status, body = doBearer(t, app, "/me", "revoked")
assert.Equal(t, 403, status)
assert.Equal(t, "device_revoked:d-42", body["code"])

status, body = doBearer(t, app, "/me", "")
assert.Equal(t, 401, status)
assert.Equal(t, "unauthorized", body["code"])
})
}
}

func TestAuthMiddlewares_LegacyBodyWithoutHandler(t *testing.T) {
app := fiber.New()
app.Use(SmartAuthMiddleware(causeAuthService{}, Config{}))
app.Get("/me", func(c fiber.Ctx) error { return c.SendString("ok") })

status, body := doBearer(t, app, "/me", "revoked")
assert.Equal(t, 401, status, "without a handler the legacy status is kept")
assert.Equal(t, "Invalid token", body["error"])
}
Loading
Loading