feat: accept invites via OAuth (Google/GitHub), not just passkeys#1868
feat: accept invites via OAuth (Google/GitHub), not just passkeys#1868afonsojramos wants to merge 3 commits into
Conversation
🦋 Changeset detectedLatest commit: 5600977 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
All contributors have signed the CLA ✍️ ✅ |
There was a problem hiding this comment.
The approach is the right one for EmDash: carrying the invite token through OAuth state lets an invited user accept via Google/GitHub while preserving per-email control and the passkey path. The implementation is generally well-scoped and follows repo conventions — Lingui-wrapped UI strings, RTL-safe Tailwind, no raw SQL, a clear changeset, and unit tests for the success/failure modes of acceptInviteViaOAuth.
I found three issues worth addressing before merge:
-
Invite-token single-use invariant is not always enforced. When
acceptInviteViaOAuthfinds an existing user (or OAuth account) for the invite email, it links the account and returns without deleting the invite token. The comment calls this “single-use,” but in this branch the token is left valid. A race with passkey-accept, an admin-created user, or a retried callback can land here, leaving a live invite that can still be validated on the invite-accept page. The fix is simple: delete the token after linking. -
New invite-accept OAuth buttons inherit the admin basepath bug. You already call this out in the PR description (#1720 dependency), but it means the UI added here is not actually usable without that fix: Kumo’s
LinkProviderin the admin SPA treats the absolute/_emdash/api/auth/oauth/...href as a TanStack Router path and prepends/_emdash/admin, producing the doubled/_emdash/admin/_emdash/api/auth/oauth/...URL. Either fold a LinkProvider fix in here or ensure #1720 lands first. -
Unverified provider email gives a misleading message.
consumer.tsthrowsinvite_email_mismatchfor both “email not verified” and “email does not match,” andcallback.tsmaps the code to “This invite was sent to a different email address…”. Users with an unverified provider email will get the wrong message. Consider a distinctinvite_email_unverifiedcode and mapping.
None of these are security regressions, but #1 is a data-integrity / invariant issue and #2 means the user-facing half of the feature is broken in this branch.
|
I have read the CLA Document and I hereby sign the CLA |
…erified-email error Addresses review on emdash-cms#1868: - acceptInviteViaOAuth now deletes the invite token in the existing-user and existing-account branches, preserving the single-use guarantee. - Unverified provider email throws a new invite_email_unverified code with its own callback message, instead of the misleading email-mismatch message.
There was a problem hiding this comment.
Pull request overview
Adds OAuth-based invite acceptance to EmDash so invited users can complete their invite by signing in with a configured OAuth provider (Google/GitHub) rather than being limited to passkey registration. This extends the existing OAuth consumer flow to optionally complete an invite during the callback and updates the admin invite-accept UX to present OAuth options.
Changes:
- Carry an optional invite token through the OAuth initiation/state store and complete invites during the OAuth callback when present.
- Add admin UI support for accepting invites via provider login buttons (Google/GitHub) on the invite-accept page.
- Add unit tests covering invite acceptance via OAuth (including replay protection and email verification/mismatch cases) and publish a changeset.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/tests/unit/auth/oauth-invite.test.ts | New unit tests validating OAuth-based invite completion behavior and error cases. |
| packages/core/src/auth/providers/google-admin.tsx | Adds inviteToken support to the Google OAuth login button href. |
| packages/core/src/auth/providers/github-admin.tsx | Adds inviteToken support to the GitHub OAuth login button href. |
| packages/core/src/auth/oauth-state-store.ts | Persists/rehydrates inviteToken in stored OAuth state. |
| packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts | Maps new invite-related OAuth error codes to user-facing messages. |
| packages/core/src/astro/routes/api/auth/oauth/[provider].ts | Reads ?invite= and passes it into createAuthorizationUrl options. |
| packages/auth/src/oauth/types.ts | Extends OAuthState with optional inviteToken documentation and typing. |
| packages/auth/src/oauth/consumer.ts | Implements acceptInviteViaOAuth and routes invite flows from handleOAuthCallback. |
| packages/auth/src/index.ts | Exports acceptInviteViaOAuth from the auth package public API. |
| packages/admin/src/lib/auth-provider-context.tsx | Updates provider LoginButton typing to accept an optional inviteToken. |
| packages/admin/src/components/InviteAcceptPage.tsx | Renders configured provider buttons (with invite token) under an “Or continue with” divider. |
| .changeset/oauth-acceptable-invites.md | Declares a minor release across affected packages with release notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This is the right change for EmDash: carrying the invite token through OAuth state lets invited users accept via Google/GitHub while preserving per-email control and leaving the passkey path intact. The implementation is well-scoped and follows repo conventions — Lingui-wrapped UI strings, RTL-safe Tailwind, no raw SQL, a clear changeset, and focused unit tests for acceptInviteViaOAuth.
Re-checking against the prior review:
- Invite-token single-use invariant — fixed.
acceptInviteViaOAuthnow deletes the invite token in every success path: retried callback (existingAccount), pre-existing user (existingUser), and fresh invite completion (completeInvite). The new test verifies the token cannot be replayed after linking to a pre-existing account. - Unverified-email messaging — fixed. The code now throws a distinct
invite_email_unverifiedOAuthError, and the callback maps it to a user-friendly message. - Admin basepath bug — still open. The OAuth buttons added to the invite-accept page still use Kumo
LinkButtonwithoutexternal, so in the admin SPA they get routed through TanStack Router and produce the doubled/_emdash/admin/_emdash/api/auth/oauth/...URL. This is the same inherited issue the PR description already calls out as depending on #1720; I am re-flagging it below because the new invite UI is affected.
I did not find any new logic bugs, SQL-safety issues, authorization gaps, or missing locale filters. The backend invite-completion path is solid and tested.
An invited user can complete their invite by signing in with a configured OAuth provider. The invite token is carried through the OAuth state, and the callback completes the invite (creating the user with the invited role and linking the account) only when the provider-verified email matches the invited address. The invite-accept page renders the configured providers under an 'Or continue with' divider.
…erified-email error Addresses review on emdash-cms#1868: - acceptInviteViaOAuth now deletes the invite token in the existing-user and existing-account branches, preserving the single-use guarantee. - Unverified provider email throws a new invite_email_unverified code with its own callback message, instead of the misleading email-mismatch message.
…n, changeset - acceptInviteViaOAuth: in the already-linked branch, require the linked user's email to match the invite before consuming, so an identity whose email changed after linking can't consume someone else's invite. - oauth/[provider]: validate the ?invite= token shape/length before persisting to the (unauthenticated) OAuth state store. - changeset: rewrite as a single user-facing sentence.
9731675 to
5600977
Compare
What does this PR do?
Adds OAuth-acceptable invites: an invited user can complete their invite by signing in with a configured OAuth provider (Google/GitHub) instead of only registering a passkey.
Today invites are passkey-only (
invite/completerequires a WebAuthn credential), and OAuth only logs in existing users or self-signs-up whole email domains viaallowed_domains. There was no way to invite one specific person who then joins via social login without hand-writing ausersrow. This closes that gap while keeping per-email control.How it works
state(OAuthState.inviteToken).createAuthorizationUrlaccepts an optionalinviteToken; the start route reads?invite=<token>and the state store persists it.handleOAuthCallbackdelegates to the new exportedacceptInviteViaOAuth(...). It validates the invite, requires the provider-verified email to match the invited address (case-insensitive), then completes the invite (creating the user with the invited role) and links the OAuth account. Unverified/mismatched email and invalid/expired tokens are rejected (invite_email_mismatch/invite_invalid).LoginButtongained an optionalinviteTokenprop).Related discussion: #1859 (filed, awaiting maintainer feedback).
#1720 has merged and this branch is rebased on it. The invite/login buttons keep using the shared provider
LoginButton; #1720'snormalizeAdminHrefnow returns""for/_emdash/api/...paths (covered bynormalize-admin-href.test.ts), so the absolute OAuth href is no longer routed through TanStack Router and navigates correctly (same tab). Noexternalprop is used — that would forcetarget="_blank", which is wrong for a login redirect.Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain.AI-generated code disclosure
Screenshots / test output
New tests —
packages/core/tests/unit/auth/oauth-invite.test.ts(8 tests): matching verified email completes the invite (invited role applied, OAuth account linked, single-use token consumed); case-insensitive email match; mismatched email rejected (no user created); unverified provider email rejected with a distinct code; the invite is consumed when linking to a pre-existing account; the already-linked branch rejects (without consuming) when the linked user's email differs from the invite; invalid/unknown token rejected; errors surface asOAuthError.Full-repo
pnpm typecheck,pnpm lint(oxlint --type-aware --deny-warnings), andpnpm format:checkall pass. Existing@emdash-cms/auth(57) and core auth (225) suites still pass.