Skip to content

beta.30 integration report: DB/app clock skew invalidates new sessions, composed persistence rejects OAuth, and more #183

Closed

Description

@Makisuo

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 issuedAt from the database clock (clock.engineNowMillis → clock_timestamp(); auth-persistence/src/internal/session-native-stateful.ts).
  • Checking: validateSessionTimeline compares it against the process clock (DateTime.now) and rejects issuedAt > 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.make that uses a contract with the same name, so the session module IDs and Context keys 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 used OpenIdConnect.provider({ protocol: "oidc", issuer: "https://accounts.google.com", … }) instead.
  • GitHub.provider({ scopes }) isn't accepted by the published ProviderOptions.

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. register returns RegistrationAccepted without 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?
  • trustedOrigins is HTTPS-only. The primary origin allows http://localhost, but trustedOrigins uses httpsOrigin (http/auth-http.ts ~L249). A local web app on http://localhost:3000 calling an API on http://localhost:3003 can't be configured; startup fails with OperationHttpConfigurationError { reason: "origin" }. Allowing loopback origins in trustedOrigins (the same rule as origin) would fix local dev.
  • No CORS from Http.make. OPTIONS on auth routes returns 404, although OperationHttpServer contains 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.
  • Auth service scope. The Auth service is only provided under http.middleware. Calling auth.verifySession(...) from a route outside it dies with Service 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions