Session and API key auth
You’ll add two kinds of credentials to one API: server-side sessions in an HttpOnly cookie for your web frontend, and API keys for scripts and integrations. gisila ships ApiKeyAuthenticator; sessions use a small custom Authenticator backed by a CacheProvider.
Prerequisites
Section titled “Prerequisites”- A gisila project with a user store.
- A cache:
InMemoryCacheProviderfor one instance,RedisCacheProviderfromgisila_redisfor several.
1. Choose a session store
Section titled “1. Choose a session store”import 'package:gisila/gisila.dart';
final sessions = InMemoryCacheProvider(defaultTtl: const Duration(days: 7));// Several instances: RedisCacheProvider(GisilaRedis.fromUrl(env['REDIS_URL']!))2. Write the session authenticator
Section titled “2. Write the session authenticator”import 'dart:convert';
import 'package:gisila/gisila.dart';
class SessionAuthenticator extends Authenticator { SessionAuthenticator(this.sessions, {this.cookieName = 'sid'});
final CacheProvider sessions; final String cookieName;
@override Future<Principal?> authenticate(Request request) async { final sid = readCookie(request, cookieName); if (sid == null) return null; // anonymous → 401 on guarded routes 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>()}, permissions: {...(data['permissions'] as List).cast<String>()}, claims: {'sid': sid}, ); }}
String? readCookie(Request request, String name) { for (final part in (request.headers['cookie'] ?? '').split(';')) { final i = part.indexOf('='); if (i > 0 && part.substring(0, i).trim() == name) return part.substring(i + 1).trim(); } return null;}Throwing an HttpException with status 401 sends that error (session_expired) to the client; any other exception becomes a generic 401.
3. Log in and out
Section titled “3. Log in and out”class SessionService extends Service { CacheProvider get _sessions => singleton<CacheProvider>();
Future<String> create(User user) async { final sid = ApiKeyAuthenticator.generate(); // 32 random bytes, base64url await _sessions.set('session:$sid', jsonEncode({ 'userId': '${user.id}', 'roles': user.roles.toList(), 'permissions': user.permissions.toList(), }), ttl: const Duration(days: 7)); return sid; }
Future<void> destroy() async { final sid = ctx.principal?.claims['sid'] as String?; if (sid != null) await _sessions.delete('session:$sid'); }}
@Controller('/session')class SessionController { @Post('/') Future<Response> login(LoginForm form, AccountService accounts, SessionService sessions) async { final user = await accounts.verify(form.email.value!, form.password.value!); final sid = await sessions.create(user); return jsonResponse(body: user, extraHeaders: { 'set-cookie': 'sid=$sid; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=604800', }); }
@Delete('/') @RequireAuth() Future<Response> logout(SessionService sessions) async { await sessions.destroy(); return Response(204, headers: {'set-cookie': 'sid=; Path=/; HttpOnly; Secure; Max-Age=0'}); }}Because the browser sends the cookie automatically, protect state-changing routes against cross-site requests: keep SameSite=Lax (or Strict), and with CORS list exact origins with allowCredentials: true.
4. Issue and check API keys
Section titled “4. Issue and check API keys”Store only the hash. Show the key once.
class ApiKeyService extends Service { Future<String> issue(Set<String> scopes) async { final key = ApiKeyAuthenticator.generate(prefix: 'sk_live_'); await _apiKeys.insert( hash: ApiKeyAuthenticator.hash(key), ownerId: ctx.principal!.id, scopes: scopes, ); return key; }}
final apiKeys = ApiKeyAuthenticator( lookup: (hash, request) async { final row = await apiKeyStore.findActive(hash); return row == null ? null : Principal(id: row.ownerId, permissions: row.scopes); },);Clients send X-API-Key: sk_live_.... An unknown key is 401 invalid_api_key.
5. Combine both
Section titled “5. Combine both”final app = GisilaApp( config: AppConfig( cache: sessions, authenticator: CompositeAuthenticator([ SessionAuthenticator(sessions), apiKeys, ]), cors: const CorsConfig(allowedOrigins: {'https://app.example.com'}, allowCredentials: true), ),);app.registerSingleton<CacheProvider>(sessions);app ..registerService<SessionService>(SessionService.new) ..registerService<ApiKeyService>(ApiKeyService.new);The first authenticator that returns a principal wins. One that throws (an expired session, a wrong key) stops the search, so a bad credential is never retried as another type. You can use ctx.cache instead of the singleton inside services, since the same provider is set as AppConfig.cache.
Verify it works
Section titled “Verify it works”curl -si -c jar -X POST localhost:8080/session -H 'content-type: application/json' \ -d '{"email":"ann@example.com","password":"correct horse"}'# HTTP/1.1 200 OK ... set-cookie: sid=...; HttpOnly ...
curl -s -b jar localhost:8080/me# {"id":"1", ...}
curl -s localhost:8080/me -H 'x-api-key: sk_live_wrong'# {"error":{"status":401,"message":"Invalid API key","code":"invalid_api_key"}}