Skip to main content
Add a Sign in with AFAuth button and a human lands in the account their agent created. Your service acts as an OIDC relying party to the trust attestor’s OpenID Provider. This page is the how-to; the concept is the why.
No SDK helper yet. The trust attestor’s OIDC side is live, but the reference SDK doesn’t ship a drop-in RP handler — you hand-wire the callback (roughly the same shape as a “Sign in with Google” route). It’s a small amount of standard OIDC code; the snippets below are complete.

Prerequisites

  • A service_did you already use for attestation (§4.3) — sign-in reuses it, it is not a new identifier.
  • Agents that sign up attested (linked to their human), so accounts carry a sub_h. See Preconditions if some accounts were created as guests first.

1. Register as an OIDC client

Ask the attestor operator to register your service. A client binds three things (§10.8.3):
FieldValue
client_idYour service identifier (your service_did is a fine value).
service_didMUST equal the aud / service_did you use for attestation. It is the audience input to the sub_h derivation — a mismatch lands humans in a different, empty account.
redirect_urisExact callback URLs. Anything not on the allowlist is rejected before a code is issued.
For trust.afauth.org, the operator sets this in the TRUST_OIDC_CLIENTS environment variable:
[
  {
    "client_id": "did:web:api.example.com",
    "service_did": "did:web:api.example.com",
    "redirect_uris": ["https://api.example.com/auth/afauth/callback"]
  }
]
Running your own attestor? The same registry lives in your attestor’s config — see Run your own attestor.

2. The flow

Standard OIDC Authorization Code + PKCE (S256). Discovery is at https://trust.afauth.org/.well-known/openid-configuration.
  Human                  Your service (RP)              trust.afauth.org (OP)
    │  GET /login          │                                │
    │─────────────────────▶│ 302 /oidc/authorize            │
    │                      │  ?client_id&redirect_uri        │
    │                      │  &response_type=code&scope=openid│
    │                      │  &code_challenge&…=S256&state&nonce
    │                      │────────────────────────────────▶│
    │        authenticate as the human   ◀───────────────────│
    │                      │  302 redirect_uri?code&state     │
    │                      │◀────────────────────────────────│
    │                      │ POST /oidc/token (code_verifier) │
    │                      │────────────────────────────────▶│
    │                      │  { id_token, token_type, … }     │
    │                      │◀────────────────────────────────│
    │  session cookie  ◀───│ verify → resolve (iss, sub_h)    │

3. Start the sign-in (authorize)

Generate a PKCE verifier + challenge and a state/nonce, stash them in a short-lived store keyed by state, and redirect:
import { generators } from "openid-client"; // or any PKCE helper

const verifier = generators.codeVerifier();
const challenge = generators.codeChallenge(verifier); // S256
const state = generators.state();
const nonce = generators.nonce();
await pending.put(state, { verifier, nonce }, { ttlSeconds: 600 });

const authorize = new URL("https://trust.afauth.org/oidc/authorize");
authorize.search = new URLSearchParams({
  client_id: SERVICE_DID,
  redirect_uri: "https://api.example.com/auth/afauth/callback",
  response_type: "code",
  scope: "openid",
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
  nonce,
}).toString();

return Response.redirect(authorize.toString(), 302);

4. Handle the callback (token + verify)

Exchange the code at the token endpoint, then verify the id_token against the trust JWKS:
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = "https://trust.afauth.org";
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));

// callback: GET /auth/afauth/callback?code&state
const { code, state } = Object.fromEntries(new URL(req.url).searchParams);
const stash = await pending.take(state); // missing → reject (CSRF / replay)

const res = await fetch(`${ISSUER}/oidc/token`, {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    code_verifier: stash.verifier,
    client_id: SERVICE_DID,
    redirect_uri: "https://api.example.com/auth/afauth/callback",
  }),
});
const { id_token } = await res.json();

const { payload } = await jwtVerify(id_token, jwks, {
  issuer: ISSUER,        // iss === https://trust.afauth.org
  audience: SERVICE_DID, // aud === your service_did
});
if (payload.nonce !== stash.nonce) throw new Error("nonce mismatch");
jwtVerify checks the signature, iss, aud, and exp for you; the explicit nonce check closes the loop opened at the authorize step.

5. Resolve to the account — canonicalize the issuer

The account lives under (iss, sub_h), where sub_h is id_token.sub. The catch: your attestation path stored iss = "afauth-trust", but the id_token carries the URL. Map both to one issuer before you look up, or the human lands in a new empty account:
const canonicalIss = (iss) =>
  iss === "afauth-trust" ? "https://trust.afauth.org" : iss;

const account = await users.findByPrincipal(canonicalIss(payload.iss), payload.sub);
if (!account) {
  // No agent has signed up under this human yet at your service.
  // Don't auto-create — tell them to point their agent here first.
  return noAgentAccountYet();
}
return startSession(account); // they're in
This canonicalization is the one thing that silently breaks sign-in. The agent’s attestation iss is the bare string afauth-trust; the id_token iss is https://trust.afauth.org. They are the same attestor — treat them as one before keying the account (§10.8.4). Store your attestation issuer canonicalized too, so both paths agree.

Preconditions

Sign-in resolves an account by (iss, sub_h), so the account needs a sub_h. That is written when an agent signs up attested (after linking to its human). Two cases to know:
  • Attested signup, then sign-in — works immediately; the signup wrote sub_h.
  • Guest signup, then link, then sign-in — the guest account had no sub_h. Because signup is idempotent on the agent’s did:key, the principal binds on the agent’s next attested request after linking. After that one request, sign-in resolves to the same account. (Until then, the human signing in would resolve to nothing and see “no account yet”.)

What sign-in does not do

Signing in authenticates the human to the (iss, sub_h) account — it does not transfer ownership. It does not run the claim ceremony or raise the §7.5 owner-binding floor. If you want a recoverable human owner (recovery contacts, credential enrollment), run a claim alongside sign-in — they compose. See §10.8.5.

Where to next

Sign in with AFAuth (concept)

The (iss, sub_h) = OIDC (issuer, subject) idea, in full.

Accept trust attestations

The agent-side gate that writes sub_h in the first place.

Invite and claim

Establish a recoverable human owner alongside sign-in.

Trust API reference

Full OIDC endpoint and id_token shapes.