CP OAuth is an OAuth 2.0 provider built for competitive programming platforms. Users can register, link their competitive programming accounts (Luogu, AtCoder, Codeforces, Clist, etc.), and authorize third-party applications via standard OAuth 2.0 flows with PKCE support.
- Runtime: Node.js 20, Python 3.10+
- Framework: Nuxt 4 (SSR) with Nitro server engine
- Database: PostgreSQL 16 (via Prisma ORM)
- Cache: Redis 7 (via ioredis)
- UI: Element Plus, Lucide icons, SCSS
- Auth: JWT, bcrypt, TOTP 2FA, WebAuthn
- i18n: English, Chinese, Japanese
- Node.js >= 20
- Python >= 3.10 (required for Clist OAuth integration)
- PostgreSQL >= 16
- Redis >= 7
- npm (comes with Node.js)
git clone <repo-url> cp-oauth
cd cp-oauth
npm installPython is required for the Clist OAuth TLS bypass (clist.by's Cloudflare blocks standard HTTP clients).
pip install -r requirements.txtThis installs curl_cffi, which provides browser-like TLS fingerprints to bypass Cloudflare's TLS detection.
Note: If your Python binary is not
python(e.g.python3), set thePYTHON_PATHenvironment variable:export PYTHON_PATH=python3
docker compose up -dThis starts PostgreSQL (port 5432) and Redis (port 6379).
cp .env.example .envEdit .env with your values:
DATABASE_URL=postgresql://cpuser:cppass@localhost:5432/cpoauth
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secure-random-secretnpx prisma generate
npx prisma migrate devnpm run devThe app will be available at http://localhost:3000.
npx prisma generate
npm run buildIf S3 upload is enabled, npm run build will also upload static assets from .output/public.
Set S3_UPLOAD_ENABLED=true and the required S3 env vars before building.
You can run upload separately with:
npm run upload:s3node .output/server/index.mjsdocker build -t cp-oauth .
docker run -p 3000:3000 \
-e DATABASE_URL=postgresql://... \
-e REDIS_URL=redis://... \
-e JWT_SECRET=... \
cp-oauthDocker note: The default Dockerfile uses
node:20-alpinewhich does not include Python. If you need Clist OAuth support in Docker, use a custom image that includes both Node.js and Python withcurl_cffi, or deploy the Python dependency as a sidecar.
# Development
npm run dev # Start Nuxt dev server
npm run build # Production build
npm run preview # Preview production build
npm run upload:s3 # Upload .output/public to S3-compatible storage
npm run upload:oss # Alias of upload:s3
# Code quality
npm run lint # ESLint check
npm run format # Prettier format
# Database
npx prisma generate # Regenerate Prisma client
npx prisma migrate dev # Create and apply migrations
npx prisma db push # Push schema without migrations
# Infrastructure
docker compose up -d # Start PostgreSQL + Redis| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
REDIS_URL |
Yes | Redis connection string |
JWT_SECRET |
Yes | Secret for signing JWT tokens |
NUXT_APP_CDN_URL |
No | CDN base URL for Nuxt _nuxt assets |
PYTHON_PATH |
No | Path to Python binary (default: python) |
S3_UPLOAD_ENABLED |
No | Set true to upload static files to S3-compatible storage |
S3_REGION |
No | S3 region (required when upload is enabled) |
S3_BUCKET |
No | S3 bucket name (required when upload is enabled) |
S3_ACCESS_KEY_ID |
No | Access key ID (required when upload is enabled) |
S3_SECRET_ACCESS_KEY |
No | Secret access key (required when upload is enabled) |
S3_ENDPOINT |
No | Custom S3 endpoint (for OSS/COS/MinIO etc.) |
S3_SESSION_TOKEN |
No | Optional temporary session token |
S3_PREFIX |
No | Remote prefix for uploaded files |
S3_BUILD_DIR |
No | Local static directory to upload (default .output/public) |
S3_UPLOAD_CONCURRENCY |
No | Upload concurrency (default 8) |
S3_FORCE_PATH_STYLE |
No | Force path-style URLs (default auto-enabled when endpoint set) |
Backward compatibility:
OSS_*environment variables are still supported as aliases.
- Redirect user to
/oauth/authorizewithclient_id,redirect_uri,scope, and PKCE parameters. - User consents on the authorization page.
- User is redirected back to your
redirect_uriwith an authorizationcode. - Exchange
codeforaccess_tokenandrefresh_tokenvia POST/api/oauth/token. - Use
access_tokento call/api/oauth/userinfo. - When the
access_tokenexpires, use therefresh_tokento obtain a new one via POST/api/oauth/tokenwithgrant_type=refresh_token.
| Endpoint | Method | Description |
|---|---|---|
/oauth/authorize |
GET | Initiate authorization, redirect to consent page |
/api/oauth/token |
POST | Exchange authorization code for tokens, or refresh an access token |
/api/oauth/userinfo |
GET | Get user profile (filtered by granted scopes) |
/api/oauth/revoke |
POST | Revoke an access token or refresh token (RFC 7009) |
| Scope | Description |
|---|---|
openid |
Required. Returns user's unique identifier (sub). |
profile |
Basic profile: username, display_name, avatar_url, bio. |
email |
Email address and verification status. |
cp:linked |
All linked competitive programming accounts. |
link:luogu |
Linked Luogu account info. |
link:atcoder |
Linked AtCoder account info. |
link:codeforces |
Linked Codeforces account info. |
link:github |
Linked GitHub account info. |
link:google |
Linked Google account info. |
link:clist |
Linked Clist account info. |
cp:summary |
Aggregated CP stats (rating, contests, ranking) from Clist.by. |
cp:details |
Full rating history from Clist.by. |
Note on
cp:summaryandcp:details: These scopes require the user to have a linked Clist.by account. Data is only returned for platforms where the user's account on this site matches the one linked on Clist.by. If no Clist.by account is linked, the response will include{ "available": false, "message": "..." }.
GET /api/oauth/userinfo returns a JSON object filtered by the granted scopes. Below is the full response when all scopes are granted:
Notes:
- AtCoder Heuristic Contests are automatically separated from regular AtCoder contests. Their
resourceis"atcoder.jp/heuristic"andresource_nameis"AtCoder Heuristic". resource_nameis a human-readable display name mapped from the domain (e.g."codeforces.com"→"Codeforces").- When Clist.by API is unreachable,
cp_summary/cp_detailsreturn{ "available": false, "message": "Failed to fetch data from Clist.by" }instead of causing the entire request to fail.
const response = await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: 'AUTHORIZATION_CODE',
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET'
})
});
const { access_token, refresh_token, token_type, expires_in, scope } = await response.json();
// access_token: JWT, expires in 1 hour
// refresh_token: opaque token, expires in 30 daysconst response = await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: 'YOUR_REFRESH_TOKEN',
client_id: 'YOUR_CLIENT_ID',
client_secret: 'YOUR_CLIENT_SECRET' // optional for PKCE clients
})
});
// Returns new access_token + new refresh_token (rotation)
const { access_token, refresh_token, expires_in } = await response.json();Note: Refresh token rotation is enforced — each refresh request invalidates the old refresh token and issues a new one.
await fetch('/api/oauth/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: 'TOKEN_TO_REVOKE',
token_type_hint: 'refresh_token' // or 'access_token'
})
});
// Always returns 200, even if the token was already invalidRevoking a refresh token also invalidates all access tokens issued to the same client and user. Users can also manage authorized applications and revoke access from their profile page.
// Generate code_verifier and code_challenge
const codeVerifier = generateRandomString(128);
const data = new TextEncoder().encode(codeVerifier);
const digest = await crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
// Step 1: Include code_challenge in authorization request
// /oauth/authorize?code_challenge={codeChallenge}&code_challenge_method=S256
// Step 2: Include code_verifier in token request (replaces client_secret)
await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: 'AUTHORIZATION_CODE',
redirect_uri: 'https://yourapp.com/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: codeVerifier
})
});Generate an SVG card image showing a user's linked platform accounts. Useful for embedding in GitHub READMEs, blogs, or any place that supports images.
GET /api/users/{username}/card.svg
| Parameter | Type | Default | Description |
|---|---|---|---|
width |
number | 480 | Card width in pixels (300-800) |
theme |
string | light |
Color theme: light or dark |
lang |
string | en |
Language: en, zh, or ja |
Markdown:
Markdown (dark theme):
HTML:
<img
src="https://www.cpoauth.com/api/users/YOUR_USERNAME/card.svg?theme=dark&width=600"
alt="CP OAuth Profile"
/>- The card respects the user's privacy settings — only platforms marked as public in profile settings will be displayed.
- The card is cached for 1 hour (via
Cache-Control). - Platform icons are embedded inline in the SVG, so the card works everywhere without external dependencies.
Users can sign in or register via external OAuth providers. Configure credentials in the admin panel (/admin/config):
| Provider | Type |
|---|---|
| GitHub | OAuth 2.0 |
| OpenID Connect | |
| Codeforces | OpenID Connect |
| Clist | OAuth 2.0 (with TLS bypass) |
| Luogu | Paste-based verification |
cp-oauth/
├── pages/ # Nuxt file-based routing
│ ├── admin/ # Admin pages (config, users, notices)
│ ├── oauth/ # OAuth flow & third-party callbacks
│ └── ...
├── server/
│ ├── api/ # API routes
│ │ ├── auth/ # Login, register, email verify
│ │ ├── oauth/ # OAuth endpoints (authorize, token, userinfo)
│ │ ├── account/ # Linked accounts management
│ │ ├── admin/ # Admin APIs
│ │ └── public/ # Public config endpoint
│ └── utils/ # Server utilities
│ ├── prisma.ts # Database client
│ ├── redis.ts # Cache client
│ ├── auth.ts # JWT authentication
│ ├── oauth.ts # OAuth 2.0 core logic
│ ├── clist-oauth.ts # Clist OAuth integration
│ ├── clist-fetch.ts # Node.js wrapper for TLS bypass
│ ├── clist-fetch.py # Python TLS bypass via curl_cffi
│ └── ...
├── prisma/
│ └── schema.prisma # Database schema
├── i18n/locales/ # Translation files (en, zh, ja)
├── assets/scss/ # Global styles
├── requirements.txt # Python dependencies
├── docker-compose.yml # PostgreSQL + Redis
├── Dockerfile # Production container
└── package.json # Node.js dependencies
{ // openid "sub": "a1b2c3d4-uuid", // profile "username": "tourist", "display_name": "Gennady Korotkevich", "avatar_url": "https://example.com/avatar.png", "bio": "Competitive programmer", // email "email": "user@example.com", "email_verified": true, // cp:linked (or individual link:* scopes) "linked_accounts": [ { "platform": "codeforces", "platformUid": "tourist", "platformUsername": "tourist" }, { "platform": "atcoder", "platformUid": "tourist", "platformUsername": "tourist" }, { "platform": "luogu", "platformUid": "123456", "platformUsername": "tourist" } ], "link_scopes": ["link:codeforces", "link:atcoder"], // only present if individual link:* scopes are granted // cp:summary — requires Clist.by linked account "cp_summary": { "available": true, "accounts": [ { "resource": "codeforces.com", "resource_name": "Codeforces", "handle": "tourist", "rating": 3800, "n_contests": 150, "resource_rank": 1, "last_activity": "2026-03-20T15:00:00" }, { "resource": "atcoder.jp", "resource_name": "AtCoder", "handle": "tourist", "rating": 4229, "n_contests": 80, "resource_rank": 1, "last_activity": "2026-03-15T12:00:00" } ], "highest_rating": { "resource": "atcoder.jp", "resource_name": "AtCoder", "handle": "tourist", "rating": 4229 }, "total_contests": 230 }, // If Clist.by is not linked: // "cp_summary": { "available": false, "message": "Link a Clist.by account to enable CP stats" } // cp:details — requires Clist.by linked account "cp_details": { "available": true, "rating_history": [ { "resource": "codeforces.com", "resource_name": "Codeforces", "contest_id": 2001, "event": "Codeforces Round #900 (Div. 1)", "date": "2026-03-15T15:35:00", "handle": "tourist", "place": 1, "score": 7000, "old_rating": 3780, "new_rating": 3800, "rating_change": 20 }, { "resource": "atcoder.jp/heuristic", "resource_name": "AtCoder Heuristic", "contest_id": 500, "event": "AtCoder Heuristic Contest 030", "date": "2026-03-10T12:00:00", "handle": "tourist", "place": 3, "score": 1500000, "old_rating": 2800, "new_rating": 2850, "rating_change": 50 } ] } // If Clist.by is not linked: // "cp_details": { "available": false, "message": "Link a Clist.by account to enable CP details" } }