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>
     ▼
┌────────────┐  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.

Read the spec