-
Notifications
You must be signed in to change notification settings - Fork 0
Roles and Scopes
Both work the same way: the route declares what is required per scheme, and the scheme's validator checks it after authentication succeeds.
func (r *AdminRoute) RequiredRoles() map[string][]string {
return map[string][]string{"jwt": {"admin", "user:write"}} // ALL of them
}
func (r *AdminRoute) RequiredScopes() map[string][]string {
return map[string][]string{"jwt": {"users.write"}}
}Roles are an application's own authorization model. Scopes are OAuth2 / OIDC delegation. Use whichever your tokens carry — or both.
A missing role or scope is a 403, not a 401: the caller authenticated, they are just not permitted.
Two interfaces, and you need both halves:
type RoleValidator interface {
ValidateRoles(r *http.Request, principal interface{}, requiredRoles []string) error
}
type RoleEnforcer interface {
RoleValidator
SupportsRoles() bool
}ScopeValidator / ScopeEnforcer are identical in shape.
Implement them on the validator — the thing that actually understands the
token — and BearerScheme delegates to it:
type jwtValidator struct{ keys jwk.Set }
func (v *jwtValidator) ValidateToken(token string) (interface{}, error) { … }
func (v *jwtValidator) SupportsRoles() bool { return true }
func (v *jwtValidator) ValidateRoles(r *http.Request, principal interface{}, required []string) error {
claims, ok := principal.(*Claims)
if !ok {
return errors.New("unexpected principal type")
}
for _, want := range required {
if !slices.Contains(claims.Roles, want) {
return fmt.Errorf("missing role %q", want) // logged, never sent
}
}
return nil
}This is the most important thing on this page, because the failure it prevents is invisible.
BearerScheme implements RoleValidator. Its implementation delegates to its
inner TokenValidator when that validator also implements RoleValidator — and
used to return nil when it did not:
func (s *BearerScheme) ValidateRoles(...) error {
if rv, ok := s.validate.(RoleValidator); ok {
return rv.ValidateRoles(...)
}
return nil // ← "no validator" indistinguishable from "allowed"
}So scheme.(RoleValidator) succeeds, the middleware calls ValidateRoles, gets
nil, and lets the request through. A route declaring
RequiredRoles: {"jwt": {"admin"}} against a bearer scheme whose validator knows
nothing about roles was served to every authenticated caller — and nothing
anywhere reported it.
Reflection cannot see this either: the fail-open is one level down, inside a field.
SupportsRoles moves the answer to where it is knowable. Two consequences:
-
At startup, the framework refuses to boot when a route declares roles for
a scheme reporting
false. See Startup Validation. - At request time, an unenforceable requirement is a 403, never a pass.
This deliberately reverses an earlier design that described silent skipping as backward compatibility. It was: authorization silently granting access nobody granted.
security.NewBearerScheme("jwt", v).SetRolesClaim("realm_access.roles")Documentation only. It becomes x-roles-claim on the OpenAPI security
scheme so UI tooling can surface where roles live. It configures no enforcement
— that is entirely your validator's business.
Every listed role and every listed scope must be present. There is no built-in "any of" — if you need it, express it in the validator:
func (v *jwtValidator) ValidateRoles(r *http.Request, p interface{}, required []string) error {
// Convention: "a|b" means either.
for _, spec := range required {
if !anyOf(p, strings.Split(spec, "|")) {
return fmt.Errorf("missing any of %s", spec)
}
}
return nil
}Keep such a convention documented next to the routes that use it — a reader
seeing {"admin|owner"} should not have to guess.
The map is keyed by scheme name because a multi-scheme route has more than one identity, and they carry different authorization:
func (r *InternalRoute) RequiredSchemes() []string {
return []string{"serviceToken", "session"}
}
func (r *InternalRoute) RequiredRoles() map[string][]string {
return map[string][]string{
"serviceToken": {"svc:orders"},
"session": {"support"},
}
}Both must pass. A key naming a scheme not in RequiredSchemes is a startup
error, not a silently ignored entry.
rextension-security — authentication, authorization and CSRF for Rex · MIT · © 2026 Kryovyx