Skip to content

Sign in with any OpenID provider, instead of Firebase - #157

Open
monperrus wants to merge 3 commits into
TeoSlayer:mainfrom
monperrus:feat/oidc-any-provider
Open

monperrus wants to merge 3 commits into
TeoSlayer:mainfrom
monperrus:feat/oidc-any-provider

Conversation

@monperrus

Copy link
Copy Markdown
Contributor

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-configuration when 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_ID still 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.

Variable Purpose
VITE_OIDC_ISSUER, VITE_OIDC_CLIENT_ID compiled into the client
OIDC_ISSUER, OIDC_AUDIENCE what the API checks; both default to the VITE_ values
OIDC_JWKS_URI optional, discovered from the issuer otherwise
FIREBASE_PROJECT_ID legacy shorthand, still accepted

The browser is the half that changed

firebase/auth is replaced by oidc-client-ts doing authorization code with PKCE (-1083 lines 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, /signup and /reset 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.

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-minute auth_time check 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-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. X-Frame-Options on documents becomes SAMEORIGIN for the same reason. The API's own responses are untouched and still send DENY.

Deployment

app/docker-compose.yml, .env.example, wrangler.jsonc and both workflows take the new variables. render-deploy-config.mjs now requires OIDC_ISSUER + OIDC_AUDIENCE or FIREBASE_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_ISSUER and VITE_OIDC_CLIENT_ID, and a public PKCE-S256 client at the provider whose redirect URI is <WEB_ORIGIN>/auth/callback and whose web origins list <WEB_ORIGIN> — the second is what lets renewal work.

Checks

npm run typecheck, npm test (887 passing), npm run lint and npm run build all pass in app/, plus node scripts/test-landing-seo.mjs at 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

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>
monperrus and others added 2 commits September 15, 2026 06:58
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant