This repository was archived by the owner on Sep 20, 2026. It is now read-only.
Turn Accounts into the 3AG OpenID Connect provider - #2
Merged
Merged
Conversation
A client signs in once at accounts.3ag.app and the product apps sign them in from there. This app owns identity and nothing else: every product keeps its own codebase, database, APP_KEY and host-only session cookie, and the only thing crossing the boundary is a signed ID token. Passport supplies the OAuth 2.1 server. It ships no OIDC, so app/Oidc adds the missing half: ID tokens signed RS256 with Passport's key pair, the discovery document, a JWKS endpoint, userinfo, and RP-initiated logout. Two behaviours are specific to how we work: - First-party clients skip the consent screen. Client::skipsAuthorization() returns the new first_party flag, so a signed-in user is sent straight back to one of our own products. The Blade consent page still stands for anything else. - Accounts decides which product a user may enter. Without a client_user grant the authorization endpoint refuses, so no code is ever issued. The products keep their own roles and permissions; this is the front door. The OIDC parameters that must outlive the authorization code ride on the oauth_auth_codes row: a custom AuthCodeRepository writes nonce and auth_time, and ScopeRepository reads them back in finalizeScopes(), the one point of the token exchange handed the code's identifier. The refresh grant arrives there with no code, so a refreshed ID token correctly carries no nonce. The subject claim is a ULID public_id rather than the primary key, so products store something opaque that survives a change of name or email. Fortify handles login, password reset, email verification and 2FA behind hand-written Blade views. There is no public registration: accounts:create-user makes an account and mails a link to set a password, and accounts:grant and accounts:revoke manage access per product. Revoking also kills the tokens already issued, refresh tokens included, since Passport never looks past a refresh token's own revoked flag. Production runs MySQL. deploy.php shares only storage and .env, so a SQLite file under database/ would be replaced on every release, and CI now runs the suite against MySQL for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting the scaffolded example left the directory empty, and git does not track empty directories, so a fresh checkout had no tests/Unit for the suite phpunit.xml declares. CI caught it; local runs did not, because the directory still existed on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A client signs in once at accounts.3ag.app; SalesReport, ProductSyncManager and CompliancePlatform sign them in from there over OpenID Connect. This app owns identity and nothing else — separate databases, separate
APP_KEYs, host-only session cookies.Passport gives us the OAuth 2.1 server but ships no OIDC, so
app/Oidcadds the missing half./.well-known/openid-configuration/oauth/jwkskid= RFC 7638 thumbprint/oauth/userinfo/oauth/logoutAuth code + PKCE, refresh tokens,
nonce,auth_time,max_age,prompt=none|login|consent,at_hash.Two behaviours worth reviewing
Client::skipsAuthorization()returns the newfirst_partyflag. The Blade consent page still fires for anything else.client_usergrant, no authorization code — the user is told they don't have access instead.subis a ULIDpublic_id, not the primary key.How
noncesurvives the back channelIt rides on the
oauth_auth_codesrow.AuthCodeRepositorywrites it;ScopeRepository::finalizeScopes()reads it back, being the one point in the token exchange handed the code's identifier. The refresh grant gets there with no code, so a refreshed ID token correctly carries no nonce.Testing
56 feature tests covering the flow end to end, not the pieces: a real authorization request through to an ID token verified against the published JWKS, consent appearing for third parties and not for us, the gate refusing to issue a code, and logout honouring only registered redirect URIs.
Verified in a browser across two running apps, and against both SQLite and MySQL.
Deploying this
.envon the server needsDB_*(MySQL — a SQLite file underdatabase/would be wiped every release),APP_URL,CLIENT_*_URL, and a real mailer, since the only way to create an account isaccounts:create-usermailing a set-password link. The MySQL database must exist before the first deploy, because Deployer runsartisan:migrate. Afterwards,php artisan passport:keysonce on the server —storageis shared, so the keys persist across releases — thenphp artisan db:seed --class=ClientSeederto register the products.🤖 Generated with Claude Code