Skip to content

feat: add BaseServiceAccountProvider for RFC 7523 JWT-bearer auth - #3442

Merged
chubes4 merged 7 commits into
mainfrom
feat-3437-service-account-provider
Sep 4, 2026
Merged

feat: add BaseServiceAccountProvider for RFC 7523 JWT-bearer auth#3442
chubes4 merged 7 commits into
mainfrom
feat-3437-service-account-provider

Conversation

@chubes4

@chubes4 chubes4 commented Sep 4, 2026

Copy link
Copy Markdown
Member

Refs #3437. The core half — consumer migration follows in data-machine-business and extrachill-events, which is where the deletions land.

Problem

Core shipped BaseOAuth2Provider, BaseOAuth1Provider, and HttpBasicAuthProvider but had no service-account primitive:

grep -rn "RS256|openssl_sign|jwt|service_account" inc/Core/OAuth/   ->  0 matches
grep -rln "openssl_sign" inc/                                       ->  0 files

RFC 7523 (urn:ietf:params:oauth:grant-type:jwt-bearer) is the standard server-to-server grant. With no base to extend, every consumer hand-rolled it inside an ability class. Three copies exist:

Consumer Repo
GoogleAnalyticsAbilities data-machine-business
GoogleSearchConsoleAbilities data-machine-business
VenueDiscoveryAbilities extrachill-events — a different plugin

Worth stressing: downstream is not ignoring an available abstraction. It composes core correctly everywhere core provides one (GoogleAuth extends BaseOAuth2Provider, SlackAuth/DiscordAuth extends BaseAuthProvider). The hand-rolling happens precisely where core is silent.

The drift was a real bug

GA and GSC are near-identical — same claims, same endpoint, differing only in scope string, error prefix, brace style, and this:

- $cached = get_transient( self::TOKEN_TRANSIENT );        // GA  — site-scoped
+ $cached = get_site_transient( self::TOKEN_TRANSIENT );   // GSC — network-wide

On a 10-site network GA re-minted the same token per site: up to 10× the token-endpoint round trips and 10× the RSA signings for one credential. Nothing reconciled the copies because nothing could. Symptom fixed in Extra-Chill/data-machine-business#120; this removes the class of bug.

Design

BaseServiceAccountProvider extends BaseAuthProvider owns claim assembly, RS256 signing, the jwt-bearer exchange, and caching. Subclasses supply only get_token_endpoint().

Two properties are structural, not per-consumer decisions:

  • Network-wide token caching. The credential is stored with get_site_option(), so its token is valid across the network. Leaving this to each consumer is exactly what produced the GA bug.
  • Scope is part of the cache key. One credential serves several consumers — GA, GSC, and Places all read the same config today — so their entries must not evict each other.

Also accepts a raw service-account JSON blob, since providers hand one out and operators paste it verbatim.

Optional domain-wide delegation via sub, omitted unless configured — an empty sub would make every request look like an impersonation attempt.

Encryption gap closed

Added private_key, service_account_json, credentials_json to ENCRYPTED_FIELDS.

Every OAuth access token on the platform was encrypted at rest while service-account RSA private keys sat plaintext in wp_sitemeta. That is inverted relative to risk: a private key is long-lived and unscoped; a token is short-lived and scoped.

Tests

16 tests. The signature is verified cryptographically against a real RSA keypair, not asserted by shape — a JWT that parses but does not verify is precisely the failure this exists to prevent.

Covers: RS256 header, claim contents, exp/iat relationship, sub omitted-by-default and sent-when-configured, network-wide cache reuse, per-scope isolation, cache clearing, four specific error codes, JSON-blob credentials, and that the new fields are marked for encryption.

Compatibility

Purely additive. No existing provider changes behavior. The three existing implementations keep working until migrated.

Follow-up

Consumer migration is where lines actually disappear — roughly 130 lines of duplicated JWT code across two repos collapse into extends BaseServiceAccountProvider plus a token endpoint. Tracked in Extra-Chill/data-machine-business#119.

Core shipped OAuth2, OAuth1, and HTTP Basic providers but had no
service-account primitive, so every consumer hand-rolled JWT assembly,
RS256 signing, and token caching inside its own ability class.

Three independent copies exist today - GoogleAnalyticsAbilities,
GoogleSearchConsoleAbilities, and VenueDiscoveryAbilities in a
different plugin entirely. They drifted, and the drift produced a real
bug: GA cached its token with get_transient() while GSC used
get_site_transient() for the identical flow, so on a 10-site network
the same credential minted up to 10 tokens.

This class owns the mechanism. Vendor specifics - token endpoint and
scopes - stay in the subclass.

Two properties are structural rather than left to subclasses:

- Tokens cache network-wide. A service account credential is stored via
  get_site_option(), so the token it mints is valid across the network.
  Making this a per-consumer decision is what produced the GA bug.
- Scope is part of the cache key, so one credential can serve several
  consumers without evicting each other.

Also adds private_key, service_account_json, and credentials_json to
ENCRYPTED_FIELDS. Every OAuth access token on the platform was
encrypted at rest while service account RSA private keys sat plaintext
in wp_sitemeta - protection inverted relative to risk, since a private
key is long-lived and unscoped where a token is short-lived and scoped.

Supports optional domain-wide delegation via a subject claim, omitted
unless configured.

Sixteen tests including cryptographic signature verification against a
real RSA keypair. A JWT that parses but does not verify is exactly the
failure this primitive exists to stop recurring.

Refs #3437
homeboy-ci Bot added 6 commits September 4, 2026 13:42
Three findings, all real.

PHPCS flagged base64_encode() as possible obfuscation. RFC 7515
requires base64url for JWT segments, so this is a wire format.
Suppressed with the reason recorded, matching the existing convention
in BaseAuthProvider and JobsCommand.

PHPStan flagged '' === $tag as always false. $tag is filled by
reference by openssl_encrypt(), which static analysis cannot model. The
check is real - an empty auth tag produces an undecryptable envelope -
so it is kept and made type-honest rather than removed. The line is
pre-existing, pulled into lint scope by the ENCRYPTED_FIELDS addition.

The test runner reported zero executed tests. The concrete stub was
named TestServiceAccountProvider, which matches PHPUnit's Test* class
discovery prefix; it was collected as a test class, contributed no test
methods, and the suite reported nothing ran. Renamed to
StubServiceAccountProvider.
strlen() did not help - PHPStan still infers $tag as non-empty because
openssl_encrypt() fills it by reference, so any comparison against the
empty case narrows to always-false.

empty() is not folded the same way. The guard itself is unchanged in
behavior and still catches an empty auth tag, which would otherwise
produce an envelope that cannot be decrypted.
The sandbox bootstrap failed loading the test file: the stub subclass
inherited BaseAuthProvider::get_config_fields() as abstract and did not
implement it, so the class was not concrete and the whole shard
reported zero executed tests.

Implementing it in the primitive rather than the stub is the right
level. Every service account flow takes the same input - a key file and
an optional delegated subject - so the concrete default belongs with
the mechanism. Subclasses override only when a vendor needs something
different.

A minimal subclass now needs to implement exactly one method,
get_token_endpoint(), which is the intended contract.
setUpBeforeClass() called openssl_pkey_new(), which returns false in
the sandbox PHP build, so openssl_pkey_get_details() got false and the
whole class errored with a TypeError before any assertion ran.

Commits a 2048-bit test keypair as a fixture instead. This also makes
the signature assertion deterministic rather than depending on runtime
key generation.

The key is a test fixture and protects nothing; it exists so
test_assertion_signature_verifies_against_the_public_key can do real
cryptographic verification rather than assert JWT shape.
Two test shards failed at 'install Homeboy extension' with exit code
100 and review test not_run, while sibling shards on the same commit
passed. Nothing in the diff is involved - the failure is before any
test executes.
extract_credential() required both client_email and private_key before
treating discrete fields as the configured credential. A config with
only client_email fell through to the JSON branch and returned
datamachine_service_account_missing - telling an operator no credential
was configured when one was, just incompletely.

Any discrete credential field now means discrete fields were
configured. Completeness is checked separately in
get_service_account(), which produces the actionable "missing X" error.

Caught by test_incomplete_credential_is_a_specific_error, which is the
kind of bug that would otherwise surface as a confusing support
question rather than a stack trace.
@chubes4
chubes4 merged commit 164326e into main Sep 4, 2026
30 checks passed
@chubes4
chubes4 deleted the feat-3437-service-account-provider branch September 4, 2026 15:18
chubes4 added a commit to Extra-Chill/data-machine-business that referenced this pull request Sep 4, 2026
…121)

* refactor: migrate Google service account auth to the shared provider

Deletes two hand-rolled copies of the RFC 7523 JWT-bearer flow -
GoogleAnalyticsAbilities and GoogleSearchConsoleAbilities - in favour
of chubes4/data-machine's BaseServiceAccountProvider.

Net 211 lines removed, 122 added, most of the additions being the
vendor provider that replaces both copies.

GoogleServiceAccountAuth supplies only Google's token endpoint and the
stored credential. Core owns claim assembly, RS256 signing, the token
exchange, and network-wide caching.

Fixes a second instance of the same drift the copies caused. GA cleared
its cached token on config save with delete_transient(), which is
site-scoped, while the token it was clearing is network-wide - so a
config change never actually invalidated the cache. GSC used
delete_site_transient() correctly. Both now clear through the provider,
which owns the cache scope, so the question cannot be answered
differently in two places again.

Legacy credential storage is preserved. The provider falls back to each
consumer's existing option (datamachine_ga_config,
datamachine_gsc_config) when no provider-scoped config exists, so
current installs keep working with no migration step. New configuration
flows through provider storage, where the private key is encrypted at
rest.

The per-ability TOKEN_TRANSIENT constants are removed; both were dead
after migration and both external call sites now route through the
provider.

Depends on Extra-Chill/data-machine#3442.

Refs #119

* refactor: inherit service account config fields from the primitive

get_config_fields() now has a concrete default in
BaseServiceAccountProvider, since every service account flow takes the
same input. The vendor provider only needed the token endpoint and the
legacy option fallback.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant