Something went wrong. Try again.
A fully featured CLI used as an alternative to AppView applications (Bsky.app/mu.social)
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264import { findAccount, readConfig, setting, withLock, writeConfig, type AccountRecord } from './config.ts';import { didWeb, resolveIdentity, type Identity } from './identity.ts';import { deleteSecret, readSecret, writeSecret } from './secrets.ts';import { learnNonce, oauthAuthorizer, refreshOAuth, revokeOAuth, type OAuthSecret } from './oauth.ts';import { xrpc, xrpcResponse, xrpcUrl, type Authorizer, type CallOptions } from './xrpc.ts';import { AuthError, XrpcError } from './errors.ts';
// Signed-in accounts, ready to make calls.//// Any number of accounts can be signed in at once; one of them is the default,// and `--account <handle|did>` picks another for a single command. What is not// secret — DID, handle, PDS, how it signed in — is in the config file; the// tokens are in the credential store, under the DID.//// Tokens are refreshed when they are known to have expired, or when the PDS// says they have, under a lock so that parallel commands do not race to spend// one single-use refresh token.
export interface PasswordSecret { kind: 'password'; accessJwt: string; refreshJwt: string;}
export type Secret = PasswordSecret | OAuthSecret;
/** The service proxied to for app.bsky.* calls, and the public instance used signed out. */export function appviewProxy(): string { return setting('appview');}export function publicAppview(): string { return setting('public-appview').replace(/\/+$/, '');}export function chatProxy(): string { return setting('chat');}
// ------------------------------------------------------------- signing in ---
export interface PasswordLogin { identifier: string; password: string; /** Where to sign in, when the identifier is an email address or resolution should be skipped. */ pds?: string | undefined; /** The code mailed to the account when it has email two-factor turned on. */ authFactorToken?: string | undefined;}
export async function loginPassword(login: PasswordLogin): Promise<{ identity: Identity; secret: PasswordSecret }> { const identifier = login.identifier.replace(/^@/, ''); const service = login.pds ?? (identifier.includes('@') ? 'https://bsky.social' : (await resolveIdentity(identifier)).pds); const created = await xrpc<{ did: string; handle: string; accessJwt: string; refreshJwt: string }>( service.replace(/\/+$/, ''), 'com.atproto.server.createSession', { input: { identifier, password: login.password, ...(login.authFactorToken ? { authFactorToken: login.authFactorToken } : {}) }, }); // The DID document says where the account lives, not the server we asked. const identity = await resolveIdentity(created.did); return { identity: { ...identity, handle: identity.handle ?? created.handle }, secret: { kind: 'password', accessJwt: created.accessJwt, refreshJwt: created.refreshJwt } };}
/** Keep a new sign-in: the secret in the credential store, the rest in the config. */export function saveLogin(identity: Identity, secret: Secret, { makeDefault }: { makeDefault?: boolean } = {}): AccountRecord { writeSecret(identity.did, secret); const config = readConfig(); const account: AccountRecord = { did: identity.did, handle: identity.handle ?? identity.did, pds: identity.pds, method: secret.kind, added: new Date().toISOString(), }; config.accounts[identity.did] = account; if (makeDefault || !config.default || !config.accounts[config.default]) config.default = identity.did; writeConfig(config); return account;}
/** Sign an account out: end its session at the server, forget its secret and its record. */export async function logout(account: AccountRecord): Promise<void> { const secret = readSecret<Secret>(account.did); if (secret?.kind === 'oauth') await revokeOAuth(secret); if (secret?.kind === 'password') { try { await xrpc(account.pds, 'com.atproto.server.deleteSession', { method: 'POST', auth: () => ({ authorization: `Bearer ${secret.refreshJwt}` }) }); } catch { // The session may already be gone; it is forgotten here either way. } } deleteSecret(account.did); const config = readConfig(); delete config.accounts[account.did]; if (config.default === account.did) config.default = Object.keys(config.accounts)[0]; writeConfig(config);}
// ---------------------------------------------------------------- session ---
export class Session { account: AccountRecord; secret: Secret; /** Signed in from the environment for this command only: nothing is written back. */ ephemeral: boolean;
constructor(account: AccountRecord, secret: Secret, ephemeral = false) { this.account = account; this.secret = secret; this.ephemeral = ephemeral; }
/** The account `selector` names, else the default one. */ static async load(selector?: string): Promise<Session | undefined> { const config = readConfig(); const account = findAccount(config, selector); if (account) { const secret = readSecret<Secret>(account.did); if (!secret) throw new AuthError(`the session for ${account.handle} is not in the credential store; run 'atcli login ${account.handle}' again`, 'SessionMissing'); return new Session(account, secret); } if (selector) { const known = Object.values(config.accounts).map((each) => each.handle).join(', '); throw new AuthError(`not signed in as ${selector}${known ? ` (signed in: ${known})` : ''}; run 'atcli login ${selector.replace(/^@/, '')}'`, 'NotSignedIn'); } return undefined; }
get did(): string { return this.account.did; }
get handle(): string { return this.account.handle; }
get pds(): string { return this.account.pds; }
/** When the access token runs out, in seconds since the epoch, as far as is known. */ get expiresAt(): number | undefined { if (this.secret.kind === 'oauth') return this.secret.expiresAt; return jwtExpiry(this.secret.accessJwt); }
/** * Call a method on the account's PDS, or — through `proxy` — on a service * the PDS forwards to. Retried when the PDS wants a new DPoP nonce, and once * after refreshing when the access token has expired. */ async call<T = unknown>(nsid: string, options: CallOptions & { proxy?: string | undefined } = {}): Promise<T> { const response = await this.respond(nsid, options); const type = response.headers.get('content-type') ?? ''; if (type.includes('json')) return await response.json() as T; const text = await response.text(); return (text || {}) as T; }
async respond(nsid: string, options: CallOptions & { proxy?: string | undefined } = {}): Promise<Response> { const expiry = this.expiresAt; if (expiry !== undefined && expiry - 30 < Date.now() / 1000) await this.refresh(); const headers = { ...options.headers, ...(options.proxy ? { 'atproto-proxy': options.proxy } : {}) }; let refreshed = false; const origin = new URL(this.pds).origin; for (let attempt = 0; ; attempt++) { // The nonce this request is sent with. Requests in flight together // are all refused for want of a new one, and the first answer // teaches it: each of the others is retried because the nonce now // known is not the one it was sent with, not because it is new. const used = this.secret.kind === 'oauth' ? this.secret.dpopNonces[origin] : undefined; try { return await xrpcResponse(this.pds, nsid, { ...options, headers, auth: this.authorizer() }); } catch (error) { if (!(error instanceof XrpcError)) throw error; const errorHeaders = (error as XrpcError & { headers?: Headers }).headers; if (this.secret.kind === 'oauth' && (error.code === 'use_dpop_nonce' || /use_dpop_nonce/.test(errorHeaders?.get('www-authenticate') ?? ''))) { learnNonce(this.secret, xrpcUrl(this.pds, nsid), errorHeaders); if (attempt < 3 && this.secret.dpopNonces[origin] !== used) continue; } if (isExpired(error) && !refreshed) { refreshed = true; await this.refresh(); continue; } throw error; } } }
authorizer(): Authorizer { const secret = this.secret; if (secret.kind === 'password') return () => ({ authorization: `Bearer ${secret.accessJwt}` }); return oauthAuthorizer(secret); }
/** * Spend the refresh token for fresh tokens, and keep them. Another process * may have refreshed meanwhile; then its tokens are taken up instead. */ async refresh(): Promise<void> { if (this.ephemeral) { this.secret = await exchange(this.account, this.secret); return; } const stale = refreshTokenOf(this.secret); await withLock(`refresh-${this.did}`, async () => { const stored = readSecret<Secret>(this.did); if (stored && refreshTokenOf(stored) !== stale) { this.secret = stored; return; } this.secret = await exchange(this.account, this.secret); writeSecret(this.did, this.secret); }); }
/** A service-auth token: a JWT the PDS signs for `aud`, good for `lxm` until it expires. */ async serviceAuth(aud: string, lxm: string | undefined, lifetime = 60): Promise<string> { const { token } = await this.call<{ token: string }>('com.atproto.server.getServiceAuth', { params: { aud, ...(lxm ? { lxm } : {}), exp: Math.floor(Date.now() / 1000) + lifetime }, }); return token; }
/** The DID the account's PDS is known by, as an audience for service auth. */ pdsDid(): string { return didWeb(new URL(this.pds).host); }}
async function exchange(account: AccountRecord, secret: Secret): Promise<Secret> { try { if (secret.kind === 'password') { const refreshed = await xrpc<{ accessJwt: string; refreshJwt: string }>(account.pds, 'com.atproto.server.refreshSession', { method: 'POST', auth: () => ({ authorization: `Bearer ${secret.refreshJwt}` }), }); return { kind: 'password', accessJwt: refreshed.accessJwt, refreshJwt: refreshed.refreshJwt }; } return await refreshOAuth(secret, account.did); } catch (error) { if (error instanceof AuthError || (error instanceof XrpcError && (error.status === 400 || error.status === 401))) { throw new AuthError(`the session for ${account.handle} has ended; run 'atcli login ${account.handle}' again`, 'SessionExpired'); } throw error; }}
function refreshTokenOf(secret: Secret): string | undefined { return secret.kind === 'password' ? secret.refreshJwt : secret.refreshToken;}
function isExpired(error: XrpcError): boolean { if (error.code === 'ExpiredToken' || error.code === 'invalid_token') return true; return error.status === 401 && /expired/i.test(error.message);}
/** The `exp` of a JWT, without checking anything else about it. */export function jwtExpiry(token: string): number | undefined { try { const payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf-8')) as { exp?: number }; return typeof payload.exp === 'number' ? payload.exp : undefined; } catch { return undefined; }}