Authentication
Auth in Opake is OAuth by default, with legacy app passwords as an escape hatch for places OAuth can't reach. Whichever flow you pick, the user's password stays at their PDS and the resulting tokens stay inside WASM.
Pick a shape #
Two surfaces depending on how your app handles the redirect to the authorization server:
Opake.startLogin+Opake.completeLoginis the explicit two-step. Safe across a browser navigation, because state persists throughsessionStoragewhile the user is off authorizing.Opake.loginis the composed wrapper. One call, given anauthorizecallback you supply for the round-trip. Useful when JS stays alive through authorization (popup window, ElectronBrowserWindow, mobile custom URL scheme).
SPA that navigates to the AS and comes back: almost always the two-step. Embedded flows where you can keep the original page alive: either one works.
The two-step flow #
On the page that triggers login:
import { Opake } from "@opake/sdk";
import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
const storage = new IndexedDbStorage();
const { authUrl, pending } = await Opake.startLogin("alice.bsky.social", {
redirectUri: "https://myapp.com/callback",
});
Opake.savePendingLogin(pending);
window.location.href = authUrl;On the callback page:
import { Opake } from "@opake/sdk";
import { IndexedDbStorage } from "@opake/sdk/storage/indexeddb";
const storage = new IndexedDbStorage();
const pending = Opake.loadPendingLogin();
if (!pending) {
// No pending state, or expired (10-minute TTL). Send back to login.
window.location.href = "/login";
}
const params = new URLSearchParams(window.location.search);
await Opake.completeLogin(
params.get("code")!,
params.get("state")!,
pending!,
{ storage, redirectUri: "https://myapp.com/callback" },
);
const opake = await Opake.init({ storage });savePendingLogin stashes the DPoP key and PKCE verifier in sessionStorage under a namespaced
key. loadPendingLogin reads that state and clears the storage entry on the same call, so the
DPoP key doesn't linger after the flow completes or fails. The pending state has a ten-minute
TTL; any older entry reads back as null.
Always pass the same redirectUri to both calls. It's part of the OAuth exchange that the AS
verifies, and a mismatch rejects the token exchange with no useful error message.
The composed flow #
When JS stays alive through authorization, Opake.login wraps both steps around an authorize
callback:
await Opake.login("alice.bsky.social", {
storage,
redirectUri: "https://myapp.com/callback",
authorize: async (authUrl) => {
const popup = window.open(authUrl, "_blank", "width=600,height=800");
// Resolve with { code, state } however your host surfaces the callback —
// postMessage from the popup, main-window listener on custom URL scheme,
// Electron BrowserWindow webContents event, etc.
return await waitForCallback(popup);
},
});The SDK calls your authorize function with the AS URL and waits for { code, state } back,
then finishes the exchange internally. You never see the DPoP key or pending state; it never
leaves WASM.
App password fallback #
OAuth isn't always reachable: Obsidian plugins, scripts without a browser, CI jobs, sandboxes
without fetch redirects. For those, the PDS exposes app passwords. The user generates one in
their PDS account settings, and you sign in with it once:
import { Opake } from "@opake/sdk";
await Opake.loginWithAppPassword(
"alice.bsky.social",
"xxxx-xxxx-xxxx-xxxx",
{ storage },
);
const opake = await Opake.init({ storage });App passwords are scoped credentials, not the user's real password. The user can revoke one
from the PDS settings at any time. The session lands in Storage with type: "legacy" instead
of "oauth"; everything you do with the Opake instance afterwards is identical. Refresh goes
through com.atproto.server.refreshSession rather than OAuth's token endpoint, but the SDK
handles that for you.
Token refresh is automatic #
Every authenticated SDK method runs through a token guard before firing. The guard checks
tokenExpiresAt() (a sync WASM call that returns only the timestamp, not the token). If the
access token is within 30 seconds of expiry, the guard triggers proactiveRefresh() before the
main call. Concurrent callers share a single in-flight refresh promise, so two parallel
operations don't each fire their own refresh round.
Reactive refresh is the backup. If the PDS returns ExpiredToken unexpectedly, the XRPC client
catches it, refreshes inline, and retries the original request. You don't need to write any
refresh logic in application code.
Post-init error branching #
After Opake.init({ storage }), there are three failure modes worth special-casing:
import { Opake, OpakeError } from "@opake/sdk";
try {
const opake = await Opake.init({ storage });
// Signed in, identity loaded. Proceed.
} catch (err) {
if (err instanceof OpakeError) {
switch (err.kind) {
case "NotFound":
// No session in storage for this DID. Send to login.
window.location.href = "/login";
break;
case "IdentityMissing":
// Session is live, but no encryption identity yet on this device.
// Route to identity creation, seed-phrase recovery, or device pairing.
window.location.href = "/recover-or-pair";
break;
case "Auth":
// Session exists but refresh failed. Credentials are stale.
await storage.clearSession(accounts[0]!.did);
window.location.href = "/login";
break;
default:
throw err;
}
} else {
throw err;
}
}IdentityMissing is the one most apps forget. It fires on a freshly-signed-in device that has
a session but no Identity in Storage. That isn't an error in the "something went wrong" sense;
it's the shape of a newly-paired-but-not-yet-bootstrapped account. See Identity &
pairing for how to resolve it from both ends.
Multiple accounts #
One Storage can hold multiple signed-in accounts. Opake.listAccounts returns all of them:
const accounts = await Opake.listAccounts(storage);
for (const { did, handle, pdsUrl } of accounts) {
console.log(handle, did, pdsUrl);
}
// Open a specific account:
const opake = await Opake.init({ storage, did: accounts[0]!.did });Without did, Opake.init opens the default account (the one marked default in Config).
Passing an explicit DID overrides that for this instance.
To sign an account out without removing its identity from storage, clear the session:
await storage.clearSession(did). To forget the account entirely (identity, session, cached
records), call opake.removeAccount(did) on an instance that currently holds it.