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 { public function __construct( protected UserProvider $provider, private Encrypter $encrypter, private JwtParser $jwtParser ) { // } /** * Get the user for the incoming request. */ public function user(Request $request): ?Authenticatable { if ($request->bearerToken()) { return $this->authenticateViaBearerToken($request); } elseif ($request->cookie(AnikeenId::cookie())) { return $this->authenticateViaCookie($request); } return null; } /** * Authenticate the incoming request via the Bearer token. */ protected function authenticateViaBearerToken(Request $request): ?Authenticatable { if (!$token = $this->validateRequestViaBearerToken($request)) { return null; } // client credentials tokens have no user if (!is_scalar($token->sub ?? null) || (string) $token->sub === '') { return null; } 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; } /** * Verify the Bearer token and expose its claims as request attributes. */ protected function validateRequestViaBearerToken(Request $request): ?stdClass { try { $decoded = $this->jwtParser->decode($request); } 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): ?Authenticatable { if (!$token = $this->getTokenViaCookie($request)) { return null; } // 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|null $user */ if ($user = $this->retrieveUser((string) $token['sub'])) { return method_exists($user, 'withAnikeenAccessToken') ? $user->withAnikeenAccessToken((object) ['scopes' => ['*']]) : $user; } return null; } /** * Get the token cookie via the incoming request. */ protected function getTokenViaCookie(Request $request): ?array { // If we need to retrieve the token from the cookie, it'll be encrypted so we must // first decrypt the cookie and then attempt to find the token value within the // database. If we can't decrypt the value we'll bail out with a null return. try { $token = $this->decodeJwtTokenCookie($request); } 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 (time() >= $token['expiry'] || (!AnikeenId::$ignoreCsrfToken && !$this->validCsrf($token, $request))) { return null; } return $token; } /** * Decode and decrypt the JWT token cookie. */ protected function decodeJwtTokenCookie(Request $request): array { return (array) JWT::decode( CookieValuePrefix::remove($this->encrypter->decrypt($request->cookie(AnikeenId::cookie()), AnikeenId::$unserializesCookies)), new Key( $this->encrypter->getKey(), 'HS256' ) ); } /** * Determine if the CSRF / header are valid and match. */ protected function validCsrf(array $token, Request $request): bool { return isset($token['csrf']) && hash_equals( $token['csrf'], $this->getTokenFromRequest($request) ); } /** * Get the CSRF token from the request. */ protected function getTokenFromRequest(Request $request): string { $token = $request->header('X-CSRF-TOKEN'); if (!$token && $header = $request->header('X-XSRF-TOKEN')) { $token = CookieValuePrefix::remove($this->encrypter->decrypt($header, static::serialized())); } return (string) $token; } /** * Determine if the cookie contents should be serialized. */ public static function serialized(): bool { return EncryptCookies::serialized('XSRF-TOKEN'); } }