Google OAuth is the original and still primary SSO provider — this page keeps its name and most of its detail for that reason — but it’s no longer the only one, and OAuth itself is no longer the only way in. GitHub and Microsoft (365 / Azure AD / Entra ID) ship as their own auth modules alongside Google. All three implement Modules\Core\Contracts\AuthProvider and share one login flow: OAuth handles “is this person who they say they are,” the users table handles “should they be allowed in,” and App\Services\Auth\OAuthLoginHandler enforces the allowlist + logs the audit trail identically regardless of which provider they signed in with. No auto-provisioning on any of the three — a first-time OAuth user is denied unless someone added them first.
Local email/password sign-in also exists now, handled directly by LoginController::login() (POST /login), and is the always-available fallback: /login always renders the local sign-in form, with OAuth provider buttons appended below a divider only when at least one is actually configured. This page stays focused on the Google OAuth path; see Architecture → Request lifecycle and Architecture → Security model for the local-password flow and the full picture of both auth paths together.
LoginController::show() only lists an OAuth provider once its credentials are actually configured (AuthProvider::isConfigured()) — an installed-but-uncredentialed module simply doesn’t show a button, rather than rendering one that fails when clicked.
Why we use OAuth at all
Everyone on the team already has a Google account (the agency runs Google Workspace); GitHub and Microsoft cover contributors/agencies who’d rather sign in with those instead. Building our own password store would be more attack surface, more recovery flows, more calls to me. OAuth + an allowlist keeps the auth surface tiny and each provider’s own security posture (Workspace 2FA, GitHub 2FA, Entra Conditional Access) protects the app for free.
Setup
-
Create an OAuth 2.0 Client at
console.cloud.google.com/apis/credentials(type: Web application). -
Add an Authorized redirect URI matching
GOOGLE_REDIRECT_URIexactly. Multiple URIs are allowed — registerhttp://localhost:8000/auth/google/callbackfor dev plus the LAN hostname callback for the team. -
Set in
.env:GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... GOOGLE_REDIRECT_URI=http://localhost:8000/auth/google/callback GOOGLE_HD= # optional: pin to a Workspace domain -
Add yourself to the allowlist:
php artisan clockwork:add-user you@example.com --name="Your Name"
- Visit
/login, click “Sign in with Google.”
Auth
laravel/socialite Google driver. Standard OAuth 2.0 flow:
- Authorize →
accounts.google.com/o/oauth2/auth - Token exchange →
oauth2.googleapis.com/token - Userinfo →
www.googleapis.com/oauth2/v2/userinfo
GOOGLE_HD (the hd parameter — “hosted domain”) restricts the Google account picker to a Workspace domain (e.g. your-agency.com). Off by default so personal Google accounts work for testing.
Endpoints we expose
| Method | Path | Purpose |
|---|---|---|
| GET | /login |
Renders the local email/password form, plus a configured-provider’s OAuth button below a divider. |
| POST | /login |
Local password sign-in (LoginController::login()), rate-limited throttle:5,1. |
| GET | /auth/google/redirect |
Kick off the Socialite flow. |
| GET | /auth/google/callback |
Where Google sends the user back. Allowlist check happens here. |
| GET | /auth/github/redirect / /auth/github/callback |
Same shape, GitHub. Routes registered by Modules\AuthGitHub\GitHubAuthServiceProvider::boot() — module-owned, gated on the module being enabled. |
| GET | /auth/microsoft/redirect / /auth/microsoft/callback |
Same shape, Microsoft. Routes registered by Modules\AuthMicrosoft\MicrosoftAuthServiceProvider::boot(). |
All are public (the only public routes besides /login). Google’s pair is the one exception to “module-owned” — they’re still declared in core routes/web.php pointing at GoogleAuthController, not self-registered by Modules\AuthGoogle the way the other two are. Functionally equivalent today, but worth knowing if you’re looking for where to add a fourth provider — copy the GitHub/Microsoft module-owned-routes pattern, not Google’s.
Files
modules/AuthGoogle/src/GoogleAuthProvider.php,modules/AuthGitHub/src/GitHubAuthProvider.php,modules/AuthMicrosoft/src/MicrosoftAuthProvider.php— each implementsAuthProvider: builds its Socialite driver (Google/GitHub) or hand-rolled OAuth2 flow (Microsoft — no Socialite driver used there), does the provider-side redirect/callback dance, then hands off toOAuthLoginHandler.app/Services/Auth/OAuthLoginHandler.php— shared allowlist check,usersrow sync (name/avatar/provider id/last_login_at), session login, and audit log entry. One implementation, all three providers call it.app/Http/Controllers/Auth/GoogleAuthController.php— thinredirect()/callback()delegator toGoogleAuthProvider. GitHub/Microsoft have their own equivalent controllers living inside their modules instead ofapp/Http/Controllers/Auth/.app/Http/Controllers/Auth/LoginController.php—show()renders/login(local form always, OAuth buttons filtered to configured providers only);login()handles localPOST /login;logout()handles/logout.app/Console/Commands/AddUser.php— bootstrap + recovery escape hatch; takes an optional--password=to set a local password on creation/restore.app/Console/Commands/SetPassword.php—clockwork:set-passwordCLI recovery path (masked prompt or--password=) for setting/resetting an existing operator’s local password.app/Http/Controllers/UsersSettingsController.php—/settings/usersUI.database/migrations/2026_09_04_000003_add_oauth_provider_ids_to_users_table.php— addsgithub_id/microsoft_idalongside the existinggoogle_id.- Config:
config/services.php→google/github/microsoftkeys, or set per-instance via/settings/integrations(resolved throughCredentialResolver, DB first).
Allowlist semantics
The users table IS the allowlist, for OAuth and local password sign-in alike:
- A row exists ⇒ allowed.
revoked_at IS NULL⇒ active.- No matching row, or revoked ⇒ OAuth callback bounces back to
/loginwith a denial banner; local login gets an equivalent “account has been revoked” message. - A row with
password IS NULLis SSO-only — attempting local password login on it returns a message pointing the user at their configured OAuth provider (or asking an admin to set a local password). - No auto-provisioning. A first-time Google (or GitHub/Microsoft) user is denied unless someone added them first.
Bootstrap and recovery
# Add or restore a user (idempotent — restores revoked rows), optionally with a local password:
php artisan clockwork:add-user alice@example.com --name=Alice --password=secret
# Set or reset an existing operator's local password (masked prompt, or --password=):
php artisan clockwork:set-password alice@example.com
# UI version (administrators only):
# /settings/users — add, revoke, restore, and set/reset a local password
clockwork:add-user and clockwork:set-password are the always-works escape hatch when the UI is locked out — useful now that local password is a valid recovery path even on an instance that only ever used OAuth.
Audit trail
Every login + add/revoke/restore/password-change lands in action_logs:
TYPE_LOGINTYPE_USER_ADDEDTYPE_USER_REVOKEDTYPE_USER_RESTOREDTYPE_USER_PASSWORD_CHANGED
Surfaces on /maintenance-history.
Gotchas
- Redirect URI must match exactly. Google’s error message is unhelpful — usually a wrong port, missing
/callback, or http vs https. - Revoke and password change kill live sessions.
User::invalidateSessions()cyclesremember_tokenand deletes stored database sessions;EnsureUserIsActivelogs a revoked operator out on the next request. - Revoke-self is blocked at the controller. The
clockwork:add-user/clockwork:set-passwordcommands + restore flow are the always-works recovery if the team accidentally locks themselves out. GOOGLE_HDis enforced on callback. The account picker’shdparameter is a hint;GoogleAuthProvider::handleCallback()also requires the token’shdclaim to match.- A
NULLpassword isn’t a broken account, it’s SSO-only by design. Don’t “fix” it by setting a random password unless you actually want to offer that user a local-login fallback.
