External apps
Let external services sign users in with their rCTF account.
External apps (scoring backends, instancers, dashboards, anything that lives next to rCTF) can sign users in via a “Sign in with rCTF” flow. The user visits the external service, clicks a button, lands on an rCTF consent page, approves, and is redirected back authorized.
Warning (This is NOT OAuth2)
The flow looks OAuth2-shaped, but it’s not. The wire-level field names (client_id,
redirect_uri, code, state) match what integrators expect. But there are no scopes, no
refresh tokens, no PKCE, no id_token/OIDC discovery, no token introspection or revocation. The
issued access token is a regular rCTF auth token - identical to one minted on login - and grants
full account access to the signing-in user.
Registering an app (admin)#
Admin-only. Open /admin/settings, find the External apps section, click “Add”, and provide:
rCTF generates a client_id (UUID) and a client_secret (32 random bytes, base64url-encoded). The secret is shown exactly once. Lose it and you have to delete the app and re-register.
Deleting an app makes future /api/v2/external-auth/token exchanges with that client fail. Existing access tokens (regular rCTF auth tokens) stay valid - rCTF has no per-app token registry.
Flow#
1. External app → Sends the user to <rctf>/external-auth/authorize?client_id=...&redirect_uri=...&state=...2. rCTF → Asks the user to log in if they aren't already, then shows a consent screen: "<App> wants to sign you in as <Team>"3. User clicks Authorize → rCTF mints a single-use code and redirects the browser to <redirect_uri>?code=...&state=...4. External app → POSTs to <rctf>/api/v2/external-auth/token with {clientId, clientSecret, code} and receives {accessToken, tokenType: "bearer"}5. External app → Uses the access token in Authorization: Bearer <accessToken> against any rCTF endpoint; GET /api/v1/users/me identifies the teamThe code is single-use. It lives in Redis for 60 seconds, and the first /token call atomically deletes it - any second exchange fails. The redirect_uri is bound to the registered URI for the client and checked byte-for-byte at /authorize; the /token call only needs the secret and the code. Mismatches on any field (client id, secret, code) all return the same generic badExternalAuthRequest so the endpoint can’t be probed.
Endpoints#
GET /api/v2/external-auth/clients/:id#
Public. Returns goodExternalAuthClient with {id, name, redirectUri}, used by the consent page to look up app metadata. Returns badExternalAuthRequest (HTTP 400) for unknown ids.
POST /api/v2/external-auth/authorize#
Authenticated (requires the user’s session token in Authorization: Bearer). Body: {clientId, redirectUri, state?}. The redirectUri must match what the client was registered with. Returns goodExternalAuthAuthorize with {redirectTo} - a fully-built URL containing code and (if provided) state.
POST /api/v2/external-auth/token#
Public. Body: {clientId, clientSecret, code}. Mismatch on any field returns badExternalAuthRequest (HTTP 400). On success, returns goodExternalAuthToken with {accessToken, tokenType: "bearer"}.
Note (Failure responses don't distinguish causes)
Unknown client, wrong secret, and wrong/expired/reused code all return the same badExternalAuthRequest body.
Reference client (TypeScript)#
const RCTF_BASE_URL = 'https://ctf.example.com'const CLIENT_ID = process.env.RCTF_CLIENT_ID!const CLIENT_SECRET = process.env.RCTF_CLIENT_SECRET!const REDIRECT_URI = 'https://my-service.example.com/auth/rctf/callback'
// 1. Send the user hereexport function buildSignInUrl(state: string): string { const params = new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, state, }) return `${RCTF_BASE_URL}/external-auth/authorize?${params}`}
// 2. When the user comes back, exchange the codeexport async function exchangeCode(code: string): Promise<{ accessToken: string }> { const res = await fetch(`${RCTF_BASE_URL}/api/v2/external-auth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, code, }), }) const body = (await res.json()) as { kind: string data: { accessToken: string; tokenType: 'bearer' } } if (body.kind !== 'goodExternalAuthToken') { throw new Error(`rCTF rejected token exchange: ${JSON.stringify(body)}`) } return { accessToken: body.data.accessToken }}
// 3. Identify the teamexport async function fetchTeam(accessToken: string): Promise<{ id: string; name: string }> { const res = await fetch(`${RCTF_BASE_URL}/api/v1/users/me`, { headers: { Authorization: `Bearer ${accessToken}` }, }) const body = (await res.json()) as { data: { id: string; name: string } } return body.data}The state parameter is yours (rCTF passes it through verbatim).