Repository navigation
beta.30 integration report: DB/app clock skew invalidates new sessions, composed persistence rejects OAuth, and more #183
Description
We evaluated @yielded/auth@0.1.0-beta.30 as a Clerk replacement for an existing app (Effect 4.0.1, Bun, PostgreSQL 17 via @effect/sql-pg). The setup was GitHub/Google OAuth registration and sign-in, passkeys, and stateful sessions mapped onto an existing users table. We tested end to end over HTTP against real Postgres, with only the provider network endpoints simulated.
Most of it worked well. Below is everything we hit, ordered by severity. Happy to share the repro package.
1. Bug: a fresh stateful session is rejected as SessionInvalid when the DB clock is ahead of the app clock
The two halves of the session timestamp check use different clocks:
- Stamping: the SQL session persistence sets
issuedAtfrom the database clock (clock.engineNowMillis→clock_timestamp();auth-persistence/src/internal/session-native-stateful.ts). - Checking:
validateSessionTimelinecompares it against the process clock (DateTime.now) and rejectsissuedAt > now(auth/src/sessions/policy.ts, ~L77).
With local Docker Postgres a few ms ahead of the host, a session read immediately after sign-in fails. We instrumented the check and saw issuedAt - now of +9 ms and +13 ms. getSession() returns null and verifySession() fails with SessionInvalid. It's intermittent, depending on skew and timing, which made it look like a flaky integration.
In production the DB and the app servers are always on different machines. On Cloudflare Workers Date.now() only advances on I/O, which makes this worse.
Repro: stateful sessions with SQL persistence, Postgres clock slightly ahead (anything >0 ms), sign in, then read the session in the next request.
Suggestion: allow a small clock tolerance for issuedAt > now (and the related verifiedAt / authenticatedAt comparisons), or take all session times from a single clock.
2. The composed SQL persistence rejects any Auth containing an OAuth strategy
AuthPersistence.make(auth).layer fails at first use with PersistenceConfigurationError("Use explicit services for strategies without composed persistence support") (auth-persistence/src/internal/persistence.ts, ~L251–260). For OAuth strategies feature === undefined, so a common setup isn't supported: OAuth sign-in plus passkeys with stateful SQL sessions. The error surfaces at runtime only as an opaque SessionUnavailable → OAuthUnavailable from completeSignIn (see 6).
Our workaround:
- Build the composed layer from a second, passkey-only
Auth.makethat uses a contract with the same name, so the session module IDs andContextkeys are identical. - Supply OAuth storage separately via
OAuthPersistence.
It works, but it relies on keys being derived from strings, which is an internal detail. The documented alternative means reproducing roughly 360 lines of passkey-managed.ts with the makeNative* factories.
Suggestion: let the composed layer ignore strategies whose persistence is supplied explicitly (OAuth via OAuthPersistence), or document an official way to combine the two.
3. Subject id column type: generated SQL fails with uuid = text
When the subjects table's id is uuid (very common for existing apps), the OAuth sign-in read joins it against the subjectId columns of the identity, login and authority tables. If those columns are text, as in every example, Postgres errors with operator does not exist: uuid = text. Making every subjectId column uuid fixes it.
The mapping still declares them as type: "text", and storage validation only checks column names and unique keys, so nothing catches this at startup.
Suggestion: document this, cast in the generated joins, or add a uuid column type, and consider validating column types.
4. The docs describe APIs that aren't in 0.1.0-beta.30
Google/Google.provider/Google.GoogleUserProfile(guide/google, guide/oauth, the login-server example) aren't exported from the published package. We usedOpenIdConnect.provider({ protocol: "oidc", issuer: "https://accounts.google.com", … })instead.GitHub.provider({ scopes })isn't accepted by the publishedProviderOptions.
If the docs track main, a version banner or a beta publish would help.
5. GitHub sign-in can't obtain a private email address
The GitHub provider's identity source is only GET /user (oauth/github/protocol.ts), and sign-in scopes can't be configured (see 4). Users with a private email come back with no email in OAuthDisplayProfile, so registration can't provision a user with a required email column.
Suggestion: an option to request user:email and read the primary verified address from /user/emails.
6. Failures are very hard to diagnose
Internal failures are collapsed to OAuthUnavailable / SessionUnavailable / SessionInvalid with no cause. That's understandable for the client, but nothing reaches the server either. Effect.fn spans also weren't observable when serving through HttpRouter.toWebHandler with a custom Tracer, though that could be on our side. To find 1 and 2 we had to patch dist in node_modules and log at each *.make({}) site.
Suggestion: an opt-in server-side diagnostic hook, or a debug log level that reports the reason (configuration error, timeline check, stale authority, …), without changing the redacted public error.
7. Smaller items
- New users sign in twice.
registerreturnsRegistrationAcceptedwithout a session, so after registering the client has to restart the provider flow and do a second round-trip. An option to issue the session on successful registration would make the UX much smoother. Is this a deliberate security choice? trustedOriginsis HTTPS-only. The primaryoriginallowshttp://localhost, buttrustedOriginsuseshttpsOrigin(http/auth-http.ts~L249). A local web app onhttp://localhost:3000calling an API onhttp://localhost:3003can't be configured; startup fails withOperationHttpConfigurationError { reason: "origin" }. Allowing loopback origins intrustedOrigins(the same rule asorigin) would fix local dev.- No CORS from
Http.make.OPTIONSon auth routes returns 404, althoughOperationHttpServercontains preflight handling. If that's intended, a short snippet in the HTTP guide showing the CORS config needed for cross-origin clients would help: credentials,content-type,x-effect-auth-csrf. Authservice scope. TheAuthservice is only provided underhttp.middleware. Callingauth.verifySession(...)from a route outside it dies withService not found: <namespace>. It would be worth documenting, or exposing a way to verify credentials outside the HTTP middleware, e.g. for RPC servers or WebSocket handshakes.
Thanks for the library. The contract, client and atom design is excellent, and once wired the OAuth and session flows behaved exactly as documented.
Activity
fix(persistence): compose explicit strategies and validate SQL joins
fix(persistence): keep fallback authentication snapshots coherent