External apps
Let external services sign users in with their rCTF account.
Scoring backends, instancers, dashboards, and other external apps can let users sign in with their rCTF account. After the user approves the app on rCTF, they are sent back with an authorization code.
Warning (This is NOT OAuth2)
This flow borrows OAuth2 field names such as client_id, redirect_uri, code, and state, but it is not an OAuth2 implementation. It has no scopes, refresh tokens, PKCE, OIDC discovery, token introspection, or revocation. The access token is an ordinary rCTF login token with full access to the user’s account.
Registering an app (admin)
Admin-only. Open /admin/settings, find the External apps section, click Add, and provide:
| Field | Purpose |
|---|---|
name |
Display name shown on the consent page. |
redirect_uri |
URI that receives the authorization code. It must match the registered value byte for byte. rCTF does not expand wildcards or normalize the URI. |
rCTF generates a UUID for client_id and 32 random bytes, encoded with base64url, for client_secret. The secret is shown exactly once. If it is lost, delete the app and register it again.
Deleting an app prevents it from exchanging any more authorization codes. Access tokens already issued to the app remain valid.
Flow
- External app sends the user to
/external-auth/authorize?client_id=...&redirect_uri=...&state=... - rCTF asks the user to log in if they aren’t already, then shows a consent screen
- User clicks authorize and rCTF mints a single-use code and redirects the browser to
<redirect_uri>?code=...&state=... - External app sends a
POST /api/v2/external-auth/tokenrequest - External app uses the access token in
Authorization: Bearer <accessToken>against any rCTF endpoint
The code is valid for 60 seconds and can only be used once. Before issuing it, /authorize checks that redirect_uri exactly matches the URI registered for the client.
Exchange the code by sending it with the client ID and secret.
Endpoints
GET /api/v2/external-auth/clients/:id
This public endpoint returns goodExternalAuthClient with the app’s {id, name, redirectUri} for the consent page. An unknown ID returns badExternalAuthRequest with HTTP 400.
POST /api/v2/external-auth/authorize
Send {clientId, redirectUri, state?} with the user’s session token in Authorization: Bearer. The response contains {redirectTo}, the complete callback URL with code and the optional state. Banned teams receive badPerms with HTTP 403 and cannot complete the flow.
POST /api/v2/external-auth/token
This public endpoint exchanges {clientId, clientSecret, code} for goodExternalAuthToken with {accessToken, tokenType: "bearer"}. A mismatch returns badExternalAuthRequest with HTTP 400.
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 team and reject banned teamsexport async function fetchTeam(accessToken: string): Promise<{ id: string; name: string }> { const res = await fetch(`${RCTF_BASE_URL}/api/v2/users/me`, { headers: { Authorization: `Bearer ${accessToken}` }, }) const body = (await res.json()) as { data: { id: string; name: string; banned: boolean } } if (body.data.banned) { throw new Error('team is banned') } return body.data}The state parameter is yours (rCTF passes it through verbatim).