Authentication
CoreSDK is an OAuth 2.0 engine — but it is never called directly by your client apps. cpod-backend (FastAPI) is the proxy: it owns the consent screen, surfaces the token exchange endpoints as REST, and calls the sidecar internally over gRPC loopback.
Your App → REST → cpod-backend → gRPC [::1]:50051 → CoreSDK Sidecar
↑
Consent screen lives here
Token exchange proxied here
Sidecar port never exposedThe sidecar binds to [::1]:50051 (loopback). It is never reachable from the internet. All OAuth endpoints your apps call are on cpod-backend, which delegates to the sidecar internally.
Register an OAuth application. Tokens can only grant scopes you declare here.
curl -X POST http://localhost:8000/v1/oauth/apps \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CPOD_ADMIN_TOKEN" \
-d '{
"client_id": "app-my-service",
"name": "My Service",
"declared_scopes": ["jobs.read"],
"app_type": "service"
}'Two flows are supported:
| Flow | When to use |
|---|---|
| Client Credentials | Backend services, automation, CI/CD — no human user involved |
| Authorization Code + PKCE | User-facing apps where end users grant consent via cpod-backend’s consent screen |
Register an App
Register via cpod-backend. It proxies to the CoreSDK Control Plane internally — you never call the control plane directly in production.
curl -X POST https://api.yourdomain.com/v1/oauth/apps \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $CPOD_ADMIN_TOKEN" \
-d '{
"client_id": "app-myservice",
"name": "My Backend Service",
"declared_scopes": ["jobs.read", "jobs.write", "files.read"],
"app_type": "service"
}'Response — the client_secret is returned exactly once:
{
"client_id": "app-myservice",
"client_secret": "cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"name": "My Backend Service",
"declared_scopes": ["jobs.read", "jobs.write", "files.read"],
"app_type": "service",
"created_at": "2026-05-21T00:00:00Z"
}Store your client_secret immediately. It is returned only once and stored argon2-hashed on the server. If you lose it, you must rotate it via POST /api/v1/apps/:id/rotate-secret. Never commit credentials to source control — use a secrets manager such as Doppler, AWS Secrets Manager, or HashiCorp Vault.
Other app management endpoints (all via cpod-backend):
| Method | Endpoint | Description |
|---|---|---|
GET | /v1/oauth/apps | List registered apps |
DELETE | /v1/oauth/apps/:id | Delete an app |
POST | /v1/oauth/apps/:id/rotate-secret | Rotate the client secret |
Get a Token
Send a client_credentials grant to cpod-backend. It proxies to the sidecar OAuth engine internally. Use application/x-www-form-urlencoded — not JSON.
curl -X POST https://api.yourdomain.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=$CPOD_CLIENT_ID&client_secret=$CPOD_CLIENT_SECRET&scope=jobs.read+files.write"Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "jobs.read files.write"
}Use the Token
Add Authorization: Bearer <access_token> to every request. This is standard HTTP — no SDK magic, no wrapper.
GET /api/orders HTTP/1.1
Host: your-service.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...Pass the same header to every cpod-backend REST call. The backend validates it against the sidecar on every request — your code doesn’t call the sidecar directly:
// cpod-backend validates this against sidecar internally
const result = await sdk.skills.run('my-skill', input, {
headers: { Authorization: `Bearer ${access_token}` }
})Token Cache Pattern (TypeScript)
Fetching a new token on every request is wasteful. The correct pattern is to cache the token and refresh it before it expires. Refresh at 80% of the TTL to avoid races. Use a mutex (or equivalent) to prevent concurrent fetches.
import { Mutex } from 'async-mutex'
interface TokenState {
token: string
expiresAt: number
}
class TokenCache {
private state: TokenState | null = null
private mutex = new Mutex()
async getToken(): Promise<string> {
// Fast path: token is valid
if (this.state && Date.now() < this.state.expiresAt) {
return this.state.token
}
// Slow path: acquire lock, re-check, then fetch
return this.mutex.runExclusive(async () => {
if (this.state && Date.now() < this.state.expiresAt) {
return this.state.token
}
const payload = new URLSearchParams({
grant_type: 'client_credentials',
client_id: process.env.CPOD_CLIENT_ID!,
client_secret: process.env.CPOD_CLIENT_SECRET!,
})
const resp = await fetch(
`${process.env.CPOD_API_URL}/v1/oauth/token`,
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: payload,
}
)
if (!resp.ok) {
throw new Error(`Token fetch failed: ${resp.status} ${await resp.text()}`)
}
const { access_token, expires_in } = await resp.json()
// Refresh at 80% of TTL
this.state = {
token: access_token,
expiresAt: Date.now() + expires_in * 1000 * 0.8,
}
return access_token
})
}
}
// Use a single shared instance per process
export const tokenCache = new TokenCache()
// Usage:
// const token = await tokenCache.getToken()
// Authorization: Bearer ${token}Token Cache Pattern (Python)
import time
import threading
import httpx
class TokenCache:
def __init__(self):
self._token: str | None = None
self._expires_at: float = 0
self._lock = threading.Lock()
def get_token(self) -> str:
# Fast path
if self._token and time.monotonic() < self._expires_at:
return self._token
with self._lock:
# Re-check after acquiring lock
if self._token and time.monotonic() < self._expires_at:
return self._token
resp = httpx.post(
f"{os.environ['CPOD_API_URL']}/v1/oauth/token",
data={
"grant_type": "client_credentials",
"client_id": os.environ["CPOD_CLIENT_ID"],
"client_secret": os.environ["CPOD_CLIENT_SECRET"],
},
)
resp.raise_for_status()
data = resp.json()
self._token = data["access_token"]
# Refresh at 80% of TTL
self._expires_at = time.monotonic() + data["expires_in"] * 0.8
return self._token
# Singleton per process
token_cache = TokenCache()
# Usage:
# token = token_cache.get_token()
# headers = {"Authorization": f"Bearer {token}"}Introspect a Token
Check whether a token is active and inspect its claims (RFC 7662). Call cpod-backend — it proxies to the sidecar:
curl -X POST https://api.yourdomain.com/v1/oauth/introspect \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=<access_token>&client_id=$CPOD_CLIENT_ID&client_secret=$CPOD_CLIENT_SECRET"Response:
{
"active": true,
"client_id": "app-myservice",
"scope": "jobs.read files.write",
"exp": 1748823600,
"iat": 1748820000,
"sub": "app-myservice"
}Returns { "active": false } for expired or revoked tokens.
Revoke a Token
Revoke an access or refresh token immediately (RFC 7009). cpod-backend proxies to the sidecar, which adds the JTI to the deny-list within 30 s:
curl -X POST https://api.yourdomain.com/v1/oauth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=<access_token>&client_id=$CPOD_CLIENT_ID&client_secret=$CPOD_CLIENT_SECRET"Returns 200 OK on success, including for already-expired tokens (per RFC 7009).
Rotate Secret
Rotate your client_secret via cpod-backend:
curl -X POST https://api.yourdomain.com/v1/oauth/apps/app-myservice/rotate-secret \
-H "Authorization: Bearer $CPOD_ADMIN_TOKEN"Response:
{
"client_id": "app-myservice",
"client_secret": "cs_yyyyyyyyyyyyyyyyyyyyyyyyyyyy",
"rotated_at": "2026-05-21T00:00:00Z"
}After rotation, the old secret is immediately invalidated. For zero-downtime rotation: update and deploy your new secret first, then rotate.
Scopes
Scopes follow the resource.action naming convention. Wildcards are supported: jobs.* matches both jobs.read and jobs.write.
| Scope | Description |
|---|---|
jobs.read | List and view job executions |
jobs.write | Create, cancel, and manage jobs |
files.read | Read file content and metadata |
files.write | Upload and delete files |
secrets.read | Read secret values |
secrets.write | Create and update secrets |
audit.write | Write audit events |
admin.* | Full administrative access — use sparingly |
Request only the scopes your app genuinely needs. Tokens cannot grant scopes that were not declared at app registration time.
Authorization Code + PKCE
For user-facing apps. The consent screen is rendered by cpod-backend — users are never redirected to the sidecar. PKCE is required for all public clients (SPA, mobile, CLI).
1. Redirect the user’s browser to cpod-backend:
GET https://api.yourdomain.com/oauth/authorize
?client_id=app-myapp
&response_type=code
&redirect_uri=https://myapp.example.com/callback
&scope=jobs.read+files.read
&code_challenge=<base64url(sha256(code_verifier))>
&code_challenge_method=S256
&state=<random-csrf-token>cpod-backend shows the consent screen. After the user approves, it redirects to your redirect_uri with ?code=...&state=....
2. Exchange the code server-side (never in the browser):
curl -X POST https://api.yourdomain.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code&client_id=$CPOD_CLIENT_ID&code=<authorization_code>&redirect_uri=https://myapp.example.com/callback&code_verifier=<original_verifier>"3. Refresh without re-prompting:
curl -X POST https://api.yourdomain.com/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&client_id=$CPOD_CLIENT_ID&refresh_token=<refresh_token>"