Conversation
Firebase Authentication is the one part of a self-hosted deployment that still needs an account with somebody. It turns out to be a thin dependency. The server never used the Firebase admin SDK: it verified ID tokens as ordinary RS256 JWTs against Google's published keys, with the issuer and audience spelled the Firebase way. So the verifier now takes those three as configuration, and finds the key set from the issuer's OpenID configuration when it is not told one. Discovery is deferred to the first token rather than done at boot, because the app and the provider start together and one refusing to start because the other was still starting is a worse failure than a first request that waits. A failed discovery is not cached, so a provider that was briefly down does not need a restart to recover. FIREBASE_PROJECT_ID still works, as shorthand for the issuer, audience and key set it always stood for, so an existing deployment keeps verifying the tokens it already issued while it migrates. The browser is the half that changed. Firebase's SDK is replaced by oidc-client-ts doing authorization code with PKCE, and the session is mapped onto the same handful of fields the app already read from a Firebase user --- uid, email, display name, verified, a token getter --- so the account, consent and invitation screens did not have to care. What did have to change is that this app no longer holds passwords. Setting one, resetting one, registering, verifying an address: all of that belongs to whoever stores the password, and doing it here would mean storing it here. So the sign-up and reset screens are gone, their paths redirect to /login, and sign-in is one button that hands over to the provider, which shows a Register link exactly when it accepts registrations --- a truer answer than anything this app could infer from its own configuration. Deleting an account asks the provider for a fresh sign-in, in a popup with prompt=login, because the screen that asks has typed confirmation in it and a redirect would throw that away. The service is then emptied as before. The identity itself is not deleted from here: a public PKCE client has no standing to remove a user and OpenID Connect gives it no way to ask, so it is removed where it lives, which is also where it can be removed from every other application that uses it. The document policy is built from the configured issuer rather than from a list of Google hostnames, and frame-ancestors relaxes from 'none' to 'self': silent renewal loads this app's own callback in a hidden iframe, and 'none' would have ended every session at its token's expiry. The API's own responses still send DENY. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL read the account object as sensitive because it carried a token getter, and so read the uid written to localStorage --- which scopes this browser's remembered tabs to one account, and has always been written there --- as storing a credential in clear text. It was right about the shape if not the leak. Nothing outside oidc.ts ever called that getter: api.ts reads the token from the manager at the moment of the request, which is the only way to get one that a silent renew has not replaced. So the credential and the identity are separate now, and what the app hands around is identity alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CodeQL classifies anything named for authentication as a credential, so the uid this object hands to the tab store still read as storing a secret in clear text even after the token getter came out of it. The object carries no credential now, and its name should say what it holds rather than where it came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Firebase Authentication is the one part of a self-hosted
app/that still needs an account with somebody. It turns out to be a thin dependency, and taking it out makes the app work against Keycloak, Authelia, Zitadel, Dex, Auth0 or Google without a line of code changing.The server barely moves
It never used the Firebase admin SDK: it verified ID tokens as ordinary RS256 JWTs against Google's published keys, with the issuer and audience spelled the Firebase way. So the verifier now takes issuer, audience and key set as configuration, and discovers the key set from the issuer's
/.well-known/openid-configurationwhen it is not given one.Discovery is deferred to the first token rather than done at boot: the app and the provider start together in a compose stack, and one refusing to start because the other was still starting is a worse failure than a first request that waits. A failed discovery is not cached, so a provider that was briefly down does not need a restart.
FIREBASE_PROJECT_IDstill works, as shorthand for the issuer, audience and key set it always stood for — an existing deployment keeps verifying the tokens it already issued while it migrates.VITE_OIDC_ISSUER,VITE_OIDC_CLIENT_IDOIDC_ISSUER,OIDC_AUDIENCEVITE_valuesOIDC_JWKS_URIFIREBASE_PROJECT_IDThe browser is the half that changed
firebase/authis replaced byoidc-client-tsdoing authorization code with PKCE (-1083lines of lockfile). The session is mapped onto the same handful of fields the app already read from a Firebase user — uid, email, display name, verified, a token getter — so the account, consent, vault and invitation screens did not have to care.What did have to change: this app no longer holds passwords. Setting one, resetting one, registering, verifying an address all belong to whoever stores the password. So the sign-up and reset screens are gone,
/signupand/resetredirect to/login, and sign-in is one button that hands over to the provider — which shows a Register link exactly when it accepts registrations, a truer answer than anything this app could infer from its own configuration.The one visible loss is the terms/privacy checkbox that sign-up carried. Worth a decision before merging: it can come back as a one-time consent screen after the first sign-in, in a follow-up, if you want the record kept.
Account deletion
It asks the provider for a fresh sign-in, in a popup with
prompt=login, because the screen that asks has typed confirmation in it and a redirect would throw that away. The service is then emptied exactly as before, and the server's ten-minuteauth_timecheck still applies.The identity itself is not deleted from here: a public PKCE client has no standing to remove a user, and OpenID Connect gives it no way to ask. It is removed where it lives, which is also where it can be removed from every other application using it. The screen says so.
Headers
The document policy is built from the configured issuer instead of a list of Google hostnames, and
frame-ancestorsrelaxes from'none'to'self': silent renewal loads this app's own callback in a hidden iframe, and'none'would have ended every session at its token's expiry.X-Frame-Optionson documents becomesSAMEORIGINfor the same reason. The API's own responses are untouched and still sendDENY.Deployment
app/docker-compose.yml,.env.example,wrangler.jsoncand both workflows take the new variables.render-deploy-config.mjsnow requiresOIDC_ISSUER+OIDC_AUDIENCEorFIREBASE_PROJECT_ID, and fails naming what is missing rather than rendering a Worker that refuses every call.Deploying this needs two new repository variables,
VITE_OIDC_ISSUERandVITE_OIDC_CLIENT_ID, and a public PKCE-S256 client at the provider whose redirect URI is<WEB_ORIGIN>/auth/callbackand whose web origins list<WEB_ORIGIN>— the second is what lets renewal work.Checks
npm run typecheck,npm test(887 passing),npm run lintandnpm run buildall pass inapp/, plusnode scripts/test-landing-seo.mjsat the root. New tests cover the verifier (issuer, audience, discovery,email_verified,auth_time), the configuration reader including the Firebase fallback, and the policy builder.Running against Keycloak 26 at https://auth.gakoy.com since 2026-09-11.
🤖 Generated with Claude Code