Authentication and authorization¶
Diana's Spring Security analog lives in the optional diana_auth
package: pluggable authentication strategies, an injectable principal, and
@Secured / @RolesAllowed guards the generator wires into your routes,
WebSocket handshakes and gRPC services.
The design splits authentication (who are you?) from authorization (are you allowed?), exactly like Spring:
- An
AuthenticationMiddlewareruns a chain of strategies and stamps the authenticated principal onto the request. It never rejects anonymous requests — it only establishes identity. - Guards (
@Secured,@RolesAllowed) enforce access at each route.
Setup¶
Assembling the authentication filter¶
Subclass AuthenticationMiddleware, pick the strategies your app accepts,
and register it — usually as a @GlobalMiddleware so it runs before every
route:
import 'package:diana/diana.dart';
import 'package:diana_auth/diana_auth.dart';
@GlobalMiddleware(order: -100)
class AppAuth extends AuthenticationMiddleware {
AppAuth(@Value('auth.jwt.secret') String secret)
: super(authenticators: [
JwtAuthenticator(Hs256TokenVerifier(secret)),
ApiKeyAuthenticator(myKeyStore),
]);
}
Strategies run in order; the first one that recognizes credentials wins.
A strategy returns null when the request carries no credentials for
its scheme (the chain continues), or throws InvalidCredentialsException
when credentials are present but invalid — an immediate 401 with a
WWW-Authenticate challenge (a bad token is never silently downgraded to
anonymous). A request with no credentials at all passes through
anonymously; enforcement happens at the guards.
Built-in strategies¶
All produce an AuthPrincipal { String name; Set<String> roles;
Map<String, Object?> attributes; } and implement the Authenticator
interface, so you can add your own.
JWT (Authorization: Bearer <token>)¶
Hs256TokenVerifier validates HS256 signatures in-package (no
heavyweight dependency) and is hardened: it accepts only alg: HS256
(rejecting none and algorithm-confusion), compares signatures in
constant time, and enforces exp/nbf with a configurable clock skew
plus optional iss/aud checks. sub becomes the principal name; the
roles claim accepts a JSON list or a space-delimited string (covering
OAuth scope).
To mint tokens (login endpoints, tests) use Hs256TokenSigner:
RS256 and JWKS (external identity providers)¶
For tokens minted by an IdP (Auth0, Keycloak, Cognito, Firebase, Entra ID)
verify RS256 in-package — RSA signature verification is pure Dart
(BigInt.modPow + an exact encode-then-compare of the PKCS#1 v1.5
padding), no new dependency:
// Keys rotate behind the IdP's JWKS endpoint (the common case):
JwtAuthenticator(JwksTokenVerifier(
Uri.parse('https://idp.example.com/.well-known/jwks.json'),
issuer: 'https://idp.example.com/',
audience: 'my-api',
))
// Or a static public key — from a JWK or a PEM file:
JwtAuthenticator(Rs256TokenVerifier.single(
RsaPublicKey.fromPem(pemString), // BEGIN (RSA) PUBLIC KEY
))
JwksTokenVerifier fetches the key set lazily, caches it (cacheTtl,
default 10 min) and handles rotation the standard way: a token with an
unknown kid triggers one immediate re-fetch, rate-limited by
refreshCooldown (default 30 s) so bogus kids cannot hammer the IdP.
When a refresh fails, previously cached keys keep serving
(stale-while-error). Call close() from @PreDestroy to release the HTTP
client.
Both verifiers enforce the same hardening as HS256: exact alg: RS256
(the RS→HS downgrade dies on the algorithm check before any key is used),
exp/nbf with clock skew, and optional issuer/audience. Malformed
tokens are rejected before any network fetch.
TokenVerifier.verify returns FutureOr, so custom verifiers may be
async; the built-in HS256/RS256 ones stay synchronous.
HTTP Basic¶
Provide a UserStore { Future<AuthPrincipal?> verify(String username,
String password); }. The store owns password hashing — Diana never sees a
hash.
API key¶
Provide an ApiKeyValidator { Future<AuthPrincipal?> validate(String key); }.
Authorizing routes¶
Annotate controllers or methods (method-level replaces controller-level,
like @CrossOrigin):
@RestController('/admin')
@Secured() // every route requires auth
class AdminController {
@Get('/stats')
Stats stats() => ...;
@Delete('/users/:id')
@RolesAllowed(['admin']) // ...and this one, the admin role
void deleteUser(@PathParam() int id) => ...;
}
@Secured()→ 401 for anonymous requests.@RolesAllowed([...])→ 401 for anonymous, 403 when the principal holds none of the listed roles (any-of semantics). It implies@Secured.
The generator appends the matching guard as the last middleware of the route, so it runs after your authentication filter and immediately before the handler. Fail-closed: with no authentication middleware present, every request is anonymous and secured routes return 401.
A security annotation placed where it would have no effect (a @Service, a
non-route method) is a build error — a silently ignored guard is a hole
you think is closed.
Reading the principal¶
Bind it with @Principal():
@Get('/me')
@Secured()
Profile me(@Principal() AuthPrincipal user) => Profile(user.name, user.roles);
A non-nullable parameter throws 401 when unauthenticated; a nullable one
(AuthPrincipal?) receives null. In middleware or raw handlers, read
request.attributes[principalAttributeKey] directly (the constant is
exported from package:diana).
WebSockets¶
Authentication is applied at the handshake. Global middleware do not run on handshakes, so list the authentication middleware explicitly and add a guard:
@WebSocketController('/ws/chat/:room')
@UseMiddleware([AppAuth]) // authenticate the upgrade
@Secured() // reject anonymous upgrades with 401
class ChatHandler extends BaseWebSocketHandler {
@override
void onOpen(WebSocketSession session) {
final principal = session.attributes[principalAttributeKey];
// ...the authenticated principal is copied into the session.
}
}
gRPC¶
gRPC services are secured the same way — @Secured / @RolesAllowed on the
@GrpcService class. The generator discovers your AuthenticationMiddleware
provider and installs a package:grpc interceptor that authorizes calls
from their metadata (authorizeMetadata), returning UNAUTHENTICATED or
PERMISSION_DENIED.
Limitations (v1): gRPC interceptors are server-wide, so every
@GrpcService must declare identical security (a build error otherwise);
principal injection into service methods is not yet available.
Configuration¶
diana_auth reads no keys itself — your middleware pulls them via @Value.
By convention:
| Key | Meaning |
|---|---|
auth.jwt.secret |
HS256 signing/verification secret (env AUTH_JWT_SECRET) |
auth.jwt.jwksUri |
JWKS endpoint of the identity provider (env AUTH_JWT_JWKSURI) |
auth.jwt.issuer / auth.jwt.audience |
Expected iss/aud claims |
Extending¶
- Implement
Authenticatorfor a custom scheme and add it to the chain. - Implement
TokenVerifierfor other algorithms (ES256, EdDSA) or an external JWT library —verifymay be async. - Subclass
AuthPrincipalfor a richer identity —@Principalbinds any class type.
See also¶
- Middleware — the pipeline guards and filters run in
- WebSockets — handshake interceptors
- gRPC — serving gRPC
- Build diagnostics — the auth errors