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 | |
|---|---|
| Issuer | https://admin.fixweb.cloud |
| JWKS | https://admin.fixweb.cloud/.well-known/jwks.json |
| Discovery | https://admin.fixweb.cloud/.well-known/oauth-authorization-server |
| Admin console | https://admin-admin.fixweb.cloud |
| Example redirect URI | https://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
| Endpoint | Purpose |
|---|---|
GET /authorize | Start the auth code + PKCE flow (provider select screen). |
POST /token | Exchange an authorization code (or refresh token) for tokens. |
POST /introspect | RFC 7662 introspection (service tokens — see Service tokens). |
GET /.well-known/jwks.json | Public ES256 signing keys (for verification). |
GET /.well-known/oauth-authorization-server | OAuth 2.0 server metadata (discovery). |
GET /{provider}/callback | Upstream 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:
| Field | Value |
|---|---|
mode | "access" |
type | "user" (interactive) or "service" (machine / kiosk — see Service tokens) |
properties | user: { userID, email, name, source, roles, permissions, apps } · service: { tokenID, name, application_id, permissions, apps } |
iss | the issuer origin, e.g. https://admin.fixweb.cloud |
aud | the clientID the token was issued to |
sub | user:{userID} — stable per user (also the refresh-session storage key) |
exp | expiry (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):
| Endpoint | Auth | Returns |
|---|---|---|
GET /api/auth/me | optional | { user } — null when anonymous/invalid/deactivated |
GET /api/auth/me/apps | optional | { user, apps, login_url } — personalized catalog; public-only when anonymous |
GET /api/public/apps | none | { 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):
- App Selector (
_home) — IdP/homelauncher (default) - Any installed application that has a public URL
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:
| Old | New |
|---|---|
Signature alg: RS256 | Signature alg: ES256 |
Claims top-level (email, roles, …) | Claims nested under properties |
iss could include a base path | iss is the origin only |
Custom session cookies / /api/auth/* login | Standard /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).