Self-hostingVisitor sign-in

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:

TopologyWho runs the loginWhat the runner doesToken
externalan OIDC issuer the app namesfull-page PKCE redirect to the issuerthe issuer’s JWT
platformthe API at the app’s own /api/app/… baseposts email + password, or a social PKCE code, to that basean 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).

StepRequestParameters
Discovery (only if an endpoint is missing)GET {issuer}/.well-known/openid-configurationreads authorization_endpoint, token_endpoint; both required
Authorizeredirect to authorizeUrlresponse_type=code, client_id, redirect_uri, scope (space-joined), state, code_challenge, code_challenge_method=S256
ExchangePOST tokenUrl (form-encoded)grant_type=authorization_code, code, redirect_uri, client_id, code_verifier
RefreshPOST 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() renders

redirect_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/appBody / headersResponse
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/logoutx-app-session or bearer200 { data: { signedOut: true } }; idempotent
GET /:worldId/:projectId/sessionx-app-session or bearer200 { 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 session

Social 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

  1. Register a public client (PKCE, no secret). clientId goes in auth.json; the API expects audience identity.issuer.audience ?? clientId.
  2. Register one redirect URI: https://<host><app base> — the shell’s pathname under hash routing, the <base href> under path routing. Legacy redirectPath is honoured only if a callback already lands there.
  3. The issuer’s discovery document and token endpoint must answer cross-origin fetch from your host, or state authorizeUrl/tokenUrl in auth.json to skip discovery.
  4. Tokens must be RSA-signed (RS256/384/512) with iss, aud, exp and the claims named in identity.claims.
  5. If the app reads data, the API serving /api/app/<w>/<p>/records/* must resolve the project’s identityBinding with a jwksUri — see Data and receipts.

Platform door

  1. auth.json must point at a reachable /api/app/<worldId>/<projectId> base; it is written from PUBLIC_API_ORIGIN at publish — if the API moves, rewrite the file.
  2. The API must allow your origin for CORS with credentials and the X-App-Session header. The code allows an explicit CORS_ORIGINS list plus any https://*<APPS_HOST_SUFFIX> origin; an app on another domain is refused at preflight unless its origin is added.
  3. Social buttons need OAUTH_<GOOGLE|GITHUB|MICROSOFT>_CLIENT_ID and _CLIENT_SECRET on the API (Microsoft: secret optional, OAUTH_MICROSOFT_TENANT_ID defaults to common), and the provider registration must list the app’s origin + pathname as a redirect URI. Set OAUTH_<P>_REDEEM=browser for a SPA-type registration and republish.
  4. Serve over HTTPS: sessionStorage holds 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() and clearAppSession() have no caller in the runner; logoutUrl and registerUrl are written to auth.json but the shipped gate never posts to them.
  • External topology on PhiWebs hosting. Receipts are fetched with appSession?.token only — the PKCE bridge token is not sent — so a gated external app behind the apps-gateway Worker would be refused its receipts. Whether such apps are published there was not checked. Irrelevant on a plain static self-host.
  • sessionUrl on boot. The runner never calls GET …/session; validity is judged locally by expiresAt until a request returns 401. What the runner does on that 401 beyond logging was not traced.
  • PKCE against the platform door. buildAuthJson can emit issuer/clientId/authorizeUrl/tokenUrl for 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.