Authentication
Since 0.9.0. package:fespalier_auth is the pattern of Guards packaged: a session provider, guards for guard.dart, session storage, lazy renewal with a single flight and a transport that carries the session, behind one AuthBackend interface. It adds no timer and no listener.
Since 0.21.0 the package is the protocol-agnostic core. It knows a SessionCredential, a RequestAuthenticator and an AuthBackend, and no token, no OpenID Connect flow and no key. What proves a session to your API is a backend you add next to it:
| You sign in with | Add | The session is proved by |
|---|---|---|
| OpenID Connect or Keycloak, bearer tokens | fespalier_oidc | Authorization: Bearer <token> |
| OpenID Connect, tokens bound to the device | fespalier_oidc and fespalier_keypair_dpop (and fespalier_keypair) | Authorization: DPoP <token> and a signed DPoP proof |
| Enrolling the device, no token at all | fespalier_sign_keypair (and fespalier_keypair) | an attestation and a signature over each request |
| Firebase, Supabase, or your own API | a backend of your own (a recipe) | whatever its RequestAuthenticator adds |
- The session is a provider,
authSession, whose state is sealed:SessionRestoring,SignedOut(reason)andSignedIn(session). restoreAuth(config)instartup()reads the stored session before the first frame, and never touches the network.- The guard helpers (
requireSignedIn,requireRole,requireUser,redirectIfSignedIn) are plain Dart over aRef, so a guard stays synchronous, and signing in navigates back by itself. authTransportis anHttpTransportthat attaches the session to your API's requests, renews once when the credential has expired and sends a request again after a verdict that says so.fespalier_authsends only through it, so it links neitherpackage:httpnor Dio.package:fespalier_auth/testing.darthasfakeAuth(...): a signed-in or signed-out test in one line.
How it fits together
flowchart TB
subgraph app[Your app]
page["guard.dart, data.dart, action.dart"]
end
subgraph core[fespalier_auth: the core]
session["authSession<br/>state, store, single-flight renewal"]
authz[Authorizer]
st[SessionTransport]
end
subgraph backends[A backend: how a session is made, renewed and proved]
oidc["fespalier_oidc<br/>OidcBackend, BearerAuthenticator"]
dpop["fespalier_keypair_dpop<br/>DpopProof"]
dev["fespalier_sign_keypair<br/>DeviceAuthBackend, DeviceSigner"]
kp["fespalier_keypair<br/>KeypairSigner"]
end
subgraph transport[fespalier_transport]
iface[HttpTransport]
http["fespalier_http<br/>HttpClientTransport"]
dio["fespalier_dio<br/>DioTransport"]
end
page -->|"watch"| session
page -->|"requests"| st
st --> authz
authz --> session
session -->|"signIn, renew, discard"| oidc
session -->|"signIn, renew, discard"| dev
authz -->|"headers, judge"| oidc
authz -->|"headers, judge"| dev
oidc -.->|"proof:"| dpop
dpop --> kp
dev --> kp
st --> iface
oidc -->|"token endpoint"| iface
dev -->|"enrolment, renewal"| iface
iface --- http
iface --- dioThe core owns when: lazy single-flight renewal, the store, the guards, the transport. A backend owns how: how a session is made (signIn), renewed (renew), proved on each request (its RequestAuthenticator) and forgotten (discard). AuthConfig is the one place that joins them, in startup(): a backend, a transport and the origins that get the session.
Installing fespalier_auth
Add it next to fespalier, with the same url and the same ref (Companion packages says why), and a backend:
dependencies:
fespalier:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier
ref: v0.25.0
fespalier_auth:
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_auth
ref: v0.25.0
fespalier_oidc: # the backend: OpenID Connect and Keycloak
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_oidc
ref: v0.25.0
fespalier_http: # a transport implementation: package:http (or fespalier_dio for Dio)
git:
url: https://github.com/fespalier/fespalier
path: packages/fespalier_http
ref: v0.25.0fespalier_transport comes with them at the same tag. The core's one plugin is flutter_secure_storage (the session store; Android minSdk 24, >=10.0.0 <12.0.0 accepted), which is in every app that imports the package, whichever backend it uses. A backend that is an SDK of its own (Firebase, Supabase) keeps its own session and does not use the store. No package of the auth family depends on package:http or Dio except the two transport implementations you pick (test/no_http_test.dart in each).
The session
final state = ref.watch(authSession); // SessionRestoring | SignedOut | SignedIn| Provider | What it is |
|---|---|
authSession | The SessionState, and ref.read(authSession.notifier) for signIn, adopt, signOut, endSession, credential() and stepUp (since 0.25.0). Not auto-disposed |
isSignedIn (a bool) | What a guard watches: a renewal does not change it |
authUser (AuthUser?) | The user: id, roles, claims, email, name. Changes when the user or their roles do, never on a renewal that changes nothing |
authUserId (String?) | Watch it in a data.dart whose data belongs to the user: another user loads it again, a renewal does not |
authTransport | The HttpTransport that carries the session (see Calling your API) |
authConfig, authInitialState | The app's AuthConfig, and what restoreAuth read. Reading authConfig with no override throws a StateError that says what to do |
There is no renewing state: a renewal keeps SignedIn and swaps the credential, so a guard that watches the session never runs again, and a request is never bounced to the sign-in page in the middle of one.
stateDiagram-v2
[*] --> SessionRestoring: no startup()
[*] --> SignedOut: restoreAuth, nothing stored
[*] --> SignedIn: restoreAuth, a session that passed the checks
SessionRestoring --> SignedOut
SessionRestoring --> SignedIn
SignedOut --> SignedIn: signIn, adopt
SignedIn --> SignedIn: renew (same user, new credential)
SignedIn --> SignedIn: stepUp (same user, proved again)
SignedIn --> SignedOut: signOut (user)
SignedIn --> SignedOut: the server refused to renew (expired)
SignedIn --> SignedOut: the server ended it (revoked)
SignedIn --> SignedOut: the device key is gone (keyLost)SignedOut carries a SignOutReason: expired (the server refused to renew the session: the sign-in page can say "your session expired"), user (a sign-out), keyLost (a session bound to a device key that is gone, see below) or revoked (since 0.21.0: the server says this device or session is dead). The first start has no reason.
AuthUser, AuthSession and PasswordSignIn print without a token, an id, an e-mail or a password (AuthUser(roles: {admin})), and a SessionCredential's toString never shows a secret, so a log line is safe. unverifiedJwtClaims(token) (in fespalier_oidc) reads a JWT's payload for display and routing; it does not verify it, and the server still decides.
Restoring at startup
// lib/app/startup.dart
FutureOr<List<Override>> startup() => restoreAuth(authSetup());
// lib/auth_setup.dart
final transport = HttpClientTransport(http.Client()); // fespalier_http; or DioTransport(dio) from fespalier_dio
AuthConfig authSetup() => AuthConfig(
backend: ApiBackend(Uri.parse('https://api.example.com'), transport), // an AuthBackend
transport: transport, // what requests (and the backend's own calls) go through
apiOrigins: [Uri.parse('https://api.example.com')], // where the session is sent: nothing else
);restoreAuth returns the overrides for the app's ProviderScope (the generated main() calls startup() and passes them), so every guard is synchronous from the first navigation: a cold deep link to a signed-in page has no blank frame and no redirect.
- It is synchronous when the store answers synchronously (
MemoryTokenStore, a backend that keeps its own session), and one keychain read otherwise (SecureTokenStore, the default), shown behindsplash.dart, or the native splash when there is none. - It does no network: an expired credential is renewed by the first request that needs it, so an offline start works.
It drops what it cannot trust, without a request:
| Stored session | Result |
|---|---|
| none | SignedOut() |
another backend's (AuthBackend.name differs) | deleted, SignedOut() |
the credential cannot be renewed any more (renewableUntil has passed, or it expired and is not renewable) | deleted, SignedOut(reason: SignOutReason.expired) |
the backend's check refuses it: the device key a credential is bound to is gone, or is another | deleted, SignedOut(reason: SignOutReason.keyLost) |
written by 0.20.0 or earlier ("v": 1), or corrupt | reported (FlutterError.reportError, context while restoring the stored session), deleted, SignedOut(): the user signs in once more |
A store that cannot be read at all (a locked keychain) makes startup() fail, which the generated main() shows with a retry. AuthConfig has backend, transport, store and apiOrigins; when a credential counts as expired is the backend's (AuthBackend.renewAhead: 30 seconds before expiry for OidcBackend, which is what AuthConfig.leeway was until 0.20.0). Install the telemetry sink first in startup() to see the restore as a span.
Where the session is stored. A stored session is AuthSession.toJson ({"v": 2, "backend", "credential", "user"}) under one key (fespalier_auth.session): on Apple in the Keychain with first_unlock_this_device (not in backups, not on another device); on Android in encrypted storage; on the web in encrypted localStorage, which a script on the page can read, so use MemoryTokenStore there (a reload signs out) for anything that matters.
An app with no startup.dart still works: add authConfig.overrideWithValue(config) to the ProviderScope, and the session restores itself at the first read. The state is SessionRestoring meanwhile, and the guard helpers return a Future for that long, which costs a frame.
Guarding signed-in routes
// lib/app/(signed-in)/guard.dart: every route under the group needs a session
GuardResult guard(Ref ref, {required Uri uri}) =>
requireSignedIn(ref, uri, signIn: (from) => SignInRoute(from: from));
// lib/app/(signed-in)/admin/guard.dart: and this one an admin
GuardResult guard(Ref ref, {required Uri uri}) => requireRole(
ref, uri, 'admin',
signIn: (from) => SignInRoute(from: from),
forbidden: const ForbiddenRoute(),
);
// lib/app/sign-in/guard.dart: the sign-in page's own guard, beside the group
GuardResult guard(Ref ref, {String? from}) => redirectIfSignedIn(ref, from: from);requireSignedIn(ref, uri, signIn:)returnsnullwhen signed in, elsesignIn(uri.toString()).location: a typed call the compiler checks, with the requested location (query included) asfrom. It watches only the session's phase, so it runs again on sign-out (the user is moved in that frame, from a pushed page too since 0.21.0: Guards) and never on a renewal. It answers synchronously, unless the session is still being restored.requireRole(ref, uri, role, signIn:, forbidden:)isrequireSignedIn, thenforbiddenwhen the user lacks the role.requireUser(ref, uri, test, ...)takes a function of theAuthUserinstead. They watch only their own answer, so a role gained or lost runs the guard again, and a renewal does not.redirectIfSignedIn(ref, from:)is for the sign-in route's ownguard.dart: when there is a session it returnsreturnTo(from). It watches the session, so signing in on that page sends the user back tofromby itself, and the page has no navigation code.returnTorefuses//host,https://…and/\, so a crafted?from=cannot send the user elsewhere.- The sign-in page sits beside the guarded group, never inside it: a guard on the sign-in page would send the user to the sign-in page.
- A guard under a pushed page reacts only when you pop back to it, but the pushed page's own guard watches the session while the page is on top and moves it (since 0.21.0, Guards). A sign-out button over a pushed page that has no guard of its own should navigate too:
const HomeRoute().go(context). requireFreshAuth(ref, uri, maxAge:, signIn:, reauthenticate:)(since 0.25.0) isrequireSignedIn, then the re-proof page when the user last authenticated more thanmaxAgeago, andredirectIfFreshAuthis that page's own guard: Step-up authentication.- A guard is not access control: the server decides what a session may do.
Signing in and out
The sign-in page is a form on an action (fespalier_forms, which the app's pubspec.yaml needs for it), and the backend throws FieldErrors for wrong credentials, which the form shows under its field:
// lib/app/sign-in/action.dart
typedef SignInFields = ({String username, String password});
SignInFields form() => (username: '', password: '');
FieldErrors? validate(SignInFields input) => FieldErrors({
if (input.username.trim().isEmpty) 'username': 'Enter your user name',
if (input.password.isEmpty) 'password': 'Enter your password',
});
Future<void> action(Ref ref, {required SignInFields input}) async {
await ref
.read(authSession.notifier)
.signIn(PasswordSignIn(username: input.username.trim(), password: input.password));
}signIn(request) asks the backend, stores the session, then sets SignedIn, and rethrows what the backend threw with the state unchanged: AuthCancelled (the user closed a browser sign-in), FieldErrors (wrong credentials), AuthRejected (the server refused), or any other error (it could not ask). A sign-in overtaken by another one, or by a sign-out, throws NotSignedIn and changes nothing. The request is typed: PasswordSignIn and BrowserSignIn (from fespalier_oidc), a CompleteEnrolment (fespalier_sign_keypair), or a subclass of SignInRequest of your own, so a page is the same whichever backend is configured, and a test swaps in FakeAuthBackend. adopt(session) signs in with a session the app obtained itself (a deep-link callback, a multi-step SDK flow), and throws an ArgumentError when it comes from another backend than the configured one.
signOut() sets SignedOut(reason: SignOutReason.user) synchronously, so the guards move the user in that frame (from a pushed page too since 0.21.0), then clears the store, runs the backend's signOut (best effort: its errors are swallowed) and discard, which forgets what was bound to the session (a DPoP key and its nonces, a device's keys). It does nothing when nobody is signed in. endSession(reason) (since 0.21.0) is the same without the server call: an app whose own API says the device is revoked ends the session with SignOutReason.revoked; the transport does it itself when an authenticator says so.
Calling your API
// lib/app/(signed-in)/orders/data.dart
Future<List<Order>> data(Ref ref) async {
ref.watch(authUserId); // another user: load again; a renewal: nothing
final client = TransportClient(ref.watch(authTransport)); // fespalier_http: an http.Client over the session
final response = await client.get(Uri.parse('https://api.example.com/orders'));
if (response.statusCode != 200) throw ApiError(response.statusCode);
return Order.listFromJson(response.body);
}authTransport is a SessionTransport over AuthConfig.transport (override authConfig in a test with a FakeTransport). A request to an origin in AuthConfig.apiOrigins carries what the backend's RequestAuthenticator adds (Authorization: Bearer <token> for fespalier_oidc); any other origin gets nothing, so a credential cannot leak to a third party, and apiOrigins empty is an error the first time authorizer is read. A client that is not a transport sits on top of it: TransportClient(authTransport) is an http.Client, TransportAdapter(authTransport) a Dio adapter (HTTP transport).
sequenceDiagram
participant P as data.dart
participant ST as SessionTransport
participant AZ as Authorizer
participant S as authSession
participant B as AuthBackend
participant RA as RequestAuthenticator
participant T as inner transport
participant API as Your API
P->>ST: send(request)
ST->>AZ: authorize(request)
AZ->>S: credential()
alt the credential is within renewAhead of its expiry
S->>B: renew(session), one at a time, shared
B-->>S: new credential, stored before it is published
end
AZ->>RA: headers(session, request), with the exact body bytes
RA-->>AZ: Authorization, a signature, a proof
ST->>T: send(request + headers)
T->>API: request
API-->>T: 401
T-->>ST: response (a 401 or 403 body is read, 64 KiB at most)
ST->>AZ: retry(attempt, response)
AZ->>RA: judge(session, request, 401) = Renew
AZ->>S: credential(rejected: the one that was refused)
S->>B: renew(session), or the one a concurrent request already renewed
ST->>AZ: authorize(request, previous: attempt)
AZ->>RA: headers(session, request), a fresh signature
ST->>T: send(request + headers), marked fespalier.auth.replay
T->>API: request
API-->>T: 200
T-->>ST: response
ST-->>P: response- Renewal is lazy and single-flight, with no timer.
ref.read(authSession.notifier).credential()returns the credential synchronously while it is good, and starts one shared renewal when it has expired (expiresAtlessrenewAhead, againstclock.now()), after a verdict that saysRenew(a 401, for a bearer token) to the credential in use, or when forced. Any number of requests that find it expired wait for the sameFuture: with refresh-token rotation (Keycloak's "Revoke Refresh Token"), a second renewal with the same token would sign the user out. The new credential is stored before the state publishes it, so a kill between the two leaves the store on the one the server accepts. - A renewal the server refuses (
AuthRejected, an OAuthinvalid_grant) signs the user out withexpiredand clears the store; a backend that throwsAuthEnded(reason)gives another reason (revoked). One that could not run (offline, a 5xx:AuthUnavailable, aTransportFailure) keeps the session, and the next request asks again. - What a response means is the authenticator's to say.
RequestAuthenticator.judgeanswers anAuthVerdict:Accept(the response stands: anything ambiguous is this, so a session is never wiped and a request never loops on a response about something else),Resend(same session, new headers: a DPoP nonce, a clock correction),Renew(renew, then send once more),StepUp(challenge)(since 0.25.0: the user proves who they are again, then send once more; Step-up authentication) orSessionEnded(reason)(sign out and fail the request withAuthRejected). - At most four sends per request: the first, one after a
Resend, one after aRenewand, since 0.25.0, one after aStepUp(three before). Every send is authorized again with the exact bytes of the body, so a signature is fresh each time. Only a request that can be sent again (TransportRequest.replayable: its body is bytes) is: a streamed body is sent once, and its caller gets the 401, after the renewal, so its next attempt works. A second 401 is returned as it is. - For a client of your own,
ref.watch(authorizer)is anHttpCredentials(fespalier_transport):authorize(request, previous:)returns the headers, andretry(attempt, response)says whether to send again. A download or a plugin that is not a transport takes it as it is. - One logical request, however many sends. A retrier above the session (
dio_smart_retry) calls it again for the same request, and the request'sTransportMemocarries what the session settled the first time: a request the session refused is refused again with the sameAuthRejected, with no renewal and no send, so a retrier that retries every error cannot send it out unauthenticated; a request authorized for one user is never sent again as another (or with nobody signed in); a request renews at most once, however many sends it takes. A client that keeps no memo makes every send a first one:TransportAdapterandTransportClientkeep one (The memo says where it breaks,RetryClientfor one). - A replay is marked, and keeps its abort trigger. A request sent again carries
fespalier.auth.replay: trueinTransportRequest.attributes(transportReplayKey), so a guard against re-sent writes (Dio'sWriteGuard) lets that one through; the first send is not a replay. TheabortTriggeris the request's, so aborting still cancels the replay when the page that wanted it goes away. - Where the session sits in the chain.
SessionTransport(WriteGuardTransport(HttpClientTransport(client)), authorizer): the write guard next to the network. A retrier goes above the session, never under it: under a session that signs requests it would re-send the same signature (with DPoP, the same proof, and the server refuses a reusedjti). Decorators and their order has the whole order. - One container. The single flight is per
ProviderContainer: a second isolate or a second web tab that renews the same rotating token getsinvalid_grant. Renew in one place, or do not turn rotation on. - User data across users. A
dataCachekeeps the previous user's data until it loads again: watchauthUserIdin user-owned data and clear the cache storage on sign-out.
Step-up authentication
Since 0.25.0. An API may accept the session and still refuse one request because the user's sign-in is too weak or too old for it: a payout, a change of e-mail address. RFC 9470 says so with a 401 and a challenge:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
error_description="A different authentication level is required",
acr_values="gold", max_age=300fespalier_oidc's BearerAuthenticator reads that as a StepUp verdict with a StepUpChallenge (acrValues, maxAge; a DPoP challenge counts too), and every other 401 as before, a Renew. The session then:
- asks the user once.
AuthSessionNotifier.stepUp(challenge)runs the backend'sAuthBackend.stepUp;OidcBackendopens the browser sign-in again for the same user, withacr_valuesandmax_agewhen the challenge names them andprompt=loginwhen it names neither, andlogin_hintset to the user's e-mail address. One at a time: every request refused while it runs waits for the same one, and a refusal that arrives after another request stepped up is sent again with the new credential without asking a second time; - stores the new session (new tokens, and
authenticatedAtfrom the new ID token'sauth_time) and publishes it, as a renewal does; - sends the request again, once. A request steps up at most once, kept in its
TransportMemolike its renewal: a second step-up refusal is returned as it is, never another prompt, and a retrier above the session that sends it again gets the refusal too.
A step-up is not a renewal. It asks the user, and when the user cancels (AuthCancelled) or the provider refuses (AuthRejected, an unmet_authentication_requirements), the session stays as it was and the caller gets the 401: nobody is signed out because they declined to prove themselves again. A step-up that could not run is an AuthUnavailable, as for a renewal, and one that comes back signed in as another user is refused (AuthRejected('step_up_user_mismatch')) without taking that session. A request can still renew once and step up once (an expired token, then a weak sign-in), so at most four sends.
Never from work the user is not looking at. A sync, a queued intent or a background refresh that hits a step-up would open the browser by itself. Mark those requests so the 401 stands:
TransportRequest(method: 'POST', uri: uri, body: bytes)
.copyWith(attributes: {Authorizer.stepUpAttribute: false});
// under Dio: Options(extra: {Authorizer.stepUpAttribute: false})
// a client that cannot set attributes (a generated CrateStack call), since 0.25.0:
await Authorizer.unattended(() => api.payOrder(args));Authorizer.unattended(body) runs body in a zone where every request it sends is treated as stepUpAttribute: false, as TransportZone carries an abort trigger for a client that takes none; Authorizer.attended(request) is the test both read, for an authenticator that steps up by itself.
Routes that need a recent sign-in. The session carries AuthSession.authenticatedAt: an OpenID Connect auth_time claim when the ID token has one, else the time of the sign-in or of the last step-up (clock.now()); a renewal keeps it. requireFreshAuth sends the user to the app's re-proof page before the route opens, and redirectIfFreshAuth on that page sends them back, as redirectIfSignedIn does for the sign-in page:
// lib/app/(signed-in)/payouts/guard.dart
GuardResult guard(Ref ref, {required Uri uri}) => requireFreshAuth(
ref, uri,
maxAge: const Duration(minutes: 5),
signIn: (from) => SignInRoute(from: from),
reauthenticate: (from) => ReauthRoute(from: from),
);
// lib/app/(signed-in)/reauth/guard.dart
GuardResult guard(Ref ref, {String? from}) =>
redirectIfFreshAuth(ref, maxAge: const Duration(minutes: 5), from: from);
// lib/app/(signed-in)/reauth/page.dart: the page's button (a ConsumerWidget)
FilledButton(
onPressed: () => ref
.read(authSession.notifier)
.stepUp(const StepUpChallenge(maxAge: Duration(minutes: 5))),
child: const Text('Confirm it is you'),
)- The page's button awaits
stepUp, which throwsAuthCancelledwhen the user closes the browser andAuthRejectedwhen the provider refuses: show a message for those, and the guard does the rest. - The age is read when the guard runs, from
clock.now(): when the route opens, and when the session's authentication time changes (a step-up). There is no timer, so a page open for longer thanmaxAgestays open; what it sends from there is the API's to refuse, with a step-up challenge. - An unknown time is too old. A session stored before 0.25.0 has no
authenticatedAt(the stored format is the same version 2, with one more optional key, so an older app still reads a newer session and the other way round); its first visit to a fresh-only route asks for a re-proof. So does a backend that keeps its own session and says nothing. - A clock set back is not trusted: an authentication time in the future is not fresh.
- Device sessions (
fespalier_sign_keypair) step up per request, since 0.25.0: there is no browser to open and no session-wide re-proof (DeviceAuthBackendhas nostepUp), soDeviceSigneranswers a step-up refusal by signing that request once more with the user-present key, which prompts (below). The same rules hold: once per request, never for work that may not ask, and a cancelled prompt leaves the 401 as the answer with the session kept. - A declined proof on a resend. An authenticator that needs the user for a send the session makes again throws a
ProofDeclined(since 0.25.0;UserPresenceRefusedis one) when the user says no, andSessionTransportthen hands back the response that asked for the resend (a 401 or a 403 it read) instead of the error. On a first send it fails the request, as before. - CrateStack's typed
StepUpRequired(cratestack/cratestack#1244) is the same refusal as the generated client reads it: the session acts on the 401'sWWW-Authenticatebefore the client sees it, and only a refusal that stands (a second one, a cancelled prompt, unattended work) reaches the call as aCratestackRpcExceptionorCratestackRestExceptionwhosestepUpis set. CrateStack's challenge isBearerwhatever the provider;StepUpChallenge.fromResponse(..., schemes: null)reads one of any scheme, which is whatDeviceSignerdoes. - UNCHECKED: a real browser re-authorization with
acr_valuesandmax_ageagainst Keycloak or Authkestra (the tests run a fake provider). Keycloak's step-up needs its authentication flows configured with levels of authentication.
OpenID Connect and Keycloak
package:fespalier_oidc (a separate package since 0.21.0; it was package:fespalier_auth/oidc.dart from 0.9.0 to 0.20.0: an app that signs in some other way links none of it) has OidcBackend: the authorization code flow with PKCE (S256) for a public client, in pure Dart over an HttpTransport, with Keycloak's defaults. It owns the token exchange and the renewal, which is what makes the single flight, refresh-token rotation and DPoP on the token endpoint possible. Its BearerAuthenticator sends Authorization: Bearer <token> and answers a 401 with Renew (a step-up refusal with StepUp since 0.25.0, Step-up authentication). The browser step is a function you give it, so the package links no plugin: flutter_web_auth_2 (MIT; Android, iOS, macOS, web, Windows and Linux) is the usual one.
// lib/auth_setup.dart
final issuer = Uri.parse('https://sso.example.com/realms/shop');
final transport = HttpClientTransport(http.Client());
AuthConfig authSetup() => AuthConfig(
transport: transport,
backend: OidcBackend(
issuer: issuer,
clientId: 'shop-app',
redirectUri: Uri.parse('com.example.shop:/callback'),
endpoints: OidcEndpoints.keycloak(issuer), // no discovery request
openBrowser: openBrowser,
transport: transport, // the token, discovery and revocation calls; no session on them
),
apiOrigins: [Uri.parse('https://api.example.com')],
);
Future<Uri> openBrowser(Uri url, Uri redirect) async {
try {
return Uri.parse(
await FlutterWebAuth2.authenticate(
url: url.toString(),
callbackUrlScheme: redirect.scheme,
options: const FlutterWebAuth2Options(preferEphemeral: true),
),
);
} on PlatformException catch (e) {
if (e.code == 'CANCELED') throw const AuthCancelled();
rethrow;
}
}The backend's transport: is the app's transport without the session: the token endpoint is not your API, and a session on the call that renews the session would wait for itself. Pass the same transport as AuthConfig.transport.
The sign-in button calls ref.read(authSession.notifier).signIn(const BrowserSignIn()) straight from its onPressed (a web popup is blocked when signIn runs later), and catches AuthCancelled. The sign-in guard moves the user. BrowserSignIn has loginHint, prompt (login shows the form over a single-sign-on cookie, none fails with login_required) and parameters ({'kc_idp_hint': 'google'}). The session holds a TokenCredential (access, refresh and ID token, expiresAt, renewableUntil, jkt); OidcBackend(renewAhead:) is how early before expiresAt a token is renewed (30 seconds by default).
- What it checks. The redirect: its
state, itserror(access_deniedisAuthCancelled, anything else anOidcException), itsisswhen present (RFC 9207, which Keycloak sends) and itscode. Then the ID token'siss,audandnonce. The ID token's signature is not verified (OpenID Connect Core 3.1.3.7: it came straight from the token endpoint over TLS). Every message is in the troubleshooting skill. - Roles come from the access token:
realm_access.rolesandresource_access.<clientId>.roles(keycloakRoles(clientId), the default; passroles:to read another claim). Keycloak does not put them in the ID token unless a mapper does.userFromClaimsbuilds theAuthUser. - Discovery.
OidcEndpoints.keycloak(issuer)spells Keycloak's four endpoints, andOidcEndpoints.discover(issuer)reads<issuer>/.well-known/openid-configurationand refuses a document whoseissueris another one. Leaveendpoints:out and discovery runs at the first sign-in or renewal, once. - Sign-out revokes the refresh token (RFC 7009), which makes Keycloak end the whole session, best effort.
endBrowserSession(session)also ends the browser's single-sign-on session through the end-session endpoint; withpreferEphemeral: truethere is none to end. - No timeout of its own (that would be a timer): give it a transport over a client that times out if you want one. A confidential client (a
client_secret) is out of scope: a mobile or web app is a public client.
Keycloak settings and traps, read from a live Keycloak 26.8.0 (examples/auth/keycloak/realm-fespalier.json is a realm exported from it):
- A public client with Standard flow on, Direct access grants off, and the client attribute
pkce.code.challenge.methodset toS256(without a challenge the authorization endpoint answerserror=invalid_request&error_description=Missing+parameter%3A+code_challenge_method). Its valid redirect URIs must listredirectUriexactly. - "Revoke Refresh Token" makes a refresh token good once: a second renewal with the same one answers
invalid_grantwithMaximum allowed refresh token reuse exceeded, and the whole session is then gone. That is why the renewal is a single flight, and why two isolates or two web tabs that share a session sign each other out. - The refresh token lives as long as the SSO session's idle timeout (30 minutes by default; the token response's
refresh_expires_insays): a user who is away longer getsinvalid_grantwithToken is not active. For longer sessions ask for theoffline_accessscope (OidcBackend(scopes: ['openid', 'profile', 'email', 'offline_access'])). - The issuer is Keycloak's configured hostname. With
--hostname=http://10.0.2.2:8080every token sayshttp://10.0.2.2:8080/realms/..., so an app that reaches the same Keycloak aslocalhostgetsthe ID token was issued by ..., not .... On an Android emulator, useadb reverse tcp:8080 tcp:8080andlocalhost, or give Keycloak the10.0.2.2hostname. - A single-sign-on cookie signs the user in again silently after a sign-out, unless the browser session is ephemeral (
preferEphemeral: true) or the sign-in asksBrowserSignIn(prompt: 'login').
Device-bound tokens: DPoP with fespalier_keypair_dpop
package:fespalier_keypair_dpop (a separate package; it was package:fespalier_sign_keypair from 0.9.0 to 0.20.0) is DPoP (RFC 9449) for OidcBackend: every token request, renewal and API call carries a proof, a JWT signed by a key that lives in the Secure Enclave (iOS, macOS), the AndroidKeyStore (StrongBox or the TEE) or, on the web, a non-extractable CryptoKey. The keys come from fespalier_keypair (through flutter-sign-keypair). The server binds the access and refresh tokens to that key (cnf.jkt), so a token copied off the device is useless without the device. DpopProof implements fespalier_oidc's ProofOfPossession, so nothing else in the app changes:
// lib/auth_setup.dart
AuthConfig authSetup() => AuthConfig(
transport: transport,
backend: OidcBackend(
issuer: issuer,
clientId: 'shop-app',
redirectUri: Uri.parse('com.example.shop:/callback'),
endpoints: OidcEndpoints.keycloak(issuer),
openBrowser: openBrowser,
transport: transport,
proof: DpopProof.device(), // throws DpopUnavailable on Windows and Linux unless a fallback is given
),
apiOrigins: [Uri.parse('https://api.example.com')],
);Install fespalier_keypair and fespalier_keypair_dpop next to fespalier, fespalier_auth and fespalier_oidc, with the same url and ref for all (the READMEs have the block). They need Dart 3.12 and Flutter 3.44, Android minSdk 24, iOS 15 and macOS 10.15, and depend on flutter-sign-keypair by git, pinned to a commit (the repository is not on pub.dev), so flutter pub get clones github.com/vaam-apps/flutter-sign-keypair.
- What is sent. A proof is
{typ: dpop+jwt, alg: ES256, jwk}and{jti, htm, htu, iat, ath?, nonce?}, ES256 signed:jtiis new for every send (retries included),htuhas no query or fragment,athis only on requests that carry an access token, and the authorization request carriesdpop_jkt, so the code is bound to the key too. One signature per request, made by the secure element; nothing is cached (a proof is single-use), and no timer or listener is started. The API call carriesAuthorization: DPoP <token>and the proof. - Nonces and the clock. A
DPoP-Noncefrom any response is kept per origin, and ause_dpop_noncechallenge (an authorization server's400, a resource server's401) is answered once: the authenticator's verdict isResend. When a server refuses a proof as not active and itsDateheader says the device clock is more than 5 seconds off, the difference is applied to every lateriatand the request is sent once more. Keycloak sends neither a nonce nor aDate, so a wrong clock there isinvalid_request/DPoP proof is not active: set the clock. - The key. An ambient key (it never prompts), made on first use under the key id
fespalier_dpop. Sign-out deletes it (rotateKeyOnSignOut, throughdiscard), so the next sign-in makes a new one and an old refresh token, even a stolen one, is useless.restoreAuthsigns the user out (SignedOut(reason: SignOutReason.keyLost)) when the key a stored session is bound to is gone: a restored backup, a wiped keychain. - The web (since 0.21.0) uses the browser's non-extractable key, kept in IndexedDB, so the session survives a reload; until 0.20.0
DpopProof.device()refused on the web by default. It is not a secure element, and it is UNCHECKED in CI: it was run by hand in Chrome only (fespalier_keypair's README says what was and was not tried). - Where there is no key at all (Windows, Linux, Fuchsia, a web page that is not a secure context)
DpopFallback.refuse, the default, throwsDpopUnavailable: a library that promises tokens bound to a device must not quietly give you tokens bound to nothing.DpopFallback.softwareis a key in memory (its scalar is in the process; pass asoftwareStoreto keep it across restarts), andDpopFallback.beareris no DPoP (device()returns null; the client must not require DPoP-bound tokens).requireHardware: truemakes a device without a secure element fail instead (the iOS simulator has only the keychain, and the web key is not one). - Keycloak supports DPoP since 26.4. Switch on Require DPoP bound tokens on the client (the attribute
dpop.bound.access.tokens;examples/auth/keycloak/has a realm with such a client,fespalier-auth-example-dpop). Read from Keycloak 26.8.0: errors areinvalid_requestwith descriptions (DPoP proof is missing,DPoP proof is not active,DPoP proof has already been used), a refresh token bound to another key isinvalid_grant/DPoP confirmation doesn't match DPoP proof, and a resource server's 401 isWWW-Authenticate: DPoP ... error="invalid_token"for every problem with the proof. - A retrier goes over the session transport, never under it (see Calling your API).
- Tests.
package:fespalier_keypair/testing.darthasFakeKeypairSigner(a software key from a fixed scalar: the same key and signature on every run) andpackage:fespalier_keypair_dpop/verify.darthasverifyDpopProof, which a fake server checks every proof with and which names the first check that failed.examples/authruns a DPoP-checking demo server.
Device-signed sessions: fespalier_sign_keypair
package:fespalier_sign_keypair (since 0.21.0; it held DPoP until 0.20.0) is a session with no token at all: no OIDC provider, no access or refresh token, no DPoP, no Authorization header. "Signed in" means this device's key is enrolled and holds a valid attestation, and every request then carries its own signature over its method, path, query and exact body bytes. It plugs into the same core as an OIDC app: the same authSession states, guards, restore, keyLost and sign-out. It depends on fespalier_auth and fespalier_keypair, and on no HTTP client; it talks to your server through the DeviceAuthServer you write. Its README has the full guide; this section is the map, and examples/device is the device-sessions example (an enrolment you can run).
// lib/auth_setup.dart
final backend = DeviceAuthBackend(
server: ShopDeviceServer(transport), // your endpoints: a DeviceAuthServer
keys: DeviceKeys.device(), // ambient + user-present where the device has it
profile: const DeviceSignatureProfile(audience: 'https://api.example.com'),
);
AuthConfig authSetup() => AuthConfig(
backend: backend,
transport: transport, // the enrolment and renewal legs go through it: no session on it
apiOrigins: [Uri.parse('https://api.example.com')],
);- Enrolling the device is signing in. The device holds two P-256 keys: an ambient key that never prompts and signs ordinary requests, and, where the platform has a secure element, a user-present key (a biometric or the device credential) for calls worth a prompt. A phone screen calls
backend.beginEnrolment(fields: {'phone': phone}), a code screen finishes withref.read(authSession.notifier).signIn(CompleteEnrolment(challenge, proof: code)); both legs are unsigned. The server answers with an attestation per key: a JWS from your issuer (iss,sub,did,iat,exp,cnf.jkt, a status) that binds the key and means nothing without a fresh signature from it. - Every request carries
X-Attestation(the stored attestation) andX-Signature(a compact JWS overiat,exp, a freshjti,mth,pth,qshandbdh, the hash of the exact body bytes).SessionTransporthands the signer the final bytes, so JSON, CBOR and multipart are covered alike; a streamed body cannot be signed and is refused with anArgumentError. - Renewal is a re-attestation, not a refresh token: a silent proof of possession (
beginRenewal, sign the challenge,confirmRenewal), lazy and single-flight, when the ambient attestation is withinrenewAhead(12 hours) of its expiry or a server refuses it. - A refusal is classified, not guessed.
webankRefusals(replace it withclassify:) reads the status and theerrorof the body:attestation_expiredanddevice_not_activerenew and send once more;key_not_bound,bad_jwkandmissing_credentialend the session askeyLost(both keys are deleted); a 403DEVICE_REVOKEDends it asrevoked. Anything else is ambiguous and never wipes the session or loops. - Key roles per request. A request asks for the user-present key through its attributes (
DeviceSigner.roleAttribute: KeyRole.userPresent,DeviceSigner.reasonAttribute: 'Confirm the transfer', or apolicy:of your own, aKeyRolePolicy). A refused prompt is aUserPresenceRefusedthat fails that one request and nothing else. Background and queued work never asks. - Step-up, when the server asks (since 0.25.0). A server can keep the rule itself: a 401 with
WWW-Authenticate: ... error="insufficient_user_authentication"(RFC 9470; any scheme, since CrateStack's@authnchallenges withBearerwhatever the provider; or thaterrorin a JSON body) makesDeviceSignersign that request once more with the user-present key and send it again: the same bytes, a freshjti, one prompt captioned by the request'sreasonAttributeorstepUpReason:. Never twice for one request (it is kept in the request'sTransportMemo), never for a request already signed with that key, never on a device with no user-present key, and never for a request that may not ask (Authorizer.stepUpAttribute: false, or sent insideAuthorizer.unattended), so background and queued work still never prompts. A cancelled prompt leaves the server's 401 as the answer (UserPresenceRefusedis aProofDeclined) and the session as it was. Why per request: a user-present proof covers the one signature it made, so there is nothing for a session-widestepUpto keep (fespalier#319's open question 1). An app may still sign a call with the user-present key from the start (roleAttribute), which saves the refused round trip. - Sign-out revokes the device, best effort, signed with the user-present key when there is one (it prompts), and then deletes both keys and the stored session whatever the revoke did. A restore signs the user out as
keyLostwhen the ambient key is gone or is not the one the stored attestation is bound to. - The enrolment and renewal calls are unsigned by name, not by origin: send them through the transport you gave
AuthConfig.transport, or list their paths inunsignedPaths:when they go throughauthTransport. - A server and tests.
package:fespalier_sign_keypair/verify.darthasverifyDeviceSignaturefor a demo server (it checks the attestation,cnf.jkt, the signature, freshness, the claims and ajtithat was not used before; the failure'scodeis the one the client classifier reads), andpackage:fespalier_sign_keypair/testing.darthasFakeDeviceAuthServernext toFakeKeypairSigner.
sequenceDiagram
participant P as Pages
participant B as DeviceAuthBackend
participant K as Device keys
participant S as Your DeviceAuthServer
participant API as Your API
P->>B: beginEnrolment(fields: phone)
B->>K: publicJwk() of each role
B->>S: beginEnrolment(public keys, fields) [unsigned]
S-->>P: a challenge per key, a one-time code is sent
P->>B: signIn(CompleteEnrolment(challenge, proof: code))
B->>K: sign the challenges (the user-present key prompts)
B->>S: confirmEnrolment(signatures, code) [unsigned]
S-->>B: deviceId, user, an attestation per key
B-->>P: SignedIn
P->>API: a request through authTransport
Note over B,API: X-Attestation + X-Signature over method, path, query and body
API-->>P: 401 attestation_expired
B->>S: beginRenewal, sign, confirmRenewal
S-->>B: a new attestation
P->>API: the request again, signed again
P->>API: a request the API wants confirmed, signed by the ambient key
API-->>P: 401 WWW-Authenticate error="insufficient_user_authentication"
B->>K: sign the same request with the user-present key (it prompts)
P->>API: the request again, user-present
API-->>P: 200, or the 401 stands when the prompt is cancelledstateDiagram-v2
[*] --> SignedOut
SignedOut --> SignedIn: enrolment (two unsigned legs)
SignedIn --> SignedIn: renewal (attestation within 12 h of expiry, or refused)
SignedIn --> SignedOut_keyLost: 401 key_not_bound, bad_jwk, missing_credential
SignedIn --> SignedOut_revoked: 403 DEVICE_REVOKED
SignedIn --> SignedOut_user: sign-out (revoke, then both keys deleted)
SignedOut_keyLost --> SignedOut: restore sees the ambient key goneUnchecked on a device. No CI job runs the secure-element parts; the tests use FakeKeypairSigner. The user-present prompt (the Secure Enclave and StrongBox/TEE biometric or device-credential prompt, a cancelled prompt, a device with no screen lock), biometric invalidation (it destroys the user-present key and leaves the ambient one), the web key across a reload and in Safari and Firefox, hardware-backed keys on real devices, and a real backend that issues attestations and enforces the profile are all untried: the package README lists them.
Firebase, Supabase and your own API
Firebase's and Supabase's SDKs keep the session and renew it themselves, so their backends say keepsOwnSession: the token store is not used, currentSession() is read once at start-up (a one-shot read of the SDK, so the framework holds no listener), and renew asks the SDK for a new credential. They are recipes, not packages: a first-party package for each would add a heavy SDK to every app and a release surface. The code is in skills/fespalier-guards/references/auth-backends.md:
- Firebase (
firebase_auth, no Linux):signIn(PasswordSignIn)issignInWithEmailAndPassword, whosewrong-password,invalid-credentialanduser-not-foundbecomeFieldErrors;renewisgetIdTokenResult(true), anduser-disabledoruser-token-expiredbecomeAuthRejected. Initialise Firebase instartup()beforerestoreAuth. - Supabase (
supabase_flutter): its client auto-refreshes with a timer by default, so initialise it withauthOptions: FlutterAuthClientOptions(autoRefreshToken: false)and renew lazily withrefreshSession(). Import it with a prefix:gotrueexportsAuthState,SessionandUser. - Your own API (a username and a password):
examples/auth/lib/demo/demo_backend.dart. A wrong password is aFieldErrors, a refused renewal isAuthRejected, and a socket error or a 5xx keeps the session.
Writing a backend
A backend is AuthBackend. A short constant name (oidc, device, firebase, api: what a stored session is tied to, and telemetry's fespalier.auth.backend) and scheme (bearer, dpop, device: telemetry's fespalier.auth.scheme, the backend's name by default); signIn(request); renew(session, {hint}), which throws AuthRejected when the server refused (the session is over; AuthEnded(reason) gives another reason) and anything else when it could not ask (the session stays); renewAhead, how long before SessionCredential.expiresAt the credential is renewed; readCredential(json), which reads back what SessionCredential.toJson wrote and throws a FormatException on anything else; check(session), run at restore after the expiry rule (null keeps the session, a SignOutReason drops it); signOut(session), best effort on the server; discard(session), which forgets local secrets (keys, nonces); and, since 0.25.0, stepUp(session, challenge), the same user proved again (Step-up authentication: AuthCancelled, AuthRejected or another error, the session stays in every case; the default throws UnsupportedError). A session it makes may carry authenticatedAt (when the user authenticated); without it the core stamps the time of the sign-in or step-up.
Its credential is a SessionCredential (expiresAt, renewableUntil, renewable, toJson) and what it adds to each request is its RequestAuthenticator: headers(session, request) returns the headers for one send (it is called again for every send, with the exact body bytes in request.body, and may throw to refuse the send), and judge(session, request, statusCode:, headers:, body:, retried:) returns an AuthVerdict. The starter in skills/fespalier-guards/references/auth-package.md is a complete one over a JSON API: a POST /auth/login and /auth/renew. The core knows nothing else about the scheme: no token, no header name, no key.
Testing signed-in routes
package:fespalier_auth/testing.dart has fakeAuth(...), the overrides for pumpRouter: signed in as the AuthUser you give, or signed out, on a FakeAuthBackend (a FakeCredential, no token) and a MemoryTokenStore, with no startup() and no network:
testWidgets('a member sees the orders', (tester) async {
await pumpRouter(
tester,
AppRoutes.router(initialLocation: '/orders'),
overrides: fakeAuth(signedInAs: const AuthUser(id: 'ada', roles: {'admin'})),
);
expect(currentLocation(tester), '/orders');
});Call it inside the test body, where the fake clock starts. Pass backend: to read its counters (signIns, renewals, signOuts, discards, and since 0.25.0 stepUps and stepUpChallenges), to make it fail (signInError, renewError, stepUpError) or to hold a call open (gate, a Completer<void>: a pending state to look at), credentialLifetime: to age the session with tester.pump(const Duration(minutes: 6)), authenticatedAt: (since 0.25.0; clock.now() for a fresh sign-in, none is too old) for a route behind requireFreshAuth, apiOrigins: and transport: (a FakeTransport from package:fespalier_transport/testing.dart) to test an API call. Signed out, a guarded route lands on /sign-in?from=%2Forders. In fsp test, the setup file returns fakeAuth(signedInAs: …) from overrides, so guarded routes render:
// test/routes/setup.dart
List<Override> overrides(String pattern) => fakeAuth(signedInAs: const AuthUser(id: 'ada'));FakeAuthenticator stands in for a scheme that signs (it adds x-fake-credential: <id>#<n>, so every send is visibly signed afresh, and answers StepUp to a 401 that carries a step-up challenge since 0.25.0), FakeProof (package:fespalier_oidc/testing.dart) for a proof of possession, FakeKeypairSigner for a key, and RecordingTelemetry sees the auth spans (#2 start auth renew backend=fake trigger=expired); Telemetry conventions lists their attributes (the step is renew and the scheme fespalier.auth.scheme since 0.21.0; they were refresh and a boolean fespalier.auth.dpop).