Skip to content

Commit ddb60ad

Browse files
authored
Merge pull request #665 from hypervel/feature/passport
Add Passport OAuth 2.0 server
2 parents 293bfca + 43f0399 commit ddb60ad

315 files changed

Lines changed: 27758 additions & 721 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎composer.json‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"aws/aws-sdk-php": "^3.322.9",
4848
"brick/math": "^1.0",
4949
"chillerlan/php-qrcode": "^6.0",
50+
"defuse/php-encryption": "^2.4",
5051
"doctrine/inflector": "^2.0.5",
5152
"dragonmantank/cron-expression": "^3.4",
5253
"egulias/email-validator": "^4.0",
@@ -59,12 +60,13 @@
5960
"guzzlehttp/psr7": "^2.13 || ^3.1",
6061
"guzzlehttp/uri-template": "^1.0 || ^2.0",
6162
"laravel/serializable-closure": "^2.0.11",
62-
"lcobucci/jwt": "^5.0",
63+
"lcobucci/jwt": "^5.6",
6364
"league/commonmark": "^2.10.3",
6465
"league/flysystem": "^3.25.1",
6566
"league/flysystem-aws-s3-v3": "^3.25.1",
6667
"league/flysystem-path-prefixing": "^3.25.1",
6768
"league/flysystem-read-only": "^3.25.1",
69+
"league/oauth2-server": "^9.4",
6870
"league/uri": "^7.5.1",
6971
"monolog/monolog": "^3.10",
7072
"nesbot/carbon": "^3.13.1",
@@ -189,6 +191,7 @@
189191
"hypervel/opentelemetry": "self.version",
190192
"hypervel/pagination": "self.version",
191193
"hypervel/passkeys": "self.version",
194+
"hypervel/passport": "self.version",
192195
"hypervel/permission": "self.version",
193196
"hypervel/pipeline": "self.version",
194197
"hypervel/process": "self.version",
@@ -274,6 +277,8 @@
274277
"Hypervel\\OpenTelemetry\\": "src/opentelemetry/src/",
275278
"Hypervel\\Pagination\\": "src/pagination/src/",
276279
"Hypervel\\Passkeys\\": "src/passkeys/src/",
280+
"Hypervel\\Passport\\": "src/passport/src/",
281+
"Hypervel\\Passport\\Database\\Factories\\": "src/passport/database/factories/",
277282
"Hypervel\\Permission\\": "src/permission/src/",
278283
"Hypervel\\Pipeline\\": "src/pipeline/src/",
279284
"Hypervel\\Process\\": "src/process/src/",
@@ -398,6 +403,7 @@
398403
"Hypervel\\Passkeys\\PasskeysServiceProvider",
399404
"Hypervel\\Fortify\\FortifyServiceProvider",
400405
"Hypervel\\Grpc\\GrpcServiceProvider",
406+
"Hypervel\\Passport\\PassportServiceProvider",
401407
"Hypervel\\Permission\\PermissionServiceProvider",
402408
"Hypervel\\Pipeline\\PipelineServiceProvider",
403409
"Hypervel\\Queue\\QueueServiceProvider",

‎docs/plans/2026-10-12-0000-hypervel-passport.md‎

Lines changed: 640 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/todo.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
## Authentication
44

55
- Create hypervel/react-starter-kit. Include the standard skeleton pieces that currently only exist as follow-ups: a `composer dev` script for running the Hypervel development server and frontend asset watcher together, plus explicit Hypervel Vite refresh paths instead of the Laravel plugin's `refresh: true` shortcut.
6-
- Port Passport package
7-
- Replace permission package fake Passport client-credentials coverage with real Passport tests once Passport is ported. The current tests use a local fake guard/client so the permission package can keep Passport middleware parity without depending on a package that does not exist yet.
86

97
## Artisan
108

‎docs/upstream-sync/sync.yaml‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,32 @@ laravel/sanctum:
7171
last_reviewed_pr: 620
7272
sync_date: '2026-10-02'
7373

74+
laravel/passport:
75+
branch: 13.x
76+
checked_through: f5f5e9f64559dd52339daa0d6102cb1c30e4a0d7
77+
last_reviewed_pr: null
78+
sync_date: '2026-10-14'
79+
notes: >-
80+
PassportServiceProvider's server and grant construction maps to ServerFactory;
81+
KeysCommand's phpseclib key generation maps to the OpenSSL-based KeyGenerator.
82+
Token issuance changes also affect Bridge/AuthorizationServer, Bridge/TokenIssuance
83+
and the bridge grants and repositories; credential lookup and mutation changes
84+
also affect PassportCache, TokenRelation and ClientRelation. Bridge/BearerTokenValidator
85+
adapts League's AuthorizationValidators/BearerTokenValidator, and
86+
Bridge/AccessToken::convertToJWT() replaces League's AccessTokenTrait method;
87+
compare these with league/oauth2-server when its dependency version changes.
88+
Also recheck League's grant-to-repository call order against TokenIssuance's
89+
validation, claim and commit boundaries, and its public request entry points
90+
against Bridge/AuthorizationServer and Bridge/ResourceServer's delegation to
91+
the current settings. Compare Bridge/ParsedKey and RsaSigner with lcobucci/jwt's
92+
key checks and signing behavior when that dependency changes.
93+
Upstream tests retain their Feature and Unit paths under tests/Passport;
94+
jasonmccreary/double test doubles use Mockery here. Workbench user models and
95+
factories map to tests/Passport/Fixtures. Test keys
96+
are generated in per-process temporary directories by PassportTestCase, using
97+
SigningKeyFactory's shared test key, rather than upstream's tests/keys directory.
98+
User documentation maps to src/docs/passport.md through the laravel/docs entry.
99+
74100
laravel/socialite:
75101
branch: 5.x
76102
checked_through: fa0181ee6204ca28a55cd67145fadd631ac209cf
@@ -164,7 +190,7 @@ spatie/laravel-permission:
164190
checked_through: 6615eefac655efcd652fe394a20cbdf4f72c609f
165191
last_reviewed_pr: null
166192
sync_date: '2026-10-05'
167-
notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Passport::actingAsClient() maps to TestCase::actingAsClient() with the PassportGuard fixture because Hypervel has no Passport package. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. Upstream's ide.json code-generation metadata for the Laravel Idea PhpStorm plugin is not ported either.
193+
notes: Upstream Pest files map to PHPUnit classes at the same paths under tests/Permission, with each it()/test() description as the method name. Upstream's permission.testing migration flag, which adds the roles team column while teams are off so cases can enable teams mid-test, is not ported; those cases enable teams before migrating through the DefineEnvironment attribute's usesTeams method. The CACHE_DRIVER cache-driver test runs map to CACHE_STORE. Upstream's single cached permission collection maps to PermissionRegistrar's catalog payload (getSerializedPermissionsForCache() stores role keys per permission; permissionCatalog() and modelClassCatalog() hydrate it) plus the per-model assignment caches (rememberModel*()), which are filled through Cache\ModelCacheCoordinator and invalidated after commit; apply upstream cache or loading changes to both. Upstream relation definitions map to the Role and Permission relation methods built through BuildsPermissionRelations, whose Partitioned* relations add partition and team constraints and loaded-relation tracking. Upstream's detach-then-attach syncs map to HasRoles::syncRoles() and HasPermissions::synchronizePermissionAssignments(), which read the current pivots and write only the differences, keeping each permission's is_denied effect. Upstream docs map to sections of src/docs/permission.md; the example app, PhpStorm, UI options, schema diagram, upgrade and project pages are not ported. Upstream's ide.json code-generation metadata for the Laravel Idea PhpStorm plugin is not ported either.
168194

169195
aimeos/laravel-nestedset:
170196
branch: master

‎src/auth/src/EloquentUserProvider.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public function retrieveById(mixed $identifier): ?UserContract
131131

132132
return $this->cacheCoordinator->fill(
133133
$this->cache,
134+
fn (): Model => $this->createModel(),
134135
$this->buildCacheKey($identifier),
135136
$this->cacheTtl,
136137
fn () => $this->fetchUserByIdForCache($identifier),

‎src/auth/src/GuardHelpers.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@
1414
* so per-request user state cannot be stored as instance properties. Each guard must
1515
* implement its own coroutine-safe user(), setUser(), and hasUser() using Context.
1616
* This trait provides only the methods that are truly universal across all guards.
17+
*
18+
* @template TProvider of UserProvider = UserProvider
1719
*/
1820
trait GuardHelpers
1921
{
2022
/**
2123
* The user provider implementation.
2224
*
2325
* Nullable because some guards (e.g. RequestGuard) may not require a provider.
26+
*
27+
* @var null|TProvider
2428
*/
2529
protected ?UserProvider $provider = null;
2630

@@ -60,6 +64,8 @@ public function id(): int|string|null
6064

6165
/**
6266
* Get the user provider used by the guard.
67+
*
68+
* @return null|TProvider
6369
*/
6470
public function getProvider(): ?UserProvider
6571
{
@@ -71,6 +77,8 @@ public function getProvider(): ?UserProvider
7177
*
7278
* Boot or tests only. The provider is stored on the worker-lifetime guard
7379
* and affects every subsequent request.
80+
*
81+
* @param TProvider $provider
7482
*/
7583
public function setProvider(UserProvider $provider): void
7684
{

‎src/auth/src/RequestGuard.php‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Hypervel\Auth;
66

77
use Hypervel\Context\CoroutineContext;
8+
use Hypervel\Context\RequestContext;
89
use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract;
910
use Hypervel\Contracts\Auth\Guard;
1011
use Hypervel\Contracts\Auth\UserProvider;
@@ -50,10 +51,9 @@ public function __construct(
5051
/**
5152
* Get the currently authenticated user.
5253
*
53-
* Uses coroutine Context to cache the resolved user per-request,
54-
* since this guard is a process-global singleton. A sentinel value
55-
* is used to cache "no user found" so repeated calls don't trigger
56-
* redundant provider lookups.
54+
* RequestContext keeps resolved users local to the coroutine and clears them
55+
* when its request changes. A sentinel caches "no user found" so repeated
56+
* calls don't trigger redundant provider lookups.
5757
*/
5858
public function user(): ?AuthenticatableContract
5959
{
@@ -67,7 +67,7 @@ public function user(): ?AuthenticatableContract
6767
}
6868

6969
$contextKey = $this->getResolvedUserContextKey();
70-
$cached = CoroutineContext::get($contextKey);
70+
$cached = RequestContext::getValue($contextKey);
7171

7272
if ($cached === self::$nullUserSentinel) {
7373
return null;
@@ -82,7 +82,7 @@ public function user(): ?AuthenticatableContract
8282
$this->getProvider()
8383
);
8484

85-
CoroutineContext::set($contextKey, $user ?? self::$nullUserSentinel);
85+
RequestContext::setValue($contextKey, $user ?? self::$nullUserSentinel);
8686

8787
return $user;
8888
}
@@ -109,7 +109,7 @@ public function hasUser(): bool
109109
return true;
110110
}
111111

112-
$cached = CoroutineContext::get($this->getResolvedUserContextKey());
112+
$cached = RequestContext::getValue($this->getResolvedUserContextKey());
113113

114114
return $cached !== null && $cached !== self::$nullUserSentinel;
115115
}
@@ -130,7 +130,7 @@ public function setUser(AuthenticatableContract $user): static
130130
public function forgetUser(): static
131131
{
132132
CoroutineContext::forget($this->getExplicitUserContextKey());
133-
CoroutineContext::forget($this->getResolvedUserContextKey());
133+
RequestContext::forgetValue($this->getResolvedUserContextKey());
134134

135135
return $this;
136136
}

‎src/auth/src/TokenGuard.php‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Hypervel\Auth;
66

77
use Hypervel\Context\CoroutineContext;
8+
use Hypervel\Context\RequestContext;
89
use Hypervel\Contracts\Auth\Authenticatable as AuthenticatableContract;
910
use Hypervel\Contracts\Auth\Guard;
1011
use Hypervel\Contracts\Auth\UserProvider;
@@ -45,10 +46,9 @@ public function __construct(
4546
/**
4647
* Get the currently authenticated user.
4748
*
48-
* Uses coroutine Context to cache the resolved user per-request,
49-
* keyed by token fingerprint so different tokens don't collide.
50-
* A sentinel value caches "no user found" so repeated calls
51-
* don't trigger redundant provider lookups.
49+
* RequestContext keeps resolved users local to the coroutine and clears them
50+
* when its request changes. Token fingerprints separate credentials, and a
51+
* sentinel caches "no user found" to avoid redundant provider lookups.
5252
*/
5353
public function user(): ?AuthenticatableContract
5454
{
@@ -63,7 +63,7 @@ public function user(): ?AuthenticatableContract
6363

6464
$token = $this->getTokenForRequest();
6565
$contextKey = $this->getContextKeyForToken($token);
66-
$cached = CoroutineContext::get($contextKey);
66+
$cached = RequestContext::getValue($contextKey);
6767

6868
if ($cached === self::$nullUserSentinel) {
6969
return null;
@@ -81,7 +81,7 @@ public function user(): ?AuthenticatableContract
8181
]);
8282
}
8383

84-
CoroutineContext::set($contextKey, $user ?? self::$nullUserSentinel);
84+
RequestContext::setValue($contextKey, $user ?? self::$nullUserSentinel);
8585

8686
return $user;
8787
}
@@ -143,7 +143,7 @@ public function hasUser(): bool
143143
return true;
144144
}
145145

146-
$cached = CoroutineContext::get($this->getContextKeyForToken($this->getTokenForRequest()));
146+
$cached = RequestContext::getValue($this->getContextKeyForToken($this->getTokenForRequest()));
147147

148148
return $cached !== null && $cached !== self::$nullUserSentinel;
149149
}
@@ -164,7 +164,7 @@ public function setUser(AuthenticatableContract $user): static
164164
public function forgetUser(): static
165165
{
166166
CoroutineContext::forget($this->getExplicitUserContextKey());
167-
CoroutineContext::forget($this->getContextKeyForToken($this->getTokenForRequest()));
167+
RequestContext::forgetValue($this->getContextKeyForToken($this->getTokenForRequest()));
168168

169169
return $this;
170170
}

‎src/cache/src/ModelCacheCoordinator.php‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
use Hypervel\Contracts\Cache\RefreshableLock;
1212
use Hypervel\Contracts\Cache\Repository as CacheRepository;
1313
use Hypervel\Contracts\Cache\Store;
14+
use Hypervel\Contracts\Container\Container;
15+
use Hypervel\Database\DatabaseTransactionsManager;
16+
use Hypervel\Database\Eloquent\Builder;
17+
use Hypervel\Database\Eloquent\Model;
18+
use Hypervel\Support\Collection;
1419

1520
/**
1621
* Coordinate shared model cache fills and exact invalidations.
@@ -32,6 +37,11 @@ class ModelCacheCoordinator
3237
*/
3338
private const int INVALIDATION_RETRY_MILLISECONDS = 25;
3439

40+
/**
41+
* The maximum number of models written and invalidated per batch.
42+
*/
43+
private const int MUTATION_BATCH_SIZE = 1000;
44+
3545
/**
3646
* The envelope marker key.
3747
*/
@@ -47,20 +57,47 @@ class ModelCacheCoordinator
4757
*/
4858
private const string ENVELOPE_VALUE_KEY = 'value';
4959

60+
/**
61+
* Create a model cache coordinator.
62+
*/
63+
public function __construct(protected Container $container)
64+
{
65+
}
66+
67+
/**
68+
* Determine whether the source has a transaction that prevents shared caching.
69+
*
70+
* @param Closure(): Model $source
71+
*/
72+
public function hasApplicableTransaction(Closure $source): bool
73+
{
74+
/** @var DatabaseTransactionsManager $transactions */
75+
$transactions = $this->container->make('db.transactions');
76+
77+
// Check the manager first so ordinary cache hits do not construct a source model.
78+
return $transactions->hasApplicableTransaction() && $source()->hasApplicableTransaction();
79+
}
80+
5081
/**
5182
* Retrieve or fill a shared model cache entry.
5283
*
84+
* @param Closure(): Model $source
5385
* @param Closure(): mixed $read
5486
* @param null|(Closure(): CacheRepository) $writeCache
5587
*/
5688
public function fill(
5789
CacheRepository $cache,
90+
Closure $source,
5891
string $key,
5992
int $ttl,
6093
Closure $read,
6194
bool $cacheNull = true,
6295
?Closure $writeCache = null,
6396
): mixed {
97+
if ($this->hasApplicableTransaction($source)) {
98+
return $read();
99+
}
100+
64101
$cached = $cache->get($key);
65102

66103
if ($this->isEnvelope($cached)) {
@@ -112,6 +149,57 @@ public function fill(
112149
return $acquired ? $result : $read();
113150
}
114151

152+
/**
153+
* Mutate selected models atomically and invalidate each written batch.
154+
*
155+
* @template TModel of Model
156+
* @param Builder<TModel> $query
157+
* @param Closure(Builder<TModel>): int $write
158+
* @param Closure(array<int, int|string>): void $invalidate
159+
*/
160+
public function mutate(Builder $query, Closure $write, Closure $invalidate): int
161+
{
162+
$query = clone $query;
163+
$base = $query->getQuery();
164+
165+
// Original OR predicates must not escape the batch's key or pagination constraints.
166+
$query->setQuery($base->cloneWithout(['wheres'])->cloneWithoutBindings(['where'])->addNestedWhereQuery($base));
167+
168+
$model = $query->getModel();
169+
$key = $model->getKeyName();
170+
$qualifiedKey = $model->getQualifiedKeyName();
171+
172+
// Batching must preserve the atomicity of the uncached, single-statement write.
173+
return $model->getConnection()->transaction(function () use ($query, $write, $invalidate, $key, $qualifiedKey): int {
174+
$affected = 0;
175+
176+
$selection = (clone $query)->select($qualifiedKey)->useWritePdo()->toBase();
177+
$writeBatch = function (array $ids) use ($query, $write, $invalidate, &$affected): void {
178+
$affected += $write((clone $query)->whereKey($ids)->reorder()->limit(null)->offset(null));
179+
$invalidate($ids);
180+
};
181+
182+
if ($selection->getLimit() !== null || $selection->getOffset() > 0) {
183+
// Writes may change the filter or sort columns, so preserve the selected set first.
184+
$ids = array_unique($selection->pluck($qualifiedKey)->all());
185+
186+
foreach (array_chunk($ids, self::MUTATION_BATCH_SIZE) as $batch) {
187+
$writeBatch($batch);
188+
}
189+
} else {
190+
// Other sort columns would make chunkById skip records between batches.
191+
$selection->reorder()->chunkById(
192+
self::MUTATION_BATCH_SIZE,
193+
static fn (Collection $rows) => $writeBatch($rows->pluck($key)->all()),
194+
$qualifiedKey,
195+
$key,
196+
);
197+
}
198+
199+
return $affected;
200+
});
201+
}
202+
115203
/**
116204
* Invalidate an exact shared model cache entry.
117205
*/

0 commit comments

Comments
 (0)