Skip to content

auth: signInOAuth locks out ALL OAuth users (existing & new) when ALLOW_REGISTRATION=false #363

@techxpert99

Description

@techxpert99

Summary

When ALLOW_REGISTRATION=false, every OAuth sign-in attempt is rejected with Registrations are not allowed — including for users who already have a fully populated User + Account record.
Self-hosted installs that want to lock down sign-ups end up locking out their entire existing user
base the moment those users' sessions expire. There is no documented escape hatch.

This is not just "new sign-ups are blocked" — it is a hard lockout for the whole install.

Why this is severe

The current population of OAuth users on any locked-down self-hosted instance is on borrowed time:

  • Can't sign back in via OAuthsignInOAuth rejects them before the IdP redirect (see Root
    cause below).
  • Can't sign in via email/password — they have no Account row with provider='email';
    signInEmail calls getUserAccount(email, provider: 'email')
    (packages/db/src/services/user.service.ts) and returns "User does not exist".
  • Can't recover via password resetrequestResetPassword (packages/trpc/src/routers/auth.ts,
    ~L526) also looks up provider='email' only. For OAuth-only users it silently returns true
    without sending an email (anti-enumeration). The user just sees "if your email exists, we sent a
    link" — but no link ever arrives.

Result: the only thing keeping any existing OAuth user signed in is their session cookie. The moment
it expires (or they log out), they are permanently locked out without manual DB intervention by an
operator.

Repro

  1. Self-host with ALLOW_REGISTRATION=false, ALLOW_INVITATION=true.
  2. Have at least one user who signed up via OAuth (GitHub or Google).
  3. Sign that user out.
  4. Click "Sign in with Google" / "Sign in with GitHub".
  5. tRPC immediately returns:
    {
      "message": "Registrations are not allowed",
      "code": "UNAUTHORIZED",
      "path": "auth.signInOAuth"
    }
    No redirect to the IdP happens.
    

This also reproduces for invited users post-first-login: they accept the invite once, get an account,
sign out, and can never come back — the invite link is consumed/one-time, so on return their
signInOAuth call has no inviteId and is blocked too.

Root cause

packages/trpc/src/routers/auth.ts (signInOAuth, ~L95) runs getIsRegistrationAllowed() upfront, but at
this point the server has no identity for the caller — the user hasn't hit the IdP yet, so we can't
distinguish a returning user from a new sign-up. The gate is binary and rejects everyone equally.

signInOAuth: publicProcedure
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
.mutation(async ({ input, ctx }) => {
const isRegistrationAllowed = await getIsRegistrationAllowed(input.inviteId);
if (!isRegistrationAllowed) {
throw TRPCAccessError('Registrations are not allowed'); // <-- blocks existing users too
}
...

The check must happen after we've fetched the OAuth profile and looked up whether a user already
exists.

Proposed fix

Defer the registration check to the OAuth callback, where we already branch on handleExistingUser vs
handleNewUser.

  1. In packages/trpc/src/routers/auth.ts, remove the getIsRegistrationAllowed call from signInOAuth.
    (Keep it in signUpEmail — that's an explicit sign-up.)
  2. In apps/api/src/controllers/oauth-callback.controller.tsx, inside handleNewUser (just before
    db.user.create), gate creation on the same policy:
    if (!(await isNewUserRegistrationAllowed(inviteId))) {
    throw new LogError(
    'Registrations are not allowed. Ask an admin for an invite.',
    { oauthUser, providerName, inviteId },
    );
    }
  3. isNewUserRegistrationAllowed is the existing getIsRegistrationAllowed logic (env-driven,
    invite-aware) — either export it from @openpanel/db (preferred, since it does db.user.count() /
    db.invite.findUnique) or inline a copy in the controller.

This keeps existing semantics for the ALLOW_REGISTRATION / ALLOW_INVITATION env vars but moves
enforcement to the only point where we actually know whether a user is new.

Acceptance criteria

With ALLOW_REGISTRATION=false, ALLOW_INVITATION=true:

  • Existing OAuth user clicks "Sign in with Google/GitHub" → completes OAuth → signed back in (no
    error).
  • Invited user signs up via OAuth once, signs out, and can sign back in afterwards without holding
    the invite link.
  • New user with valid invite cookie → user created, attached to inviting org.
  • New user with no invite → redirected to /login?error=Registrations+are+not+allowed.... No User row
    created.
  • First-time bootstrap (zero users in DB) still works: the first OAuth sign-in creates the seed user.

With ALLOW_REGISTRATION=true: behavior unchanged (anyone can sign up).

Workarounds (for operators stuck on this today)

None of these are good. Listing for completeness:

  1. Manually INSERT Account rows with provider='email' + a hashed password for every existing user via
    direct DB access, then issue password resets. Painful and per-user.
  2. Temporarily flip ALLOW_REGISTRATION=true to let existing users back in — but this also lets any
    internet stranger sign up while the flag is on.
  3. Keep extending session cookie TTL indefinitely (just delays the inevitable).

Notes

  • Email sign-up (signUpEmail) is unaffected — the upfront check there is correct because the request
    itself declares "I'm creating an account".
  • Same fix applies to GitHub OAuth and Google OAuth — both share signInOAuth and the callback
    dispatcher.
  • Adjacent UX nit (separate issue worth filing): the OAuth buttons on _login.login.tsx are
    unconditionally rendered. Self-hosters who want to disable an OAuth provider have to unset the env
    vars, which leaves the buttons visible and they fail at the IdP with invalid_client_id instead of
    being hidden.

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