# SMT Account integration guide

SMT Account is the shared OAuth 2.1 and OpenID Connect (OIDC) provider for SMT applications.

This guide is the canonical integration contract for:

- production issuer: `https://auth.smallticket.dev/api/auth`
- development issuer: `https://auth-dev.smallticket.dev/api/auth`
- allowed user email domains: `smt.ai.kr`, `smallticket.dev`, and `smtax.vn`
- allowed application hosts: the apex and any subdomain of `smallticket.dev` or `smtax.vn`, plus HTTPS subdomains of `trycloudflare.com` and `ngrok-free.dev`
- public clients identified by a Client ID Metadata Document (CIMD), without preregistration or a client secret

## Production integration contract

Use Authorization Code with PKCE (`S256`). Discover all provider endpoints from:

```text
https://auth.smallticket.dev/api/auth/.well-known/openid-configuration
```

The production application must:

1. publish a CIMD JSON document at a stable HTTPS URL (the development issuer provides a loopback generator described below);
2. use that document URL as its `client_id`;
3. keep `state`, `nonce`, and the PKCE verifier in a short-lived encrypted transaction;
4. exchange the callback code without a client secret;
5. validate the ID token with the discovered JWKS;
6. fetch the discovered UserInfo endpoint with the access token;
7. require UserInfo `sub` to exactly match ID-token `sub`;
8. require `email_verified: true` and an allowed email domain;
9. create its own secure application session.

Do not treat `email` or `name` as required ID-token claims. In this Authorization Code flow, `profile` and `email` claims are returned by UserInfo. The ID token proves the authentication event and stable subject.

## 1. Publish client metadata

Publish a JSON document on the application's own origin. A conventional path is:

```text
https://YOUR-APP.smallticket.dev/.well-known/oauth-client.json
```

Example:

```json
{
  "client_id": "https://YOUR-APP.smallticket.dev/.well-known/oauth-client.json",
  "client_name": "Your application name",
  "client_uri": "https://YOUR-APP.smallticket.dev",
  "application_type": "web",
  "redirect_uris": [
    "https://YOUR-APP.smallticket.dev/auth/callback"
  ],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}
```

Requirements:

- `client_id` must exactly equal the public URL of this document.
- Return `200`, `Content-Type: application/json`, and no redirect.
- Every callback must exactly match a value in `redirect_uris`.
- Web callbacks must use HTTPS and the metadata-owned application origin.
- Do not include a `client_secret`.
- Keep the document public; SMT Account fetches and periodically revalidates it.

SMT Account accepts CIMD documents at the apex or any subdomain of `smallticket.dev` and `smtax.vn`. For development, it also accepts HTTPS documents on subdomains of `trycloudflare.com` and `ngrok-free.dev`; the provider apex domains are not allowed.

The tunnel domains are shared infrastructure. Their wildcard allowlist permits a client hosted by any user of those services, not only SMT team members. Use tunnel clients only for development, verify the exact `client_id` before authorizing, and move persistent applications to an SMT-owned domain.

Known-good examples:

- `https://ams.smallticket.dev/.well-known/oauth-client.json`
- `https://knia.smallticket.dev/.well-known/oauth-client.json`
- `https://random-words.trycloudflare.com/.well-known/oauth-client.json`
- `https://your-tunnel.ngrok-free.dev/.well-known/oauth-client.json`

## 2. Discover the provider

Do not hardcode endpoint paths independently. Fetch and cache the OIDC discovery document, then require its `issuer` to exactly equal:

```text
https://auth.smallticket.dev/api/auth
```

Use the discovered `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`, `end_session_endpoint`, and signing-algorithm allowlist. Reject endpoints that are not HTTPS or are hosted outside `auth.smallticket.dev`.

## 3. Start Authorization Code + PKCE

For every login:

1. Generate a random `code_verifier` of 43–128 base64url characters.
2. Compute `code_challenge = base64url(SHA-256(code_verifier))`.
3. Generate independent random `state` and `nonce` values.
4. Store them with the exact callback URI, creation time, and safe return path in a short-lived encrypted transaction.
5. Redirect to the discovered authorization endpoint.

Parameters:

```text
client_id=THE_EXACT_CIMD_DOCUMENT_URL
redirect_uri=AN_EXACT_DECLARED_CALLBACK
response_type=code
scope=openid profile email
state=RANDOM_STATE
nonce=RANDOM_NONCE
code_challenge=SHA256_CHALLENGE
code_challenge_method=S256
```

Request `offline_access` only when the application genuinely needs a refresh token.

Minimal TypeScript helpers:

```ts
const base64url = (bytes: Uint8Array) => {
  let value = "";
  for (const byte of bytes) value += String.fromCharCode(byte);
  return btoa(value)
    .replaceAll("+", "-")
    .replaceAll("/", "_")
    .replace(/=+$/u, "");
};

const randomValue = () =>
  base64url(crypto.getRandomValues(new Uint8Array(32)));

const pkceChallenge = async (verifier: string) =>
  base64url(new Uint8Array(await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(verifier),
  )));
```

For server-rendered web apps, store the sealed transaction in a cookie with:

```text
HttpOnly; Secure; SameSite=Lax; Path=/auth/callback; Max-Age=600
```

Do not store the raw verifier, state, or nonce in a readable browser cookie.

## 4. Validate the callback

The provider returns `code`, `state`, and `iss`. Before token exchange, reject the callback unless:

- the transaction exists and decrypts successfully;
- it is no more than 10 minutes old;
- callback `state` exactly matches the transaction;
- callback `iss` equals the configured issuer;
- the transaction callback URI equals the configured callback.

Treat authorization error parameters as a failed or cancelled login. Delete the transaction cookie on every outcome so it cannot be replayed.

## 5. Exchange the code without a secret

Send a form-encoded `POST` to the discovered token endpoint:

```sh
curl -sS https://auth.smallticket.dev/api/auth/oauth2/token \
  -H 'content-type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'client_id=https://YOUR-APP.smallticket.dev/.well-known/oauth-client.json' \
  --data-urlencode 'redirect_uri=https://YOUR-APP.smallticket.dev/auth/callback' \
  --data-urlencode 'code=CODE_FROM_CALLBACK' \
  --data-urlencode 'code_verifier=ORIGINAL_PKCE_VERIFIER'
```

Do not send HTTP Basic authentication or a `client_secret`.

Why this is safe:

- A public application cannot keep a shared secret confidential.
- CIMD binds the client ID to operator-controlled metadata and exact callbacks.
- PKCE binds the authorization code to the transaction that created it.
- `state` prevents callback request forgery.
- `nonce` binds the ID token to the authorization request.
- Authorization codes are short-lived and single-use.

A client secret would add complexity without proving that a public client instance is genuine.

## 6. Validate the ID token

Use a maintained JOSE/OIDC library. Validate:

- signature using the discovered JWKS;
- algorithm against the discovery allowlist;
- exact issuer (`iss`);
- audience (`aud`) containing the exact CIMD URL;
- expiry (`exp`) and issued-at (`iat`);
- required subject (`sub`);
- exact original `nonce`.

Use `sub` as the stable account identifier. Never use email as a database primary key.

Example with `jose`:

```ts
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL(metadata.jwks_uri));
const { payload } = await jwtVerify(tokens.id_token, jwks, {
  issuer: "https://auth.smallticket.dev/api/auth",
  audience: clientId,
  algorithms: metadata.id_token_signing_alg_values_supported,
  requiredClaims: ["sub", "exp", "iat", "nonce"],
});

if (payload.nonce !== transaction.nonce) {
  throw new Error("ID token nonce mismatch");
}
```

Do not require `email`, `email_verified`, or `name` in the ID token.

## 7. Load and bind UserInfo

Call the discovered UserInfo endpoint with the access token:

```sh
curl -sS https://auth.smallticket.dev/api/auth/oauth2/userinfo \
  -H 'authorization: Bearer ACCESS_TOKEN'
```

Expected shape:

```json
{
  "sub": "stable-account-subject",
  "email": "person@smtax.vn",
  "email_verified": true,
  "name": "Non-empty display name"
}
```

SMT Account always returns a non-empty `name` when the `profile` scope is granted. If the account has no display name, the provider uses the part of the verified email before `@` (for example, `duy.le`). This fallback is provider-owned; applications must not duplicate it.

Validation rules:

- Require a non-empty UserInfo `sub`.
- Require UserInfo `sub` to exactly match ID-token `sub`.
- Require a syntactically valid email and `email_verified: true`.
- Normalize email casing before comparison or storage.
- Require the exact domain `smt.ai.kr`, `smallticket.dev`, or `smtax.vn`.
- Require `name` to be a non-empty string. Reject an invalid response instead of synthesizing a value in the application.

Never accept UserInfo whose subject differs from the ID token; that can indicate token substitution.

```ts
const response = await fetch(metadata.userinfo_endpoint, {
  headers: {
    accept: "application/json",
    authorization: `Bearer ${tokens.access_token}`,
  },
});
if (!response.ok) throw new Error("UserInfo request failed");

const user = await response.json();
if (user.sub !== payload.sub) throw new Error("UserInfo subject mismatch");
if (user.email_verified !== true) throw new Error("Email is not verified");

const email = String(user.email).trim().toLowerCase();
const domain = email.slice(email.lastIndexOf("@") + 1);
if (!["smt.ai.kr", "smallticket.dev", "smtax.vn"].includes(domain)) {
  throw new Error("Email domain is not allowed");
}

const identity = {
  id: user.sub,
  email,
  name: typeof user.name === "string" ? user.name.trim() : "",
};
if (!identity.name) throw new Error("UserInfo name is missing");
```

## 8. Create an application session

Create an application-owned session containing only the claims the app needs. Do not put provider access, refresh, or ID tokens into a client-readable cookie.

Recommended cookie:

```text
__Host-your_app_session=SEALED_VALUE; Path=/; HttpOnly; Secure; SameSite=Lax
```

Also:

- use authenticated encryption, or store an opaque session ID server-side;
- expire the application session independently from SMT Account;
- return `Cache-Control: no-store` from login, callback, and identity responses;
- recheck current authorization policy when opening long-lived sessions if policy can change.

## 9. Refresh and sign out

If `offline_access` was granted, store refresh tokens only in server-controlled encrypted storage. Rotate them according to the token response and never put them in browser local storage.

For sign-out:

1. clear the application's local session unconditionally;
2. redirect to the discovered `end_session_endpoint` when upstream logout is desired;
3. local logout must still succeed if discovery or upstream logout is unavailable.

## Development issuer and local loopback clients

Use the isolated development issuer for local loopback integrations:

```text
https://auth-dev.smallticket.dev/api/auth
```

The development service has separate users, sessions, signing keys, and storage. It accepts any valid email address and does not send email; use the displayed development OTP `000000`. It still requires Authorization Code, PKCE (`S256`), `state`, and `nonce`.

Never configure a production application to trust the development issuer. Development tokens must be accepted only when their exact `iss` is `https://auth-dev.smallticket.dev/api/auth`; they do not grant access to production identities or data.

### Quick start

Choose the exact local callback your application listens on:

```text
http://127.0.0.1:3000/auth/callback
```

Build the dynamic CIMD URL by placing that callback in the `redirect_uri` query parameter. Using a URL builder avoids nested-query encoding mistakes:

```ts
const issuer = "https://auth-dev.smallticket.dev/api/auth";
const redirectUri = "http://127.0.0.1:3000/auth/callback";
const clientMetadataUrl = new URL(
  "/dev/cimd",
  "https://auth-dev.smallticket.dev",
);
clientMetadataUrl.searchParams.set("redirect_uri", redirectUri);

const clientId = clientMetadataUrl.href;
```

The resulting full URL is the exact OAuth `client_id`:

```text
https://auth-dev.smallticket.dev/dev/cimd?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fauth%2Fcallback
```

It returns a no-store metadata document containing that one exact callback:

```json
{
  "client_id": "https://auth-dev.smallticket.dev/dev/cimd?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fauth%2Fcallback",
  "client_name": "SMT local development client",
  "application_type": "native",
  "redirect_uris": ["http://127.0.0.1:3000/auth/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}
```

Configure a generic/custom OIDC client with:

```text
issuer:                     https://auth-dev.smallticket.dev/api/auth
client_id:                  the complete dynamic CIMD URL above
client_secret:              none
token_endpoint_auth_method: none
redirect_uri:               the exact decoded loopback callback
PKCE method:                S256
scopes:                     openid profile email
```

Discover endpoints from:

```text
https://auth-dev.smallticket.dev/api/auth/.well-known/openid-configuration
```

When constructing an authorization request manually, use `URLSearchParams` so the complete CIMD URL is correctly encoded as the outer `client_id` parameter. Do not shorten, decode, rebuild, or omit its query string. Use the identical `client_id` and `redirect_uri` during the code exchange, and do not send a client secret.

At the sign-in page:

1. enter any syntactically valid email address;
2. continue to the code form (no email is sent);
3. use development OTP `000000`;
4. approve the requested scopes.

After the callback, validate `state`, exchange the code with the original PKCE verifier, validate the ID token against the development discovery document and JWKS, require the exact development issuer and audience, validate `nonce`, and load UserInfo as in the production flow.

Only HTTP(S) loopback callbacks on `localhost`, `*.localhost`, `127.0.0.0/8`, or `[::1]` are accepted. Any port is supported. Credentials, fragments, extra generator parameters, and non-loopback hosts are rejected. The callback must match exactly during authorization and token exchange; changing its host, port, path, or query requires a new dynamic client ID.

Browser-based local clients may call the discovered token, UserInfo, and revocation endpoints directly because validated loopback origins receive development CORS headers.

For HTTPS integration testing instead, publish CIMD on an allowed SMT-owned, `*.trycloudflare.com`, or `*.ngrok-free.dev` origin as described above.

Do not add an OTP bypass to `auth.smallticket.dev`. A production-hosted bypass would turn knowledge of an email address into account access.

## Codex and MCP

Codex publishes CIMD at:

```text
https://chatgpt.com/oauth/codex/client.json
```

Add an authenticated MCP server with:

```sh
codex mcp add smt-mcp \
  --url https://YOUR-MCP-HOST.smallticket.dev/mcp \
  --oauth-client-registration cimd
```

Codex is a native public client, so its declared loopback callbacks may use an ephemeral localhost port. That native-client exception does not weaken the HTTPS and same-origin rules for SMT web apps.

## Troubleshooting

### `Authentication could not be verified` after a valid OTP

Log safe stages such as `exchange_code`, `validate_id_token`, and `load_userinfo`. Never log codes, tokens, cookies, email values, or complete claim bodies.

Common causes:

- requiring email/profile claims in the ID token instead of calling UserInfo;
- receiving a missing or empty `name`, which violates the SMT Account contract;
- not checking that UserInfo `sub` matches ID-token `sub`;
- mismatched client ID, callback, state, nonce, or PKCE verifier;
- using a consumed authorization code.

### Token exchange returns `invalid_grant`

Confirm the code is unused, the callback is exact, and the verifier is the same one used to create the PKCE challenge.

### Client metadata is rejected

Confirm that the CIMD URL is HTTPS on an allowed SMT hostname, does not redirect, has a self-equal `client_id`, declares exact metadata-owned callbacks, and uses `token_endpoint_auth_method: "none"`.

### A library insists on a client secret

It is assuming a confidential client. Use its public-client or generic OIDC configuration with `token_endpoint_auth_method=none` and PKCE. Do not invent a shared secret.

## Pre-deployment checklist

- [ ] CIMD is public, exact, and does not redirect.
- [ ] Discovery issuer and endpoint hosts are validated.
- [ ] Authorization Code uses PKCE `S256`.
- [ ] State, nonce, verifier, callback, and creation time are sealed together.
- [ ] Callback state and issuer are checked before token exchange.
- [ ] ID-token signature, issuer, audience, expiry, subject, and nonce are checked.
- [ ] UserInfo is fetched and its `sub` matches the ID token.
- [ ] Verified email and exact domain allowlist are enforced.
- [ ] UserInfo `name` is present and non-empty; applications do not synthesize it.
- [ ] Session cookie is HttpOnly, Secure, SameSite, and host-only.
- [ ] Tokens, codes, cookies, and user claims are absent from logs.
- [ ] Login, callback, and identity responses use `Cache-Control: no-store`.
- [ ] Logout clears the local session even if upstream logout fails.
