Authentication
Curupira implements the OAuth2 Authorization Code flow with PKCE (RFC 6749 + RFC 7636) and OpenID Connect. This page walks the full flow end to end with concrete requests and responses against the live endpoints.
All examples use the production auth host:
https://auth.curupira.api.br
1. OIDC discovery
Any OIDC-compatible client can bootstrap from the discovery document. Fetch it once and read the endpoint URLs from it rather than hard-coding them:
curl -s https://auth.curupira.api.br/.well-known/openid-configuration | jq .
{
"issuer": "https://auth.curupira.api.br",
"authorization_endpoint": "https://auth.curupira.api.br/oauth2/authorize",
"token_endpoint": "https://auth.curupira.api.br/oauth2/token",
"userinfo_endpoint": "https://auth.curupira.api.br/oauth2/userinfo",
"jwks_uri": "https://auth.curupira.api.br/.well-known/jwks.json",
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "profile"],
"claims_supported": [
"sub", "iss", "aud", "exp", "iat",
"email", "email_verified", "name", "given_name", "family_name",
"tenant", "roles", "permissions"
],
"token_endpoint_auth_methods_supported": ["none", "api_key"],
"code_challenge_methods_supported": ["S256"]
}
Key facts encoded here:
- Only the
coderesponse type is supported — the Authorization Code flow. (No implicit flow.) id_tokens are signed withRS256— verify them against thejwks_uri.- PKCE uses
S256— the only supported code-challenge method. permissionsis a first-class claim (aggregated from the user's roles).
2. Generate a PKCE verifier and challenge
PKCE protects the authorization code from interception. Before redirecting the user, create a
random code verifier and derive the code challenge (S256 = base64url(SHA-256(verifier))):
# code_verifier: 43–128 chars, URL-safe
code_verifier=$(openssl rand -base64 60 | tr -d '\n=+/' | cut -c1-64)
# code_challenge = base64url( sha256( code_verifier ) )
code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -sha256 -binary \
| openssl base64 -A | tr '+/' '-_' | tr -d '=')
echo "verifier=$code_verifier"
echo "challenge=$code_challenge"
Keep the code_verifier in the user's session — you will need it in step 4.
3. Redirect the user to the authorization endpoint
Send the user's browser to /oauth2/authorize with your client parameters. Generate a random
state (CSRF protection) and store it in the session too:
https://auth.curupira.api.br/oauth2/authorize
?response_type=code
&client_id=<YOUR_CLIENT_ID>
&redirect_uri=https://app.example.com/callback
&scope=openid%20email%20profile
&state=<RANDOM_STATE>
&code_challenge=<CODE_CHALLENGE>
&code_challenge_method=S256
| Parameter | Required | Notes |
|---|---|---|
response_type | ✅ | Always code. |
client_id | ✅ | Your application's client_id (a UUID). |
redirect_uri | ✅ | Must exactly match a URI registered on the application. |
scope | ✅ | Space-separated. Include openid to get an id_token. |
state | ✅ (recommended) | Opaque value echoed back; compare on return to stop CSRF. |
code_challenge | ✅ | The S256 challenge from step 2. |
code_challenge_method | ✅ | Always S256. |
Curupira serves its own login/consent UI. The user authenticates (password or, if enabled for
the tenant, SSO), then Curupira redirects back to your redirect_uri:
https://app.example.com/callback?code=<AUTHORIZATION_CODE>&state=<RANDOM_STATE>
Verify state matches what you stored before continuing. The authorization code is
single-use and short-lived.
4. Exchange the code for tokens
POST to the token endpoint with grant_type=authorization_code. The body is
application/x-www-form-urlencoded. Include the code_verifier from step 2 — Curupira
recomputes the challenge and rejects a mismatch.
Public clients (SPA / mobile) — PKCE only
Public clients cannot keep a secret, so they authenticate with PKCE alone (no API key):
curl -s -X POST https://auth.curupira.api.br/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=<AUTHORIZATION_CODE>" \
-d "redirect_uri=https://app.example.com/callback" \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "code_verifier=<CODE_VERIFIER>"
Confidential clients (server-side) — PKCE + API key
Confidential clients additionally present their application API key in the X-API-Key
header:
curl -s -X POST https://auth.curupira.api.br/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-API-Key: <YOUR_APPLICATION_API_KEY>" \
-d "grant_type=authorization_code" \
-d "code=<AUTHORIZATION_CODE>" \
-d "redirect_uri=https://app.example.com/callback" \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "code_verifier=<CODE_VERIFIER>"
Token response
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVC...",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVC...",
"refresh_token": "def50200a1b2c3...",
"token_type": "Bearer",
"expires_in": 3600
}
access_token— send to your APIs and to/oauth2/userinfoasAuthorization: Bearer.id_token— an OIDC identity JWT (see below). Present only whenopenidwas requested.refresh_token— exchange for fresh tokens without re-prompting (see step 6). Rotated on every use.expires_in— access-token lifetime in seconds (default3600).
Error responses
Errors follow RFC 6749 (400 / 401 with a JSON body):
{ "error": "invalid_grant", "error_description": "authorization code is invalid or expired" }
Common cases: invalid_request (missing/malformed params), invalid_grant (bad/expired code
or PKCE mismatch), unsupported_grant_type, invalid_client (bad/missing API key or a
disabled application → 401).
5. Inspect the ID token and fetch UserInfo
The id_token is a signed JWT. Always verify its signature against the JWKS before
trusting any claim:
curl -s https://auth.curupira.api.br/.well-known/jwks.json | jq .
Decoded id_token payload (illustrative):
{
"iss": "https://auth.curupira.api.br",
"sub": "b3f1c0de-0000-4a1b-9c2d-1234567890ab",
"aud": "<YOUR_CLIENT_ID>",
"exp": 1735689600,
"iat": 1735686000,
"email": "user@example.com",
"email_verified": true,
"name": "Ada Lovelace",
"tenant": "acme",
"roles": ["editor", "viewer"],
"permissions": ["documents:read", "documents:write"]
}
Verify at minimum: the signature (RS256 against JWKS), iss equals
https://auth.curupira.api.br, aud equals your client_id, and exp is in the future.
Then use roles / permissions for authorization decisions.
You can also call the UserInfo endpoint with the access token:
curl -s https://auth.curupira.api.br/oauth2/userinfo \
-H "Authorization: Bearer <ACCESS_TOKEN>"
{
"sub": "b3f1c0de-0000-4a1b-9c2d-1234567890ab",
"email": "user@example.com",
"email_verified": true,
"name": "Ada Lovelace",
"tenant": "acme",
"roles": ["editor", "viewer"],
"permissions": ["documents:read", "documents:write"]
}
6. Refresh the access token
When the access token nears expiry, exchange the refresh token for a new set. Confidential
clients include the X-API-Key header; public clients rely on PKCE-issued refresh tokens.
curl -s -X POST https://auth.curupira.api.br/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-H "X-API-Key: <YOUR_APPLICATION_API_KEY>" \
-d "grant_type=refresh_token" \
-d "refresh_token=<REFRESH_TOKEN>" \
-d "client_id=<YOUR_CLIENT_ID>"
:::warning Refresh tokens rotate
Curupira rotates the refresh token on every use — the response contains a new
refresh_token, and the one you just sent is invalidated. Always persist the newest refresh
token and discard the previous value, or the next refresh will fail with invalid_grant.
:::
Token lifetimes
| Token | Default lifetime | Configurable via |
|---|---|---|
| Access token | 3600 s (1 hour) | DEFAULT_ACCESS_TTL_SECS (operator) |
| Refresh token | 43200 min (30 days) | DEFAULT_REFRESH_TTL_MINS (operator) |
| ID token | matches access token | — |
Security notes
- PKCE (
S256) is mandatory on the authorization-code grant — for every client type. - Redirect URIs are exact-match against the registered set; there is no wildcard matching.
- Always validate
stateon the callback to defend against CSRF. - Verify
id_tokensignatures against the JWKS; never trust an unverified JWT. - Confidential clients must keep the API key secret — never ship it in a browser or mobile bundle. Public clients omit it and rely on PKCE.
- Login is protected by per-IP rate limiting and per-account lockout (exponential backoff after repeated failures).