Authentication and permissions
import 'package:gisila/gisila.dart';JWT support has its own page: JWT.
Permission annotations
Section titled “Permission annotations”Placed on a controller class or a route method. The generator turns them into the route’s guard config. A guarded route calls AppConfig.authenticator, then checks the principal with AppConfig.permissionResolver, before your method runs.
Precedence:
- A method-level
@Public()wins: the route is open. - Otherwise class-level and method-level guards are combined (method guards add to class guards, they never replace them).
- A class-level
@RequireAuth()protects every route except those marked@Public().
| Outcome | Status | Error code |
|---|---|---|
| No principal (no or unrecognised credentials) | 401 | unauthorized |
Authenticator threw an HttpException with status 401 or 403 |
that status | the exception’s code (for example token_expired) |
| Authenticator threw anything else | 401 | unauthorized (details logged as a warning) |
| Missing permission or role | 403 | forbidden |
RequireAuth
Section titled “RequireAuth”class RequireAuth { const RequireAuth(); }Requires an authenticated principal. Targets classes and methods.
When to use: endpoints any signed-in user may call (/me, own resources). Put it on the class and mark exceptions with @Public().
Public
Section titled “Public”class Public { const Public(); }Marks a method as open, overriding every class-level guard. Targets methods only.
When to use: sign-up, login, health and documentation routes inside an otherwise protected controller.
Permissions
Section titled “Permissions”class Permissions { const Permissions(Set<String> values); final Set<String> values;}Requires every listed permission. Implies @RequireAuth.
When to use: fine-grained capabilities such as orders:refund. Prefer permissions over roles in route guards: roles can then change meaning without touching code.
AnyPermission
Section titled “AnyPermission”class AnyPermission { const AnyPermission(Set<String> values); final Set<String> values;}Requires at least one listed permission. Implies @RequireAuth.
When to use: when several capabilities each grant access, for example {'users:write', 'admin'}.
class Roles { const Roles(Set<String> values); final Set<String> values;}Requires at least one listed role. Implies @RequireAuth.
When to use: coarse areas such as an admin controller, when your identity provider issues roles.
Example
@Controller('/orders')@RequireAuth()class OrdersController { @Get('/') Future<List<Order>> mine(RequestContext ctx, OrderService orders) => orders.forUser(ctx.principal!.id);
@Post('/{id}/refund') @Permissions({'orders:refund'}) Future<Order> refund(String id, OrderService orders) => orders.refund(id);
@Delete('/{id}') @AnyPermission({'orders:delete', 'admin'}) Future<void> delete(String id, OrderService orders) => orders.delete(id);
@Get('/pricing') @Public() Future<Pricing> pricing(OrderService orders) => orders.pricing();}Principals
Section titled “Principals”Principal
Section titled “Principal”The authenticated caller, returned by an Authenticator and stored on ctx.principal.
class Principal { const Principal({ required String id, Set<String> roles = const {}, Set<String> permissions = const {}, Map<String, Object?> claims = const {}, }); final String id; final Set<String> roles; final Set<String> permissions; final Map<String, Object?> claims; bool hasRole(String role); bool hasPermission(String permission);}When to use: build one in your authenticator. Built-in guards read only roles and permissions (through the default resolver); put anything else (tenant id, the loaded user, token expiry) in claims.
| Field | Description |
|---|---|
id |
Stable identifier: user id, service-account id. |
roles |
Checked by @Roles (with the default resolver). |
permissions |
Checked by @Permissions and @AnyPermission (with the default resolver). |
claims |
Your extra data. |
hasRole(), hasPermission() |
Set lookups, for checks inside handlers. |
Authenticators
Section titled “Authenticators”Authenticator
Section titled “Authenticator”Resolves a request to a principal.
abstract class Authenticator { const Authenticator(); Future<Principal?> authenticate(Request request);}When to use: implement it for any credential scheme gisila doesn’t ship: session cookies, mTLS, signed URLs. Set it as AppConfig.authenticator.
Return null for an anonymous request (no credentials): the framework answers 401. Throw only for credentials that are present but bad. Throw an HttpException with status 401 or 403 (for example HttpException(401, 'Session expired', code: 'session_expired')) to send that error to the client; any other exception becomes a generic 401 and is logged.
class SessionAuthenticator extends Authenticator { SessionAuthenticator(this.sessions); final CacheProvider sessions;
@override Future<Principal?> authenticate(Request request) async { final sid = _cookie(request, 'sid'); if (sid == null) return null; final raw = await sessions.get('session:$sid'); if (raw == null) throw HttpException(401, 'Session expired', code: 'session_expired'); final data = jsonDecode(raw) as Map<String, Object?>; return Principal( id: data['userId'] as String, roles: {...(data['roles'] as List).cast<String>()}, ); }
static String? _cookie(Request request, String name) { for (final part in (request.headers['cookie'] ?? '').split(';')) { final kv = part.trim().split('='); if (kv.length == 2 && kv[0] == name) return kv[1]; } return null; }}NullAuthenticator
Section titled “NullAuthenticator”class NullAuthenticator extends Authenticator { const NullAuthenticator(); }Always returns null. The default, so every guarded route answers 401 until you configure real authentication.
StaticTokenAuthenticator
Section titled “StaticTokenAuthenticator”class StaticTokenAuthenticator extends Authenticator { const StaticTokenAuthenticator(Map<String, Principal> tokens, {String scheme = 'Bearer'}); final String scheme;}Maps Authorization: <scheme> <token> to a fixed principal. The scheme comparison is case-sensitive.
When to use: demos, local development and internal tools with a handful of fixed tokens. For tests, prefer TestAuthenticator; for real users, JWT or sessions.
AppConfig( authenticator: const StaticTokenAuthenticator({ 'admin-token': Principal(id: 'admin', roles: {'admin'}, permissions: {'users:read', 'users:write'}), 'reader-token': Principal(id: 'reader', permissions: {'users:read'}), }),)ApiKeyAuthenticator
Section titled “ApiKeyAuthenticator”Authenticates requests carrying an API key, by default in the X-API-Key header. Keys are looked up by their SHA-256 hash, so you store hashes, never keys.
class ApiKeyAuthenticator extends Authenticator { ApiKeyAuthenticator({ required ApiKeyLookup lookup, String header = 'X-API-Key', String? Function(Request request)? keyFromRequest, });
static const int maxKeyLength = 256; static String hash(String key); static String generate({String prefix = ''});}When to use: machine-to-machine access: scripts, integrations, webhooks from partners. Combine it with JWT through CompositeAuthenticator when browsers and scripts share an API.
| Parameter / member | Type | Default | Description |
|---|---|---|---|
lookup |
ApiKeyLookup |
required | Finds the principal for a key hash; return null for unknown or revoked keys. |
header |
String |
'X-API-Key' |
Header holding the key (case-insensitive). |
keyFromRequest |
String? Function(Request)? |
null |
Read the key from somewhere else instead of header. |
maxKeyLength |
int |
256 |
Longer keys are rejected without hashing. |
hash(key) |
String |
SHA-256, hex-encoded. Store this. | |
generate(prefix:) |
String |
prefix plus 32 random bytes, base64url without padding. A prefix such as sk_live_ helps secret scanners. |
No key: anonymous (null). An unknown or too-long key: 401 with code invalid_api_key.
// Issuing a key:final key = ApiKeyAuthenticator.generate(prefix: 'sk_live_');await apiKeys.insert(hash: ApiKeyAuthenticator.hash(key), ownerId: user.id, scopes: {'orders:read'});// Show `key` to the user once.
// Checking keys:final auth = ApiKeyAuthenticator( lookup: (hash, request) async { final row = await apiKeys.findActive(hash); return row == null ? null : Principal(id: row.ownerId, permissions: row.scopes); },);ApiKeyLookup
Section titled “ApiKeyLookup”typedef ApiKeyLookup = FutureOr<Principal?> Function(String hash, Request request);Callback that resolves an API key hash to a principal.
CompositeAuthenticator
Section titled “CompositeAuthenticator”class CompositeAuthenticator extends Authenticator { CompositeAuthenticator(Iterable<Authenticator> authenticators); final List<Authenticator> authenticators;}Tries authenticators in order and returns the first principal. One that throws (credentials present but bad) ends the search with that error, so a rejected token is never retried as something else. Throws ArgumentError when the list is empty.
When to use: one API, several credential types.
AppConfig( authenticator: CompositeAuthenticator([ JwtAuthenticator(key: JwtKey.hmac(jwtSecret), algorithms: {'HS256'}), ApiKeyAuthenticator(lookup: (hash, _) => apiKeys.principalFor(hash)), ]),)Permission resolution
Section titled “Permission resolution”PermissionResolver
Section titled “PermissionResolver”abstract class PermissionResolver { const PermissionResolver(); Future<bool> hasPermission(Principal principal, String permission); Future<bool> hasRole(Principal principal, String role);}Decides whether a principal holds a permission or role. Called once per listed permission or role on every guarded request.
When to use: when permissions don’t fit in the principal: they live in the database, depend on the tenant, or come from a policy engine. Cache lookups, since they run on every request.
class RolePermissionResolver extends PermissionResolver { const RolePermissionResolver(this.grants); final Map<String, Set<String>> grants; // role -> permissions
@override Future<bool> hasPermission(Principal p, String permission) async => p.permissions.contains(permission) || p.roles.any((r) => grants[r]?.contains(permission) ?? false);
@override Future<bool> hasRole(Principal p, String role) async => p.roles.contains(role);}ClaimsPermissionResolver
Section titled “ClaimsPermissionResolver”class ClaimsPermissionResolver extends PermissionResolver { const ClaimsPermissionResolver();}The default resolver: reads principal.permissions and principal.roles.
See also: Permissions concept, Role-based permissions guide, Session and API key auth guide.