rCTF Docs
Overview

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:

FieldPurpose
nameDisplay name shown on the consent page.
redirect_uriExact URI the user will be redirected to with the authorization code. Bytes-exact match (no wildcards, no path normalization).

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 team

The 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)#

sign-in-with-rctf.ts
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 here
export 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 code
export 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
export 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).

Esc

Start typing to search the docs.