docs
/
Courses — AppEngine

AppEngine as an Identity Provider: OIDC, OAuth and SAML

Watch on YouTube ↗

Written guide

Current demonstration status — 10 October 2026: Public OIDC discovery, JWKS and SAML metadata were read successfully on the controlled training deployment. Registration, completed relying-app sign-in, token exchange, refresh and SAML assertion delivery below are implementation instructions, not claimed live results from this recording. They require a configured registered client. Video production is underway; the current concept and metadata sections do not constitute a complete end-to-end course.

Why this course

Juniper Yoga's instructors already sign into Appmint every day. Now the studio has a second app — a class-roster tool built by a contractor — and nobody wants a second password, a second "forgot password" flow, or a second place to remove someone who leaves. AppEngine can be the sign-in for that app. It speaks the two standard sign-in protocols, OpenID Connect (OIDC, built on OAuth 2) and SAML, so any app or library that supports "Sign in with…" can use it. AppEngine shows people the normal Appmint login, checks they're allowed into that app, and hands the app a signed statement of who they are. Locking an Appmint staff account prevents a fresh authorized identity lookup. A relying application's existing session has its own lifetime; this is not an instant sign-out from every connected app.

In this course you register the roster app, run it locally, sign an instructor in, read their details, refresh and sign out — then see how SAML apps are added the same way.

This is about other apps signing in with Appmint. Customers signing into your own customer app use the routes in Foundations.

1. The words, and what AppEngine already does

  • Identity provider (issuer) — the service that signs people in and vouches for them. Here, AppEngine at <API>/idp.
  • Relying application — the app that trusts the issuer. Here, the roster app.
  • Client ID / client secret — the relying app's own username and password with the issuer. A server-side app gets both; a browser or phone app gets only the client ID (a public client).
  • Redirect URI (callback) — the exact address in your app the browser returns to after sign-in.
  • ID token — a signed token the app receives, containing claims (facts about the person) such as sub, email and org.
  • PKCE — a one-time secret the app makes for each sign-in so a stolen authorization code is useless. AppEngine requires it for every app.

Built-in data type

Data typeWhat it isIts real fields (data.…)
idp_clientOne app allowed to sign people in through AppEnginename, protocol (oidc or saml), status (active/disabled), emailClaim (login or mailbox), access.orgs, access.userTypes, oidc.clientId, oidc.redirectUris, oidc.postLogoutRedirectUris, oidc.scopes, oidc.refreshTokens, oidc.publicClient, saml.entityId, saml.acsUrl, saml.sloUrl, saml.nameIdFormat, saml.signingCert

A registration identifies the relying application and its allowed organizations. Staff accounts belong to their own organizations; sign-in preserves that organization identity.

Built-in actions

You callAppEngine does for you
POST idp/clientschecks every redirect URL is exact HTTPS (or http://localhost / 127.0.0.1), with no wildcards or # fragments; keeps only known scopes; creates a client ID (amc_…); returns the client secret once
PUT idp/clients/{id}changes a registration, keeping its client ID and secret
POST idp/clients/{id}/rotate-secretissues a new secret; the old one stops working at once
DELETE idp/clients/{id}removes the registration
GET idp/.well-known/openid-configurationpublishes the issuer's addresses, so libraries configure themselves
GET idp/oauth/authorizesends the person to the Appmint login, checks they may use this app, returns a one-time code
POST idp/oauth/tokenswaps the code (plus PKCE verifier) for an ID token, access token and, if allowed, a refresh token
GET idp/oauth/userinforeturns the person's claims for an access token
GET idp/saml/metadata, idp/saml/ssothe same sign-in for SAML apps

Also at the issuer: idp/.well-known/jwks.json (the public signing keys), idp/oauth/revoke, idp/oauth/introspect and idp/oauth/logout.

The rule. Use a standard OIDC or SAML library in the relying app and point it at the issuer. Don't build a "log in with your Appmint password" form in the other app, and don't pass Appmint session tokens to it — the issuer does the sign-in, the checks and the signing for you.

Who can sign in. Staff accounts (user records) that are active and not locked, from an organization the registration allows. Customers can't sign in through the issuer.

2. Before you start: issuer and application settings

Start with a configured AppEngine issuer and a registered relying application. Keep its client ID, exact callback, logout return address and allowed organizations together. A public client has no client secret; a confidential application keeps its secret on its server. The callback in your application must match the registration exactly.

If you run your own installation, the issuer needs this once:

  • IDP_SIGNING_KEY, IDP_SIGNING_CERT and IDP_SECRET set in the AppEngine environment. scripts/idp-keys.ts in the AppEngine repository generates them and prints the Kubernetes secret command. Without them, a production server answers the OIDC routes with 503 temporarily_unavailable.
  • SHARED_ORG set, and Redis reachable — registrations are stored in the shared organization, and sign-in state in Redis.
  • The public address: the issuer is <base URL>/idp, where the base URL is the first of APPENGINE_LOCAL_URL, APPENGINE_URL, BASE_URL that is set. Make sure that's the address the outside world uses.
  • BUILDER_URL pointing at your Studio (default https://studio.appmint.io), because that's where people log in.
  • Node 22.12 or newer for AppEngine itself.

3. Read the issuer's discovery document

Every OIDC library starts here:

curl -sS "$API/idp/.well-known/openid-configuration"
{
  "issuer": "https://appengine.appmint.io/idp",
  "authorization_endpoint": "https://appengine.appmint.io/idp/oauth/authorize",
  "token_endpoint": "https://appengine.appmint.io/idp/oauth/token",
  "userinfo_endpoint": "https://appengine.appmint.io/idp/oauth/userinfo",
  "jwks_uri": "https://appengine.appmint.io/idp/.well-known/jwks.json",
  "end_session_endpoint": "https://appengine.appmint.io/idp/oauth/logout",
  "scopes_supported": ["openid", "offline_access", "email", "profile", "org"],
  "code_challenge_methods_supported": ["S256"],
  "…": "…"
}

Check that issuer is your public API address plus /idp. Libraries compare it exactly, so an internal hostname here breaks sign-in.

4. Register the roster app

The relying app needs its own HTTPS origin and returns to /auth/callback. The examples below use https://sso-course.appmint.test, a reserved training hostname that must be mapped to your local HTTPS proxy or replaced with a real hostname you control. It is not a public hosted application. Use the same origin in your registration and APP_ORIGIN. The proposed registration:

{
  "name": "Juniper class roster",
  "protocol": "oidc",
  "emailClaim": "login",
  "access": { "orgs": ["juniper-yoga"], "userTypes": ["user"] },
  "oidc": {
    "redirectUris": ["https://sso-course.appmint.test/auth/callback"],
    "postLogoutRedirectUris": ["https://sso-course.appmint.test/"],
    "scopes": ["openid", "profile", "email", "org"],
    "refreshTokens": true,
    "publicClient": false
  }
}
  • access.orgs — only Juniper Yoga's staff may use it. Leave it empty and staff from every organization on the platform could sign in, so always fill it for a single business's app.
  • name — what people see while signing in.
  • emailClaim: "login" — the app receives the person's Appmint sign-in email. (mailbox instead lets them pick one of the mailboxes assigned to them, for webmail apps.)
  • publicClient: false — the roster app has a server, so it gets a secret. A browser-only or phone app would set true and use PKCE alone.

The administrator sends it:

curl -sS -X POST "$API/idp/clients" \
  -H "orgid: $CALLER_ORG" -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary @roster-registration.json
{
  "sk": "66f5…",
  "name": "Juniper class roster",
  "data": {
    "name": "Juniper class roster",
    "protocol": "oidc",
    "status": "active",
    "emailClaim": "login",
    "access": { "orgs": ["juniper-yoga"], "userTypes": ["user"] },
    "oidc": {
      "clientId": "amc_3f9a1c…",
      "secretVersion": 1,
      "redirectUris": ["https://sso-course.appmint.test/auth/callback"],
      "postLogoutRedirectUris": ["https://sso-course.appmint.test/"],
      "scopes": ["openid", "profile", "email", "org"],
      "refreshTokens": true,
      "publicClient": false
    }
  },
  "clientSecret": "Yk2…"
}

Two IDs, two jobs: sk is the registration record — use it in idp/clients/{id} to change or rotate it. data.oidc.clientId is what the roster app sends to the issuer. clientSecret appears only now and after a rotation; AppEngine doesn't store it, so save it in the roster app's server configuration.

A redirect such as http://roster.juniper.example/cb is refused with 400 "redirect URL must use https"; https://*.juniper.example/cb with "cannot contain wildcards".

5. Sign an instructor in

Here's what happens when an instructor selects "Sign in" in the roster app:

sequenceDiagram
  participant B as Instructor's browser
  participant R as Roster app
  participant I as AppEngine issuer
  participant S as Appmint Studio login
  B->>R: Sign in
  R->>R: Make state, nonce and PKCE verifier; keep them in this browser's session
  R->>I: Redirect to /idp/oauth/authorize with client_id, redirect_uri, code_challenge
  I->>S: Send the browser to the Appmint login
  S->>I: Signed in — exchange for a one-time ticket
  I->>I: Check: staff, active, organization allowed
  I->>R: Back to /auth/callback with code and state
  R->>I: POST /idp/oauth/token with code, verifier, client ID and secret
  I-->>R: ID token, access token, refresh token
  R->>R: Validate the ID token, start the app's own session

The instructor uses the Appmint login they already know; there's no consent screen, because every registered app was approved by an administrator.

The start of sign-in, in Node — your OIDC library usually does this for you:

import { randomBytes, createHash } from 'node:crypto';

const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
const state = randomBytes(24).toString('base64url');
const nonce = randomBytes(24).toString('base64url');
session.oidc = { verifier, state, nonce };   // this browser's session, used once

const authorize = new URL(discovery.authorization_endpoint);
authorize.search = new URLSearchParams({
  client_id: clientId,
  redirect_uri: 'https://sso-course.appmint.test/auth/callback',
  response_type: 'code',
  scope: 'openid profile email org',
  state, nonce,
  code_challenge: challenge,
  code_challenge_method: 'S256',
}).toString();
// redirect the browser to authorize.href

Run the roster app's sign-in

oidc-lab.mjs is a complete relying app — sign-in, callback, claims, userinfo, refresh and both kinds of sign-out — using the openid-client library. In a new folder, with Node 22.12 or newer:

npm init -y
npm install --save-exact [email protected]

Create .env.local with the values from section 4:

APP_ORIGIN=https://sso-course.appmint.test
OIDC_ISSUER=https://appengine.appmint.io/idp
OIDC_CLIENT_ID=amc_3f9a1c…
OIDC_CLIENT_SECRET=Yk2…

(For a public client, leave out OIDC_CLIENT_SECRET.)

node --env-file=.env.local oidc-lab.mjs

Serve the loopback process behind an HTTPS reverse proxy that preserves the configured Host header. Map the reserved training hostname only in your controlled environment, or use a real HTTPS origin you control. The sample uses Secure, HttpOnly cookies. Open your configured APP_ORIGIN and select Sign in through AppEngine. Log in as a Juniper Yoga staff member. With a correctly registered client, the expected result is a return to the relying app with a validated local session. That result remains pending in this course recording until the administrator provides the registration.

6. Check who signed in

The lab shows the validated claims from the ID token:

{
  "sub": "juniper-yoga|66f0a7…",
  "email": "[email protected]",
  "email_verified": true,
  "name": "Ana Ortiz",
  "given_name": "Ana",
  "family_name": "Ortiz",
  "org": "juniper-yoga",
  "org_name": "Juniper Yoga"
}

sub is <organization>|<staff record ID> for login identity — use the validated subject as the identity key, rather than an email that can change. Mailbox identity adds a third component, <organization>|<staff record ID>|<mailbox>, so two mailbox selections can intentionally be different subjects. org tells a multi-business app which organization they came from. The profile scope adds the name claims, email the email, org the organization.

Select Read userinfo to ask idp/oauth/userinfo for the same claims with the access token; the lab checks sub matches.

Behind the scenes the lab validated everything a callback must: the returned state matches the one it saved, the code was exchanged within its 60-second life with the saved PKCE verifier, and the ID token's signature (from jwks.json), issuer, audience, expiry and nonce are correct. Only then did it start its own session. Use a maintained OIDC library for this in your own app.

The access token is for the issuer's own endpoints such as userinfo. To call AppEngine's business routes, use the credentials in the Authentication course.

7. Refresh, sign out, rotate, disable

Refresh. Access and ID tokens last an hour. Select Refresh access: the lab sends grant_type=refresh_token to idp/oauth/token and gets new tokens. Refresh tokens are issued when the registration has refreshTokens: true (the default) and last 30 days.

Sign out. End local session removes the roster app's own session — the instructor is still signed into Appmint, so the next sign-in is instant. End local and issuer session also sends them to the issuer's end_session_endpoint and back to the registered post-logout URL. Pick per app what "Sign out" should mean.

Rotate the secret.

curl -sS -X POST "$API/idp/clients/$REGISTRATION_SK/rotate-secret" \
  -H "orgid: $CALLER_ORG" -H "Authorization: Bearer $PLATFORM_ADMIN_TOKEN"

The answer carries the new clientSecret; the old one stops working immediately, so update the roster app's configuration in the same change. Public clients have no secret to rotate.

Change who may sign in. PUT idp/clients/{id} with the fields to change — for example {"access": {"orgs": ["juniper-yoga", "juniper-east"], "userTypes": ["user"]}}. Top-level fields you send replace the stored ones, so send oidc whole if you change any part of it.

Turn the app off. PUT idp/clients/{id} with {"status": "disabled"} stops new sign-ins at once. Someone who leaves the studio is handled in Appmint: lock their account, and the issuer refuses them at their next sign-in or refresh.

When something fails, look at the first step that went wrong:

SymptomWhere to look
Discovery returns 503Issuer keys not configured (section 2)
The login page is the wrong StudioBUILDER_URL
"… is not available to your account" after logging inaccess.orgs, or the person isn't active staff
Error at the callback, or token exchange refusedRedirect URI not exactly as registered, PKCE verifier lost, or wrong secret
Library says the issuer doesn't matchissuer in discovery isn't your public address

8. Add a SAML app

Some tools — older HR or wiki software — only speak SAML. Register them the same way, with protocol: "saml":

{
  "name": "Juniper staff wiki",
  "protocol": "saml",
  "access": { "orgs": ["juniper-yoga"], "userTypes": ["user"] },
  "saml": {
    "entityId": "https://wiki.juniper.example/saml/metadata",
    "acsUrl": "https://wiki.juniper.example/saml/acs",
    "nameIdFormat": "persistent"
  }
}

Copy entityId and acsUrl (where the app receives sign-in responses) from the wiki's SAML settings. The registration schema accepts sloUrl, but the current AppEngine controller does not implement a SAML single-logout route; do not claim that setting alone enables single logout. Add signingCert when the service provider signs its requests. Then give the wiki AppEngine's metadata — <API>/idp/saml/metadata — which carries the issuer's certificate and the sign-in address <API>/idp/saml/sso (both redirect and POST work). nameIdFormat is email by default; persistent gives the app a stable ID that survives an email change.

9. Where to go next

  • Authentication — credentials for AppEngine's business routes, and two-factor sign-in, which every connected app inherits.
  • Foundations — customer sign-in for your own customer-facing apps.
  • API Reference — the idp/clients contracts in the reference that ships with your server.

Mailbox identity for webmail applications

Use emailClaim: "login" for an ordinary staff application. Use emailClaim: "mailbox" when the relying application needs an assigned mailbox. Studio asks the signed-in staff member to choose from their assigned mailboxes on every sign-in. The selected mailbox becomes email and is appended to sub; do not treat all those subjects as the same staff-only subject. mailboxHostedBy can filter the hosted-mail provider when supplied. Removing an assignment makes that mailbox identity unavailable at the next account lookup. This source-verified route needs its own assigned mailbox and relying-app rehearsal before being labelled a completed demonstration.

Recorded metadata and deployment addresses

The controlled deployment advertised issuer https://proxy.appmint.io/idp. Some endpoint addresses were derived from the request's training origin. Learner-facing excerpts explicitly substitute [training API address] for that transport address; they do not silently relabel it as a successful public hosted response. Use the exact administrator-provided deployment address and confirm discovery through the same public origin that clients will use. Requests to the advertised public issuer did not yield usable metadata during this recording; no global issuer configuration was changed.

See the course production evidence at Identity Provider contract review.

Companion source and verification status

The existing OIDC relying app and setup instructions are packaged with pinned dependencies, proposed registration templates, public metadata and multi-language request examples. The downloadable source archive includes the same maintained files. Dependency exports and syntax are checked; live sign-in and signed SAML assertion delivery remain unverified. This does not change the partial video status.

The maintained source is now published in appmint-examples. Publication makes the existing implementation available; it does not turn the pending live SSO outcomes into verified results.