Operator-app OIDC integration

The MVS platform OIDC provider is live in production at https://c2-auth.mvscloud.com. It is the operator-facing IdP: use it when the relying party is an MVS operator/admin tool (e.g. mvs-pay, mvs-ops-ai, mvs-c2) whose users are operators — the PlatformUser directory, signed in via Okta SSO + passkeys. Any standards-compliant OIDC client library works (e.g. openid-client, Better Auth’s genericOAuth).

Two providers, two surfaces. Tenant apps consume central auth at c-auth.mvscloud.com (see the OIDC integration guide); operator apps consume platform auth at c2-auth.mvscloud.com. The two are fully isolated: c2-auth FKs to PlatformUser and writes the platform_oauth_* tables; c-auth FKs to User and writes oauth_client. A client registered on one surface is invisible to the other. For the shared-cookie redirect model (no OIDC), see the Platform auth integration guide.

1. Endpoints

Resolve everything from the discovery document — let your client library derive the rest, and do not hardcode endpoints other than discovery.

PurposeURL
Issuerhttps://c2-auth.mvscloud.com (exact, no trailing slash)
Discoveryhttps://c2-auth.mvscloud.com/.well-known/openid-configuration
Authorizationhttps://c2-auth.mvscloud.com/api/platform-auth/oauth2/authorize
Tokenhttps://c2-auth.mvscloud.com/api/platform-auth/oauth2/token
UserInfohttps://c2-auth.mvscloud.com/api/platform-auth/oauth2/userinfo
JWKShttps://c2-auth.mvscloud.com/api/platform-auth/jwks
Admin registration (self-service)POST https://c2-auth.mvscloud.com/api/platform-auth/admin/oauth-clients

The platform JWKS lives under /api/platform-auth/jwksnot /api/auth/jwks (that path is central). Always resolve jwks_uri from discovery rather than hardcoding it.

Invariants (enforced server-side, non-negotiable):

  • PKCE S256 mandatoryplain is rejected.
  • ID token signing is EdDSA (Ed25519) only — pin algorithms: ["EdDSA"] in verifiers; never accept RS256/HS256.
  • Issuer is exactly https://c2-auth.mvscloud.com (no trailing slash).
  • Dynamic client registration (RFC 7591) is disabled — every RP needs a platform_oauth_client row (see §4).

2. Configure your client (Better Auth genericOAuth)

For an operator app running its own Better Auth instance and consuming c2-auth as an upstream OIDC provider:

1import { betterAuth } from "better-auth";
2import { genericOAuth } from "better-auth/plugins";
3
4export const auth = betterAuth({
5 baseURL: process.env.APP_BASE_URL, // e.g. https://pay.mvscloud.com
6 // ...adapter, secret, other plugins...
7 plugins: [
8 genericOAuth({
9 config: [
10 {
11 providerId: "c2-auth",
12 discoveryUrl:
13 "https://c2-auth.mvscloud.com/.well-known/openid-configuration",
14 clientId: process.env.MVS_OIDC_CLIENT_ID!,
15 clientSecret: process.env.MVS_OIDC_CLIENT_SECRET!,
16 scopes: ["openid", "profile", "email", "offline_access"],
17 pkce: true, // S256 — set explicitly
18 authentication: "basic", // client_secret_basic
19 requireIssuerValidation: true, // RFC 9207
20
21 // Operator role/groups travel as namespaced claims (see §3).
22 mapProfileToUser: (profile) => ({
23 role: (profile["https://mvscloud.com/role"] as string | null) ?? null,
24 groups: (() => {
25 const raw = profile["https://mvscloud.com/groups"] as string | null;
26 try {
27 return raw ? (JSON.parse(raw) as string[]) : [];
28 } catch {
29 return [];
30 }
31 })(),
32 }),
33 },
34 ],
35 }),
36 ],
37});

Trigger sign-in from the client (Better Auth ≥ 1.7):

1authClient.signIn.social({ provider: "c2-auth", callbackURL: "/dashboard" });

Callback / redirect URI

The redirect URI you register at c2-auth depends on your Better Auth version — this is the value the operator adds to your client’s exact-match allowlist:

Better Auth versionRedirect URI to register
≥ 1.7.0 (core social route){APP_BASE_URL}/api/auth/callback/c2-auth
≤ 1.6.x (legacy){APP_BASE_URL}/api/auth/oauth2/callback/c2-auth
SSO plugin (any){APP_BASE_URL}/api/auth/sso/callback/c2-auth

The repo pins better-auth at 1.7.0-beta.5, so the ≥ 1.7 path applies: register {APP_BASE_URL}/api/auth/callback/c2-auth.

Set your client env to the credentials returned at registration: MVS_OIDC_CLIENT_ID and MVS_OIDC_CLIENT_SECRET (the mvs_cs_… secret is shown once and stored hashed). If the app also consumes central auth — rare for operator apps — use a distinct prefix (e.g. C2_AUTH_OIDC_CLIENT_*) to disambiguate.

3. Claims

The c2-auth ID token and /userinfo response carry these operator claims, sourced from PlatformUser (not tenant-org membership). sub, email, name, etc. are standard.

ClaimValue
https://mvscloud.com/roleOperator role string (super_admin | admin | …) or null
https://mvscloud.com/groupsJSON-stringified array, e.g. '["admin"]', or null

Authorize on role / groups, never on email. These claims live on the ID token and /userinfo — they are not on the access token. Consume them from the ID token or userinfo.

4. Register your client

Dynamic registration is off; every RP needs a platform_oauth_client row. Both paths below write that row through the one shared registration core (surface: "platform") — identical validation, the mvs_cs_ secret scheme, and idempotent-by-name upsert.

Path A — operator CLI

Dry-run by default; add --apply to write. Requires AUTH_DATABASE_URL pointing at the auth database, run from the mvs-auth repo.

$# DRY-RUN (plan only, no write)
$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 (writes the row; prints the integration packet + one-time secret)
$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" \
> --post-logout="https://pay.mvscloud.com/logged-out" \
> --type=confidential \
> --apply

Flags mirror the central CLI: --scopes, --grant-types, --client-id, --no-skip-consent, --rotate-secret, --force. Re-running with the same --name is idempotent (it refuses to mint a duplicate unless --force).

Path B — self-service HTTP API

The admin registration route is gated by PLATFORM_OAUTH_SELF_REGISTRATION_ENABLED=true (already on in prod). A platform admin (a PlatformUser with an admin role) either calls it with an admin session, or mints a platform Initial Access Token (IAT) to delegate scoped, bounded registration.

$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"
> }'

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:/mvs-pay:MVS_OIDC_CLIENT_SECRET" }
7}

Platform IATs are minted at POST https://c2-auth.mvscloud.com/api/platform-auth/admin/registration-tokens (admin session only). The same request shape, IAT bounds, and Infisical auto-provision (infisicalTarget) as central apply here — see the self-service client registration guide.

The surfaces are isolated. A platform IAT can only mint platform clients — presenting it to the central route (/api/auth/admin/oauth-clients) returns 401. Always register operator apps against c2-auth, not c-auth.

5. Verify tokens (resource servers)

ID tokens are EdDSA JWTs — verify against the platform JWKS, pinning the issuer, your audience, and EdDSA only.

1import { createRemoteJWKSet, jwtVerify } from "jose";
2
3const JWKS = createRemoteJWKSet(
4 new URL("https://c2-auth.mvscloud.com/api/platform-auth/jwks"),
5);
6
7export async function verifyOperatorToken(token: string, expectedAudience: string) {
8 const { payload } = await jwtVerify(token, JWKS, {
9 issuer: "https://c2-auth.mvscloud.com",
10 audience: expectedAudience, // your API origin (see §6)
11 algorithms: ["EdDSA"], // EdDSA only
12 });
13 const role = payload["https://mvscloud.com/role"] as string | null;
14 if (!role || !["admin", "super_admin"].includes(role)) {
15 throw new Error("forbidden: insufficient operator role");
16 }
17 return payload;
18}

6. JWT access tokens (PLATFORM_OIDC_VALID_AUDIENCES)

By default the provider issues opaque access tokens (introspect them, or just use the ID token). To receive an EdDSA JWT access token bound to your API, send the RFC 8707 resource parameter on /authorize, and the operator must add your API origin to the platform audience allowlist.

  • Env var: PLATFORM_OIDC_VALID_AUDIENCES (Infisical path /platform-auth, prod) — distinct from central’s OIDC_VALID_AUDIENCES.
  • Format: comma-separated absolute origins, exact match, no wildcards — e.g. https://pay.mvscloud.com/api,https://ops-ai.mvscloud.com/api.
  • Never set it to the issuer origin — self-aliasing rejects every RP. By design the parser never defaults to the issuer; unset means no aud-bound JWTs are accepted.
  • Cold-start flag: changing it requires a redeploy of mvs-platform-auth.

ID-token and opaque-token flows work without this var. Only set it when an operator RP needs aud-bound JWT access tokens, and only the operator can set it (it is not self-service).

7. Security model

ControlEnforcement
Surface isolationPlatform IATs/clients live in platform_oauth_* / oauth_registration_token (surface='platform'); cross-surface use is rejected with 401. Verified live.
PKCEForced S256 on every registered client; plain is rejected at authorize/token.
Redirect URIsAbsolute HTTPS, exact-match allowlist, no wildcards. IAT registrations are further bounded to the token’s redirectUriPrefixes.
Secret storageclient_secret and IAT are stored only as SHA-256 base64url hashes; plaintext shown once; never recoverable.
ID token signingEdDSA (Ed25519) only; the platform uses its own platform_jwks keys, never central’s.
Provider gateThe provider is live in prod behind PLATFORM_OIDC_PROVIDER_ENABLED=true + a pinned PLATFORM_OIDC_PROVIDER_ISSUER_URL (cold-start reads).

The platform OIDC provider is shipped and live in production. Full request/response schemas are in the Platform Auth API reference.