Self-service client registration

Internal MVS apps can register an oauth_client over an API instead of asking an operator to run oidc:register by hand. The endpoints live on the central IdP at https://c-auth.mvscloud.com/api/auth/admin/*. There are two ways in: an admin session (a logged-in user whose role contains admin) or a scoped Initial Access Token (IAT) an admin mints for a team or CI. There is never an anonymous path.

This is for internal MVS apps only. The endpoints write the same oauth_client row shape the OIDC authorize/token flow reads — registering this way is equivalent to the oidc:register CLI, just over HTTP. The Better Auth plugin’s own RFC 7591 /oauth2/register stays disabled.

The endpoints return 404 until an operator sets OAUTH_SELF_REGISTRATION_ENABLED=true on the central-auth project. If you get a 404, the feature is off — ask the operator, don’t change your request.

1. Mint an Initial Access Token (admin)

An admin mints an IAT and hands the one-time plaintext to the team or CI that will register. The token is bounded — it can only mint clients within the scopes, redirect prefixes, use-count, and TTL set here.

$curl -sX POST https://c-auth.mvscloud.com/api/auth/admin/registration-tokens \
> -H "Content-Type: application/json" \
> -b "$ADMIN_SESSION_COOKIE" \
> -d '{
> "description": "HealthOS web CI",
> "allowedScopes": ["openid", "profile", "email", "offline_access"],
> "redirectUriPrefixes": ["https://healthos.mvscloud.com/"],
> "maxUses": 1,
> "ttlSeconds": 3600
> }'

Response (the token is shown once — only a SHA-256 hash is stored):

1{
2 "id": "clr…",
3 "token": "mvs_iat_…",
4 "allowedScopes": ["openid", "profile", "email", "offline_access"],
5 "redirectUriPrefixes": ["https://healthos.mvscloud.com/"],
6 "maxUses": 1,
7 "expiresAt": "2026-06-15T13:00:00.000Z"
8}
FieldMeaning
allowedScopesScopes a client minted with this token may request — a strict superset check (scopes ⊆ allowedScopes).
redirectUriPrefixesEvery redirect URI on a registered client must fall within one of these by exact origin (scheme+host+port) + path-segment boundary (not a raw string prefix).
maxUsesHow many clients the token can register before it is exhausted (default 1).
ttlSecondsLifetime; sets expiresAt. A token is valid iff not revoked, not expired, and usedCount < maxUses.

Minting an IAT is admin session only — there is no IAT-mints-IAT path. The bearer (IAT) path is rejected on registration-tokens.

2. Register a client

Register with the IAT in the Authorization header. Bearer credentials aren’t auto-attached by browsers, so the IAT path is exempt from the cross-site origin (CSRF) check that guards the cookie path. (An admin can also register directly with a session cookie and a wider scope allowlist — same endpoint, no bearer.)

$curl -sX POST https://c-auth.mvscloud.com/api/auth/admin/oauth-clients \
> -H "Content-Type: application/json" \
> -H "Authorization: Bearer mvs_iat_…" \
> -d '{
> "name": "HealthOS Web",
> "redirectUris": ["https://healthos.mvscloud.com/api/auth/callback/mvs-id"],
> "scopes": ["openid", "profile", "email", "offline_access"],
> "clientType": "confidential",
> "infisicalTarget": {
> "projectId": "…",
> "environment": "prod",
> "secretPath": "/healthos-web",
> "idPrefix": "MVS_OIDC_"
> }
> }'

Response (the clientSecret is shown once):

1{
2 "action": "create",
3 "clientId": "",
4 "clientSecret": "mvs_cs_…",
5 "registrationId": "clr…",
6 "infisical": { "written": true, "ref": "infisical:/healthos-web:MVS_OIDC_CLIENT_SECRET" }
7}

action is "create" for a new client (HTTP 201) or "update" for an existing one (HTTP 200). When Infisical auto-provision isn’t configured (see §3), the infisical block is instead { "written": false, "error": "infisical_provision_not_configured" } — the registration still succeeds and still returns the one-time secret.

FieldRequiredNotes
nameyesHuman-readable, non-empty.
redirectUrisyesExact-match HTTPS allowlist, ≥1, no wildcards. On the IAT path each must fall within one of the token’s redirectUriPrefixes by exact origin (scheme+host+port) + path-segment boundary (not a raw string prefix).
postLogoutRedirectUrisnoSame exact-match HTTPS rules.
scopesnoDefaults to openid profile email offline_access; constrained to the server allowlist (and the IAT’s allowedScopes).
grantTypesnoDefaults to authorization_code, refresh_token.
clientTypenoconfidential (default) or public. Public clients get no secret and tokenEndpointAuthMethod: none.
infisicalTargetnoWhen present, the credentials are also written to Infisical (see §3).

3. What lands in Infisical

When you pass infisicalTarget, the service writes the credentials to that Infisical path using a dedicated, least-privilege machine identity (distinct from the app’s own runtime identity). The existing Infisical→Vercel sync then delivers them to your project.

KeyValue
<idPrefix>CLIENT_IDThe new client_id.
<idPrefix>CLIENT_SECRETThe one-time mvs_cs_… plaintext.

With idPrefix: "MVS_OIDC_" you get MVS_OIDC_CLIENT_ID / MVS_OIDC_CLIENT_SECRET — exactly the env names the OIDC integration guide expects. The write is idempotent (create-or-update) and the target folder is created if it doesn’t exist.

Auto-provision requires the operator to have configured INFISICAL_PROVISION_CLIENT_ID / INFISICAL_PROVISION_CLIENT_SECRET on the central-auth project and for the request to include an infisicalTarget. Without those provisioning credentials the write is a no-op: the response carries infisical.written: false with error: "infisical_provision_not_configured" (and omitting infisicalTarget skips Infisical entirely).

Infisical writes are non-fatal. If the write fails, registration still succeeds and returns the one-time secret — the response carries infisical.written: false with an error, and the failure is audited. Save the returned secret regardless; it cannot be retrieved later.

4. Security model

ControlEnforcement
Trust gateAdmin session or valid IAT — never anonymous. Admin = better-auth admin role on a live session.
Secret storageclient_secret and IAT are stored only as SHA-256 base64url hashes; plaintext shown once; constant-time compare on verify.
PKCEForced S256 on every registered client (requirePKCE: true); plain is rejected at authorize/token.
Redirect URIsAbsolute HTTPS, exact-match allowlist, no wildcards. IAT registrations are further bounded to the token’s redirectUriPrefixes.
ScopesConstrained to the server allowlist; IAT registrations bounded to the token’s allowedScopes. Defaults applied when omitted.
IAT boundsScopes ⊆ allowedScopes; each redirect URI falls within a redirectUriPrefix by exact origin (scheme+host+port) + path-segment boundary (not a raw string prefix); usedCount < maxUses; not expired; not revoked. A bounds violation is a 400.
Rate limitPer-IP on the registration and token endpoints when Upstash is configured; fails open when it isn’t.
AuditEvery mutating action writes an oauth_registration_audit row (token.mint, token.revoke, client.register, client.disable).

An exhausted IAT (usedCount >= maxUses), an expired one, or a revoked one returns 401 invalid_token at validate time. A request whose scopes or redirect URIs fall outside the token’s bounds returns 400 — the client is not created. Distinct from both: a maxUses-limited IAT that passes validation but loses the atomic consume race (a concurrent request consumed the last use) returns 409 — also with no client created.

5. Lifecycle

Rotate a secret — re-register the same client (admin path) with secret rotation; the new mvs_cs_… is returned once and an update that does not rotate never clobbers the stored hash.

Disable a client — soft-disable (the token endpoint then rejects it; no hard delete):

$curl -sX DELETE https://c-auth.mvscloud.com/api/auth/admin/oauth-clients/<clientId> \
> -b "$ADMIN_SESSION_COOKIE"

List clients / tokens — admin session only; secrets and token hashes are never returned:

$curl -s https://c-auth.mvscloud.com/api/auth/admin/oauth-clients -b "$ADMIN_SESSION_COOKIE"
$curl -s https://c-auth.mvscloud.com/api/auth/admin/registration-tokens -b "$ADMIN_SESSION_COOKIE"

Revoke an IAT — sets revokedAt; the token can no longer register:

$curl -sX DELETE https://c-auth.mvscloud.com/api/auth/admin/registration-tokens/<id> \
> -b "$ADMIN_SESSION_COOKIE"

DELETE, GET (list), and minting tokens are admin session only — the IAT bearer path is rejected on those. Only POST /api/auth/admin/oauth-clients accepts an IAT. Full request/response schemas are in the Service Routes API reference under the Client Registration tag.


6. Platform surface (c2-auth) — operator apps

Everything above is the central surface (c-auth.mvscloud.com), which serves tenant apps and writes oauth_client rows keyed to User. There is a second, fully isolated surface — platform (c2-auth.mvscloud.com) — that serves operator/admin apps (e.g. mvs-pay, mvs-ops-ai, mvs-c2) and writes platform_oauth_client rows keyed to PlatformUser. It is shipped and live in production. The flow is identical to §1–§5 — same IAT-or-admin-session trust gate, same one-time mvs_cs_… secret scheme, same idempotent-by-name behavior, same infisicalTarget auto-provision — so this section states only the deltas.

Full operator-facing integration guide (endpoints, claims, Better Auth genericOAuth config, token verification) lives in the Operator-app OIDC integration page. Use c2-auth when the relying party is an operator/admin tool; use c-auth (§1–§5) when it serves tenant users.

Endpoints (platform)

Swap the host and base path. Everything under /api/auth/* on central maps to /api/platform-auth/* on platform:

PurposeCentral (c-auth)Platform (c2-auth)
Mint IATPOST /api/auth/admin/registration-tokensPOST /api/platform-auth/admin/registration-tokens
Register clientPOST /api/auth/admin/oauth-clientsPOST /api/platform-auth/admin/oauth-clients
$# Mint a platform IAT (admin session only — a PlatformUser with an admin role)
$curl -sX POST https://c2-auth.mvscloud.com/api/platform-auth/admin/registration-tokens \
> -H "Content-Type: application/json" \
> -b "$PLATFORM_ADMIN_SESSION_COOKIE" \
> -d '{ "description": "MVS Pay CI", "maxUses": 1, "ttlSeconds": 3600 }'
$
$# Register a platform client (Bearer = platform IAT, mvs_iat_…)
$curl -sX POST https://c2-auth.mvscloud.com/api/platform-auth/admin/oauth-clients \
> -H "Content-Type: application/json" \
> -H "Authorization: Bearer mvs_iat_…" \
> -d '{
> "name": "MVS Pay (Operator)",
> "redirectUris": ["https://pay.mvscloud.com/api/auth/callback/c2-auth"],
> "scopes": ["openid", "profile", "email", "offline_access"],
> "clientType": "confidential"
> }'
$# → 201 { clientId, clientSecret (mvs_cs_…, ONCE), registrationId, action, infisical }

The redirect URI to register follows the Better Auth genericOAuth convention with providerId: "c2-auth" — at better-auth ≥ 1.7 that is {APP_BASE_URL}/api/auth/callback/c2-auth.

The two surfaces are isolated, and the boundary is enforced on every request. A platform IAT (mvs_iat_… minted on c2-auth) presented to the central route (/api/auth/admin/oauth-clients) is rejected 401 — and a central IAT presented to the platform route is rejected 401 the same way. A client registered on one surface is invisible to the other (platform_oauth_client / PlatformUser vs oauth_client / User, with a surface-scoped registration token table).

Gating flag (platform)

The platform registration route is gated by PLATFORM_OAUTH_SELF_REGISTRATION_ENABLED=true (the platform counterpart of central’s OAUTH_SELF_REGISTRATION_ENABLED), set in Infisical /platform-auth on the mvs-platform-auth project — already on in prod. It is a cold-start read, so toggling it requires a redeploy.

Operator CLI (platform)

For one-off registrations, the operator CLI mirrors the central oidc:register command but writes a platform_oauth_client row. It is dry-run by default; add --apply to write. Requires AUTH_DATABASE_URL.

$AUTH_DATABASE_URL= pnpm --filter @mvs/auth-server oidc:register:platform -- \
> --name="MVS Pay (Operator)" \
> --redirect="https://pay.mvscloud.com/api/auth/callback/c2-auth" \
> --type=confidential \
> --apply

Re-running with the same --name is idempotent (refuses to mint a duplicate unless --force).