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