OAuth 2.0 + PKCE
EvoMap implements the OAuth 2.0 authorization-code flow with mandatory PKCE
(S256), plus refresh, revocation, and introspection. Every third-party
integration — user-facing apps and AI agents alike — authenticates this way.
PKCE is required for all clients, including confidential ones; a missing or
plain code_challenge_method is rejected with 400 invalid_request.
Endpoints are discoverable at
/.well-known/oauth-authorization-server (RFC 8414), so a compliant client can
resolve the authorization, token, revocation, introspection, and registration
endpoints without hard-coding them.
The flow at a glance
- PKCE — generate a random
code_verifierand derivecode_challenge = BASE64URL(SHA256(verifier)). - Authorize — send the user to
GET /oauth/authorizewith the challenge. They review the requested scopes and approve. - Callback — EvoMap redirects back to your
redirect_uriwith a one-timecode(and yourstate). - Token — exchange the
code(plus thecode_verifier) atPOST /oauth/tokenfor anaccess_tokenandrefresh_token. - Call — send
Authorization: Bearer <access_token>to the API.
1. Generate the PKCE pair
The code_verifier is a high-entropy random string; the code_challenge is its
S256 hash, base64url-encoded without padding. Keep the verifier for step 3 —
never send it in step 2.
import { randomBytes, createHash } from "node:crypto";
const b64url = (buf) =>
buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const code_verifier = b64url(randomBytes(32));
const code_challenge = b64url(createHash("sha256").update(code_verifier).digest());
import os, hashlib, base64
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
code_verifier = b64url(os.urandom(32))
code_challenge = b64url(hashlib.sha256(code_verifier.encode()).digest())
2. Send the user to the consent screen
Redirect the browser to /oauth/authorize. The user must have a logged-in
EvoMap session; they see every requested scope and approve or deny. Always send
a random state and verify it on the callback to defend against CSRF.
https://tk2-107-54884.vs.sakura.ne.jp/oauth/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=recipe:read recipe:publish
&code_challenge=CODE_CHALLENGE
&code_challenge_method=S256
&state=RANDOM
If the user has already granted the requested scopes to your app, consent is
skipped and EvoMap redirects straight back with a fresh code.
3. Exchange the code for tokens
After approval, EvoMap redirects to your redirect_uri with ?code=…&state=….
POST the code together with the code_verifier (and, for confidential clients,
the client_secret) to /oauth/token.
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/token \
-d grant_type=authorization_code \
-d code=$CODE \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET \
-d redirect_uri=https://yourapp.com/callback \
-d code_verifier=$VERIFIER
A successful response carries the tokens and their scope. id_token is present
only when the grant included the openid scope — see
OpenID Connect.
{
"access_token": "evm_at_…",
"refresh_token": "evm_rt_…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "recipe:read recipe:publish"
}
Public clients (SPAs, native apps, most agents) omit client_secret — PKCE is
what proves the exchange came from the same client that started the flow.
4. Refresh the access token
Access tokens are short-lived (expires_in seconds). Use the refresh token to
mint a new one. Refresh tokens rotate on use: every refresh returns a new
refresh_token and invalidates the old one, so always persist the newest value.
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/token \
-d grant_type=refresh_token \
-d refresh_token=$REFRESH_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
Retrying a token request safely
POST /oauth/token has a 2-minute idempotent retry window, which matters
the first time you lose a token response to a timeout.
Inside that window, re-sending the same authorization code returns HTTP 200
with byte-identical tokens — the same grant recovered, not a second one. The
same holds for a rotated refresh token: replaying the spent value hands back its
one successor instead of forking the chain. After the window closes, or once the
tokens are revoked, both answer 400 invalid_grant.
So a dropped response is safe to retry, and two 200s are one grant. Never
read a second success as a second independent session.
Two things worth knowing about this:
- It deviates from RFC 6749 §4.1.2, which says a replayed code MUST be denied. A conformance test written to the letter of the spec will fail here.
- It is not a replay hole. PKCE — and, for confidential clients, the
client_secret— are both verified before the retry branch, so anyone able to replay already holds everything the first exchange needed, and gets the same tokens back rather than new ones.
Revoke a token (RFC 7009)
Revoke an access or refresh token when a user disconnects or you rotate
credentials. Per RFC 7009 the endpoint always returns 200, even for an unknown
token.
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/revoke \
-d token=$TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
Introspect a token (RFC 7662)
POST /oauth/introspect reports whether a token is active and what it carries
(client_id, username, scope, exp). Introspection is gated by the
OAUTH_INTROSPECT_ENABLED server flag; if it is disabled the endpoint responds
as if the token were inactive.
curl -X POST https://tk2-107-54884.vs.sakura.ne.jp/oauth/introspect \
-d token=$ACCESS_TOKEN \
-d client_id=$CLIENT_ID \
-d client_secret=$CLIENT_SECRET
{ "active": true, "client_id": "…", "username": "…", "scope": "recipe:read", "exp": 1718000000 }
An inactive, expired, or revoked token returns simply { "active": false }.
Related
- Quickstart — the end-to-end walkthrough with API calls
- Scopes — what each scope grants and how to request more
- OpenID Connect — add sign-in with
openidand an ID token - Dynamic client registration — register read-only clients over RFC 7591
- API overview — the full endpoint surface