Partner integrations
Sign in with AcademyOS
OAuth 2.0 authorization code + PKCE with OpenID Connect, so learners sign in on your site with their AcademyOS account and consent to what you read.
How it fits together
With the partner API you act as yourself: an organisation-level key lists your licensed catalog and grants access to your customers. Sign in with AcademyOS is the opposite direction: an AcademyOS learner signs in on your site and consents to you reading their identity β and, with an extra scope, the marketplace courses they hold. You never see their password; they can revoke you at any time; every token you hold is scoped to exactly what they agreed to.
It is standard OAuth 2.0 authorization code + PKCE with OpenID Connect on top (discovery, RS256 id_tokens, JWKS, userinfo). Any off-the-shelf OIDC client library works: point it at the discovery document and you are most of the way done.
Registration
Register your partner organisation in the developer portal β a signed-in AcademyOS account with a confirmed email is all it takes β and create your client there. Clients work immediately; every use of one is gated by the learner's consent. Catalog licensing for the partner API key is a separate, commercial decision you request from the same page. You will need:
- Your redirect URIs β exact match,
httpsrequired outside development (loopback IPs such ashttp://127.0.0.1:4567/callbackare accepted for local testing). - Whether your client is confidential (you can keep a secret server-side: a classic web app) or public (SPA or mobile app; no secret, PKCE carries the proof). The client type cannot be changed later β a different type is a new client.
- Optionally, a narrower scope set than the default four (see Scopes).
- The name to show on the consent screen, and a technical contact email.
You receive a client_id, and for confidential clients a client_secret that is shown exactly once, at creation β we store only its SHA-256 digest, exactly like partner API keys. Lost secrets are rotated, not recovered; rotation re-displays a new secret once and invalidates the old one immediately.
One partner can hold several clients (one per platform). Every client, token and consent of yours dies the moment your partner account is deactivated β the same kill switch as your API keys.
Endpoints
| Endpoint | Purpose | Cross-origin |
|---|---|---|
GET /.well-known/openid-configuration | OIDC discovery β start here | CORS open |
GET /oauth/authorize | Consent screen (top-level browser navigation, never an iframe or XHR) | β |
POST /oauth/token | Code β tokens; refresh | CORS open |
POST /oauth/revoke | RFC 7009 token revocation | CORS open |
GET/POST /oauth/userinfo | OIDC userinfo (Bearer token) | CORS open |
GET /oauth/discovery/keys | JWKS β the RS256 public key for id_token verification | CORS open |
GET /api/oauth/v1/me | Userinfo mirror in the resource-API namespace | β |
GET /api/oauth/v1/me/enrollments | The learner's marketplace courses | β |
CORS is deliberately open only on the cookie-less, public endpoints a browser-based PKCE client must call; everything else keeps the browser's same-origin default. /oauth/authorize is a redirect target, not an API β do not fetch it.
Scopes
| Scope | Grants | Shown to the learner as |
|---|---|---|
openid | The id_token and the sub claim. Always required (it is the default scope) | Confirm who you are on AcademyOS |
email | email, email_verified β mirrored into the id_token as well as userinfo, so plain sign-in needs no userinfo round-trip | See your email address |
profile | name, given_name, family_name, preferred_username, locale, picture β userinfo only | See your basic profile (name, username, language, picture) |
enrollments:read | GET /api/oauth/v1/me/enrollments | See the courses you have access to |
Note the colon in enrollments:read. Unknown scopes are rejected with the standard invalid_scope error, and a per-client configuration can narrow this list further. Request the smallest set you need: the consent screen shows every scope to the learner, and asking for enrollments:read you don't use costs you consent-screen trust.
The authorization flow
Authorization code with PKCE (S256). PKCE is required β public clients cannot complete the flow without it, and confidential clients that send it get it verified. No implicit, no password, no client-credentials grant: a partner acting as itself uses its partner API key, not OAuth.
-
Redirect the learner (top-level) to:
HTTP GET https://app.academyos.app/oauth/authorize?client_id=β¦&redirect_uri=β¦&response_type=code &scope=openid+email+enrollments:read &state=<random>&nonce=<random> &code_challenge=<S256(code_verifier)>&code_challenge_method=S256 The learner signs in to AcademyOS if they aren't already β including their 2FA step β and lands back on the consent screen automatically. The screen names your app and your partner organisation and lists the scopes. They approve or deny.
Approval redirects to your
redirect_uriwith?code=β¦&state=β¦. The code is single-use and lives 10 minutes.-
Exchange it (server-side, or from the SPA β the endpoint is CORS-open):
HTTP POST https://app.academyos.app/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=β¦&redirect_uri=β¦ &client_id=β¦&code_verifier=β¦ [&client_secret=β¦ for confidential clients]JSON { "access_token": "β¦", "token_type": "Bearer", "expires_in": 7200, "refresh_token": "β¦", "scope": "openid email enrollments:read", "created_at": 1724751600, "id_token": "eyJhbGciOiJSUzI1NiIsβ¦" }
Confidential clients may authenticate at the token endpoint with HTTP Basic (client_secret_basic) or form parameters (client_secret_post); both are accepted.
Consent memory
The first authorization always shows the consent screen. A confidential client is not re-prompted while a still-live token from an earlier consent carries exactly the scope set it asks for again β the authorization redirects straight back with a fresh code. Anything else shows the screen again: a different scope set (broader or narrower), no surviving token (all expired or revoked), and every authorization from a public client β without a secret there is no proof it is the same app, so consent is per-authorization there. Denial redirects with the standard error=access_denied. prompt=login and a stale max_age force re-authentication.
The id_token
RS256-signed JWT; verify it against the JWKS endpoint, and validate iss (the issuer above), aud (your client_id), exp (10 minutes), and nonce (echoes yours).
subis the learner's stable public id (user_β¦) β opaque, non-enumerable, permanent per user. Key your local accounts onsub, never on email: an email can change,subcannot.auth_timeis when the learner last actually authenticated, not when the session was restored.- With the
emailscope,email/email_verifiedride in theid_tokendirectly.
Token lifecycle
| Credential | Lifetime |
|---|---|
| Authorization code | 10 minutes, single-use |
| Access token | 2 hours |
id_token | 10 minutes (an authentication assertion, not an access credential) |
| Refresh token | No clock expiry β superseded by rotation (see below), dies on revocation or partner deactivation |
Refresh rotates with a hand-off window: exchanging a refresh token returns a new access + refresh pair, and the pair it replaces stays valid until the new access token is first used β a token response lost on the network cannot lock you out, because retrying the exchange with the old refresh token still works until then. From the first use of the new access token, the replaced pair is revoked and replaying its refresh token is refused (invalid_grant). Store the newest pair, always.
POST https://app.academyos.app/oauth/token
grant_type=refresh_token&refresh_token=β¦&client_id=β¦[&client_secret=β¦]Revoke tokens you no longer need (sign-out, account unlink) via RFC 7009:
POST https://app.academyos.app/oauth/revoke
token=β¦&client_id=β¦[&client_secret=β¦]Tokens are stored as SHA-256 digests, like every other credential we hold for you.
The resource API
Authorization: Bearer <access_token> against /api/oauth/v1. This is a third credential regime: partner API keys and AcademyOS mobile-app tokens are not accepted here, and OAuth tokens are not accepted on the partner API or the mobile API. Errors use the familiar shape:
| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthorized | Missing/expired/revoked token β or the partner account is deactivated |
| 403 | insufficient_scope | Live token, but not this scope. WWW-Authenticate names the scope needed |
GET /api/oauth/v1/me
Scope: openid. The OIDC userinfo claims, filtered by the token's scopes, in the resource-API namespace so you integrate against one base path:
{ "sub": "user_9tRkβ¦", "email": "learner@example.com", "email_verified": true, "name": "Ada Learner" }GET /api/oauth/v1/me/enrollments
Scope: enrollments:read. The learner's marketplace courses β byte-identical in shape to what the learner's own AcademyOS mobile app shows them, because both render the same feed. Two kinds of row, distinguished by kind:
{
"marketplace_enrollments": [
{
"kind": "purchase",
"order_id": "mord_β¦", "order_status": "paid",
"purchased_at": "2026-08-11T14:03:22Z", "granted_at": null,
"product_id": "prod_β¦", "name": "Practical Math", "slug": "practical-math",
"creator_name": "Creator One Studio", "cover_image_url": "https://β¦",
"access_state": "active", "access_expires_at": "2026-09-10T14:03:22Z",
"days_remaining": 14, "enrollment_status": "active"
},
{
"kind": "grant",
"order_id": null, "order_status": null,
"purchased_at": null, "granted_at": "2026-08-12T09:00:00Z",
"product_id": "prod_β¦", "name": "β¦", "slug": "β¦",
"creator_name": "β¦", "cover_image_url": "https://β¦",
"access_state": "active", "access_expires_at": null,
"days_remaining": null, "enrollment_status": "active"
}
]
}kind: "purchase"β a course the learner bought on AcademyOS.kind: "grant"β a course granted order-lessly, e.g. by a partner through the partner API or by an AcademyOS admin. A course covered by both appears once, as the purchase.access_stateis one ofactive/pending/expired/revoked/none;days_remainingisnullfor lifetime access.pendingon a purchase is a checkout still in flight (on a grant, an invitation not yet accepted);noneis a purchase that never completed β a failed, canceled or abandoned checkout still appears as a row, and your UI should treat it as no access.- The feed answers for the learner who consented, whoever granted the access. It is not scoped to your grants and does not say who sold what β it is the learner showing you their own shelf.
- No money fields: what the learner paid is between them and whoever they bought from.
What the learner sees and controls
- The consent screen is branded, localized (en/es/pt), and names both your application and your partner organisation.
- Learners review every app they have authorized under Settings β Authorized Apps, and can revoke one at any time. Revocation kills all your tokens for that learner at once; the next authorize round-trip starts from a fresh consent screen. Build for tokens dying at any moment β the 401 tells you to re-authorize.
- An AcademyOS administrator impersonating a learner cannot consent on their behalf; the flow refuses.
Kill switch, rate limits and the fine print
- Partner deactivation is total: authorize renders the standard OAuth error page, the token endpoint answers
invalid_client, and every already-issued access/refresh token answers401 invalid_tokenfrom the same moment. Reactivation restores tokens that haven't expired. /oauth/authorizeis rate-limited per IP (default 30/min),/oauth/tokenper IP (default 60/min);429with a plain body. These bound credential-stuffing, not your legitimate traffic.- Redirect URIs are exact-match and
https-only outside development, except loopback-IP callbacks (http://127.0.0.1:<port>/β¦), whose port may vary at authorize time per RFC 8252. No wildcard URIs, no fragments. - The authorize endpoint must be a top-level navigation β it depends on the learner's AcademyOS session cookie, which does not exist in cross-site iframe or fetch contexts.
- There is no token introspection endpoint; identity comes from the
id_tokenand userinfo.
Integration checklist
- Register your organisation and client in the developer portal; store the
client_id(and one-shotclient_secret, if confidential) in your secret manager. - Point an OIDC client library at
https://app.academyos.app/.well-known/openid-configuration. - Send learners through authorize with PKCE +
state+nonce; validate everything on the way back. - Key your accounts on
sub. Treatemailas display data unlessemail_verifiedis true. - Ask for
enrollments:readonly if you actually render the learner's courses. - Refresh proactively (access tokens live 2 hours), store the rotated pair, and treat any 401 as "send the learner through authorize again".
- Revoke tokens on sign-out/unlink instead of letting them age out.
Run the example client
The AcademyOS repository ships a framework-free reference client at examples/oauth-client/app.rb that walks this whole page in a real browser: discovery, PKCE, the callback, the code exchange as a confidential client, id_token verification against the JWKS, the three resource calls, refresh rotation and revocation. Every page it renders prints the raw upstream responses, so it doubles as a debugging tool for your own integration.
cp .env.example .env # ACADEMYOS_ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SESSION_SECRET
ruby app.rb # then open http://localhost:4567 and click "Sign in with AcademyOS"Against a deployed AcademyOS the redirect URI must be https (or a loopback IP): put the client behind a tunnel such as cloudflared tunnel --url http://localhost:4567 and register the tunnel's callback URL. The README next to the script carries the evidence checklist we run ourselves.
Was this page useful?
Last updated September 04, 2026