Skip to content

Authentication

Protect the route with a guard, then read the authenticated user from the request:

Route.get('/profile', (Request request) {
final user = request.requireUser<User>();
return {'id': user.id, 'email': user.email};
}).middleware(['auth:cartouche']);

Maat’s core package defines authentication contracts, password hashing, guards, and request access. It does not define a User model or depend on the ORM: the columns that identify a user belong to the application.

The Authenticatable Contract

Implement Authenticatable on any object a guard may return:

class User extends Model<User> implements Authenticatable {
// Model definition and fields omitted.
@override
Object get authIdentifier => id!;
@override
String get authPassword => password;
}

authIdentifier is the value stored by token and session systems. authPassword is the encoded password, or an empty string for an account that cannot sign in with a password.

Password Hashing

final encoded = await Hash.makeAsync('correct horse battery staple');
if (await Hash.checkAsync(input, encoded)) {
// Authenticated.
}
if (Hash.needsRehash(encoded)) {
// Replace it after a successful login.
}

Store only encoded, never the plaintext password. Treat both the submitted password and the encoded hash as sensitive values that must not enter logs.

Use the asynchronous methods in request handlers. Argon2 is intentionally expensive; makeAsync and checkAsync move that work to another isolate so the HTTP isolate can keep serving requests. The synchronous make and check methods remain useful in scripts and controlled tests.

Passwords use Argon2id because human-chosen input has low entropy and must be expensive to guess. Personal access tokens use SHA-256 instead: their random 40-character secret already has enough entropy, so making every authenticated request deliberately slow would add cost without useful protection.

Configure new hashes in config/hashing.dart:

Map<String, dynamic> get hashing => {
'argon': {'memory': 19456, 'time': 2, 'threads': 1},
};

The parameters used to verify an existing password come from its encoded PHC string. Raising the configuration therefore does not invalidate old hashes; needsRehash tells you when to upgrade one after a successful login. Malformed hashes return false, not an exception.

Guards

A guard resolves the user for one request:

class HeaderGuard implements Guard {
@override
Future<Authenticatable?> user(Request request) async {
final id = int.tryParse(request.header('x-user-id') ?? '');
return id == null ? null : User.query().find(id);
}
}

Register guards from a service provider:

@override
void register() {
Auth.extend('header', HeaderGuard.new);
}

The core package ships no concrete guard. cartouche registers cartouche; a session package can later register web through the same contract.

Protecting Routes

Register the parameterized middleware alias in bootstrap/app.dart:

.withMiddleware((middleware) => middleware.alias({
'auth': Authenticate.factory,
}))

Then name the guard on a route:

Route.get('/profile', profile).middleware(['auth:cartouche']);

With no parameter, auth reads the default from config/auth.dart:

import 'package:maat/maat.dart';
Map<String, dynamic> get auth => {
'defaults': {'guard': env('AUTH_GUARD', 'cartouche')},
};

An unauthenticated request receives 401. An unknown guard throws an ArgumentError instead of silently leaving the route unprotected.

Reading the User

Future<Object?> profile(Request request) async {
final user = request.requireUser<User>();
return user;
}

request.user<User>() returns null when no user was authenticated. requireUser<User>() throws a 401 and is convenient behind auth middleware. Both check the requested type; asking for the wrong model type produces an error naming the mismatch rather than a misleading null.

Failure Responses

When a guard returns null, authentication stops before the handler and the client receives 401 Unauthenticated. An unknown guard name throws an ArgumentError; a typo such as auth:cartochue must fail loudly rather than silently expose the route.

Authorization is a separate step. Once a user is authenticated, middleware or application policy should still decide whether that user may perform the requested action and return 403 when they may not.

Testing Authentication

Test the route boundary, not just the guard in isolation:

final guest = await TestClient(app).get('/profile');
guest.assertStatus(401);
final member = await TestClient(app)
.withToken(token)
.get('/profile');
member.assertOk();

For Cartouche tests that do not need token persistence, use Cartouche.actingAs(user) and reset its static state in tearDown. See Cartouche API Tokens.