External apps

Let external services sign users in with their rCTF account.

Edit this page View Markdown

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

  1. External app sends the user to /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
  3. User clicks authorize and rCTF mints a single-use code and redirects the browser to <redirect_uri>?code=...&state=...
  4. External app sends a POST /api/v2/external-auth/token request
  5. 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)

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 and reject banned teams
export 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).

Type to search.