diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml index 1d532ac778..78e64a7456 100644 --- a/.github/workflows/redis.yml +++ b/.github/workflows/redis.yml @@ -66,6 +66,7 @@ jobs: QUEUE_CONNECTION=redis vendor/bin/paratest --max-processes=15 tests/Integration/Queue vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis + vendor/bin/paratest --max-processes=15 tests/Integration/Saloon/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Session/Redis redis_cluster: @@ -213,6 +214,7 @@ jobs: vendor/bin/phpunit --no-progress tests/Integration/Pennant/Redis QUEUE_CONNECTION=redis vendor/bin/phpunit --no-progress tests/Integration/Queue vendor/bin/phpunit --no-progress tests/Integration/RateLimiter/Redis + vendor/bin/phpunit --no-progress tests/Integration/Saloon/Redis vendor/bin/phpunit --no-progress tests/Integration/Session/Redis # This explicit list contains the topology-neutral Redis tests; the remaining files require standalone-only behavior. vendor/bin/phpunit --no-progress \ @@ -286,4 +288,5 @@ jobs: vendor/bin/paratest --max-processes=15 tests/Integration/RateLimiter/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Redis vendor/bin/phpunit --no-progress tests/Integration/Reverb/ClearStateCommandTest.php + vendor/bin/paratest --max-processes=15 tests/Integration/Saloon/Redis vendor/bin/paratest --max-processes=15 tests/Integration/Session/Redis diff --git a/composer.json b/composer.json index f63ca001a2..f728758c4d 100644 --- a/composer.json +++ b/composer.json @@ -131,7 +131,7 @@ "mockery/mockery": "^1.6.15", "opis/json-schema": "^2.6.0", "pda/pheanstalk": "^8.0.2", - "phpstan/phpstan": "^2.3", + "phpstan/phpstan": "2.3.1", "phpunit/phpunit": "~13.4.3", "pusher/pusher-php-server": "^7.2", "resend/resend-php": "^1.0", diff --git a/docs/plans/2026-10-24-1738-saloon-oauth-token-management.md b/docs/plans/2026-10-24-1738-saloon-oauth-token-management.md new file mode 100644 index 0000000000..2e018f88c7 --- /dev/null +++ b/docs/plans/2026-10-24-1738-saloon-oauth-token-management.md @@ -0,0 +1,250 @@ +# Saloon token management + +## Outcome + +Add two opt-in capabilities to `hypervel/saloon`: + +- Cache independently reacquirable tokens, such as client-credentials grants, without an application repeating expiry and concurrency handling. +- Persist connected OAuth accounts, cache their credentials, and renew rotating refresh tokens without holding database connections through provider calls. + +Keep manual grant methods, immutable connectors, existing authenticators, request modifiers and application-owned provider credentials. Applications choose their cache stores and publish the account migration only when using durable accounts. No new package, store-driver abstraction, provider registry, scheduler or UI is needed. + +Status: in PR review; complete the remaining CI/bot review cycle before merge. The only approved existing API departure is scope metadata on `OAuthAuthenticator`, `AccessTokenAuthenticator` and the corresponding grant factories. No Socialite API change is required. + +## Existing boundaries and evidence + +Read the current implementations when changing their callers: + +| Existing source | Consequence for this design | +|---|---| +| `Contracts/OAuthAuthenticator.php`, `Http/Auth/AccessTokenAuthenticator.php` | Token values already own expiry and authentication; extend them instead of adding a parallel token DTO. | +| `Traits/OAuth2/{ClientCredentialsGrant,ClientCredentialsBasicAuthGrant,AuthorizationCodeGrant,ParsesOAuthTokenResponses}.php` | Keep existing grant construction, configuration and per-call modifiers. Both response factories must carry scopes. | +| `SaloonManager::send()`, `Http/PendingRequest.php` | Authentication runs when each pending request is built. A normal business-request retry creates a new pending request; never send a business request from token-management code. | +| `Cache/CacheKey.php` | Default response-cache identity includes authorization headers. Token renewal naturally creates a new response-cache identity. | +| `Cache/ModelCacheCoordinator.php`, `ModelCacheStoreValidator.php` | Reuse committed model caching, fill/invalidation coordination and supported-store validation. Do not build another coherence protocol. | +| `Database/Eloquent/Model.php` | Ordinary saves do not compare a version. Use fresh credential revisions and conditional renewal writes. Models are transient; serialization clears cast caches. | +| `Coroutine/Locker.php`, `Database/ConnectionResolver.php` | Keyed owner/waiter coordination already exists. Release idle connections before provider calls and backoff; do not hold transactions across them. | +| `Socialite/Two/{Token,AbstractProvider}.php` | A refresh callback can adapt the existing token. An empty approved-scopes array does not distinguish an omitted field. | + +Saloon paths are relative to `src/saloon/src`; other package anchors refer to `src/{package}/src`. Existing tests under `tests/Saloon/Feature/Oauth2`, authentication tests and cache integration tests establish the surrounding behavior. + +Relevant external evidence: + +- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749.html), §§3.3, 5.1, 5.2 and 6: scope metadata, token errors and refresh behavior. +- [RFC 9700 §4.14.2](https://www.rfc-editor.org/rfc/rfc9700.html#section-4.14.2): replay of a rotated refresh token can revoke the active grant. +- [OAuth2 Proxy renewal](https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/middleware/stored_session.go): lock, reread, persist, release; transient and rejected-credential failures differ. +- [Supabase session recovery](https://supabase.com/docs/guides/auth/sessions#what-is-refresh-token-reuse-detection-and-what-does-it-protect-from): retries can be supported by provider reuse grace and parent-token recovery. Do not assume those guarantees for arbitrary providers. +- [Saloon OAuth addon](https://github.com/SimpleSquid/saloonphp-oauth), available in `references/saloonphp-oauth`: useful comparison, not a port. Do not copy class-name-only token keys, mandatory persistence, or best-effort saving of rotated tokens. + +## 1. Token metadata + +Append `?array $scopes = null` to `AccessTokenAuthenticator` and add `getScopes(): ?array` to `OAuthAuthenticator`, documented as `null|list`. Preserve current parameter names/order. Append the same optional metadata to both protected `createOAuthAuthenticator()` signatures and update overrides and fixtures together. + +- `null`: provider did not report scopes; a renewal retains stored scopes. +- `[]`: explicitly supplied empty set; do not replace it with a fallback. +- Parse supplied space-delimited scope strings at the shared OAuth response boundary. Treat absent/null/empty-string metadata as unknown, and reject other wire types. An empty wire string is not a valid reported OAuth scope; it must not erase stored scopes. Custom callbacks can supply normalized lists, including an intentional `[]`, directly. Do not infer effective permissions from the requested scopes when the response omits them. +- Preserve omitted refresh tokens through the existing fallback path. Scope handling must work for client credentials, Basic-auth client credentials and authorization-code refreshes. +- Mark credential parameters sensitive. Do not log token response bodies, credentials, or serialized authenticators. + +## 2. Cached acquisition + +Public usage: + +```php +use Hypervel\Saloon\Contracts\OAuthAuthenticator; +use Hypervel\Saloon\Http\Auth\CachedTokenAuthenticator; + +$authenticator = new CachedTokenAuthenticator( + key: $grantIdentity, + resolver: fn (): OAuthAuthenticator => $oauth->getAccessToken(), + store: 'redis', +); + +$authenticator = $oauth->cachedAccessToken( + scopes: ['read'], + store: 'redis', + requestModifier: fn ($request) => $request->timeout(20), +); + +$authenticator->forget(); +``` + +`CachedTokenAuthenticator` implements `Authenticator`. Its constructor configuration is immutable; it holds one completed entry for its fixed identity. The entry contains the actual `OAuthAuthenticator` and one computed renewal time, preserving custom token authentication behavior. Never serialize resolver closures or pending requests. + +Add `cachedAccessToken()` to the existing client-credentials trait, inherited by the Basic-auth variant. Keep scopes, scope separator, optional store, per-call request modifier, and an optional extra `key` contribution. This convenience returns an authenticator; it does not fetch during connector construction. + +### Identity and lifetime + +- Hash an unambiguous encoding with SHA-256. The convenience identity includes connector class, resolved token endpoint, client ID, client secret, effective requested scopes and explicit key contribution. The generic constructor requires the caller to identify its complete grant. Never put raw credentials in a key. +- A modifier, overridden request factory or custom audience can change the grant. Document the extra key contribution for those cases; do not hash closures or prepare a whole pending request on each cache hit. +- Resolve stores as explicit store → `saloon.cache.store` → framework default. `worker-array` shares entries within a worker; the ordinary `array` driver does not share across request coroutines. Redis supports sharing between workers. +- Calculate renewal time once: expiry minus a small early allowance, capped at half the token's remaining lifetime. Store that absolute time; rereading an entry does not restart its life. Use a 30-second maximum allowance. +- Cache only through renewal time. A token without expiry follows the existing non-expiring contract; the caller must provide expiry when the provider documents one. Reject an already-expired newly acquired token without an internal fetch loop. +- `forget()` clears this object's entry and its shared cache entry. It is not provider revocation and does not erase entries retained by other live authenticator instances. + +### Coordination + +Add one internal `Http/Auth/TokenAcquisition.php` service, used by both cached acquisition and account renewal, reusing `Locker` for owner/waiter coordination. Keep **active acquisitions only** in an instance property, keyed by operation kind, full identity and store. A `Data/TokenAcquisitionResult.php` holder carries the completed token or provider failure for existing waiters. Remove the service entry in `finally`, including owner cancellation; waiters retain their captured holder until they resume. This avoids serial retry chains after a shared failure. No completed-token registry or exception history remains in worker memory. The service is safe to auto-singleton; container teardown resets it, so it needs no static `flushState()` hook. + +Keep cancellation local to the canceled coroutine; a canceled waiter must not release another caller's lock. An owner cancellation cleans up and wakes waiters without sharing its `CanceledException` as their cancellation. With no completed result, waiters use Locker's next-owner rule: one becomes the new owner, rereads the cache/account and starts a fresh attempt; others wait. Durable claim cleanup/recovery still applies. No detached work is spawned. A genuinely later call may also try again. Separate workers may acquire independent client-credentials tokens; do not add a distributed acquisition lock for grants where this is harmless. + +Use the existing cache serialization allowlist for built-in token/entry classes. Custom authenticators use its normal application registration mechanism. Capture the selected token locally while applying it; resource headers and merchant context stay on the connector/request, never in shared token state. + +## 3. Durable accounts and public API + +Add these files relative to `src/saloon/src`: + +- `OAuthAccount.php` and `OAuthAccounts.php` for the model and account service, following Sanctum's root model placement. +- `Traits/HasOAuthAccounts.php` for the owner relation. +- `Http/Auth/CachedTokenAuthenticator.php` and `Http/Auth/OAuthAccountAuthenticator.php` beside existing authenticators, plus the shared acquisition files specified above. +- `Console/Commands/OAuthAccountsTableCommand.php` and `Console/Commands/stubs/oauth-accounts.stub`, following Saloon's existing command directory and `MigrationGeneratorCommand`'s stub loading. + +`OAuthAccounts` owns model selection, partition configuration and account operations; it is a worker-safe service with no current-account or current-partition field. Use dependency injection and existing auto-singleton semantics, not a new facade or redundant bindings. + +```php +use Hypervel\Saloon\OAuthAccounts; + +// Application provider; optional model customization. +OAuthAccounts::useAccountModel(App\Models\OAuthAccount::class); + +$account = $user->oauthAccounts()->create([ + 'provider' => 'gitlab', + 'provider_account_id' => $providerUserId, + 'access_token' => $token->getAccessToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires_at' => $token->getExpiresAt(), + 'scopes' => $token->getScopes(), +]); + +$authenticator = $oauth->accountAuthenticator($account); + +// Other OAuth clients can supply the same token contract. +$authenticator = $account->authenticator( + refreshUsing: fn ($current) => $provider->refresh($current), +); +``` + +`AuthorizationCodeGrant::accountAuthenticator()` adapts its existing `refreshAccessToken()` method; retain a per-call request modifier. The generic callback receives `OAuthAuthenticator` and returns `OAuthAuthenticator`. The account authenticator captures model class, resolved connection/table, primary key and persisted partition, not a mutable account model or a permanent token copy. + +`OAuthAccount::newOAuthAuthenticator()` constructs the current token from persisted fields and is the model extension point for custom authentication behavior; `authenticator()` constructs the renewing account authenticator. The default token represents OAuth bearer credentials. Persist defined credential fields, not arbitrary serialized provider objects. Provide `clearCredentialCache()` for application-controlled bulk/quiet mutations, following the existing token-model cache conventions. Credential writes through these escape hatches must also replace the revision, calculate the renewal time and clear obsolete claims; cache invalidation alone does not fence an in-flight renewal. + +### Migration and casts + +Add `make:oauth-accounts-table`, alias `oauth-accounts:table`, using `MigrationGeneratorCommand`. Generate `oauth_accounts`; do not load or run a migration automatically. Applications may adjust the published migration and model table/connection. + +| Column | Contract | +|---|---| +| `id` | UUID primary key; framework UUID model concern | +| `owner_type`, `owner_id` | Morph owner, using framework morph-key conventions | +| `provider`, nullable `provider_account_id` | Provider reference and optional external account identifier | +| `access_token`, nullable `refresh_token` | Text columns, encrypted casts and hidden serialization | +| nullable `expires_at` | `dateTime`, immutable cast using the application's date conventions | +| nullable `refresh_at` | `dateTime`, immutable cast; fixed early-renewal time calculated when credentials are installed | +| nullable `scopes` | `text()`, array cast; opaque list read back whole, preserving omission versus empty set | +| `credential_revision` | Fresh UUID whenever credentials change | +| nullable `refresh_claim`, `refresh_started_at` | UUID ownership of one renewal attempt and `timestamp()` recording its start | +| timestamps | Ordinary created/updated timestamps | + +Index owner lookup and provider/external-account lookup. Do not impose uniqueness on owner + provider + external account: distinct client registrations or permission grants may legitimately share those values. Reconnect replaces a selected account row's credentials; it is not inferred from that tuple. + +Keep client secrets in application configuration. Encrypt stored access/refresh tokens, including cached model attributes; never hash tokens that must later be sent to a provider. Use existing encryption casts and key management. These protections complement normal cache/database access controls; cached standalone token values still contain usable credentials. + +### Ordinary mutations and cache configuration + +Use model events for normal saves/deletes and committed invalidation. The credential `saving` hook assigns a fresh UUID revision and clears an obsolete claim when credential attributes change. On creation or a changed access token/expiry, that same hook computes `refresh_at` from save time using the capped allowance in §4, or sets it to null when expiry is absent. Thus relation `create()` and ordinary reconnect `save()` need no manually supplied deadline. Unrelated metadata saves must neither move the deadline nor clear a live claim. + +All account reads, claims, conditional writes and cleanup start from the account model's `newQuery()`, preserving global scopes and custom Eloquent builders. Do not use base/raw queries to bypass them. Conditional builder updates need explicitly cast/encrypted attributes, revision/deadline calculation and invalidation because they do not run model events. Raw, quiet and bulk application mutations retain normal Eloquent responsibilities: callers invalidate affected account keys; do not build a second Eloquent mutation system. + +Add `saloon.oauth_accounts.cache` with `enabled` (false), `store` (null = framework default), `ttl` (300 seconds), and `prefix` (`saloon:oauth-accounts:`). Add `saloon.oauth_accounts.lock_store` (null = selected account-cache store). Validate selected stores lazily when durable accounts are used; unused installations need no account table or lock connection. + +- Cache enabled: use `ModelCacheCoordinator` and `ModelCacheStoreValidator`; cache a relations-unset model, using normal encrypted casts and model serialization. Register the configured model with the cache serialization policy. +- Warm cache hits require no database query and no per-request write. No `last_used_at` update or worker-lifetime private token slot for durable accounts. +- Cache disabled: read the authoritative row; renewal still uses the shared lock store. +- Key identity encodes model class, captured resolved connection **name**, table name, account ID and partition, hashed with SHA-256. Capture the selected connection even when the model follows the default. Write aliases retain their requested connection for transactions but normalize to the base name in the hash. Do not inspect physical database configuration. Reuse the identity for reads, writes, invalidation and locks; cache-fill and renewal locks are distinct. +- Stores must span the deployment using those accounts. Redis/database suit multiple hosts; file/Swoole scope must match the deployment. Reject worker-local/stack/failover stores for durable credential caching; never hide a cache outage by serving stale credentials. +- Use writer reads for renewal and recovery. Never publish uncommitted credentials. Cache writes and invalidations follow `afterCommitOrNow`; normal application transactions bypass shared fills through the existing coordinator. + +## 4. Renewal and recovery + +Renewal uses a short cache lock for waiting and a committed row claim for coordination that survives a cache restart. Neither provides exactly-once provider execution. The recovery policy intentionally permits another attempt after transient failures or an abandoned claim; delayed provider requests can still overlap and trigger reuse detection. + +### When to renew + +When installing credentials, compute `refresh_at` once using the same expiry calculation as cached acquisition: `expires_at - min(30 seconds, remaining lifetime / 2)`. Recompute on successful renewal or a replacement access token/expiry, never on cache reads or unrelated metadata saves. Persisting this deadline avoids both resetting it on reads and immediately re-renewing short-lived tokens. + +- Before `refresh_at`, apply usable credentials without renewal. A current claim still takes precedence over the old token. +- At/after `refresh_at`, renew if a refresh token exists. Inside a caller transaction, defer early renewal while the token remains unexpired and has no claim. Without a refresh token, use the access token until its actual expiry, then throw `OAuthAccountReconnectRequiredException` before taking a claim. +- No expiry means no automatic renewal and a null renewal time. + +### One attempt + +1. Resolve/check the current partition and read credentials. Inside an applicable caller transaction, an unexpired token without a claim remains usable, including during the early-renewal window. Otherwise reject renewal before waiting or contacting the provider. Do not escape the caller into a detached coroutine. +2. Enter worker-local acquisition coordination. Existing same-worker waiters share the entire bounded attempt chain and its final result/error. +3. Acquire the account's cache lock, then reread the writer. If another attempt installed usable credentials, use them. Missing/deleted accounts fail; never recreate them. A present claim blocks use of the old token because some providers invalidate it immediately on refresh. +4. Validate locally before claiming. Atomically set a fresh `refresh_claim` UUID and `refresh_started_at`, conditioned on account, partition, credential revision and a null or abandoned claim. A claim is abandoned after 300 seconds. This is a recovery interval, not proof that the old provider operation has ended. +5. Commit the claim and invalidate its cache before provider I/O. Proceed only when one row changed; an uncertain claim write does not authorize a provider request. Release idle database connections. +6. Invoke one provider refresh attempt. The Saloon convenience applies the caller's modifier, then disables transport retries for this request. Custom callbacks must also perform one provider attempt. +7. On success, prepare encrypted credentials through the model's casts, retain omitted refresh token/scopes, calculate `refresh_at`, and conditionally store credentials + a fresh revision + cleared claim. Match account, partition, read credential revision **and claim UUID**. Commit and invalidate before exposing the result through the model's token factory. Reject an expired access token after persistence so its rotated refresh token remains available for a later attempt; do not retry immediately. On a lost comparison, reread the account; never return the losing result or overwrite a reconnect. Do not repeat provider refresh merely because persistence or cache invalidation failed. +8. On failure/cancellation, conditionally clear only this attempt's claim and invalidate. Do not clear a successor's claim. A process crash or failed cleanup leaves a reclaimable claim. Propagate cancellation unchanged; never start its next retry. Preserve the primary failure if cleanup also fails, reporting the cleanup failure without credentials. +9. Release the cache lock in `finally`. No transaction or pooled connection is held while waiting or during HTTP. + +Use a 120-second cache-lock lease and a 120-second acquisition wait. Default single-attempt HTTP timeouts are currently 10 seconds connecting and 30 seconds total; these leave headroom for ordinary processing but do not enforce a whole-operation deadline. Custom timeouts/callbacks must respect the documented budget. The durable claim still blocks a second attempt if the cache lease expires before the abandonment interval. A late owner loses local write authority after its claim is replaced. Do not wait for the full abandonment interval on a request that finds a still-owned row after the cache lease expired; fail explicitly and let a later operation recover. + +### Retry ownership + +- Built in, fixed small budget: three provider attempts total; exponential full-jitter delays capped at 250 ms then 500 ms. No policy selector or collection of retry configuration keys. +- Release the distributed lock and row claim before sleeping; retain worker-local result sharing for callers already waiting on this operation. Reacquire and reread before every attempt. Another operation may have succeeded during the delay. +- Retry provider connection failures, HTTP 408/429/500/502/503/504 and OAuth `server_error`/`temporarily_unavailable`. Read structured OAuth error codes from the actual response, never substring-match exception messages. `invalid_grant`, `invalid_client`, invalid scope and malformed configuration take precedence over a generic server status and are not retried. Restrict this classification to the provider invocation, not local database/cache failures. +- Respect `Retry-After`; if it requires a delay outside the small retry budget, surface the provider response for the caller to schedule later rather than retry early. Reuse existing parsing logic at its owning boundary if needed, without building a general retry subsystem. +- Handle Saloon/HTTP client exceptions and the corresponding Guzzle exceptions from Socialite callbacks. Unknown callback exceptions are not assumed transient. Do not wrap provider errors in a new parallel exception hierarchy. +- On `invalid_grant`, reread first. If the credentials changed, use/reassess the replacement instead of rejecting it. Otherwise clear only this claim and surface the original rejection without another attempt. The application handles reconnecting/disabling use of the account; do not silently delete credentials or introduce a persisted account-status system. +- `invalid_client` requires correcting client configuration, not reconnecting every account. Preserve stored grants and surface it without automatic retries. Transient exhaustion likewise retains credentials for a later operation. +- Same-worker waiters share errors through the active holder, rather than each starting its own chain. Across workers, authoritative claims serialize provider attempts; do not serialize exception objects into the cache or promise distributed error broadcasting. + +Tests must exercise late completion after claim replacement and cancellation, not just a mocked lock call. Keep clocks, delays and operation budgets explicit; do not add a timer heartbeat, detached completion, durable retry queue or periodic cleanup job. + +## 5. Partitioning and state lifetime + +Expose boot-only `OAuthAccounts::resolvePartitionUsing(string $column, Closure $resolver)`, beside `useAccountModel()` and its `accountModel()` getter. Follow Permission's SQL-column validation and registration-before-use guard. Seal configuration on first account operation, not a harmless model-class getter, so application boot can override a default model before use. A configured resolver returns the current nonempty string or integer partition; missing context fails explicitly. An unconfigured ordinary application has no partition column requirement. + +- Creating an account fills the partition column from the resolver and rejects a conflicting supplied value. Existing accounts cannot change partition; normal saves and deletes validate the current partition before writing. +- Capture the **persisted** partition when constructing its authenticator. Validate it against the current resolver on every apply, including cache hits, and after yielding before using credentials or publishing changes. +- Saloon's account reads, claims, conditional writes and invalidations always include that partition. A UUID alone is not authorization. +- Do not add an application-wide Eloquent global scope in Saloon. Applications/integrations own query policy and schema columns. A model scope alone cannot protect warmed cache entries or retained authenticators. +- Platform-owned accounts use an explicit platform partition. There is no special global-credential bypass flag. +- Reacquirable-token keys follow credential/grant identity, not the current partition automatically. Identical platform credentials may share their token; distinct grants supply distinct identity. Resource/merchant headers remain request-owned. +- Boot callbacks capture only worker-safe services and resolve current context when called. Never change global config per request or retain a tenant/account object in a singleton. Mark configuration mutators boot-only and register static cleanup in the existing testing subscriber. + +## 6. Documentation and integration + +Update `src/docs/saloon.md` in its existing Laravel-docs style: + +- Replace the boot-fetch example with cached authentication; explain worker-array versus shared storage, identity, expiry, invalidation and custom token classes. +- Show account migration, owner relation, custom model, OAuth callback persistence, reconnect/delete, encryption and optional cache setup. Do not imply installing Saloon creates a table. +- Show a Socialite callback mapping `token`, `refreshToken`, `expiresIn` to an absolute immutable expiry, and `approvedScopes ?: null`. Initial Socialite user data already exposes the raw response when exact scope presence matters. Custom provider adapters can explicitly supply an empty scopes list. +- Explain transaction rejection, transient recovery, `invalid_grant` versus client configuration failures, custom callback single-attempt bounds, cancellation and the remaining provider-side overlap risk. +- Document generic partition registration and request-owned credentials/headers. No private integration names or dependencies. +- Explain that default response-cache entries stop matching after bearer renewal. Apps choosing stable custom keys/scopes must retain account/permission isolation; no automatic cache-key rewrite. +- Keep the package README's lasting API differences accurate, including the approved scope contract/factory additions. Purely additive helpers belong in the guide. Update porting guidance only for an actual affected upstream contract. + +## 7. Implementation and verification + +Implement metadata and cached acquisition, then account persistence/cache/partitioning, then renewal/recovery and documentation as coherent slices. One final code-review boundary covers the complete feature. + +Before adding tests, inspect existing coverage and extend it where practical. The focused matrix is: + +| Area | Meaningful coverage | +|---|---| +| Token metadata | Omitted/empty/nonempty scopes, both grant factories and custom override signatures; existing refresh-token fallback | +| Acquisition | Memory/shared reuse; fixed short/no-expiry lifetime; identities across credentials/environments/custom audience; separate instances coalesce; owner failure reaches waiters; canceled waiter leaves owner intact; explicit forget and normal request retry | +| Persistence | Generated migration and morph-key conventions; encrypted DB/cache representation; hidden secrets; custom model/connection; save/delete/reconnect invalidation and rollback | +| Database load | Warm shared-cache authentication performs no SQL; cache disabled works; no repeated account writes on ordinary API calls | +| Renewal | Fixed early deadline including short/no-expiry tokens; non-refreshable token remains usable until actual expiry; two competing callers invoke once; success saved before use; rejected/expired returned token; omitted metadata retained; failed save surfaces; cache lease expiry alone does not bypass a fresh claim | +| Recovery | Transient backoff/jitter is bounded; no nested HTTP retries; fresh writer read before retry; rejected grant versus invalid client; abandoned claim takeover; old success/cleanup cannot overwrite new claim/reconnect/delete | +| Transactions/cancellation | Caller transaction rejected before provider call; cancellation propagates and releases owned coordination; cleanup failure does not hide cancellation; no detached child or held DB connection | +| Partitioning | Cold/warm cross-partition denial, captured authenticator after context switch, concurrent partitions, missing context, create mismatch, post-yield checks | +| HTTP integration | New pending request receives renewed auth; provider response/errors remain available. Run existing response-cache and OAuth suites for unchanged behavior, without duplicating their tests. | + +Use real supported shared-store/database integration for competing claims and invalidation; mock-only tests cannot prove cross-worker behavior. Use testbench SQLite for portable model behavior, with focused Redis and database integration where the shared-state claim needs it. Reuse existing service traits and CI discovery conventions; avoid a full driver/HTTP/provider cross-product. Never contact real provider accounts in tests. + +Run each changed test file immediately during implementation. At completion run `composer lint:fix`, `composer analyse`, then affected Saloon tests and touched integration suites sequentially, from the worktree root, with execution timeouts. Use the full suite only for framework-wide changes. Self-review callers/callees, retry multiplication, cache keys, context changes, secret exposure and API signatures before requesting review. Do not add benchmark claims; obtain owner idle-machine confirmation before benchmarking. diff --git a/docs/todo.md b/docs/todo.md index 2a734bac7e..355d9ad90a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -34,6 +34,7 @@ ## Testing +- Remove the PHPStan `2.3.1` pins in the root, Database and Foundation manifests once a release fixes the 2.3.2 loop-narrowing regression: `DataCollectionEloquentCast::set()` rejects a value narrowed by `instanceof` against `class-string`. Verify the full source and type-fixture analyses before restoring the version ranges. - Adopt a Swoole release fixing the intermittent AIO shutdown hang in [swoole-src#6297](https://github.com/swoole/swoole-src/issues/6297). On 6.3.0RC1, ordinary coroutine scheduler teardown can wait forever for an idle native thread that missed its shutdown notification, including between tests. Verify the released fix with the standalone reproduction and affected suites; do not add a Hypervel workaround or a timing-dependent regression test. - Add the required title docblocks to the untitled non-test methods in `tests/`: about 7,600 methods in 1,500 files, mostly helpers, fixtures, and methods of anonymous classes in older tests. `AGENTS.md` requires a title on every method except test methods, but most existing tests omit them, and agents follow the surrounding code over the written rule, so new tests keep reproducing the gap until review catches it. Make the change as one approved sweep (short imperative titles only), then add an automated check, such as a PHPStan rule, so the code and the rule cannot drift apart again. Decide whether the check also covers `src/`. - Add native return types to the `test*` methods that lack them: about 5,600 methods in 490 files. `AGENTS.md` requires `: void` on test methods; a test that returns a value for `#[Depends]` takes its actual return type instead. As with method titles, untyped methods in existing files lead new tests to copy them, so make the change as one approved sweep and enforce it with the same automated check. diff --git a/src/database/composer.json b/src/database/composer.json index d21b8be164..b8e0f7917f 100644 --- a/src/database/composer.json +++ b/src/database/composer.json @@ -59,7 +59,7 @@ }, "require-dev": { "fakerphp/faker": "^1.24", - "phpstan/phpstan": "^2.3" + "phpstan/phpstan": "2.3.1" }, "suggest": { "fakerphp/faker": "Required to use Eloquent model factories (^1.24)." diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 090b7077bb..6dade8895a 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -586,6 +586,8 @@ Integrations built with `saloonphp/saloon`, its Laravel plugin, and its cache, p Connectors may be shared by concurrent requests, so they are read-only. Move code that changes a connector's headers, query parameters, options, authenticator, delay, middleware, or mock client at runtime to the request, the `send` call, or the connector's `boot` method. Requests use fluent methods such as `withHeaders` and `withQueryParameters` instead of `headers()->add()` and `query()->add()`. Replace `sendAsync` and promises with pools, retry properties with `retry` or `defaultRetryPolicy`, custom senders with HTTP connections, and the rate limit plugin's limits and stores with rate limiter policies. Request exceptions extend the HTTP client's `RequestException`, so `catch (SaloonException $e)` no longer catches failed responses. See [Differences From Saloon](/docs/{{version}}/saloon#differences-from-saloon). +Custom OAuth authenticators must implement `getScopes(): ?array`. Overrides of either grant's `createOAuthAuthenticator()` factory append `?array $scopes = null` and pass it to the authenticator. Return `null` for unreported scopes and `[]` for an explicitly empty set. See [customizing OAuth requests](/docs/{{version}}/saloon#customizing-oauth-requests). + ### Broadcasting diff --git a/src/docs/saloon.md b/src/docs/saloon.md index 3e15a64411..cc6018d40e 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -42,7 +42,12 @@ - [OAuth 2](#oauth2) - [Authorization Code Grant](#authorization-code-grant) - [Client Credentials Grant](#client-credentials-grant) + - [Cached Access Tokens](#cached-access-tokens) - [Refreshing Access Tokens](#refreshing-access-tokens) + - [Connected OAuth Accounts](#connected-oauth-accounts) + - [Account Caching](#oauth-account-caching) + - [Account Renewal](#oauth-account-renewal) + - [Partitioning Accounts](#partitioning-oauth-accounts) - [Customizing OAuth Requests](#customizing-oauth-requests) - [Concurrent Requests](#concurrent-requests) - [Collecting Responses](#collecting-responses) @@ -731,27 +736,13 @@ class GetPrivateReport extends Request You may also add the trait to a connector to require authentication for every request in that integration. Saloon throws `MissingAuthenticatorException` before sending an unauthenticated request. You may override `getRequiresAuthMessage(PendingRequest $pendingRequest): string` when the integration needs a more specific message. -Some APIs require you to request a token before calling other endpoints. A connector's `boot` method may send that request and authenticate the pending request with the result: +For APIs that issue reusable access tokens, use [cached authentication](#cached-access-tokens) instead of requesting a token for every API call. Here, `$oauth` is a client-credentials connector: ```php -use Hypervel\Saloon\Http\Auth\TokenAuthenticator; -use Hypervel\Saloon\Http\PendingRequest; - -public function boot(PendingRequest $pendingRequest): void -{ - if ($pendingRequest->request() instanceof GetAccessToken) { - return; - } - - $token = $this->send(new GetAccessToken($this->username, $this->password)) - ->throw() - ->json('token'); - - $pendingRequest->authenticate(new TokenAuthenticator($token)); -} +$request->authenticate($oauth->cachedAccessToken(store: 'redis')); ``` -The early return stops the token request from requesting a token itself. Don't combine this with `RequiresAuth`, which checks for an authenticator before `boot` runs. When the API's tokens last longer than one request, you may store the token in your application's [cache](/docs/{{version}}/cache) until it expires. +For user-authorized connections with refresh tokens, use [connected OAuth accounts](#connected-oauth-accounts). Token endpoint requests must not use the authenticator that fetches their own token; use a separate OAuth connector when your API connector supplies authentication by default. ### Request Bodies @@ -1633,10 +1624,65 @@ $response = $connector->getAccessToken( ); ``` + +### Cached Access Tokens + +Client credentials connectors provide a `cachedAccessToken` method. It returns an authenticator that fetches a token on first use and reuses it until renewal is due: + +```php +$authenticator = $oauth->cachedAccessToken( + scopes: ['reports:read'], + store: 'redis', +); + +$response = $api->send((new GetReports)->authenticate($authenticator)); +``` + +The cache key includes the connector class, token URL, client credentials and requested scopes. Credentials are hashed, never included directly in cache keys. Fresh connector and authenticator instances with the same grant reuse the shared entry. Account and merchant headers stay on each request or connector. + +If a request modifier or custom request factory changes the grant, include that difference in the optional `key` argument: + +```php +$authenticator = $oauth->cachedAccessToken( + store: 'redis', + requestModifier: fn ($request) => $request->withData(['audience' => 'billing']), + key: 'billing', +); +``` + +The store defaults to `saloon.cache.store`, then the framework's default cache store. Use Redis to share tokens across workers. The `worker-array` store shares within one worker; the ordinary `array` store is local to a coroutine. Concurrent callers in one worker share the active fetch and its result or failure. Different workers may safely acquire their own client-credentials tokens on a simultaneous cache miss. + +Tokens renew up to 30 seconds before expiry, capped at half their remaining lifetime when acquired. This deadline does not move when an entry is read. Tokens without expiry do not renew automatically; supply an expiry when the provider documents one. An already-expired result is rejected. + +For APIs with a username/password token endpoint, supply a resolver returning `OAuthAuthenticator`. In this example, `$auth` is a connector for the authentication API, and `LoginRequest` exchanges the credentials for a token and its lifetime: + +```php +use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; +use Hypervel\Saloon\Http\Auth\CachedTokenAuthenticator; +use Hypervel\Support\Facades\Date; + +$authenticator = new CachedTokenAuthenticator( + key: serialize([LoginRequest::class, $auth->resolveBaseUrl(), $username, $password]), + resolver: function () use ($auth, $username, $password): AccessTokenAuthenticator { + $response = $auth->send(new LoginRequest($username, $password))->throw(); + + return new AccessTokenAuthenticator( + accessToken: $response->json('token'), + expiresAt: Date::now()->addSeconds($response->json('expires_in'))->toDateTimeImmutable(), + ); + }, + store: 'redis', +); +``` + +Your grant identity must distinguish credentials, environments and any audience or other grant inputs. Saloon hashes it before storage. The cache retains the actual authenticator, including custom authentication behavior. Register custom classes through the cache's `allowSerializableClassesUsing` method during provider boot, and use a serializer that preserves PHP objects. Cached standalone tokens contain usable credentials, so protect access to the cache. + +Call `$authenticator->forget()` to clear that instance's token and its shared entry. This does not revoke the provider token or clear copies held by other live instances. Use connected accounts for rotating refresh tokens, which require durable storage and coordinated updates. + ### Refreshing Access Tokens -An `AccessTokenAuthenticator` exposes its access token, optional refresh token, expiry, and expiration helpers: +An `AccessTokenAuthenticator` exposes its access token, optional refresh token, expiry, reported scopes, and expiration helpers: ```php if ($authenticator->hasExpired() && $authenticator->isRefreshable()) { @@ -1644,7 +1690,7 @@ if ($authenticator->hasExpired() && $authenticator->isRefreshable()) { } ``` -To keep tokens between requests, store the authenticator's `accessToken`, `refreshToken`, and `expiresAt` properties and create a new authenticator from them when you need it. For example, you may store the tokens in [encrypted](/docs/{{version}}/eloquent-mutators#encrypted-casting) model attributes and [cast](/docs/{{version}}/eloquent-mutators#date-casting) the expiry attribute to `immutable_datetime`: +For automatic persistence and renewal, use [connected OAuth accounts](#connected-oauth-accounts). You may also manage tokens yourself using [encrypted](/docs/{{version}}/eloquent-mutators#encrypted-casting) model attributes and an `immutable_datetime` [cast](/docs/{{version}}/eloquent-mutators#date-casting) for expiry: ```php use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; @@ -1658,6 +1704,8 @@ $authenticator = new AccessTokenAuthenticator( OAuth token expiry values use immutable dates. Saloon rejects negative or unrepresentable expiry durations instead of creating an already-expired token. +`getScopes()` returns the scopes reported by the provider, or `null` when none were reported. Saloon does not infer granted permissions from the requested scopes. A custom authenticator may supply `[]` to represent an explicitly empty set. + Refresh requests use the configured `tokenEndpoint` by default. If your provider uses a separate endpoint, set `refreshEndpoint` on your `OAuthConfig`: ```php @@ -1672,6 +1720,136 @@ return new OAuthConfig( For an absolute refresh URL, also set `allowBaseUrlOverride: true`. + +### Connected OAuth Accounts + +Connected accounts store user-authorized credentials and renew them when needed. Generate and run the migration only if your application uses this feature: + +```shell +php artisan make:oauth-accounts-table +php artisan migrate +``` + +The `oauth-accounts:table` alias is also available. The migration creates `oauth_accounts` with a UUID key and a polymorphic owner. Add `HasOAuthAccounts` to the owning model: + +```php +use Hypervel\Saloon\Traits\HasOAuthAccounts; + +class User extends Authenticatable +{ + use HasOAuthAccounts; +} +``` + +After exchanging an authorization code, save the returned credentials: + +```php +$account = $user->oauthAccounts()->create([ + 'provider' => 'gitlab', + 'provider_account_id' => $providerUserId, + 'access_token' => $token->getAccessToken(), + 'refresh_token' => $token->getRefreshToken(), + 'expires_at' => $token->getExpiresAt(), + 'scopes' => $token->getScopes(), +]); + +$request->authenticate($oauth->accountAuthenticator($account)); +``` + +Access and refresh tokens use encrypted casts and are hidden from model serialization. Client secrets remain in your application configuration. Reconnect by updating the selected account's credential fields, and disconnect locally by deleting the account. Deletion does not revoke the provider grant. The same owner may have several grants for one provider account, so Saloon does not infer which row a reconnect should replace. + +To customize the table, connection or token behavior, extend `Hypervel\Saloon\OAuthAccount` and select the model during provider boot, before using accounts: + +```php +use Hypervel\Saloon\OAuthAccounts; + +OAuthAccounts::useAccountModel(App\Models\OAuthAccount::class); +``` + +Override `newOAuthAuthenticator()` when persisted credentials need custom authentication behavior. The default creates a bearer authenticator. An account authenticator retains the account's storage identity, not a permanent copy of its token. + +You may use another OAuth client by supplying a refresh callback. For example, a Socialite provider's refresh result can be adapted without changing Socialite: + +```php +use Hypervel\Saloon\Contracts\OAuthAuthenticator; +use Hypervel\Saloon\Http\Auth\AccessTokenAuthenticator; +use Hypervel\Support\Facades\Date; + +$authenticator = $account->authenticator( + refreshUsing: function (OAuthAuthenticator $current) use ($provider): OAuthAuthenticator { + $token = $provider->refreshToken($current->getRefreshToken()); + + return new AccessTokenAuthenticator( + accessToken: $token->token, + refreshToken: $token->refreshToken, + expiresAt: $token->expiresIn === null + ? null + : Date::now()->addSeconds($token->expiresIn)->toDateTimeImmutable(), + scopes: $token->approvedScopes ?: null, + ); + }, +); +``` + +Socialite's empty approved-scopes list does not distinguish an omitted field. Mapping it to `null` retains the account's stored scopes; adapters that know the provider explicitly cleared them may return `[]`. Initial Socialite user results expose the raw token response when exact field presence matters. + + +### Account Caching + +Credential caching is disabled by default. Enable it in `config/saloon.php` to avoid database reads on ordinary authenticated calls: + +```php +'oauth_accounts' => [ + 'cache' => [ + 'enabled' => true, + 'store' => 'redis', + 'ttl' => 300, + 'prefix' => 'saloon:oauth-accounts:', + ], + 'lock_store' => null, +], +``` + +The cache stores encrypted model attributes. Normal saves and deletes invalidate the entry after commit; reads inside an application transaction bypass shared caching. Custom account models retain their global scopes and query builders. + +The renewal lock store defaults to the account cache store, then the framework default. It is required even when credential caching is disabled. Use Redis or a database store for deployments spanning hosts. File and Swoole stores are supported when their sharing scope covers every worker using the accounts. Worker-local, stack and failover stores are not supported for durable account credentials or renewal locks. + +Quiet saves, bulk updates and raw queries have the usual Eloquent responsibilities. After such changes, call `$account->clearCredentialCache()`. Credential changes must also replace `credential_revision`, recompute `refresh_at` and clear obsolete refresh claims. Prefer normal model updates so these steps happen automatically. + +The default response-cache key includes the bearer token, so renewed tokens no longer match old response entries. If you choose stable response-cache keys or scopes, preserve account and permission isolation yourself. + + +### Account Renewal + +Saloon records a fixed early-renewal deadline when credentials are saved. A token without a refresh token remains usable until its actual expiry, then throws `Hypervel\Saloon\Exceptions\OAuthAccountReconnectRequiredException`. Catch this exception to prompt the user to reconnect. Tokens without expiry do not renew automatically. + +Renewal rereads the account, records ownership of the attempt, then calls the provider without holding an idle database connection. The replacement is stored before it is applied to a request. A concurrent reconnect, deletion or replacement attempt cannot be overwritten by a late result. Inside a transaction on the account connection, Saloon uses an unexpired token when no attempt holds a refresh claim, deferring early renewal. Otherwise, it rejects renewal before waiting or contacting the provider. + +If the returned access token has already expired, Saloon stores the replacement credentials and throws an `UnexpectedValueException` instead of using it. This preserves a rotated refresh token for a later attempt without immediately calling the provider again. + +Transient connection failures, selected HTTP errors and temporary OAuth errors receive up to three attempts, with jittered delays capped at 250 and 500 milliseconds. Each attempt rereads current credentials. Longer `Retry-After` delays are returned to the caller for later scheduling. The connector convenience disables nested HTTP retries; custom refresh callbacks must also make one provider attempt per invocation. + +An `invalid_grant` response is not retried. Saloon first checks for replacement credentials, then preserves the provider exception if the grant is still rejected. Your application decides how to reconnect or disable the account. `invalid_client` indicates client configuration needs attention; it does not require every user to reconnect. Transient exhaustion preserves the stored grant for a later attempt. Provider responses remain available through their normal exceptions. + +The renewal lock lasts 120 seconds, with up to 120 seconds waiting to acquire it. A recorded attempt becomes recoverable after 300 seconds. Keep custom refresh callbacks and timeouts within those bounds; the default HTTP timeouts are 10 seconds connecting and 30 seconds total. Cancellation stops that caller and clears its owned claim where possible. Waiting callers may continue independently. + +These measures cannot guarantee exactly one provider request after a crash or ambiguous network failure. A delayed request may overlap recovery, and providers that detect reuse of rotated refresh tokens may revoke the grant. Such accounts need reconnection. Saloon does not run detached refresh tasks or silently retry failed local persistence. + + +### Partitioning Accounts + +Applications that divide accounts by tenant or another ownership boundary may register a partition resolver during provider boot: + +```php +use Hypervel\Saloon\OAuthAccounts; + +OAuthAccounts::resolvePartitionUsing('tenant_id', fn () => $tenancy->currentKey()); +``` + +Add the partition column and suitable indexes to your published migration. The callback should capture a worker-safe context service and resolve the current key when called. Missing context fails explicitly. New accounts receive that partition and cannot move to another partition. Normal saves and deletes are rejected outside the account's partition. Retained authenticators are checked against it on every use, including warm cache reads and after provider calls. + +Your model or integration still owns application-wide query scoping; Saloon does not install a global Eloquent scope. Platform-owned accounts use an explicit platform partition. Cached client-credentials tokens use grant identity instead, allowing identical platform credentials to share a token. Keep merchant-specific headers on the request or connector. + ### Customizing OAuth Requests @@ -1704,15 +1882,15 @@ $authenticator = $connector->getAccessToken( For providers that require a different request body or token response format, override the protected `resolveAccessTokenRequest`, `resolveRefreshTokenRequest`, `resolveUserRequest`, `createOAuthAuthenticatorFromResponse`, or `createOAuthAuthenticator` method on the connector. These methods let you adapt the provider protocol without replacing the OAuth flow. A PKCE verifier is added to the request your `resolveAccessTokenRequest` method returns. -For example, you may return your own authenticator class. Authorization code connectors receive the access token, refresh token, and expiry, while client credentials connectors receive the access token and expiry: +For example, you may return your own authenticator class. Authorization code connectors receive the access token, refresh token, expiry and scopes; client credentials connectors receive the access token, expiry and scopes: ```php use DateTimeImmutable; use Hypervel\Saloon\Contracts\OAuthAuthenticator; -protected function createOAuthAuthenticator(string $accessToken, ?string $refreshToken = null, ?DateTimeImmutable $expiresAt = null): OAuthAuthenticator +protected function createOAuthAuthenticator(string $accessToken, ?string $refreshToken = null, ?DateTimeImmutable $expiresAt = null, ?array $scopes = null): OAuthAuthenticator { - return new SpotifyAuthenticator($accessToken, $refreshToken, $expiresAt); + return new SpotifyAuthenticator($accessToken, $refreshToken, $expiresAt, $scopes); } ``` diff --git a/src/foundation/composer.json b/src/foundation/composer.json index f39d851f74..5824c64206 100644 --- a/src/foundation/composer.json +++ b/src/foundation/composer.json @@ -84,7 +84,7 @@ "hypervel/websocket-server": "^0.4" }, "require-dev": { - "phpstan/phpstan": "^2.3" + "phpstan/phpstan": "2.3.1" }, "suggest": { "algolia/algoliasearch-client-php": "Required to use the InteractsWithAlgolia trait (^4.49.0).", diff --git a/src/saloon/README.md b/src/saloon/README.md index 678328265b..2e5cfb6f5a 100644 --- a/src/saloon/README.md +++ b/src/saloon/README.md @@ -42,7 +42,7 @@ Authentication helpers use the HTTP client's names: `withToken()`, `withBasicAut The upstream NTLM authenticator is omitted. Integrations requiring NTLM must supply their own authenticator and transport middleware. -OAuth 2 configuration is an immutable `Data\OAuthConfig` built from constructor arguments and read through public properties, replacing upstream's `Helpers\OAuth2\OAuthConfig` setters and getters, so connectors define `defaultOAuthConfig()` and per-call changes use the grant methods' `requestModifier`. `invokeRequestModifier()` is `modify()`. `authorizationUrl()` returns an `AuthorizationUrl` holding the URL and its state, instead of `getAuthorizationUrl()` storing the state on the shared connector for `getState()`. Additional authorization parameters cannot replace that state, and if `getAccessToken()` receives either state, both must be non-empty and equal. `getUser()` applies the configuration's request modifier before the per-call one, as the token methods do, and its request sends no form `Content-Type`. See [OAuth 2](https://hypervel.org/docs/saloon#oauth2). +OAuth 2 configuration is an immutable `Data\OAuthConfig` built from constructor arguments and read through public properties, replacing upstream's `Helpers\OAuth2\OAuthConfig` setters and getters, so connectors define `defaultOAuthConfig()` and per-call changes use the grant methods' `requestModifier`. `invokeRequestModifier()` is `modify()`. `authorizationUrl()` returns an `AuthorizationUrl` holding the URL and its state, instead of `getAuthorizationUrl()` storing the state on the shared connector for `getState()`. Additional authorization parameters cannot replace that state, and if `getAccessToken()` receives either state, both must be non-empty and equal. `getUser()` applies the configuration's request modifier before the per-call one, as the token methods do, and its request sends no form `Content-Type`. `OAuthAuthenticator` also requires `getScopes(): ?array`; `AccessTokenAuthenticator` and both grants' `createOAuthAuthenticator()` factories append an optional `?array $scopes = null` argument. This preserves reported permissions during account renewal: `null` means unreported, while `[]` is explicitly empty. See [OAuth 2](https://hypervel.org/docs/saloon#oauth2). Ported from: diff --git a/src/saloon/config/saloon.php b/src/saloon/config/saloon.php index 2cf479d5f7..82ac7e9e4a 100644 --- a/src/saloon/config/saloon.php +++ b/src/saloon/config/saloon.php @@ -43,6 +43,28 @@ 'store' => null, ], + /* + |-------------------------------------------------------------------------- + | Connected OAuth Accounts + |-------------------------------------------------------------------------- + | + | Credential caching avoids database reads for ordinary authenticated calls. + | Use a shared store that spans the workers using these accounts. A null + | lock store uses the account cache store, or the framework default. + | Omitted settings use the defaults below. + | + */ + + 'oauth_accounts' => [ + 'cache' => [ + 'enabled' => false, + 'store' => null, + 'ttl' => 300, + 'prefix' => 'saloon:oauth-accounts:', + ], + 'lock_store' => null, + ], + /* |-------------------------------------------------------------------------- | Fixtures diff --git a/src/saloon/src/Console/Commands/OAuthAccountsTableCommand.php b/src/saloon/src/Console/Commands/OAuthAccountsTableCommand.php new file mode 100644 index 0000000000..ed706e3f46 --- /dev/null +++ b/src/saloon/src/Console/Commands/OAuthAccountsTableCommand.php @@ -0,0 +1,45 @@ +uuid('id')->primary(); + $table->morphs('owner'); + $table->string('provider'); + $table->string('provider_account_id')->nullable(); + $table->text('access_token'); + $table->text('refresh_token')->nullable(); + $table->dateTime('expires_at')->nullable(); + $table->dateTime('refresh_at')->nullable(); + $table->text('scopes')->nullable(); + $table->uuid('credential_revision'); + $table->uuid('refresh_claim')->nullable(); + $table->timestamp('refresh_started_at')->nullable(); + $table->timestamps(); + $table->index(['provider', 'provider_account_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('{{table}}'); + } +}; diff --git a/src/saloon/src/Contracts/OAuthAuthenticator.php b/src/saloon/src/Contracts/OAuthAuthenticator.php index 7c732e53fa..e8a7affc27 100644 --- a/src/saloon/src/Contracts/OAuthAuthenticator.php +++ b/src/saloon/src/Contracts/OAuthAuthenticator.php @@ -23,6 +23,13 @@ public function getRefreshToken(): ?string; */ public function getExpiresAt(): ?DateTimeImmutable; + /** + * Get the reported scopes, or null when the provider did not report them. + * + * @return null|list + */ + public function getScopes(): ?array; + /** * Determine if the authenticator has expired. */ diff --git a/src/saloon/src/Data/CachedToken.php b/src/saloon/src/Data/CachedToken.php new file mode 100644 index 0000000000..0b104b8776 --- /dev/null +++ b/src/saloon/src/Data/CachedToken.php @@ -0,0 +1,36 @@ +hasExpired()) { + throw new UnexpectedValueException('The acquired OAuth token has already expired.'); + } + + $this->refreshAt = TokenExpiry::refreshAt($authenticator->getExpiresAt()); + } + + /** + * Determine whether this entry can still be used. + */ + public function isUsable(): bool + { + return $this->refreshAt === null || $this->refreshAt > Date::now()->getTimestamp(); + } +} diff --git a/src/saloon/src/Data/OAuthAccountIdentity.php b/src/saloon/src/Data/OAuthAccountIdentity.php new file mode 100644 index 0000000000..e5f5993bb0 --- /dev/null +++ b/src/saloon/src/Data/OAuthAccountIdentity.php @@ -0,0 +1,54 @@ + $model + */ + public function __construct( + public string $model, + public string $connection, + public string $table, + public int|string $id, + public ?string $partitionColumn, + public ?string $partition, + ) { + $this->key = hash('sha256', serialize([$model, ConnectionName::withoutWriteType($connection), $table, (string) $id, $partitionColumn, $partition])); + } + + /** + * Create a source model on the captured connection and table. + */ + public function newModel(): OAuthAccount + { + return (new $this->model)->setConnection($this->connection)->setTable($this->table); + } + + /** + * Start an account query with its model scopes and partition intact. + * + * @return Builder + */ + public function query(): Builder + { + $query = $this->newModel()->newQuery()->whereKey($this->id); + + if ($this->partitionColumn !== null) { + $query->where($this->partitionColumn, $this->partition); + } + + return $query; + } +} diff --git a/src/saloon/src/Data/TokenAcquisitionResult.php b/src/saloon/src/Data/TokenAcquisitionResult.php new file mode 100644 index 0000000000..f83e31de18 --- /dev/null +++ b/src/saloon/src/Data/TokenAcquisitionResult.php @@ -0,0 +1,20 @@ + $scopes */ public function __construct( #[SensitiveParameter] @@ -21,6 +23,7 @@ public function __construct( #[SensitiveParameter] public readonly ?string $refreshToken = null, public readonly ?DateTimeImmutable $expiresAt = null, + public readonly ?array $scopes = null, ) { } @@ -72,6 +75,16 @@ public function getExpiresAt(): ?DateTimeImmutable return $this->expiresAt; } + /** + * Get the reported scopes. + * + * @return null|list + */ + public function getScopes(): ?array + { + return $this->scopes; + } + /** * Determine if the authenticator is refreshable. */ diff --git a/src/saloon/src/Http/Auth/CachedTokenAuthenticator.php b/src/saloon/src/Http/Auth/CachedTokenAuthenticator.php new file mode 100644 index 0000000000..fb87832eae --- /dev/null +++ b/src/saloon/src/Http/Auth/CachedTokenAuthenticator.php @@ -0,0 +1,97 @@ +make(ConfigRepository::class); + $this->store = $store ?? $config->get('saloon.cache.store') ?? $config->string('cache.default'); + $this->cacheKey = 'saloon:oauth-token:' . hash('sha256', $key); + $this->resolver = $resolver(...); + } + + /** + * Apply a cached token, acquiring one when needed. + */ + public function set(PendingRequest $pendingRequest): void + { + $token = $this->token; + + if ($token === null || ! $token->isUsable()) { + $token = Container::getInstance()->make(TokenAcquisition::class)->run( + $this->store . ':' . $this->cacheKey, + function (): CachedToken { + $cache = $this->cache(); + $token = $cache->get($this->cacheKey); + + if ($token instanceof CachedToken && $token->isUsable()) { + return $token; + } + + ConnectionResolver::releaseIdleConnections(); + $token = new CachedToken(($this->resolver)()); + $cache->put( + $this->cacheKey, + $token, + $token->refreshAt === null ? null : new DateTimeImmutable('@' . $token->refreshAt), + ); + + return $token; + }, + ); + + $this->token = $token; + } + + $token->authenticator->set($pendingRequest); + } + + /** + * Forget this instance's token and the shared cache entry. + */ + public function forget(): void + { + $this->token = null; + $this->cache()->forget($this->cacheKey); + } + + /** + * Resolve the selected token cache. + */ + protected function cache(): CacheRepository + { + return Container::getInstance()->make(CacheFactory::class)->store($this->store); + } +} diff --git a/src/saloon/src/Http/Auth/OAuthAccountAuthenticator.php b/src/saloon/src/Http/Auth/OAuthAccountAuthenticator.php new file mode 100644 index 0000000000..a0c770ac6f --- /dev/null +++ b/src/saloon/src/Http/Auth/OAuthAccountAuthenticator.php @@ -0,0 +1,41 @@ +identity = Container::getInstance()->make(OAuthAccounts::class)->identity($account); + } + + /** + * Apply current credentials without retaining a private token copy. + */ + public function set(PendingRequest $pendingRequest): void + { + $accounts = Container::getInstance()->make(OAuthAccounts::class); + $token = $accounts->token($this->identity, $this->refreshUsing); + $accounts->ensurePartition($this->identity); + $token->set($pendingRequest); + } +} diff --git a/src/saloon/src/Http/Auth/TokenAcquisition.php b/src/saloon/src/Http/Auth/TokenAcquisition.php new file mode 100644 index 0000000000..2c66060182 --- /dev/null +++ b/src/saloon/src/Http/Auth/TokenAcquisition.php @@ -0,0 +1,65 @@ +> */ + protected array $active = []; + + /** + * Share an active acquisition with callers already waiting for it. + * + * @template TResult + * @param Closure(): TResult $acquire + * @return TResult + */ + public function run(string $key, Closure $acquire): mixed + { + ConnectionResolver::releaseIdleConnections(); + $lockKey = '__saloon.token_acquisition.' . $key; + + while (true) { + /** @var TokenAcquisitionResult $result */ + $result = $this->active[$key] ??= new TokenAcquisitionResult; + + if (! Locker::lock($lockKey, 120)) { + if ($result->error !== null) { + throw $result->error; + } + + if ($result->completed) { + return $result->value; + } + + // Owner cancellation is not cancellation of its waiters. + continue; + } + + try { + $result->value = $acquire(); + $result->completed = true; + + return $result->value; + } catch (CanceledException $exception) { + throw $exception; + } catch (Throwable $exception) { + $result->error = $exception; + + throw $exception; + } finally { + unset($this->active[$key]); + Locker::unlock($lockKey); + } + } + } +} diff --git a/src/saloon/src/Http/Auth/TokenExpiry.php b/src/saloon/src/Http/Auth/TokenExpiry.php new file mode 100644 index 0000000000..9e4b8696f9 --- /dev/null +++ b/src/saloon/src/Http/Auth/TokenExpiry.php @@ -0,0 +1,26 @@ +getTimestamp(); + $remaining = max(0, $expiry - Date::now()->getTimestamp()); + + return $expiry - min(30, intdiv($remaining, 2)); + } +} diff --git a/src/saloon/src/Http/RetryAfter.php b/src/saloon/src/Http/RetryAfter.php new file mode 100644 index 0000000000..9259a60d13 --- /dev/null +++ b/src/saloon/src/Http/RetryAfter.php @@ -0,0 +1,121 @@ +\d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT)\z/', + $retryAfter, + $matches, + ) === 1) { + $format = 'd M Y H:i:s \G\M\T'; + $timestamp = $matches['timestamp']; + } elseif (preg_match( + '/\A(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?\d{2})-(?[A-Z][a-z]{2})-(?\d{2}) (?