@atcute/oauth-node-client
v0.1.2
Published
atproto OAuth client for Node.js
Downloads
291
Readme
@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-clientusage
examples below use Hono, but any web framework with Request/Response style 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 } 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` (below).
client_id: 'https://example.com/oauth-client-metadata.json',
redirect_uris: ['https://example.com/oauth/callback'],
scope: 'atproto transition:generic',
// optional: if set, this must be the URL where you serve `oauth.jwks` (below).
// 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() {},
},
};