@atcute/oauth-browser-client
v3.0.0
Published
minimal OAuth browser client implementation for AT Protocol
Downloads
1,514
Readme
@atcute/oauth-browser-client
minimal OAuth browser client for AT Protocol.
npm install @atcute/oauth-browser-clientclient 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({ aud, createDpopProof }) {
const htu = 'https://example.com/api/client-assertion';
const dpop = await createDpopProof(htu);
const response = await fetch(htu, {
method: 'POST',
headers: { dpop, 'content-type': 'application/json' },
body: JSON.stringify({ 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 derived from the proof).
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
