| title | OAuth Sign-In |
|---|---|
| description | Build AT Protocol OAuth sign-in with Poptart, DPoP sessions, and generated descriptors. |
Use OAuth for user-facing apps. It keeps passwords out of your UI, lets the
authorization server own the consent flow, and returns an OAuthSession that
Poptart can use for authenticated AT Protocol requests.
The OAuth helpers live in poptart_oauth and are also exported by the umbrella
poptart package.
dart pub add poptartimport 'package:poptart/poptart.dart';OAuth starts with client metadata. In production, host a JSON metadata document
at your client_id URL and pass that URL to getClientMetadata.
final metadata = await getClientMetadata(
'https://example.com/oauth/client-metadata.json',
);
final oauth = OAuthClient(metadata);Your metadata controls redirect URIs, scopes, grant types, and display information. Poptart reads it before creating authorization requests so the authorization server sees the same client identifier and redirect URI that your app expects.
Call authorize when the user taps your sign-in button. The optional argument
is a login hint: usually a handle, DID, or email address entered by the user.
Future<Uri> startSignIn(String handle) async {
final metadata = await getClientMetadata(
'https://example.com/oauth/client-metadata.json',
);
final oauth = OAuthClient(metadata);
final (authorizationUrl, context) = await oauth.authorize(handle);
await secureAuthStore.saveContext(context);
return authorizationUrl;
}Open the returned URL in the system browser or your platform OAuth helper. In a
Flutter app, that is usually a package such as flutter_web_auth_2; in a server
or CLI, it might be a normal browser redirect.
The OAuthContext must survive until the callback is handled. It contains the
PKCE verifier, state, and DPoP nonce. Store it temporarily and treat it as
sensitive.
After the user authorizes the app, the authorization server redirects to one of
your configured redirect URIs. Pass the full callback URL and the saved
OAuthContext to callback.
Future<OAuthSession> completeSignIn(String callbackUrl) async {
final metadata = await getClientMetadata(
'https://example.com/oauth/client-metadata.json',
);
final oauth = OAuthClient(metadata);
final context = await secureAuthStore.loadContext();
final session = await oauth.callback(callbackUrl, context);
await secureAuthStore.saveSession(session);
return session;
}callback validates the state, exchanges the authorization code, creates the
DPoP proof, and returns an OAuthSession.
Pass the returned session to an authenticated client:
import 'package:poptart/poptart.dart';
import 'package:bluesky_poptart/app/bsky/actor/get_profile.dart'
as get_profile;
Future<void> printMyProfile(OAuthSession session) async {
final client = PoptartClient.fromOAuthSession(session);
final profile = await client.call(
get_profile.appBskyActorGetProfile,
parameters: get_profile.ActorGetProfileInput(actor: session.sub),
);
print(profile.data.handle);
}OAuth sessions are DPoP-bound. Poptart uses the session's access token, nonce, public key, and private key to sign authenticated requests.
Persist every field needed to refresh and sign future requests:
final saved = <String, String>{
'accessToken': session.accessToken,
'refreshToken': session.refreshToken,
'clientId': session.$clientId ?? '',
'dpopNonce': session.$dPoPNonce,
'publicKey': session.$publicKey,
'privateKey': session.$privateKey,
};Use secure storage on mobile and a protected secret store on servers. The private key and refresh token are both sensitive.
To restore a saved session, use restoreOAuthSession from the umbrella package:
final restored = restoreOAuthSession(
accessToken: saved['accessToken']!,
refreshToken: saved['refreshToken']!,
clientId: saved['clientId'],
dPoPNonce: saved['dpopNonce'],
publicKey: saved['publicKey']!,
privateKey: saved['privateKey']!,
);
final client = PoptartClient.fromOAuthSession(restored);restoreOAuthSession decodes the access token to recover the scope,
expiration, subject, and PDS endpoint information.
Refresh with the same OAuthClient metadata you used to create the session:
Future<OAuthSession> refreshIfNeeded(OAuthSession session) async {
if (session.expiresAt.isAfter(DateTime.now().toUtc().add(
const Duration(minutes: 5),
))) {
return session;
}
final metadata = await getClientMetadata(
'https://example.com/oauth/client-metadata.json',
);
final oauth = OAuthClient(metadata);
final refreshed = await oauth.refresh(session);
await secureAuthStore.saveSession(refreshed);
return refreshed;
}The refresh flow reuses the original DPoP key pair and updates the nonce if the authorization server rotates it.
Do not throw away the DPoP keys. Without them, the access token and refresh token cannot be used correctly.
Do not accept a callback with an unknown state. OAuthClient.callback checks
this for you as long as you pass the original OAuthContext.
Do not use OAuth for simple unattended scripts. App passwords are usually a better fit for trusted automation.