Private household SSO provider for two users. This is a standalone service using OAuth 2.1 / OIDC.
- API: Hono, TypeScript, Better Auth,
@better-auth/oauth-provider - DB: Drizzle schema on Postgres, using its own logical database named
auth - Web: Vite, React, TypeScript, Tailwind v4, shadcn-style UI primitives
- Package manager: pnpm
api/: Hono entrypoint, Better Auth config, Drizzle schema/migration, seed/reset scriptsweb/: single login page SPAdocker-compose.yml: base API/web services and shared platform networksdocker-compose.production.yml: server-side database secret mountdocs/production.md: production workflow and platform integration contract
This uses the current Better Auth OAuth 2.1 Provider plugin from @better-auth/oauth-provider, not the deprecated OIDC Provider plugin.
The provider is configured with:
loginPage: "/sign-in", served by the SPA fallback- scopes:
openid,profile,email,offline_access - trusted clients cached by ID:
finlens(the only registered client today) - no consent prompt for those trusted clients via seeded
skipConsent: true - generous central sessions and refresh tokens, controlled by env
ID tokens include Better Auth's stable sub plus explicit email and name custom claims. The UserInfo endpoint also returns email and name.
- For local development, create a logical Postgres database named
auth. Production database and role provisioning is owned bypior-labs/platform-deploy. - Copy
.env.exampleto.envand fill real application secrets, emails, and passwords. - Install dependencies with
pnpm installfrom this directory. - Apply the checked-in migration with
pnpm db:migrate. - Seed users and clients with
pnpm db:seed. - Run locally with
pnpm dev, or build and start the containers with Docker Compose.
Production uses the platform-generated connection file rather than a DATABASE_URL stored in GitHub. See docs/production.md.
Clients discover the provider through the OIDC discovery document, which is served under the Better Auth base path (not the domain root):
<issuer>/.well-known/openid-configuration
where <issuer> is <BETTER_AUTH_URL>/api/auth. The document advertises these endpoints, all rooted at the issuer:
authorization_endpoint:/oauth2/authorizetoken_endpoint:/oauth2/tokenuserinfo_endpoint:/oauth2/userinfojwks_uri:/jwks(ID tokens are signed with EdDSA; clients verify against this key set)end_session_endpoint:/oauth2/end-session
In production the issuer is https://auth.szarans.ca/api/auth. Platform Caddy routes /api/auth/* and /.well-known/* to the Auth API and serves the web container for /sign-in. The same auth.szarans.ca hostname is used on the trusted local network and over Tailscale; split-horizon DNS resolves it to the appropriate private address for each access path.
The intended local setup mirrors production's single front door: run both processes with pnpm dev. The Vite dev server on http://localhost:5173 serves the /sign-in SPA and proxies /api/* to the API on http://localhost:3000, so http://localhost:5173 is the local issuer origin.
- Ensure Postgres is running and the
DATABASE_URLdatabase exists. pnpm db:migratethenpnpm db:seed.pnpm dev(starts API on:3000and web on:5173).- Point the client app (e.g. FinLens running on
http://localhost:3001) at the discovery URLhttp://localhost:5173/api/auth/.well-known/openid-configuration.
The full authorization-code + PKCE flow (authorize -> /sign-in -> token -> userinfo, plus refresh_token) has been verified end to end against a local client on http://localhost:3001.
Baseline service checks:
pnpm typecheckpnpm buildGET /health, which returns{ "ok": true }once the API process starts.
Every app that uses this SSO provider must be registered as a trusted OAuth client. To add one (using a placeholder app name myapp):
-
Register the client secret env var.
- Add
MYAPP_CLIENT_SECRET=...to.env(and a placeholder line to.env.example). Generate withopenssl rand -base64 32. - Expose it in
api/src/env.ts:myappClientSecret: requiredEnv("MYAPP_CLIENT_SECRET", "change-me-myapp"),
- Add
-
Add the client to
api/src/oauth-clients.ts. Give it a stableclientId, its canonical productionuri, and the exact allowed callback URLs. For a Better Auth client app the callback path is/api/auth/oauth2/callback/<provider-id>; include the canonical production hostname and localhost so the same client works in local testing:{ clientId: "myapp", clientSecret: env.myappClientSecret, name: "MyApp", uri: "https://myapp.szarans.ca", redirectUris: [ "https://myapp.szarans.ca/api/auth/oauth2/callback/auth-pior", "http://localhost:3001/api/auth/oauth2/callback/auth-pior", ], },
trustedClientIdsis derived from this array, so no other code change is needed. -
Re-seed. Run
pnpm db:seed. The client is upserted withauthorization_code+refresh_tokengrants,client_secret_postauth, PKCE required, and consent skipped. The seed hashes the client secret (SHA-256, base64url) before storing it, because the provider verifies incoming secrets against the hashed form; storing plaintext would make every token exchange fail withinvalid_client. -
Configure the client app with the issuer/discovery URL (see above), the registered
clientIdand its plaintext secret, the exact callback URL, and scopesopenid profile email offline_access.
To rotate a secret, change the env value and re-run pnpm db:seed (the upsert re-hashes and overwrites). To retire a client, remove it from oauth-clients.ts and delete its row: DELETE FROM "oauthClient" WHERE "clientId" = 'myapp'; (removing it from the array alone does not delete the stored row).
The seed script creates exactly two users and upserts the trusted OAuth clients defined in api/src/oauth-clients.ts.
Required user env:
SEED_USER_1_EMAIL,SEED_USER_1_NAME,SEED_USER_1_PASSWORDSEED_USER_2_EMAIL,SEED_USER_2_NAME,SEED_USER_2_PASSWORD
Required OAuth client env (one per registered client):
FINLENS_CLIENT_SECRET
Seed command:
pnpm db:seedThere is no public forgot-password or email reset flow. Run the server-side script instead:
pnpm reset-password -- user@example.com 'new-password-here'If arguments are omitted, the script prompts for the email and password. It hashes with the same password hasher configured for Better Auth and updates the credential account row.
The only UI is the SPA login form. When Better Auth redirects an authorize request to /sign-in, submitting the form calls authClient.signIn.email(...); then it calls authClient.oauth2.continue({}) so the OAuth Provider plugin can complete the pending authorize flow and redirect back to the client app.
No signup UI, password reset UI, email sending, social login, MFA, admin UI, orgs, or client-app integration code is included.
The API intercepts /api/auth/oauth2/authorize?prompt=login, clears the central session cookie, and redirects back to the same authorize request with a marker. That forces the OAuth Provider plugin to send the browser to /sign-in even if a central session existed.