A spec of Open Learn Protocol

EduSSOv1 draft

The launcher already knows the child. EduSSO hands that identity to the learning app in one signed JWT — no client registration, no OAuth dance, about ten lines of code on the app side.

The idea

When a launcher loads a learning app, it appends a query parameter to the URL: ?edu_session=<JWT>. That JWT is signed by the launcher's issuer, names the child, and is valid for five minutes. The app verifies the token against the issuer's public JWKS, reads the email out of it, drops its own session cookie, and redirects to strip the parameter.

That's the entire protocol. No client_id, no callback URLs, no refresh tokens, no PKCE, no OIDC discovery dance.

The flow

┌──────────┐  1. mint(child, aud)   ┌───────────────┐
│ Launcher │ ─────────────────────► │ Issuer        │
│          │ ◄───────────────────── │ (signs JWT)   │
└────┬─────┘     2. edu_session JWT └───────────────┘
     │
     │ 3. GET https://your-app.com/?edu_session=<jwt>
     │    (or the path your manifest declares via "entry")
     ▼
┌────────────┐  4. verify(jwt, JWKS)   ┌──────────────┐
│ Your app   │ ──────────────────────► │ Public JWKS  │
│            │                         └──────────────┘
│            │  5. setSessionCookie(email)
│            │  6. 302 → same path without ?edu_session
└────────────┘

The integration, in full

Server-side, Node + jose:

import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(
  new URL("https://issuer.example-launcher.com/.well-known/jwks.json")
);

export async function eduSSO(req, res, next) {
  const token = req.query.edu_session;
  if (!token) return next();

  try {
    const { payload } = await jwtVerify(token, jwks, {
      issuer: "https://issuer.example-launcher.com",
      audience: "your-app-id",
      clockTolerance: 5,
    });
    await upsertUser({ email: payload.email, name: payload.name });
    await setSessionCookie(res, payload.email);

    const clean = new URL(req.url, `https://${req.headers.host}`);
    clean.searchParams.delete("edu_session");
    return res.redirect(302, clean.pathname + clean.search);
  } catch {
    return next();
  }
}

Mount it as middleware at the root of your app. That's the whole integration.

The token

{
  "iss": "https://issuer.example-launcher.com",
  "aud": "your-app-id",
  "sub": "child:abc123",
  "email": "student@example.com",
  "email_verified": true,
  "name": "Sam",
  "iat": 1779150000,
  "exp": 1779150300,
  "jti": "01HX5XYV6FPK3R3D6T2H8E2VPR"
}
Five-minute expiry. Audience-bound, so a token minted for one app can't be replayed at another. Signed with RS256 or EdDSA; never symmetric, never none.

Quickstart for AI coding agents

If you use Claude Code, Cursor, Codex, Aider, or similar, paste the prompt below into your agent. It is self-contained and tells the agent exactly what to implement and what to skip. You only need ISSUER and AUDIENCE from your launcher operator.

Implement EduSSO v1 (tutor / relying party).

Specs (fetch before coding):
- https://raw.githubusercontent.com/open-learn-org/open-learn-protocol/main/specs/edu-sso/tutor.md
- https://raw.githubusercontent.com/open-learn-org/open-learn-protocol/main/specs/edu-sso/discovery.md
- https://raw.githubusercontent.com/open-learn-org/open-learn-protocol/main/specs/edu-sso/conformance.md
Reference: https://github.com/open-learn-org/open-learn-protocol/tree/main/specs/edu-sso/examples/example-tutor

Read EDU_SSO_ISSUER and EDU_SSO_AUDIENCE from env (fail fast if missing).
For multi-launcher, accept EDU_SSO_LAUNCHERS as a JSON array of {issuer, audience}.

On every request, when ?edu_session=<jwt> is present:
- Verify via the issuer JWKS at ${ISSUER}/.well-known/jwks.json (jose
  createRemoteJWKSet / PyJWKClient — they cache for 1h).
- Allow only RS256 and EdDSA. Reject alg=none and HS*.
- Check iss matches exactly, aud matches exactly, exp+iat with ≤5s skew,
  email_verified === true.
- Multi-launcher: dispatch by the token's iss; drop unknown issuers silently.
- On success: upsert by email, set your session cookie, 302 to the same
  path with edu_session stripped.
- On any failure: drop the token silently and fall through to normal flow.
  Never log the raw token; redact edu_session from URL logs.

Publish /.well-known/edu-sso.json:
  { "version": 1, "audience": "<EDU_SSO_AUDIENCE>",
    "issuers": [...], "entry": "/auth/edu-sso" }  (entry optional, same-origin)
Read fields from env so the manifest can't drift from your config.

Do NOT implement (even if the framework makes them easy):
- OAuth code exchange, refresh tokens, PKCE, state/nonce.
- Scopes, dynamic client registration, federated logout.
- jti replay tracking. Symmetric JWT verification.

Verify with https://validator.openlearnprotocol.org — six checks, must
all pass. Add https://test-issuer.openlearnprotocol.org to your trust
list in staging only (its private key is public).

When done: show the diff, which conformance items you verified, and
anything you couldn't verify.

Append one of these hints so the agent doesn't guess your stack:

Expected diff size: ~50 lines. The reference Node implementation is that size. If your agent writes substantially more, ask why.

Read the spec