From dc29eb2844dd6b2d5f81ebf1d7af4ba46d62ea3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Tue, 3 Nov 2026 23:07:58 +0100 Subject: [PATCH] Fix the anikeen API guard and register its user provider - Register the guard driver and the anikeen user provider with static closures: Laravel binds extend() closures to the AuthManager, so $this->makeGuard() failed. - Look the token's sub up in the user's Anikeen ID column (anikeen_id by default; provider 'column', services.anikeen.user_id_column or useUserIdField()), not in the primary key; works with the anikeen, eloquent and database providers. - JwtParser: key from services.anikeen.public_key(_path) or the bundled key, read once per process; misconfiguration is an error, not a 401. Passport tokens have no client claim, so it is optional now. - Optional revocation check against Anikeen ID (verify_revocation), cached per token. - anikeenTokenCan() without a token, anikeenTokenCanAny(), scope middleware accept JSON responses. - Update the bundled staging public key to the current staging key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5 --- README.md | 69 +++--- README.stub | 69 +++--- oauth-public.staging.key | 28 +-- src/Id/Auth/TokenGuard.php | 171 +++++++++------ src/Id/Auth/UserProvider.php | 27 ++- src/Id/HasAnikeenTokens.php | 18 +- src/Id/Helpers/JwtParser.php | 62 +++++- src/Id/Http/Middleware/CheckForAnyScope.php | 4 +- src/Id/Http/Middleware/CheckScopes.php | 4 +- src/Id/Providers/AnikeenIdServiceProvider.php | 47 ++-- src/Id/Providers/AnikeenIdUserProvider.php | 14 +- tests/Id/TokenGuardTest.php | 204 ++++++++++++++++++ 12 files changed, 554 insertions(+), 163 deletions(-) create mode 100644 tests/Id/TokenGuardTest.php diff --git a/README.md b/README.md index 4d9dc93..c409476 100644 --- a/README.md +++ b/README.md @@ -323,30 +323,10 @@ public function boot(): void } ``` -### Implementing Auth +### Implementing Auth (API tokens) -This method should typically be called in the `boot` method of your `AppServiceProvider` class: - -```php -use Anikeen\Id\AnikeenId; -use Anikeen\Id\Providers\AnikeenIdUserProvider; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; - -public function boot(): void -{ - Auth::provider('anikeen', function ($app, array $config) { - return new AnikeenIdUserProvider( - $app->make(AnikeenId::class), - $app->make(Request::class), - $config['model'], - $config['fields'] ?? [], - ); - }); -} -``` - -reference the guard in the `guards` configuration of your `auth.php` configuration file: +The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them +in `config/auth.php`: ```php 'guards' => [ @@ -360,11 +340,7 @@ reference the guard in the `guards` configuration of your `auth.php` configurati 'provider' => 'anikeen', ], ], -``` -reference the provider in the `providers` configuration of your `auth.php` configuration file: - -```php 'providers' => [ 'users' => [ 'driver' => 'eloquent', @@ -374,11 +350,48 @@ reference the provider in the `providers` configuration of your `auth.php` confi 'anikeen' => [ 'driver' => 'anikeen', 'model' => App\Models\User::class, - 'fields' => ['first_name', 'last_name', 'email'], + 'column' => 'anikeen_id', // optional, see below ], ], ``` +The guard accepts `Authorization: Bearer ` with an access token issued by Anikeen ID +(personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's +public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the +user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's +`column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of +unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request +unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests. + +The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available: + +```php +$request->user()->anikeenTokenCan('user'); // has the scope (or `*`) +$request->user()->anikeenTokenCanAny(['user', 'user:read']); +``` + +or as middleware (`403` without the scope): + +```php +use Anikeen\Id\Http\Middleware\CheckForAnyScope; +use Anikeen\Id\Http\Middleware\CheckScopes; + +Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes +Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope +``` + +Options in `config/services.php` (`anikeen`): + +| Key | Default | | +|-----|---------|-| +| `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) | +| `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used | +| `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID | +| `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable | +| `revocation_cache_ttl` | `300` | seconds that answer is cached per token | + +The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token. + ## General #### Setters and Getters diff --git a/README.stub b/README.stub index 5522a5f..f512bd4 100644 --- a/README.stub +++ b/README.stub @@ -286,30 +286,10 @@ public function boot(): void } ``` -### Implementing Auth +### Implementing Auth (API tokens) -This method should typically be called in the `boot` method of your `AppServiceProvider` class: - -```php -use Anikeen\Id\AnikeenId; -use Anikeen\Id\Providers\AnikeenIdUserProvider; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; - -public function boot(): void -{ - Auth::provider('anikeen', function ($app, array $config) { - return new AnikeenIdUserProvider( - $app->make(AnikeenId::class), - $app->make(Request::class), - $config['model'], - $config['fields'] ?? [], - ); - }); -} -``` - -reference the guard in the `guards` configuration of your `auth.php` configuration file: +The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them +in `config/auth.php`: ```php 'guards' => [ @@ -323,11 +303,7 @@ reference the guard in the `guards` configuration of your `auth.php` configurati 'provider' => 'anikeen', ], ], -``` -reference the provider in the `providers` configuration of your `auth.php` configuration file: - -```php 'providers' => [ 'users' => [ 'driver' => 'eloquent', @@ -337,11 +313,48 @@ reference the provider in the `providers` configuration of your `auth.php` confi 'anikeen' => [ 'driver' => 'anikeen', 'model' => App\Models\User::class, - 'fields' => ['first_name', 'last_name', 'email'], + 'column' => 'anikeen_id', // optional, see below ], ], ``` +The guard accepts `Authorization: Bearer ` with an access token issued by Anikeen ID +(personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's +public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the +user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's +`column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of +unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request +unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests. + +The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available: + +```php +$request->user()->anikeenTokenCan('user'); // has the scope (or `*`) +$request->user()->anikeenTokenCanAny(['user', 'user:read']); +``` + +or as middleware (`403` without the scope): + +```php +use Anikeen\Id\Http\Middleware\CheckForAnyScope; +use Anikeen\Id\Http\Middleware\CheckScopes; + +Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes +Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope +``` + +Options in `config/services.php` (`anikeen`): + +| Key | Default | | +|-----|---------|-| +| `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) | +| `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used | +| `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID | +| `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable | +| `revocation_cache_ttl` | `300` | seconds that answer is cached per token | + +The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token. + ## General #### Setters and Getters diff --git a/oauth-public.staging.key b/oauth-public.staging.key index a62c138..c3ec83d 100644 --- a/oauth-public.staging.key +++ b/oauth-public.staging.key @@ -1,14 +1,14 @@ ------BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAi4Ta8r01zKaGSnGi1EiD -uMFWRXBlK4y/ZIfWBpElmS2ygv4mGeP3hT4Flm696Z2UMy56KC+c7CC/PQCiutLk -5NUphyX/t+0QS5Dqpw6FB33fLTNNY7GqSmGIUE4os8XYZRSyDQRgtOgq3R3vJkoV -7zoavTJmSCQlG5Qf0T//iMmzQ+b+6VZm1CJSz5nGx94u1DuXNyP5Epkk0wuHrtwy -kADR2lmydNodJzqpSD+8yQqnAhOZNtNF4qwQ3g13fRvHycBp3G2nlCfOn2g5PmYD -KYBKqvTq4PQH4E+K3pbbMz6zf/T6Dw7zTfksqHR4hqMgN6byRRxmwuBczIumcu9b -y7xbgoIGIVZXgJliALPFi+zTPTN7c8MedFs/xCBHCmzWYTCZfHgr8RPRewD19tCG -NSny5R0vlArpuZCTTgedPESDeGU4eNEddg4yXFzKlpE2nNuvzZ1Ohruc5ETOSU19 -RTCBUBkjeL6ESZRd/yKGjbVx4dEYxZdIz4yBl+hZ2ZOIyG7L3zPrccAWrPpG56xr -E5IDBXxLFhaJ5LlyEAGQehB0ShEuCdkr88Xz7ba9PHpGqY83l4//ULrqPIZPAa4Z -E3AWHT1ZtXNPeA4SzZ9Y9Oij4M3chyHxqM0lL3kYP+dstZehTujStfElDIx2Ni10 -73tILu4edYS0FxsL19m8gbsCAwEAAQ== ------END PUBLIC KEY----- \ No newline at end of file +-----BEGIN PUBLIC KEY----- +MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEArGn4rtfKjlLtJsh61eW/ +cZdLC6ECbSUKVTZlLLvRoYr7Li6RKRDiIj7KEy+lRL4miiMtadXNx6d89KZwr8SL +DQXDbP//F3HEXVJBtr6j8p8HbJh1pinLJeKtcmeJfOIwVLyQ3sPdewkQoFtwkQyF +uZWLeh6TCfAtcQs49I6C0rYgLDQrfTzvQ195mX1HkgArndNDXBiECMYCCulmPpzv +yq5MWS5+/uQjKmPToo5lPfKxbWD6lyLII9ZkGp7wFilSonpUN1LmwRJlgQqh+1Hb +celGHTIXiP3lDNIG3sR/H5v6vwGeqfu9MBQ2Ksrz259O1aojqRQ+d9k1dj8pcbuC +ZurlI4++JOsmecc4B3g8tvlsevlb0UbtW/JHKckgK/JL9PO+04WJD+hSJrEhokLq +aB5Ceg6FKwqoM0l7dtoHRka8vh/q/UU3oawUESoZ5l3TjWI4b0jZzsJqE4cyKgK3 +S3tBooj4rM4dOh4y7crD1PcsJG/IF2bg4YEAO8q8Fz6TmOzCM20oBhaLrFJOnYph +Xhhl7ZPHqCCIt6HSDbwlFJw1Iw/2nygnkAZ0iJV/EQl0M+zlDGEv41SWHQfsLTK6 +HBSM1QSEbuMz0sF/TpolYEvYso+nCDjDzQ4MFdhGxH6OVyuSySm6nG0fK/Bio5Ow +6SNz0wIg3RagjzTbljHsT4cCAwEAAQ== +-----END PUBLIC KEY----- diff --git a/src/Id/Auth/TokenGuard.php b/src/Id/Auth/TokenGuard.php index a3e1f26..e5134c7 100644 --- a/src/Id/Auth/TokenGuard.php +++ b/src/Id/Auth/TokenGuard.php @@ -9,38 +9,39 @@ use Exception; use Firebase\JWT\JWT; use Firebase\JWT\Key; use Illuminate\Auth\AuthenticationException; -use Illuminate\Auth\GuardHelpers; -use Illuminate\Container\Container; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Contracts\Debug\ExceptionHandler; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Cookie\CookieValuePrefix; use Illuminate\Cookie\Middleware\EncryptCookies; -use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use stdClass; use Throwable; +/** + * Resolves the user of a request from an Anikeen ID access token (JWT, "Authorization: Bearer …") + * or from the encrypted token cookie of CreateFreshApiToken. + * + * A bearer token is verified with the JwtParser (signature, expiry). Its `sub` is the user's + * Anikeen ID, looked up by the provider (AnikeenIdUserProvider::retrieveByAnikeenId(), column + * `anikeen_id` by default). The token is attached to the user: `$user->anikeenTokenCan('scope')`. + * + * With `services.anikeen.verify_revocation` the token is also checked against Anikeen ID + * (GET v1/user, cached per token for `services.anikeen.revocation_cache_ttl` seconds), so + * revoked tokens stop working before they expire. + */ class TokenGuard { - use GuardHelpers; - - private Encrypter $encrypter; - - private JwtParser $jwtParser; - - public function __construct(UserProvider $provider, Encrypter $encrypter, JwtParser $jwtParser) - { - $this->provider = $provider; - $this->encrypter = $encrypter; - $this->jwtParser = $jwtParser; + public function __construct( + protected UserProvider $provider, + private Encrypter $encrypter, + private JwtParser $jwtParser + ) { + // } /** * Get the user for the incoming request. - * - * @throws BindingResolutionException - * @throws Throwable */ public function user(Request $request): ?Authenticatable { @@ -55,9 +56,6 @@ class TokenGuard /** * Authenticate the incoming request via the Bearer token. - * - * @throws BindingResolutionException - * @throws Throwable */ protected function authenticateViaBearerToken(Request $request): ?Authenticatable { @@ -65,51 +63,97 @@ class TokenGuard return null; } - // If the access token is valid we will retrieve the user according to the user ID - // associated with the token. We will use the provider implementation which may - // be used to retrieve users from Eloquent. Next, we'll be ready to continue. - /** @var Authenticatable|HasAnikeenTokens $user */ - $user = $this->provider->retrieveById( - $request->attributes->get('oauth_user_id') ?: null - ); + // client credentials tokens have no user + if (!is_scalar($token->sub ?? null) || (string) $token->sub === '') { + return null; + } - return $user?->withAnikeenAccessToken($token); + if (!$this->tokenIsActive($token, (string) $request->bearerToken())) { + return null; + } + /** @var Authenticatable|HasAnikeenTokens|null $user */ + $user = $this->retrieveUser((string) $token->sub); + + return $user && method_exists($user, 'withAnikeenAccessToken') + ? $user->withAnikeenAccessToken($token) + : $user; } /** - * Authenticate and get the incoming request via the Bearer token. - * - * @throws BindingResolutionException - * @throws Throwable + * Verify the Bearer token and expose its claims as request attributes. */ protected function validateRequestViaBearerToken(Request $request): ?stdClass { try { $decoded = $this->jwtParser->decode($request); - - $request->attributes->set('oauth_access_token_id', $decoded->jti); - $request->attributes->set('oauth_client_id', $decoded->aud); - $request->attributes->set('oauth_client_trusted', $decoded->client->trusted); - $request->attributes->set('oauth_user_id', $decoded->sub); - $request->attributes->set('oauth_scopes', $decoded->scopes); - - return $decoded; - } catch (AuthenticationException $e) { - $request->headers->set('Authorization', '', true); - - Container::getInstance()->make( - ExceptionHandler::class - )->report($e); - + } catch (AuthenticationException) { return null; } + + $decoded->scopes = array_values(array_filter((array) ($decoded->scopes ?? []), 'is_string')); + + $request->attributes->set('oauth_access_token_id', $decoded->jti ?? null); + $request->attributes->set('oauth_client_id', $decoded->aud ?? null); + $request->attributes->set('oauth_client_trusted', $decoded->client->trusted ?? null); + $request->attributes->set('oauth_user_id', $decoded->sub ?? null); + $request->attributes->set('oauth_scopes', $decoded->scopes); + + return $decoded; + } + + /** + * Whether Anikeen ID still accepts the token (only with services.anikeen.verify_revocation). + */ + protected function tokenIsActive(stdClass $token, string $jwt): bool + { + if (!config('services.anikeen.verify_revocation')) { + return true; + } + + $key = 'anikeen-id:token-active:' . hash('sha256', $jwt); + + if (($cached = Cache::get($key)) !== null) { + return (bool) $cached; + } + + try { + $result = (clone app(AnikeenId::class))->withToken($jwt)->getAuthedUser(); + } catch (Throwable) { + return false; // Anikeen ID unreachable: fail closed, ask again next time + } + + $status = $result->response?->getStatusCode(); + + if ($result->success()) { + $active = true; + } elseif (in_array($status, [401, 403], true)) { + $active = false; + } else { + return false; // server error: fail closed without caching + } + + $ttl = (int) config('services.anikeen.revocation_cache_ttl', 300); + if (isset($token->exp)) { + $ttl = max(1, min($ttl, (int) $token->exp - time())); + } + Cache::put($key, $active, $ttl); + + return $active; + } + + /** + * Find the local user of an Anikeen ID. + */ + protected function retrieveUser(string $anikeenId): ?Authenticatable + { + return $this->provider->retrieveByAnikeenId($anikeenId); } /** * Authenticate the incoming request via the token cookie. */ - protected function authenticateViaCookie(Request $request): mixed + protected function authenticateViaCookie(Request $request): ?Authenticatable { if (!$token = $this->getTokenViaCookie($request)) { return null; @@ -118,9 +162,11 @@ class TokenGuard // If this user exists, we will return this user and attach a "transient" token to // the user model. The transient token assumes it has all scopes since the user // is physically logged into the application via the application's interface. - /** @var Authenticatable|HasAnikeenTokens $user */ - if ($user = $this->provider->retrieveById($token['sub'])) { - return $user->withAnikeenAccessToken((object)['scopes' => ['*']]); + /** @var Authenticatable|HasAnikeenTokens|null $user */ + if ($user = $this->retrieveUser((string) $token['sub'])) { + return method_exists($user, 'withAnikeenAccessToken') + ? $user->withAnikeenAccessToken((object) ['scopes' => ['*']]) + : $user; } return null; @@ -136,15 +182,18 @@ class TokenGuard // database. If we can't decrypt the value we'll bail out with a null return. try { $token = $this->decodeJwtTokenCookie($request); - } catch (Exception $e) { + } catch (Exception) { + return null; + } + + if (!isset($token['sub'], $token['expiry'])) { return null; } // We will compare the CSRF token in the decoded API token against the CSRF header // sent with the request. If they don't match then this request isn't sent from // a valid source and we won't authenticate the request for further handling. - if (!AnikeenId::$ignoreCsrfToken && (!$this->validCsrf($token, $request) || - time() >= $token['expiry'])) { + if (time() >= $token['expiry'] || (!AnikeenId::$ignoreCsrfToken && !$this->validCsrf($token, $request))) { return null; } @@ -156,7 +205,7 @@ class TokenGuard */ protected function decodeJwtTokenCookie(Request $request): array { - return (array)JWT::decode( + return (array) JWT::decode( CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)), new Key( $this->encrypter->getKey(), @@ -171,8 +220,8 @@ class TokenGuard protected function validCsrf(array $token, Request $request): bool { return isset($token['csrf']) && hash_equals( - $token['csrf'], $this->getTokenFromRequest($request) - ); + $token['csrf'], $this->getTokenFromRequest($request) + ); } /** @@ -186,7 +235,7 @@ class TokenGuard $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized())); } - return $token; + return (string) $token; } /** @@ -196,4 +245,4 @@ class TokenGuard { return EncryptCookies::serialized('XSRF-TOKEN'); } -} \ No newline at end of file +} diff --git a/src/Id/Auth/UserProvider.php b/src/Id/Auth/UserProvider.php index 8583185..74115b0 100644 --- a/src/Id/Auth/UserProvider.php +++ b/src/Id/Auth/UserProvider.php @@ -2,6 +2,7 @@ namespace Anikeen\Id\Auth; +use Anikeen\Id\AnikeenId; use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\UserProvider as Base; @@ -10,11 +11,33 @@ class UserProvider implements Base /** * Create a new AnikeenId user provider. */ - public function __construct(protected Base $provider, protected string $providerName) + public function __construct(protected Base $provider, protected string $providerName, protected ?string $column = null) { // } + /** + * The column that holds a user's Anikeen ID: the provider's `column`, else + * `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`). + */ + public function column(): string + { + return $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField()); + } + + /** + * Find the user of an Anikeen ID (the `sub` of an access token). + */ + public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable + { + if (method_exists($this->provider, 'retrieveByAnikeenId')) { + return $this->provider->retrieveByAnikeenId($anikeenId); + } + + // eloquent / database providers: a plain where on the column + return $this->provider->retrieveByCredentials([$this->column() => $anikeenId]); + } + /** * {@inheritdoc} */ @@ -65,6 +88,6 @@ class UserProvider implements Base public function rehashPasswordIfRequired(Authenticatable $user, #[\SensitiveParameter] array $credentials, bool $force = false) { - // TODO: Implement rehashPasswordIfRequired() method. + $this->provider->rehashPasswordIfRequired($user, $credentials, $force); } } diff --git a/src/Id/HasAnikeenTokens.php b/src/Id/HasAnikeenTokens.php index 0ed969e..2a23741 100644 --- a/src/Id/HasAnikeenTokens.php +++ b/src/Id/HasAnikeenTokens.php @@ -26,9 +26,23 @@ trait HasAnikeenTokens */ public function anikeenTokenCan(string $scope): bool { - $scopes = $this->accessToken ? $this->accessToken->scopes : []; + $scopes = (array) ($this->accessToken->scopes ?? []); - return in_array('*', $scopes) || in_array($scope, $this->accessToken->scopes); + return in_array('*', $scopes, true) || in_array($scope, $scopes, true); + } + + /** + * Determine if the current API token has any of the given scopes. + */ + public function anikeenTokenCanAny(array $scopes): bool + { + foreach ($scopes as $scope) { + if ($this->anikeenTokenCan($scope)) { + return true; + } + } + + return false; } /** diff --git a/src/Id/Helpers/JwtParser.php b/src/Id/Helpers/JwtParser.php index c3597bc..0fb9f92 100644 --- a/src/Id/Helpers/JwtParser.php +++ b/src/Id/Helpers/JwtParser.php @@ -7,32 +7,74 @@ use Firebase\JWT\JWT; use Firebase\JWT\Key; use Illuminate\Auth\AuthenticationException; use Illuminate\Http\Request; +use RuntimeException; use stdClass; use Throwable; +/** + * Verifies access tokens issued by Anikeen ID (Passport, RS256): signature against + * Anikeen ID's public key plus the time claims (exp, nbf, iat; 60 seconds leeway). + * + * The key is `services.anikeen.public_key` (PEM, "\n" escapes allowed) or the file at + * `services.anikeen.public_key_path`, else the key shipped with this package for the + * configured mode (production / staging). It is read once per process. + */ class JwtParser { + /** + * Public keys already read, by source. + * + * @var array + */ + private static array $keys = []; + /** * @throws AuthenticationException */ public function decode(Request $request): stdClass { + return $this->decodeToken((string) $request->bearerToken()); + } + + /** + * @throws AuthenticationException + */ + public function decodeToken(string $token): stdClass + { + if ($token === '') { + throw new AuthenticationException('No access token given.'); + } + JWT::$leeway = 60; + // outside the try: a missing key is a configuration error, not an invalid token + $key = new Key($this->getOauthPublicKey(), 'RS256'); + try { - return JWT::decode( - $request->bearerToken(), - new Key($this->getOauthPublicKey(), 'RS256') - ); - } catch (Throwable $exception) { - throw (new AuthenticationException()); + return JWT::decode($token, $key); + } catch (Throwable) { + throw new AuthenticationException('The access token is invalid or expired.'); } } - private function getOauthPublicKey(): bool|string + /** + * Forget the keys read so far (e.g. after changing the configuration in tests). + */ + public static function flushKeys(): void { - return AnikeenId::getMode() === 'staging' - ? file_get_contents(dirname(__DIR__, 3) . '/oauth-public.staging.key') - : file_get_contents(dirname(__DIR__, 3) . '/oauth-public.key'); + self::$keys = []; + } + + protected function getOauthPublicKey(): string + { + if ($key = config('services.anikeen.public_key')) { + return self::$keys['config:' . md5($key)] ??= str_replace('\\n', "\n", $key); + } + + $path = config('services.anikeen.public_key_path') ?: (AnikeenId::getMode() === 'staging' + ? dirname(__DIR__, 3) . '/oauth-public.staging.key' + : dirname(__DIR__, 3) . '/oauth-public.key'); + + return self::$keys['file:' . $path] ??= (is_readable($path) ? (string) file_get_contents($path) : throw new RuntimeException("Anikeen ID public key [$path] is not readable.")); } } diff --git a/src/Id/Http/Middleware/CheckForAnyScope.php b/src/Id/Http/Middleware/CheckForAnyScope.php index 7b28ea2..13123c9 100644 --- a/src/Id/Http/Middleware/CheckForAnyScope.php +++ b/src/Id/Http/Middleware/CheckForAnyScope.php @@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException; use Closure; use Illuminate\Auth\AuthenticationException; use Illuminate\Http\Request; -use Illuminate\Http\Response; +use Symfony\Component\HttpFoundation\Response; class CheckForAnyScope extends UseParameters { @@ -17,7 +17,7 @@ class CheckForAnyScope extends UseParameters */ public function handle(Request $request, Closure $next, ...$scopes): Response { - if (!$request->user() || !$request->user()->anikeenToken()) { + if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) { throw new AuthenticationException; } diff --git a/src/Id/Http/Middleware/CheckScopes.php b/src/Id/Http/Middleware/CheckScopes.php index b308a12..51310fe 100644 --- a/src/Id/Http/Middleware/CheckScopes.php +++ b/src/Id/Http/Middleware/CheckScopes.php @@ -6,7 +6,7 @@ use Anikeen\Id\Exceptions\MissingScopeException; use Closure; use Illuminate\Auth\AuthenticationException; use Illuminate\Http\Request; -use Illuminate\Http\Response; +use Symfony\Component\HttpFoundation\Response; class CheckScopes extends UseParameters { @@ -17,7 +17,7 @@ class CheckScopes extends UseParameters */ public function handle(Request $request, Closure $next, ...$scopes): Response { - if (!$request->user() || !$request->user()->anikeenToken()) { + if (!$request->user() || !method_exists($request->user(), 'anikeenToken') || !$request->user()->anikeenToken()) { throw new AuthenticationException; } diff --git a/src/Id/Providers/AnikeenIdServiceProvider.php b/src/Id/Providers/AnikeenIdServiceProvider.php index d9aff1c..5d26743 100644 --- a/src/Id/Providers/AnikeenIdServiceProvider.php +++ b/src/Id/Providers/AnikeenIdServiceProvider.php @@ -8,8 +8,11 @@ use Anikeen\Id\Auth\UserProvider; use Anikeen\Id\Contracts; use Anikeen\Id\Helpers\JwtParser; use Anikeen\Id\Repository; +use Illuminate\Auth\AuthManager; use Illuminate\Auth\RequestGuard; -use Illuminate\Support\Facades\Auth; +use Illuminate\Contracts\Foundation\Application; +use Illuminate\Http\Request; +use InvalidArgumentException; use Illuminate\Support\ServiceProvider; class AnikeenIdServiceProvider extends ServiceProvider @@ -38,15 +41,28 @@ class AnikeenIdServiceProvider extends ServiceProvider } /** - * Register the token guard. + * Register the `anikeen` guard driver and user provider. + * + * Laravel binds non-static extend() closures to the AuthManager, so these closures are + * static and use nothing but their arguments. */ protected function registerGuard(): void { - Auth::resolved(function ($auth) { - $auth->extend('anikeen', function ($app, $name, array $config) { - return tap($this->makeGuard($config), function ($guard) { - $this->app->refresh('request', $guard, 'setRequest'); - }); + $this->callAfterResolving('auth', static function (AuthManager $auth): void { + $auth->provider('anikeen', static fn (Application $app, array $config) => new AnikeenIdUserProvider( + $app->make(AnikeenId::class), + $app->make('request'), + $config['model'] ?? $app['config']->get('auth.providers.users.model'), + $config['fields'] ?? [], + $config['column'] ?? null, + )); + + $auth->extend('anikeen', static function (Application $app, string $name, array $config): RequestGuard { + $guard = AnikeenIdServiceProvider::makeGuard($app, $config); + + $app->refresh('request', $guard, 'setRequest'); + + return $guard; }); }); } @@ -54,15 +70,20 @@ class AnikeenIdServiceProvider extends ServiceProvider /** * Make an instance of the token guard. */ - protected function makeGuard(array $config): RequestGuard + public static function makeGuard(Application $app, array $config): RequestGuard { - return new RequestGuard(function ($request) use ($config) { + $providerName = $config['provider'] ?? $app['config']->get('auth.defaults.provider', 'users'); + $provider = $app['auth']->createUserProvider($providerName) + ?? throw new InvalidArgumentException("Authentication user provider [$providerName] is not configured."); + $providerConfig = $app['config']->get("auth.providers.$providerName", []); + + return new RequestGuard(static function (Request $request) use ($app, $provider, $providerName, $providerConfig, $config) { return (new TokenGuard( - new UserProvider(Auth::createUserProvider($config['provider']), $config['provider']), - $this->app->make('encrypter'), - $this->app->make(JwtParser::class) + new UserProvider($provider, $providerName, $config['column'] ?? $providerConfig['column'] ?? null), + $app->make('encrypter'), + $app->make(JwtParser::class) ))->user($request); - }, $this->app['request']); + }, $app['request'], $provider); } /** diff --git a/src/Id/Providers/AnikeenIdUserProvider.php b/src/Id/Providers/AnikeenIdUserProvider.php index 956f01f..99e5dc0 100644 --- a/src/Id/Providers/AnikeenIdUserProvider.php +++ b/src/Id/Providers/AnikeenIdUserProvider.php @@ -18,7 +18,8 @@ class AnikeenIdUserProvider implements UserProvider private AnikeenId $anikeenId, private Request $request, private string $model, - private array $fields = [] + private array $fields = [], + private ?string $column = null, ) { $this->accessTokenField = AnikeenId::getAccessTokenField(); } @@ -97,6 +98,17 @@ class AnikeenIdUserProvider implements UserProvider ->first(); } + /** + * Find the user of an Anikeen ID (the `sub` of an access token) by the provider's `column`, + * else `services.anikeen.user_id_column`, else AnikeenId::getUserIdField() (`anikeen_id`). + */ + public function retrieveByAnikeenId(string $anikeenId): ?Authenticatable + { + $column = $this->column ?: (config('services.anikeen.user_id_column') ?: AnikeenId::getUserIdField()); + + return $this->newModelQuery()->where($column, $anikeenId)->first(); + } + /** * {@inheritDoc} */ diff --git a/tests/Id/TokenGuardTest.php b/tests/Id/TokenGuardTest.php new file mode 100644 index 0000000..687be20 --- /dev/null +++ b/tests/Id/TokenGuardTest.php @@ -0,0 +1,204 @@ + 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($key, $private); + self::$privateKey = $private; + self::$publicKey = openssl_pkey_get_details($key)['key']; + } + + JwtParser::flushKeys(); + config([ + 'services.anikeen.public_key' => self::$publicKey, + 'app.key' => 'base64:'.base64_encode(str_repeat('k', 32)), + 'database.default' => 'testing', + 'auth.guards.api' => ['driver' => 'anikeen', 'provider' => 'anikeen'], + 'auth.providers.anikeen' => ['driver' => 'anikeen', 'model' => GuardUser::class], + ]); + + Schema::create('users', function (Blueprint $table) { + $table->id(); + $table->string('anikeen_id')->nullable(); + $table->string('email'); + }); + + GuardUser::query()->insert([ + ['id' => 1, 'anikeen_id' => 'ulid-of-alice', 'email' => 'alice@example.com'], + ['id' => 2, 'anikeen_id' => '1', 'email' => 'bob@example.com'], + ]); + + Route::middleware('auth:api')->get('/me', fn () => ['email' => auth()->user()->email, 'scopes' => auth()->user()->anikeenToken()->scopes]); + Route::middleware(['auth:api', CheckScopes::using('user')])->post('/write', fn () => ['ok' => true]); + Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get('/read', fn () => ['ok' => true]); + } + + /** + * Like a fresh request in production: the guard remembers its user per application. + */ + public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null) + { + Auth::forgetGuards(); + + return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content); + } + + private function token(array $claims = [], ?string $privateKey = null): string + { + $now = time(); + + return JWT::encode(array_merge([ + 'aud' => '019a0000-0000-7000-8000-000000000001', + 'jti' => Str::random(40), + 'iat' => $now, + 'nbf' => $now, + 'exp' => $now + 3600, + 'sub' => 'ulid-of-alice', + 'scopes' => ['user'], + ], $claims), $privateKey ?? self::$privateKey, 'RS256'); + } + + public function test_the_api_guard_resolves(): void + { + $this->assertInstanceOf(RequestGuard::class, Auth::guard('api')); + $this->assertNull(Auth::guard('api')->user()); + } + + public function test_a_valid_token_resolves_the_user_by_anikeen_id(): void + { + $this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()]) + ->assertOk() + ->assertExactJson(['email' => 'alice@example.com', 'scopes' => ['user']]); + } + + public function test_the_user_is_not_looked_up_by_primary_key(): void + { + // sub "2" is bob's primary key, but nobody has the Anikeen ID "2" + $this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '2'])])->assertUnauthorized(); + // sub "1" is bob's Anikeen ID (alice's primary key) + $this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => '1'])]) + ->assertOk()->assertJsonPath('email', 'bob@example.com'); + } + + public function test_the_lookup_column_is_configurable(): void + { + config(['auth.providers.anikeen.column' => 'email']); + + $this->getJson('/me', ['Authorization' => 'Bearer '.$this->token(['sub' => 'bob@example.com'])]) + ->assertOk()->assertJsonPath('email', 'bob@example.com'); + } + + public function test_the_eloquent_provider_works_too(): void + { + config([ + 'auth.guards.api.provider' => 'users', + 'auth.providers.users' => ['driver' => 'eloquent', 'model' => GuardUser::class], + ]); + + $this->getJson('/me', ['Authorization' => 'Bearer '.$this->token()]) + ->assertOk()->assertJsonPath('email', 'alice@example.com'); + } + + public function test_invalid_tokens_answer_401_json(): void + { + $other = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); + openssl_pkey_export($other, $otherPrivate); + + $cases = [ + 'no token' => [], + 'garbage' => ['Authorization' => 'Bearer not-a-jwt'], + 'expired' => ['Authorization' => 'Bearer '.$this->token(['exp' => time() - 120])], + 'not yet valid' => ['Authorization' => 'Bearer '.$this->token(['nbf' => time() + 600])], + 'foreign key' => ['Authorization' => 'Bearer '.$this->token([], $otherPrivate)], + 'unknown user' => ['Authorization' => 'Bearer '.$this->token(['sub' => 'nobody'])], + 'client credentials' => ['Authorization' => 'Bearer '.$this->token(['sub' => ''])], + ]; + + foreach ($cases as $case => $headers) { + $this->getJson('/me', $headers)->assertUnauthorized()->assertJson(['message' => 'Unauthenticated.'], $case); + } + } + + public function test_scopes_are_enforced(): void + { + $read = $this->token(['scopes' => ['user:read']]); + $billing = $this->token(['scopes' => ['billing']]); + $all = $this->token(['scopes' => ['*']]); + + $this->getJson('/read', ['Authorization' => "Bearer $read"])->assertOk(); + $this->postJson('/write', [], ['Authorization' => "Bearer $read"])->assertForbidden(); + $this->getJson('/read', ['Authorization' => "Bearer $billing"])->assertForbidden(); + $this->postJson('/write', [], ['Authorization' => "Bearer $all"])->assertOk(); + $this->postJson('/write', [], ['Authorization' => 'Bearer '.$this->token()])->assertOk(); + } + + public function test_token_can(): void + { + $user = GuardUser::query()->find(1)->withAnikeenAccessToken((object) ['scopes' => ['user:read']]); + + $this->assertTrue($user->anikeenTokenCan('user:read')); + $this->assertFalse($user->anikeenTokenCan('user')); + $this->assertTrue($user->anikeenTokenCanAny(['user', 'user:read'])); + $this->assertFalse((new GuardUser())->anikeenTokenCan('user')); + } + + public function test_revoked_tokens_are_rejected_when_revocation_is_verified(): void + { + config(['services.anikeen.verify_revocation' => true]); + $active = $this->token(); + $revoked = $this->token(); + + $this->respond(200, ['data' => ['id' => 'ulid-of-alice']]); + $this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk(); + // cached: no second request to Anikeen ID + $this->getJson('/me', ['Authorization' => "Bearer $active"])->assertOk(); + $this->assertCount(1, $this->sent); + $this->assertSame("Bearer $active", $this->sent[0]['request']->getHeaderLine('Authorization')); + $this->assertStringEndsWith('v1/user', (string) $this->sent[0]['request']->getUri()); + + $this->respond(401, ['message' => 'Unauthenticated.']); + $this->getJson('/me', ['Authorization' => "Bearer $revoked"])->assertUnauthorized(); + + // Anikeen ID down: fail closed, not cached + $other = $this->token(); + $this->respond(503); + $this->getJson('/me', ['Authorization' => "Bearer $other"])->assertUnauthorized(); + $this->respond(200, ['data' => ['id' => 'ulid-of-alice']]); + $this->getJson('/me', ['Authorization' => "Bearer $other"])->assertOk(); + } +} + +class GuardUser extends Authenticatable +{ + use HasAnikeenTokens; + + protected $table = 'users'; + + public $timestamps = false; + + protected $guarded = []; +}