Skip to content

Authorization Server (CLI login, service SSO, MCP connectors) ​

The inbound twin of Federated Login. There, aoothjs is the OAuth client of an external IdP ("Continue with Google"). Here, aoothjs is the OAuth/OIDC authorization server for its OWN clients — a local CLI on a loopback redirect, a registered first-party service ("Sign in with the main app"), or a self-registered connector (an MCP client like a claude.ai custom connector that discovers the server, registers itself, and drives the user through login + consent). One authorization-code + PKCE flow drives a real interactive login (password, MFA, consent, even a mid-flow "Continue with Google"); the only thing that varies between the cases is the injected client/redirect policy (and, for service SSO, whether an id_token is signed).

The framework-agnostic pieces — the two short-lived stores, the client/redirect policies, the id_token signer, the claims resolver, the token policy, and the error taxonomy — live in @aooth/auth/authz. This page is the moost HTTP layer: the AuthorizeController endpoints and the DI wiring.

Where it sits ​

client opens browser →
  GET /auth/authorize?response_type=code&client_id?&redirect_uri&state
                       &code_challenge&code_challenge_method=S256&scope?&nonce?
    → policy.resolveClient(...)  ← TRUST GATE: authorize client + redirect_uri (+ scope) FIRST
    → PendingAuthorizationStore.create(...)  ← ALL authority fixed HERE (tokenPolicy, id_token
                              intent, audience, granted scope, nonce) + a browser-binding secret
    → 302 /login?authz=<opaque handle>  + Set-Cookie: aooth_authz=<binding>  (httpOnly, Lax)
                                             ← the SPA forwards the handle into auth/login/flow
  → user authenticates (password / MFA / mid-flow SSO) →
      `authz-consent` terminal: re-verify the aooth_authz browser binding, then the user
          explicitly APPROVES the client (Deny → 302 redirect_uri?error=access_denied, no code) →
      `mint-authz-code` terminal mints a single-use CODE bound to ctx.subject + the recorded
          authority, and 302s redirect_uri?code=&state=
  → POST /auth/token { grant_type, code, code_verifier, client_id?, client_secret? }
    → consume code (single-use) → verify PKCE → authenticate client (Tier 2) →
      mint access_token and/or id_token  ← minted HERE, off the browser
    → { token_type, access_token?, expires_in?, id_token?, userId }

# Tier 2 only:
GET /auth/.well-known/openid-configuration   → OIDC discovery (derived from the signer's issuer)
GET /auth/jwks                               → the signer's public JWKS

# Discovery + registration (MCP connectors; signer NOT required):
GET  /auth/.well-known/oauth-authorization-server → RFC 8414 AS metadata (needs only getIssuer())
POST /auth/register                               → RFC 7591 dynamic client registration (when wired)

The grant's authority is fixed at /authorize time (the policy's TokenPolicy, id_token intent, aud, and granted scope are recorded on the pending authorization and copied onto the issued code), never inferred at /token. Nothing long-lived ever rides a redirect URL — only the single-use code does.

Two tiers, one flow ​

Client / redirect policyWhat the token endpoint mints
Tier 1 — CLILoopbackClientPolicy — any 127.0.0.1 / [::1] / localhost redirect, any port (RFC 8252); public client, PKCE is the binding; no client_id.A full-authority cli-session access token for the main API (no id_token).
Tier 2 — service SSORegisteredClientPolicy — a static registry; each client has a client_id, an exact-match (or strict-prefix) redirect_uri allowlist, a public/confidential type, and what it may receive.An id_token (RS256/ES256, aud = client_id), optionally also an access token. Consumable by the existing OidcProvider.
MCP connectors — DCRDynamicClientPolicy — RFC 7591 self-registered public clients from a DynamicClientStore; exact-match https redirects (loopback entries are port-agnostic per RFC 8252); granted scope = requested ∩ a server allow-list ∩ the registration's scope.An access token with the configured tokenPolicy (e.g. { kind: "mcp-session", ttl: 30d }). No id_token — connectors are plain OAuth.

Run them side by side with CompositeClientPolicy, which dispatches on the presence and ownership of client_id — no client_id is a loopback CLI; a client_id belongs to the static registry when it knows the id (hasClient, static-first so a dynamic registration can never shadow a static client), else to the dynamic policy. The same picker drives authenticateClient at /token.

Wiring — Tier 1 (CLI loopback) ​

ts
import {
  AuthorizeController,
  AUTH_CODE_STORE_TOKEN,
  CLIENT_REDIRECT_POLICY_TOKEN,
  PENDING_AUTHORIZATION_STORE_TOKEN,
} from "@aooth/auth-moost";
import {
  AuthCodeStoreMemory,
  LoopbackClientPolicy,
  PendingAuthorizationStoreMemory,
} from "@aooth/auth/authz";

app.setProvideRegistry(
  createProvideRegistry(
    [AuthCredential, () => authCredential],
    [UserService, () => userService],
    // All three are abstract/interface deps → they bind under STRING tokens
    // (moost's class-reference ctor injection can't resolve an abstract paramtype).
    [CLIENT_REDIRECT_POLICY_TOKEN, () => new LoopbackClientPolicy()],
    [PENDING_AUTHORIZATION_STORE_TOKEN, () => new PendingAuthorizationStoreMemory()],
    [AUTH_CODE_STORE_TOKEN, () => new AuthCodeStoreMemory()],
  ),
);
app.registerControllers(AuthorizeController /* + AuthController, your AuthWorkflow subclass */);

The memory stores are single-process; a multi-pod deployment swaps the durable PendingAuthorizationStoreAtscriptDb / AuthCodeStoreAtscriptDb (from @aooth/auth/atscript-db, backed by the @aooth/auth/atscript-db/pending-authorization + …/auth-code models) under the same tokens — no controller change. AuthorizeController.loginPath() defaults to /login — override it in a subclass for a custom login route. The CLI side ships ready-made as @aooth/login-client — a zero-dependency authorize() helper that does the whole round-trip (generate state + PKCE → open the browser → await the one-shot loopback callback → verify state → POST /auth/token), with headless/SSH support via openBrowser: false + onUrl and typed AuthorizeError codes.

Wiring — Tier 2 (first-party OIDC) ​

Tier 2 adds three things to the Tier-1 wiring: a signer, a claims resolver, and a RegisteredClientPolicy (here behind a CompositeClientPolicy so the CLI grant still works). The signer + claims resolver are supplied by overriding two getters on a controller subclass, not by DI tokens — see the override seam for why.

ts
import {
  CompositeClientPolicy,
  IdTokenSigner,
  LoopbackClientPolicy,
  OidcClaimsResolver,
  RegisteredClientPolicy,
  scopeGrants,
} from "@aooth/auth/authz";
import { AuthorizeController /* …tokens… */ } from "@aooth/auth-moost";
import { Controller, Inherit, Inject } from "moost";

// 1. A signer holds ONE asymmetric keypair. `issuer` is `{origin}/auth` — the exact
//    value a relying OidcProvider is configured with, so `id_token.iss` / `doc.issuer`
//    match byte-for-byte. Keys are imported lazily; construction is cheap.
const signer = new IdTokenSigner({
  issuer: `${PUBLIC_ORIGIN}/auth`,
  kid: "main-1",
  alg: "RS256", // default; OidcProvider accepts RS256/ES256
  privateKey: PKCS8_PEM,
  publicKey: SPKI_PEM,
});

// 2. Profile claims, read from YOUR user record and gated by the granted scope.
//    The registered claims (iss/aud/sub/iat/exp/nonce) are owned by the controller.
//    `email`/`name` are NOT base `UserCredentials` fields — they're whatever YOUR
//    user model declares (the base type carries no `email`; a login-handle email
//    is a consumer field tagged `@aooth.user.email`). Read your own columns here.
class MyClaimsResolver extends OidcClaimsResolver {
  async resolveClaims(userId: string, scope: string | undefined) {
    const user = await userService.getUser(userId); // UserService<YourUserModel>
    const claims: Record<string, unknown> = {};
    if (scopeGrants(scope, "email") && user.email) {
      claims.email = user.email;
      claims.email_verified = true;
    }
    return claims;
  }
}
const claims = new MyClaimsResolver();

// 3. Composite policy — loopback CLI (no client_id) + a registry of first-party clients.
const policy = new CompositeClientPolicy({
  loopback: new LoopbackClientPolicy(),
  registered: new RegisteredClientPolicy({
    clients: [
      {
        clientId: "billing-app",
        redirectUris: ["https://billing.example.com/auth/callback"],
        type: "confidential",
        clientSecret: BILLING_SECRET, // confidential → secret checked in constant time at /token
        scopes: ["openid", "email", "profile"], // granted = requested ∩ allowed
      },
    ],
  }),
});

// 4. Subclass the controller to wire the signer + claims resolver. moost@0.6.x does
//    NOT inherit @Inject / design:paramtypes across `extends`, so re-declare the ctor
//    with the SAME three tokens and forward to super().
@Inherit()
@Controller("auth")
class OidcAuthorizeController extends AuthorizeController {
  constructor(
    auth: AuthCredential,
    @Inject(CLIENT_REDIRECT_POLICY_TOKEN) p: ClientRedirectPolicy,
    @Inject(PENDING_AUTHORIZATION_STORE_TOKEN) pending: PendingAuthorizationStore,
    @Inject(AUTH_CODE_STORE_TOKEN) codes: AuthCodeStore,
  ) {
    super(auth, p, pending, codes);
  }
  protected override getIdTokenSigner() {
    return signer;
  }
  protected override getOidcClaimsResolver() {
    return claims;
  }
}

app.setProvideRegistry(
  createProvideRegistry(
    [AuthCredential, () => authCredential],
    [UserService, () => userService],
    [CLIENT_REDIRECT_POLICY_TOKEN, () => policy],
    [PENDING_AUTHORIZATION_STORE_TOKEN, () => new PendingAuthorizationStoreMemory()],
    [AUTH_CODE_STORE_TOKEN, () => new AuthCodeStoreMemory()],
  ),
);
app.registerControllers(OidcAuthorizeController);

Wiring — MCP connectors (dynamic client registration) ​

Connector-style MCP clients (claude.ai custom connectors and anything implementing the MCP authorization spec) cannot set headers — the user pastes a resource URL and the client expects to discover the authorization server (RFC 9728 + RFC 8414), register itself (RFC 7591), and run the standard code + PKCE grant. Four pieces on top of the Tier-1 wiring:

ts
import {
  CompositeClientPolicy,
  DynamicClientPolicy,
  DynamicClientRegistration,
  LoopbackClientPolicy,
} from "@aooth/auth/authz";
import { DynamicClientStoreAtscriptDb } from "@aooth/auth/atscript-db";
import { AuthorizeController, DYNAMIC_CLIENT_STORE_TOKEN /* …tokens… */ } from "@aooth/auth-moost";

// 1. The client store — durable (model: @aooth/auth/atscript-db/dynamic-client,
//    an empty-extension consumer model + syncSchema), or DynamicClientStoreMemory
//    for tests. One instance backs BOTH the policy and the registration op.
const dynamicClients = new DynamicClientStoreAtscriptDb({ table: db.getTable(MyDynamicClient) });

// 2. The registration operation behind POST /auth/register, with the abuse knobs:
//    a reject-when-full cap (used registrations are NEVER evicted — a connector
//    caches its client_id), lazy GC of never-used registrations, and an optional
//    guard hook (throw ClientRegistrationError to reject). Rate limiting stays
//    at your ingress.
const registration = new DynamicClientRegistration({
  store: dynamicClients,
  maxClients: 1000,
  unusedClientTtlMs: 24 * 60 * 60_000,
});

// 3. The policy — what a dynamic grant mints, and the SERVER-side scope bound
//    (never trust the registration's self-declared scope as the allow-set).
const policy = new CompositeClientPolicy({
  loopback: new LoopbackClientPolicy(),
  dynamic: new DynamicClientPolicy({
    store: dynamicClients,
    tokenPolicy: { kind: "mcp-session", ttl: 30 * 24 * 60 * 60_000 },
    allowedScopes: ["read", "write"],
  }),
  // registered: …  ← add the Tier-2 registry too; static ids win the dispatch.
});

// 4. The controller subclass: the issuer (RFC 8414 works WITHOUT a signer) and
//    the registration getter. Inject the store under DYNAMIC_CLIENT_STORE_TOKEN
//    if you prefer DI; the BASE controller never injects it (optional @Inject
//    panics in moost's route pass — same seam as the signer).
@Inherit()
@Controller("auth")
class McpAuthorizeController extends AuthorizeController {
  constructor(/* …same 4 ctor params + super() as Tier 2… */) {
    /* … */
  }
  protected override getIssuer() {
    return `${PUBLIC_ORIGIN}/auth`; // byte-exact; NEVER derive from the Host header
  }
  protected override getDynamicClientRegistration() {
    return registration;
  }
  protected override scopesSupported() {
    return ["read", "write"];
  }
}

With that, GET /auth/.well-known/oauth-authorization-server serves RFC 8414 metadata (no signer needed — getIssuer() is the only requirement; with a Tier-2 signer wired it defaults to the signer's issuer and also advertises jwks_uri), POST /auth/register accepts registrations, and both discovery documents advertise registration_endpoint when a signer is also present. Registration normalizes per RFC 7591 §2: grant_types: ["authorization_code", "refresh_token"] registers as ["authorization_code"] and the 201 echo of the narrowed set is the contract; token_endpoint_auth_method defaults to "none" (public clients only — an explicit ask for a secret method is rejected, never silently downgraded); client_name is sanitized (control/format/bidi characters stripped) and rendered on the consent prompt as text, next to the validated redirect host — the host is where the code is actually delivered, which a self-chosen name can't fake.

Root-mounted discovery (what the controller cannot serve) ​

Two documents belong at the HTTP-server root, which a prefix-mounted controller can't register — mount them yourself from the exported builders (re-exported by @aooth/auth-moost):

ts
import {
  buildAuthorizationServerMetadata, // RFC 8414 — the path-insertion form
  buildProtectedResourceMetadata, // RFC 9728 PRM (resource-server side)
  buildWwwAuthenticateBearerChallenge, // the 401 challenge header VALUE
} from "@aooth/auth-moost";

@Controller()
class WellKnownController {
  // RFC 8414 path-insertion form for an issuer mounted at /auth — clients try
  // this BEFORE the {issuer}/.well-known suffix form the controller serves.
  // Same builder + issuer ⇒ byte-identical documents.
  @Get(".well-known/oauth-authorization-server/auth")
  @Public()
  meta() {
    return buildAuthorizationServerMetadata({
      issuer: `${PUBLIC_ORIGIN}/auth`,
      registrationEndpoint: `${PUBLIC_ORIGIN}/auth/register`,
    });
  }

  // RFC 9728 — which authorization server guards this resource.
  @Get(".well-known/oauth-protected-resource")
  @Public()
  prm() {
    return buildProtectedResourceMetadata({
      resource: `${PUBLIC_ORIGIN}/mcp`,
      authorizationServers: [`${PUBLIC_ORIGIN}/auth`],
    });
  }
}

// And on the protected resource's 401 (this header starts the whole discovery):
res.setHeader(
  "WWW-Authenticate",
  buildWwwAuthenticateBearerChallenge({
    resourceMetadataUrl: `${PUBLIC_ORIGIN}/.well-known/oauth-protected-resource`,
  }),
);

The challenge builder is framework-light (a header-value string) and sanitizes every value (control characters stripped, quotes escaped) so attacker-influenced strings can't split the response. The demo's makeMcpDemoController (packages/e2e-demo) is the working reference for all three mounts.

The resource parameter (RFC 8707) ​

/authorize and /token accept resource. v1 records + consistency-checks it: a repeated or oversized value fails with invalid_target (never silently truncated), a mismatch between the two legs is 400 invalid_target, and one-sided presence is accepted. There is no audience enforcement — access tokens are opaque credentials consumed by the same origin that minted them, so cross-resource confusion doesn't arise; the recorded value stays on the grant so a future multi-resource deployment can enforce it without re-minting.

Connector token lifecycle ​

Dynamic grants mint plain bearer access tokens with your tokenPolicy TTL (e.g. 30 days) — no refresh tokens, no RFC 7009 revocation endpoint in v1. At expiry the connector re-runs authorize + consent. The only revocation path is your own sessions surface: pick a dedicated kind (e.g. mcp-session) so connector grants show up under listSessions(userId, { kind: "mcp-session" }) and users can revoke them — without that, a stolen 30-day bearer has no user-visible kill switch. Operators MAY disable DCR entirely (don't wire the getter) and fall back to a statically pre-registered connector via RegisteredClientPolicy — its callback URL is fixed and the connector UI accepts a manually-entered client id — at the cost of "paste URL and it works" UX.

Schema migration

The aooth_pending_authorizations / aooth_auth_codes models gained nullable clientName / resource columns. Run your schema sync before serving connector traffic — connector clients always send resource, so an un-synced column fails at the /authorize insert.

Consuming it — "the inbound grant and the outbound provider are two ends of the same wire" ​

A first-party sibling service signs in against the main app with the existing OidcProvider — no new client code. Point it at the same issuer; discovery resolves /authorize, /token, and /jwks automatically:

ts
import { OidcProvider } from "@aooth/idp";

const provider = new OidcProvider({
  issuer: "https://main.example.com/auth", // = the signer's issuer
  clientId: "billing-app",
  clientSecret: BILLING_SECRET,
});
// front-channel (your code): build /authorize URL → user logs in → capture code at redirect_uri
const profile = await provider.exchange({ code, redirectUri, codeVerifier, expectedNonce: nonce });
// → verified NormalizedProfile { provider, subject, email?, emailVerified?, displayName?, raw }

The whole federated leg (beginSso, sso-callback, account resolution, login-gate re-entry) then works exactly as it does for Google — the main app is just another OIDC provider to the sibling.

The signer / claims override seam ​

The id_token signer and claims resolver are optional (a Tier-1-only CLI deployment wires neither — then discovery / /auth/jwks 404 and no id_token is minted). They are supplied by overriding two protected getters on an AuthorizeController subclass, not by DI tokens:

ts
protected getIdTokenSigner(): IdTokenSigner | undefined { /* default: undefined */ }
protected getOidcClaimsResolver(): OidcClaimsResolver { /* default: NoopOidcClaimsResolver */ }

Why a getter and not a token: an OPTIONAL @Inject/@Optional dependency panics in moost's resolveMoost route-table pass (triggered by AuthController's @MoostInit refresh-cookie hook — it re-instantiates the controller graph to discover handler paths, and an unprovided optional dependency throws "Class is not Injectable and not Optional"). A plain method has nothing for the resolver to walk, so it sidesteps the pass entirely. The mandatory deps (stores, client policy) stay on string tokens — they're always provided, so they resolve cleanly in that pass.

Stores — fix the authority early, single-use the code ​

StoreHoldsTTLNotes
PendingAuthorizationStorethe in-flight request, keyed by an opaque handle: { clientId?, redirectUri, codeChallenge, clientState?, scope?, nonce?, idToken?, accessToken?, audience?, tokenPolicy, binding }≈ the login-session ceilingThe handle rides the login-wf ctx and survives a "Continue with Google" detour. binding is the per-request browser-binding secret (Consent gate) — a custom durable store must persist it.
AuthCodeStorethe minted code, keyed by the code: { userId, codeChallenge, redirectUri, clientId?, scope?, nonce?, idToken?, accessToken?, audience?, tokenPolicy, expiresAt }≈ 30–60 sconsume() is single-use + atomic — a reuse / double-redeem misses.

Both ship abstract + in-memory (tests) + an atscript-db adapter. The login workflow's mint-authz-code terminal resolves them through the AuthorizeRuntime DI holder (a @Step body can't @Inject a string token, so it instantiates AuthorizeRuntime, whose ctor resolves the two tokens).

Two defenses run before mint-authz-code mints a code, so a logged-in (or silently re-authenticated) browser cannot be walked into delivering a code to a client it never approved. Both are built in — you don't wire anything.

1. Browser binding (the aooth_authz cookie). GET /auth/authorize mints a high-entropy binding secret, records it on the pending authorization, and drops it as an httpOnly; SameSite=Lax aooth_authz cookie. The authz-consent step constant-time-matches that cookie against the stored secret before doing anything; a mismatch (or absence) fails closed — no prompt, no code. The opaque authz handle alone is a bearer ticket, so phishing it into a different browser would otherwise let an attacker's client receive a code minted for the victim. The binding closes that: the secret lives only in the browser that started the request, so the handle can be redeemed only in that browser. SameSite=Lax is deliberate — the cookie must still ride the top-level GET back from a "Continue with Google" detour.

2. Explicit consent (the authz-consent step). After authentication the run pauses on the bundled AuthorizeConsentForm (the authzConsent slot in opts.forms): the user sees which client + scope is asking and must press Authorize. Deny (or abandoning) 302s the client back with error=access_denied and mints nothing. The form is a standard workflow form — it renders through <AsWfForm> with no bespoke component, and you override its copy by swapping opts.forms.authzConsent for an extends AuthorizeConsentForm subclass (import it from @aooth/auth-moost/atscript).

The mint step runs only after consent stamps approval on the run, so a deny / binding failure leaves its own finish intact (a benign error, or the access_denied redirect) — mint-authz-code never overwrites it. AuthorizeController sets the cookie via the package-exported AUTHZ_BINDING_COOKIE name + authzBindingCookieAttrs (parallel to the federated OAUTH_CSRF_COOKIE); reference the name if a reverse proxy filters cookies by allowlist.

DOs / DON'Ts ​

  • DO keep the trust gate (resolveClient) first at /authorize — until it passes there is no validated redirect, so a failure is a benign 400, never a reflected redirect.
  • DON'T infer a grant's authority at /token. It is fixed at /authorize from the policy and recorded on the code; /token only verifies PKCE + client auth and mints.
  • DO register exact-match redirectUris. Reach for redirectPrefixes only for a tightly-scoped path prefix on a trusted origin — it is boundary-checked (URL-normalized to kill .. traversal + a path-boundary after the prefix), but exact match is the safe default.
  • DO set the signer's issuer to exactly {origin}/auth — a relying OidcProvider compares id_token.iss / doc.issuer for exact string equality (a trailing slash is stripped at construction).
  • DON'T wire an id_token client without a signer — the token endpoint returns 500 server_error (a misconfiguration, not a client error) rather than mint an unsigned identity assertion.
  • DON'T send a client_id on a loopback (Tier 1) /token request — a code minted for a public loopback client must carry no client_id; a spurious one is rejected 401 to keep the binding symmetric.
  • DO mount the AuthorizeController subclass (not the base) when you override the getters — registering the base class would mint sub-only tokens with no signer.
  • DO serve loginPath() on the same origin as /auth/authorize — the aooth_authz binding cookie is host-scoped; a cross-origin login route never receives it, so authz-consent fails closed and no code is ever minted. (Consent gate.) The same applies to connector flows: the binding cookie is SameSite=Lax, so a login UI on a different registrable domain than the issuer fails closed — that needs a deliberate, separately-reviewed cookie change, not a quiet flip.
  • DO persist the binding field in any custom durable PendingAuthorizationStore — the consent gate matches the cookie against it; drop it and every authorize request fails the binding check.
  • DON'T derive getIssuer() from the request's Host header — the metadata document is cacheable, and a request-controlled host would be injected into every client's view of your endpoints. Configure it.
  • DON'T treat a DCR registration's scope as the allow-set — it is attacker-supplied (it also feeds the consent copy). The grant is bounded by DynamicClientPolicyOptions.allowedScopes, the server-side list.
  • DO size maxClients and keep unusedClientTtlMs on — /register is anonymous by spec. The cap rejects-when-full; never evict used registrations (a connector caches its client_id, and evicting it strands the user's connector).

See also ​

Released under the MIT License.