Skip to main content

CIMD sample: publish a document and sign in

This sample shows the whole developer experience of Client ID Metadata Documents end to end, in one file with no dependencies: it serves a metadata document at the URL that is its client_id, discovers LumoAuth, runs an authorization-code + PKCE sign-in with a loopback callback, and calls userinfo with the token. Nothing is registered in LumoAuth beforehand; CIMD is on by default in every organization and on every plan.

Files:

1. The metadata document​

A Client ID Metadata Document is a plain JSON file using the RFC 7591 client metadata vocabulary. Three things matter:

  • client_id is the URL the file is served at, byte for byte.
  • It must be reachable over public https, answer 200 with Content-Type: application/json, and not redirect.
  • redirect_uris lists where the code may be sent: https://… URLs, or http://127.0.0.1/… / http://localhost/… for a loopback listener (any port matches at runtime).
https://client.example.com/oauth/metadata.json
{
"client_id": "https://client.example.com/oauth/metadata.json",
"client_name": "CIMD sample client",
"client_uri": "https://client.example.com",
"redirect_uris": [
"https://client.example.com/callback",
"http://127.0.0.1/callback"
],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"scope": "openid profile email mcp:read"
}

Serve it from anything that can host a static file: your app, an object-storage bucket, GitHub Pages. Add Cache-Control: max-age=60 while iterating (LumoAuth honours 60 s to 1 hour) and raise it once the document is stable.

What the organization decides

The document asks; the organization's policy decides. Identity scopes (openid profile email phone address) and mcp:read are always granted on request, the scopes of the organization's MCP servers too; anything else is dropped unless the admin allow-lists it. Loopback redirects are allowed with a consent warning unless the admin blocks them. Run the document through the check tool to see the effective client.

2. The client​

LUMO_ISSUER=https://app.lumoauth.dev/orgs/acme-corp/api/v1 \
CLIENT_ID=https://client.example.com/oauth/metadata.json \
node cimd-client.mjs
VariableMeaningDefault
LUMO_ISSUERThe organization's issuer: https://app.lumoauth.dev/orgs/<org>/api/v1 (or your custom domain / self-hosted base URL)required
CLIENT_IDThe public https URL of the metadata document; this process serves itrequired
PORTPort the metadata document is served on locally8787
CALLBACK_PORTLoopback port for the authorization callback8788
SCOPEScopes to requestopenid profile email mcp:read

What the script does, in order:

cimd-client.mjs (excerpt)
// 1. Serve the metadata document at the client_id's path.
const metadata = {
client_id: CLIENT_ID,
client_name: 'CIMD sample client',
redirect_uris: ['http://127.0.0.1/callback'],
grant_types: ['authorization_code', 'refresh_token'],
token_endpoint_auth_method: 'none',
scope: SCOPE,
};
http.createServer((req, res) => {
if (req.method === 'GET' && req.url === new URL(CLIENT_ID).pathname) {
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'public, max-age=60' });
res.end(JSON.stringify(metadata));
return;
}
res.writeHead(404).end();
}).listen(PORT);

// 2. Discover the authorization server; bail out early if CIMD is off there.
const as = await (await fetch(`${ISSUER}/.well-known/oauth-authorization-server`)).json();
if (as.client_id_metadata_document_supported !== true) throw new Error('CIMD is switched off for this organization');

// 3. PKCE, then send the user to /authorize with client_id = the document URL.
const verifier = b64url(randomBytes(32));
const challenge = b64url(createHash('sha256').update(verifier).digest());
const authorize = new URL(as.authorization_endpoint);
authorize.search = new URLSearchParams({
response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT_URI,
scope: SCOPE, state, code_challenge: challenge, code_challenge_method: 'S256',
}).toString();

// 4. Receive the code on 127.0.0.1:<CALLBACK_PORT>/callback, exchange it: no secret, just PKCE.
const tokens = await (await fetch(as.token_endpoint, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier }),
})).json();

The full file is cimd-client.mjs. A successful run prints the authorization URL to open, then the token summary and the userinfo claims:

metadata document on :8787/oauth/metadata.json (public URL: https://client.example.com/oauth/metadata.json)

Open this URL in a browser to sign in:

https://app.lumoauth.dev/orgs/acme-corp/api/v1/oauth/authorize?response_type=code&client_id=https%3A%2F%2Fclient.example.com%2Foauth%2Fmetadata.json&...

access_token (first 32 chars): eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0…
scope: openid profile email mcp:read | expires_in: 3600 | refresh_token: yes
userinfo: { sub: '01HF…', email: 'user@acme.example', name: 'A. User' }

On the consent screen the user sees the client badged as External client · client.example.com, and, because the redirect goes to 127.0.0.1, the localhost warning.

3. When something is wrong​

Every rejection names its reason, so you rarely need to guess:

  • The browser lands on an error page titled Client metadata document rejected with the reason under Technical details, for example client_id_mismatch with the explanation that the client_id member must equal the fetched URL.

  • The token endpoint answers 401 invalid_client and the script prints it:

    { "error": "invalid_client", "error_description": "Client ID Metadata Document rejected (invalid_redirect_uri): Every redirect URI must be an https:// URL, or http:// strictly on the loopback interface (localhost, 127.0.0.1, [::1]); no fragments, no custom schemes." }
  • An organization admin can run the URL through Applications → External clients (CIMD) → Check a metadata document or POST /admin/clients/cimd/check and see the effective client, including which scopes or redirect URIs were dropped.

Failures are never cached: fix the document and simply retry. The reason table lists every code.

Local development​

LumoAuth only fetches documents from public https hosts (SSRF protection, draft §8.6), so https://localhost/… or a private IP is rejected with unsafe_url. Options while developing:

  1. Host the document somewhere public, run the client locally. The document is static; put it on GitHub Pages, an S3/R2 bucket, or your staging site, and point CLIENT_ID at it. The sample's loopback redirect (http://127.0.0.1/callback) keeps the rest local.
  2. Expose the sample's port with any HTTPS tunnel (Cloudflare Tunnel, ngrok, Tailscale Funnel) and use the tunnel URL plus a path as CLIENT_ID. Tunnel hostnames change; so does the client identity (draft §8.3), so users re-consent after each new hostname.
  3. Use a sandbox organization (lumo dev start on Business, or any organization you administer) and leave the trust policy at its defaults.

Going confidential: private_key_jwt​

A client that can keep a private key should authenticate at the token endpoint. Publish the public key in the document and switch the method:

{
"client_id": "https://client.example.com/oauth/metadata.json",
"client_name": "CIMD sample client (confidential)",
"redirect_uris": ["https://client.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token", "client_credentials"],
"token_endpoint_auth_method": "private_key_jwt",
"jwks_uri": "https://client.example.com/oauth/jwks.json"
}

Then every token request carries a client assertion (RFC 7523) with iss and sub equal to the document URL and aud the token endpoint, signed with a key from jwks_uri; client_credentials becomes available and its tokens count as machine-to-machine tokens against the plan. The token endpoint reference shows the assertion format.

Standards used on this page​

Plain nameStandard
Client ID metadata documentsOAuth Client ID Metadata Document, draft 02
Proof Key for Code Exchange (PKCE)RFC 7636
OAuth for native apps (loopback redirect)RFC 8252
JWT client authenticationRFC 7523
OAuth 2.0 Authorization Server MetadataRFC 8414

See the standards reference for what each one covers and how LumoAuth uses it.