Appearance
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_atcolumns on the User model. - Rejected: Laravel signed URLs (no DB columns); separate
email_change_requeststable. - 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:
UpdateUserProfileActionignores email on self-updates; dedicatedPOST /profile/request-email-changefor 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:
RequestEmailChangeActionvalidates current password viaHasher::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 inEditUserFormModalfor 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
PasswordConfirmModalpattern). - Source: KD-0260
Password change field name: rename frontend, keep Laravel convention
- Chose: Rename frontend
passwordConfirmation→newPasswordConfirmationto match Laravel'sconfirmedrule. - 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
GithubServicefor repo OAuth. Two separate GitHub OAuth apps in production. - Rejected: Extend
GithubServiceto 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) withslug()/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
enumCollectionpattern 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 existingGithubConnectButton). - 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
OAuth: password optional, with set-password + unlink-protection safety net
- Chose: Users can choose OAuth as their sole login method. Profile shows "Set Password" if
user.passwordis 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
Redis sessions: rely on subdomain cookie isolation, no per-tenant prefix
- 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 clearsuser.valueso the auth guard allows navigation to the challenge page. - Rejected: Set
canSeeWhenLoggedIn: trueon the challenge route; hardwindow.locationredirect; special-case the auth guard for 2FA. - Why: Mirrors the existing
handleSessionExpiredpattern. 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_2fato 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_atcolumn. - Why: Security policy changes should take immediate effect. Idle tabs would otherwise stay in a broken zombie state. Mirrors existing
UpdatePasswordActionprecedent. - 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_tokenon 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_tokennaming. - Source: KD-0569
Tenant verification: dedicated Action, not embedded in LoginUserAction or middleware
- Chose: New
EnsureTenantVerifiedActioncalled fromAuthController::loginbeforeLoginUserAction. - 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 amodeparam (claimvsverify). - Why: Different moments need different copy ("set your password" vs "verify your email"). Generalizing couples two flows and forces an
@ifladder 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,60keyed 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
$variantenum onWelcomeUserthat 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
@ifladder; future copy changes risk leaking between flows. - Source: KD-0576
Welcome email constructor: drop $inviterName and $appName for new-tenant
- Chose:
WelcomeNewTenantconstructor is(User $user, string $inviteUrl)— subject and body hardcode "Kendo". - Rejected: Mirror
WelcomeUsersignature (drop-in shape); keepappNamefor 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
appNameis 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()insideCreateTenantAction; letTenantAwareQueueinjecttenantIdcorrectly. - Rejected: Mirror KD-0563 D2 by replacing
User $useronWelcomeUserwithstring $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 isCreateTenantActionviolating its dispatch-time invariant, not the typing. - Source: KD-0579
TenantAwareQueue: JobQueueing listener as runtime safety net
- Chose: Hook the
Illuminate\Queue\Events\JobQueueingevent; reflect on the job (andSendQueuedMailable->mailable) to detect tenant-connection Eloquent models with no active tenant context, throwMissingTenantContextException. - Rejected:
Queue::createPayloadUsingparsing serialized PHP (fragile); architecture test parsing PHP forMail::send/dispatch(calls inside Actions (false positives + false negatives). - Why:
JobQueueingfires 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 CustomExceptioninapp/Exceptions/. - Rejected:
extends \RuntimeException(semantic match for developer error). - Why: 40/40 codebase convention — every dedicated exception extends
CustomException. PlusCustomException::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 whenAPP_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_countto the existingGET /two-factor/statusresponse (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