Skip to content

Authentication and permissions

import 'package:gisila/gisila.dart';

JWT support has its own page: JWT.

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:

  1. A method-level @Public() wins: the route is open.
  2. Otherwise class-level and method-level guards are combined (method guards add to class guards, they never replace them).
  3. 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
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().

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.

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.

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();
}

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.

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;
}
}
class NullAuthenticator extends Authenticator { const NullAuthenticator(); }

Always returns null. The default, so every guarded route answers 401 until you configure real authentication.

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'}),
}),
)

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);
},
);
typedef ApiKeyLookup = FutureOr<Principal?> Function(String hash, Request request);

Callback that resolves an API key hash to a principal.

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)),
]),
)
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);
}
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.