happyr/google-site-authenticator-bundle
Symfony bundle for adding Google Authenticator two-factor authentication to login flows. It generates and verifies time-based six-digit codes, integrates with Symfony security, and gives older Symfony apps a simple way to add TOTP-based 2FA without external services.
What this package actually is. happyr/google-site-authenticator-bundle is a thin Symfony bundle that gives you a ready-to-use, pre-authenticated Google\Client backed by a service account. It authenticates the application, not a user — the classic server-to-server flow. That is exactly the building block you need for cron jobs, report generators, bot accounts, or anything that reads/writes Google data on behalf of the app itself. The "instead of Domain-Wide Delegation" phrasing means: you do not need a Workspace admin to grant impersonation rights if all you need is access to resources you can share with the service account directly.
Laravel reality check first. There is no Laravel service provider here. This is a AbstractBundle/DependencyInjection extension for Symfony. For a Laravel app you have two honest paths:
google/apiclient directly (recommended for greenfield).The bundle's value in a Laravel assessment is mostly as a reference implementation and a config-shape you can mirror.
Version note (1.2.0). The bundle now targets Symfony 5.0 and has dropped Symfony 3.x. That's a framework-constraint change only — the service-account setup, config keys, and yielded Google\Client are unchanged, so a Laravel port (below) is unaffected. Symfony 6/7 is still unsupported.
Bootstrap checklist (works for both Symfony usage and a Laravel port):
[email protected]).Symfony config shape (so you know what the bundle expects — mirror this in Laravel):
# config/packages/google_site_authenticator.yaml (Symfony only)
happyr_google_site_authenticator:
application_name: "Reporting Bot"
client_id: "123-abc.apps.googleusercontent.com"
service_account: "[email protected]"
key_file: "%kernel.project_dir%/config/google/service-account.json"
scopes:
- "https://www.googleapis.com/auth/analytics.readonly"
- "https://www.googleapis.com/auth/spreadsheets.readonly"
Then pull the authenticated client from the container (check the bundle's services.yml for the exact service ID) and hand it to any Google\Service\* class.
Minimal Laravel equivalent — this is what you'll actually ship:
// app/Services/GoogleSiteClient.php
namespace App\Services;
use Google\Client as GoogleClient;
class GoogleSiteClient
{
public function __construct(private array $cfg) {}
public function make(array $scopes, ?string $subject = null): GoogleClient
{
$client = new GoogleClient();
$client->setApplicationName($this->cfg['application_name']);
$client->setAuthConfig($this->cfg['key_file']); // JSON (or legacy .p12)
$client->setScopes($scopes);
if ($subject !== null) {
$client->setSubject($subject); // only with Domain-Wide Delegation
}
return $client;
}
}
// config/google-site.php
return [
'application_name' => env('GOOGLE_APP_NAME', config('app.name')),
'key_file' => env('GOOGLE_KEY_FILE', storage_path('app/google/service-account.json')),
];
// AppServiceProvider::register()
$this->app->singleton(GoogleSiteClient::class, fn ($app) =>
new GoogleSiteClient(config('google-site'))
);
First use case to validate end-to-end: list files in a Drive folder shared with the service account, or fetch a single range from a shared Sheet. It's low-stakes, returns data immediately, and proves the whole credential + sharing chain works before you build anything on it.
Wrap the client, don't sprinkle it. The bundle's whole point is "client already authed." Keep that ergonomic benefit: inject GoogleSiteClient, request a client scoped to the job, and pass it to a service object. One client per scope set — don't try to build a mega-client with every scope; it makes auditing and least-privilege harder.
Scheduled/reporting jobs are the dominant use case. A queued job that pulls data and writes a summary is the canonical server-to-server pattern:
class PullAnalyticsReport implements ShouldQueue
{
public function __construct(public string $date) {}
public function handle(GoogleSiteClient $siteAuth): void
{
$client = $siteAuth->make(['https://www.googleapis.com/auth/analytics.readonly']);
$analytics = new \Google\Service\AnalyticsData($client);
$response = $analytics->properties->runReport(
'properties/123456789',
new \Google\Service\AnalyticsData\RunReportRequest([/* ... */])
);
// persist $response->getRows() ...
}
}
Domain-Wide Delegation pattern (impersonation). This is where "instead of DWD" flips — you use DWD deliberately. When you need to act as a specific Workspace user (send mail as noreply@, write to a user's calendar, access their Drive), you set the subject:
$client = $siteAuth->make(
['https://www.googleapis.com/auth/gmail.send'],
'[email protected]' // the impersonated user
);
Requires the admin to authorize the service account's client ID with those scopes in the Admin console. Without that, you get unauthorized_client. Keep a distinct helper for delegated clients so it's obvious in code review which flows impersonate and which don't.
Token reuse within a process. Google\Client refreshes its own access token transparently within a request, but nothing persists across requests. If you fire many API calls in one job, build the client once and reuse it (the authenticated client is effectively your rate/expiry anchor). For long-running workers, cache the access token yourself:
$token = Cache::remember('google.site.token', 3500, function () use ($client) {
return $client->fetchAccessTokenWithAssertion();
});
$client->setAccessToken($token);
This avoids re-signing a JWT for every single API call in a fan-out job.
Configuration via env + config file. Mirror the bundle's YAML with a Laravel config file and env vars for the key path and scopes. Never bake scope lists into classes — scopes are policy, not code. A config('google-site.scopes.sheets') map keeps them reviewable.
Testing pattern. Don't hit Google in tests. Type-hint against an interface, or in tests assert on the shape of the client (scopes, subject) and mock the Google\Service\* layer. A fake GoogleSiteClient returning a stub GoogleClient is enough; integration tests with a real service account belong in a separate, explicitly-tagged suite.
Extension point worth knowing. Because the bundle just yields a GoogleClient, anything that accepts one works — Google\Service\Sheets, Drive, Calendar, Gmail, BigQuery, and libraries like google/analytics-data. That's the leverage: one credential source, many API consumers.
"403 / insufficient permissions" is almost never auth. Nine times out of ten the service account authenticated fine but the resource was never shared with [email protected]. Verify sharing by adding a human with the same email-style grant — if a person can't see it, the SA can't either. For Search Console / Analytics, verify that exact email appears in the property's user list.
Enable the API, per API. Auth succeeding but 403 accessNotConfigured means the specific API (Sheets, Drive, Analytics Data) isn't enabled on the Cloud project. The bundle cannot fix this.
Domain-Wide Delegation has two independent switches. (1) Admin console grants the SA's client ID the scopes; (2) code calls setSubject() with a real, existing Workspace user. Miss either and you get unauthorized_client / invalid_grant. Also: DWD scopes are snapshotted at grant time — changing scopes in code requires re-granting in the Admin console, which is a silent-deploy footgun.
client_id vs service-account email. The bundle config asks for both client_id and service_account. They are different values. The client_id (a numeric-ish xxx.apps.googleusercontent.com ID) is what the Admin console authorizes for DWD; the service-account email is what you share resources with. Swapping them is a classic misconfiguration.
Key file hygiene. Keep the JSON/P12 out of VCS (storage/app/google/ + .gitignore), lock file permissions to the app user, and rotate keys deliberately. The bundle's key_file supports the legacy .p12; Google\Client::setAuthConfig() also accepts JSON — prefer JSON for new setups.
Framework constraint is still the adoption risk — but the story shifted with 1.2.0. Symfony 5.0 is now supported and 3.x is dropped, so the package is no longer strictly a Symfony 3/4-era artifact. That said it still stops at Symfony 5: on Symfony 6/7 expect Composer constraint conflicts, and you'll need a fork, a version override, or to copy the (small) service wiring into your own bundle. If you're on Symfony 5 already, 1.2.0 is likely a drop-in upgrade; if you're on Symfony 3 you're now cut off and must either pin the previous release or upgrade the framework. On Laravel this remains moot if you port the logic — which is why porting is often the pragmatic choice. Note the release notes only touch framework constraints; no auth, config-key, or API-surface changes, so existing configs and ports carry over untouched.
Scopes are fixed on the client at creation. Need to add Sheets access to an existing reporting job? Build a new client with the wider scope set — and if DWD is involved, remember the re-grant above. A single long-lived client with all scopes is convenient and bad for security review.
Error messages are terse. Google returns { "error": { "code": 401, "message": "Invalid Credentials" } } with no hint. Log the full exception body, the scopes you requested, and the subject (if any) on failure — that trio identifies almost every auth issue. Check $client->getAccessToken()['created'] when debugging expiry-related flakiness.
Clock skew breaks JWT assertion signing. If the server clock drifts, service-account assertions fail with opaque invalid_grant. Keep NTP healthy on workers; this bites scheduled jobs on long-running VMs.
Per-process token caching matters for fan-out. A nightly job doing hundreds of API calls will re-sign and re-fetch if you naively rebuild the client per call. Build once, cache the token (see above), and respect per-API quotas — service accounts share the Cloud project's quota, so a noisy job can throttle everything else in the same project.
How can I help you explore Laravel packages today?