import { 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 ` 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 { const secret = readSecret(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 { const config = readConfig(); const account = findAccount(config, selector); if (account) { const secret = readSecret(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(nsid: string, options: CallOptions & { proxy?: string | undefined } = {}): Promise { 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 { 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 { 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(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 { 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 { 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; } }