External auth
Public routes that let an external service sign users in with their rCTF account.
These routes power the “Sign in with rCTF” flow for external services. The user-facing consent page lives at /external-auth/authorize; the API routes here are what that page (and the external service’s backend) call.
The admin-side routes for registering and revoking external-auth clients are documented in Admin. The operator walkthrough lives at External apps.
Warning (Not OAuth2)
The wire-level field names (client_id, redirect_uri, code, state) match what integrators
expect, but the flow is not OAuth2. There are no scopes, refresh tokens, PKCE, id_token/OIDC
discovery, token introspection, or revocation. The access token returned by /token is a regular
rCTF auth token - identical to one minted on login - and grants full account access to the
signing-in user.
Flow#
- The external service sends the user to
/external-auth/authorize?client_id=...&redirect_uri=...&state=.... - The consent page calls
GET /api/v2/external-auth/clients/:idto render the client name and verify the redirect URI. - The user logs into rCTF if needed, then approves. The page calls
POST /api/v2/external-auth/authorizewith the user’s session token and receives aredirectToURL. - The browser navigates to
redirect_uri?code=...&state=.... - The external service’s backend exchanges the code through
POST /api/v2/external-auth/tokenand receives{accessToken, tokenType: "bearer"}. - The service uses the access token in
Authorization: Bearer ...against any rCTF endpoint - typicallyGET /api/v1/users/meto identify the team.
Failure model#
Every failure mode (unknown client, wrong secret, mismatched redirect URI, missing/expired/reused code, mismatched client on a code) returns the same 400 badExternalAuthRequest body. The endpoint deliberately doesn’t distinguish causes so callers can’t probe it. The only authenticated route in the section is POST /api/v2/external-auth/authorize, which additionally returns 401 badToken when the user session token is missing or invalid.
Code lifetime#
Authorization codes live in Redis for 60 seconds and are single-use - the first POST /api/v2/external-auth/token call atomically deletes the code, so a second exchange always fails. There is no per-app token registry: deleting a client through the admin routes blocks future token exchanges but does not revoke access tokens that were already issued.
GET Get client metadata
GET /api/v2/external-auth/clients/:id
- Auth
- Public
- Gate
- None
- Permissions
- No extra permissions
- Captcha
- No captcha
- Rate limit
- No rate limit
Public lookup for the consent page. The page calls this route with the client_id from the query string to render the app name and verify the redirect URI before showing the consent prompt. The endpoint never returns the client secret.
Unknown ids return 400 badExternalAuthRequest - the same response used for every other failure mode in the External auth flow.
Path parameters
id requiredstringResponse fields
idstringnamestringredirectUristringPOST Authorize
POST /api/v2/external-auth/authorize
- Auth
- Required
- Gate
- None
- Permissions
- No extra permissions
- Captcha
- No captcha
- Rate limit
- No rate limit
Mints a single-use authorization code for the signed-in user and returns the URL the browser should navigate to next. The consent page calls this when the user clicks Authorize.
The redirectUri must match what the client was registered with byte-for-byte (no wildcards, no path normalization). Any mismatch returns 400 badExternalAuthRequest - the response intentionally doesn’t distinguish “unknown client” from “wrong redirect URI” so the endpoint can’t be probed.
Request body
clientId requiredstringredirectUri requiredstringstatestringResponse fields
redirectTostringThe returned redirectTo is the registered redirectUri with code=... (and, when provided, state=...) appended using a ? or & separator as needed. The code lives in Redis for 60 seconds and is consumed by the first POST /api/v2/external-auth/token call - a second exchange always fails.
The state value is opaque to rCTF and is passed through verbatim. It is the integrator’s responsibility to use it for CSRF protection.
POST Exchange code for token
POST /api/v2/external-auth/token
- Auth
- Public
- Gate
- None
- Permissions
- No extra permissions
- Captcha
- No captcha
- Rate limit
- No rate limit
Server-to-server exchange. The external service’s backend trades the single-use code from the browser redirect for a regular rCTF auth token. Call this from a trusted environment - the client secret must not be exposed to the browser.
The code is consumed atomically: the first call deletes it from Redis, so a replay or any concurrent second exchange always fails.
Note (Failures all look identical)
Unknown client, wrong secret, wrong code, expired code, reused code, and code/client mismatch all
return the same 400 badExternalAuthRequest body. Don’t rely on the failure
reason to debug an integration - check timestamps, secret rotation, and the exact bytes you sent
instead.
Request body
clientId requiredstringclientSecret requiredstringcode requiredstringResponse fields
accessTokenstringtokenType"bearer"The returned accessToken is a regular rCTF auth token with no expiry - the same kind of token issued at login. Use it in Authorization: Bearer <accessToken> against any rCTF endpoint. There is no per-app token registry: deleting the client through the admin routes prevents future code exchanges but does not revoke tokens that were already minted.