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 | |
|---|---|
| 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 app / redirect | https://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
| Model | IdP (issuer) | Admin console | Apps |
|---|---|---|---|
| Vanity / custom | https://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:
| Issuer | App RP (example) | Admin |
|---|---|---|
https://admin.fixweb.cloud | https://app.fixweb.cloud | https://admin-admin.fixweb.cloud |
Platform pattern (other tenants): https://acme.fixweb.cloud → https://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)
- App generates PKCE (
code_verifier/code_challenge) +state. - Browser redirect →
GET https://admin.fixweb.cloud/authorize?client_id=…&redirect_uri=…&response_type=code&code_challenge=…&code_challenge_method=S256. - IdP authenticates (local / Google / Microsoft / … — on the IdP only).
- Redirect →
{redirect_uri}?code=…&state=…. - App (backend / Worker):
POST https://admin.fixweb.cloud/token(code + verifier) → access (+ refresh). - 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
| Topic | How | |
|---|---|---|
| MFA | After password verify, TOTP and/or WebAuthn (passkey) challenge. Admin enroll: /api/admin/users/:id/mfa/*. Login WebAuthn uses CSP-safe /embed/webauthn-login.js. | |
| Invites | Admin POST /api/admin/invitations → user opens /invite/accept?token=… and sets password (min length from auth policy). | |
| Data API | JWT-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`. |
| Logout | Clear RP tokens then GET/POST https://admin.fixweb.cloud/end_session?post_logout_redirect_uri=… — toolkit auth.logout(). |
3. Required app configuration
| Variable | Rule | This IdP |
|---|---|---|
| Issuer | HTTPS IdP origin, no trailing slash | https://admin.fixweb.cloud |
| JWKS | Always {issuer}/.well-known/jwks.json | https://admin.fixweb.cloud/.well-known/jwks.json |
| client_id | Exactly the registered application id (see §4) | e.g. app slug |
| redirect_uri | Absolute 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
- ✅
https://admin.fixweb.cloud/.well-known/jwks.json - ✅ WfP worker→worker option: injected
IDP_JWKS_URL(control-plane proxy) — same document, different URL - ❌ Never hardcode a
kidor PEM key - ❌ Never use another app’s JWKS (e.g. CRM
/jwks) or a “nearby” issuer
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:
- it is listed exactly in the app's
oauth.redirect_uris, or - it is HTTPS and its host matches the app's
urlhost or the host of a
declared redirect URI (host trust is scoped to this app only).
Two exceptions bypass the registered-application lookup entirely:
- Local dev. A
redirect_urionlocalhost/127.0.0.1is accepted for
any client_id, registered or not. Never point a production callback at a loopback host.
- Built-in clients.
admin(admin console) andapps(app launcher) are
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
- [ ] Issuer =
https://admin.fixweb.cloud(vanityid.*or platform{tenant}.{domain}— do not mix). - [ ] JWKS =
https://admin.fixweb.cloud/.well-known/jwks.json(or WfPIDP_JWKS_URL). - [ ]
client_id= the registered application id (register first — §4); verifyaudif you pin audience. - [ ] Exact
redirect_uri(scheme + host + path) declared in the app'soauth.redirect_uris. - [ ] Server-side callback (code + PKCE); do not exchange the code in an unprotected front without PKCE.
- [ ] Verify ES256 signature +
exp; identity inproperties. - [ ] API CORS: exact app origin if browser → third-party API.
- [ ] App session cookies:
Secure+ appropriateSameSite(Lax is often OK for top-level OAuth redirects).
7. Common failures
| Symptom | Likely cause | Action |
|---|---|---|
| 401 after IdP redeploy | JWKS / ES256 keys regenerated; stale kid cache | Re-fetch JWKS (no pinned kid); restart / invalidate jose cache |
error=unauthorized_client on /authorize | client_id is not a registered application id, or redirect_uri not declared for that app | Register the app (§4) with client_id as id + exact oauth.redirect_uris |
| Wrong issuer | App points at a different origin than https://admin.fixweb.cloud | Align on https://admin.fixweb.cloud + the iss claim |
| IdP login session broken mid-flow | OpenAuth 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_URL | IdP / control-plane ops — not an RP OIDC bug |
| Token OK but empty roles | gated app without role grant / override | IdP admin → roles → applications |
| JWKS 404 from a WfP Worker | IdP hostname unreachable worker→worker | Use 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.