session
tcmts.examples.userinput.session · #5rjmifvk · ts file
// tcm: namespace userinput.account
// Signing in, as userinput.app does: AT Protocol OAuth, from the browser (a public client: the keys and tokens stay
// in this browser, and the server never sees them). On localhost it's the development ("loopback") client, which
// needs nothing hosted; on a domain, the client metadata document the app serves (`client`, below: run it with
// `tcm start userinput.app.App --http userinput.oauth=/oauth`). An app password works too, for scripts and tests.
import { useEffect, useState, useSyncExternalStore } from "react";
import { href } from "#codebase/userinput/lib/react";
import {
configureOAuth, createAuthorizationUrl, deleteStoredSession, finalizeAuthorization, getSession, OAuthUserAgent,
} from "@atcute/oauth-browser-client";
import {
CompositeDidDocumentResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver,
} from "@atcute/identity-resolver";
import { resolveIdentity, SCOPE, services } from "#codebase/userinput";
/** Someone signed in: who, and how to call their PDS as them. */
export interface Session {
did: string;
handle: string;
/** how they signed in */
kind: "oauth" | "password";
/** a request to their PDS (`/xrpc/...`), authenticated as them */
fetch(path: string, init?: RequestInit): Promise<Response>;
signOut(): Promise<void>;
}
/** Where the signed-in DID is kept between visits (the tokens are kept by the OAuth library). */
export const SIGNED_IN = "userinput.did";
/**
* The OAuth client this page is: the loopback one on localhost, else the metadata document the app serves (`client`).
* Either comes back to the app's home, under its base if it's served under one.
*/
export function clientMetadata(): { client_id: string; redirect_uri: string } {
const local = location.hostname === "localhost" || location.hostname === "127.0.0.1";
// (a loopback client must come back to 127.0.0.1, not localhost)
const redirect = `${location.origin.replace("//localhost", "//127.0.0.1")}${href("/")}`;
return local
? { client_id: `http://localhost?redirect_uri=${encodeURIComponent(redirect)}&scope=${encodeURIComponent(SCOPE)}`, redirect_uri: redirect }
: { client_id: `${location.origin}${href("/oauth/client")}`, redirect_uri: redirect };
}
/** Whether OAuth is set up on this page. */
export const configured = { done: false };
/** Set up the OAuth client, once per page. */
export function configure() {
if (configured.done) return;
configured.done = true;
configureOAuth({
metadata: clientMetadata(),
identityResolver: new LocalActorResolver({
handleResolver: new XrpcHandleResolver({ serviceUrl: services().appview }),
didDocumentResolver: new CompositeDidDocumentResolver({ methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() } }),
}),
});
}
/** A session from the OAuth library's. */
export async function oauthSession(s: Awaited<ReturnType<typeof getSession>>): Promise<Session> {
const agent = new OAuthUserAgent(s);
const did = s.info.sub;
const handle = await resolveIdentity(did).then((i) => i.handle).catch(() => did);
return {
did,
handle,
kind: "oauth",
fetch: (path, init) => agent.handle(path, init),
signOut: async () => {
try {
await agent.signOut();
} catch {
deleteStoredSession(did);
}
},
};
}
/** Send the browser to sign in as `handle` (it comes back to this page, which finishes it: `finishSignIn`). */
export async function startSignIn(handle: string): Promise<void> {
configure();
const url = await createAuthorizationUrl({ target: { type: "account", identifier: handle.trim().replace(/^@/, "") as `${string}.${string}` }, scope: SCOPE });
// (the library keeps what it needs in local storage: let it be written before leaving)
await new Promise((r) => setTimeout(r, 200));
location.assign(url);
}
/** If the page was opened coming back from signing in (its fragment holds the answer), finish signing in. */
export async function finishSignIn(): Promise<Session | undefined> {
const params = new URLSearchParams(location.hash.slice(1));
if (!params.has("state") || !(params.has("code") || params.has("error"))) return undefined;
// (so it isn't used twice)
history.replaceState(null, "", location.pathname + location.search);
configure();
const { session } = await finalizeAuthorization(params);
localStorage.setItem(SIGNED_IN, session.info.sub);
return oauthSession(session);
}
/** The session kept from a visit before, if there is one and it still works. */
export async function resumeSession(): Promise<Session | undefined> {
const did = localStorage.getItem(SIGNED_IN);
if (!did) return undefined;
if (did.startsWith("password:")) return passwordSession(JSON.parse(sessionStorage.getItem(did) ?? "null"));
configure();
try {
return await oauthSession(await getSession(did as `did:${string}:${string}`, { allowStale: true }));
} catch {
localStorage.removeItem(SIGNED_IN);
return undefined;
}
}
/** What an app-password sign-in keeps for the tab: the account's PDS and its tokens. */
export interface PasswordTokens {
did: string;
handle: string;
pds: string;
accessJwt: string;
refreshJwt: string;
}
/** A session from an app password's tokens (refreshed when they expire). */
export function passwordSession(t: PasswordTokens | null): Session | undefined {
if (!t) return undefined;
const tokens = { ...t };
const keep = () => sessionStorage.setItem(`password:${t.did}`, JSON.stringify(tokens));
const call = (path: string, init: RequestInit = {}, jwt = tokens.accessJwt) =>
fetch(`${tokens.pds}${path}`, { ...init, headers: { ...Object.fromEntries(new Headers(init.headers)), authorization: `Bearer ${jwt}` } });
return {
did: t.did,
handle: t.handle,
kind: "password",
async fetch(path, init) {
const res = await call(path, init);
if (res.status !== 400 && res.status !== 401) return res;
const err = (await res.clone().json().catch(() => ({}))) as { error?: string };
if (err.error !== "ExpiredToken") return res;
const r = await call("/xrpc/com.atproto.server.refreshSession", { method: "POST" }, tokens.refreshJwt);
if (!r.ok) return res;
Object.assign(tokens, (await r.json()) as Partial<PasswordTokens>);
keep();
return call(path, init);
},
async signOut() {
await call("/xrpc/com.atproto.server.deleteSession", { method: "POST" }, tokens.refreshJwt).catch(() => undefined);
sessionStorage.removeItem(`password:${t.did}`);
},
};
}
/** Where accounts sign in by email: Bluesky's entryway, which says which server holds the account. */
export const ENTRYWAY = "https://bsky.social";
/**
* Sign in with a handle (or an account's email) and a password, an app password preferably (kept for this tab only).
* A handle is looked up for its server; an email signs in through the entryway, which says which server it is.
*/
export async function signInWithPassword(identifier: string, password: string): Promise<Session> {
const id = identifier.trim().replace(/^@/, "");
const byEmail = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(id);
const who = byEmail ? undefined : await resolveIdentity(id);
const res = await fetch(`${who?.pds ?? ENTRYWAY}/xrpc/com.atproto.server.createSession`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ identifier: who?.did ?? id, password }),
});
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as { message?: string }).message ?? "That didn't work");
const t = (await res.json()) as PasswordTokens & { didDoc?: { service?: { id: string; serviceEndpoint: string }[] } };
const pds = who?.pds ?? t.didDoc?.service?.find((x) => x.id === "#atproto_pds")?.serviceEndpoint ?? ENTRYWAY;
const tokens = { did: t.did, handle: t.handle, pds, accessJwt: t.accessJwt, refreshJwt: t.refreshJwt };
sessionStorage.setItem(`password:${t.did}`, JSON.stringify(tokens));
localStorage.setItem(SIGNED_IN, `password:${t.did}`);
return passwordSession(tokens)!;
}
/** Who's signed in, as the page knows it: still finding out, nobody, or someone. */
export type SignedIn = { state: "checking" } | { state: "out"; error?: string } | { state: "in"; session: Session };
/** The page's sign-in state, shared by every component that asks (`useSignedIn`). */
export const signedIn = { current: { state: "checking" } as SignedIn, listeners: new Set<() => void>() };
/** Change the sign-in state, and tell the components showing it. */
export function setSignedIn(s: SignedIn) {
signedIn.current = s;
for (const l of signedIn.listeners) l();
}
/** The sign-in state; the component re-renders when it changes. */
export function useSignedIn(): SignedIn {
return useSyncExternalStore(
(l) => (signedIn.listeners.add(l), () => void signedIn.listeners.delete(l)),
() => signedIn.current,
() => signedIn.current,
);
}
/** The signed-in person's DID, if anyone is. */
export function useMe(): string | undefined {
const s = useSignedIn();
return s.state === "in" ? s.session.did : undefined;
}
/** Find out who's signed in, once, when the page starts: coming back from signing in, or from a visit before. */
export function useSessionStart() {
const [started, setStarted] = useState(false);
useEffect(() => {
if (started) return;
setStarted(true);
void (async () => {
try {
const s = (await finishSignIn()) ?? (await resumeSession());
setSignedIn(s ? { state: "in", session: s } : { state: "out" });
} catch (e) {
setSignedIn({ state: "out", error: (e as Error).message });
}
})();
}, [started]);
}
/** Sign out, here and on the account's server. */
export async function signOut() {
const s = signedIn.current;
if (s.state === "in") await s.session.signOut();
localStorage.removeItem(SIGNED_IN);
setSignedIn({ state: "out" });
}
// tcm: namespace userinput.oauth
/**
* The app's OAuth client metadata document, served where its client id says (`/oauth/client`, under the app's base:
* `tcm start userinput.app.App --http userinput.oauth=/oauth`): what an AT Protocol server reads to know the app
* that's asking. A public client, with DPoP, coming back to the app's home.
*/
export function client(request: Request): Record<string, unknown> {
const home = new URL(request.url).href.replace(/\/oauth\/client(?:\?.*)?$/, "/");
return {
client_id: `${home}oauth/client`,
client_name: "userinput (a tcm example)",
client_uri: home,
redirect_uris: [home],
scope: SCOPE,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
application_type: "web",
dpop_bound_access_tokens: true,
};
}
export const testClientMetadataFollowsTheAddress = () => {
const doc = client(new Request("https://docs.example/examples/userinput/oauth/client"));
if (doc.client_id !== "https://docs.example/examples/userinput/oauth/client") throw new Error(`client_id ${String(doc.client_id)}`);
if (JSON.stringify(doc.redirect_uris) !== '["https://docs.example/examples/userinput/"]') throw new Error(`redirect_uris ${JSON.stringify(doc.redirect_uris)}`);
if (client(new Request("https://app.example/oauth/client")).client_uri !== "https://app.example/") throw new Error("at a site's root");
};
used by