Connect an app to the tenant IdP

App developer guide: OIDC against this Identity Provider.

To register the app (manifest, catalogue vs private, RBAC grants), see Register an app.

For OAuth endpoints, JWT shape and PHP verification, see the Integration guide. For Google / Microsoft and other sign-in methods configured on the IdP, see Sign-in methods. For clients without an interactive login (kiosk, cron), see Service tokens instead of the code + PKCE flow below.

SPA quick start (@fixweb/toolkit/oauth-client)

import { createOAuthClient } from '@fixweb/toolkit/oauth-client';

const auth = createOAuthClient({
  issuer: 'https://admin.fixweb.cloud',
  clientId: 'my-app',
  redirectUri: 'https://app.fixweb.cloud/callback',
});

await auth.login(); // → GET https://admin.fixweb.cloud/authorize (PKCE)
// callback page:
await auth.handleCallback();
const bearer = await auth.getAccessToken();
auth.logout(); // clears refresh + redirects to https://admin.fixweb.cloud/end_session

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 app / redirecthttps://app.fixweb.cloud · https://app.fixweb.cloud/callback
Cookie domain.fixweb.cloud

1. Architecture

One fw-saas-tenant worker per tenant = the IdP (plus optional admin console). Business apps are OAuth relying parties (RPs); they do not talk to Google/Microsoft directly.

Browser → App (RP) → authorize/token → tenant IdP (fw-saas-tenant)
                              ↓
                     JWKS ES256 to validate the Bearer

Hostnames

ModelIdP (issuer)Admin consoleApps
Vanity / customhttps://id.{tenant-domain}https://admin.{tenant-domain}https://{app}.{tenant-domain}, …
Platform (WfP, primary_domain)https://{tenant}.{platformDomain}https://admin-{tenant}.{platformDomain}https://{tenant}-{app}.{platformDomain}

This deployment:

IssuerApp RP (example)Admin
https://admin.fixweb.cloudhttps://app.fixweb.cloudhttps://admin-admin.fixweb.cloud

Platform pattern (other tenants): https://acme.fixweb.cloudhttps://acme-crm.fixweb.cloud / https://admin-acme.fixweb.cloud.

The issuer is the origin of the IdP worker (no path). Discovery: https://admin.fixweb.cloud/.well-known/oauth-authorization-server.


2. OIDC / OAuth flow (code + PKCE)

  1. App generates PKCE (code_verifier / code_challenge) + state.
  2. Browser redirect → GET https://admin.fixweb.cloud/authorize?client_id=…&redirect_uri=…&response_type=code&code_challenge=…&code_challenge_method=S256.
  3. IdP authenticates (local / Google / Microsoft / … — on the IdP only).
  4. Redirect → {redirect_uri}?code=…&state=….
  5. App (backend / Worker): POST https://admin.fixweb.cloud/token (code + verifier) → access (+ refresh).
  6. App calls its APIs with Authorization: Bearer <access>; the server verifies via JWKS.

End session (logout)

RP-initiated logout: GET or POST https://admin.fixweb.cloud/end_session?post_logout_redirect_uri=…. Clears IdP session cookies; redirects only when the URI is same-origin or registered (oauth.redirect_uris).

Confidential OAuth clients

Register machine clients via POST /api/admin/oauth-clients with exact redirect_uris. The issuer enforces those URIs for confidential clients during authorization. POST /token requires client_secret (body or HTTP Basic) for confidential clients that have a stored secret.

Official client library: @openauthjs/openauth/client (see Integration guide). SPA: @fixweb/toolkit/oauth-client. Workers: @fixweb/toolkit/jwt / idp.

MFA, invites, data API, logout

TopicHow
MFAAfter password verify, TOTP and/or WebAuthn (passkey) challenge. Admin enroll: /api/admin/users/:id/mfa/*. Login WebAuthn uses CSP-safe /embed/webauthn-login.js.
InvitesAdmin POST /api/admin/invitations → user opens /invite/accept?token=… and sets password (min length from auth policy).
Data APIJWT-gated /api/v1/db/:collection, /api/v1/storage/*, /api/v1/realtime/ws. Permissions `data:{collection}:read\write. Optional quotas: api_reads, api_writes, documents, storage_bytes`.
LogoutClear RP tokens then GET/POST https://admin.fixweb.cloud/end_session?post_logout_redirect_uri=… — toolkit auth.logout().

3. Required app configuration

VariableRuleThis IdP
IssuerHTTPS IdP origin, no trailing slashhttps://admin.fixweb.cloud
JWKSAlways {issuer}/.well-known/jwks.jsonhttps://admin.fixweb.cloud/.well-known/jwks.json
client_idExactly the registered application id (see §4)e.g. app slug
redirect_uriAbsolute callback URL, declared in the app's oauth.redirect_uris (or on the app's own host)e.g. https://app.fixweb.cloud/callback

JWKS — hard rules

Tokens: ES256. Business claims under properties (userID, email, roles, permissions, apps, …). aud = client_id. See Integration guide.

Typical WfP vars (CONTRACT): IDP_ISSUER_URL / derived issuer, IDP_JWKS_URL, TENANT_ID, APP_ID.


4. Register the OAuth client (RP)

Prefer a manifest — see Register an app:

POST /api/admin/applications/register
Authorization: Bearer <admin access token>
Content-Type: application/json

{
  "id": "jarvis",
  "name": "Jarvis",
  "deployable": false,
  "oauth": {
    "redirect_uris": ["https://app.fixweb.cloud/callback"]
  },
  "permissions": [
    { "id": "jarvis.chat.read", "label": "Read chat history" }
  ]
}

Tenant UI: Apps → Register app. Catalogue (hosted) apps: control plane Applications → Register from manifest.

The IdP has no client-secret registry for public RPs (PKCE). The /authorize gate is strict: your client_id must equal the id of a registered application, otherwise the request is rejected with unauthorized_client — even if your app runs on the same domain as the issuer. There is no same-domain bypass and no env allowlist for OAuth.

Once the application is registered, the redirect_uri is accepted when:

  1. it is listed exactly in the app's oauth.redirect_uris, or
  2. it is HTTPS and its host matches the app's url host or the host of a

declared redirect URI (host trust is scoped to this app only).

Two exceptions bypass the registered-application lookup entirely:

any client_id, registered or not. Never point a production callback at a loopback host.

not applications rows; they are pinned to the IdP's own host and to fixed callback paths.

Recommended: always declare exact oauth.redirect_uris (scheme + host + path), and rely on host trust only as a convenience for hosted apps whose callback lives on their own url host.

App switcher embed (header): load https://admin.fixweb.cloud/embed/switcher.js, place <identity-app-switcher>, set window.identityGetToken. See Integration guide. Tenant admins set post-login landing under Branding & settings.

Legacy PUT /api/admin/applications (id + url only) still works. Then grant app:{id}:access (and fine perms) via Apps RBAC, or legacy role → application grants for gated apps.

Control plane (fw-saas-admin)

Publishes global catalogue apps and enables WfP deploy (deployable: true). It does not replace tenant-private registration for external RPs.


5. Google / Microsoft SSO

Configure only in the IdP admin → Providers (IdP redirect_uri = https://admin.fixweb.cloud/{provider}/callback, e.g. https://admin.fixweb.cloud/google/callback).

The RP app configures no Google/Microsoft client — only OIDC against the IdP. Details: Sign-in methods.


6. Checklist


7. Common failures

SymptomLikely causeAction
401 after IdP redeployJWKS / ES256 keys regenerated; stale kid cacheRe-fetch JWKS (no pinned kid); restart / invalidate jose cache
error=unauthorized_client on /authorizeclient_id is not a registered application id, or redirect_uri not declared for that appRegister the app (§4) with client_id as id + exact oauth.redirect_uris
Wrong issuerApp points at a different origin than https://admin.fixweb.cloudAlign on https://admin.fixweb.cloud + the iss claim
IdP login session broken mid-flowOpenAuth authorization cookie lost (SameSite / domain / clear site data)Restart from the app; keep IdP COOKIE_DOMAIN coherent (e.g. .fixweb.cloud)
portal_not_configured (503)/api/tenant/* without CONTROL_PLANE_URLIdP / control-plane ops — not an RP OIDC bug
Token OK but empty rolesgated app without role grant / overrideIdP admin → roles → applications
JWKS 404 from a WfP WorkerIdP hostname unreachable worker→workerUse injected IDP_JWKS_URL

8. Workers / JS snippets

Verify an access token (jose)

import { createRemoteJWKSet, jwtVerify } from 'jose';

const issuer = 'https://admin.fixweb.cloud';
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));

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

Via @fixweb/toolkit (WfP catalogue apps)

import { verifyIdpAccessToken } from '@fixweb/toolkit/idp';

const user = await verifyIdpAccessToken(env, bearerToken);
if (!user) return new Response('Unauthorized', { status: 401 });
// user.userID, user.email, user.roles, user.permissions

Use verifyRemoteJwt(issuer, token, { jwksUrl }) when you only have the proxy JWKS URL.

Start login (OpenAuth client)

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

const client = createClient({
  clientID: 'jarvis',
  issuer: 'https://admin.fixweb.cloud',
});
const { url } = await client.authorize('https://app.fixweb.cloud/callback', 'code');
// 302 → url; on callback: client.exchange(code, redirectUri)

More detail (token claims, PHP): Integration guide.