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

+204
View File
@@ -0,0 +1,204 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\HasAnikeenTokens;
use Anikeen\Id\Helpers\JwtParser;
use Anikeen\Id\Http\Middleware\CheckForAnyScope;
use Anikeen\Id\Http\Middleware\CheckScopes;
use Firebase\JWT\JWT;
use Illuminate\Auth\RequestGuard;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class TokenGuardTest extends TestCase
{
private static ?string $privateKey = null;
private static string $publicKey;
protected function setUp(): void
{
parent::setUp();
if (self::$privateKey === null) {
$key = openssl_pkey_new(['private_key_bits' => 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 = [];
}