@atcute/oauth-browser-client#
minimal OAuth browser client for AT Protocol.
npm install @atcute/oauth-browser-client
client metadata#
your app needs an OAuth client metadata document hosted at a public URL. this tells authorization servers about your app:
{
"client_id": "https://example.com/oauth-client-metadata.json",
"client_name": "My App",
"client_uri": "https://example.com",
"redirect_uris": ["https://example.com/oauth/callback"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
the client_id must be the URL where this document is hosted. see the
OAuth client metadata spec
for all available fields.
usage#
configuration#
call configureOAuth before using any other functions from this library:
import { configureOAuth } from '@atcute/oauth-browser-client';
import {
CompositeDidDocumentResolver,
LocalActorResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
XrpcHandleResolver,
} from '@atcute/identity-resolver';
configureOAuth({
metadata: {
client_id: 'https://example.com/oauth-client-metadata.json',
redirect_uri: 'https://example.com/oauth/callback',
},
identityResolver: new LocalActorResolver({
handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }),
didDocumentResolver: new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
}),
}),
});
NOTE
this example uses Bluesky's AppView for handle resolution since web apps lack direct DNS access. Bluesky may log handle resolutions per their privacy policy - consider the implications for your use case.
starting authorization#
import { createAuthorizationUrl } from '@atcute/oauth-browser-client';
const authUrl = await createAuthorizationUrl({
target: { type: 'account', identifier: 'mary.my.id' },
scope: 'atproto transition:generic transition:chat.bsky',
});
await sleep(200); // let browser persist local storage
window.location.assign(authUrl);
finalizing authorization#
on your redirect URL, extract the parameters and finalize:
import { Client } from '@atcute/client';
import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client';
// server redirects with params in hash, not search string
const params = new URLSearchParams(location.hash.slice(1));
// scrub params from URL to prevent replay
history.replaceState(null, '', location.pathname + location.search);
const { session } = await finalizeAuthorization(params);
const agent = new OAuthUserAgent(session);
const rpc = new Client({ handler: agent });
const { data } = await rpc.get('com.atproto.identity.resolveHandle', {
params: { handle: 'mary.my.id' },
});
the session is persisted internally - don't store it elsewhere. track signed-in DIDs yourself for your UI, as sessions without refresh tokens may expire.
resuming sessions#
import { OAuthUserAgent, getSession } from '@atcute/oauth-browser-client';
const session = await getSession('did:plc:ia76kvnndjutgedggx2ibrem', { allowStale: true });
const agent = new OAuthUserAgent(session);
signing out#
import { OAuthUserAgent, deleteStoredSession, getSession } from '@atcute/oauth-browser-client';
const did = 'did:plc:ia76kvnndjutgedggx2ibrem';
try {
const session = await getSession(did, { allowStale: true });
const agent = new OAuthUserAgent(session);
await agent.signOut();
} catch {
deleteStoredSession(did); // fallback if signOut fails
}
confidential client mode#
by default, this library operates as a public client with shorter session lifetimes. for longer-lived sessions, set up a client assertion backend to enable confidential client mode.
add fetchClientAssertion to your config. the backend API is entirely up to you - this is just one
example:
configureOAuth({
// ... existing config
async fetchClientAssertion({ jkt, aud, createDpopProof }) {
const dpop = await createDpopProof('https://example.com/api/client-assertion');
const response = await fetch('https://example.com/api/client-assertion', {
method: 'POST',
headers: { dpop, 'content-type': 'application/json' },
body: JSON.stringify({ jkt, aud }),
});
const data = await response.json();
return {
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: data.assertion,
};
},
});
your backend validates the DPoP proof and signs a client assertion JWT containing iss, sub (both
your client ID), aud (authorization server), exp, jti (unique nonce), and cnf: { jkt } (the
allowed key thumbprint).
update your client metadata for confidential mode - replace token_endpoint_auth_method with
private_key_jwt, add token_endpoint_auth_signing_alg: "ES256", and add a jwks_uri pointing to
your public keys.
local development with Vite#
AT Protocol OAuth forbids localhost - use 127.0.0.1 instead:
// vite.config.ts
import { defineConfig } from 'vite';
import metadata from './public/oauth-client-metadata.json' with { type: 'json' };
const SERVER_HOST = '127.0.0.1';
const SERVER_PORT = 12520;
export default defineConfig({
server: { host: SERVER_HOST, port: SERVER_PORT },
plugins: [
{
config(_conf, { command }) {
if (command === 'build') {
process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id;
process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0];
} else {
const redirectUri = `http://${SERVER_HOST}:${SERVER_PORT}${new URL(metadata.redirect_uris[0]).pathname}`;
process.env.VITE_OAUTH_CLIENT_ID =
`http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` +
`&scope=${encodeURIComponent(metadata.scope)}`;
process.env.VITE_OAUTH_REDIRECT_URI = redirectUri;
}
process.env.VITE_OAUTH_SCOPE = metadata.scope;
},
},
],
});
then use environment variables in your code:
configureOAuth({
metadata: {
client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI,
},
// ...
});
caveats#
- minimal implementation: only ES256 DPoP keys, requires PKCE and DPoP-bound PAR
- no IndexedDB: works in Safari lockdown mode but can't use non-exportable keys as recommended by DPoP spec
- limited testing: works in personal projects but consider the reference implementation for production