Skip to content

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.

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.

  1. The Consumer self-registers (see §4.3) and obtains a client_id.

  2. 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
  3. You authenticate the administrator, show a consent screen displaying the verified client identity (§4.3), and redirect back with ?code=...&state=....

  4. The Consumer exchanges the code (with its PKCE code_verifier) at the token_endpoint:

    POST /oauth/token
    Content-Type: application/x-www-form-urlencoded
    grant_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/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=sdi.enrollment.write

No refresh token is issued for this grant; the Consumer simply requests a new token when one expires.

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)
})
  • 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.