diff --git a/.changeset/chilled-jokes-relax.md b/.changeset/chilled-jokes-relax.md new file mode 100644 index 00000000000..eb6f6888e1b --- /dev/null +++ b/.changeset/chilled-jokes-relax.md @@ -0,0 +1,7 @@ +--- +"@atproto/oauth-client-browser": minor +"@atproto/oauth-client-node": minor +"@atproto/oauth-client": minor +--- + +The `OAuthClient` (and runtime specific sub-classes) no longer return @atproto/api `Agent` instances. Instead, they return `OAuthSession` instances that can be used to instantiate the `Agent` class. diff --git a/.changeset/early-rivers-occur.md b/.changeset/early-rivers-occur.md new file mode 100644 index 00000000000..b32e0dc7497 --- /dev/null +++ b/.changeset/early-rivers-occur.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-client-node": patch +--- + +Remove un-necessary dev dependency diff --git a/.changeset/hungry-parrots-study.md b/.changeset/hungry-parrots-study.md new file mode 100644 index 00000000000..c32a1e7b5fd --- /dev/null +++ b/.changeset/hungry-parrots-study.md @@ -0,0 +1,5 @@ +--- +"@atproto/xrpc": patch +--- + +Improve handling of fetchHandler errors when turning them into `XrpcError`. diff --git a/.changeset/nine-deers-count.md b/.changeset/nine-deers-count.md new file mode 100644 index 00000000000..61828ec6a58 --- /dev/null +++ b/.changeset/nine-deers-count.md @@ -0,0 +1,5 @@ +--- +"@atproto/api": patch +--- + +Drop use of `AtpBaseClient` class diff --git a/.changeset/old-mice-give.md b/.changeset/old-mice-give.md new file mode 100644 index 00000000000..6d6bb1d5ea6 --- /dev/null +++ b/.changeset/old-mice-give.md @@ -0,0 +1,6 @@ +--- +"@atproto/api": patch +--- + +Expose the `CredentialSession` class that can be used to instantiate both `Agent` and `XrpcClient`, while internally managing credential based (username/password) sessions. + diff --git a/.changeset/polite-toys-happen.md b/.changeset/polite-toys-happen.md new file mode 100644 index 00000000000..1547eed02f8 --- /dev/null +++ b/.changeset/polite-toys-happen.md @@ -0,0 +1,5 @@ +--- +"@atproto/api": patch +--- + +`Agent` is no longer an abstract class. Instead it can be instantiated using object implementing a new `SessionManager` interface. If your project extends `Agent` and overrides the constructor or any method implementations, consider that you may want to call them from `super`. diff --git a/.changeset/short-llamas-rescue.md b/.changeset/short-llamas-rescue.md new file mode 100644 index 00000000000..13cc5276fe7 --- /dev/null +++ b/.changeset/short-llamas-rescue.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-client": patch +--- + +Add `getTokenInfo()` method to `OAuthSession`. diff --git a/.changeset/smooth-houses-hope.md b/.changeset/smooth-houses-hope.md new file mode 100644 index 00000000000..b9a7334c56b --- /dev/null +++ b/.changeset/smooth-houses-hope.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-client": minor +--- + +Rename OAuthAgent into OAuthSession diff --git a/.changeset/tame-elephants-unite.md b/.changeset/tame-elephants-unite.md new file mode 100644 index 00000000000..d537fe0732e --- /dev/null +++ b/.changeset/tame-elephants-unite.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-client": minor +--- + +Rename `OAuthSession`'s `request` method to `fetchHandler`. The goal of this change is to allow `OAuthSession` to be used in order to instantiate `XrpcClient` by implementing the `FetchHandlerObject` interface. diff --git a/.changeset/tasty-dingos-design.md b/.changeset/tasty-dingos-design.md new file mode 100644 index 00000000000..45a0e7fa508 --- /dev/null +++ b/.changeset/tasty-dingos-design.md @@ -0,0 +1,5 @@ +--- +"@atproto/xrpc": patch +--- + +Add ability to instantiate XrpcClient from FetchHandlerObject type diff --git a/.changeset/twelve-years-speak.md b/.changeset/twelve-years-speak.md new file mode 100644 index 00000000000..ba9b68442b9 --- /dev/null +++ b/.changeset/twelve-years-speak.md @@ -0,0 +1,5 @@ +--- +"@atproto/xrpc": patch +--- + +Add global headers to `XrpcClient` instances diff --git a/.changeset/wet-radios-fry.md b/.changeset/wet-radios-fry.md new file mode 100644 index 00000000000..296dccc2735 --- /dev/null +++ b/.changeset/wet-radios-fry.md @@ -0,0 +1,5 @@ +--- +"@atproto/oauth-client": patch +--- + +Make `getTokenSet()` method public in `OAuthSession`. diff --git a/packages/api/OAUTH.md b/packages/api/OAUTH.md index 911e4f8569a..0f6bf4f9f51 100644 --- a/packages/api/OAUTH.md +++ b/packages/api/OAUTH.md @@ -168,6 +168,7 @@ ngrok as the `client_id`: Replace the content of the `src/app.ts` file, with the following content: ```typescript +import { Agent } from '@atproto/api' import { BrowserOAuthClient } from '@atproto/oauth-client-browser' async function main() { @@ -200,19 +201,28 @@ following code: ```typescript const result = await oauthClient.init() -const agent = result?.agent + +if (result) { + if ('state' in result) { + console.log('The user was just redirected back from the authorization page') + } + + console.log(`The user is currently signed in as ${result.session.did}`) +} + +const session = result?.session // TO BE CONTINUED ``` At this point you can detect if the user is already authenticated or not (by -checking if `agent` is `undefined`). +checking if `session` is `undefined`). Let's initiate an authentication flow if the user is not authenticated. Replace the `// TO BE CONTINUED` comment with the following code: ```typescript -if (!agent) { +if (!session) { const handle = prompt('Enter your atproto handle to authenticate') if (!handle) throw new Error('Authentication process canceled by the user') @@ -234,14 +244,16 @@ if (!agent) { // TO BE CONTINUED ``` -At this point in the script, the user **will** be authenticated. API calls can -be made using the `agent`. The `agent` is an instance of a sub-class of the -`Agent` from `@atproto/api`. Let's make a simple call to the API to retrieve the -user's profile. Replace the `// TO BE CONTINUED` comment with the following -code: +At this point in the script, the user **will** be authenticated. Authenticated +API calls can be made using the `session`. The `session` can be used to instantiate the +`Agent` class from `@atproto/api`. Let's make a simple call to the API to +retrieve the user's profile. Replace the `// TO BE CONTINUED` comment with the +following code: ```typescript -if (agent) { +if (session) { + const agent = new Agent(session) + const fetchProfile = async () => { const profile = await agent.getProfile({ actor: agent.did }) return profile.data @@ -263,7 +275,7 @@ if (agent) { document.body.appendChild(logoutBtn) logoutBtn.textContent = 'Logout' logoutBtn.onclick = async () => { - await oauthAgent.signOut() + await session.signOut() window.location.reload() } diff --git a/packages/api/README.md b/packages/api/README.md index fb4607deb1d..b27e5c621f6 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -88,17 +88,21 @@ are available: Lower lever; compatible with most JS engines. Every `@atproto/oauth-client-*` implementation has a different way to obtain an -OAuth based API agent instance. Here is an example restoring a previously -saved session: +`OAuthSession` instance that can be used to instantiate an `Agent` (from +`@atproto/api`). Here is an example restoring a previously saved session: ```typescript +import { Agent } from '@atproto/api' import { OAuthClient } from '@atproto/oauth-client' const oauthClient = new OAuthClient({ // ... }) -const agent = await oauthClient.restore('did:plc:123') +const oauthSession = await oauthClient.restore('did:plc:123') + +// Instantiate the api Agent using an OAuthSession +const agent = new Agent(oauthSession) ``` ### API calls @@ -106,6 +110,10 @@ const agent = await oauthClient.restore('did:plc:123') The agent includes methods for many common operations, including: ```typescript +// The DID of the user currently authenticated (or undefined) +agent.did +agent.accountDid // Throws if the user is not authenticated + // Feeds and content await agent.getTimeline(params, opts) await agent.getAuthorFeed(params, opts) @@ -151,11 +159,13 @@ await agent.updateSeenNotifications() await agent.resolveHandle(params, opts) await agent.updateHandle(params, opts) -// Session management (OAuth based agent instances have a different set of methods) +// Legacy: Session management should be performed through the SessionManager +// rather than the Agent instance. if (agent instanceof AtpAgent) { - await agent.createAccount(params) - await agent.login(params) - await agent.resumeSession(session) + // AtpAgent instances support using different sessions during their lifetime + await agent.createAccount({ ... }) // session a + await agent.login({ ... }) // session b + await agent.resumeSession(savedSession) // session c } ``` diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index c2e6d27d75b..b79810a7cab 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -1,19 +1,19 @@ import { TID } from '@atproto/common-web' import { AtUri, ensureValidDid } from '@atproto/syntax' -import { - buildFetchHandler, - FetchHandler, - FetchHandlerOptions, -} from '@atproto/xrpc' +import { buildFetchHandler, FetchHandler, XrpcClient } from '@atproto/xrpc' import AwaitLock from 'await-lock' import { AppBskyActorDefs, AppBskyActorProfile, AppBskyFeedPost, AppBskyLabelerDefs, - AtpBaseClient, + AppNS, + ChatNS, ComAtprotoRepoPutRecord, + ComNS, + ToolsNS, } from './client/index' +import { schemas } from './client/lexicons' import { MutedWord } from './client/types/app/bsky/actor/defs' import { BSKY_LABELER_DID } from './const' import { interpretLabelValueDefinitions } from './moderation' @@ -23,6 +23,7 @@ import { LabelPreference, ModerationPrefs, } from './moderation/types' +import { SessionManager } from './session-manager' import { AtpAgentGlobalOpts, AtprotoServiceType, @@ -68,14 +69,13 @@ export type { FetchHandler } /** * An {@link Agent} is an {@link AtpBaseClient} with the following * additional features: - * - Abstract session management utilities * - AT Protocol labelers configuration utilities * - AT Protocol proxy configuration utilities * - Cloning utilities * - `app.bsky` syntactic sugar * - `com.atproto` syntactic sugar */ -export abstract class Agent extends AtpBaseClient { +export class Agent extends XrpcClient { //#region Static configuration /** @@ -94,8 +94,18 @@ export abstract class Agent extends AtpBaseClient { //#endregion - constructor(fetchHandlerOpts: FetchHandler | FetchHandlerOptions) { - const fetchHandler = buildFetchHandler(fetchHandlerOpts) + com = new ComNS(this) + app = new AppNS(this) + chat = new ChatNS(this) + tools = new ToolsNS(this) + + /** @deprecated use `this` instead */ + get xrpc(): XrpcClient { + return this + } + + constructor(readonly sessionManager: SessionManager) { + const fetchHandler = buildFetchHandler(sessionManager) super((url, init) => { const headers = new Headers(init?.headers) @@ -118,16 +128,20 @@ export abstract class Agent extends AtpBaseClient { ) return fetchHandler(url, { ...init, headers }) - }) + }, schemas) } //#region Cloning utilities - abstract clone(): Agent + clone(): Agent { + return this.copyInto(new Agent(this.sessionManager)) + } copyInto(inst: T): T { inst.configureLabelers(this.labelers) inst.configureProxy(this.proxy ?? null) + inst.clearHeaders() + for (const [key, value] of this.headers) inst.setHeader(key, value) return inst } @@ -185,7 +199,9 @@ export abstract class Agent extends AtpBaseClient { /** * Get the authenticated user's DID, if any. */ - abstract readonly did?: string + get did() { + return this.sessionManager.did + } /** * Get the authenticated user's DID, or throw an error if not authenticated. diff --git a/packages/api/src/atp-agent.ts b/packages/api/src/atp-agent.ts index 1495c9423e3..0d4129dc6d3 100644 --- a/packages/api/src/atp-agent.ts +++ b/packages/api/src/atp-agent.ts @@ -4,16 +4,18 @@ import { Gettable, ResponseType, XRPCError, - combineHeaders, + XrpcClient, errorResponseBody, } from '@atproto/xrpc' import { Agent } from './agent' import { - AtpBaseClient, ComAtprotoServerCreateAccount, ComAtprotoServerCreateSession, ComAtprotoServerGetSession, + ComAtprotoServerNS, } from './client' +import { schemas } from './client/lexicons' +import { SessionManager } from './session-manager' import { AtpAgentLoginOpts, AtpPersistSessionHandler, @@ -32,92 +34,44 @@ export type AtpAgentOptions = { } /** - * An {@link AtpAgent} extends the {@link Agent} abstract class by - * implementing password based session management. + * A wrapper around the {@link Agent} class that uses credential based session + * management. This class also exposes most of the session management methods + * directly. + * + * This class will be deprecated in the near future. Use {@link Agent} directly + * with a {@link CredentialSession} instead: + * + * ```ts + * const session = new CredentialSession({ + * service: new URL('https://example.com'), + * }) + * + * const agent = new Agent(session) + * ``` */ export class AtpAgent extends Agent { - public readonly headers: Map> - public readonly sessionManager: SessionManager - - constructor(options: AtpAgentOptions | SessionManager) { - super(async (url: string, init?: RequestInit): Promise => { - // wait for any active session-refreshes to finish - await this.sessionManager.refreshSessionPromise - - const initialHeaders = combineHeaders(init?.headers, this.headers) - const reqInit = { ...init, headers: initialHeaders } - - const initialUri = new URL(url, this.dispatchUrl) - const initialReq = new Request(initialUri, reqInit) - - const initialToken = this.session?.accessJwt - if (!initialToken || initialReq.headers.has('authorization')) { - return (0, this.sessionManager.fetch)(initialReq) - } - - initialReq.headers.set('authorization', `Bearer ${initialToken}`) - const initialRes = await (0, this.sessionManager.fetch)(initialReq) - - if (!this.session?.refreshJwt) { - return initialRes - } - const isExpiredToken = await isErrorResponse( - initialRes, - [400], - ['ExpiredToken'], - ) - - if (!isExpiredToken) { - return initialRes + readonly sessionManager: CredentialSession + + constructor(options: AtpAgentOptions | CredentialSession) { + const sessionManager = + options instanceof CredentialSession + ? options + : new CredentialSession( + new URL(options.service), + options.fetch, + options.persistSession, + ) + + super(sessionManager) + + // This assignment is already being done in the super constructor, but we + // need to do it here to make TypeScript happy. + this.sessionManager = sessionManager + + if (!(options instanceof CredentialSession) && options.headers) { + for (const [key, value] of options.headers) { + this.setHeader(key, value) } - - try { - await this.sessionManager.refreshSession() - } catch { - return initialRes - } - - if (reqInit?.signal?.aborted) { - return initialRes - } - - // The stream was already consumed. We cannot retry the request. A solution - // would be to tee() the input stream but that would bufferize the entire - // stream in memory which can lead to memory starvation. Instead, we will - // return the original response and let the calling code handle retries. - if (ReadableStream && reqInit.body instanceof ReadableStream) { - return initialRes - } - - // Return initial "ExpiredToken" response if the session was not refreshed. - const updatedToken = this.session?.accessJwt - if (!updatedToken || updatedToken === initialToken) { - return initialRes - } - - // Make sure the initial request is cancelled to avoid leaking resources - // (NodeJS 👀): https://undici.nodejs.org/#/?id=garbage-collection - await initialRes.body?.cancel() - - // We need to re-compute the URI in case the PDS endpoint has changed - const updatedUri = new URL(url, this.dispatchUrl) - const updatedReq = new Request(updatedUri, reqInit) - - updatedReq.headers.set('authorization', `Bearer ${updatedToken}`) - - return await (0, this.sessionManager.fetch)(updatedReq) - }) - - if (options instanceof SessionManager) { - this.headers = new Map() - this.sessionManager = options - } else { - this.headers = new Map(options.headers) - this.sessionManager = new SessionManager( - new URL(options.service), - options.fetch, - options.persistSession, - ) } } @@ -125,36 +79,16 @@ export class AtpAgent extends Agent { return this.copyInto(new AtpAgent(this.sessionManager)) } - copyInto(inst: T): T { - if (inst instanceof AtpAgent) { - for (const [key] of inst.headers) { - inst.unsetHeader(key) - } - for (const [key, value] of this.headers) { - inst.setHeader(key, value) - } - } - return super.copyInto(inst) - } - - setHeader(key: string, value: Gettable): void { - this.headers.set(key.toLowerCase(), value) - } - - unsetHeader(key: string): void { - this.headers.delete(key.toLowerCase()) - } - get session() { return this.sessionManager.session } get hasSession() { - return !!this.session + return this.sessionManager.hasSession } get did() { - return this.session?.did + return this.sessionManager.did } get serviceUrl() { @@ -166,7 +100,7 @@ export class AtpAgent extends Agent { } get dispatchUrl() { - return this.pdsUrl || this.serviceUrl + return this.sessionManager.dispatchUrl } /** @deprecated use {@link serviceUrl} instead */ @@ -186,7 +120,7 @@ export class AtpAgent extends Agent { ) } - /** @deprecated This will be removed in OAuthAtpAgent */ + /** @deprecated use {@link AtpAgent.serviceUrl} instead */ getServiceUrl() { return this.serviceUrl } @@ -216,23 +150,34 @@ export class AtpAgent extends Agent { } /** - * Private class meant to be used by clones of {@link AtpAgent} so they can - * share the same session across multiple instances (with different - * proxying/labelers/headers options). + * Credentials (username / password) based session manager. Instances of this + * class will typically be used as the session manager for an {@link AtpAgent}. + * They can also be used with an {@link XrpcClient}, if you want to use you + * own Lexicons. */ -class SessionManager { +export class CredentialSession implements SessionManager { public pdsUrl?: URL // The PDS URL, driven by the did doc public session?: AtpSessionData public refreshSessionPromise: Promise | undefined /** - * Private {@link AtpBaseClient} used to perform session management API + * Private {@link ComAtprotoServerNS} used to perform session management API * calls on the service endpoint. Calls performed by this agent will not be - * authenticated using the user's session. + * authenticated using the user's session to allow proper manual configuration + * of the headers when performing session management operations. */ - protected api = new AtpBaseClient((url, init) => { - return (0, this.fetch)(new URL(url, this.serviceUrl), init) - }) + protected server = new ComAtprotoServerNS( + // Note that the use of the codegen "schemas" (to instantiate `this.api`), + // as well as the use of `ComAtprotoServerNS` will cause this class to + // reference (way) more code than it actually needs. It is not possible, + // with the current state of the codegen, to generate a client that only + // includes the methods that are actually used by this class. This is a + // known limitation that should be addressed in a future version of the + // codegen. + new XrpcClient((url, init) => { + return (0, this.fetch)(new URL(url, this.serviceUrl), init) + }, schemas), + ) constructor( public readonly serviceUrl: URL, @@ -240,6 +185,18 @@ class SessionManager { protected readonly persistSession?: AtpPersistSessionHandler, ) {} + get did() { + return this.session?.did + } + + get dispatchUrl() { + return this.pdsUrl || this.serviceUrl + } + + get hasSession() { + return !!this.session + } + /** * Sets a WhatWG "fetch()" function to be used for making HTTP requests. */ @@ -247,6 +204,71 @@ class SessionManager { this.fetch = fetch } + async fetchHandler(url: string, init?: RequestInit): Promise { + // wait for any active session-refreshes to finish + await this.refreshSessionPromise + + const initialUri = new URL(url, this.dispatchUrl) + const initialReq = new Request(initialUri, init) + + const initialToken = this.session?.accessJwt + if (!initialToken || initialReq.headers.has('authorization')) { + return (0, this.fetch)(initialReq) + } + + initialReq.headers.set('authorization', `Bearer ${initialToken}`) + const initialRes = await (0, this.fetch)(initialReq) + + if (!this.session?.refreshJwt) { + return initialRes + } + const isExpiredToken = await isErrorResponse( + initialRes, + [400], + ['ExpiredToken'], + ) + + if (!isExpiredToken) { + return initialRes + } + + try { + await this.refreshSession() + } catch { + return initialRes + } + + if (init?.signal?.aborted) { + return initialRes + } + + // The stream was already consumed. We cannot retry the request. A solution + // would be to tee() the input stream but that would bufferize the entire + // stream in memory which can lead to memory starvation. Instead, we will + // return the original response and let the calling code handle retries. + if (ReadableStream && init?.body instanceof ReadableStream) { + return initialRes + } + + // Return initial "ExpiredToken" response if the session was not refreshed. + const updatedToken = this.session?.accessJwt + if (!updatedToken || updatedToken === initialToken) { + return initialRes + } + + // Make sure the initial request is cancelled to avoid leaking resources + // (NodeJS 👀): https://undici.nodejs.org/#/?id=garbage-collection + await initialRes.body?.cancel() + + // We need to re-compute the URI in case the PDS endpoint has changed + const updatedUri = new URL(url, this.dispatchUrl) + const updatedReq = new Request(updatedUri, init) + + updatedReq.headers.set('authorization', `Bearer ${updatedToken}`) + + return await (0, this.fetch)(updatedReq) + } + /** * Create a new account and hydrate its session in this agent. */ @@ -255,7 +277,7 @@ class SessionManager { opts?: ComAtprotoServerCreateAccount.CallOptions, ): Promise { try { - const res = await this.api.com.atproto.server.createAccount(data, opts) + const res = await this.server.createAccount(data, opts) this.session = { accessJwt: res.data.accessJwt, refreshJwt: res.data.refreshJwt, @@ -283,7 +305,7 @@ class SessionManager { opts: AtpAgentLoginOpts, ): Promise { try { - const res = await this.api.com.atproto.server.createSession({ + const res = await this.server.createSession({ identifier: opts.identifier, password: opts.password, authFactorToken: opts.authFactorToken, @@ -312,7 +334,7 @@ class SessionManager { async logout(): Promise { if (this.session) { try { - await this.api.com.atproto.server.deleteSession(undefined, { + await this.server.deleteSession(undefined, { headers: { authorization: `Bearer ${this.session.accessJwt}`, }, @@ -335,7 +357,7 @@ class SessionManager { this.session = session try { - const res = await this.api.com.atproto.server + const res = await this.server .getSession(undefined, { headers: { authorization: `Bearer ${session.accessJwt}` }, }) @@ -346,15 +368,14 @@ class SessionManager { session.refreshJwt ) { try { - const res = await this.api.com.atproto.server.refreshSession( - undefined, - { headers: { authorization: `Bearer ${session.refreshJwt}` } }, - ) + const res = await this.server.refreshSession(undefined, { + headers: { authorization: `Bearer ${session.refreshJwt}` }, + }) session.accessJwt = res.data.accessJwt session.refreshJwt = res.data.refreshJwt - return this.api.com.atproto.server.getSession(undefined, { + return this.server.getSession(undefined, { headers: { authorization: `Bearer ${session.accessJwt}` }, }) } catch { @@ -425,7 +446,7 @@ class SessionManager { } try { - const res = await this.api.com.atproto.server.refreshSession(undefined, { + const res = await this.server.refreshSession(undefined, { headers: { authorization: `Bearer ${this.session.refreshJwt}` }, }) // succeeded, update the session diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b482f66baee..c92323bc88a 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -25,10 +25,13 @@ export { LABELS, DEFAULT_LABEL_SETTINGS } from './moderation/const/labels' export { Agent } from './agent' export { AtpAgent, type AtpAgentOptions } from './atp-agent' +export { CredentialSession } from './atp-agent' export { BskyAgent } from './bsky-agent' -/** @deprecated */ -export { AtpAgent as default } from './atp-agent' +export { + /** @deprecated */ + AtpAgent as default, +} from './atp-agent' // Expose a copy to prevent alteration of the internal Lexicon instance used by // the AtpBaseClient class. diff --git a/packages/api/src/session-manager.ts b/packages/api/src/session-manager.ts new file mode 100644 index 00000000000..029115947e6 --- /dev/null +++ b/packages/api/src/session-manager.ts @@ -0,0 +1,5 @@ +import { FetchHandlerObject } from '@atproto/xrpc' + +export interface SessionManager extends FetchHandlerObject { + readonly did?: string +} diff --git a/packages/oauth/oauth-client-browser/README.md b/packages/oauth/oauth-client-browser/README.md index 344987bc88e..4f3e16fbd94 100644 --- a/packages/oauth/oauth-client-browser/README.md +++ b/packages/oauth/oauth-client-browser/README.md @@ -1,11 +1,12 @@ # atproto OAuth Client for the Browser -This package provides an OAuth bases `@atproto/api` agent interface for the -browser. It implements all the OAuth features required by [ATPROTO] (PKCE, DPoP, +This package provides a browser specific OAuth client implementation for +atproto. It implements all the OAuth features required by [ATPROTO] (PKCE, DPoP, etc.). `@atproto/oauth-client-browser` is designed for front-end applications that do -not have a backend server to manage OAuth sessions. +not have a backend server to manage OAuth sessions, a.k.a "Single Page +Applications" (SPA). > [!IMPORTANT] > @@ -163,22 +164,24 @@ initialize itself. Note that this operation must be performed once (and **only once**) whenever the web app is loaded. ```typescript -const result: undefined | { agent: OAuthAgent; state?: string } = +const result: undefined | { session: OAuthSession; state?: string } = await client.init() if (result) { - const { agent, state } = result + const { session, state } = result if (state != null) { - console.log(`${agent.sub} was successfully authenticated (state: ${state})`) + console.log( + `${session.sub} was successfully authenticated (state: ${state})`, + ) } else { - console.log(`${agent.sub} was restored (last active session)`) + console.log(`${session.sub} was restored (last active session)`) } } ``` The return value can be used to determine if the client was able to restore the -last used session (`agent` is defined) or if the current navigation is the -result of an authorization redirect (both `agent` and `state` are defined). +last used session (`session` is defined) or if the current navigation is the +result of an authorization redirect (both `session` and `state` are defined). ### Initiating an OAuth flow @@ -217,18 +220,21 @@ the OAuth server). The promise will reject if the user cancels the sign in When the user is redirected back to the application, the OAuth response will be available in the URL. The `BrowserOAuthClient` will automatically detect the -response and handle it when `client.init()` is called. +response and handle it when `client.init()` is called. Alternatively, the +application can manually handle the response using the +`client.callback(urlQueryParams)` method. ### Restoring a session -The client keeps an internal store of all the sessions that it manages. -Regardless of the agent that was returned from the `client.init()` call, -any other session can be loaded into a new agent using the `client.restore()` -method. +The client keeps track of all the sessions that it manages through an internal +store. Regardless of the session that was returned from the `client.init()` +call, any other session can be loaded using the `client.restore()` method. This +method will throw an error if the session is no longer available or if it has +become expired. ```ts -const aliceAgent = await client.restore('did:plc:alice') -const bobAgent = await client.restore('did:plc:bob') +const aliceSession = await client.restore('did:plc:alice') +const bobSession = await client.restore('did:plc:bob') ``` In its current form, the client does not expose methods to list all sessions @@ -256,16 +262,19 @@ client.addEventListener( ## Usage with `@atproto/api` -The `@atproto/api` package provides a way to interact with the `com.atproto` and -`app.bsky` XRPC lexicons through the `Agent` interface. The `agent` returned -by the `BrowserOAuthClient` extend the `Agent` class, allowing to use the -`BrowserOAuthClient` as a regular `Agent` (akin to `AtpAgent` class -instances). +The `@atproto/api` package provides a way to interact with multiple Bluesky +specific XRPC lexicons (`com.atproto`, `app.bsky`, `chat.bsky`, `tools.ozone`) +through the `Agent` interface. The `oauthSession` returned by the +`BrowserOAuthClient` can be used to instantiate an `Agent` instance. ```typescript -const aliceAgent = await client.restore('did:plc:alice') +import { Agent } from '@atproto/api' -await aliceAgent.getProfile({ actor: aliceAgent.did }) +const session = await client.restore('did:plc:alice') + +const agent = new Agent(session) + +await agent.getProfile({ actor: agent.accountDid }) ``` Any refresh of the credentials will happen under the hood, and the new tokens diff --git a/packages/oauth/oauth-client-browser/example/src/auth/atp/use-atp-auth.ts b/packages/oauth/oauth-client-browser/example/src/auth/atp/use-atp-auth.ts index f6679cde008..431b81dbd5d 100644 --- a/packages/oauth/oauth-client-browser/example/src/auth/atp/use-atp-auth.ts +++ b/packages/oauth/oauth-client-browser/example/src/auth/atp/use-atp-auth.ts @@ -47,7 +47,10 @@ export function useAtpAuth() { [createAgent], ) - return useMemo(() => ({ signIn, agent }), [signIn, agent]) + return useMemo( + () => ({ agent, signIn, signOut: () => agent?.logout() }), + [signIn, agent], + ) } const SESSION_KEY = '@@ATPROTO/SESSION' diff --git a/packages/oauth/oauth-client-browser/example/src/auth/auth-provider.tsx b/packages/oauth/oauth-client-browser/example/src/auth/auth-provider.tsx index 3841106fa48..f083caba9de 100644 --- a/packages/oauth/oauth-client-browser/example/src/auth/auth-provider.tsx +++ b/packages/oauth/oauth-client-browser/example/src/auth/auth-provider.tsx @@ -26,18 +26,23 @@ export const AuthProvider = ({ client: oauthClient, agent: oauthAgent, signIn: oauthSignIn, + signOut: oauthSignOut, } = useOAuth(options) - const { agent: atpAgent, signIn: atpSignIn } = useAtpAuth() + const { + agent: atpAgent, + signIn: atpSignIn, + signOut: atpSignOut, + } = useAtpAuth() const value = useMemo( () => oauthAgent - ? { pdsAgent: oauthAgent, signOut: () => oauthAgent.signOut() } + ? { pdsAgent: oauthAgent, signOut: oauthSignOut } : atpAgent - ? { pdsAgent: atpAgent, signOut: () => atpAgent.logout() } + ? { pdsAgent: atpAgent, signOut: atpSignOut } : null, - [atpAgent, oauthAgent], + [oauthAgent, oauthSignOut, atpAgent, atpSignOut], ) if (isLoginPopup) { diff --git a/packages/oauth/oauth-client-browser/example/src/auth/oauth/use-oauth.ts b/packages/oauth/oauth-client-browser/example/src/auth/oauth/use-oauth.ts index 79cdd5367aa..7256a8c4340 100644 --- a/packages/oauth/oauth-client-browser/example/src/auth/oauth/use-oauth.ts +++ b/packages/oauth/oauth-client-browser/example/src/auth/oauth/use-oauth.ts @@ -1,17 +1,19 @@ 'use client' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { Agent } from '@atproto/api' import { AuthorizeOptions, BrowserOAuthClient, BrowserOAuthClientLoadOptions, BrowserOAuthClientOptions, LoginContinuedInParentWindowError, - OAuthAtpAgent, + OAuthSession, } from '@atproto/oauth-client-browser' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -export type OnRestored = (agent: OAuthAtpAgent | null) => void -export type OnSignedIn = (agent: OAuthAtpAgent, state: null | string) => void +export type OnRestored = (session: OAuthSession | null) => void +export type OnSignedIn = (session: OAuthSession, state: null | string) => void export type OnSignedOut = () => void export type GetState = () => | undefined @@ -154,7 +156,7 @@ export function useOAuth(options: UseOAuthOptions) { const clientForInit = useOAuthClient(options) - const [agent, setAgent] = useState(null) + const [session, setSession] = useState(null) const [client, setClient] = useState(null) const [isInitializing, setIsInitializing] = useState(true) const [isLoginPopup, setIsLoginPopup] = useState(false) @@ -165,7 +167,7 @@ export function useOAuth(options: UseOAuthOptions) { if (clientForInitRef.current === clientForInit) return clientForInitRef.current = clientForInit - setAgent(null) + setSession(null) setClient(null) setIsLoginPopup(false) setIsInitializing(clientForInit != null) @@ -178,12 +180,12 @@ export function useOAuth(options: UseOAuthOptions) { setClient(clientForInit) if (r) { - setAgent(r.agent) + setSession(r.session) if ('state' in r) { - await onSignedIn(r.agent, r.state) + await onSignedIn(r.session, r.state) } else { - await onRestored(r.agent) + await onRestored(r.session) } } else { await onRestored(null) @@ -218,22 +220,22 @@ export function useOAuth(options: UseOAuthOptions) { client.addEventListener( 'updated', ({ detail: { sub } }) => { - if (!agent || agent.did !== sub) { - setAgent(null) - client.restore(sub, false).then((agent) => { - if (!signal.aborted) setAgent(agent) + if (!session || session.sub !== sub) { + setSession(null) + client.restore(sub, false).then((session) => { + if (!signal.aborted) setSession(session) }) } }, { signal }, ) - if (agent) { + if (session) { client.addEventListener( 'deleted', ({ detail: { sub } }) => { - if (agent.did === sub) { - setAgent(null) + if (session.sub === sub) { + setSession(null) void onSignedOut() } }, @@ -241,22 +243,22 @@ export function useOAuth(options: UseOAuthOptions) { ) } - void agent?.refreshIfNeeded() + // Force fetching the token info in order to trigger a token refresh + void session?.getTokenInfo(true) return () => { controller.abort() } - }, [client, agent, onSignedOut]) + }, [client, session, onSignedOut]) const signIn = useCallback( async (input: string, options?: AuthorizeOptions) => { if (!client) throw new Error('Client not initialized') const state = options?.state ?? (await getState()) ?? undefined - const agent = await client.signIn(input, { ...options, state }) - setAgent(agent) - await onSignedIn(agent, state ?? null) - return agent + const session = await client.signIn(input, { ...options, state }) + setSession(session) + await onSignedIn(session, state ?? null) }, [client, getState, onSignedIn], ) @@ -269,10 +271,11 @@ export function useOAuth(options: UseOAuthOptions) { isLoginPopup, signIn, + signOut: () => session?.signOut(), client, - agent, + agent: session ? new Agent(session) : null, }), - [isInitializing, isLoginPopup, agent, client, signIn], + [isInitializing, isLoginPopup, session, client, signIn], ) } diff --git a/packages/oauth/oauth-client-browser/src/browser-oauth-client.ts b/packages/oauth/oauth-client-browser/src/browser-oauth-client.ts index ebfdf6a5c19..f083d7bd76f 100644 --- a/packages/oauth/oauth-client-browser/src/browser-oauth-client.ts +++ b/packages/oauth/oauth-client-browser/src/browser-oauth-client.ts @@ -3,7 +3,7 @@ import { AuthorizeOptions, ClientMetadata, Fetch, - OAuthAtpAgent, + OAuthSession, OAuthCallbackError, OAuthClient, SessionEventMap, @@ -172,15 +172,15 @@ export class BrowserOAuthClient extends OAuthClient implements Disposable { const signInResult = await this.signInCallback() if (signInResult) { - localStorage.setItem(`${NAMESPACE}(sub)`, signInResult.agent.did) + localStorage.setItem(`${NAMESPACE}(sub)`, signInResult.session.sub) return signInResult } const sub = localStorage.getItem(`${NAMESPACE}(sub)`) if (sub) { try { - const agent = await this.restore(sub, refresh) - return { agent } + const session = await this.restore(sub, refresh) + return { session } } catch (err) { localStorage.removeItem(`${NAMESPACE}(sub)`) throw err @@ -188,10 +188,10 @@ export class BrowserOAuthClient extends OAuthClient implements Disposable { } } - async restore(sub: string, refresh?: boolean) { - const agent = await super.restore(sub, refresh) - localStorage.setItem(`${NAMESPACE}(sub)`, agent.did) - return agent + async restore(sub: string, refresh?: boolean): Promise { + const session = await super.restore(sub, refresh) + localStorage.setItem(`${NAMESPACE}(sub)`, session.sub) + return session } async revoke(sub: string) { @@ -202,7 +202,7 @@ export class BrowserOAuthClient extends OAuthClient implements Disposable { signIn( input: string, options: AuthorizeOptions & { display: 'popup' }, - ): Promise + ): Promise signIn(input: string, options?: AuthorizeOptions): Promise async signIn(input: string, options?: AuthorizeOptions) { if (options?.display === 'popup') { @@ -239,7 +239,7 @@ export class BrowserOAuthClient extends OAuthClient implements Disposable { async signInPopup( input: string, options?: Omit, - ): Promise { + ): Promise { // Open new window asap to prevent popup busting by browsers const popupFeatures = 'width=600,height=600,menubar=no,toolbar=no' let popup: Window | null = window.open( @@ -266,7 +266,7 @@ export class BrowserOAuthClient extends OAuthClient implements Disposable { popup?.focus() - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const popupChannel = new BroadcastChannel(POPUP_CHANNEL_NAME) const cleanup = () => { @@ -381,12 +381,12 @@ export class BrowserOAuthClient extends OAuthClient implements Disposable { key: result.state.slice(POPUP_STATE_PREFIX.length), result: { status: 'fulfilled', - value: result.agent.did, + value: result.session.sub, }, }) // Revoke the credentials if the parent window was closed - if (!receivedByParent) await result.agent.signOut() + if (!receivedByParent) await result.session.signOut() throw new LoginContinuedInParentWindowError() // signInPopup } diff --git a/packages/oauth/oauth-client-node/README.md b/packages/oauth/oauth-client-node/README.md index 67c3cb9c1e8..9a90afb6b87 100644 --- a/packages/oauth/oauth-client-node/README.md +++ b/packages/oauth/oauth-client-node/README.md @@ -105,12 +105,14 @@ app.get('/atproto-oauth-callback', async (req, res, next) => { try { const params = new URLSearchParams(req.url.split('?')[1]) - const { agent, state } = await client.callback(params) + const { session, state } = await client.callback(params) // Process successful authentication here console.log('authorize() was called with state:', state) - console.log('User authenticated as:', agent.did) + console.log('User authenticated as:', session.did) + + const agent = new Agent(session) // Make Authenticated API calls const profile = await agent.getProfile({ actor: agent.did }) @@ -126,12 +128,14 @@ app.get('/atproto-oauth-callback', async (req, res, next) => { async function worker() { const userDid = 'did:plc:123' - const agent = await client.restore(userDid) + const oauthSession = await client.restore(userDid) - // Note: If the current access_token is expired, the agent will automatically + // Note: If the current access_token is expired, the session will automatically // (and transparently) refresh it. The new token set will be saved though // the client's session store. + const agent = new Agent(oauthSession) + // Make Authenticated API calls const profile = await agent.getProfile({ actor: agent.did }) console.log('Bsky profile:', profile.data) @@ -290,7 +294,8 @@ list of examples below). Any refresh of the credentials will happen under the hood, and the new tokens will be saved in the session store. ```ts -const agent = await client.restore('did:plc:123') +const session = await client.restore('did:plc:123') +const agent = new Agent(session) // Feeds and content await agent.getTimeline(params, opts) @@ -339,9 +344,8 @@ await agent.updateHandle(params, opts) // etc. -if (agent instanceof OAuthAtpAgent) { - agent.signOut() -} +// Always remember to revoke the credentials when you are done +await session.signOut() ``` ## Advances use-cases @@ -379,7 +383,7 @@ client.addEventListener( // - session data does not match expected values returned by the OAuth server } else if (cause instanceof TokenRevokedError) { // Session was revoked through: - // - agent.signOut() + // - session.signOut() // - client.revoke(sub) } else { // An unexpected error occurred, causing the session to be deleted @@ -415,9 +419,15 @@ app.get('/atproto-oauth-callback', async (req, res) => { const params = new URLSearchParams(req.url.split('?')[1]) try { try { - const { agent, state } = await client.callback(params) + const { session, state } = await client.callback(params) + + // Process successful authentication here. For example: + + const agent = new Agent(session) + + const profile = await agent.getProfile({ actor: agent.did }) - // Process successful authentication here + console.log('Bsky profile:', profile.data) } catch (err) { // Silent sign-in failed, retry without prompt=none if ( diff --git a/packages/oauth/oauth-client-node/package.json b/packages/oauth/oauth-client-node/package.json index 6bcebe46d93..e3d8370bf78 100644 --- a/packages/oauth/oauth-client-node/package.json +++ b/packages/oauth/oauth-client-node/package.json @@ -39,7 +39,6 @@ "@atproto/oauth-types": "workspace:*" }, "devDependencies": { - "@atproto/api": "workspace:*", "typescript": "^5.3.3" }, "scripts": { diff --git a/packages/oauth/oauth-client/README.md b/packages/oauth/oauth-client/README.md index b7c5f4139c8..7e9b973868b 100644 --- a/packages/oauth/oauth-client/README.md +++ b/packages/oauth/oauth-client/README.md @@ -1,6 +1,6 @@ # @atproto/oauth-client: atproto flavoured OAuth client -Core library for implementing [ATPROTO] OAuth clients. +Core library for implementing [atproto][ATPROTO] OAuth clients. For a browser specific implementation, see [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser). For a node specific implementation, see @@ -147,7 +147,35 @@ const result = await client.callback(params) // Verify the state (e.g. to link to an internal user) result.state === '434321' // true -const agent = result.agent +const oauthSession = result.session +``` + +The sign-in process results in an `OAuthSession` instance that can be used to make +authenticated requests to the resource server. This instance will automatically +refresh the credentials when needed. + +### Making authenticated requests + +The `OAuthSession` instance obtained after signing in can be used to make +authenticated requests to the user's PDS. There are two main use-cases: + +1. Making authenticated request to Bluesky's AppView in order to fetch and + manipulate data from the `app.bsky` lexicon. + +2. Making authenticated request to your own AppView, in order to fetch and + manipulate data from your own lexicon. + +#### Making authenticated requests to Bluesky's AppView + +The `@atproto/oauth-client` package provides a `OAuthSession` class that can be +used to make authenticated requests to Bluesky's AppView. This can be achieved +by constructing an `Agent` (from `@atproto/api`) instance using the +`OAuthSession` instance. + +```ts +import { Agent } from '@atproto/api' + +const agent = new Agent(oauthSession) // Make an authenticated request to the server. New credentials will be // automatically fetched if needed (causing sessionStore.set() to be called). @@ -155,12 +183,106 @@ await agent.post({ text: 'Hello, world!', }) -if (agent instanceof AtpAgent) { - // revoke credentials on the server (causing sessionStore.del() to be called) - await agent.logout() -} +// revoke credentials on the server (causing sessionStore.del() to be called) +await agent.signOut() ``` +#### Making authenticated requests to your own AppView + +The `OAuthSession` instance obtained after signing in can be used to instantiate +the `XrpcClient` class from the `@atproto/xrpc` package. + +```ts +import { Lexicons } from '@atproto/lexicon' +import { OAuthClient } from '@atproto/oauth-client' // or "@atproto/oauth-client-browser" or "@atproto/oauth-client-node" +import { XrpcClient } from '@atproto/xrpc' + +// Define your lexicons +const myLexicon = new Lexicons([ + { + lexicon: 1, + id: 'com.example.query', + defs: { + main: { + // ... + }, + }, + }, +]) + +// Describe your app's oauth client +const oauthClient = new OAuthClient({ + // ... +}) + +// Authenticate the user +const oauthSession = await oauthClient.restore('did:plc:123') + +// Instantiate a client using the `oauthSession` as fetch handler object +const client = new XrpcClient(oauthSession, myLexicon) + +// Make authenticated calls +const response = await client.call('com.example.query') +``` + +Note that the user's PDS might not know about your lexicon, or what to do with +those calls (PDS' are only mandated to implement the `com.atproto` lexicon). In +order to process your calls, you need to have a backend that will process those +calls. You can then instruct your PDS to forward those calls to your backend. + +```ts +const response = await client.call( + 'com.example.query', + { + // Params + }, + { + headers: { + // The PDS will proxy calls to the specified service in did:plc:xyz's did document. + // These calls will be authenticated using "service auth", a single use JWT Bearer token, signed with the logged-in user's private key. + 'atproto-proxy': 'did:plc:xyz#serviceId', + }, + }, +) +``` + +You can also instantiate the `XrpcClient` class with a custom `fetch` function +that will provide the `atproto-proxy` header on all calls: + +```ts +const boundClient = new XrpcClient((url, init) => { + const headers = new Headers(init?.headers) + + // Add the atproto-proxy header if it is not already present + if (!headers.has('atproto-proxy')) { + headers.set('atproto-proxy', 'did:plc:xyz#serviceId') + } + + return oauthSession.fetchHandler(url, { ...init, headers }) +}, myLexicon) + +// No need to specify the atproto-proxy header anymore +const response = await boundClient.call('com.example.query') +``` + +> [!NOTE] +> +> Proxying every call through the PDS is not recommended for performance +> reasons, as it will increase the latency of readonly calls to your lexicon. +> Doing so will also prevent your backend from being able to anticipate writes +> on the network. Indeed, write calls will be sent to the PDS, which will then +> propagate them on the network through a relay (a.k.a. "firehose"). This will +> introduce a delay between the time the write is made and the time it is +> processed by your backend. +> +> In order to avoid those issues, it is recommended that you implement your +> backend using a backend-for-frontend pattern. This backend will be responsible +> for processing the calls made by the client, and will be able to anticipate +> writes on the network. +> +> Read more about the backend-for-frontend pattern in the [atproto][ATPROTO] +> documentation website. + ## Advances use-cases ### Listening for session updates and deletion diff --git a/packages/oauth/oauth-client/package.json b/packages/oauth/oauth-client/package.json index d64ae65bf6a..717aedfe71e 100644 --- a/packages/oauth/oauth-client/package.json +++ b/packages/oauth/oauth-client/package.json @@ -31,7 +31,6 @@ "@atproto-labs/identity-resolver": "workspace:*", "@atproto-labs/simple-store": "workspace:*", "@atproto-labs/simple-store-memory": "workspace:*", - "@atproto/api": "workspace:*", "@atproto/did": "workspace:*", "@atproto/jwk": "workspace:*", "@atproto/oauth-types": "workspace:*", diff --git a/packages/oauth/oauth-client/src/index.ts b/packages/oauth/oauth-client/src/index.ts index 2fe62de455f..57320785363 100644 --- a/packages/oauth/oauth-client/src/index.ts +++ b/packages/oauth/oauth-client/src/index.ts @@ -9,8 +9,6 @@ export * from '@atproto-labs/handle-resolver' export * from '@atproto/did' export * from '@atproto/oauth-types' -export * from './oauth-agent.js' -export * from './oauth-atp-agent.js' export * from './oauth-authorization-server-metadata-resolver.js' export * from './oauth-callback-error.js' export * from './oauth-client.js' @@ -19,6 +17,7 @@ export * from './oauth-resolver-error.js' export * from './oauth-response-error.js' export * from './oauth-server-agent.js' export * from './oauth-server-factory.js' +export * from './oauth-session.js' export * from './runtime-implementation.js' export * from './session-getter.js' export * from './state-store.js' diff --git a/packages/oauth/oauth-client/src/oauth-atp-agent.ts b/packages/oauth/oauth-client/src/oauth-atp-agent.ts deleted file mode 100644 index 8b35492d5de..00000000000 --- a/packages/oauth/oauth-client/src/oauth-atp-agent.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Agent } from '@atproto/api' -import { XRPCError } from '@atproto/xrpc' -import { FetchError, FetchResponseError } from '@atproto-labs/fetch' - -import { OAuthAgent } from './oauth-agent.js' - -export class OAuthAtpAgent extends Agent { - constructor(readonly oauthAgent: OAuthAgent) { - super(async (url, init) => { - try { - return await this.oauthAgent.request(url, init) - } catch (cause) { - if (cause instanceof FetchError) { - const { statusCode, message } = cause - throw new XRPCError(statusCode, undefined, message, undefined, { - cause, - }) - } - - if (cause instanceof FetchResponseError) { - const { statusCode, message, response } = cause - const headers = Object.fromEntries(response.headers.entries()) - throw new XRPCError(statusCode, undefined, message, headers, { - cause, - }) - } - - throw cause - } - }) - } - - clone(): OAuthAtpAgent { - return this.copyInto(new OAuthAtpAgent(this.oauthAgent)) - } - - get did(): string { - return this.oauthAgent.sub - } - - async signOut() { - await this.oauthAgent.signOut() - } - - public async refreshIfNeeded(): Promise { - await this.oauthAgent.refreshIfNeeded() - } -} diff --git a/packages/oauth/oauth-client/src/oauth-client.ts b/packages/oauth/oauth-client/src/oauth-client.ts index 326e98373df..9187911f325 100644 --- a/packages/oauth/oauth-client/src/oauth-client.ts +++ b/packages/oauth/oauth-client/src/oauth-client.ts @@ -23,8 +23,6 @@ import { import { FALLBACK_ALG } from './constants.js' import { TokenRevokedError } from './errors/token-revoked-error.js' -import { OAuthAgent } from './oauth-agent.js' -import { OAuthAtpAgent } from './oauth-atp-agent.js' import { AuthorizationServerMetadataCache, OAuthAuthorizationServerMetadataResolver, @@ -37,6 +35,7 @@ import { import { OAuthResolver } from './oauth-resolver.js' import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js' import { OAuthServerFactory } from './oauth-server-factory.js' +import { OAuthSession } from './oauth-session.js' import { RuntimeImplementation } from './runtime-implementation.js' import { Runtime } from './runtime.js' import { @@ -362,7 +361,7 @@ export class OAuthClient extends CustomEventTarget { } async callback(params: URLSearchParams): Promise<{ - agent: OAuthAtpAgent + session: OAuthSession state: string | null }> { const responseJwt = params.get('response') @@ -452,9 +451,9 @@ export class OAuthClient extends CustomEventTarget { tokenSet, }) - const agent = this.createAgent(server, sub) + const session = this.createSession(server, sub) - return { agent, state: stateData.appState ?? null } + return { session, state: stateData.appState ?? null } } catch (err) { await server.revoke(tokenSet.access_token) @@ -468,12 +467,12 @@ export class OAuthClient extends CustomEventTarget { } /** - * Build an agent from a stored session. This will refresh the token only if - * needed (about to expire) by default. + * Load a stored session. This will refresh the token only if needed (about to + * expire) by default. * * @param refresh See {@link SessionGetter.getSession} */ - async restore(sub: string, refresh?: boolean): Promise { + async restore(sub: string, refresh?: boolean): Promise { const { dpopKey, tokenSet } = await this.sessionGetter.getSession( sub, refresh, @@ -484,7 +483,7 @@ export class OAuthClient extends CustomEventTarget { allowStale: refresh === false, }) - return this.createAgent(server, sub) + return this.createSession(server, sub) } async revoke(sub: string) { @@ -504,14 +503,7 @@ export class OAuthClient extends CustomEventTarget { } } - createAgent(server: OAuthServerAgent, sub: string): OAuthAtpAgent { - const oauthAgent = new OAuthAgent( - server, - sub, - this.sessionGetter, - this.fetch, - ) - - return new OAuthAtpAgent(oauthAgent) + protected createSession(server: OAuthServerAgent, sub: string): OAuthSession { + return new OAuthSession(server, sub, this.sessionGetter, this.fetch) } } diff --git a/packages/oauth/oauth-client/src/oauth-agent.ts b/packages/oauth/oauth-client/src/oauth-session.ts similarity index 85% rename from packages/oauth/oauth-client/src/oauth-agent.ts rename to packages/oauth/oauth-client/src/oauth-session.ts index 21bca4d7cc2..94b14e8f29d 100644 --- a/packages/oauth/oauth-client/src/oauth-agent.ts +++ b/packages/oauth/oauth-client/src/oauth-session.ts @@ -1,5 +1,5 @@ +import { asDid } from '@atproto/did' import { Fetch, bindFetch } from '@atproto-labs/fetch' -import { JwtPayload, unsafeDecodeJwt } from '@atproto/jwk' import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types' import { TokenInvalidError } from './errors/token-invalid-error.js' @@ -12,7 +12,16 @@ const ReadableStream = globalThis.ReadableStream as | typeof globalThis.ReadableStream | undefined -export class OAuthAgent { +export type TokenInfo = { + expiresAt?: Date + expired?: boolean + scope?: string + iss: string + aud: string + sub: string +} + +export class OAuthSession { protected dpopFetch: Fetch constructor( @@ -32,40 +41,34 @@ export class OAuthAgent { }) } - get serverMetadata(): Readonly { - return this.server.serverMetadata + get did() { + return asDid(this.sub) } - public async refreshIfNeeded(): Promise { - await this.getTokenSet(undefined) + get serverMetadata(): Readonly { + return this.server.serverMetadata } /** * @param refresh See {@link SessionGetter.getSession} */ - protected async getTokenSet(refresh?: boolean): Promise { + public async getTokenSet(refresh?: boolean): Promise { const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh) return tokenSet } - async getInfo(): Promise<{ - userinfo?: JwtPayload - expired?: boolean - scope?: string - iss: string - aud: string - sub: string - }> { - const tokenSet = await this.getTokenSet() + async getTokenInfo(refresh?: boolean): Promise { + const tokenSet = await this.getTokenSet(refresh) + const expiresAt = + tokenSet.expires_at == null ? undefined : new Date(tokenSet.expires_at) return { - userinfo: tokenSet.id_token - ? unsafeDecodeJwt(tokenSet.id_token).payload - : undefined, - expired: - tokenSet.expires_at == null + expiresAt, + get expired() { + return expiresAt == null ? undefined - : new Date(tokenSet.expires_at).getTime() < Date.now() - 5e3, + : expiresAt.getTime() < Date.now() - 5e3 + }, scope: tokenSet.scope, iss: tokenSet.iss, aud: tokenSet.aud, @@ -85,7 +88,7 @@ export class OAuthAgent { } } - async request(pathname: string, init?: RequestInit): Promise { + async fetchHandler(pathname: string, init?: RequestInit): Promise { // This will try and refresh the token if it is known to be expired const tokenSet = await this.getTokenSet(undefined) diff --git a/packages/xrpc/src/client.ts b/packages/xrpc/src/client.ts index 0aa4460a1bc..4d36b6aa0ca 100644 --- a/packages/xrpc/src/client.ts +++ b/packages/xrpc/src/client.ts @@ -59,7 +59,6 @@ export class Client { /** @deprecated Use {@link XrpcClient} instead */ export class ServiceClient extends XrpcClient { uri: URL - headers: Record = {} constructor( public baseClient: Client, @@ -71,12 +70,4 @@ export class ServiceClient extends XrpcClient { }, baseClient.lex) this.uri = typeof serviceUri === 'string' ? new URL(serviceUri) : serviceUri } - - setHeader(key: string, value: string): void { - this.headers[key] = value - } - - unsetHeader(key: string): void { - delete this.headers[key] - } } diff --git a/packages/xrpc/src/fetch-handler.ts b/packages/xrpc/src/fetch-handler.ts index 6f236c8217c..d1f1ae3881a 100644 --- a/packages/xrpc/src/fetch-handler.ts +++ b/packages/xrpc/src/fetch-handler.ts @@ -39,11 +39,27 @@ export type BuildFetchHandlerOptions = { fetch?: typeof globalThis.fetch } +export interface FetchHandlerObject { + fetchHandler: ( + this: FetchHandlerObject, + /** + * The URL (pathname + query parameters) to make the request to, without the + * origin. The origin (protocol, hostname, and port) must be added by this + * {@link FetchHandler}, typically based on authentication or other factors. + */ + url: string, + init: RequestInit, + ) => Promise +} + export function buildFetchHandler( - options: FetchHandler | FetchHandlerOptions, + options: FetchHandler | FetchHandlerObject | FetchHandlerOptions, ): FetchHandler { // Already a fetch handler (allowed for convenience) if (typeof options === 'function') return options + if (typeof options === 'object' && 'fetchHandler' in options) { + return options.fetchHandler.bind(options) + } const { service, diff --git a/packages/xrpc/src/types.ts b/packages/xrpc/src/types.ts index 41e01a39d77..003cd097809 100644 --- a/packages/xrpc/src/types.ts +++ b/packages/xrpc/src/types.ts @@ -4,8 +4,10 @@ import { ValidationError } from '@atproto/lexicon' export type QueryParams = Record export type HeadersMap = Record -/** @deprecated not to be confused with the WHATWG Headers constructor */ -export type Headers = HeadersMap +export type { + /** @deprecated not to be confused with the WHATWG Headers constructor */ + HeadersMap as Headers, +} export type Gettable = T | (() => T) @@ -101,7 +103,7 @@ export class XRPCResponse { constructor( public data: any, - public headers: Headers, + public headers: HeadersMap, ) {} } @@ -114,7 +116,7 @@ export class XRPCError extends Error { statusCode: number, public error: string = httpResponseCodeToName(statusCode), message?: string, - public headers?: Headers, + public headers?: HeadersMap, options?: ErrorOptions, ) { super(message || error || httpResponseCodeToString(statusCode), options) @@ -133,22 +135,37 @@ export class XRPCError extends Error { return cause } - // Extract status code from "http-errors" like errors + // Type cast the cause to an Error if it is one + const causeErr = cause instanceof Error ? cause : undefined + + // Try and find a Response object in the cause + const causeResponse: Response | undefined = + cause instanceof Response + ? cause + : cause?.['response'] instanceof Response + ? cause['response'] + : undefined + const statusCode: unknown = - cause instanceof Error - ? ('statusCode' in cause ? cause.statusCode : undefined) ?? - ('status' in cause ? cause.status : undefined) - : undefined + // Extract status code from "http-errors" like errors + causeErr?.['statusCode'] ?? + causeErr?.['status'] ?? + // Use the status code from the response object as fallback + causeResponse?.status + // Convert the status code to a ResponseType const status: ResponseType = typeof statusCode === 'number' ? httpResponseCodeToEnum(statusCode) : fallbackStatus ?? ResponseType.Unknown - const error = ResponseTypeNames[status] - const message = cause instanceof Error ? cause.message : String(cause) + const message = causeErr?.message ?? String(cause) + + const headers = causeResponse + ? Object.fromEntries(causeResponse.headers.entries()) + : undefined - return new XRPCError(status, error, message, undefined, { cause }) + return new XRPCError(status, undefined, message, headers, { cause }) } } diff --git a/packages/xrpc/src/xrpc-client.ts b/packages/xrpc/src/xrpc-client.ts index 423bc98128f..32b29941195 100644 --- a/packages/xrpc/src/xrpc-client.ts +++ b/packages/xrpc/src/xrpc-client.ts @@ -1,11 +1,13 @@ import { LexiconDoc, Lexicons, ValidationError } from '@atproto/lexicon' import { FetchHandler, + FetchHandlerObject, FetchHandlerOptions, buildFetchHandler, } from './fetch-handler' import { CallOptions, + Gettable, QueryParams, ResponseType, XRPCError, @@ -14,6 +16,7 @@ import { httpResponseCodeToEnum, } from './types' import { + combineHeaders, constructMethodCallHeaders, constructMethodCallUrl, encodeMethodCallBody, @@ -24,10 +27,11 @@ import { export class XrpcClient { readonly fetchHandler: FetchHandler + readonly headers = new Map>() readonly lex: Lexicons constructor( - fetchHandlerOpts: FetchHandler | FetchHandlerOptions, + fetchHandlerOpts: FetchHandler | FetchHandlerObject | FetchHandlerOptions, // "Lexicons" is redundant here (because that class implements // "Iterable") but we keep it for explicitness: lex: Lexicons | Iterable, @@ -37,6 +41,18 @@ export class XrpcClient { this.lex = lex instanceof Lexicons ? lex : new Lexicons(lex) } + setHeader(key: string, value: Gettable): void { + this.headers.set(key.toLowerCase(), value) + } + + unsetHeader(key: string): void { + this.headers.delete(key.toLowerCase()) + } + + clearHeaders(): void { + this.headers.clear() + } + async call( methodNsid: string, params?: QueryParams, @@ -65,7 +81,7 @@ export class XrpcClient { // anywhere in docs or types. See whatwg/fetch#1438, nodejs/node#46221. const init: RequestInit & { duplex: 'half' } = { method: reqMethod, - headers: reqHeaders, + headers: combineHeaders(reqHeaders, this.headers), body: reqBody, duplex: 'half', signal: opts?.signal, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca6d36b232e..d48c2a5ec55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -771,9 +771,6 @@ importers: '@atproto-labs/simple-store-memory': specifier: workspace:* version: link:../../internal/simple-store-memory - '@atproto/api': - specifier: workspace:* - version: link:../../api '@atproto/did': specifier: workspace:* version: link:../../did @@ -918,9 +915,6 @@ importers: specifier: workspace:* version: link:../oauth-types devDependencies: - '@atproto/api': - specifier: workspace:* - version: link:../../api typescript: specifier: ^5.3.3 version: 5.4.4