@atcute/oauth-node-client#
atproto OAuth client for Node.js (plus Deno, Bun, and other server runtimes). this package
implements a confidential client that authenticates using private_key_jwt.
npm install @atcute/oauth-node-client
usage#
examples below use Hono, but any web framework works.
import { Hono } from 'hono';
const app = new Hono();
key management#
confidential clients require a persistent private key, so we need one to be generated.
one pattern is to keep a committed .env with empty placeholders and generate a developer-specific
.env.local that is never checked in:
- create
.envwith an empty value:
PRIVATE_KEY_JWK=
- add
scripts/setup-env.mjs:
import { existsSync } from 'node:fs';
import { copyFile, readFile, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { exportJwkKey, generatePrivateKey, importJwkKey } from '@atcute/oauth-node-client';
const ensureEnvLocal = async () => {
const envPath = resolve(process.cwd(), '.env');
const envLocalPath = resolve(process.cwd(), '.env.local');
if (!existsSync(envLocalPath)) {
await copyFile(envPath, envLocalPath);
}
return envLocalPath;
};
const upsertEnvVar = (input, key, value) => {
const line = `${key}=${value}`;
const re = new RegExp(`^${key}=.*$`, 'm');
if (re.test(input)) {
const match = input.match(re);
const current = match ? match[0].slice(key.length + 1) : '';
const trimmed = current.trim();
if (trimmed === '' || trimmed === `''` || trimmed === `""`) {
return input.replace(re, line);
}
return input;
}
const suffix = input.endsWith('\n') || input.length === 0 ? '' : '\n';
return `${input}${suffix}${line}\n`;
};
const envLocalPath = await ensureEnvLocal();
const envLocal = await readFile(envLocalPath, 'utf8');
const privateKey = await generatePrivateKey('main', 'ES256');
const jwk = await exportJwkKey(privateKey);
// sanity-check that the key parses before writing
await importJwkKey(jwk);
const jwkJson = JSON.stringify(jwk);
const updated = upsertEnvVar(envLocal, 'PRIVATE_KEY_JWK', `'${jwkJson}'`);
if (updated !== envLocal) {
await writeFile(envLocalPath, updated);
console.log(`updated ${envLocalPath}`);
} else {
console.log(`no changes to ${envLocalPath}`);
}
- run it:
node scripts/setup-env.mjs
- create a keyset at runtime:
import { importJwkKey } from '@atcute/oauth-node-client';
const keyset = await Promise.all([importJwkKey(process.env.PRIVATE_KEY_JWK!)]);
create an OAuth client#
import { MemoryStore, OAuthClient, importJwkKey, scope } from '@atcute/oauth-node-client';
import {
CompositeDidDocumentResolver,
CompositeHandleResolver,
LocalActorResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
WellKnownHandleResolver,
} from '@atcute/identity-resolver';
import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node';
const oauth = new OAuthClient({
metadata: {
// this must be the URL where you serve `oauth.metadata`.
client_id: 'https://example.com/oauth-client-metadata.json',
redirect_uris: ['https://example.com/oauth/callback'],
// scopes; shown here is an example for a full-featured Bluesky client.
scope: [
scope.include({
nsid: 'app.bsky.authFullApp',
aud: 'did:web:api.bsky.app#bsky_appview',
}),
scope.include({
nsid: 'chat.bsky.authFullChatClient',
aud: 'did:web:api.bsky.chat#bsky_chat',
}),
scope.rpc({ lxm: ['com.atproto.moderation.createReport'], aud: '*' }),
scope.blob({ accept: ['image/*', 'video/*'] }),
scope.account({ attr: 'email', action: 'manage' }),
scope.identity({ attr: 'handle' }),
],
// optional: if set, this must be the URL where you serve `oauth.jwks`.
// must be same-origin as client_id. if omitted, `oauth.metadata` will inline jwks instead.
jwks_uri: 'https://example.com/jwks.json',
},
keyset: await Promise.all([importJwkKey(process.env.PRIVATE_KEY_JWK!)]),
stores: {
// sessions are keyed by DID - should be durable across restarts.
// states are keyed by OAuth state value - should have ~10 minute TTL.
// MemoryStore works for development; use Redis or similar in production.
sessions: new MemoryStore(),
states: new MemoryStore(),
},
// optional: custom lock for coordinating token refresh across processes.
// defaults to in-memory, which works for single-process deployments.
// for multi-process/clustered deployments, provide a distributed lock
// (e.g., Redis-based) to prevent concurrent refresh for the same session.
async requestLock(name, fn) {
// ...
},
actorResolver: new LocalActorResolver({
handleResolver: new CompositeHandleResolver({
methods: {
dns: new NodeDnsHandleResolver(),
http: new WellKnownHandleResolver(),
},
}),
didDocumentResolver: new CompositeDidDocumentResolver({
methods: {
plc: new PlcDidDocumentResolver(),
web: new WebDidDocumentResolver(),
},
}),
}),
});
serve metadata and jwks#
the PDS/authorization server fetches your client metadata and JWKS from the URLs you advertise:
app.get('/oauth-client-metadata.json', (c) => c.json(oauth.metadata));
app.get('/jwks.json', (c) => c.json(oauth.jwks));
start authorization#
app.get('/login', async (c) => {
const { url } = await oauth.authorize({
target: { type: 'account', identifier: 'mary.my.id' },
state: { returnTo: '/protected' },
});
return c.redirect(url.toString());
});
handle the callback#
pass the callback query params to callback(). if your framework only gives you a path, combine it
with your public origin (the same origin used in your redirect_uri):
app.get('/oauth/callback', async (c) => {
const callbackUrl = new URL(c.req.url);
const { session, state } = await oauth.callback(callbackUrl.searchParams);
// store session.did in your own cookie/session so you know who is signed in.
// oauth tokens are stored in your session store - don't store them elsewhere.
const did = session.did;
const returnTo = (state as { returnTo?: string } | undefined)?.returnTo ?? '/';
void did;
return c.redirect(returnTo);
});
session restoration#
restore a session by DID. this will refresh tokens if needed.
import { Client } from '@atcute/client';
const session = await oauth.restore(did);
const client = new Client({ handler: session });
const { data } = await client.get('com.atproto.server.getSession');
signing out#
await oauth.revoke(did);
or, if you already have an OAuthSession:
await session.signOut();
custom stores#
for production deployments, implement the Store interface with a shared store like Redis:
import type { OAuthClientStores } from '@atcute/oauth-node-client';
const stores: OAuthClientStores = {
sessions: {
async get(did, options) {
// ...
},
async set(did, session) {
// ...
},
async delete(did) {
// ...
},
async clear() {},
},
states: {
async get(stateId, options) {
// ...
},
async set(stateId, state) {
// ...
},
async delete(stateId) {
// ...
},
async clear() {},
},
};