The auth UI: eight screens, zero frontend wiring
Part 4 of the Be Your Own Identity Provider series. Part 3 covered the relying party.
Part 2 ended on a deliberate hole: the server package owns every authentication flow
but ships no views at all. Bind nothing and the first request to /login throws
MissingAuthViewException.
That’s a defensible design and a terrible out-of-the-box experience, which is what the third package is for.
Eight contracts, eight pages
bambamboole/laravel-oidc-ui binds all eight view contracts the server declares —
LoginView, RegisterView, PasswordResetRequestView, PasswordResetView,
EmailVerificationView, PasswordConfirmationView, TwoFactorChallengeView, and
ConsentView — to Lattice pages. Login, registration,
password reset, email verification, password confirmation, the 2FA challenge, and the
OAuth consent screen, as real React, described in PHP.
The bindings happen in register(), which is the only reason overriding one is
pleasant. Every provider’s register() completes before any boot() runs, so a bind
in your provider always wins:
$this->app->bind(LoginView::class, CustomLoginPage::class);
One contract, one screen. Every other page keeps the package’s default, and nothing
gets forked. The consent page isn’t a special case — it’s the same seam, which was
the whole point of routing it through a contract in part 2 instead of Passport’s
authorizationView().
The part I like: no frontend step
Back in Part 10 of the Lattice series I wrote about
distributing a Lattice component as a plain Composer package — the PHP class and its
React renderer in one dependency, no npm publish, because Lattice’s Vite plugin scans
vendor/composer/installed.json and compiles the package’s TSX straight into the
consuming app’s bundle.
This package is the first real thing I built on that. Its composer.json declares:
"extra": {
"lattice": {
"discover": ["src"],
"plugin": "resources/js/plugin.ts"
}
}
discover makes Lattice find the package’s attributed classes — pages, actions, the
#[AsLayout('auth')] auth layout — without the host app touching
config('lattice.discover'). plugin hands its React components to the app’s bundle.
So the install is composer require bambamboole/laravel-oidc-ui, and there is no
second step. No npm package, no import to add, no plugin array to edit, no publishing
of stubs — the frontend arrives with the backend, versioned together, because it is
the same artifact. Writing a package that consumed the mechanism was also the best test
of it: a plugin distribution that only ever gets used by demo components proves nothing.
Passkeys need actual browser APIs
Most of these screens are ordinary forms, which Lattice already describes in PHP. Two
things aren’t: the WebAuthn ceremonies. navigator.credentials.create() and
.get() can’t be expressed as a schema — you need real client-side code at exactly the
right moment in the flow.
So the package ships two custom Lattice components, oidc.passkey-registration and
oidc.passkey-verify, through the same plugin entry. They’re the escape hatch working
as intended: describe the 95% in PHP, drop to React for the part that genuinely lives
in the browser, and let both travel in one Composer package.
Building blocks, not just screens
The auth flow screens are the obvious half. The other half is the part every app has to build itself: a security section in the settings page. That one can’t be a package-owned route — it lives inside your app’s layout, your navigation, your authorization rules.
So the package ships it as Lattice primitives instead, discovered like everything else:
| Kind | ID |
|---|---|
| Action | oidc.two-factor.enable / .disable / .revoke-factor / .regenerate-recovery-codes |
| Action | oidc.send-verification-email |
| Form | oidc.two-factor.confirm |
| Fragment | oidc.two-factor-setup / oidc.recovery-codes |
| Table | oidc.two-factor.methods |
Composed into your own page, a working 2FA settings section is this:
Stack::make('two-factor')->schema([
Action::use(EnableTwoFactorAuthenticationAction::class),
Action::use(EnableTwoFactorAuthenticationAction::class, ['provider' => 'webauthn']),
Action::use(DisableTwoFactorAuthenticationAction::class)->visible($twoFactorEnabled),
Action::use(RegenerateRecoveryCodesAction::class)->visible($twoFactorEnabled),
Table::lazy(TwoFactorMethodsTable::class),
]);
TOTP enrollment with its QR code, passkey registration, listing and revoking factors, recovery codes — the logic stays in the package, the layout is yours. That’s the same trade as the view contracts, one level down.
That’s the series
Four posts: the protocols and why you’d own them, the provider, the relying party, and the UI. What I set out to prove was that a Laravel app can be the identity provider — not integrate with one — and that the pieces should be separable enough that you take only the half you need.
It’s version 0.x, the API still moves, and the auth engine’s more interesting corners — the post-login pipeline, MFA providers, social login — didn’t fit in four posts. The docs are at bambamboole.github.io/laravel-oidc, the code at github.com/bambamboole/laravel-oidc. If you run one of these in anger and it breaks, that’s the feedback I want most.