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:
metadata.json, a minimal Client ID Metadata Document to copycimd-client.mjs, the runnable client (Node 20+, standard library only)
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_idis the URL the file is served at, byte for byte.- It must be reachable over public https, answer
200withContent-Type: application/json, and not redirect. redirect_urislists where the code may be sent:https://…URLs, orhttp://127.0.0.1/…/http://localhost/…for a loopback listener (any port matches at runtime).
{
"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.
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
| Variable | Meaning | Default |
|---|---|---|
LUMO_ISSUER | The organization's issuer: https://app.lumoauth.dev/orgs/<org>/api/v1 (or your custom domain / self-hosted base URL) | required |
CLIENT_ID | The public https URL of the metadata document; this process serves it | required |
PORT | Port the metadata document is served on locally | 8787 |
CALLBACK_PORT | Loopback port for the authorization callback | 8788 |
SCOPE | Scopes to request | openid profile email mcp:read |
What the script does, in order:
// 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_mismatchwith the explanation that theclient_idmember must equal the fetched URL. -
The token endpoint answers
401 invalid_clientand 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/checkand 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:
- 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_IDat it. The sample's loopback redirect (http://127.0.0.1/callback) keeps the rest local. - 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. - Use a sandbox organization (
lumo dev starton 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.
Related
- Client ID Metadata Documents, rules, policy, plans and the reason table
- MCP Authorization Flow, where CIMD sits in the full MCP flow
- AI Agent + MCP in 5 minutes, the server side
Standards used on this page
| Plain name | Standard |
|---|---|
| Client ID metadata documents | OAuth Client ID Metadata Document, draft 02 |
| Proof Key for Code Exchange (PKCE) | RFC 7636 |
| OAuth for native apps (loopback redirect) | RFC 8252 |
| JWT client authentication | RFC 7523 |
| OAuth 2.0 Authorization Server Metadata | RFC 8414 |
See the standards reference for what each one covers and how LumoAuth uses it.