DocumentationAuthentication

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 exposed

The 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.

OAuth PlaygroundInteractive

Register an OAuth application. Tokens can only grant scopes you declare here.

POST /api/v1/apps
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:

FlowWhen to use
Client CredentialsBackend services, automation, CI/CD — no human user involved
Authorization Code + PKCEUser-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):

MethodEndpointDescription
GET/v1/oauth/appsList registered apps
DELETE/v1/oauth/apps/:idDelete an app
POST/v1/oauth/apps/:id/rotate-secretRotate 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.

ScopeDescription
jobs.readList and view job executions
jobs.writeCreate, cancel, and manage jobs
files.readRead file content and metadata
files.writeUpload and delete files
secrets.readRead secret values
secrets.writeCreate and update secrets
audit.writeWrite 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>"