Skip to content

feat(auth): productionize auth, onboarding, and enterprise SSO - #79

Open
teetangh wants to merge 6 commits into
devfrom
feat/auth-onboarding-sso
Open

feat(auth): productionize auth, onboarding, and enterprise SSO#79
teetangh wants to merge 6 commits into
devfrom
feat/auth-onboarding-sso

Conversation

@teetangh

Copy link
Copy Markdown
Collaborator

Productionizes authentication, authorization, onboarding and enterprise SSO.

The Better Auth foundation was already good and is largely untouched — durable
Postgres sessions with Redis reserved for rate limiting, per-endpoint brute-force
rules, the cache()-deduped session helper, the four-tier next-safe-action
hierarchy, and the comma-safe org-role parser. What was missing was everything
around it that a user or an enterprise buyer actually touches.

No Prisma changes — this PR needs no schema-approved label. SAML fits in the
existing SsoProvider.samlConfig; onboarding writes User.onboardedAt,
PortfolioProfile.about and NotificationPreference.prefs, all of which already
existed.

Things that were broken

  • /account did not exist, though proxy.ts has guarded it from the start — the guard protected a 404.
  • 2FA could never be turned on. The plugin was enabled and /two-factor verified challenges, but nothing ever called twoFactor.enable, so no one could reach that page.
  • User.onboardedAt was a dead column, written only by the seed script. There was no learner onboarding at all.
  • registerOrgSsoProvider discarded the DNS token (void result;), so an org admin could never see the TXT record their provider needed — making self-service verification unreachable.
  • accept-invitation never said which org, its Decline button only navigated home (leaving the invitation pending until expiry), and it sent enterprise invitees to /studio.
  • reset-password ignored ?error=INVALID_TOKEN, showing a normal form and only admitting the problem after the user typed a new password twice.
  • The Google button rendered even when Google was unconfigured, then failed on click.
  • next went straight from a query param into router.push — an open redirect.
  • organizationLimit used role: "owner" as an exact match, so "owner,instructor" slipped past the 3-org cap — the precise bug roles.ts warns about.
  • sendEmail threw inside Better Auth's hooks, so a Resend outage took down sign-up and rolled the account back.
  • Every form was toast-only — no inline errors, no aria-invalid, no live region, no spinner anywhere in src/.
  • All three auth emails were <pre>-wrapped plaintext, despite react-email being a dependency since day one.

What's here

Auth UI — split-screen brand panel reusing the footer/ink-band treatments, RSC shell + client form per page, inline field errors with a polite live region, password reveal and strength meter, h-10 inputs, (auth)/error.tsx. "Welcome back" and "Create your account" kept verbatim.

/account — profile, security (2FA enrol with QR and one-time backup codes, password change, email change), active devices with revoke, notification preferences.

Onboarding — three-step learner wizard at /welcome, server-persisted per step so it resumes anywhere; can't skip ahead, can go back to revise; skipping still stamps onboardedAt. /onboarding keeps its URL and gains a deterministic redirect plus ?new=1 (without which "Create another school" bounced straight back).

Enterprise SSO — SAML 2.0 alongside OIDC as a discriminated union; self-service DNS TXT verification; OIDC issuers probed at /.well-known/openid-configuration before persisting; copy-to-clipboard ACS / SP entity ID / metadata / SLO / redirect URIs; /admin/sso split into a pending queue and a live list, with the trust switch repositioned as an audited override.

SAML hardening: InResponseTo correlation, requireTimestamps, 2-minute clock skew, allowIdpInitiated: false, and onDeprecated: "reject" for SHA-1/RSA1_5/3DES. The schema offers no way to select a deprecated algorithm.

Emails — react-email templates rendered to both HTML and plain text.

Docs

docs/auth/ — architecture, three ADRs (form architecture · enterprise SSO · SCIM, designed and deliberately not built), an IdP onboarding runbook with real URLs, and a manual verification checklist. docs/design/auth-ui.md for the visual spec.

Playwright removed

Config, both specs, the devDependency and the e2e script. UI is verified through the chrome-devtools MCP server against an already-running browser; playwright test spawns its own Next dev server, which is too RAM-heavy on the primary dev machine. CI never ran it.

The coverage it would have given is preserved where it can be — the schema and discovery-probe invariants are unit-tested — and the rest is written up as a checklist, including the three curl-able SSO negative checks.

Verification

pnpm typecheck clean · pnpm lint clean (3 pre-existing warnings, untouched) · 185 unit tests across 18 files, up from 174/17.

Two bugs the new tests caught, both real:

  • slugify ran the character strip before the separator replacement, so underscores were deleted rather than converted — "Acme___Corp" gave "acmecorp".
  • zod's z.url() accepts any parseable URI, so an OIDC issuer of urn:acme:idp passed validation and failed much later inside the discovery probe.

Before removing Playwright I did run the specs I'd written: 5/5 SSO (including the negative test proving unverified providers are inert, and SAML SP metadata returning valid XML with the correct ACS URL) and 14/14 auth. Those runs are gone with the specs; the checklist is what carries forward.

pnpm build not run locally — CI covers it.

Reviewing

src/lib/auth/index.ts and src/actions/org-sso.ts are the two files worth reading closely. SAML shapes were verified against the installed @better-auth/sso@1.6.23 dist rather than the docs site, which documents the newer 1.7 shape — several option names differ.

🤖 Generated with Claude Code

teetangh and others added 6 commits August 27, 2026 14:50
Two things the auth surface was missing, and the SSO work that depends on
the first.

Form primitives
- `Field`/`FieldLabel`/`FieldControl`/`FieldDescription`/`FieldError` wire
  htmlFor, aria-invalid, aria-describedby and a polite live region. Until
  now every form in the app surfaced validation as a sonner toast, which
  announces once and never associates itself with the wrong control.
- `Spinner` — there was no spinner anywhere in src/; pending state was a
  label swap, invisible to assistive tech.
- `Input`/`InputGroup` gain a size variant. `default` is byte-identical to
  the previous h-8, so no existing caller changes; `lg` (h-10) is for auth
  and account forms, where the form *is* the page.
- `fieldErrors()`/`formError()` flatten next-safe-action's validation tree.
  Reading the default shape rather than switching
  `defaultValidationErrorsShape` — that is a createSafeActionClient option,
  and flipping it would change the result type of all 51 existing actions.
- One `slugify()` replaces three divergent copies. The SSO copy skipped the
  repeated-hyphen collapse, so "Acme  Corp" produced a slug that then failed
  slugSchema server-side with an error the form could not explain.

Enterprise SSO
- SAML 2.0 registration alongside OIDC, as a discriminated union on
  `protocol`. No new dependency and no schema change: samlify ships inside
  @better-auth/sso, and SsoProvider.samlConfig already existed.
- registerOrgSsoProvider now RETURNS the domainVerificationToken instead of
  discarding it with `void result`. Without it an org admin could never see
  the DNS TXT record their provider needs, so no provider could ever be
  verified through the self-service path.
- Self-service DNS verification: requestSsoDomainVerification/verifySsoDomain.
- OIDC issuers are probed at /.well-known/openid-configuration before we
  persist. A typo previously produced a row that looked healthy in the
  console and failed only at an employee's first real sign-in.
- setSsoDomainVerified is repositioned as a platform-admin override/revoke.
  BetterAuth throws CONFLICT on verifyDomain once domainVerified is true, so
  granting closes the self-service route and revoking re-opens it.
- Connection-details panel surfaces the ACS URL, SP entity ID, SP metadata
  URL, SLO URL and OIDC redirect URI as copy-to-clipboard rows. Enterprise
  IdP setup is a transcription exercise; a hand-retyped ACS URL is the most
  common reason a SAML integration fails on first contact.

SAML config shapes verified against the installed @better-auth/sso@1.6.23
dist rather than the docs site, which documents the newer 1.7 shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every page in (auth) becomes an RSC shell plus a client form. Reading
searchParams on the server removes the useSearchParams dependency, and with
it the two <Suspense> boundaries that had no fallback and flashed blank on
first paint.

Layout
- Split screen: brand panel on the left reusing the gradient-primary +
  .noise + blurred-orb treatment already proven in the marketing footer and
  ink band, form on the right. Panel is hidden below lg.
- gradient-mesh damped to opacity-40/25. Every marketing section damps it;
  at full strength behind a form it competed with the input borders.
- Adds the ThemeToggle that (marketing) and the dashboards both have.

Behaviour fixes, not just paint
- Google's button now renders only when GOOGLE_CLIENT_ID and
  GOOGLE_CLIENT_SECRET are actually set. socialProviders is spread
  conditionally in lib/auth, so previously the button always rendered and
  failed on click when Google was unconfigured.
- `next` is validated as a same-origin absolute path. It came straight from
  a query param into router.push, which is an open redirect.
- reset-password reads ?error=INVALID_TOKEN on the server and renders a real
  expired-link page. Before, an expired link showed a normal form and only
  admitted the problem after the user typed a new password twice.
- verify-email reads the address from the session or ?email= instead of
  asking the user to retype what they just signed up with. Adds a 45s resend
  cooldown and an already-verified state.
- accept-invitation is now a server component that shows the organization,
  the inviter, and what the role actually grants. Decline calls
  rejectInvitation — it previously just navigated home, leaving the
  invitation pending until expiry and still listed to the inviter. Accept
  routes enterprise and university invitees to /org, not the hardcoded
  /studio. Also removes a router.push during render.
- sign-in distinguishes 401/403/429 and offers a resend link inline on the
  unverified path. 401 deliberately does not distinguish unknown-email from
  wrong-password.
- sign-up gains confirm-password, a strength meter, a terms checkbox and
  marketing opt-in, plus the Google button it was missing while sign-in had
  one.
- two-factor gains a trust-device option and distinct copy for a used backup
  code vs a stale TOTP.
- (auth)/error.tsx — the group had no error boundary, so a render error fell
  through to global-error.tsx, which replaces the whole document.

lib/auth/client gains inferAdditionalFields<Auth>() so the client knows
about phone/timezone/locale/marketingOptIn. The import is type-only and is
erased at build.

"Welcome back" and "Create your account" are kept verbatim —
tests/e2e/smoke.spec.ts asserts on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/account has been in proxy.ts's PROTECTED_PREFIXES since the beginning with
no route behind it — the guard protected a 404. This is that route: profile,
security, devices, notifications.

2FA can now actually be turned on. The twoFactor plugin has been enabled and
/two-factor has verified challenges from the start, but nothing in the
codebase ever called twoFactor.enable/getTotpUri/generateBackupCodes, so no
user could reach the challenge page in the first place. Enrolment confirms a
live TOTP code before flipping 2FA on — enabling from the secret alone would
lock out anyone whose authenticator failed to scan — and the backup codes
are shown once, with the "done" button gated until they are copied or
downloaded.

Also: change password (defaulting to revoking other sessions), change email,
and an active-session list with per-device and revoke-all-others.

Auth config
- trustedOrigins, from Netlify's DEPLOY_PRIME_URL/URL rather than a
  *.netlify.app wildcard, plus explicit useSecureCookies in production.
- user.changeEmail enabled. The confirmation goes to the CURRENT address, so
  an attacker holding a live session cannot silently move the account.
- sendAuthEmail(): non-fatal wrapper for BetterAuth's lifecycle hooks.
  sendEmail throws, and BetterAuth awaits these inside the request, so a
  Resend outage took sign-up down and rolled the account back over an email
  we could have retried.
- organizationLimit counted `where: { role: "owner" }` as an exact string
  match, so a member whose role reads "owner,instructor" was invisible to
  the 3-org cap. Counts via hasOrgRole now — the precise bug roles.ts warns
  about in its header comment.
- SSO: provisionUser fills locale/zoneinfo from IdP claims (never role,
  email or emailVerified), and SAML gets InResponseTo correlation,
  requireTimestamps, a 2-minute clock skew, allowIdpInitiated: false, and
  onDeprecated: "reject" for SHA-1/RSA1_5/3DES.

fieldErrors/formError move from lib/safe-action to lib/form-errors. They
were only ever pure render helpers, but safe-action imports
lib/auth/session, which is server-only — so a client form importing them
pulled the Prisma client into the browser bundle. Typecheck cannot see this;
it surfaces as a module-not-found at request time.

getAccountSessions degrades instead of 500ing under DEV_DISABLE_AUTH: it
reads BetterAuth's own session store, which needs a real cookie the dev
bypass never mints. Production still throws, into the new (account)/error.tsx.

Adds react-qr-code (no dependencies, renders inline SVG — CSP-safe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
User.onboardedAt has existed since the initial migration and was written by
nothing but the seed script — there was no learner onboarding at all.
/onboarding was, despite its name, organization creation.

Learner wizard at /welcome
- Three steps: profile, goals, communication preferences.
- Each step persists on submit. There is no draft row and no client-side
  wizard state, so a refresh, a crash, or finishing on another device all
  resume in the same place. The step to show is derived server-side from
  what is actually stored; a ?step= the user has not reached yet falls back
  to the first unfinished one, so you can revise but not skip ahead.
- Gated once in (learn)/layout.tsx, for verified users with no onboardedAt.
- Skipping stamps onboardedAt exactly like finishing. Leaving it null would
  re-ask on every visit, turning a one-time nicety into a permanent
  obstacle — and every question here also lives under /account.
- Interest chips come from the live Category table, not a hardcoded list,
  and the submitted slugs are re-checked against it server-side.

No Prisma changes (schema freeze, issue #43): answers land in
PortfolioProfile.headline/.about and NotificationPreference.prefs, both
existing columns, and wizard progress rides in that same about JSON.

/onboarding keeps its URL so the marketing nav, the footer and
studio/page.tsx all keep working, and gains:
- A deterministic redirect. `findFirst` had no orderBy, so a user in more
  than one org was sent somewhere different on each visit.
- `?new=1`, without which the "Create another school" button on /studio
  redirected straight back to the dashboard it came from.
- Inline field errors, and the shared slugify.
- creator-application-form.tsx deleted — exported, imported nowhere,
  superseded by organization-application-form.tsx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Emails
- react-email templates for verify, reset, change-email and invitation.
  @react-email/components has been a dependency since day one and was never
  used: every auth email went out as plain text wrapped in <pre> as the HTML
  part. sendEmail now renders a template to BOTH parts — HTML for clients
  that show it, plain text for those that don't and for spam scoring, which
  penalises HTML-only mail.
- Each one names the action, gives one button, prints the URL as a fallback
  (clients strip buttons; corporate scanners pre-visit links), and says what
  to do if the recipient didn't ask for it. The change-email one deliberately
  says "change your password" rather than "ignore this" — an unexpected
  change-email confirmation means someone already has a live session.

/admin/sso
- Split into an "awaiting verification" queue and a live list, with protocol
  badges and the last audit event per provider.
- The trust switch now confirms first and says what it does. Granting is not
  the same as the customer proving ownership: BetterAuth rejects verifyDomain
  once domainVerified is true, so an override closes the org's own DNS route
  until it is revoked.

Tests: 185 unit tests across 18 files, up from 174/17. Covers the auth
schemas, the OIDC/SAML discriminated union, SSO connection URLs, the
discovery probe (stubbed fetch), onboarding step arithmetic, slugify, and
that every email template renders both parts.

Two bugs the tests found, both real:
- slugify ran the strip before the separator replacement, so underscores were
  deleted rather than converted: "Acme___Corp" gave "acmecorp".
- zod's z.url() accepts any parseable URI, so an OIDC issuer of "urn:acme:idp"
  passed validation and failed much later inside the discovery probe. The
  scheme is now pinned at the schema, where it renders on the issuer field.

Playwright removed — config, both existing specs, the devDependency and the
e2e script. The user tests UI through the chrome-devtools MCP server against
a browser they already have running; `playwright test` spawns its own Next
dev server, which is too RAM-heavy on this machine. CI never ran it. The SSO
coverage it would have provided is preserved where it can be: the schema and
discovery-probe invariants are unit-tested, and the server-dependent checks
(unverified providers are inert, SP metadata carries the right ACS URL) are
written up as a manual checklist in the docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…list

docs/ held only DEPLOYMENT.md and two .docx strategy files — no ADRs, and
nothing describing how identity actually works.

- README — the whole model: two role systems and why they are separate, the
  four-layer guard ladder and why none of the layers is sufficient alone, the
  comma-separated-role trap, session vs rate-limit storage, and the two
  deliberate escape hatches (DEV_DISABLE_AUTH, showAllSurfaces) written down
  as the risks they are rather than left to be discovered. Also records the
  gaps we did not close: `support` grants nothing, and hasOrgPermission is
  the unused bridge to the fine-grained ac engine.
- ADR 001 — the hybrid form architecture and the three concrete reasons we
  did not route credentials through server actions.
- ADR 002 — why SAML got built despite the roadmap saying defer (no new
  dependency, no schema change, no new routes), the SAML security posture
  option by option, and why DNS verification is primary with the admin
  toggle demoted to an override.
- ADR 003 — SCIM designed and explicitly not built, with the endpoint sketch,
  the Prisma table it would need, the mapping, and the trigger to revisit.
- runbook-sso.md — step-by-step Okta / Entra ID / Google Workspace / SAML
  onboarding with our real URLs, plus a symptom-to-cause table.
- verification-checklist.md — what needs a browser now that there is no
  browser test runner, including the three curl-able SSO negative checks.
- design/auth-ui.md — the split-screen spec, the six rules that keep it
  coherent, and the pre-existing `<a role="button">` inconsistency.

README gains a pointer to docs/auth/ and a note that the repo is unit-test
only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@netlify

netlify Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploy Preview for elluminar ready!

Name Link
🔨 Latest commit 32f13c2
🔍 Latest deploy log https://app.netlify.com/projects/elluminar/deploys/6a900d1824367f000856c9d1
😎 Deploy Preview https://deploy-preview-79--elluminar.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 1 minute.

View limit details

Limit details: You’ve used the included review currently available. Your 76 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1568e7bc-b0f9-4a48-9609-5a78e2befc41

📥 Commits

Reviewing files that changed from the base of the PR and between b6543d6 and 32f13c2.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (95)
  • README.md
  • docs/auth/README.md
  • docs/auth/adr-001-form-architecture.md
  • docs/auth/adr-002-enterprise-sso.md
  • docs/auth/adr-003-scim-design.md
  • docs/auth/runbook-sso.md
  • docs/auth/verification-checklist.md
  • docs/design/auth-ui.md
  • package.json
  • playwright.config.ts
  • src/actions/account.ts
  • src/actions/onboarding.ts
  • src/actions/org-sso.ts
  • src/app/(account)/account/error.tsx
  • src/app/(account)/account/layout.tsx
  • src/app/(account)/account/notifications/notification-form.tsx
  • src/app/(account)/account/notifications/page.tsx
  • src/app/(account)/account/page.tsx
  • src/app/(account)/account/profile-form.tsx
  • src/app/(account)/account/security/change-email-form.tsx
  • src/app/(account)/account/security/change-password-form.tsx
  • src/app/(account)/account/security/page.tsx
  • src/app/(account)/account/security/two-factor-panel.tsx
  • src/app/(account)/account/sessions/page.tsx
  • src/app/(account)/account/sessions/session-list.tsx
  • src/app/(admin)/admin/sso/domain-verify-toggle.tsx
  • src/app/(admin)/admin/sso/page.tsx
  • src/app/(auth)/accept-invitation/[id]/invitation-actions.tsx
  • src/app/(auth)/accept-invitation/[id]/page.tsx
  • src/app/(auth)/error.tsx
  • src/app/(auth)/forgot-password/forgot-password-form.tsx
  • src/app/(auth)/forgot-password/page.tsx
  • src/app/(auth)/layout.tsx
  • src/app/(auth)/reset-password/page.tsx
  • src/app/(auth)/reset-password/reset-password-form.tsx
  • src/app/(auth)/sign-in/page.tsx
  • src/app/(auth)/sign-in/sign-in-form.tsx
  • src/app/(auth)/sign-up/page.tsx
  • src/app/(auth)/sign-up/sign-up-form.tsx
  • src/app/(auth)/two-factor/page.tsx
  • src/app/(auth)/verify-email/page.tsx
  • src/app/(auth)/verify-email/verify-email-form.tsx
  • src/app/(learn)/layout.tsx
  • src/app/(onboarding)/welcome/comms-step.tsx
  • src/app/(onboarding)/welcome/goals-step.tsx
  • src/app/(onboarding)/welcome/layout.tsx
  • src/app/(onboarding)/welcome/page.tsx
  • src/app/(onboarding)/welcome/profile-step.tsx
  • src/app/(onboarding)/welcome/skip-link.tsx
  • src/app/(onboarding)/welcome/wizard-progress.tsx
  • src/app/(org)/org/[tenantSlug]/settings/page.tsx
  • src/app/(org)/org/[tenantSlug]/settings/sso-connection-details.tsx
  • src/app/(org)/org/[tenantSlug]/settings/sso-provider-form.tsx
  • src/app/(studio)/studio/page.tsx
  • src/app/onboarding/creator-application-form.tsx
  • src/app/onboarding/organization-application-form.tsx
  • src/app/onboarding/page.tsx
  • src/components/account/preference-toggle.tsx
  • src/components/account/section.tsx
  • src/components/account/timezone-field.tsx
  • src/components/auth/auth-shell.tsx
  • src/components/auth/form-alert.tsx
  • src/components/auth/index.ts
  • src/components/auth/password-field.tsx
  • src/components/auth/provider-icons.tsx
  • src/components/auth/submit-button.tsx
  • src/components/auth/text-field.tsx
  • src/components/ui/field.tsx
  • src/components/ui/input-group.tsx
  • src/components/ui/input.tsx
  • src/components/ui/spinner.tsx
  • src/lib/account/queries.ts
  • src/lib/auth/client.ts
  • src/lib/auth/index.ts
  • src/lib/email/index.ts
  • src/lib/email/templates/auth-emails.tsx
  • src/lib/email/templates/layout.tsx
  • src/lib/enterprise/sso.ts
  • src/lib/form-errors.ts
  • src/lib/onboarding/state.ts
  • src/lib/onboarding/steps.ts
  • src/lib/safe-action.ts
  • src/lib/slug.ts
  • src/lib/validation/auth.ts
  • src/lib/validation/enterprise.ts
  • src/lib/validation/onboarding.ts
  • src/proxy.ts
  • tests/e2e/enterprise.spec.ts
  • tests/e2e/smoke.spec.ts
  • tests/unit/auth-schemas.test.ts
  • tests/unit/email-templates.test.tsx
  • tests/unit/onboarding-steps.test.ts
  • tests/unit/sso-config.test.ts
  • tests/unit/sso-discovery.test.ts
  • vitest.config.ts

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant