signIn

tcmts.atproto.signIn · #gcydztxj · term · runs on the server

uses node:crypto

Sign in as who (a handle or DID) with OAuth, in the browser, for scope: as AT Protocol's loopback client (no client metadata to host), with PKCE and DPoP-bound tokens. tell says what's happening.

import {
  authServerFor, b64url, dpopFetch, loopbackServer, oauthAnswer, okJson, openBrowser, resolveIdentity,
} from "#codebase/tcmts/atproto";
import type { OAuthSession, TokenAnswer } from "#codebase/tcmts/atproto";
import crypto from "node:crypto";

export async function signIn(
  who: string,
  scope: string,
  tell: (line: string) => void = console.log,
): Promise<OAuthSession> {
  const id = await resolveIdentity(who);
  const as = await authServerFor(id.pds);
  const dpopKey = crypto
    .generateKeyPairSync("ec", { namedCurve: "P-256" })
    .privateKey.export({ format: "jwk" });
  const verifier = b64url(crypto.randomBytes(32));
  const state = b64url(crypto.randomBytes(16));
  const back = await loopbackServer(undefined, state);
  try {
    const redirect = `http://127.0.0.1:${back.port}/callback`;
    const clientId = `http://localhost?${new URLSearchParams({ redirect_uri: redirect, scope })}`;
    const form = { "content-type": "application/x-www-form-urlencoded" };
    const par = await okJson<{ request_uri: string }>(
      await dpopFetch(dpopKey, as.pushed_authorization_request_endpoint, {
        method: "POST",
        headers: form,
        body: new URLSearchParams({
          client_id: clientId,
          response_type: "code",
          code_challenge: b64url(crypto.createHash("sha256").update(verifier).digest()),
          code_challenge_method: "S256",
          redirect_uri: redirect,
          scope,
          state,
          login_hint: id.handle ?? id.did,
        }).toString(),
      }),
      `signing in at ${as.issuer}`,
    );
    const url = `${as.authorization_endpoint}?${new URLSearchParams({ client_id: clientId, request_uri: par.request_uri })}`;
    tell(`signing in as ${id.handle ?? id.did}, in the browser (if it doesn't open: ${url})`);
    openBrowser(url);
    const q = await back.answer;
    const code = oauthAnswer(q, state, as.issuer);
    const t = await okJson<TokenAnswer>(
      await dpopFetch(dpopKey, as.token_endpoint, {
        method: "POST",
        headers: form,
        body: new URLSearchParams({
          grant_type: "authorization_code",
          code,
          redirect_uri: redirect,
          client_id: clientId,
          code_verifier: verifier,
        }).toString(),
      }),
      `signing in at ${as.issuer}`,
    );
    if (t.sub !== id.did) throw new Error(`signed in as ${t.sub}, not ${id.did}`);
    return {
      did: id.did,
      ...(id.handle ? { handle: id.handle } : {}),
      pds: id.pds,
      issuer: as.issuer,
      tokenEndpoint: as.token_endpoint,
      clientId,
      dpopKey,
      accessToken: t.access_token,
      ...(t.refresh_token ? { refreshToken: t.refresh_token } : {}),
      expiresAt: Date.now() + (t.expires_in ?? 300) * 1000,
      scope: t.scope ?? scope,
    };
  } finally {
    back.close();
  }
}

uses

used by