@amend-id/server
v0.2.0
Published
Sign in with Amend iD, on your server — verify Amend iD's tokens against its published keys, keep people signed in with a sealed HttpOnly cookie, refresh properly, hear back-channel logout. No dependencies.
Maintainers
Readme
@amend-id/server
Sign in with Amend iD, on your server. No dependencies. Node 22, Deno, Bun and the edge
runtimes: everything it needs is fetch, Request/Response and Web Crypto.
The recommended shape for an app that has a server: the OAuth client lives there, the browser holds only your own HttpOnly cookie, and no token ever sits in a page.
import { createAmendIdServer, withCookies } from "@amend-id/server";
const amend = createAmendIdServer({
issuer: "https://id.amend.id",
clientId: "amend_app_…", // from Apps you make on the hub, or your published identity's https:// address
redirectUri: "https://your.app/auth/amend-id",
scope: "openid email profile",
secret: process.env.SESSION_SECRET, // at least 32 bytes: openssl rand -base64 32
});
// GET /auth/start → send the person to Amend iD
export const start = (request) => amend.beginSignIn(request, { returnTo: "/dashboard" });
// GET /auth/amend-id → finish: the ID token is checked against Amend iD's keys, the session cookie set
export const callback = async (request) => (await amend.finishSignIn(request)).response;
// Anywhere: who is signed in? Put `setCookie` on your response every time (a refresh rotates it).
export const me = async (request) => {
const { session, refused, setCookie } = await amend.getSession(request);
if (!session) return withCookies(new Response(null, { status: refused === "cross_site" ? 403 : 401 }), setCookie);
return withCookies(Response.json({ id: session.person.id, name: session.person.name }), setCookie);
};
// POST /auth/signout → the refresh token given back, the cookie forgotten
export const signOut = (request) => amend.signOut(request, { returnTo: "/" });
// POST /auth/logout → Amend iD tells you a sign-in ended elsewhere (Back-Channel Logout)
export const backchannel = (request) => amend.handleBackchannelLogout(request);Register https://your.app/auth/logout as your app's sign-out address (on the hub: the
optional "Sign-out address"; in a published identity or self-registration:
backchannel_logout_uri with backchannel_logout_session_required: true). Then, the
moment the person signs out everywhere, ends that session on the hub, disconnects your app
or signs out of Amend iD in that browser, your server hears it and the next
getSession says signed_out_elsewhere.
What it does, so that you do not have to:
- The sign-in: discovery, PKCE,
stateandnoncesealed in a ten-minute cookie, the redirect back finished, the ID token verified (ES256 against Amend iD's published keys, issuer, audience, nonce, time) before anyone is called signed in,returnTokept on your own site. - The session: sealed (AES-256-GCM under a key derived from your secret) in an
HttpOnly, SameSite cookie, or — give it a
store— a session id in the cookie and the session in your store (a table, Redis,memorySessionStore()for one process). - Refresh, properly: the access token is refreshed before it runs out, under a lock, the store read again first (another instance may have refreshed), a refused refresh ending the session only when the token it holds is over too.
- Cross-site requests: anything but GET/HEAD/OPTIONS must come from your own site
(
Sec-Fetch-Site, elseOrigin), orgetSessionanswersrefused: "cross_site"andsignOutanswers 403. - Sign-out: the refresh token given back at Amend iD's revoke address (that ends that
sign-in's tokens, never the person's consent), the cookies cleared, and with
everywhere: truea redirect through Amend iD's own sign-out page. - The verifier, on its own too:
createVerifier({ issuer })checks access tokens for your API (verifyAccessToken(token, { audience })— who, which permissions, under what consent (grant_id), from which sign-in (sid), how they signed in (amr)), ID tokens, and logout tokens.
The rules it follows are written out at https://id.amend.id/developers, "Keeping people signed in, properly".
Two APIs from one sign-in (0.2.0)
An app whose server reaches a second API as the person — Build's server reaches Amend Cloud
beside its own API — names it in resources (RFC 8707). It is asked for at sign-in next to
resource, a token for it is fetched with the refresh token and kept in the session, renewed
with the session's own token under the same lock, and handed out by accessTokenFor. No second
sign-in, no token in a page; ask for the permissions that API checks in scope.
const amend = createAmendIdServer({
issuer: "https://id.amend.id",
clientId: "build",
redirectUri: "https://build.amend.id/auth/callback",
scope: "openid email profile build:read build:write cloud:build",
resource: "https://build.amend.id/api", // your own API: the session's token
resources: ["https://cloud.amend.id"], // the other API this sign-in reaches
secret: process.env.SESSION_SECRET,
});
// A request to Amend Cloud, as the person. Put `setCookie` on your response as with getSession.
export const myNotes = async (request) => {
const { token, refused, setCookie } = await amend.accessTokenFor(request, "https://cloud.amend.id");
if (!token) return withCookies(Response.json({ error: refused }), setCookie); // no_such_resource · no_refresh_token · refresh_refused · the session's own reasons
const r = await fetch("https://cloud.amend.id/v1/me", { headers: { authorization: `Bearer ${token}` } });
return withCookies(Response.json(await r.json()), setCookie);
};A refusal for one API leaves the sign-in itself untouched: refresh_refused for a resource
means this sign-in never asked for it (sign the person in again); the session's own token
keeps working. Never fetch a second API's token around the library — the refresh token rotates
on every use, and two users of it race and end the sign-in.
