Add JWT authentication
You’ll add a login endpoint that issues short-lived JWTs, configure the app to verify them on protected routes, and read the caller in your handlers. The same setup works for tokens from an external identity provider by swapping the key for a RemoteJwks.
Prerequisites
Section titled “Prerequisites”- A gisila project with a user store you can check passwords against.
- A secret of at least 32 bytes:
openssl rand -base64 48, exported asJWT_SECRET.
1. Share one key, issuer and audience
Section titled “1. Share one key, issuer and audience”lib/auth/jwt.dart:
import 'dart:io';
import 'package:gisila/gisila.dart';
final _secret = Platform.environment['JWT_SECRET'] ?? (throw StateError('JWT_SECRET is not set'));
final jwtKey = JwtKey.hmac(_secret); // throws if shorter than 32 bytesconst jwtIssuer = 'notes-api';
final jwtSigner = JwtSigner( key: jwtKey, algorithm: 'HS256', issuer: jwtIssuer, expiresIn: const Duration(minutes: 15),);
final jwtAuthenticator = JwtAuthenticator( key: jwtKey, algorithms: {'HS256'}, issuer: jwtIssuer,);The authenticator rejects tokens signed with any other algorithm, from another issuer, expired (with 30 seconds of leeway) or without exp.
2. Configure the app
Section titled “2. Configure the app”final app = GisilaApp(config: AppConfig(authenticator: jwtAuthenticator));Without toPrincipal, the token’s sub becomes principal.id, the roles claim becomes principal.roles, and scope plus permissions become principal.permissions.
3. Issue tokens at login
Section titled “3. Issue tokens at login”class LoginForm extends Form { final email = EmailField(name: 'email', required: true); final password = StringField(name: 'password', required: true, trim: false);
@override List<FormField<Object?>> collectFields() => [email, password];}
class AuthService extends Service { Future<Map<String, Object?>> login(String email, String password) async { final user = await _users.findByEmail(email); if (user == null || !await _passwords.verify(password, user.passwordHash)) { throw Unauthorized('Invalid email or password'); } final token = jwtSigner.sign( subject: '${user.id}', claims: {'roles': user.roles.toList(), 'scope': user.permissions.join(' ')}, ); return {'access_token': token, 'token_type': 'Bearer', 'expires_in': 900}; }}
@Controller('/auth')class AuthController { @Post('/login', statusCode: 200, config: RouteConfig( rateLimit: RateLimitConfig(requestsPerMinute: 10, burstSize: 5), )) Future<Map<String, Object?>> login(LoginForm form, AuthService auth) => auth.login(form.email.value!, form.password.value!);}statusCode: 200 overrides @Post’s default 201. The rate limit slows down password guessing. Hash passwords with a slow algorithm (argon2, bcrypt, scrypt); gisila doesn’t ship one.
4. Protect routes and read the caller
Section titled “4. Protect routes and read the caller”@Controller('/me')@RequireAuth()class MeController { @Get('/') Future<Map<String, Object?>> me(RequestContext ctx) async => { 'id': ctx.principal!.id, 'roles': ctx.principal!.roles.toList(), 'permissions': ctx.principal!.permissions.toList(), };
@Get('/notes') @Permissions({'notes:read'}) Future<List<Note>> notes(RequestContext ctx, NoteService notes) => notes.forOwner(ctx.principal!.id);}5. Map claims to your own user (optional)
Section titled “5. Map claims to your own user (optional)”Load the user on every request to reject disabled accounts and attach data for handlers:
final jwtAuthenticator = JwtAuthenticator( key: jwtKey, algorithms: {'HS256'}, issuer: jwtIssuer, toPrincipal: (claims, request) async { final user = await userCache.find(claims.subject); if (user == null || user.disabled) return null; // → 401 invalid_token return Principal( id: '${user.id}', roles: user.roles, permissions: claims.strings('scope'), claims: {'user': user}, ); },);toPrincipal runs on every guarded request, so keep it fast (cache the lookup).
6. Tokens from an identity provider
Section titled “6. Tokens from an identity provider”final jwtAuthenticator = JwtAuthenticator( keys: RemoteJwks(Uri.parse('https://YOUR_DOMAIN/.well-known/jwks.json')), algorithms: {'RS256'}, issuer: 'https://YOUR_DOMAIN/', audience: {'https://api.example.com'},);Keys are cached for 10 minutes and refreshed early when a token names an unknown kid.
Verify it works
Section titled “Verify it works”TOKEN=$(curl -s -X POST localhost:8080/auth/login -H 'content-type: application/json' \ -d '{"email":"ann@example.com","password":"correct horse"}' | jq -r .access_token)
curl -s localhost:8080/me -H "authorization: Bearer $TOKEN"# {"id":"1","roles":["staff"],"permissions":["notes:read"]}
curl -si localhost:8080/me# HTTP/1.1 401 ... {"error":{"status":401,"message":"Authentication required","code":"unauthorized"}}
curl -si localhost:8080/me -H 'authorization: Bearer nonsense'# HTTP/1.1 401 ... {"error":{"status":401,"message":"Invalid token","code":"invalid_token"}}An expired token answers 401 with code token_expired: the client’s cue to refresh or log in again.
Next steps
Section titled “Next steps”- Role-based permissions
- Session and API key auth for cookies and machine clients
- JWT reference for key rotation,
maxAge,requiredTypeand custom token locations