Skip to Content
MuoCeon Docs
Third-party OAuth IntegrationBefore you start

Quick start

Run "redirect → consent → callback → token exchange → userinfo" in fifteen minutes.

This page walks a minimal confidential client (web_server) through the whole flow. Everything here is standard OAuth 2.0 — swap in your preferred client library and the parameter meanings stay the same.

0. Prerequisites

NeedWhere it comes from
client_id / client_secretSee Registering a client
redirect_uriA registered callback, e.g. https://your-app.example.com/callback
issuere.g. https://id.example.com/v1/oauth
authorization_endpointRead from metadata, e.g. https://id.example.com/authorize

Minimal permission set for this walkthrough: openid profile email. Add offline_access only if you need long-term access.

1. Generate PKCE and state

import { randomBytes, createHash } from 'node:crypto'

const verifier = randomBytes(32).toString('base64url')
const challenge = createHash('sha256').update(verifier, 'ascii').digest('base64url')
const state = randomBytes(16).toString('base64url')

The verifier and state must be bound to the current browser session (an HttpOnly cookie or a server-side session), so the callback can retrieve them for comparison.

const url = new URL('https://id.example.com/authorize')
url.searchParams.set('client_id', CLIENT_ID)
url.searchParams.set('redirect_uri', REDIRECT_URI)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid profile email')
url.searchParams.set('state', state)
url.searchParams.set('code_challenge', challenge)
url.searchParams.set('code_challenge_method', 'S256')

return Response.redirect(url, 302)

Do not hand-build /v1/oauth/authorize

The server's /v1/oauth/authorize is an internal JSON endpoint used by the consent UI to submit its decision, and accepts POST only. Browser redirects must target the metadata's authorization_endpoint (the consent UI address).

The consent page shows the application name and each permission. When the user clicks "Allow", the browser is redirected (302) back to your redirect_uri:

https://your-app.example.com/callback?code=...&state=...&iss=https%3A%2F%2Fid.example.com%2Fv1%2Foauth

Three things in the callback handler, in this order:

  1. Compare state; mismatch means abort immediately (anti-CSRF).
  2. Compare iss against the issuer you trust; mismatch means abort immediately (RFC 9207, anti-mix-up).
  3. Take the code and exchange it immediately (authorization codes are single-use with a minute-scale lifetime).

If the user denies, the callback looks like:

https://your-app.example.com/callback?error=access_denied&state=...&iss=...

4. Exchange the code

curl -X POST 'https://id.example.com/v1/oauth/token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d 'grant_type=authorization_code' \
  -d "code=$CODE" \
  -d "redirect_uri=$REDIRECT_URI" \
  -d "code_verifier=$VERIFIER"

Successful response:

{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJSUzI1NiIs...",
  "scope": "openid profile email offline_access",
  "id_token": "eyJhbGciOiJSUzI1NiIs..."
}

Notes:

  • redirect_uri must be exactly the value used in the authorization request, or the exchange fails.
  • code_verifier corresponds to the challenge from step 1; the plain method is not accepted.
  • The response carries Cache-Control: no-store; nothing along the path may cache it.

5. Read the user's identity

curl 'https://id.example.com/v1/auth/userinfo' \
  -H "Authorization: Bearer $ACCESS_TOKEN"
{
  "sub": "0192f3a4-...",
  "preferred_username": "muoceon_user",
  "name": "Display Name",
  "email": "user@example.com",
  "email_verified": true
}

sub is the stable user identifier — use it as the account key. Do not use email: email can change, sub does not.

Checkpoints

SymptomUsual cause
Consent page says the client does not existclient_id unregistered or mistyped
Consent page rejects the redirect URIAddress not registered, or redirect_uri differs by a trailing slash
invalid_grant on exchangeCode already used or expired, or code_verifier does not match the challenge
invalid_client on exchangeConfidential client without its secret, or the wrong auth method
UserInfo audience mismatchAn oauth audience token was used, or the scope lacks openid/profile/email
Received fewer scopes than requestedThe server filters by the registered allowlist; filtered scopes do not raise errors — see Scopes and audiences

Once this works, read Authorization code flow to cover edge cases, then Tokens to make validation solid.

On this page