From 9d061a8b2ddf921e4ac94dee22888f302daccd5f Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Sat, 19 Sep 2026 23:22:06 +0600 Subject: [PATCH 1/3] Add "Sign in with 3AG Accounts" SalesReport becomes a relying party of the identity provider at accounts.3ag.app, so a client signs in once there instead of keeping a separate password here. A Socialite provider drives the authorization code flow with PKCE. Claims come from the provider's userinfo endpoint rather than the ID token, because that call is a direct back-channel request over TLS and needs no signature check of its own. The callback resolves the local account by oidc_sub first. Failing that it matches on email, but only when the provider reports the address verified: without that check anyone able to set an unverified email there could claim an existing account here. Otherwise it creates the user and carries on into the existing onboarding flow. This is additive. The local login, registration and password reset all still work, and the existing suite is untouched. THREE_AG_SSO_ONLY=true is the switch that retires them, redirecting the sign-in pages to the provider, removing the endpoints that would accept a local password, and ending the session at the provider on logout. It ships off; flipping it is a separate and deliberate change once the flow is proven in production. Co-Authored-By: Claude Opus 5 --- .env.example | 9 + .../Auth/ThreeAgCallbackController.php | 97 +++++ .../Auth/ThreeAgRedirectController.php | 18 + app/Http/Middleware/ThreeAgSingleSignOn.php | 73 ++++ app/Http/Responses/LogoutResponse.php | 29 ++ app/Providers/AppServiceProvider.php | 13 + app/Providers/FortifyServiceProvider.php | 3 + app/Services/Auth/ThreeAgProvider.php | 173 +++++++++ bootstrap/app.php | 2 + composer.json | 1 + composer.lock | 336 +++++++++++++++++- config/services.php | 22 ++ ..._19_111219_add_oidc_sub_to_users_table.php | 32 ++ resources/js/pages/auth/login.tsx | 18 + routes/web.php | 17 + .../Feature/Auth/ThreeAgSingleSignOnTest.php | 188 ++++++++++ 16 files changed, 1030 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Auth/ThreeAgCallbackController.php create mode 100644 app/Http/Controllers/Auth/ThreeAgRedirectController.php create mode 100644 app/Http/Middleware/ThreeAgSingleSignOn.php create mode 100644 app/Http/Responses/LogoutResponse.php create mode 100644 app/Services/Auth/ThreeAgProvider.php create mode 100644 database/migrations/2026_09_19_111219_add_oidc_sub_to_users_table.php create mode 100644 tests/Feature/Auth/ThreeAgSingleSignOnTest.php diff --git a/.env.example b/.env.example index a6ae433..5e35b78 100644 --- a/.env.example +++ b/.env.example @@ -77,3 +77,12 @@ WOOCOMMERCE_SYNC_OVERLAP_MINUTES=10 WOOCOMMERCE_SYNC_AFTER_MINUTES=15 VITE_APP_NAME="${APP_NAME}" + +# Sign in with 3AG Accounts. Register this app as a client there +# (`php artisan db:seed --class=ClientSeeder` in the Accounts repo) and paste +# the id and secret it prints. THREE_AG_SSO_ONLY=true removes this app's own +# login, registration and password reset. +THREE_AG_BASE_URL=https://accounts.3ag.app +THREE_AG_CLIENT_ID= +THREE_AG_CLIENT_SECRET= +THREE_AG_SSO_ONLY=false diff --git a/app/Http/Controllers/Auth/ThreeAgCallbackController.php b/app/Http/Controllers/Auth/ThreeAgCallbackController.php new file mode 100644 index 0000000..08c1897 --- /dev/null +++ b/app/Http/Controllers/Auth/ThreeAgCallbackController.php @@ -0,0 +1,97 @@ +user(); + } catch (InvalidStateException) { + return redirect()->route('login')->withErrors([ + 'email' => 'That sign-in attempt expired. Please try again.', + ]); + } + + $user = $this->resolveUser($identity); + + if ($user === null) { + return redirect()->route('login')->withErrors([ + 'email' => 'Your 3AG Accounts email address has not been verified yet.', + ]); + } + + Auth::login($user, remember: true); + + $request->session()->regenerate(); + $request->session()->put(ThreeAgSingleSignOn::ID_TOKEN, $provider->idToken()); + + return redirect()->intended( + $this->redirectPathForCurrentOrganization($request, Fortify::redirects('login')) + ); + } + + /** + * Find, link or create the local account behind the given identity. + * + * Returns null when the account cannot be trusted to belong to this + * person. + */ + protected function resolveUser(SocialiteUser $identity): ?User + { + $existing = User::query()->where('oidc_sub', $identity->getId())->first(); + + if ($existing instanceof User) { + return tap($existing)->update(['name' => $identity->getName()]); + } + + // Matching an existing local account by email is only safe once the + // provider says it owns that address; otherwise anyone who can set an + // unverified email at the provider could claim someone else's account. + if (! ($identity->user['email_verified'] ?? false)) { + return null; + } + + $byEmail = User::query()->where('email', $identity->getEmail())->first(); + + if ($byEmail instanceof User) { + $byEmail->forceFill([ + 'oidc_sub' => $identity->getId(), + 'email_verified_at' => $byEmail->email_verified_at ?? now(), + ])->save(); + + return $byEmail; + } + + return tap(User::query()->create([ + 'name' => $identity->getName() ?? $identity->getEmail(), + 'email' => $identity->getEmail(), + 'password' => Str::password(32), + ]), function (User $user) use ($identity): void { + $user->forceFill([ + 'oidc_sub' => $identity->getId(), + 'email_verified_at' => now(), + ])->save(); + }); + } +} diff --git a/app/Http/Controllers/Auth/ThreeAgRedirectController.php b/app/Http/Controllers/Auth/ThreeAgRedirectController.php new file mode 100644 index 0000000..fbd290b --- /dev/null +++ b/app/Http/Controllers/Auth/ThreeAgRedirectController.php @@ -0,0 +1,18 @@ +redirect(); + } +} diff --git a/app/Http/Middleware/ThreeAgSingleSignOn.php b/app/Http/Middleware/ThreeAgSingleSignOn.php new file mode 100644 index 0000000..ddc37cb --- /dev/null +++ b/app/Http/Middleware/ThreeAgSingleSignOn.php @@ -0,0 +1,73 @@ + + */ + protected const REDIRECTED = [ + 'login', + 'register', + 'password.request', + 'password.reset', + ]; + + /** + * Routes that would authenticate or enrol someone without the provider. + * + * @var array + */ + protected const BLOCKED = [ + 'login.store', + 'register.store', + 'password.email', + 'password.update', + ]; + + /** + * Handle an incoming request. + * + * @param Closure(Request): Response $next + */ + public function handle(Request $request, Closure $next): Response + { + // Fortify invalidates the session before the logout response is built, + // so the ID token has to be lifted out of it first. + if ($request->routeIs('logout')) { + $request->attributes->set(self::ID_TOKEN, $request->session()->get(self::ID_TOKEN)); + } + + if (! config('services.3ag.sso_only')) { + return $next($request); + } + + if ($request->routeIs(...self::REDIRECTED)) { + return redirect()->route('auth.accounts.redirect'); + } + + abort_if($request->routeIs(...self::BLOCKED), Response::HTTP_NOT_FOUND); + + return $next($request); + } +} diff --git a/app/Http/Responses/LogoutResponse.php b/app/Http/Responses/LogoutResponse.php new file mode 100644 index 0000000..e8fb5af --- /dev/null +++ b/app/Http/Responses/LogoutResponse.php @@ -0,0 +1,29 @@ +attributes->get(ThreeAgSingleSignOn::ID_TOKEN); + + if (! config('services.3ag.sso_only') || $idToken === null) { + return redirect('/'); + } + + return redirect()->away(ThreeAgProvider::resolve()->logoutUrl($idToken)); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..3f432bc 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,11 +2,13 @@ namespace App\Providers; +use App\Services\Auth\ThreeAgProvider; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Socialite\Facades\Socialite; class AppServiceProvider extends ServiceProvider { @@ -24,6 +26,17 @@ public function register(): void public function boot(): void { $this->configureDefaults(); + $this->configureSingleSignOn(); + } + + /** + * Register 3AG Accounts as a Socialite driver. + */ + protected function configureSingleSignOn(): void + { + Socialite::extend('3ag', fn ($app) => Socialite::buildProvider( + ThreeAgProvider::class, $app['config']['services.3ag'] + )); } /** diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index 9b34e49..dd0c11c 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -5,6 +5,7 @@ use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; use App\Http\Responses\LoginResponse; +use App\Http\Responses\LogoutResponse; use App\Http\Responses\PasskeyLoginResponse; use App\Http\Responses\RegisterResponse; use App\Http\Responses\TwoFactorLoginResponse; @@ -17,6 +18,7 @@ use Illuminate\Support\Str; use Inertia\Inertia; use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; +use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; use Laravel\Fortify\Contracts\TwoFactorLoginResponse as TwoFactorLoginResponseContract; use Laravel\Fortify\Contracts\VerifyEmailResponse as VerifyEmailResponseContract; @@ -32,6 +34,7 @@ class FortifyServiceProvider extends ServiceProvider public function register(): void { $this->app->singleton(LoginResponseContract::class, LoginResponse::class); + $this->app->singleton(LogoutResponseContract::class, LogoutResponse::class); $this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class); $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); $this->app->singleton(TwoFactorLoginResponseContract::class, TwoFactorLoginResponse::class); diff --git a/app/Services/Auth/ThreeAgProvider.php b/app/Services/Auth/ThreeAgProvider.php new file mode 100644 index 0000000..2be8640 --- /dev/null +++ b/app/Services/Auth/ThreeAgProvider.php @@ -0,0 +1,173 @@ + + */ + protected $scopes = ['openid', 'profile', 'email']; + + /** + * The separator the provider uses between scopes. + * + * @var string + */ + protected $scopeSeparator = ' '; + + /** + * Public clients cannot keep a secret, and PKCE costs us nothing here. + * + * @var bool + */ + protected $usesPKCE = true; + + /** + * The raw token endpoint response for the most recent exchange. + * + * @var array + */ + protected array $tokenResponse = []; + + /** + * Get the configured driver. + * + * The Socialite facade is typed against the generic provider contract, + * which knows nothing about ID tokens or the provider's logout endpoint. + */ + public static function resolve(): self + { + $driver = Socialite::driver('3ag'); + + if (! $driver instanceof self) { + throw new RuntimeException('The [3ag] Socialite driver is not registered.'); + } + + return $driver; + } + + /** + * {@inheritdoc} + * + * Narrowed from the generic contract: mapUserToObject() below always + * builds an OAuth 2 user, and the callback needs its raw claims to read + * `email_verified`. + */ + public function user(): User + { + $user = parent::user(); + + if (! $user instanceof User) { + throw new RuntimeException('The [3ag] driver returned an unexpected user type.'); + } + + return $user; + } + + /** + * {@inheritdoc} + * + * @param array $response + * @param array $user + */ + protected function userInstance(array $response, array $user): User + { + // Socialite keeps only the access and refresh tokens, but the ID token + // is what proves to the provider which session is ending when the user + // signs out. + $this->tokenResponse = $response; + + return parent::userInstance($response, $user); + } + + /** + * Get the ID token returned alongside the access token. + */ + public function idToken(): ?string + { + return $this->tokenResponse['id_token'] ?? null; + } + + /** + * {@inheritdoc} + */ + protected function getAuthUrl($state): string + { + return $this->buildAuthUrlFromBase($this->endpoint('/oauth/authorize'), $state); + } + + /** + * {@inheritdoc} + */ + protected function getTokenUrl(): string + { + return $this->endpoint('/oauth/token'); + } + + /** + * {@inheritdoc} + * + * @return array + */ + protected function getUserByToken($token): array + { + $response = $this->getHttpClient()->get($this->endpoint('/oauth/userinfo'), [ + 'headers' => [ + 'Accept' => 'application/json', + 'Authorization' => 'Bearer '.$token, + ], + ]); + + return json_decode((string) $response->getBody(), true); + } + + /** + * {@inheritdoc} + * + * @param array $user + */ + protected function mapUserToObject(array $user): User + { + return (new User)->setRaw($user)->map([ + 'id' => Arr::get($user, 'sub'), + 'name' => Arr::get($user, 'name'), + 'email' => Arr::get($user, 'email'), + ]); + } + + /** + * Get the URL where the user is sent to end their session at the provider. + */ + public function logoutUrl(?string $idToken = null): string + { + return $this->endpoint('/oauth/logout').'?'.http_build_query(array_filter([ + 'id_token_hint' => $idToken, + 'post_logout_redirect_uri' => config('app.url').'/', + ])); + } + + /** + * Build an absolute URL to one of the provider's endpoints. + */ + protected function endpoint(string $path): string + { + return rtrim(config('services.3ag.base_url'), '/').$path; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index da912a0..773001c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\SetOrganizationUrlDefaults; +use App\Http\Middleware\ThreeAgSingleSignOn; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; @@ -23,6 +24,7 @@ HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, SetOrganizationUrlDefaults::class, + ThreeAgSingleSignOn::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/composer.json b/composer.json index 9443bab..fef9e2b 100644 --- a/composer.json +++ b/composer.json @@ -15,6 +15,7 @@ "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", "laravel/framework": "^13.17", + "laravel/socialite": "^5.31", "laravel/tinker": "^3.0", "laravel/wayfinder": "^0.1.14" }, diff --git a/composer.lock b/composer.lock index 93b3a05..4de247b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a73ce68ad68fab2a9a7c8cf74c846861", + "content-hash": "0fa78ff173a6ef12f853ed8ab06c04d4", "packages": [ { "name": "bacon/bacon-qr-code", @@ -665,6 +665,72 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "firebase/php-jwt", + "version": "v7.1.1", + "source": { + "type": "git", + "url": "https://github.com/googleapis/php-jwt.git", + "reference": "9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a", + "reference": "9bc93bd7e3ee5bead4cd23c365ec12f3c1fb0a6a", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.4||^8.0", + "phpfastcache/phpfastcache": "^9.2", + "phpseclib/phpseclib": "~3.0", + "phpspec/prophecy-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psr/cache": "^2.0||^3.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0" + }, + "suggest": { + "ext-sodium": "Support EdDSA (Ed25519) signatures", + "paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present", + "phpseclib/phpseclib": "Support PS256 (RSASSA-PSS) signatures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Firebase\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Neuman Vong", + "email": "neuman+pear@twilio.com", + "role": "Developer" + }, + { + "name": "Anant Narayanan", + "email": "anant@php.net", + "role": "Developer" + } + ], + "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", + "homepage": "https://github.com/googleapis/php-jwt", + "keywords": [ + "jwt", + "php" + ], + "support": { + "issues": "https://github.com/googleapis/php-jwt/issues", + "source": "https://github.com/googleapis/php-jwt/tree/v7.1.1" + }, + "time": "2026-09-14T17:48:47+00:00" + }, { "name": "fruitcake/php-cors", "version": "v1.4.0", @@ -1819,6 +1885,78 @@ }, "time": "2026-08-18T20:28:54+00:00" }, + { + "name": "laravel/socialite", + "version": "v5.31.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/socialite.git", + "reference": "f721b2cbec327ab820bd6aabea6ab211cfcc9f08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/socialite/zipball/f721b2cbec327ab820bd6aabea6ab211cfcc9f08", + "reference": "f721b2cbec327ab820bd6aabea6ab211cfcc9f08", + "shasum": "" + }, + "require": { + "ext-json": "*", + "firebase/php-jwt": "^6.4|^7.0", + "guzzlehttp/guzzle": "^6.0|^7.0|^8.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "league/oauth1-client": "^1.11", + "php": "^8.1", + "phpseclib/phpseclib": "^4.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^4.18|^5.20|^6.47|^7.55|^8.36|^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.12.23", + "phpunit/phpunit": "^8.0|^9.3|^10.4|^11.5|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Socialite": "Laravel\\Socialite\\Facades\\Socialite" + }, + "providers": [ + "Laravel\\Socialite\\SocialiteServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Socialite\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.", + "homepage": "https://laravel.com", + "keywords": [ + "laravel", + "oauth" + ], + "support": { + "issues": "https://github.com/laravel/socialite/issues", + "source": "https://github.com/laravel/socialite" + }, + "time": "2026-08-31T13:49:19+00:00" + }, { "name": "laravel/tinker", "version": "v3.0.2", @@ -2331,6 +2469,82 @@ ], "time": "2026-07-09T11:49:27+00:00" }, + { + "name": "league/oauth1-client", + "version": "v1.12.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth1-client.git", + "reference": "aa8fe766f772f9233d6c06f9ef003fd8129e408d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/aa8fe766f772f9233d6c06f9ef003fd8129e408d", + "reference": "aa8fe766f772f9233d6c06f9ef003fd8129e408d", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^6.5.8||^7.8.2||^8.0", + "guzzlehttp/psr7": "^1.9.1||^2.6.3||^3.0", + "php": ">=7.1||>=8.0" + }, + "require-dev": { + "ext-simplexml": "*", + "friendsofphp/php-cs-fixer": "^2.17", + "mockery/mockery": "^1.3.3", + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5||9.5" + }, + "suggest": { + "ext-simplexml": "For decoding XML-based responses." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev", + "dev-develop": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "League\\OAuth1\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Corlett", + "email": "bencorlett@me.com", + "homepage": "http://www.webcomm.com.au", + "role": "Developer" + } + ], + "description": "OAuth 1.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "bitbucket", + "identity", + "idp", + "oauth", + "oauth1", + "single sign on", + "trello", + "tumblr", + "twitter" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth1-client/issues", + "source": "https://github.com/thephpleague/oauth1-client/tree/v1.12.0" + }, + "time": "2026-09-19T05:12:51+00:00" + }, { "name": "league/uri", "version": "7.8.1", @@ -3345,6 +3559,126 @@ ], "time": "2026-08-24T00:54:40+00:00" }, + { + "name": "phpseclib/phpseclib", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpseclib/phpseclib.git", + "reference": "bb7b959c8159957edae6f5084ebbac765d310e16" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/bb7b959c8159957edae6f5084ebbac765d310e16", + "reference": "bb7b959c8159957edae6f5084ebbac765d310e16", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^2|^3", + "php": ">=8.1", + "symfony/polyfill-php82": "^1.26" + }, + "require-dev": { + "brianium/paratest": "^7.22", + "ext-xml": "*", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpunit/phpunit": "^13", + "squizlabs/php_codesniffer": "^3.7", + "vimeo/psalm": "*" + }, + "suggest": { + "ext-dom": "Install the DOM extension to load XML formatted public keys.", + "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.", + "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.", + "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations." + }, + "type": "library", + "autoload": { + "files": [ + "phpseclib/bootstrap.php" + ], + "psr-4": { + "phpseclib4\\": "phpseclib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jim Wigginton", + "email": "terrafrost@php.net", + "role": "Lead Developer" + }, + { + "name": "Patrick Monnerat", + "email": "pm@datasphere.ch", + "role": "Developer" + }, + { + "name": "Andreas Fischer", + "email": "bantu@phpbb.com", + "role": "Developer" + }, + { + "name": "Hans-Jürgen Petrich", + "email": "petrich@tronic-media.com", + "role": "Developer" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com", + "role": "Developer" + }, + { + "name": "Jack Worman", + "email": "jack.worman@gmail.com", + "homepage": "https://jackworman.com", + "role": "Developer" + } + ], + "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.", + "homepage": "https://phpseclib.com/", + "keywords": [ + "BigInteger", + "aes", + "asn.1", + "asn1", + "blowfish", + "crypto", + "cryptography", + "encryption", + "rsa", + "security", + "sftp", + "signature", + "signing", + "ssh", + "twofish", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/phpseclib/phpseclib/issues", + "source": "https://github.com/phpseclib/phpseclib/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/terrafrost", + "type": "github" + }, + { + "url": "https://www.patreon.com/phpseclib", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib", + "type": "tidelift" + } + ], + "time": "2026-08-26T12:15:13+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.5", diff --git a/config/services.php b/config/services.php index a7930be..986aa69 100644 --- a/config/services.php +++ b/config/services.php @@ -14,6 +14,28 @@ | */ + /* + |-------------------------------------------------------------------------- + | 3AG Accounts + |-------------------------------------------------------------------------- + | + | The OpenID Connect provider at accounts.3ag.app that signs users in to + | every 3AG product. `base_url` is the issuer; the other values come from + | the client registered there for this app. + | + */ + + '3ag' => [ + 'base_url' => env('THREE_AG_BASE_URL', 'http://localhost:8000'), + 'client_id' => env('THREE_AG_CLIENT_ID'), + 'client_secret' => env('THREE_AG_CLIENT_SECRET'), + 'redirect' => env('THREE_AG_REDIRECT_URI', env('APP_URL').'/auth/accounts/callback'), + + // Turning this on removes this app's own login, registration and + // password reset, leaving 3AG Accounts as the only way in. + 'sso_only' => (bool) env('THREE_AG_SSO_ONLY', false), + ], + 'postmark' => [ 'key' => env('POSTMARK_API_KEY'), ], diff --git a/database/migrations/2026_09_19_111219_add_oidc_sub_to_users_table.php b/database/migrations/2026_09_19_111219_add_oidc_sub_to_users_table.php new file mode 100644 index 0000000..7505a77 --- /dev/null +++ b/database/migrations/2026_09_19_111219_add_oidc_sub_to_users_table.php @@ -0,0 +1,32 @@ +string('oidc_sub')->nullable()->unique()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['oidc_sub']); + $table->dropColumn('oidc_sub'); + }); + } +}; diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index a7939f8..02789aa 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { register } from '@/routes'; +import { redirect as threeAgRedirect } from '@/routes/auth/accounts'; import { store } from '@/routes/login'; import { request } from '@/routes/password'; import PasskeyVerify from '@/components/passkey-verify'; @@ -36,6 +37,23 @@ export default function Login({ /> )} + +
name('home'); +/* +|-------------------------------------------------------------------------- +| Sign in with 3AG Accounts +|-------------------------------------------------------------------------- +| +| The OpenID Connect flow against accounts.3ag.app. Throttled because both +| routes reach out to the identity provider. +| +*/ + +Route::middleware('throttle:20,1')->group(function (): void { + Route::get('auth/accounts/redirect', ThreeAgRedirectController::class)->name('auth.accounts.redirect'); + Route::get('auth/accounts/callback', ThreeAgCallbackController::class)->name('auth.accounts.callback'); +}); + Route::get('onboarding', OnboardingController::class) ->middleware(['auth', 'verified']) ->name('onboarding'); diff --git a/tests/Feature/Auth/ThreeAgSingleSignOnTest.php b/tests/Feature/Auth/ThreeAgSingleSignOnTest.php new file mode 100644 index 0000000..0d77d11 --- /dev/null +++ b/tests/Feature/Auth/ThreeAgSingleSignOnTest.php @@ -0,0 +1,188 @@ + '01J0ABCDEFGHJKMNPQRSTVWXYZ', + 'name' => 'Ada Lovelace', + 'email' => 'ada@example.com', + 'email_verified' => true, + ...$claims, + ]; + + $user = new SocialiteUser; + + return $user->setRaw($claims)->map([ + 'id' => $claims['sub'], + 'name' => $claims['name'], + 'email' => $claims['email'], + ]); +} + +/** + * Bind a provider that returns the given identity instead of calling out. + */ +function fakeProvider(SocialiteUser $identity, ?string $idToken = 'stub.id.token'): void +{ + Socialite::shouldReceive('driver')->with('3ag')->andReturn( + Mockery::mock(ThreeAgProvider::class, function (MockInterface $mock) use ($identity, $idToken): void { + $mock->shouldReceive('user')->andReturn($identity); + $mock->shouldReceive('idToken')->andReturn($idToken); + }) + ); +} + +test('the redirect route hands the user to 3AG Accounts', function () { + config()->set('services.3ag.base_url', 'https://accounts.3ag.app'); + config()->set('services.3ag.client_id', 'the-client-id'); + + $location = $this->get(route('auth.accounts.redirect')) + ->assertRedirectContains('https://accounts.3ag.app/oauth/authorize') + ->headers->get('Location'); + + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + + expect($query['client_id'])->toBe('the-client-id') + ->and($query['response_type'])->toBe('code') + ->and($query['scope'])->toBe('openid profile email') + ->and($query['code_challenge_method'])->toBe('S256') + ->and($query['code_challenge'])->not->toBeEmpty() + ->and($query['state'])->not->toBeEmpty(); +}); + +test('a new person gets an account on their first sign in', function () { + fakeProvider(fakeIdentity()); + + $this->get(route('auth.accounts.callback'))->assertRedirect(route('onboarding', absolute: false)); + + $user = User::query()->where('email', 'ada@example.com')->firstOrFail(); + + expect($user->oidc_sub)->toBe('01J0ABCDEFGHJKMNPQRSTVWXYZ') + ->and($user->name)->toBe('Ada Lovelace') + ->and($user->hasVerifiedEmail())->toBeTrue(); + + $this->assertAuthenticatedAs($user); +}); + +test('an existing account is matched by subject, not email', function () { + $user = User::factory()->create([ + 'email' => 'renamed@example.com', + 'oidc_sub' => '01J0ABCDEFGHJKMNPQRSTVWXYZ', + ]); + + fakeProvider(fakeIdentity(['email' => 'ada@example.com'])); + + $this->get(route('auth.accounts.callback'))->assertRedirect(); + + $this->assertAuthenticatedAs($user); + + expect(User::query()->count())->toBe(1); +}); + +test('an existing local account is linked by email exactly once', function () { + $user = User::factory()->create(['email' => 'ada@example.com', 'oidc_sub' => null]); + + fakeProvider(fakeIdentity()); + + $this->get(route('auth.accounts.callback'))->assertRedirect(); + + expect($user->fresh()->oidc_sub)->toBe('01J0ABCDEFGHJKMNPQRSTVWXYZ') + ->and(User::query()->count())->toBe(1); +}); + +test('an unverified email is never linked to an existing account', function () { + $user = User::factory()->create(['email' => 'ada@example.com', 'oidc_sub' => null]); + + fakeProvider(fakeIdentity(['email_verified' => false])); + + $this->get(route('auth.accounts.callback')) + ->assertRedirect(route('login')) + ->assertSessionHasErrors('email'); + + $this->assertGuest(); + + expect($user->fresh()->oidc_sub)->toBeNull(); +}); + +test('a tampered callback is rejected', function () { + Socialite::shouldReceive('driver')->with('3ag')->andReturn( + Mockery::mock(ThreeAgProvider::class, function (MockInterface $mock): void { + $mock->shouldReceive('user')->andThrow(new InvalidStateException); + }) + ); + + $this->get(route('auth.accounts.callback')) + ->assertRedirect(route('login')) + ->assertSessionHasErrors('email'); + + $this->assertGuest(); +}); + +test('the id token is kept so the session can be ended at the provider', function () { + fakeProvider(fakeIdentity(), idToken: 'the.id.token'); + + $this->get(route('auth.accounts.callback')); + + expect(session(ThreeAgSingleSignOn::ID_TOKEN))->toBe('the.id.token'); +}); + +test('logging out ends the session at the provider when SSO is the only way in', function () { + config()->set('services.3ag.sso_only', true); + + $user = User::factory()->create(['oidc_sub' => '01J0ABCDEFGHJKMNPQRSTVWXYZ']); + + $this->actingAs($user)->withSession([ThreeAgSingleSignOn::ID_TOKEN => 'the.id.token']); + + $this->post(route('logout'))->assertRedirectContains('/oauth/logout'); + + $this->assertGuest(); +}); + +test('logging out stays local while local sign in is still allowed', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->withSession([ThreeAgSingleSignOn::ID_TOKEN => 'the.id.token']); + + $this->post(route('logout'))->assertRedirect('/'); +}); + +test('SSO-only mode sends the login page to the provider', function () { + config()->set('services.3ag.sso_only', true); + + $this->get(route('login'))->assertRedirect(route('auth.accounts.redirect')); +}); + +test('SSO-only mode removes the local password endpoint', function () { + config()->set('services.3ag.sso_only', true); + + $user = User::factory()->create(); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertNotFound(); + + $this->assertGuest(); +}); + +test('local sign in still works while SSO-only mode is off', function () { + $user = User::factory()->create(); + + $this->post(route('login.store'), [ + 'email' => $user->email, + 'password' => 'password', + ])->assertRedirect(); + + $this->assertAuthenticatedAs($user); +}); From c1399e5f46e4e1eb6c23f264c690cf09484380ed Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Sat, 19 Sep 2026 23:25:09 +0600 Subject: [PATCH 2/3] Use MySQL in production, and test against it `deploy.php` shares only `storage` and `.env`, so a SQLite file under `database/` is replaced on every release, taking the shops and orders with it. The example environment now describes MySQL, which is what production should have been using all along. CI gets a MySQL service to match, because SQLite waves through differences the real engine enforces. That change immediately caught one: a query-log assertion in the report tests matched SQLite's double-quoted identifiers, which MySQL writes as backticks. It now compares against an unquoted column list and passes on both. Co-Authored-By: Claude Opus 5 --- .env.example | 15 +++++++++------ .github/workflows/tests.yml | 24 ++++++++++++++++++++++++ tests/Feature/Reports/ReportPageTest.php | 7 ++++++- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 5e35b78..7ff7b84 100644 --- a/.env.example +++ b/.env.example @@ -20,12 +20,15 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= +# `deploy.php` shares only `storage` and `.env`, so a SQLite file under +# `database/` is replaced on every release, taking the shops and orders with +# it. MySQL in production, always. +DB_CONNECTION=mysql +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=salesreport +DB_USERNAME=salesreport +DB_PASSWORD= SESSION_DRIVER=database SESSION_LIFETIME=120 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 55ad834..a27d661 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,6 +13,30 @@ jobs: ci: runs-on: ubuntu-latest + # Production runs MySQL. SQLite would wave through the constraints and + # column types the real engine enforces, so the suite uses the real engine. + services: + mysql: + image: mysql:8.4 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' + MYSQL_DATABASE: salesreport + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --silent" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + + env: + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: salesreport + DB_USERNAME: root + DB_PASSWORD: '' + steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/tests/Feature/Reports/ReportPageTest.php b/tests/Feature/Reports/ReportPageTest.php index 39a2893..db3f17b 100644 --- a/tests/Feature/Reports/ReportPageTest.php +++ b/tests/Feature/Reports/ReportPageTest.php @@ -358,8 +358,13 @@ // The only figure costing a row of work per order, rather than per // bucket, should be paid for once however many panels want it. + // SQLite quotes identifiers with double quotes and MySQL with backticks, + // and the suite runs on both, so compare against an unquoted column list. $walks = collect(DB::connection()->getQueryLog()) - ->filter(fn (array $query) => str_contains($query['query'], '"shop_id", "placed_at", "total", "refunded_total"')) + ->filter(fn (array $query) => str_contains( + str_replace(['"', '`'], '', $query['query']), + 'shop_id, placed_at, total, refunded_total' + )) ->count(); expect($walks)->toBe(1); From 44729326f4bd8468fc88a7afc71ab78601f16f8e Mon Sep 17 00:00:00 2001 From: Sourov Biswas Date: Sat, 19 Sep 2026 23:41:07 +0600 Subject: [PATCH 3/3] Keep Fortify's own logout behaviour for local sign-ins The override dropped Fortify's JSON branch and ignored the configured logout redirect, changing how every existing session logs out in order to serve the one case that needs diverting. Now only a session that came from the provider, with SSO-only on, is sent to end its session there. Everything else falls through to exactly what Fortify would have done. Co-Authored-By: Claude Opus 5 --- app/Http/Responses/LogoutResponse.php | 13 ++++++++++--- tests/Feature/Auth/ThreeAgSingleSignOnTest.php | 10 ++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/app/Http/Responses/LogoutResponse.php b/app/Http/Responses/LogoutResponse.php index e8fb5af..98448bb 100644 --- a/app/Http/Responses/LogoutResponse.php +++ b/app/Http/Responses/LogoutResponse.php @@ -4,7 +4,9 @@ use App\Http\Middleware\ThreeAgSingleSignOn; use App\Services\Auth\ThreeAgProvider; +use Illuminate\Http\JsonResponse; use Laravel\Fortify\Contracts\LogoutResponse as LogoutResponseContract; +use Laravel\Fortify\Fortify; use Symfony\Component\HttpFoundation\Response; /** @@ -13,6 +15,9 @@ * Without this, signing out of this app and then signing back in would silently * reuse the still-open session at the identity provider, which is not what * anyone means by "log out". + * + * Only sign-ins that came from the provider divert; everything else keeps + * Fortify's own behaviour, JSON branch included. */ class LogoutResponse implements LogoutResponseContract { @@ -20,10 +25,12 @@ public function toResponse($request): Response { $idToken = $request->attributes->get(ThreeAgSingleSignOn::ID_TOKEN); - if (! config('services.3ag.sso_only') || $idToken === null) { - return redirect('/'); + if (config('services.3ag.sso_only') && $idToken !== null) { + return redirect()->away(ThreeAgProvider::resolve()->logoutUrl($idToken)); } - return redirect()->away(ThreeAgProvider::resolve()->logoutUrl($idToken)); + return $request->wantsJson() + ? new JsonResponse('', 204) + : redirect(Fortify::redirects('logout', '/')); } } diff --git a/tests/Feature/Auth/ThreeAgSingleSignOnTest.php b/tests/Feature/Auth/ThreeAgSingleSignOnTest.php index 0d77d11..bb8cc75 100644 --- a/tests/Feature/Auth/ThreeAgSingleSignOnTest.php +++ b/tests/Feature/Auth/ThreeAgSingleSignOnTest.php @@ -149,6 +149,16 @@ function fakeProvider(SocialiteUser $identity, ?string $idToken = 'stub.id.token $this->assertGuest(); }); +test('logging out keeps Fortify\'s JSON response for clients that want one', function () { + $user = User::factory()->create(); + + $this->actingAs($user)->withSession([ThreeAgSingleSignOn::ID_TOKEN => 'the.id.token']); + + $this->postJson(route('logout'))->assertNoContent(); + + $this->assertGuest(); +}); + test('logging out stays local while local sign in is still allowed', function () { $user = User::factory()->create();