# HTTP Server CLI — Authentication

Bootgly HTTP authentication is split into small layers:

- **Request credentials** parse Basic `Authorization` headers without trusting them.
- **Request token metadata** exposes Bearer transport as `$Request->token`.
- **Authentication guards** verify credentials and emit protocol-aware challenges.
- **The `Authentication` middleware** runs one or more guards around protected routes.

Supported mechanisms in HTTP Server CLI are:

| Mechanism | Use case | Transport |
| --- | --- | --- |
| Basic | Compatibility, development, simple protected endpoints | `Authorization: Basic ...` |
| Bearer | Opaque API tokens and access tokens | `Authorization: Bearer <token>` |
| JWT | Signed compact tokens verified by Bootgly | `Authorization: Bearer <jwt>` |
| Session | Browser flows backed by `$Request->Session` | Session cookie |

> Digest authentication is intentionally not part of this first implementation.

## Request credentials

`$Request->authenticate()` parses Basic HTTP `Authorization` credentials and returns a credentials object or `null`:

```php
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Request\Authentications\Basic;

$Credentials = $Request->authenticate();

if ($Credentials instanceof Basic) {
   $username = $Credentials->username;
   $password = $Credentials->password;
}
```

The parser does not verify credentials. Verification belongs to guards and application resolvers. Bearer and JWT tokens stay inside `Router\Middlewares`: read `$Request->token` or use the Bearer/JWT guards.

The request also exposes lazy authentication metadata:

```php
$Request->username; // Basic username
$Request->password; // Basic password
$Request->token;    // Bearer token
```

## Response challenges

Use `$Response->authenticate()` to return a Basic `401 Unauthorized` challenge. Bearer challenges are emitted by the Bearer/JWT guards so their definitions stay inside `Router\Middlewares`.

### Basic challenge

```php
use Bootgly\WPI\Modules\HTTP\Server\Response\Authentication\Basic;

return $Response->authenticate(new Basic(
   realm: 'Bootgly Protected Area'
));
```

Emits:

```http
WWW-Authenticate: Basic realm="Bootgly Protected Area"
```

### Bearer challenge

```php
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\Bearer;

$Bearer = new Bearer(
   Resolver: fn (string $token): bool => $token === 'demo-bearer-token',
   realm: 'Bootgly API',
   error: 'invalid_token',
   description: 'The access token is missing or invalid.',
   URI: 'https://docs.bootgly.com/manual/WPI/HTTP/HTTP_Server_CLI/Authentication',
   scope: 'demo:read'
);
```

On failure, the guard emits a Bearer `WWW-Authenticate` header with RFC 6750-style attributes.

## Authentication middleware

The middleware receives an `Authenticating` strategy object. The strategy stores guards in evaluation order.

```php
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication;
```

When any guard authenticates successfully, the route handler runs. When all guards fail, the middleware uses the first guard to build the challenge response.

`Authentication` requires at least one guard. Creating it with an empty `Authenticating` strategy throws `InvalidArgumentException` so protected routes cannot fail closed silently because of misconfiguration.

Custom fallback callbacks may render a body or extra headers for denied requests, but the middleware normalizes the returned response to `401 Unauthorized` before and after the callback. This prevents accidental `200 OK` responses on authentication failure. Redirecting fallbacks are the one exception: a callback result that is already a redirect (3xx status + `Location` header) is returned untouched, so browser/session flows can send guests to a sign-in page with a real `303`.

```php
$Auth = new Authentication(
   Authenticating: $Bearer,
   Fallback: function (Request $Request, Response $Response): Response {
      return $Response(body: 'Custom unauthorized body');
   }
);
```

Authentication guards expose metadata through declared `Request` properties: `$Request->identity` for the authenticated principal and `$Request->claims` for verified token claims. Request doubles should declare those properties too, or use `stdClass` in lightweight tests.

## Hardening notes

- Pair Basic and Bearer routes with `RateLimit` to reduce brute-force attempts.
- Resolver callbacks should compare secrets with `hash_equals()` when checking passwords, API tokens, or shared secrets.
- Place CSRF protection before state-changing authenticated browser routes; token/API routes that do not use cookies can be exempted by route policy.
- JWT is transported as a Bearer token and shares the `$Request->token` hook with opaque Bearer credentials.

## Bearer token guard

Use the Bearer guard for opaque API tokens. The resolver receives the token and the `Request`.

```php
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Request;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Response;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\Bearer;

$Bearer = new Authenticating(
   new Bearer(function (string $token, Request $Request): bool {
      return $token === 'demo-bearer-token';
   })
);

yield $Router->route('/auth/bearer', function (Request $Request, Response $Response) {
   return $Response->JSON->send([
      'authorized' => true,
      'guard' => 'Bearer',
   ]);
}, GET, middlewares: [new Authentication($Bearer)]);
```

Try it:

```bash :toolbar="true";
curl -H 'Authorization: Bearer demo-bearer-token' http://localhost:8082/auth/bearer
```

A resolver may return:

- `false` or `null` to deny.
- `true` to expose the token as `$Request->identity`.
- any custom value to expose that value as `$Request->identity`.

## JWT guard

Bootgly includes a native JWT signer/verifier in `Bootgly\API\Security\JWT`. JWT is not a separate HTTP scheme; it uses Bearer transport. Create the JWT object once at application boot and share it across requests instead of rebuilding the key set per request.

```php
use Bootgly\API\Security\JWT;
use Bootgly\API\Security\JWT\Policies;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Request;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Response;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\JWT as JWTGuard;

$Token = new JWT('bootgly-demo-authentication-secret');

yield $Router->route('/auth/jwt/issue', function (Request $Request, Response $Response) use ($Token) {
   $token = $Token->sign([
      'sub' => 'demo-user',
      'scope' => 'demo:read',
      'exp' => time() + 3600,
   ]);

   return $Response->JSON->send([
      'token' => $token,
      'authorization' => "Bearer {$token}",
   ]);
}, GET);

$Policies = new Policies(
   issuers: 'https://issuer.bootgly.dev',
   audiences: 'api://bootgly-demo',
   subject: true
);
$JWT = new Authenticating(new JWTGuard($Token, $Policies));

yield $Router->route('/auth/jwt', function (Request $Request, Response $Response) {
   return $Response->JSON->send([
      'authorized' => true,
      'guard' => 'JWT',
   ]);
}, GET, middlewares: [new Authentication($JWT)]);
```

JWT signing throws `RuntimeException` when claims or headers cannot be JSON encoded. JWT verification rejects malformed tokens, unsupported algorithms, unsupported `typ` values, invalid signatures, expired `exp`, future `nbf`, and future `iat`. The HS256 secret must be at least 32 bytes. `Policies` can require exact `iss`, matching `aud`, non-empty `sub`, and non-empty `jti` claims. The guard still returns only a generic Bearer `invalid_token` challenge to the client.

The default `JWT->leeway` is `0`, so time claim verification is strict. Set a small value such as `5` seconds when your servers can have minor clock drift.

For deterministic tests, use `JWT->freeze($timestamp)` and then `JWT->resume()` to return to the wall clock.

When the `sub` claim is present, the guard exposes `Bootgly\API\Security\Identity` as `$Request->identity`. It always exposes verified claims as `$Request->claims` and verified protected headers as `$Request->tokenHeaders`. A space-separated JWT `scope` claim or an array/string `scp` claim is normalized into `Identity->scopes`, so `$Request->identity->check('demo:read')` works for JWT users. When both are present, `scope` takes precedence over `scp`.

### RS256, JWKS, and key rotation

Use `Bootgly\API\Security\JWT\Key` for explicit key ids and `Bootgly\API\Security\JWT\KeysJWKS` for local JWKS documents:

```php
use Bootgly\API\Security\JWT;
use Bootgly\API\Security\JWT\Key;
use Bootgly\API\Security\JWT\KeysJWKS;

$Signer = new JWT($privatePem, 'RS256');
$Signer->select(new Key($privatePem, 'RS256', 'current'));

$Verifier = new JWT($publicPem, 'RS256');
$Verifier->trust(KeysJWKS::parse($jwks, 'RS256'));
```

`KeysJWKS::parse()` keeps every supported RS256 key and skips unsupported siblings — encryption keys, other algorithms — as RFC 7517 asks, so an IdP publishing a mixed document never poisons verification; a document with zero supported keys still fails loudly. Always set `kid` when rotating JWT keys. A no-`kid` key set is intentionally single-slot for backwards-compatible tokens; adding a second default key fails loudly instead of letting the verifier guess.

For external OAuth/OIDC issuers, use `Bootgly\API\Security\JWT\Remote` with the provider's `jwks_uri`:

```php
use Bootgly\API\Security\JWT;
use Bootgly\API\Security\JWT\Policies;
use Bootgly\API\Security\JWT\Remote;
use Bootgly\API\Security\JWT\Vault;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\JWT as JWTGuard;

$Remote = new Remote('https://issuer.example/.well-known/jwks.json');
$Remote->TTL = 900; // public property is uppercase; constructor argument remains ttl:
$Remote->cache(new Vault);
$Verifier = new JWT($Remote, 'RS256');

$Policies = new Policies(
   issuers: 'https://issuer.example',
   audiences: 'api://bootgly-demo',
   subject: true
);

$JWT = new Authenticating(new JWTGuard($Verifier, $Policies));
```

`Remote` keeps a process-local `KeySet` and can use `Vault` to share versioned JWKS records across workers. The mutable public `$TTL` property (uppercase) is the operator ceiling, from `0` through `31,536,000` seconds; the constructor's named argument remains `ttl:`. Construction and later `$TTL` assignments reject negative or larger values without replacing the last valid setting. The former lowercase `$ttl` property is no longer public.

Redirects are followed one hop at a time, and every target must remain HTTPS unless `insecure: true` was explicitly selected for tests or a controlled local environment. `Location` URI references are resolved according to RFC 3986 by the shared [`URI`](/manual/ABI/Data/URI/) resolver: dot segments are removed without collapsing meaningful consecutive slashes, fragments are never sent in the request target, and a target that is not an absolute URI with a host (`g:h`, `https:keys`) ends the fetch. Only the final response supplies the accepted status and cache headers. Its effective lifetime is the shortest `Cache-Control: max-age`, reduced by `Age` and capped by `$TTL`; `no-store`, `no-cache`, and `max-age=0` make the response stale at once and prevent a shared-cache write (the floor below still holds it in memory). Shared readers inherit the writer's absolute expiry instead of starting a new TTL. `Remote` refreshes on an unknown `kid` and fails closed for fetch errors, invalid redirect targets, final non-2xx responses, invalid JSON, or invalid JWKS. OIDC Discovery, `ETag`/`Last-Modified`, and exponential backoff remain future layers.

**An origin floor.** The key is resolved before the signature is checked, so without a floor every token — forged ones included — would make a stale `Remote` fetch the JWKS, blocking the worker while it waits. Each worker process bounds how often it asks the origin, whatever the IdP's cache headers say:

- a key set the origin confirmed is **held** — served — for one window: `cooldown` (default `60` seconds), or `$TTL` when that is positive and shorter. It is held even when the IdP answers `no-store`, `no-cache` or `max-age=0`, and even with `ttl: 0` (which therefore holds for the whole `cooldown`); such a set still never enters the shared `Vault`;
- a failed fetch is **replayed** — the same failure, fail closed — for one `cooldown`, so one transient IdP error costs up to one `cooldown` of rejections on that worker; once the hold is over, an expired set is never served while the IdP fails, and a call made while a fetch is in flight fails with `Network`;
- an unknown `kid` still triggers one immediate refresh — at most one per `cooldown` per worker, and one for the whole fleet while the set is fresh and shared through a `Vault` — so a key the IdP adds is accepted at once on the worker that refreshes, unless another unknown `kid` already spent that refresh; behind a `Vault`, the other workers pick up a cacheable rotation when their own set expires. A key the IdP removes is rejected once the set's lifetime and its hold have both passed;
- a resolver pinned to `RS256` refuses a token with another `alg` without fetching.

`refresh()` is never floored. Build `Remote` once — at route-file scope, as above — never per request: the floor lives in the instance. `cooldown: 0` disables it and makes a `no-store` IdP cost one fetch per verification — keep it for tests.

`Vault` stores its records on the Bootgly `Cache` facade. By default it uses the `file` driver (shared across workers on the same filesystem); passing a Redis-backed `Cache` shares JWKS, refresh-token state, and revocations across hosts — inject a shared HMAC secret (≥ 32 bytes) so every host can verify the records:

```php
use Bootgly\ABI\Resources\Cache;
use Bootgly\API\Security\JWT\Vault;

$Vault = new Vault(
   new Cache(['driver' => 'redis', 'host' => 'redis.internal']),
   secret: getenv('JWT_VAULT_SECRET')
);
```

Every record carries an HMAC-SHA256 envelope, so an entry altered in the storage backend fails verification and reads as a miss.

### Custom key resolvers

`KeySet`, `KeysJWKS` and `Remote` are the shipped resolvers, but the slot `trust()` fills is an interface — `Bootgly\API\Security\JWT\KeyResolver`. Implement it when the keys live somewhere Bootgly cannot know about: your own `jwt_keys` table, a KMS, an HSM, a per-tenant key registry.

The contract has two methods. `resolve()` is called once per verification, after the header was decoded and its `alg` accepted, and **before** the signature is checked: it receives the protected header's `kid` (`null` when the token carries none) and the token's algorithm, and returns the one `Key` allowed to verify that token — or `null` to refuse. `fail()` is consulted only after a `null`, and its `Failures` case becomes the `Verification` failure; returning `null` there means "no detail", and the caller sees the generic `Failures::Key`.

```php
namespace Demo\Blog\Security;

use function is_array;
use function is_string;

use Bootgly\ADI\Databases\SQL;
use Bootgly\ADI\Databases\SQL\Builder\Auxiliaries\Operators;
use Bootgly\ADI\Databases\SQL\Builder\Identifier;
use Bootgly\API\Security\JWT;
use Bootgly\API\Security\JWT\Failures;
use Bootgly\API\Security\JWT\Key;
use Bootgly\API\Security\JWT\KeyResolver;

class Keys implements KeyResolver
{
   // * Data
   private SQL $Database;
   /**
    * Keys already read from the table, by `kid`.
    *
    * @var array<string,Key>
    */
   private array $Keys = [];

   // * Metadata
   private null|Failures $failure = null;


   public function __construct (SQL $Database)
   {
      $this->Database = $Database;
   }

   public function resolve (null|string $id, string $algorithm): null|Key
   {
      // ! One outcome per call: clear the previous verification's failure
      $this->failure = null;

      // ? Rotation requires an explicit `kid` — never guess a key
      if ($id === null) {
         $this->failure = Failures::Key;
         return null;
      }

      $Key = $this->Keys[$id] ?? null;

      if ($Key === null) {
         $Builder = $this->Database
            ->table(new Identifier('jwt_keys'))
            ->select(new Identifier('algorithm'), new Identifier('material'))
            ->filter(new Identifier('kid'), Operators::Equal, $id)
            ->filter(new Identifier('retired'), Operators::Equal, 0)
            ->limit(1);

         $row = $this->Database->query($Builder)->rows[0] ?? null;
         if (is_array($row) === false || is_string($row['material'] ?? null) === false) {
            $this->failure = Failures::Key;
            return null;
         }

         $Key = new Key($row['material'], (string) $row['algorithm'], $id);
         $this->Keys[$id] = $Key;
      }

      // ? The token's `alg` must be the one this key was enrolled for
      if ($Key->algorithm !== $algorithm) {
         $this->failure = Failures::Algorithm;
         return null;
      }

      return $Key;
   }

   public function fail (): null|Failures
   {
      return $this->failure;
   }
}

$Verifier = new JWT($currentSecret, 'HS256');
$Verifier->trust(new Keys($Database));
```

Rotation is then a row: insert the new key with a fresh `kid`, sign with it, and set `retired = 1` on the old row once no live token still carries it. Tokens signed by a retired key stop resolving, and `inspect()` answers `Failures::Key` — the resolver never falls back to "the only key it has", which is exactly what makes rotation safe.

Three rules the example follows, and yours should too:

- **Never ignore `$algorithm`.** Returning a key enrolled for a different algorithm is how algorithm-confusion attacks land. `KeySet` refuses the same way.
- **Refuse a `null` `$id` unless you really have a single-slot legacy key.** Guessing between candidates is the failure mode `KeySet` was designed to avoid — it returns `null` whenever a no-`kid` token matches more than one key.
- **Reset the failure at the top of `resolve()`.** `fail()` is read right after a `null` resolve, so a stale case from an earlier verification would mislabel this one. `Remote` clears its own on every successful resolve for the same reason.

Cache resolved keys in the resolver, as above: `resolve()` runs on every verified request, and a database round trip per request is a hot-path cost. Keep the cache process-local unless the material is already protected — `Vault` exists for the shared case.

### Refresh tokens and `jti` usage

For first-party fullstack apps, keep access tokens short-lived and rotate opaque refresh tokens through `Bootgly\API\Security\JWT\Tokens`:

```php
use Bootgly\API\Security\JWT\Replay;
use Bootgly\API\Security\JWT\Token;
use Bootgly\API\Security\JWT\Tokens;
use Bootgly\API\Security\JWT\Usage;
use Bootgly\API\Security\JWT\Vault;

$Vault = new Vault;
$Tokens = new Tokens($Vault);

$Issued = $Tokens->mint('user-42', 60 * 60 * 24 * 30, [
   'role' => 'admin',
]);

$Rotated = $Tokens->rotate($Issued->refresh, 60 * 60 * 24 * 30);
if ($Rotated instanceof Replay) {
   // incident: log subject/family and force logout for sibling sessions
}
elseif ($Rotated instanceof Token) {
   // issue the new refresh token to the client
}
```

`Tokens` stores refresh state under token hashes, consumes the old refresh token on rotation, leaves a tombstone with `subject`/claims for audit, and returns `Replay` if a consumed refresh token is reused. Replay revokes the whole family and should be treated as an incident.

Use `Usage` when access-token `jti` values need persistent revocation or single-use replay protection:

```php
$Usage = new Usage($Vault);
$Verifier->track($Usage);

$Usage->block('access-token-jti', 300);

$SingleUse = new Usage($Vault, single: true);
$Verifier->track($SingleUse);

$OptionalJTI = new Usage($Vault, required: false);
```

`Usage` runs only after signature, temporal claim validation, and optional `Policies` pass. By default it requires the `jti` claim; use `required: false` only when tokens without an identifier should pass without persistent revocation. Single-use mode requires an `exp` claim so the seen `jti` marker expires automatically.

## Basic guard

```php
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Request;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Response;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\Basic as BasicGuard;

$Basic = new Authenticating(
   new BasicGuard(function (string $username, string $password, Request $Request): bool {
      return $username === 'demo' && $password === 'secret';
   })
);

yield $Router->route('/auth/basic', function (Request $Request, Response $Response) {
   return $Response->JSON->send([
      'authorized' => true,
      'guard' => 'Basic',
   ]);
}, GET, middlewares: [new Authentication($Basic)]);
```

Try it:

```bash :toolbar="true";
curl -u demo:secret http://localhost:8082/auth/basic
```

## Session guard

Use the Session guard when authentication state lives in `$Request->Session`. It checks a session key and exposes the stored value as `$Request->identity`.

```php
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\Session as SessionGuard;

$Session = new Authenticating(new SessionGuard(key: 'identity'));

yield $Router->route('/account', function ($Request, $Response) {
   return $Response->JSON->send([
      'authorized' => true,
   ]);
}, GET, middlewares: [new Authentication($Session)]);
```

Session failures return a generic `401 Unauthorized` response without a `WWW-Authenticate` header.

## Remember guard

Use the Remember guard to revive sessions from a persistent trusted-device cookie (remember-me). It validates and rotates the token through the `Bootgly\API\Security\Tokens\Trust` store, regenerates the session id (fixation defense), installs the identity in the session and re-emits the rotated cookie.

```php
use Bootgly\API\Security\Tokens\Trust;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authenticating;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\Remember;
use Bootgly\WPI\Nodes\HTTP_Server_CLI\Router\Middlewares\Authentication\Session as SessionGuard;

$Trust = new Trust($Response->Database->Database);
$Auth = new Authentication(new Authenticating(
   new SessionGuard,          // cheap session check wins
   new Remember($Trust)       // cookie revival only on session miss
));
```

The guard owns the remember cookie: login flows call `emit()` after `Trust->issue()` and logout flows call `forget()`. The cookie policy is framework-owned through statics (`Remember::$name`, `$lifetime`, `$secure`, `$httpOnly`, `$sameSite`) — hardened defaults that php.ini cannot downgrade.

`Trust` keeps only the immediately previous validator digest for a fixed,
private five-second grace window. When an in-flight duplicate presents that
exact validator during the window, the guard declines without authenticating,
clearing the cookie or revoking devices. A replay after the window, or any
unrelated wrong validator for a known series, is the stolen-cookie signature:
the store reports `Theft` and the guard clears the cookie only after every
device has been successfully revoked. If that revocation fails, `rotate()`
returns `null` instead of fabricating a theft incident.

`Tokens->revoke()` and `Trust->revoke()` return `null|int`: `0` means the
statement succeeded without matching a row, while `null` means a recorded
database failure. Callers that promise logout-everywhere or credential
invalidation must treat `null` as an infrastructure failure.

The action-token `tokens` table must enforce `UNIQUE (user_id, purpose)`.
`mint()` uses one atomic upsert, so two concurrent callers can both receive a
token while only the winning value remains valid. Apply the Auth scaffold's
`20260822000200_unique_tokens_user_purpose` migration before deploying this
code. Pause action-token writers while MySQL performs the non-transactional
deduplication/index transition. For rollback, restore and drain the old code
first, then optionally remove the unique index after no new worker remains.

The underlying `trusts` table must provide nullable `previous VARCHAR(64)` and
`rotated BIGINT` columns; apply both additive migrations before deploying and
restart or drain all workers as one cohort.

Credential, action-token and trusted-device verdicts are always read from the
primary database, even when replicas are configured. Transaction-backed stores
use `SELECT ... FOR UPDATE` on MySQL/PostgreSQL; PostgreSQL Repeatable Read can
fail closed with SQLSTATE `40001` and requires rollback plus a whole-transaction
retry. SQLite uses a zero-row writer barrier and can fail closed with
`database is locked` for a stale snapshot. Locks last until commit/rollback,
read-only transactions fail closed, and these transactions should remain
short. Constructor signatures do not change; revocation return types are
nullable as described above. See the
**[Authentication guide](/guide/authentication/overview/)** for the complete
session/cookie scaffold and upgrade sequence.

## Multiple guards

You can compose guards in order:

```php
$API = new Authenticating(new JWTGuard($Token));
$API->add(new Bearer(function (string $token, Request $Request): bool {
   return $token === 'legacy-api-token';
}));

yield $Router->route('/api/private', $Handler, GET, middlewares: [new Authentication($API)]);
```

In this example Bootgly tries JWT first, then the opaque Bearer token. If both fail, the JWT guard defines the challenge because it is the first guard.

## Demo project

The repository includes working examples in `projects/Demo/HTTP_Server_CLI`:

- `router/routes/Authentication.routes.php`

Add `'Authentication'` to `router/router.index.php`, start the demo server, then open `GET /auth` to see runnable commands for Bearer, JWT, and Basic routes.

For the session/cookie flows (registration, e-mail verification, login + remember-me, password reset), see the exportable **Auth** demo in `bootgly-web/projects/Demo/Auth` and the **[Authentication guide](/guide/authentication/overview/)**.

## Reference

### Bootgly\API\Security\JWT\KeyResolver

```php
public function resolve (null|string $id, string $algorithm): null|Key
```

Returns the single `Key` allowed to verify a token, or `null` to refuse it. `JWT::inspect()` calls it once per verification — after the protected header was decoded and its `alg` and `typ` accepted, and before the signature is checked — so a key it returns is the key the signature is then verified with. `$id` is the header's `kid`, already validated as a string, or `null` when the token carries none; `$algorithm` is the token's `alg`. The resolver owns the algorithm check: a key enrolled for a different algorithm must be refused, not returned. A `null` ends the verification immediately — the signature is never checked and no claim is read.

```php
public function fail (): null|Failures
```

Returns the reason the last `resolve()` refused, or `null` when the resolver keeps no failure state. It is consulted **only** after `resolve()` returned `null`, and the result is used as `fail() ?? Failures::Key`, so `null` degrades to the generic "JWT key could not be resolved." message. Any `Failures` case is accepted — `Network`, `Status` and `JWKS` are what `Remote` reports for a JWKS fetch that failed — and the chosen case surfaces to the caller as `Verification->failure`, with `Verification->message` filled from the framework's description of it. Clear the stored failure at the start of `resolve()` so one verification never reads the previous one's reason. Failures are internal diagnostics: HTTP guards still answer the client with a generic Bearer error.

### Bootgly\API\Security\JWT\Remote

```php
public function __construct (string $URI, null|callable $Fetcher = null, null|string $algorithm = 'RS256', int $ttl = 3600, int $cooldown = 60, int $size = 1048576, bool $insecure = false)
```

Creates a resolver for the JWKS at `$URI` (HTTPS, or HTTP only with `insecure: true`). `$Fetcher` replaces the native HTTPS GET and returns the body string or a `Remote\Response`. `$algorithm` pins the resolver to `RS256` (`null` accepts any supported key). `$ttl` caps the lifetime of a fetched set; `$cooldown` sets the origin floor; `$size` bounds the response body in bytes. Throws `InvalidArgumentException` for an empty or non-HTTPS `$URI`, an unsupported algorithm, a `$ttl` or `$cooldown` outside `0`–`31,536,000`, or a `$size` below `1`.

```php
public int $TTL
```

The operator ceiling on a fetched set's freshness and shared-cache lifetime, in seconds (`0`–`31,536,000`). Cache headers can only shorten it; a positive value shorter than `cooldown` also shortens how long a set the origin confirmed is held (`0` still holds it for one `cooldown`). An invalid assignment throws and keeps the last valid value.

```php
public int $cooldown
```

The origin floor, in seconds (`0`–`31,536,000`, default `60`): a failed fetch is replayed for one `cooldown`, and a set the origin confirmed is held for one — or for `$TTL`, when that is positive and shorter. It also spaces the refresh on an unknown `kid`. `0` disables both. A new value shapes the next origin attempt; an invalid assignment throws and keeps the last valid value.

```php
public function fetch (): KeySet|Failures
```

Returns the key set while it is fresh or held, reads a live shared `Vault` record, or asks the origin — unless the origin is still paused after a failed fetch (one `cooldown`) or an attempt in flight, in which case the last failure (or `Network`) is returned again.

```php
public function refresh (): KeySet|Failures
```

Asks the origin now, bypassing freshness and the origin floor.
