diff --git a/packages/docs/docs/sdk/oauth-client-node.md b/packages/docs/docs/sdk/oauth-client-node.md new file mode 100644 index 0000000..4c6c84d --- /dev/null +++ b/packages/docs/docs/sdk/oauth-client-node.md @@ -0,0 +1,226 @@ +# Node Client + +The Node client handles OAuth authorization and callback flows for server-side Node.js apps authenticating with a HappyView instance. It wraps the [OAuth Client](./oauth-client.md) with handle/DID resolution and a server-friendly API that returns authorization URLs instead of redirecting the browser. + +## Installation + +```bash +npm install @happyview/oauth-client-node +``` + +## Setup + +```typescript +import { HappyViewNodeClient } from "@happyview/oauth-client-node"; + +const client = new HappyViewNodeClient({ + instanceUrl: "https://happyview.example.com", + clientId: "https://example.com/oauth-client-metadata.json", + clientKey: "hvc_your_client_key", + clientSecret: "hvs_your_secret", // optional, for confidential clients + redirectUri: "https://example.com/oauth/callback", + storage: myStorageAdapter, +}); +``` + +| Option | Required | Description | +| ------------- | -------- | ---------------------------------------------------------------------------- | +| `instanceUrl` | Yes | The HappyView instance URL | +| `clientId` | Yes | URL where your app serves its [OAuth client metadata](#oauth-client-metadata) | +| `clientKey` | Yes | API client key from the HappyView admin dashboard | +| `clientSecret`| No | API client secret — makes this a confidential client | +| `redirectUri` | Yes | OAuth callback URL for your server | +| `scopes` | No | OAuth scopes to request. Defaults to `"atproto"` | +| `storage` | Yes | Storage adapter for persisting sessions and pending auth state | +| `sessionHooks`| No | Hooks called on session lifecycle events | +| `fetch` | No | Custom fetch implementation | + +:::note +Unlike the browser client, `storage` is required — there is no default. Use any adapter that implements `StorageAdapter` (e.g., backed by Redis, a database, or the filesystem). +::: + +## Authorization + +`authorize()` resolves the user's handle, discovers their PDS, provisions a DPoP key, and returns an authorization URL. Your server should redirect the user to this URL: + +```typescript +// In your login route handler +const url = await client.authorize("alice.bsky.social"); +res.redirect(url.toString()); +``` + +### Authorization options + +Pass options to customize the authorization request: + +```typescript +const url = await client.authorize("alice.bsky.social", { + scope: "atproto transition:generic", + state: myCustomState, + redirect_uri: "https://example.com/alt-callback", + display: "popup", + prompt: "consent", + ui_locales: "en", +}); +``` + +| Option | Description | +| ------------- | ------------------------------------------------------------ | +| `scope` | Override the default scopes for this request | +| `state` | Custom state value (defaults to a random hex string) | +| `redirect_uri`| Override the default redirect URI for this request | +| `display` | `"page"`, `"popup"`, `"touch"`, or `"wap"` | +| `prompt` | Prompt behavior (e.g., `"consent"`, `"login"`) | +| `nonce` | Nonce for ID token validation | +| `max_age` | Maximum authentication age in seconds | +| `ui_locales` | Preferred UI languages | +| `signal` | AbortSignal to cancel the request | + +## Callback + +In your OAuth callback route, pass the query parameters to `callback()`: + +```typescript +// In your callback route handler +const params = new URLSearchParams(req.url.split("?")[1]); +const { session, state } = await client.callback(params); + +// session is ready to use +console.log(session.did); +console.log(session.scopes); +``` + +The returned `HappyViewSession` is persisted to storage and ready for authenticated requests. + +You can override the redirect URI if needed: + +```typescript +const { session } = await client.callback(params, { + redirect_uri: "https://example.com/alt-callback", +}); +``` + +## Checking approved scopes + +After authorization or session restoration, you can check which scopes were approved: + +```typescript +console.log(session.scopes); +// ["atproto", "transition:generic"] +``` + +To fetch the latest scopes from the server: + +```typescript +const info = await client.getSession("did:plc:abc123"); +console.log(info.scopes); +// ["atproto", "transition:generic"] +``` + +## Authenticated requests + +The session's `fetchHandler` attaches DPoP proof headers automatically: + +```typescript +const response = await session.fetchHandler( + "/xrpc/com.example.getStuff?limit=10", + { method: "GET" }, +); + +const data = await response.json(); +``` + +Pass a relative path (prepends the HappyView instance URL) or a full URL (used as-is). + +## Session restoration + +Restore a previously stored session by DID: + +```typescript +const session = await client.restore("did:plc:abc123"); +``` + +Unlike the browser client, `restore()` requires a DID — there is no "last active" session concept on the server. + +Throws `InvalidStateError` if no session is found for the given DID. + +## Revoke session + +```typescript +await client.revoke("did:plc:abc123"); +``` + +## Aborting a pending authorization + +If the user abandons the login flow, clean up the pending state: + +```typescript +await client.abortRequest(authorizationUrl); +``` + +## OAuth client metadata + +Your app must serve an OAuth client metadata JSON document at the URL you pass as `clientId`. The PDS fetches this during authorization to validate the redirect URI and display your app's information. + +```typescript +// Express example +app.get("/oauth-client-metadata.json", (req, res) => { + const origin = `${req.protocol}://${req.get("host")}`; + res.json({ + client_id: `${origin}/oauth-client-metadata.json`, + client_name: "My App", + client_uri: origin, + redirect_uris: [`${origin}/oauth/callback`], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + scope: "atproto", + application_type: "web", + dpop_bound_access_tokens: true, + }); +}); +``` + +The `redirect_uris` array must include the `redirectUri` your client is configured with. + +## Re-exports + +This package re-exports everything from `@happyview/oauth-client`, so you don't need to install the core package separately: + +```typescript +import { + HappyViewNodeClient, + HappyViewSession, + ApiError, + type StorageAdapter, +} from "@happyview/oauth-client-node"; +``` + +It also re-exports handle and DID resolution utilities from `@atproto-labs/handle-resolver` and `@atproto-labs/did-resolver`. + +## Error handling + +All errors extend `HappyViewError`. The Node client additionally uses `OAuthCallbackError` for callback failures: + +| Error | When | +| --- | --- | +| `ApiError` | HappyView API returned a non-OK response (has `status` and `body`) | +| `OAuthCallbackError` | OAuth callback failed — wraps the callback params and underlying error | +| `InvalidStateError` | Missing or invalid OAuth/session state | +| `TokenExchangeError` | Token exchange with the PDS failed (has `status` and `body`) | +| `ResolutionError` | Handle or DID resolution failed | + +```typescript +import { + OAuthCallbackError, + InvalidStateError, +} from "@happyview/oauth-client-node"; + +try { + const { session } = await client.callback(params); +} catch (err) { + if (err instanceof OAuthCallbackError) { + console.error("OAuth callback failed:", err.message); + console.error("State:", err.state); + } +} +``` diff --git a/packages/docs/docs/sdk/overview.md b/packages/docs/docs/sdk/overview.md index d2efce2..9229674 100644 --- a/packages/docs/docs/sdk/overview.md +++ b/packages/docs/docs/sdk/overview.md @@ -6,7 +6,8 @@ HappyView provides JavaScript packages for building third-party apps that authen | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | [`@happyview/lex-agent`](https://npmx.dev/package/@happyview/lex-agent) | Recommended — type-safe XRPC via [`@atproto/lex`](https://npmx.dev/package/@atproto/lex) `Client` with HappyView DPoP auth | | [`@happyview/oauth-client`](https://npmx.dev/package/@happyview/oauth-client) | Platform-agnostic core — DPoP key provisioning, session management, authenticated fetch | -| [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper for apps already using `@atproto/oauth-client-browser` | +| [`@happyview/oauth-client-browser`](https://npmx.dev/package/@happyview/oauth-client-browser) | Browser OAuth wrapper with Web Crypto, localStorage, and redirect/popup flows | +| [`@happyview/oauth-client-node`](https://npmx.dev/package/@happyview/oauth-client-node) | Node.js server wrapper with handle/DID resolution and server-friendly authorize/callback API | ## Which package do I need? @@ -14,7 +15,9 @@ HappyView provides JavaScript packages for building third-party apps that authen **Already using `@atproto/oauth-client-browser`?** Add `@happyview/oauth-client-browser` to get a `HappyViewBrowserClient` that handles the HappyView-specific DPoP key provisioning and session registration on top of the standard atproto OAuth flow. -**Building a server-side app or something more custom?** Use `@happyview/oauth-client` directly and provide your own `CryptoAdapter` and `StorageAdapter`. +**Building a server-side Node.js app?** Use `@happyview/oauth-client-node`. It provides `authorize()` and `callback()` methods designed for server request handlers, plus built-in handle and DID resolution. + +**Need something more custom?** Use `@happyview/oauth-client` directly and provide your own `StorageAdapter`. ## How it works @@ -65,4 +68,5 @@ const result = await lex.xrpc(myLexicons.com.example.getGame, { - [Lex Agent](./lex-agent.md): type-safe XRPC with `@atproto/lex` - [OAuth Client](./oauth-client.md): platform-agnostic core client - [Browser Client](./oauth-client-browser.md): browser OAuth redirect flow +- [Node Client](./oauth-client-node.md): server-side authorize/callback flow - [Authentication](../getting-started/authentication.md): full details on DPoP key provisioning and API client types diff --git a/packages/docs/scripts/generate-changelogs.mjs b/packages/docs/scripts/generate-changelogs.mjs index a2cd883..96f261f 100644 --- a/packages/docs/scripts/generate-changelogs.mjs +++ b/packages/docs/scripts/generate-changelogs.mjs @@ -22,6 +22,12 @@ const CHANGELOGS = [ match: (tag) => tag.startsWith("@happyview/oauth-client-browser-v"), formatVersion: (tag) => tag.replace("@happyview/oauth-client-browser-", ""), }, + { + name: "@happyview/oauth-client-node", + output: "docs/sdk/changelog-oauth-client-node.md", + match: (tag) => tag.startsWith("@happyview/oauth-client-node-v"), + formatVersion: (tag) => tag.replace("@happyview/oauth-client-node-", ""), + }, { name: "@happyview/lex-agent", output: "docs/sdk/changelog-lex-agent.md", diff --git a/packages/docs/sidebars.ts b/packages/docs/sidebars.ts index cdcfed2..2fecf3e 100644 --- a/packages/docs/sidebars.ts +++ b/packages/docs/sidebars.ts @@ -291,6 +291,11 @@ const sidebars: SidebarsConfig = { id: "sdk/oauth-client-browser", label: "Browser Client", }, + { + type: "doc", + id: "sdk/oauth-client-node", + label: "Node Client", + }, ], }, { @@ -469,6 +474,11 @@ const sidebars: SidebarsConfig = { id: "sdk/changelog-oauth-client-browser", label: "oauth-client-browser", }, + { + type: "doc", + id: "sdk/changelog-oauth-client-node", + label: "oauth-client-node", + }, { type: "doc", id: "sdk/changelog-lex-agent",