Skip to content
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
SourovCodes merged 2 commits into
mainfrom
feature/openid-connect-provider
Sep 19, 2026
Merged

SourovCodes merged 2 commits into
mainfrom
feature/openid-connect-provider

Conversation

@SourovCodes

Copy link
Copy Markdown
Member

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/Oidc adds the missing half.

Endpoint
/.well-known/openid-configuration provider metadata, derived from the live routes
/oauth/jwks RS256 public key, kid = RFC 7638 thumbprint
/oauth/userinfo scope-filtered claims
/oauth/logout RP-initiated logout, registered redirect URIs only

Auth code + PKCE, refresh tokens, nonce, auth_time, max_age, prompt=none|login|consent, at_hash.

Two behaviours worth reviewing

  • First-party clients skip consent. Client::skipsAuthorization() returns the new first_party flag. The Blade consent page still fires for anything else.
  • Accounts gates access per product. No client_user grant, no authorization code — the user is told they don't have access instead.

sub is a ULID public_id, not the primary key.

How nonce survives the back channel

It rides on the oauth_auth_codes row. AuthCodeRepository writes 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

.env on the server needs DB_* (MySQL — a SQLite file under database/ would be wiped every release), APP_URL, CLIENT_*_URL, and a real mailer, since the only way to create an account is accounts:create-user mailing a set-password link. The MySQL database must exist before the first deploy, because Deployer runs artisan:migrate. Afterwards, php artisan passport:keys once on the server — storage is shared, so the keys persist across releases — then php artisan db:seed --class=ClientSeeder to register the products.

🤖 Generated with Claude Code

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>
@SourovCodes
SourovCodes merged commit dac829c into main Sep 19, 2026
1 check passed
@SourovCodes
SourovCodes deleted the feature/openid-connect-provider branch September 19, 2026 18:42
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant