Visitor sign-in and sessions
When an app declares that visitors must sign in, the runner refuses to render until a session exists. This page is the protocol behind that, read from the engine source. Every section names its source; anything the code leaves open is listed under Unverified rather than guessed.
There are two doors, and the app’s declaration picks one:
| Topology | Who runs the login | What the runner does | Token |
|---|---|---|---|
external | an OIDC issuer the app names | full-page PKCE redirect to the issuer | the issuer’s JWT |
platform | the API at the app’s own /api/app/… base | posts email + password, or a social PKCE code, to that base | an opaque session token the API minted |
Source: pwfabric-core/packages/contracts/src/fabric/project-identity.ts,
pwfabric-core/packages/runner/src/app-session.ts (module note).
The visitor is not a platform user
A visitor of an app is an app principal, stored per project and keyed by
(project_id, issuer, subject). The code is explicit that a platform user
cannot stand in for one: auth_sessions.user_id references users, so the
platform could only represent people with PhiWebs accounts, and “a
greengrocer’s customer is not that”. A principal belongs to one project;
signing into one app signs you into nothing else, even with the same GitHub
account. The middleware that resolves visitors is separate from the one that
resolves PhiWebs users, so an app token can never be read as a platform
session. A password visitor and a Google visitor with the same email are
different principals, because issuer differs.
Source: PhiWebs API (closed source): app-principal-service.ts,
PhiWebs API (closed source): app-auth.ts.
What the app declares
pwpack/project.json → identity, frozen at export from the project’s
identityBinding:
{ "identity": { "required": true, "topology": "platform", "methods": ["password"] } }required is the gate switch; topology is platform (default) or
external; methods (platform only) is drawn from the closed set
password | google | github | microsoft. The contract refuses contradiction
(an issuer with platform, or methods with external) but allows
required: true with no methods — the gate then shows a disabled button.
pwpack/auth.json, one of two shapes from buildAuthJson:
// external
{ "provider": "oauth2-pkce", "issuer": "https://…", "clientId": "…",
"scopes": ["openid","profile","email"], "authorizeUrl": "…", "tokenUrl": "…" }
// platform (password, optionally with social buttons)
{ "provider": "password", "loginUrl": "<base>/login", "registerUrl": "<base>/register",
"logoutUrl": "<base>/logout", "sessionUrl": "<base>/session",
"providers": [{ "method": "google", "clientId": "…", "authorizeUrl": "…",
"scopes": ["openid","email","profile"], "redeem": "server" }],
"exchangeUrl": "<base>/oauth/login", "methods": ["password","google"] }<base> is ${PUBLIC_API_ORIGIN}/api/app/<worldId>/<projectId>. If the
publishing API had no PUBLIC_API_ORIGIN, a password app ships no
auth.json. authorizeUrl/tokenUrl in the external shape are optional;
absent, the runner discovers them.
Source: PhiWebs API (closed source): phico-project-service.ts
(buildAuthJson, readProjectIdentity),
…/web-app-assembly-service.ts (projectJson, appApiBase),
PhiWebs API (closed source): phico-projects.ts (PUBLIC_API_ORIGIN),
pwfabric-core/packages/runner/src/types.ts (PwappAuth).
Door A — external issuer (PKCE)
Built only when auth.provider === 'oauth2-pkce' with issuer and
clientId; any other provider value logs “unsupported provider” and the app
renders anonymously (the gate stays up if required).
| Step | Request | Parameters |
|---|---|---|
| Discovery (only if an endpoint is missing) | GET {issuer}/.well-known/openid-configuration | reads authorization_endpoint, token_endpoint; both required |
| Authorize | redirect to authorizeUrl | response_type=code, client_id, redirect_uri, scope (space-joined), state, code_challenge, code_challenge_method=S256 |
| Exchange | POST tokenUrl (form-encoded) | grant_type=authorization_code, code, redirect_uri, client_id, code_verifier |
| Refresh | POST tokenUrl (form-encoded) | grant_type=refresh_token, refresh_token, client_id |
Token responses are read as
{ access_token, refresh_token?, id_token?, expires_in, scope? }.
A failed discovery is not cached.
runner (click) verifier = 32 random bytes as hex; state = 32 hex chars
challenge = base64url(SHA-256(verifier))
sessionStorage["pwapp_pkce_pending:<k>"] = {state, verifier, redirectUri, returnTo}
location.assign(authorizeUrl?…&code_challenge&state) ──► issuer
runner (boot) ◄── redirect_uri?code&state
takePendingPkce(state) (record removed whether or not it matches)
POST tokenUrl grant_type=authorization_code ──► issuer
◄── {access_token, refresh_token?, id_token?, expires_in}
sessionStorage["pwapp_pkce_rt:<k>"] = refresh_token; history.replaceState(returnTo)
gate removes itself; route() rendersredirect_uri is the app base — location.origin + router.base: the
shell’s own pathname under hash routing, the <base href> under path
routing. One registered redirect URI per app; no callback route. returnTo
(path + search + hash at click time) restores the starting page.
Storage and token. k = FNV-1a(issuer | clientId | sorted scopes) in
base-36, so two apps on one origin cannot share a token. Both keys live in
sessionStorage; the access token is memory-only. Session shape:
{ id, state: 'authenticated', user?, token, provider: 'oauth2', createdAt, expiresAt }
with
token = { accessToken, refreshToken?, idToken?, expiresAt: now + expires_in*1000, scopes }.
user is decoded (not verified) from id_token (else access_token):
sub, name, email ?? preferred_username, roles.
Refresh and expiry. On boot, a stored refresh token triggers one
single-flight refresh before the first route; on failure the key is removed
and the login button returns (not an error). getAccessToken() returns the
cached token while expiresAt − 30 s > now, else refreshes; a response with
no new refresh_token keeps the old one. If window.__phi_getToken exists
(SPFx host) it is asked first and PKCE is skipped.
Sign-out. logout() removes pwapp_pkce_rt:<k> and clears the session;
no end-session request goes to the issuer. A callback with no matching
state, or a failed exchange, sets lastError, shown under the button.
API side. Record and session routes accept the issuer’s JWT as
Authorization: Bearer or x-app-session. The API fetches jwksUri (cached
1 h), allows only RS256 | RS384 | RS512, checks iss === issuer, aud
contains audience ?? clientId, and exp (60 s skew); claims map through
identity.claims (sub, email, name, optional roles) and the principal
is upserted. external with no jwksUri answers 503 IDENTITY_UNRESOLVED.
Source: pwfabric-core/packages/runner/src/pkce.ts, …/auth.ts,
…/run-app.ts, …/router.ts (base),
pwfabric-core/packages/runtime/src/auth/types.ts,
PhiWebs API (closed source): app-auth.ts,
PhiWebs API (closed source): jwks-verifier.ts.
Door B — the platform door
Used when auth.json has loginUrl and identity.topology !== 'external'.
The runner never knows a hostname; it posts to the addresses it was handed.
Route under /api/app | Body / headers | Response |
|---|---|---|
POST /:worldId/:projectId/register | { email, password (≥8), displayName? } | 201 { data: { id, principalId, projectId, expiresAt, token, principal } }; 409 CONFLICT |
POST /:worldId/:projectId/login | { email, password } | 200 same shape; 401 INVALID_CREDENTIALS for every failure |
POST /:worldId/:projectId/oauth/login | { provider, code, redirectUri, codeVerifier? } or { provider, accessToken } | 200 same shape; 409 PROVIDER_NOT_CONFIGURED, 401 TOKEN_AUDIENCE_*, 400 |
POST /:worldId/:projectId/logout | x-app-session or bearer | 200 { data: { signedOut: true } }; idempotent |
GET /:worldId/:projectId/session | x-app-session or bearer | 200 { data: { principal } }; principal: null only on anonymous-allowed apps |
The login routes first check the door — 404 PROJECT_NOT_FOUND,
409 WRONG_DOOR (project is external; body carries issuer),
409 METHOD_UNAVAILABLE (body lists methods) — and are rate-limited to
5 requests/minute.
Session token. 32 random bytes, base64url; only its SHA-256 is stored and
looked up, revoked rows ignored. Lifetime 7 days, checked on every
resolve; there is no refresh. Presented on the x-app-session header (or
Authorization: Bearer) — a header, not a cookie, because the app is
cross-origin and the platform cookie must never double as a visitor
credential. The runner stores { token, expiresAt, principal? } in
sessionStorage under pwapp_session:<appId> (appId = project.id ?? project.title ?? 'app'); an expired or unparsable entry is deleted on read.
The token rides on every receipt fetch as x-app-session, and on entity
requests whose source ref equals the app’s own API base as Authorization: Bearer; any other ref gets no token.
password gate form ── POST loginUrl {email,password} ──► API: scrypt verify, mint token
◄── 200 {data:{token, expiresAt, principal}}
sessionStorage["pwapp_session:<appId>"] = …; gate down; route()
GET pwpack/surfaces/<page>.receipt.json (x-app-session)
entity reads → Authorization: Bearer → /api/app/<w>/<p>/records/<type>
social state = 16 random bytes hex; verifier/challenge as Door A
sessionStorage["pwapp_oauth_state:<appId>"] = {state, method, verifier, redeem?, tokenUrl?, clientId?}
redirect provider.authorizeUrl?client_id&redirect_uri=<origin+pathname>&response_type=code
&scope&state&code_challenge&code_challenge_method=S256
◄── ?code&state (or ?error&error_description → shown on the gate)
boot: completeProviderSignIn() runs BEFORE the gate; the URL is cleaned either way
redeem=server POST exchangeUrl {provider, code, redirectUri, codeVerifier}
redeem=browser POST provider.tokenUrl (form) → access_token, then POST exchangeUrl {provider, accessToken}
◄── 200 {data:{token, expiresAt, principal}} → stored like a password sessionSocial redirect_uri is location.origin + location.pathname (not the router
base). Browser redemption exists for registrations that refuse server-side
redemption (a Microsoft SPA registration); on the accessToken path the API
verifies the token’s audience against its own client id before reading a
profile. Which providers reach auth.json is decided at publish: declared
methods filtered by the API’s OAUTH_<PROVIDER>_CLIENT_ID/_SECRET
environment, with OAUTH_<PROVIDER>_REDEEM=browser selecting browser
redemption and adding tokenUrl.
Source: PhiWebs API (closed source): app-auth-routes.ts,
…/app.ts (app.route('/api/app', …)), …/middleware/rate-limit.ts,
…/services/app-principal-service.ts (createSession, SESSION_TTL_MS),
…/services/oauth-providers.ts (appDoorProviders, verifyAccessTokenAudience),
pwfabric-core/packages/runner/src/app-session.ts, …/helpers.ts (fetchJson),
…/run-app.ts (dataServiceConfig.getAccessToken).
The gate
isGated() is true when identity.required and neither a PKCE session nor
an app session exists. While gated the runner fetches nothing. The gate is a
fixed full-screen dialog over the mount point: a password door draws an
email/password <form> posting to loginUrl; otherwise a single button calls
bridge.loginWithPkce(), disabled with “no identity provider declared” when
there is no bridge; social buttons appear under either, one per
providers[] entry. It removes itself the moment a session appears and
re-runs the refused route. A returning visitor never sees the wall: the
bridge callback/restore and completeProviderSignIn both run before the gate
mounts. Gate text is Turkish regardless of locale. With a PKCE bridge a
floating “Oturum aç” pill is also mounted, hidden once signed in.
The gate protects the view. On PhiWebs hosting the apps-gateway Worker
also refuses pwpack/surfaces/*.receipt.json without a session that
GET …/session confirms (positive answers cached 60 s, negative never). On
your own static host there is no Worker: receipts are plain files, and the
only server-side enforcement is the API’s refusal of records.
Source: pwfabric-core/packages/runner/src/sign-in-gate.ts, …/login-button.ts,
…/run-app.ts,
PhiWebs apps-gateway Worker (closed source).
Self-host checklist
External issuer
- Register a public client (PKCE, no secret).
clientIdgoes inauth.json; the API expects audienceidentity.issuer.audience ?? clientId. - Register one redirect URI:
https://<host><app base>— the shell’s pathname under hash routing, the<base href>under path routing. LegacyredirectPathis honoured only if a callback already lands there. - The issuer’s discovery document and token endpoint must answer
cross-origin
fetchfrom your host, or stateauthorizeUrl/tokenUrlinauth.jsonto skip discovery. - Tokens must be RSA-signed (
RS256/384/512) withiss,aud,expand the claims named inidentity.claims. - If the app reads data, the API serving
/api/app/<w>/<p>/records/*must resolve the project’sidentityBindingwith ajwksUri— see Data and receipts.
Platform door
auth.jsonmust point at a reachable/api/app/<worldId>/<projectId>base; it is written fromPUBLIC_API_ORIGINat publish — if the API moves, rewrite the file.- The API must allow your origin for CORS with credentials and the
X-App-Sessionheader. The code allows an explicitCORS_ORIGINSlist plus anyhttps://*<APPS_HOST_SUFFIX>origin; an app on another domain is refused at preflight unless its origin is added. - Social buttons need
OAUTH_<GOOGLE|GITHUB|MICROSOFT>_CLIENT_IDand_CLIENT_SECRETon the API (Microsoft: secret optional,OAUTH_MICROSOFT_TENANT_IDdefaults tocommon), and the provider registration must list the app’sorigin + pathnameas a redirect URI. SetOAUTH_<P>_REDEEM=browserfor a SPA-type registration and republish. - Serve over HTTPS:
sessionStorageholds the raw session token.
Source: PhiWebs API (closed source): app.ts (CORS block),
…/services/oauth-providers.ts, pwfabric-core/packages/runner/src/auth.ts.
Unverified
Not confirmable from the code read for this page.
- No sign-out or register UI.
AuthBridge.logout(),signOut()andclearAppSession()have no caller in the runner;logoutUrlandregisterUrlare written toauth.jsonbut the shipped gate never posts to them. - External topology on PhiWebs hosting. Receipts are fetched with
appSession?.tokenonly — the PKCE bridge token is not sent — so a gated external app behind theapps-gatewayWorker would be refused its receipts. Whether such apps are published there was not checked. Irrelevant on a plain static self-host. sessionUrlon boot. The runner never callsGET …/session; validity is judged locally byexpiresAtuntil a request returns 401. What the runner does on that 401 beyond logging was not traced.- PKCE against the platform door.
buildAuthJsoncan emitissuer/clientId/authorizeUrl/tokenUrlfor the platform door, but no caller supplying them was found, and no OIDC discovery for app visitors exists in the files read. - Refresh-token rotation is the issuer’s policy; the runner only keeps the old refresh token when none is returned.