Authorization Code Grant
The OAuth2 authorization-code grant is the primary flow O2ID supports. It issues a short-lived authorization code to the client after the user authenticates, which the client then exchanges for an access token and refresh token. PKCE is required.
Flow Overview
- Client starts the flow — redirect the user's browser to
/oauth2/authorizewith the required parameters. - Login — if the user has no active session, O2ID redirects to the login portal. After the user authenticates, O2ID resumes the authorization request automatically.
- Consent — for third-party applications, O2ID redirects to the consent screen so the user can approve the requested scopes. Trusted applications and authorizations already covered by a remembered consent grant skip this step. See Consent below.
- Authorization code issued — O2ID redirects back to the registered
redirect_uriwith acodequery parameter and the originalstatevalue. - Token exchange — the client posts the code, PKCE verifier, and
client_idto/oauth2/tokenand receives tokens.
PKCE
PKCE (Proof Key for Code Exchange) is required for all authorization requests.
Both plain and S256 challenge methods are supported. Use S256 in
production.
Generate a verifier and challenge:
CODE_VERIFIER="a-long-random-string-at-least-43-chars"
# S256 challenge
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
| openssl dgst -sha256 -binary \
| openssl base64 -A \
| tr '+/' '-_' \
| tr -d '=')
Authorization Request
GET /oauth2/authorize
Required query parameters:
| Parameter | Value |
|---|---|
response_type | code |
client_id | The clientId of a registered application |
redirect_uri | A callback URL registered on the application |
code_challenge | PKCE challenge derived from the verifier |
code_challenge_method | S256 or plain |
Optional query parameters:
| Parameter | Description |
|---|---|
scope | Space-separated scopes, e.g. openid profile |
state | Opaque string round-tripped to the redirect URI |
prompt | login, select_account, none or consent — see Browser Sessions |
Success response: 302 Found. For a third-party application this redirects
to the consent screen (/consent?consent_id=…); once consent is granted (or for
a trusted application), it redirects to redirect_uri?code=…&state=….
Consent
Before an authorization code is issued, O2ID shows the user a consent screen listing the scopes the application is requesting. This step is skipped when:
- The application is marked trusted (first-party). Set this with
o2idctl applications create --trusted(or--untrustedto clear it on update). The seededo2idctlCLI application is trusted. - The user has already consented to this application for scopes that cover
the current request. O2ID remembers each decision, so repeat authorizations
don't prompt again — unless the request adds a scope that wasn't previously
granted, which prompts for the new scope, or the request passes
prompt=consent, which re-asks.
Endpoint: POST /consent (form-encoded), submitted by the consent page.
| Field | Value |
|---|---|
consent_id | The consent transaction ID from the /consent redirect |
action | approve or deny |
On approve, O2ID redirects to redirect_uri?code=…&state=…. On deny, it
redirects to redirect_uri?error=access_denied&state=…. The
authoritative pending authorization (subject and resolved scopes) is held
server-side keyed by consent_id — the form body can't widen it.
The consent page is part of the login portal — it's served (and, if you host the portal externally, hosted) alongside the login page. See Login Portal.
Token Request
POST /oauth2/token
Content type: application/x-www-form-urlencoded
Required fields:
| Field | Value |
|---|---|
grant_type | authorization_code |
code | The authorization code from the redirect |
client_id | Same clientId used in the authorization request |
redirect_uri | Same redirect URI used in the authorization request |
code_verifier | The original PKCE verifier |
Success response:
{
"access_token": "…",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…",
"scope": "openid profile"
}
Authorization codes are single-use. Reusing a code after a successful exchange
returns invalid_grant.
Full cURL Walkthrough
Every endpoint is served under a tenant path prefix (/t/{tenant}/…); this
walkthrough uses the system tenant that o2id serve seeds on first run. See
Multi-Tenancy for the tenancy model.
This walkthrough runs the complete flow from scratch using curl, jq, and
openssl. Start O2ID before running these commands:
o2id serve --addr :8080
Create an application. The callback URL must match the redirect_uri used
during authorization:
APP_JSON=$(curl -s -X POST http://localhost:8080/t/system/applications \
-H "Content-Type: application/json" \
-d '{"name":"Demo App","callbackUrls":["http://localhost:3000/callback"]}')
CLIENT_ID=$(printf '%s' "$APP_JSON" | jq -r .clientId)
Create a user with a password:
curl -s -X POST http://localhost:8080/t/system/users \
-H "Content-Type: application/json" \
-d '{"email":"alice@example.com","displayName":"Alice","password":"secret"}'
Log in and save the session cookie:
curl -i -c /tmp/o2id-cookies.txt -X POST http://localhost:8080/t/system/login \
-H "Content-Type: application/x-www-form-urlencoded" \
--data "email=alice@example.com&password=secret"
Generate a PKCE verifier and S256 challenge:
CODE_VERIFIER="verifier-1"
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
| openssl dgst -sha256 -binary \
| openssl base64 -A \
| tr '+/' '-_' \
| tr -d '=')
Authorize the logged-in user:
curl -i -b /tmp/o2id-cookies.txt \
"http://localhost:8080/t/system/oauth2/authorize?response_type=code&client_id=${CLIENT_ID}&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=openid%20profile&state=demo-state&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256"
Since the demo application is third-party, the response is a 302 Found
redirect to the consent screen (/consent?consent_id=…). Copy the consent_id
from the Location header and approve it:
CONSENT_ID="paste-consent_id-from-location-header"
curl -i -X POST http://localhost:8080/t/system/consent \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "consent_id=${CONSENT_ID}" \
--data-urlencode "action=approve"
(Creating the application with "trusted":true would skip this step and
redirect straight to the callback.)
This response is a 302 Found redirect to the registered callback URL. Copy the
code query parameter from the Location header.
Exchange the authorization code for tokens:
AUTH_CODE="paste-code-from-location-header"
curl -s -X POST http://localhost:8080/t/system/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=${AUTH_CODE}" \
--data-urlencode "client_id=${CLIENT_ID}" \
--data-urlencode "redirect_uri=http://localhost:3000/callback" \
--data-urlencode "code_verifier=${CODE_VERIFIER}"
Call the UserInfo endpoint with the access token:
ACCESS_TOKEN=$(printf '%s' "$TOKEN_JSON" | jq -r .access_token)
curl -s http://localhost:8080/t/system/oauth2/userinfo \
-H "Authorization: Bearer $ACCESS_TOKEN"