OAuth Client
The core OAuth client handles DPoP key provisioning, session registration, and session restoration against a HappyView instance. It's platform-agnostic — you provide a CryptoAdapter and optional StorageAdapter for your environment.
If you're building a browser app, use the Browser Client instead. It wraps this package with Web Crypto, localStorage, and a complete OAuth redirect flow.
Installation
npm install @happyview/oauth-clientSetup
import { HappyViewOAuthClient } from "@happyview/oauth-client";
const client = new HappyViewOAuthClient({
instanceUrl: "https://happyview.example.com",
clientKey: "hvc_your_client_key",
clientSecret: "hvs_your_secret", // optional, for confidential clients
crypto: myCryptoAdapter,
storage: myStorageAdapter, // optional, defaults to in-memory
});The clientSecret parameter makes this a confidential client. Omit it for public clients (browser apps), which use PKCE instead. See Authentication — API clients for details.
DPoP key provisioning
Request a DPoP keypair from the HappyView instance. This is the first step of the DPoP key provisioning flow.
const { provisionId, dpopKey, pkceVerifier } =
await client.provisionDpopKey();For public clients, pkceVerifier is included and must be passed back when registering the session. For confidential clients it will be undefined.
Use the returned dpopKey (a private JWK) as your DPoP keypair during your atproto OAuth flow with the user's PDS.
Client assertions
If your app is a confidential atproto OAuth client — its client_id_url document publishes token_endpoint_auth_method: "private_key_jwt" and a jwks_uri pointing back at HappyView — the PDS requires a signed private_key_jwt assertion at two points during the OAuth flow: the pushed authorization request (PAR) and the token exchange. HappyView holds the signing key on your behalf, so ask it to sign each one:
const { clientAssertion, clientAssertionType } =
await client.getClientAssertion(pdsIssuer);pdsIssuer is the issuer field from the PDS authorization server's metadata. Attach clientAssertion and clientAssertionType as the client_assertion and client_assertion_type form parameters on the request.
Public clients don't need this — PKCE is their proof of possession, and pkceVerifier from provisionDpopKey covers it. getClientAssertion is unrelated to isConfidential on this class, which only describes whether this SDK instance authenticates to HappyView with a client secret, not whether your app is a confidential OAuth client to a user's PDS.
See API Clients — Confidential clients: attaching the client assertion for the endpoint this wraps and the full manual flow.
Session registration
After completing OAuth authorization with the user's PDS, register the session with HappyView:
const session = await client.registerSession({
provisionId,
pkceVerifier, // required for public clients
did: "did:plc:abc123",
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
scopes: "atproto",
pdsUrl: "https://bsky.social",
issuer: tokens.iss,
dpopKey,
});The returned HappyViewSession is ready to make authenticated requests. The session data is also persisted to the StorageAdapter for later restoration.
The response includes the scopes that were approved by the authorization server, available on the session:
console.log(session.scopes);
// ["atproto", "transition:generic"]Retrieving session info
To fetch the current session's approved scopes from the server (e.g., after restoring from storage):
const info = await client.getSession("did:plc:abc123");
console.log(info.scopes);
// ["atproto", "transition:generic"]Making authenticated requests
HappyViewSession.fetchHandler works like fetch but automatically attaches DPoP proof, authorization, and client key headers:
// Relative path — prepends the HappyView instance URL
const response = await session.fetchHandler(
"/xrpc/com.example.getStuff?limit=10",
{ method: "GET" },
);
// Absolute URL — used as-is
const response = await session.fetchHandler(
"https://other-service.example.com/xrpc/test.method",
{ method: "GET" },
);Session restoration
Restore a previously stored session without re-authenticating:
// Restore the last active session
const session = await client.restore();
// Restore a specific user's session
const session = await client.restoreSession("did:plc:abc123");Returns null if no stored session is found.
Logout
await client.deleteSession("did:plc:abc123");This deletes the session from HappyView and local storage, and revokes it at the user's PDS.
That last part matters: without it the session stays listed under the account's active sessions on their PDS until its refresh token expires — up to two years — even though they logged out. Revocation is best-effort, so a PDS that is unreachable or does not implement RFC 7009 will not make the logout fail.
Signing in again replaces the previous session
Each login runs a full OAuth authorization and so creates a new session on the user's PDS; the DPoP key is minted fresh and the old key is overwritten in storage. Left alone, that means every re-login strands the previous session on their account with no way to reach it.
registerSession therefore retires this client's previous session for the same account as part of completing a login — revoking it at the PDS while the credentials to do so still exist. It is scoped to the account being signed into, so sessions for other accounts, and sessions belonging to the same account on other devices, are untouched. No onSessionDelete hook fires: the user is signing in, not out.
The local cleanup always happens. A 404, 401, or 403 from the server is treated as a completed logout — the session is either already gone or the credential is no longer usable, so there is nothing left to revoke. A 5xx or a network error still throws, because the server may genuinely still hold a live session and you should know that, but it throws after the local session has been cleared. Either way the user ends up logged out on this device, and calling deleteSession again is safe.
Forgetting a session locally
await client.forgetSession("did:plc:abc123");Clears the stored session without contacting the server. Use it when revocation is impossible — an unreachable instance, or a credential the server has already rejected. Nothing is revoked, so the instance may still consider the session live until it expires naturally.
Storage keys
STORAGE_PREFIX ("happyview:session:") and LAST_ACTIVE_KEY ("happyview:last-active-did") are exported, so tooling that needs to inspect or clear stored sessions directly can do so without hardcoding the format:
import { STORAGE_PREFIX, LAST_ACTIVE_KEY } from "@happyview/oauth-client";Adapters
CryptoAdapter
Implement this interface for your platform's cryptographic primitives:
interface CryptoAdapter {
generatePkceVerifier(): Promise<string>;
computePkceChallenge(verifier: string): Promise<string>;
signEs256(privateKey: JsonWebKey, payload: Uint8Array): Promise<Uint8Array>;
sha256(data: Uint8Array): Promise<Uint8Array>;
getRandomValues(length: number): Uint8Array;
}StorageAdapter
Implement this interface to persist sessions:
interface StorageAdapter {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
}If no StorageAdapter is provided, sessions are stored in memory and won't survive page reloads or process restarts.
Error handling
All errors extend HappyViewError:
| Error | When |
|---|---|
ApiError | HappyView API returned a non-OK response (has status and body) |
AuthenticationError | Authentication failed (default status 401) |
InvalidStateError | Missing or invalid OAuth state |
TokenExchangeError | Token exchange with the PDS failed (has status and body) |
ResolutionError | Handle or DID resolution failed |
import { ApiError } from "@happyview/oauth-client";
try {
await client.registerSession(params);
} catch (err) {
if (err instanceof ApiError) {
console.error(`API error ${err.status}:`, err.body);
}
}