guide · auth bridge

Bridge AAM's consent flow into your existing auth library.

AAM doesn't replace your auth. It piggy-backs on it. When an agent wants to act on behalf of a logged-in user, it opens your /agent/authorize page, your existing login flow handles authentication, your existing user gets an "approve this agent?" UI, and on approval you mint an AAM auth code.

Pick your framework — each tab gives you a working /agent/authorize page, an /api/aam/token exchange handler, and a revocation hook that plugs into your settings page.

NextAuth v5 (auth.js). The only library-specific line is the call to auth() to read the current session — everything around it is the protocol-level dance you'd do for any framework.

01Add the shared signing helperlib/aam/sign.ts

Mints + verifies the short-lived auth_code that the agent exchanges for a bearer. Uses Node crypto, no extra deps. Set AAM_AUTH_CODE_SECRET in your env.

tsx
// lib/aam/sign.ts — shared signing helper used by both routes
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";

const SECRET = process.env.AAM_AUTH_CODE_SECRET!;

export function mintAuthCode(payload: { userId: string; scope: string; ttlSec: number }) {
  const exp = Math.floor(Date.now() / 1000) + payload.ttlSec;
  const nonce = randomBytes(8).toString("hex");
  const body = `${payload.userId}.${payload.scope}.${exp}.${nonce}`;
  const sig = createHmac("sha256", SECRET).update(body).digest("hex");
  return `${body}.${sig}`;
}

export function verifyAuthCode(code: string) {
  const parts = code.split(".");
  if (parts.length !== 5) return null;
  const [userId, scope, exp, nonce, sig] = parts;
  const body = `${userId}.${scope}.${exp}.${nonce}`;
  const expected = createHmac("sha256", SECRET).update(body).digest("hex");
  if (!timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"))) return null;
  if (Date.now() / 1000 > Number(exp)) return null;
  return { userId, scope };
}
02The consent pageapp/agent/authorize/page.tsx

Reads the NextAuth session, redirects to /login if absent, renders an Approve/Deny form. On approve, mints an auth_code and redirects back to the agent's redirect_uri.

tsx
import { auth, signIn } from "@/auth"; // your NextAuth setup
import { redirect } from "next/navigation";
import { mintAuthCode } from "@/lib/aam/sign";

export default async function AgentAuthorizePage({
  searchParams,
}: {
  searchParams: Promise<{
    agent?: string; redirect_uri?: string; scope?: string; state?: string;
  }>;
}) {
  const sp = await searchParams;
  const session = await auth();

  if (!session?.user) {
    redirect(`/login?next=/agent/authorize?${new URLSearchParams(sp as Record<string, string>).toString()}`);
  }

  async function approve() {
    "use server";
    const code = mintAuthCode({
      userId: session!.user!.id!,
      scope: sp.scope ?? "",
      ttlSec: 600,
    });
    redirect(`${sp.redirect_uri}?code=${code}&state=${sp.state ?? ""}`);
  }

  return (
    <main className="approve-agent">
      <h1>Authorize {sp.agent ?? "agent"}</h1>
      <p>Acting as <strong>{session.user.email}</strong></p>
      <p>Requested scope: <code>{sp.scope}</code></p>
      <form action={approve}>
        <button type="submit">Approve</button>
        <a href="/">Deny</a>
      </form>
    </main>
  );
}
03The token-exchange handlerapp/api/aam/token/route.ts

Agent POSTs auth_code here, gets back a short-lived JWT bearer with the approved scope. Use any JWT library (jose / jsonwebtoken). The bearer is what they'll send to your /api/aam-hooks/* endpoints.

tsx
import { NextResponse } from "next/server";
import { SignJWT } from "jose";
import { verifyAuthCode } from "@/lib/aam/sign";

const JWT_SECRET = new TextEncoder().encode(process.env.AAM_JWT_SECRET!);

export async function POST(req: Request) {
  const { code } = await req.json().catch(() => ({}));
  const verified = verifyAuthCode(code);
  if (!verified) {
    return NextResponse.json({ error: "invalid_code" }, { status: 400 });
  }

  const bearer = await new SignJWT({ scope: verified.scope })
    .setProtectedHeader({ alg: "HS256" })
    .setSubject(verified.userId)
    .setIssuer("https://your-domain.com")
    .setExpirationTime("1h")
    .sign(JWT_SECRET);

  return NextResponse.json({ access_token: bearer, token_type: "Bearer", expires_in: 3600 });
}
04Revocation hook in your settings pageapp/settings/connected-agents/page.tsx

The agent's bearer expires automatically (1h above), so 'revocation' is just letting the user invalidate any cached agent state. List approved agents in your settings UI, button to delete from your agent_grants table.

tsx
import { db } from "@/lib/db";
import { auth } from "@/auth";

export default async function ConnectedAgents() {
  const session = await auth();
  const grants = await db.query.agentGrants.findMany({
    where: (g, { eq }) => eq(g.userId, session!.user!.id!),
  });

  return (
    <main>
      <h1>Connected agents</h1>
      <ul>
        {grants.map((g) => (
          <li key={g.id}>
            {g.agentVendor} · scope <code>{g.scope}</code>
            <form action={async () => { "use server"; await db.delete(agentGrants).where(eq(agentGrants.id, g.id)); }}>
              <button type="submit">Revoke</button>
            </form>
          </li>
        ))}
      </ul>
    </main>
  );
}
Don't see your auth library? The pattern is the same for any session-based auth: read the session at /agent/authorize, redirect to your login if absent, render an Approve/Deny form on submit, mint a short-lived signed code, redirect back to the agent. The protocol piece (signing the code, exchanging it for a bearer) is identical across libraries — only the "read the current user's session" line differs. Email hello@whatcanido.dev and we'll add your library.
keep going
The AAM ID identity protocol
If you don't have a user system at all, AAM ID is our hosted federated identity layer. Agents authenticate against whatcanido, your tenant just consumes the JWT.
keep going
The full v0.1 spec
Section 'Authorization handoff' has the full protocol-level definition of the auth_code → bearer flow.
keep going
See it in production
examples/starter is a working Next.js implementation of the auth bridge pattern, end-to-end on Base Sepolia.