feat(oauth): OAuth/OIDC sign-in and signup - #261
Conversation
Socialite 5.31 requires PHP ^8.1, and core-api declared ^8.0. The runtime has been 8.2 for some time (docker/Dockerfile pins php8.2), so this only drops support for installs still on 8.0 — call that out in the release notes. Socialite is used as the wire-protocol layer for the authorization-code flow; Fleetbase supplies its own state and PKCE handling on top (see ServerSidePkce).
oauth_identities links a Fleetbase user to a provider subject, keyed
unique(provider, provider_user_id). That index is a security control: it is what
guarantees a provider subject can never be claimed by two accounts. Deliberately
no soft deletes — MySQL unique indexes include trashed rows, so a soft-deleted
identity would permanently block re-linking after an accidental unlink.
oauth_states backs the three legs of the handshake (authorization, handoff,
registration intent). It is a table rather than a cache entry because
api/.env.example ships CACHE_DRIVER=file: with more than one app node the
redirect and callback legs would not share a store. Only the sha256 of each
token is persisted, and redemption is a single conditional UPDATE so exactly one
of two concurrent attempts wins.
The dormant users.{google,apple,facebook}_user_id columns are backfilled and
kept; nothing drops them.
Adding a provider later means one driver class, one Socialite subclass if Socialite core lacks the protocol, and one entry in config/oauth.php — no route, controller, migration or console change. Each driver owns the decision that matters most: whether the provider actually vouched for the email address. Google reads email_verified; GitHub relies on Socialite only returning a primary+verified address; Apple accepts both the bool and string forms and flags private-relay aliases; Microsoft trusts xms_edov, or a single-tenant deployment whose tid matches the configured tenant — a 'common' deployment without xms_edov is never trusted, because anyone can stand up a tenant and set a user's address. Credentials resolve DB setting (encrypted) -> config/oauth.php (env) -> null, and deliberately not through EnvironmentMapper: that middleware is global, cannot decrypt, and uses putenv() which leaks across Octane requests.
providers / redirect / callback / exchange, registered through the existing
fleetbaseAuthRoutes() callback seam so they inherit the same ThrottleRequests
group as login. Literal segments are registered before the {provider} wildcard.
The callback accepts GET and POST: Apple form-posts whenever the name/email
scopes are requested. The handoff code is returned in the URL fragment, never the
query string, so it is not sent to the console's web server and cannot reach an
access log or a Referer header.
exchange reproduces AuthController::login's gate order exactly (customer -> 2FA
-> not_verified -> token), so an OAuth sign-in cannot reach a state a password
sign-in could not. An existing user is never resolved by email address: an
unknown identity whose verified address collides with an account returns
link_required rather than signing anyone in.
RoutesContractTest's local macro mirrored the real one but dropped the two
extension callbacks, so it would have asserted nothing about these routes.
OAuth never creates a user or a company. An unrecognised identity yields a short-lived single-use intent, and the existing signup flow redeems it at the end of its own unchanged sequence — so an OAuth account is built by exactly the same pipeline as a password one rather than by a second implementation that would drift. Fleetbase\Support\OAuth is the whole surface a signup implementation needs, shaped like Support\TwoFactorAuth and Support\PlatformApi. OnboardRequest now accepts a password or an intent; phone, organization name and the word blacklists are unchanged. HandleAccountCreated gained an isNotVerified() guard. It previously gated only on isNotAdmin(), so an account whose address a provider had already vouched for would still have been emailed a code it had no reason to enter. No-op for password signup, which is never verified at that point.
|
Console companion: fleetbase/fleetbase#670 — depends on this merging and releasing first. |
GET/POST settings/oauth-config and POST settings/test-oauth-config, following the existing get*/save*/test* convention and restricted to admins by AdminRequest. The read never returns a secret, not even to an admin — each is reduced to whether it is set plus a short hint. It carries every provider's schema and the computed callback URL, so the console renders the form without hardcoding a provider and an operator can copy the exact redirect URI each provider requires. The write filters every field against the driver schemas before anything is stored: an unknown provider id, or a field a driver does not declare — a driver class name included — is ignored. An empty secret keeps the stored value. refreshConfigCache() is deliberately not called: nothing here lives in config() at runtime. The check confirms credentials are present and decryptable, and for Apple that the .p8 actually mints a client secret — the failure an operator is most likely to hit. It does not call the provider, so a revoked secret still only surfaces at first sign-in.
|
Added a sixth commit (93f405b): admin OAuth configuration endpoints — |
GET identities, POST {provider}/link, POST link/complete and DELETE
{provider}/unlink, all behind fleetbase.protected.
The identity is deliberately not linked at the provider callback. Doing so would
allow account-linking CSRF: an attacker starts a link on their own account and
sends the victim the provider URL, the victim's provider identity is attached to
the attacker's account, and the victim's next 'Sign in with <provider>' lands in
the attacker's account. Instead the callback issues a handoff bound to the user who
started the link, and link/complete — protected — redeems it only if the signed-in
user matches. A mismatch is reported exactly like an expired code, so the attacker
learns nothing. The public exchange refuses link handoffs outright, so there is no
path around that check. A mutation test confirms the CSRF test fails when the user
check is removed.
Unlink is refused with last_credential when it would leave the account with no
password and no other linked provider.
This also closes the loop on link_required: an existing user who tries to sign in
with an unlinked provider now has somewhere to go and link it.
|
Added a seventh commit (fe806f2): account linking — |
"Check configuration" only looked at saved settings, so credentials typed into the form but not yet saved were reported missing. It also never contacted the provider, so a wrong secret passed. - The check now runs on the form's unsaved values, layered over what is stored in memory only. A blank secret still means the stored one. - It then presents the credentials to the provider's token endpoint with a code that cannot be valid. Providers authenticate the client before the code, so invalid_client versus invalid_grant tells a bad secret from a good one without signing anyone in (CredentialCheck). - Results carry a plain-language message and the names of missing fields. - Saving refuses (422, nothing written) to switch a provider on, or to change a live provider's credentials, unless that check passes. Saves that leave a live provider's credentials alone do not call it.
The admin form is rendered from these schemas, so fields without one showed as empty boxes next to fields with one. A test now requires a placeholder on every field of every shipped driver.
Automatic linking. A first-time provider sign-in whose email matches an existing account now links the provider and signs the person in, instead of answering link_required. Every condition is required: - the administrator has not switched it off (new `auto_link` setting, on by default, OAUTH_AUTO_LINK); - the provider verified the email, and it is not an Apple relay alias; - exactly one account has that email; - the account is `admin` or `user`, never customer, contact or driver; - the account's own email is confirmed, so an address someone signed up with but never proved they own cannot capture the owner's sign-in; - no other account from the same provider is linked to it. Sign-in then runs every check it always does; two-factor still applies. The response carries `linked` and `linked_label` so the console can say what happened. Otherwise the answer is still link_required. Emails. Linking or removing a provider emails the account holder: which provider and account, when it happened, a link to review sign-in methods, and what to do if it was not them. An automatic link says why it happened. A provider linked while creating an account sends nothing extra; the signup emails cover it. A failed send is logged and never fails the link or the removal.
|
Adds automatic linking for existing console accounts ( |
- The public provider list now includes `allow_registration`, so the sign-up page can leave out its provider buttons when sign-ups are closed. Before, someone found out only after the provider round trip. - A sign-up that turns out to be an existing account (already linked, or linked automatically) signs the person in and adds `existing_account: true`, so the console can say so instead of silently landing them in the console. Ordinary sign-ins don't get the flag. authenticate() now takes the notices for the console (`linked`, `existing_account`) as one array, replacing the auto-link boolean.
|
OAuthController tests: 76 passed. Lint is clean. |
A sign-up from a provider that vouched for the email already marks the account verified, and AccountCreated sends no code for a verified account. But the response only set skipVerification (and a token) for the first administrator. So the console went to the verification step and waited for an email that never came. skipVerification now also covers an already-verified address, and the console signs the person straight in. An address the provider did not verify, or a different one, still goes through verification.
Backend half of OAuth/OIDC sign-in and signup. Companion Console PR: fleetbase/fleetbase (linked below once open).
Built from an audit of the existing auth and signup paths, including the private Cloud onboarding flow. Supersedes #213 — see "Relationship to #213" below.
Approach
OAuth proves identity; it never creates accounts. An unrecognised provider identity yields a short-lived, single-use registration intent. The existing signup flow redeems that intent at the end of its own unchanged sequence, so an OAuth account is built by the same pipeline as a password account rather than by a second implementation that would drift from it.
Server-side authorization-code flow with PKCE. The backend owns the whole handshake. Client IDs and secrets live in encrypted DB settings and never reach the browser, which is what makes providers toggleable by an administrator at runtime rather than at build time.
The rule that matters most
An existing user is never resolved by email address. Authentication comes only from an existing
(provider, provider_user_id)row, or from a link performed by an already-authenticated session. An unknown identity whose verified address collides with an account returnslink_requiredrather than signing anyone in.Per-provider "did the provider actually vouch for this address" rules:
email_verified === true;hosted_domainenforced server-side against the verifiedhdclaim, not just sent as a hintxms_edov === true, or single-tenant deployment whosetidmatches the configured tenant. Acommondeployment withoutxms_edovis never trusted — anyone can stand up a tenant and set a user's addressprimary && verifiedaddress, so presence is the flag"true";@privaterelay.appleid.comaliases flagged and excluded from account-exists checksCommits
Five, each self-consistent and reviewable on its own:
chore(deps)— PHP floor^8.0→^8.1, addlaravel/socialitefeat(oauth)— identity model + single-use state storefeat(oauth)— provider abstraction + Google/Microsoft/GitHub/Apple driversfeat(oauth)— sign-in endpointsfeat(oauth)— registration intent accepted during signupNotable decisions
oauth_statesis a table, not a cache entry.api/.env.exampleshipsCACHE_DRIVER=file; with more than one app node the redirect and callback legs would not share a store. Only the sha256 of each token is persisted, and redemption is one conditionalUPDATEso exactly one of two concurrent attempts wins.oauth_identities. MySQL unique indexes include trashed rows, so a soft-deleted identity would permanently block re-linking after an accidental unlink.EnvironmentMapper. That middleware is global, cannot decrypt, and usesputenv()which would leak secrets across Octane requests.Refererheader.Behaviour changes to existing code
^8.1. Runtime has been 8.2 for some time (docker/Dockerfilepins php8.2); this drops installs still on 8.0. Worth a release note.HandleAccountCreatedgained anisNotVerified()guard. It previously gated only onisNotAdmin(), so an account whose address a provider had already vouched for would still have been emailed a code it had no reason to enter. No-op for password signup, which is never verified at that point.RoutesContractTest's local macro was widened. It mirroredfleetbaseAuthRoutes()but dropped the two extension callbacks, so it would have asserted nothing about any route registered through them.Existing users, passwords, API tokens, sessions, invitations and companies are untouched. No existing endpoint changes shape. The only modified request rules relax a constraint (
required→required_without).Relationship to #213
That PR used client-side provider SDKs with build-time config. Reviewed in full; the ID-token verification shape and the Microsoft
oid/issuer handling informed this work. Not reused directly because:oauthRespond()matched an existing user by email with no check that the provider verified it — a personal Microsoft account can set an arbitrarypreferred_usernameloginWithAppletookappleUserIdfrom the request body rather than the verifiedsubclaim, andAppleVerifierperforms no audience checkUserwith no company, pivot, role or events — on Cloud that is an orphaned, unbillable accountVerification
composer test:lint,composer test:date-drift,composer test:unit— 1627 passing, 0 failures, ~220 new tests. Migrations verified up and down against a live connection, including backfill.composer test:typesis not run: it is not in CI (.github/workflows/ci.ymlruns lint/date-drift/unit) and is red onmain—src/Models/Setting.phpalone reports 45 errors, since core-api has no larastan and no phpstan baseline.Not yet exercised against a live provider. Every leg is covered end-to-end with a fake driver, but a smoke test with real Google/Microsoft credentials in staging is still outstanding.