Skip to content

Latest commit

 

History

History
188 lines (138 loc) · 5.38 KB

File metadata and controls

188 lines (138 loc) · 5.38 KB
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 poptart
import 'package:poptart/poptart.dart';

Client Metadata

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.

Start The Authorization Flow

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.

Handle The Callback

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.

Use The OAuth Session

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 And Restore A Session

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 Before The Access Token Expires

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.

Common Mistakes

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.