Before you start
- Contact Peliqan support and request activation of OAuth Apps in your Peliqan account
- Register an OAuth2 app in your Peliqan account: Settings → OAuth2 Apps, and select at least one scope
- Public app (no client secret) — browser/mobile apps. Use
authorization_code; it also gets a rotatingrefresh_token(no secret needed, PKCE plus rotation cover that). - Confidential app (has a client secret). Can additionally use
client_credentials(server-to-server, no user), and must also sendclient_secretwhen refreshing a token. - PKCE (
code_challenge/code_verifier, S256 only) is mandatory forauthorization_code, there is no way to opt out (RFC 9700). - Always pass an explicit
scope. An authorize request with noscopeand nodefault_scopesconfigured on the app is rejected withinvalid_scope.
Base URLs
| Deployment | Base URL |
|---|---|
| EU (Peliqan-hosted) | https://app.eu.peliqan.io |
| US (Peliqan-hosted) | https://app.us.peliqan.io |
| On-prem / white-label | your own domain |
BASE_URL constant — set it to whichever of these applies to you.
Endpoint reference
| Purpose | Method + path | Notes |
|---|---|---|
| Authorize page (browser) | GET /oauth2/authorize | Send the user’s browser here. Nuxt-hosted consent screen. |
| Token | POST /api/oauth2/token/ | All 3 grant types. Rate-limited to 20 req/min per IP. |
| Revoke | POST /api/oauth2/revoke/ | RFC 7009. Idempotent — always succeeds. |
| Introspect | POST /api/oauth2/introspect/ | RFC 7662 — check if a token is still active. |
| UserInfo | GET /api/oauth2/userinfo/ | Requires a profile:read-scoped access token, sent as Authorization: Bearer. |
| List scopes | GET /api/oauth2/scopes/ | Every scope the server recognizes, grouped by category. |
| Discovery | GET /.well-known/oauth-authorization-server | RFC 8414 metadata document. |
| JWKS | GET /api/oauth2/jwks/ | Public keys for verifying access token signatures. |
| Dynamic Client Registration | POST /api/oauth2/register/ | RFC 7591 — programmatic app registration (advanced, most devs use the Settings UI instead). |
1. Authorization code flow (user login)
This is “Login with Peliqan” — the user’s browser visits Peliqan, logs in, approves your app, and gets redirected back to you with acode. PKCE requires a code_verifier you generate and keep secret, and a code_challenge (its SHA-256 hash) you send up front.
- TypeScript
- Python
- Curl
Works in Node.js 18+, browsers, Bun, and Deno — uses only
fetch and the global Web Crypto API (crypto.subtle), no packages.const BASE_URL = "https://app.eu.peliqan.io" // or your on-prem domain
const CLIENT_ID = "YOUR_CLIENT_ID"
const REDIRECT_URI = "https://yourapp.com/auth/callback"
// --- PKCE + state helpers (RFC 7636) ---
function base64UrlEncode(bytes: Uint8Array): string {
let binary = ""
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateCodeVerifier(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)))
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
return base64UrlEncode(new Uint8Array(digest))
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
}
// 1. Build the authorize URL. Persist `state` + `codeVerifier` server-side
// (session/cookie, keyed by a per-flow id) — you need both in the callback.
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
const authorizeUrl = `${BASE_URL}/oauth2/authorize?` + new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "profile:read",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
})
// redirect the user's browser to `authorizeUrl`
// 2. On your callback route (GET /auth/callback?code=...&state=...):
// verify `state` matches what you persisted, then exchange the code.
// Confidential apps must also pass their clientSecret here.
async function exchangeCode(code: string, codeVerifier: string, clientSecret?: string) {
const response = await fetch(`${BASE_URL}/api/oauth2/token/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
...(clientSecret ? { client_secret: clientSecret } : {}),
}),
})
const data = await response.json()
if (!response.ok) throw new Error(`${data.error}: ${data.detail ?? ""}`)
return data // { access_token, refresh_token, expires_in, scope, token_type }
}
Standard library only —
urllib, hashlib, secrets, base64. No packages.import base64
import hashlib
import json
import secrets
import urllib.error
import urllib.parse
import urllib.request
BASE_URL = "https://app.eu.peliqan.io" # or your on-prem domain
CLIENT_ID = "YOUR_CLIENT_ID"
REDIRECT_URI = "https://yourapp.com/auth/callback"
# --- PKCE + state helpers (RFC 7636) ---
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def generate_code_verifier() -> str:
return _b64url(secrets.token_bytes(32))
def generate_code_challenge(verifier: str) -> str:
return _b64url(hashlib.sha256(verifier.encode("ascii")).digest())
def generate_state() -> str:
return _b64url(secrets.token_bytes(16))
# 1. Build the authorize URL. Persist `state` + `code_verifier` server-side
# (session/cookie, keyed by a per-flow id) — you need both in the callback.
state = generate_state()
code_verifier = generate_code_verifier()
code_challenge = generate_code_challenge(code_verifier)
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "profile:read",
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
authorize_url = f"{BASE_URL}/oauth2/authorize?{urllib.parse.urlencode(params)}"
# redirect the user's browser to authorize_url
def _post_json(path: str, body: dict) -> dict:
req = urllib.request.Request(
f"{BASE_URL}{path}",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
error_body = json.loads(e.read())
raise RuntimeError(f"{error_body.get('error')}: {error_body.get('detail', '')}") from e
# 2. On your callback route (GET /auth/callback?code=...&state=...):
# verify `state` matches what you persisted, then exchange the code.
# Confidential apps must also pass their client_secret here.
def exchange_code(code: str, code_verifier: str, client_secret: str = None) -> dict:
body = {
"grant_type": "authorization_code",
"code": code,
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier,
}
if client_secret:
body["client_secret"] = client_secret
return _post_json("/api/oauth2/token/", body)
# -> {"access_token", "refresh_token", "expires_in", "scope", "token_type"}
PKCE has to be generated before you build the URL. Peliqan only accepts S256 (Send the user’s browser to the authorize URL (this step can’t be scripted — it needs a real login):After the user approves, Peliqan redirects to Response:Confidential apps also send
base64url(SHA-256(verifier)), no padding):CLIENT_ID="YOUR_CLIENT_ID"
REDIRECT_URI="https://yourapp.com/auth/callback"
BASE_URL="https://app.eu.peliqan.io"
CODE_VERIFIER=$(openssl rand -base64 96 | tr -d '=+/\n' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '=' | tr '+/' '-_')
STATE=$(openssl rand -hex 16)
open "$BASE_URL/oauth2/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=profile%3Aread&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256"
redirect_uri?code=...&state=.... Confirm state matches what you generated, then exchange the code:curl -s -X POST "$BASE_URL/api/oauth2/token/" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "authorization_code",
"code": "PASTE_CODE_FROM_REDIRECT",
"client_id": "'"$CLIENT_ID"'",
"redirect_uri": "'"$REDIRECT_URI"'",
"code_verifier": "'"$CODE_VERIFIER"'"
}'
{
"access_token": "...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "...",
"scope": "profile:read"
}
"client_secret" in the same JSON body.2. Refreshing a token
Peliqan rotates refresh tokens on every use — the old one is revoked, so always persist the newrefresh_token from the response, not just the new access token. Both public and confidential apps get a refresh token; confidential apps must additionally authenticate with client_secret on every refresh — public apps don’t send one.
- TypeScript
- Python
- Curl
async function refreshAccessToken(refreshToken: string, clientSecret?: string) {
const response = await fetch(`${BASE_URL}/api/oauth2/token/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: CLIENT_ID,
...(clientSecret ? { client_secret: clientSecret } : {}), // required for confidential apps only
}),
})
const data = await response.json()
if (!response.ok) throw new Error(`${data.error}: ${data.detail ?? ""}`)
return data // new access_token + rotated refresh_token
}
def refresh_access_token(refresh_token: str, client_secret: str = None) -> dict:
body = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLIENT_ID,
}
if client_secret: # required for confidential apps only
body["client_secret"] = client_secret
return _post_json("/api/oauth2/token/", body)
# -> new access_token + rotated refresh_token
curl -s -X POST "$BASE_URL/api/oauth2/token/" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "refresh_token",
"refresh_token": "STORED_REFRESH_TOKEN",
"client_id": "'"$CLIENT_ID"'"
}'
"client_secret" to the same JSON body — public apps omit it.3. Client credentials (server-to-server, no user)
Requires a confidential app (has a client secret) — this grant is not available to public apps. Fully scriptable, no browser needed.- TypeScript
- Python
- Curl
const CLIENT_SECRET = "YOUR_CLIENT_SECRET" // never ship this to a browser
async function getClientCredentialsToken(scopes: string[]) {
const response = await fetch(`${BASE_URL}/api/oauth2/token/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
scope: scopes.join(" "),
}),
})
const data = await response.json()
if (!response.ok) throw new Error(`${data.error}: ${data.detail ?? ""}`)
return data
}
const tokens = await getClientCredentialsToken(["data:read"])
CLIENT_SECRET = "YOUR_CLIENT_SECRET" # never ship this to a browser
def get_client_credentials_token(scopes: list) -> dict:
return _post_json("/api/oauth2/token/", {
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"scope": " ".join(scopes),
})
tokens = get_client_credentials_token(["data:read"])
curl -s -X POST "$BASE_URL/api/oauth2/token/" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"scope": "data:read"
}'
4. Other endpoints
Revoke, introspect, get the logged-in user’s identity, list available scopes, and fetch the RFC 8414 discovery document.- TypeScript
- Python
- Curl
async function revokeToken(token: string) {
await fetch(`${BASE_URL}/api/oauth2/revoke/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, client_id: CLIENT_ID }),
})
}
async function introspectToken(token: string) {
const response = await fetch(`${BASE_URL}/api/oauth2/introspect/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, client_id: CLIENT_ID }),
})
return response.json() // { active, sub, client_id, scope, exp, iat, ... }
}
async function getUserInfo(accessToken: string) {
const response = await fetch(`${BASE_URL}/api/oauth2/userinfo/`, {
headers: { Authorization: `Bearer ${accessToken}` },
})
return response.json() // { sub, name, email }
}
async function listScopes() {
const response = await fetch(`${BASE_URL}/api/oauth2/scopes/`)
return response.json() // { scopes: {...by category}, sensitive_scopes: [...] }
}
async function discover() {
const response = await fetch(`${BASE_URL}/.well-known/oauth-authorization-server`)
return response.json()
}
def revoke_token(token: str) -> None:
_post_json("/api/oauth2/revoke/", {"token": token, "client_id": CLIENT_ID})
def introspect_token(token: str) -> dict:
return _post_json("/api/oauth2/introspect/", {"token": token, "client_id": CLIENT_ID})
# -> {"active", "sub", "client_id", "scope", "exp", "iat", ...}
def get_user_info(access_token: str) -> dict:
req = urllib.request.Request(
f"{BASE_URL}/api/oauth2/userinfo/",
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read()) # {"sub", "name", "email"}
def list_scopes() -> dict:
with urllib.request.urlopen(f"{BASE_URL}/api/oauth2/scopes/") as resp:
return json.loads(resp.read()) # {"scopes": {...by category}, "sensitive_scopes": [...]}
def discover() -> dict:
with urllib.request.urlopen(f"{BASE_URL}/.well-known/oauth-authorization-server") as resp:
return json.loads(resp.read())
# Revoke
curl -s -X POST "$BASE_URL/api/oauth2/revoke/" \
-H "Content-Type: application/json" \
-d '{"token": "TOKEN_TO_REVOKE", "client_id": "'"$CLIENT_ID"'"}'
# Introspect
curl -s -X POST "$BASE_URL/api/oauth2/introspect/" \
-H "Content-Type: application/json" \
-d '{"token": "ACCESS_TOKEN", "client_id": "'"$CLIENT_ID"'"}'
# UserInfo — note: Bearer auth header, not a JSON body
curl -s "$BASE_URL/api/oauth2/userinfo/" \
-H "Authorization: Bearer ACCESS_TOKEN"
# List available scopes
curl -s "$BASE_URL/api/oauth2/scopes/"
# Discovery document
curl -s "$BASE_URL/.well-known/oauth-authorization-server"
Error handling
Every non-2xx response returns a machine-readable OAuth2error code — branch on that field, not the message string.
error | Meaning |
|---|---|
invalid_request | Missing/malformed parameter. |
invalid_client | Unknown client_id, bad client_secret, or client_credentials requested on a public app. |
invalid_redirect_uri | redirect_uri doesn’t match any URI registered on the app. |
invalid_grant | Code/refresh token expired, already used, or PKCE code_verifier mismatch — restart the flow. |
invalid_scope | No scope requested and the app has no default_scopes. |
unsupported_grant_type | grant_type isn’t one of the three supported values. |
access_denied | User declined the consent screen. |
permission_denied | App-management endpoints only (not the token endpoint) — e.g. OAuth2 app management isn’t enabled for the account. |
server_error | Unexpected server-side failure. |
- TypeScript
- Python
- Curl
Wrap the raw
fetch call once and throw a typed error carrying the error code, so every caller can branch on err.code instead of string-matching:class OAuthError extends Error {
constructor(public code: string, public detail?: string) {
super(detail ? `${code}: ${detail}` : code)
}
}
async function postJson(path: string, body: Record<string, unknown>) {
const response = await fetch(`${BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
const data = await response.json()
if (!response.ok) throw new OAuthError(data.error ?? "server_error", data.detail)
return data
}
try {
const tokens = await exchangeCode(code, codeVerifier)
} catch (err) {
if (err instanceof OAuthError && err.code === "invalid_grant") {
// code expired, already used, or PKCE mismatch — restart the flow
}
throw err
}
class OAuthError(Exception):
def __init__(self, code: str, detail: str = None):
super().__init__(f"{code}: {detail}" if detail else code)
self.code = code
self.detail = detail
def _post_json(path: str, body: dict) -> dict:
req = urllib.request.Request(
f"{BASE_URL}{path}",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
error_body = json.loads(e.read())
raise OAuthError(error_body.get("error", "server_error"), error_body.get("detail")) from e
try:
tokens = exchange_code(code, code_verifier)
except OAuthError as e:
if e.code == "invalid_grant":
pass # code expired, already used, or PKCE mismatch — restart the flow
raise
{
"error": "invalid_grant",
"detail": "Authorization code has expired or already been used."
}
Gotchas
- PKCE is not optional. Every
authorization_coderequest needscode_challenge(S256) or the authorize call is rejected — there’s no legacy non-PKCE path. - No scope is added automatically. You get exactly what’s registered on the app plus whatever you explicitly request — nothing (including
profile:read) is silently injected. - Refresh tokens rotate, for every client type. The old one is revoked the instant you use it — always persist the new
refresh_tokenfrom the response, or the next refresh will fail withinvalid_grant. Public apps get refresh tokens too (PKCE + rotation stand in for a client secret); only confidential apps additionally need to sendclient_secreton refresh. - Token endpoint is rate-limited to 20 requests/minute per IP.
stateverification is on you. The server doesn’t store or check it — you must compare thestateyou generated against the one that comes back on the redirect, or you’re open to CSRF.- On-prem/white-label: just point
BASE_URLat your own domain — the flow is otherwise identical.
