OAuth without a client secret

Quickstart

The canonical issuer is https://moddingflow.com. Desktop, mobile, CLI and Mod Manager integrations use a public client_id with token_endpoint_auth_method=none. They never receive or send a static client_secret.

The app registers once automatically on first sign-in. For every authorization, generate a fresh verifier, S256 challenge, state, and nonce, open the system browser, validate state and iss, then exchange the code with client_id and the original verifier. The user clicks sign in and never copies a client_id or token.

POST https://moddingflow.com/oauth/register with Content-Type: application/json and the minimal body {"client_name":"My Mod Manager","redirect_uris":["http://127.0.0.1/callback"]}. No developer account, contact metadata, manual approval, or client secret is required; base scopes are assigned automatically.

The response contains client_id, registration_client_uri, and a one-time registration_access_token, and never client_secret. The app persists the public client_id automatically. Discard the management token if registration will not be managed, or keep it only in a protected product backend; never show it to the user.

Authorization Code + PKCE

Generate 32 random bytes with the platform CSPRNG, encode the verifier as base64url without padding, and set code_challenge=BASE64URL(SHA256(verifier)) with code_challenge_method=S256. Also generate independent state and OpenID Connect nonce values.

Open GET https://moddingflow.com/oauth/authorize?response_type=code&client_id=CLIENT_ID&redirect_uri=CALLBACK&scope=openid%20mods:read&code_challenge=CHALLENGE&code_challenge_method=S256&state=STATE&nonce=NONCE in the system browser. Embedded webviews, implicit flow, password grant and prompt=none are unsupported.

Require exact state and iss=https://moddingflow.com in both success and error callbacks. Exchange the five-minute code at POST /oauth/token with grant_type=authorization_code, client_id, code, redirect_uri and code_verifier. No client secret is sent.

Validate an OpenID Connect ID token with discovery, JWKS, RS256 signature, issuer, audience, expiry and the original nonce. Access tokens live for 15 minutes.

Device Flow

POST /oauth/device_authorization with client_id and scope, show verification_uri_complete plus user_code, and keep the full trust and permission disclosure visible. Poll POST /oauth/token with the Device Grant type at no faster than the returned interval.

Handle authorization_pending, slow_down, access_denied and expired_token exactly. Stop polling after denial, expiry or success and never log device_code, access tokens or refresh tokens.

Registration and callbacks

registration_access_token uses the mfrg_ prefix and is an administrative registration credential, not an OAuth access or refresh token. Discard it immediately when the registration lifecycle will not be managed. Otherwise keep it only in a protected product backend or secret manager and pass it as the separate managementToken argument. Never display it or ask the user to copy it.

GET registration_client_uri reads metadata without exposing or rotating the token. PUT replaces metadata and atomically rotates it. DELETE disables the client and revokes grants. Claiming the client permanently invalidates the management token.

New clients may use exact HTTPS callbacks, loopback IP callbacks on 127.0.0.1 or [::1] with a dynamic port, or reverse-domain custom schemes such as com.example.manager:/oauth/callback. localhost, fragments, userinfo, wildcard hosts and arbitrary schemes are rejected.

Scopes, consent and review

Anonymous registration may request any subset of openid, mods:read, files:download, install_plans:resolve and profile:read. Omitting scope selects that base set. The consent screen is shown on every authorization, even for previously granted permissions.

To request mods:write, version or file management, webhooks, private data or team administration, claim the dynamic client in Developer Settings using client_id and the management token. Claim requires a same-origin account session and AAL2/2FA.

Sensitive requests move through pending, approved, or rejected; the administrator must record a reason and the decision is audited. rejected is fail-closed. agent:* and admin:* are internal-only and always rejected for third-party clients.

Tokens, revocation and errors

Refresh tokens rotate on every use. Reuse of an old token revokes the entire family. POST /oauth/revoke is idempotent. Users can also revoke an app in Account Security; this closes consent, refresh families, pending codes and device grants, and makes current Bearer tokens fail immediately.

Treat invalid_request, invalid_client, invalid_grant, invalid_scope, unauthorized_client, access_denied and interaction_required as terminal for the current attempt. Retry only documented transient failures and respect Retry-After with jitter.

Open registration is limited to a 16 KiB body, 10 requests per IP per hour and 50 per IP per day. Registration management, claim, device polling and authentication failures use separate shared fail-closed buckets.

SDK helpers

TypeScript: const request = await createOAuthAuthorizationRequest({ clientId, redirectUri, scopes: ["openid", "mods:read"] }); persist request.verifier, request.state and request.nonce, then open request.authorizationUrl.

Python: request = create_oauth_authorization_request(client_id=client_id, redirect_uri=redirect_uri, scopes=["openid", "mods:read"]); persist verifier, state and nonce, then open authorization_url.

C#: var request = ModdingflowPublicApiClient.CreateOAuthAuthorizationRequest(clientId, redirectUri, new[] { "openid", "mods:read" }); persist Verifier, State and Nonce, then open AuthorizationUrl.