Skip to content

Auth Decisions

Distilled from 8 DECISIONS.md files. Each entry is a real fork in the road — what we chose, what we passed on, and why. Implementation details that aren't a tradeoff are NOT here (read the codebase for those).


Email change: token columns on User vs separate table or signed URLs

  • Chose: Token-based with pending_email, email_change_token, email_change_token_expires_at columns on the User model.
  • Rejected: Laravel signed URLs (no DB columns); separate email_change_requests table.
  • Why: The invite flow already uses an in-DB token pattern — consistency across the codebase beats avoiding two columns. A user can only have one pending email change at a time, so a separate table is over-engineered.
  • Source: KD-0260

Email change: admin updates apply immediately, only self-update verifies

  • Chose: UpdateUserProfileAction ignores email on self-updates; dedicated POST /profile/request-email-change for the verified flow.
  • Rejected: Admin email changes also require verification (consistent but blocks admin workflows).
  • Why: Issue scopes to "eigen e-mailadres" and admins shouldn't have to verify emails of users they manage.
  • Source: KD-0260

Email change: require current password at request time

  • Chose: RequestEmailChangeAction validates current password via Hasher::check() before issuing the verification email.
  • Rejected: Rely solely on the verification email + notification to old address; password prompt at verification time.
  • Why: An attacker with a hijacked session could otherwise complete request + verify before the real user reads the notification. Password-at-request stops the attack at the earliest point and matches GitHub/Google.
  • Source: KD-0260

Email change: dedicated modal, not a conditional field in the edit modal

  • Chose: New "Change Email" button on Profile → dedicated ChangeEmailModal (new email + current password). Email field hidden in EditUserFormModal for self-edits.
  • Rejected: Conditional password field inside the shared edit modal when email is dirty; sequential modals (edit → password confirm).
  • Why: Conditional fields modify a component admins also use without the password, and security-sensitive actions should be visually distinct (matches the existing 2FA PasswordConfirmModal pattern).
  • Source: KD-0260

Password change field name: rename frontend, keep Laravel convention

  • Chose: Rename frontend passwordConfirmationnewPasswordConfirmation to match Laravel's confirmed rule.
  • Rejected: Change backend to accept password_confirmation (would fight the Laravel convention and require custom validation).
  • Why: Aligns with Laravel convention and the central app already does this correctly — fighting the framework adds no value.
  • Source: KD-0314

OAuth: Socialite separate from existing GithubService

  • Chose: Use Laravel Socialite for login OAuth; keep the custom GithubService for repo OAuth. Two separate GitHub OAuth apps in production.
  • Rejected: Extend GithubService to also handle login.
  • Why: The two flows have fundamentally different scopes (repo access vs login identity) and zero shared logic. Coupling them through one OAuth app would mix permissions.
  • Source: KD-0341

OAuth: int-backed enum with slug methods

  • Chose: OAuthProviderEnum: int (Google=0, GitHub=1) with slug()/fromSlug() methods for Socialite/API/URL representation.
  • Rejected: String-backed enum matching Socialite's driver names directly.
  • Why: Every other backend enum is int-backed; the frontend enumCollection pattern uses {value, slug}. Consistency with project patterns beats matching one library's expected input.
  • Source: KD-0341

OAuth: cross-subdomain handoff via exchange token, not direct login

  • Chose: Callback mints a one-time token (1-min cache), redirects to <subdomain>.kendo.dev/auth/oauth-complete?token=..., frontend POSTs to exchange for a session.
  • Rejected: Log the user in directly during the callback and rely on a shared session cookie.
  • Why: Session cookies are scoped domain: null (exact host), so a session set on the callback domain is invisible to the tenant subdomain. Exchange token works regardless of cookie domain config.
  • Source: KD-0341

OAuth: identity matching by (provider, provider_id), never by email

  • Chose: Match users by (provider, provider_id) only. Linking step is required before OAuth login works.
  • Rejected: Match by email so users can log in immediately without explicit linking.
  • Why: Email matching means anyone with a matching email could log in — a clear security risk. It also blocks linking personal OAuth accounts to work Kendo accounts.
  • Source: KD-0341

OAuth: hybrid redirect (login/invite) + popup (settings)

  • Chose: Login and invite use a full-page redirect; settings linking uses a popup with postMessage (mirrors existing GithubConnectButton).
  • Rejected: Popup everywhere (consistency, but blocked by mobile/popup blockers); redirect everywhere (settings would lose page state).
  • Why: Login/invite have nothing to preserve, so a redirect is fine. Settings has live page state worth keeping. Two patterns is acceptable cost.
  • Source: KD-0341
  • Chose: Users can choose OAuth as their sole login method. Profile shows "Set Password" if user.password is null. Last OAuth provider can't be unlinked without first setting a password.
  • Rejected: Always require a password (simpler, but forces users to remember a credential they may never use).
  • Why: Better UX for OAuth-first users. The unlink protection prevents the only real edge case (lockout from removing last login method).
  • Source: KD-0341
  • Chose: All sessions in one Redis namespace; isolation comes from subdomain-scoped cookies (browser never sends a central cookie to a tenant subdomain).
  • Rejected: Separate Redis prefixes per subdomain (runtime middleware switching); separate Redis DB numbers (capped at 16).
  • Why: Session IDs are unique per cookie, cookies are scoped per subdomain, so collision is impossible. Production already runs this way — proves the simple approach works.
  • Source: KD-0375

2FA cross-tab fix: clear user state in interceptor, not change route meta

  • Chose: New handleTwoFactorRequired() in auth service clears user.value so the auth guard allows navigation to the challenge page.
  • Rejected: Set canSeeWhenLoggedIn: true on the challenge route; hard window.location redirect; special-case the auth guard for 2FA.
  • Why: Mirrors the existing handleSessionExpired pattern. Changing route meta breaks semantics — logged-in users could navigate to the challenge page even when not needed. Hard redirect loses SPA state.
  • Source: KD-0424

2FA enforcement change: kill sessions, don't tolerate zombies

  • Chose: When admin flips enforce_2fa to true on a role, delete sessions for all users in that role.
  • Rejected: Do nothing on backend (rely solely on frontend interceptor); add a force_reauth_at column.
  • Why: Security policy changes should take immediate effect. Idle tabs would otherwise stay in a broken zombie state. Mirrors existing UpdatePasswordAction precedent.
  • Source: KD-0424

2FA cross-tab: scope is interceptor + session kill only — no BroadcastChannel

  • Chose: Frontend interceptor fix + backend session invalidation. BroadcastChannel deferred.
  • Rejected: Original plan's BroadcastChannel for cross-tab challenge-page sync; all three at once.
  • Why: Testing revealed users were stuck on normal pages, not challenge pages — BroadcastChannel between challenge pages doesn't fix this. Ship the actual bug fix.
  • Source: KD-0424

Tenant verification: token columns on tenants, not on users or signed URLs

  • Chose: Three new columns on tenants (verified_at, verification_token, verification_token_expires_at).
  • Rejected: Reuse users.invite_token on first user (conflates "claim password" with "verify email"); Laravel signed URL with no DB token.
  • Why: Verification is a tenant-level state, not per-user. Login gate checks one column on the workspace, not per-user. Mirrors existing users.email_change_token naming.
  • Source: KD-0569

Tenant verification: dedicated Action, not embedded in LoginUserAction or middleware

  • Chose: New EnsureTenantVerifiedAction called from AuthController::login before LoginUserAction.
  • Rejected: Embed the check inside LoginUserAction; middleware on the login route.
  • Why: Conflating "credentials valid" with "workspace active" mixes concerns and exceptions. Middleware would have to call into the central DB — middleware-shaped code with Action-shaped concerns. ADR-0011 keeps business logic in Actions.
  • Source: KD-0569

Tenant verification: bespoke VerifyTenantEmail mailable, not reuse of WelcomeNewTenant

  • Chose: New App\Mail\VerifyTenantEmail + new blade.
  • Rejected: Reuse WelcomeNewTenant; generalize it with a mode param (claim vs verify).
  • Why: Different moments need different copy ("set your password" vs "verify your email"). Generalizing couples two flows and forces an @if ladder in the blade. Project precedent: every distinct moment has its own mailable.
  • Source: KD-0569

Tenant verification: no auto-login, redirect to tenant login with prefilled email

  • Chose: After verify, 302 to <subdomain>.kendo.dev/auth/login?verified=1&email=<email>. User re-enters their just-set password.
  • Rejected: Cross-subdomain auto-login via Guard::login (cookie-domain edge cases); one-time exchange code (heavy for the value).
  • Why: Avoids cross-subdomain session bridging entirely (sidesteps a known staging Sanctum bug). One extra password entry is minor friction; the exchange-code path adds two endpoints, a code table, replay protection.
  • Source: KD-0569

Tenant verification: rate limit keyed on IP+email, not IP alone

  • Chose: Limit::perHour(5)->by($ip.':'.$email) named limiter on resend.
  • Rejected: Inline throttle:5,60 keyed on IP only; per-tenant limiter keyed on subdomain.
  • Why: IP-only is bypassed by IP-rotated bots. Per-tenant requires looking up the tenant inside the limiter callback. IP+email blocks both axes — same IP can't spam many emails, same email can't be hit from many IPs.
  • Source: KD-0569

Welcome email: bespoke WelcomeNewTenant, not a $variant flag on WelcomeUser

  • Chose: New App\Mail\WelcomeNewTenant + dedicated blade for the admin-bootstrapped tenant flow.
  • Rejected: Add a $variant enum on WelcomeUser that branches subject/body in the existing template.
  • Why: Project's per-flow mailable convention (every distinct user-facing message lives as its own mailable). Variant flags conflate distinct messages and force an @if ladder; future copy changes risk leaking between flows.
  • Source: KD-0576

Welcome email constructor: drop $inviterName and $appName for new-tenant

  • Chose: WelcomeNewTenant constructor is (User $user, string $inviteUrl) — subject and body hardcode "Kendo".
  • Rejected: Mirror WelcomeUser signature (drop-in shape); keep appName for whitelabel/non-prod.
  • Why: The recipient's inviter is institutional ("Kendo on its own behalf"), not a teammate — surfacing the central admin's name would feel arbitrary. Kendo isn't whitelabeled, so appName is unused indirection.
  • Source: KD-0576

Welcome email subject: hardcoded, not templated

  • Chose: "Your Kendo workspace is ready" literal.
  • Rejected: "Your {appName} workspace is ready" (templated); "{InviterName} set up your Kendo workspace" (human angle).
  • Why: No templating means no inviter/appName dependencies (aligned with the constructor decision above). The institutional voice matches how the recipient actually meets the brand.
  • Source: KD-0576

Tenant-bound queued mailables: leverage TenantAwareQueue, don't refactor to primitives

  • Chose: Wrap the dispatch in switchTo($tenant)/reset() inside CreateTenantAction; let TenantAwareQueue inject tenantId correctly.
  • Rejected: Mirror KD-0563 D2 by replacing User $user on WelcomeUser with string $email, string $firstName.
  • Why: Whack-a-mole — every future queued mailable typing a tenant model would hit the same trap. The codebase already owns TenantAwareQueue (a primitive built specifically so jobs can carry tenant-bound models safely); the bug is CreateTenantAction violating its dispatch-time invariant, not the typing.
  • Source: KD-0579

TenantAwareQueue: JobQueueing listener as runtime safety net

  • Chose: Hook the Illuminate\Queue\Events\JobQueueing event; reflect on the job (and SendQueuedMailable->mailable) to detect tenant-connection Eloquent models with no active tenant context, throw MissingTenantContextException.
  • Rejected: Queue::createPayloadUsing parsing serialized PHP (fragile); architecture test parsing PHP for Mail::send/dispatch( calls inside Actions (false positives + false negatives).
  • Why: JobQueueing fires before serialization with the unwrapped object — clean reflection scan. Lives in the same Laravel namespace family as the existing listeners (JobProcessing/JobProcessed/JobFailed).
  • Source: KD-0579

MissingTenantContextException: extends CustomException, not \RuntimeException

  • Chose: final class MissingTenantContextException extends CustomException in app/Exceptions/.
  • Rejected: extends \RuntimeException (semantic match for developer error).
  • Why: 40/40 codebase convention — every dedicated exception extends CustomException. Plus CustomException::render() returns a JSON 500 if the exception bubbles into an HTTP response, preventing Laravel's default debug page from leaking the stack trace to the central admin's browser when APP_DEBUG=true. (Caught by plan-reviewer agent before implementation.)
  • Source: KD-0579

2FA token-revoke warning: count strategy differs per flow — exact count only when one query away

  • Chose: Self-confirm flow adds active_token_count to the existing GET /two-factor/status response (one Passport count query, co-located with a check already in the page lifecycle); enforcement flow shows newly-enforced role names from frontend state with qualitative copy, no count.
  • Rejected: A dedicated count endpoint per flow; a new enforcement preview endpoint returning affected_user_count; qualitative-only everywhere.
  • Why: The self-confirm count is a single cheap query on an endpoint the page already hits, so surfacing it is nearly free; the enforcement count requires a nested cross-user query (new route + Action + test) whose payoff is marginal — role names the component already knows are more actionable than a raw number anyway.
  • Source: KD-0869

2FA destructive warnings: confirmation modal for the irreversible flow, inline callout for the informative one

  • Chose: Enforcement Save (which revokes tokens across many users) gets a confirmation modal listing newly-enforced roles; self-confirm setup gets an inline callout on the QR step, no acknowledgment gate.
  • Rejected: Inline callout for both (no explicit acknowledgment of the destructive consequence); a confirmation step/modal for both.
  • Why: A modal matches Kendo's established destructive-action pattern (LaneRemoveModal/LabelRemoveModal) and fits the irreversible cross-user revocation; the self-confirm warning is forward-looking policy info, not a gate, so an extra acknowledgment step is friction in an already multi-step setup.
  • Source: KD-0869