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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VbpgPaLqhBzo1AXHNs9Cx5
This commit is contained in:
Maurice PreußandClaude Opus 5.5 committed 2026-11-03 23:07:58 +01:00
1 parent b074ec3c0c
commit dc29eb2844
12 files changed
+554 -163

No files matched your search

+110 -61
View File
@@ -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');
}
}
}
+25 -2
View File
@@ -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);
}
}
+16 -2
View File
@@ -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;
}
/**
+52 -10
View File
@@ -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<string, string>
*/
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."));
}
}
+2 -2
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
+34 -13
View File
@@ -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);
}
/**
+13 -1
View File
@@ -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}
*/