Integration guide

> Wiring an app (hostnames, JWKS rules, RP registration, checklist): > Connect an app.

This guide shows how a Relying Party (RP) — any app that delegates login to this Identity Provider — integrates using the OAuth 2.1 authorization code + PKCE flow exposed by @openauthjs/openauth.

This Identity Provider

URL
Issuerhttps://admin.fixweb.cloud
JWKShttps://admin.fixweb.cloud/.well-known/jwks.json
Discoveryhttps://admin.fixweb.cloud/.well-known/oauth-authorization-server
Admin consolehttps://admin-admin.fixweb.cloud
Example redirect URIhttps://app.fixweb.cloud/callback

The issuer is mounted at the origin root of the IdP (https://admin.fixweb.cloud). It derives iss and all discovery URLs from the request origin, so every endpoint below is relative to that origin.

Endpoints

EndpointPurpose
GET /authorizeStart the auth code + PKCE flow (provider select screen).
POST /tokenExchange an authorization code (or refresh token) for tokens.
POST /introspectRFC 7662 introspection (service tokens — see Service tokens).
GET /.well-known/jwks.jsonPublic ES256 signing keys (for verification).
GET /.well-known/oauth-authorization-serverOAuth 2.0 server metadata (discovery).
GET /{provider}/callbackUpstream provider redirect target (e.g. /google/callback).

Client flow (TypeScript)

Use the official client. It handles PKCE, state and the token exchange for you.

import { createClient } from '@openauthjs/openauth/client';

const client = createClient({
  clientID: 'sales',
  issuer: 'https://admin.fixweb.cloud',
});

// 1. Redirect the user to the IdP.
const { url } = await client.authorize(
  'https://app.fixweb.cloud/callback', // your redirect_uri
  'code',
);
// → 302 the browser to `url`. The PKCE verifier is kept by the client.

// 2. On your /callback route, exchange the code for tokens.
const exchanged = await client.exchange(code, 'https://app.fixweb.cloud/callback');
if (exchanged.err) throw exchanged.err;
const { access, refresh } = exchanged.tokens;

// 3. Later, refresh when the access token is near expiry.
const refreshed = await client.refresh(refresh);

Both the clientID and the redirect_uri must be allowed by the issuer: the client_id must equal the id of a registered application, and the redirect_uri must be declared in that app's oauth.redirect_uris (or live on the app's own url host). localhost / 127.0.0.1 is always accepted for local dev. Unregistered clients are rejected with unauthorized_client — see Connect an app for the exact rules.

Access token structure

Tokens are JWTs signed with ES256 (alg: ES256 in the header).

The payload:

FieldValue
mode"access"
type"user" (interactive) or "service" (machine / kiosk — see Service tokens)
propertiesuser: { userID, email, name, source, roles, permissions, apps } · service: { tokenID, name, application_id, permissions, apps }
issthe issuer origin, e.g. https://admin.fixweb.cloud
audthe clientID the token was issued to
subuser:{userID} — stable per user (also the refresh-session storage key)
expexpiry (epoch seconds)

All application identity lives under properties — there are no top-level email/roles/permissions claims.

{
  "mode": "access",
  "type": "user",
  "properties": {
    "userID": "usr_123",
    "email": "jane@example.com",
    "name": "Jane Doe",
    "source": "local",
    "roles": ["admin", "sales"],
    "permissions": ["projects.read", "projects.export"]
  },
  "iss": "https://admin.fixweb.cloud",
  "aud": "sales",
  "sub": "user:usr_123",
  "exp": 1750000000
}

Verifying a token server-side

Fetch the JWKS from https://admin.fixweb.cloud/.well-known/jwks.json, verify the signature with the ES256 key, check iss and aud, then read identity from properties.

Checking aud is not optional: roles and permissions are scoped to the application the token was issued to. A token minted for another app on the same IdP carries that app's grants, so skipping the audience check lets a user present a foreign token and be authorized against your permission names.

PHP (firebase/php-jwt)

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;

$issuer   = 'https://admin.fixweb.cloud';
$clientId = 'my-app'; // your registered application id
$jwks = json_decode(file_get_contents($issuer . '/.well-known/jwks.json'), true);
$keys = JWK::parseKeySet($jwks); // ES256 keys

$decoded = JWT::decode($accessToken, $keys); // throws on bad signature/expiry

if (($decoded->iss ?? null) !== $issuer) {
    throw new RuntimeException('bad issuer');
}
if (($decoded->aud ?? null) !== $clientId) {
    throw new RuntimeException('token issued for another application');
}
if (($decoded->mode ?? null) !== 'access' || ($decoded->type ?? null) !== 'user') {
    throw new RuntimeException('not a user access token');
}

$props = $decoded->properties;
$userId      = $props->userID;
$email       = $props->email;
$roles       = $props->roles;        // array
$permissions = $props->permissions;  // array

JavaScript / TypeScript (jose)

import { createRemoteJWKSet, jwtVerify } from 'jose';

const issuer = 'https://admin.fixweb.cloud';
const clientId = 'my-app'; // your registered application id
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));

const { payload } = await jwtVerify(accessToken, jwks, { issuer, audience: clientId });
if (payload.mode !== 'access' || payload.type !== 'user') {
  throw new Error('not a user access token');
}
const { userID, email, roles, permissions } = payload.properties as {
  userID: string; email: string; roles: string[]; permissions: string[];
};

Consumer Workers can also bind the IdP as a service and call IdentityService.authorizeApp(request, slug) to verify the token and gate access in-process, with no HTTP round-trip.

Application catalog over HTTP

Three public endpoints expose the app catalog (same data the IdentityService RPC serves), authenticated by the standard access token (Authorization: Bearer, or an id_at cookie if your deployment shares one across subdomains):

EndpointAuthReturns
GET /api/auth/meoptional{ user }null when anonymous/invalid/deactivated
GET /api/auth/me/appsoptional{ user, apps, login_url } — personalized catalog; public-only when anonymous
GET /api/public/appsnone{ apps, login_url } — anonymous catalog, edge-cacheable

login_url is always empty: each RP starts its own code+PKCE flow, so the page embedding the switcher supplies its own sign-in entry point.

Embedding the app switcher

<script src="https://admin.fixweb.cloud/embed/switcher.js" defer></script>
<identity-app-switcher></identity-app-switcher>
<script>
  window.identityGetToken = () => myAuth.getAccessToken(); // string | Promise<string>
</script>

Optional: token="…" attribute, or login-href="/login" for a Sign-in CTA when anonymous. The popover links back to the IdP App Selector at https://admin.fixweb.cloud/home. Ensure the app origin is listed in the IdP ALLOWED_ORIGINS (CORS for /api/auth/me/apps).

Default landing after login

Tenant admins can set Default landing after login under Branding & settings (general.default_landing_app_id):

Applies to the App Selector OAuth client (client_id=apps) after sign-in, and to the bare IdP / when no return_to is present. Invalid values fall back to /home.

Migration from the old token format

This IdP previously issued a custom cookie/session flow with RS256 tokens whose claims sat at the top level. The delta:

OldNew
Signature alg: RS256Signature alg: ES256
Claims top-level (email, roles, …)Claims nested under properties
iss could include a base pathiss is the origin only
Custom session cookies / /api/auth/* loginStandard /authorize + /token code + PKCE

When migrating an RP: switch your verifier to ES256, read identity from payload.properties instead of the top level, and compare iss against the bare origin (https://admin.fixweb.cloud).