Lightweight OpenID Connect provider on EdgeOne Pages (Next.js 16 App Router) with EdgeOne Blob storage. Implements the Authorization Code + PKCE flow with RS256-signed ID/Access tokens and rotating refresh tokens.
CNB Issue: Bring/ToDoList#37 — Phase 1 (OIDC core + Blob storage).
app/
.well-known/openid-configuration discovery metadata
.well-known/jwks.json public signing keys (current + archive)
oauth2/authorize authorization endpoint (PKCE S256 enforced)
oauth2/token authorization_code + refresh_token grants
oauth2/userinfo bearer-protected claims
login + login/callback/[provider] placeholder login; reserved federated callback
api/admin/clients[/:id] client CRUD (ADMIN_TOKEN)
api/admin/seed bootstrap demo client/user + signing key
lib/
storage/ storage-agnostic repository interfaces + EdgeOne Blob adapter
oidc/ keys, tokens, PKCE, grants, discovery, scopes
auth/ Authenticator registry, password provider, session, admin guard
notify/ Notifier interface (phase-2 placeholder)
Storage model. Each data domain has its own Blob namespace
(<BLOB_STORE_NAME>-users, -clients, -sessions, -authcodes, -refresh,
-jwks, -bindings, -media) for independent quota/migration/access — see
lib/storage/namespaces.ts. EdgeOne Blob is object storage with no native TTL,
so every value is wrapped in an envelope with an optional expiresAt; reads
validate it and lazily delete expired entries. Authorization codes use
strong-consistency read + immediate delete (single-use). The OIDC core depends
only on the interfaces in lib/storage/types.ts, so the backend is swappable.
Runtime. The EdgeOne Blob SDK is Node-only, so every route runs on the
Next.js nodejs runtime (Edge runtime is intentionally not used).
Run via the EdgeOne CLI so the Pages runtime injects the Blob deploy
credential — no token needed. (Plain next dev is not a Pages runtime; only
then set EDGEONE_PROJECT_ID + EDGEONE_BLOB_TOKEN in .env.local for token
mode. Production on EdgeOne Pages injects the credential automatically.)
corepack enable
pnpm install
cp .env.example .env.local # set ADMIN_TOKEN / SESSION_SECRET; leave
# EDGEONE_* empty; OIDC_ISSUER=http://localhost:8088
pnpm exec edgeone pages dev # http://localhost:8088 (Pages runtime)
ADMIN_TOKEN=dev-admin-token SEED_BASE_URL=http://localhost:8088 pnpm seed
pnpm seed POSTs to /api/admin/seed (default http://localhost:8088; set
SEED_BASE_URL to override). It prints the demo client/user credentials.
# CLIENT_ID / CLIENT_SECRET are printed by `pnpm seed` (randomly generated).
CV=$(openssl rand -base64 60 | tr -d '\n+/=' | cut -c1-43)
CC=$(printf %s "$CV" | openssl dgst -binary -sha256 | openssl base64 | tr '+/' '-_' | tr -d '=')
open "http://localhost:8088/oauth2/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=http://localhost:8080/callback&scope=openid%20profile%20email&state=xyz&code_challenge=$CC&code_challenge_method=S256"
# log in as demo / demo-password, copy `code` from the callback URL
curl -s -X POST http://localhost:8088/oauth2/token \
-d grant_type=authorization_code -d code=$CODE \
-d redirect_uri=http://localhost:8080/callback \
-d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET -d code_verifier=$CV | jq
curl -s http://localhost:8088/oauth2/userinfo -H "Authorization: Bearer $ACCESS_TOKEN" | jq
curl -s http://localhost:8088/.well-known/openid-configuration | jq
Automated end-to-end coverage: pnpm test (drives the full code+PKCE flow,
single-use code, refresh rotation, PKCE failure — Blob mocked in-memory for
tests only; production keeps no local fallback).
Login methods are pluggable via Authenticator (lib/auth/authenticator.ts).
To add SMS / Email OTP / third-party OAuth / Passkey:
Authenticator (begin, optional complete).lib/auth/register.ts.Notifier (lib/notify/notifier.ts) and
register it; OTP authenticators depend on the interface only.app/login/callback/[providerId]/route.ts endpoint.The OIDC core, token issuance and storage layer require no changes.
| Endpoint | Method | Notes |
|---|---|---|
/.well-known/openid-configuration | GET | discovery |
/.well-known/jwks.json | GET | public keys |
/oauth2/authorize | GET | PKCE S256 required |
/oauth2/token | POST | authorization_code, refresh_token |
/oauth2/userinfo | GET/POST | Bearer access token |
/oauth2/logout | GET/POST | RP-initiated logout (end_session_endpoint) |
/consent | GET | consent screen (shown by /authorize when no covering grant) |
/oauth/error · /logged-out | GET | styled OAuth error / logged-out pages |
/login · /account | GET | Email/SMS OTP login (first login auto-creates the account) · account center |
/api/otp/send | POST | issue + dispatch a login OTP |
/admin/clients · /admin/users · /admin/providers | GET | admin console — applications / users / login methods. Auth = OIDC session with role: admin (the first-ever user bootstraps as admin); non-admins get 403 |
/api/admin/clients | GET/POST | Authorization: Bearer $ADMIN_TOKEN (machine API) |
/api/admin/clients/:id | GET/PUT/DELETE | same |
/api/admin/rotate-keys | POST | rotate signing key (Bearer ADMIN_TOKEN) |
/api/admin/seed | POST | bootstrap (dev, needs ALLOW_SEED=1) |
Email/SMS login uses a 6-digit OTP (5-min TTL, 60s resend cooldown, 5-attempt
cap, single-use) stored hashed in the *-otp namespace. Delivery providers are
configured at /admin/providers (stored in *-settings, per-channel
enable/disable):
smtp (nodemailer) · tencent-ses (SES SendEmail) · aliyun-dm
(DirectMail SingleSendMail)aliyun (Dysmsapi SendSms) · aliyun-nac (Dypnsapi
SendSmsVerifyCode) · tencent (SMS SendSms)Disabled / console → the code is logged to the server console (dev). Cloud
adapters are credential-driven (entered in the admin UI, stored in Blob);
signing is implemented per each vendor's docs but untested without live
credentials.
Key rotation. POST /api/admin/rotate-keys (Bearer ADMIN_TOKEN)
generates a new RS256 key and archives the old public key so in-flight tokens
still verify. Run it periodically — e.g. a CNB pipeline crontab trigger that
curls the endpoint, or any external scheduler.
RP-initiated logout. Clients send the user to /oauth2/logout with
optional id_token_hint, post_logout_redirect_uri (must be registered in the
client's postLogoutRedirectUris) and state. The session is destroyed; with
a valid return URI the user is redirected back, otherwise shown /logged-out.
The pages use a shared dark-navy + violet design system from a Claude Design
handoff: tokens and reusable classes in app/globals.css (.btn, .input,
.panel, .chip, .scope-row, the .oidc-page frame, ...) plus
components/oidc/ (PageFrame, Icon). Restyle by editing tokens, not
per-page. The Authorization Code flow now includes a real /consent step;
approved grants are stored (ConsentGrant) and re-skipped, and are revocable
from the account center.