Implement OAuth 2.0
You are the authorization server. Support at least one of the two grants. Supporting both is recommended: Authorization Code for schools that want one-click consent, Client Credentials for headless server-to-server setups.
You do not have to build OAuth from scratch. For a full authorization server that already implements
discovery, dynamic client registration, PKCE, refresh-token rotation, and JWKS — all of which SDI needs —
oidc-provider (panva) is the most complete option and mounts
directly in an Express app. For a more minimal, framework-agnostic core,
@node-oauth/oauth2-server wires into request handlers,
including Hono, and you add discovery/JWKS yourself. (Note @hono/oauth-providers is
an OAuth client helper for social login — not an authorization server.) On an edge runtime, confirm your
library is runtime-compatible; jose, used in §4.3 and §7, is.
4.1 Authorization Code + PKCE (interactive, recommended)
Section titled “4.1 Authorization Code + PKCE (interactive, recommended)”This lets a school administrator authorize the connection in a browser without anyone copying a secret.
-
The Consumer self-registers (see §4.3) and obtains a
client_id. -
The Consumer redirects the administrator to your
authorization_endpoint:GET /oauth/authorize?response_type=code&client_id=...&redirect_uri=...&scope=sdi.enrollment.write%20sdi.enrollment.read&state=...&code_challenge=...&code_challenge_method=S256 -
You authenticate the administrator, show a consent screen displaying the verified client identity (§4.3), and redirect back with
?code=...&state=.... -
The Consumer exchanges the code (with its PKCE
code_verifier) at thetoken_endpoint:POST /oauth/tokenContent-Type: application/x-www-form-urlencodedgrant_type=authorization_code&code=...&redirect_uri=...&client_id=...&code_verifier=...Respond with:
{ "access_token": "...", "token_type": "Bearer", "expires_in": 3600,"refresh_token": "...", "scope": "sdi.enrollment.write sdi.enrollment.read" }
Require PKCE with S256 (reject plain and missing challenges).
4.2 Client Credentials (machine-to-machine)
Section titled “4.2 Client Credentials (machine-to-machine)”An administrator provisions a client in your admin UI and hands the school a client_id + client_secret,
which they paste into the Consumer. The Consumer then requests tokens directly:
POST /oauth/tokenAuthorization: Basic base64(client_id:client_secret)Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=sdi.enrollment.writeNo refresh token is issued for this grant; the Consumer simply requests a new token when one expires.
4.3 Registration security (do not skip)
Section titled “4.3 Registration security (do not skip)”If you support Dynamic Client Registration, protect it (§6.1.2). Open registration invites abuse and, worse, client impersonation — a hostile client registering as a trusted brand to phish an administrator’s consent. Require at least one of:
- a software statement — a JWT signed by the SDI steward asserting the Consumer’s verified identity, which you validate against the steward’s published keys; and/or
- an initial access token you mint inside an authenticated administrator action (e.g. an admin clicks “connect an admissions system” and you issue a one-time registration token).
Always show the verified client identity on the consent screen, rate-limit registration, and expire clients that never complete an authorization. Remember: registering a client grants no data access by itself — authorization does — so registration is about establishing who is asking, not granting anything.
Verifying a software statement is a JWT signature check against the steward’s published keys. With jose
and Hono:
import { Hono } from "hono"import { jwtVerify, createRemoteJWKSet } from "jose"
// The steward publishes its signing keys; cache the JWKS.const stewardJwks = createRemoteJWKSet(new URL("https://steward.sdi.example/.well-known/jwks.json"))
const app = new Hono()
app.post("/oauth/register", async (c) => { const body = await c.req.json<{ software_statement?: string; redirect_uris?: string[] }>() if (!body.software_statement) { return c.json({ error: "invalid_client_metadata", error_description: "software_statement required" }, 400) }
let claims try { // A software statement asserts the Consumer's identity to any Provider; it is not bound to one SMS, // so verify the steward's signature and issuer only (no audience check). ;({ payload: claims } = await jwtVerify(body.software_statement, stewardJwks, { issuer: "https://steward.sdi.example", })) } catch { return c.json({ error: "invalid_software_statement" }, 400) }
// claims.software_id / claims.client_name are vouched-for by the steward — safe to display on consent. const client = await createClient({ name: String(claims.client_name ?? claims.software_id), redirectUris: body.redirect_uris ?? [], verifiedSoftwareId: String(claims.software_id), }) return c.json({ client_id: client.id, client_secret: client.secret }, 201)})4.4 Tokens, refresh, and re-authorization
Section titled “4.4 Tokens, refresh, and re-authorization”- Keep access tokens short-lived (≤ 1 hour).
- Rotate refresh tokens, and treat reuse of a consumed refresh token as a compromise: revoke the token family and force re-authorization (§6.3).
- When a token expires, expect the Consumer to refresh and retry. If the refresh token is expired/revoked,
return
400 invalid_grant; the Consumer will move the connection to a needs-reauthorization state and prompt the administrator. Nothing extra is required of you here beyond correct error codes.