OAuth Client Browser

Browser Client

The browser client handles the full OAuth redirect flow for browser apps authenticating with a HappyView instance. It wraps the OAuth Client with Web Crypto, localStorage, and atproto handle/DID resolution.

If you're starting a new app, consider using @happyview/lex-agent with @atproto/lex instead — it provides type-safe XRPC calls and is the recommended way to interact with HappyView. This package is primarily useful if your app already uses @atproto/oauth-client-browser and you want to add HappyView authentication alongside it.

Installation

npm install @happyview/oauth-client-browser

Setup

import { HappyViewBrowserClient } from "@happyview/oauth-client-browser";

const client = new HappyViewBrowserClient({
  instanceUrl: "https://happyview.example.com",
  clientId: "https://example.com/oauth-client-metadata.json",
  clientKey: "hvc_your_client_key",
});
OptionRequiredDescription
instanceUrlYesThe HappyView instance URL
clientIdYesURL where your app serves its OAuth client metadata
clientKeyYesAPI client key from the HappyView admin dashboard
redirectUriNoOAuth callback URL. Defaults to ${window.location.origin}/oauth/callback
scopesNoOAuth scopes to request. Defaults to "atproto"
storageNoCustom storage adapter. Defaults to localStorage
sessionHooksNoEvent hooks for session lifecycle events
fetchNoCustom fetch implementation

The client uses localStorage by default. You can override it:

const client = new HappyViewBrowserClient({
  instanceUrl: "https://happyview.example.com",
  clientId: "https://example.com/oauth-client-metadata.json",
  clientKey: "hvc_your_client_key",
  storage: myCustomStorageAdapter,
});

Sign in

signIn() resolves the user's handle, discovers their PDS, provisions a DPoP key, and redirects the browser to the PDS authorization server:

await client.signIn("alice.bsky.social");
// Browser redirects — code stops here

To sign in via a popup window instead:

const session = await client.signIn("alice.bsky.social", {
  display: "popup",
});

Or use the explicit methods:

// Full-page redirect (equivalent to signIn without display option)
await client.signInRedirect("alice.bsky.social");

// Popup window
const session = await client.signInPopup("alice.bsky.social");

If you need the authorization URL without redirecting (e.g., for a custom UI), use prepareLogin():

const { authorizationUrl, did, state } =
  await client.prepareLogin("alice.bsky.social");

What happens during sign in

  1. The handle is resolved to a DID via resolveHandleToDid.
  2. The DID document is fetched to find the PDS URL.
  3. The PDS's OAuth authorization server metadata is fetched.
  4. A DPoP key is provisioned from HappyView.
  5. PKCE challenge/verifier pairs are generated (one for HappyView's DPoP provisioning, one for the PDS authorization server).
  6. The pending auth state is stored in localStorage.
  7. The browser is redirected to the PDS authorization endpoint (or a popup is opened).

Initialization

On page load, call init() to automatically handle both session restoration and OAuth callbacks:

const result = await client.init();
if (result) {
  const { session, state } = result;
  // session is ready to use
}

init() checks the URL for OAuth callback parameters. If found, it processes the callback and returns { session, state }. Otherwise, it tries to restore the last active session from localStorage.

For more control, use the specific methods:

// Restore only — ignores callback params in the URL
const result = await client.initRestore();
if (result) {
  const { session } = result;
}

// Callback only — throws if no callback params are present
const { session, state } = await client.initCallback();

Restoring a specific session

To restore a specific user's session by DID:

const session = await client.restore("did:plc:abc123");

Calling restore() with no arguments returns the last active session, or null if none is found.

Detecting callback params

readCallbackParams() checks the current URL for OAuth callback parameters without processing them. This is useful when your app uses client-side routing and needs to detect callbacks before the router changes the URL:

const params = client.readCallbackParams();
if (params) {
  // URL contains OAuth callback params — process them
  const { session } = await client.initCallback();
}

Checking approved scopes

After sign in or session restoration, you can check which scopes were approved:

console.log(session.scopes);
// ["atproto", "transition:generic"]

To fetch the latest scopes from the server:

const info = await client.getSession("did:plc:abc123");
console.log(info.scopes);
// ["atproto", "transition:generic"]

Session

Authenticated requests

The session's fetchHandler attaches DPoP proof headers automatically:

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).

Revoke session

await client.revoke(session.did);

Resolution utilities

PropertyTypeDescription
didstringThe authenticated user's DID
substringAlias for did (matches upstream naming)

Sign out

Sessions can self-revoke:

await session.signOut();

This is equivalent to calling client.revoke(session.did).

Session event hooks

React to session lifecycle events with sessionHooks:

const client = new HappyViewBrowserClient({
  // ...
  sessionHooks: {
    onSessionUpdate(did) {
      console.log(`Session created/updated for ${did}`);
    },
    onSessionDelete(did) {
      console.log(`Session deleted for ${did}`);
    },
  },
});
  • onSessionUpdate(did) fires after a new session is registered (from callback()).
  • onSessionDelete(did) fires after a session is revoked (from revoke(), logout(), or session.signOut()).

Error handling

Callback errors are always wrapped in OAuthCallbackError, which carries the original callback params and state:

import { OAuthCallbackError } from "@happyview/oauth-client-browser";

try {
  const session = await client.callback();
} catch (err) {
  if (err instanceof OAuthCallbackError) {
    console.log(err.state);           // the state from the callback
    console.log(err.params.get("error")); // e.g. "access_denied"
    console.log(err.cause);           // the underlying error, if any
  }
}

If the authorization server returns an error (e.g., the user denied access), the params contain the error and error_description fields from the server response. If the token exchange fails, the underlying TokenExchangeError is available as err.cause.

Using with @atproto/api

HappyViewSession is directly compatible with @atproto/api's Agent. Pass it as the session manager:

import { Agent } from "@atproto/api";

const result = await client.init();
if (result) {
  const agent = new Agent(result.session);

  // Use the full @atproto/api surface
  const profile = await agent.getProfile({ actor: agent.did });
  await agent.like(postUri, postCid);
}

This works because HappyViewSession implements the SessionManager interface that Agent expects — it has did and a fetchHandler that attaches DPoP authentication headers and prepends the HappyView instance URL.

Revoke session

From the client:

await client.revoke(session.did);

Or from the session itself:

await session.signOut();

Identity resolution

The client exposes its handle and DID resolvers for advanced use:

const did = await client.handleResolver.resolve("alice.bsky.social");
const doc = await client.didResolver.resolve(did);

Validate client metadata

Verify that your OAuth client metadata is served correctly:

import { HappyViewBrowserClient } from "@happyview/oauth-client-browser";

const metadata = await HappyViewBrowserClient.fetchMetadata({
  clientId: "https://example.com/oauth-client-metadata.json",
});
console.log(metadata.client_name);

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.

Example for a Next.js app:

// src/app/oauth-client-metadata.json/route.ts
import { type NextRequest } from "next/server";

export function GET(request: NextRequest) {
  const origin = request.nextUrl.origin;

  return Response.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,
  });
}

For a static site, serve a plain JSON file at /oauth-client-metadata.json.

The redirect_uris array must include the redirectUri your client is configured with (defaults to ${origin}/oauth/callback).

Local development

For local development with ATProto's loopback client ID convention, use buildLoopbackClientId:

import { buildLoopbackClientId } from "@happyview/oauth-client-browser";

const clientId = buildLoopbackClientId(window.location);
// → "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2F"

This builds a client ID that authorization servers recognize as a local development app. The redirect_uri is encoded in the client ID URL query string.

Cleanup

The browser client implements AsyncDisposable for use with await using:

await using client = new HappyViewBrowserClient({ ... });
// client.dispose() called automatically when scope exits

Or call dispose() manually:

client.dispose();

Re-exports

This package re-exports everything from @happyview/oauth-client, @atproto-labs/handle-resolver, and @atproto-labs/did-resolver. You don't need to install these packages separately:

import {
  // From @happyview/oauth-client
  HappyViewBrowserClient,
  HappyViewSession,
  ApiError,
  OAuthCallbackError,
  Key,
  type SessionEventHooks,
  type StorageAdapter,
  type TokenInfo,
  type Jwk,

  // From @atproto-labs/handle-resolver
  AtprotoDohHandleResolver,

  // From @atproto-labs/did-resolver
  DidResolverCommon,
  type DidDocument,
} from "@happyview/oauth-client-browser";