diff --git a/README.md b/README.md index efe6b14..40eeaa7 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ The adapter sits in your backend between your users and the Seamless Auth API: Which routes the adapter serves, and what each does to the session, comes from the adapter manifest the auth API publishes, so a new API route works without a new release of this package. +For an API that only checks access tokens from the Seamless OpenID Provider, the package also has a +[verify-only mode](#verify-only-resource-servers) that needs just the issuer and an audience. + It is held to the same [conformance suite](https://github.com/fells-code/seamless-cli/blob/main/verify/CONFORMANCE.md) as the Express, Fastify, Go and Rust adapters, with a FastAPI and a Django reference app, in CI on every change. @@ -273,6 +276,146 @@ It keeps the auth API's `X-Frame-Options`, `Content-Security-Policy` and `X-Cont so the console cannot be framed on your origin. When you serve it, add your API's origin to the auth server's `ORIGINS` so passkey ceremonies started in the console verify. +## Verify-only resource servers + +An API that only receives access tokens from the Seamless OpenID Provider (RFC 9068 JWTs, sent as +`Authorization: Bearer`) needs none of the adapter: no cookies, no service secret, no auth routes. +`ResourceServer` takes the provider's issuer and this API's resource identifier, and nothing else: + +```python +from seamless_auth import ResourceServer + +rs = ResourceServer( + issuer="https://login.example.com", # the API's oidc_issuer + audience="https://api.example.com", # this API's resource identifier +) +``` + +### FastAPI + +```python +from fastapi import Depends, FastAPI +from seamless_auth import VerifiedUser, require_role, require_scope, require_step_up +from seamless_auth.fastapi import RequireBearer, add_bearer_error_handler + +app = FastAPI() +add_bearer_error_handler(app) + +read_orders = RequireBearer(rs, require_scope("orders:read")) +refund = RequireBearer( + rs, require_scope("orders:write"), require_step_up(acr_values=["phr"], max_age=300) +) + + +@app.get("/orders") +def orders(user: VerifiedUser = Depends(read_orders)): + return {"sub": user.sub, "org": user.org_id} + + +@app.post("/orders/{id}/refund") +def refund_order(id: str, user: VerifiedUser = Depends(refund)): ... +``` + +`RequireBearer(rs, *guards)` verifies the token, runs the guards in order, and returns the +`VerifiedUser`. `add_bearer_error_handler(app)` answers refusals with the JSON bodies below; without +it FastAPI still sends the status and `WWW-Authenticate`, with the body under `detail`. + +### Django + +```python +# settings.py +SEAMLESS_RESOURCE_SERVER = { + "issuer": os.environ["OIDC_ISSUER"], + "audience": os.environ["API_AUDIENCE"], +} + +# views.py +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt +from seamless_auth.django import bearer_user_of, require_bearer, require_scope, require_step_up + + +@require_bearer +@require_scope("orders:read") +def orders(request): + return JsonResponse({"sub": bearer_user_of(request).sub}) + + +@csrf_exempt # bearer only: no cookie for a cross-site request to ride on +@require_bearer +@require_scope("orders:write") +@require_step_up(acr_values=["phr"], max_age=300) +def refund(request, id): ... +``` + +`SEAMLESS_RESOURCE_SERVER` takes the keyword arguments of `ResourceServer`; `confirm` may be a +dotted path. `@require_bearer` (or `@require_bearer(server=rs)`) verifies the token and puts the +`VerifiedUser` on the request; the guard decorators go beneath it and run top to bottom. A guard +with no `@require_bearer` above it answers as if there were no token. All of them work on sync and +async views. + +### What is verified + +A token passes only if all of these hold, and anything else answers 401 `invalid_token`: + +- A compact JWS with header `alg: RS256`, `typ` of `at+jwt` or `application/at+jwt` (any case), + a `kid`, and no `crit`. +- Signed by the key with that `kid` in the provider's key set, an RSA key whose `use` (if + present) is `sig` and whose `alg` (if present) is `RS256`. +- `iss` equal to `issuer` exactly, and `aud` equal to `audience` or a list containing it. +- In date: `exp` required, `nbf` optional, with 5 seconds of clock skew. +- A non-empty `sub` and `client_id`, and no first-party `typ` claim. +- No `cnf` (a sender-constrained token), unless a `confirm` hook accepts it (below). +- `scope`, `acr`, `sid` strings, `amr` and `roles` lists of strings, `auth_time` a number and + `org_id` a string or null, where present. + +Keys come from `jwks_uri` when you pass one. Otherwise the first request fetches +`/.well-known/openid-configuration` (5 second timeout, 256 KiB at most), which must name +the same `issuer` and an http(s) `jwks_uri` (https under an https issuer). Discovery succeeds once +per process; a failure answers 401 and is retried at most every 5 seconds. The key set is cached +as the adapter caches the auth API's: a new `kid` refetches it at most every 30 seconds, and it is +fetched again after 10 minutes. + +`VerifiedUser` carries `sub`, `client_id`, `scopes` (the `scope` claim split on spaces), `roles`, +`sid`, `org_id`, `acr`, `amr`, `auth_time`, `exp`, `iat`, the raw `token` and every `claims`. A +client credentials token (`sub == client_id`, no user claims) is valid, and fails role and step-up +guards. + +### Guards + +| Guard | Passes when | Otherwise | +| --- | --- | --- | +| `require_scope(*scopes)` | every scope is in `scopes`, exactly | 403 `{"error":"insufficient_scope","scope":"a b"}`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="a b"` | +| `require_role(*roles)` | any role is granted (`x:*` grants `x` and under, `billing` grants `billing:...`, `p:write` grants `p:read`) | 403 `{"error":"insufficient_role"}` | +| `require_step_up(acr_values=, amr=, max_age=)` | `acr` is one of `acr_values` (`phr` also accepts `phrh`), every `amr` is present, and at most `max_age` seconds since `auth_time` | 401 `insufficient_user_authentication` (RFC 9470) | + +A missing token, a scheme other than `Bearer` or more than one token answers 401 +`{"error":"missing_token"}` with `WWW-Authenticate: Bearer`. A step-up refusal names what the client +must ask the provider for, so it can send the user to sign in again: + +``` +WWW-Authenticate: Bearer error="insufficient_user_authentication", + error_description="More recent authentication is required", acr_values="phr", max_age="300" +``` + +with the same fields in the JSON body (`max_age` as a number). `role_grants_access(granted, +required)` is the role matching on its own. + +### Sender-constrained tokens + +A token with a `cnf` claim is bound to a key the client must prove it holds (DPoP), so it is +refused unless you pass `confirm`. The hook gets the `cnf` value and a `BearerRequest` (method, +headers, absolute URL), and accepts the token by returning `True`. Anything else, including an +exception, refuses it: + +```python +def confirm(cnf, request): + return check_dpop_proof(cnf["jkt"], request.headers.get("DPoP"), request.method, request.url) + + +rs = ResourceServer(issuer=..., audience=..., confirm=confirm) +``` + ## How it runs The adapter is synchronous and thread-safe: one `Adapter` serves the whole process. Under FastAPI diff --git a/src/seamless_auth/__init__.py b/src/seamless_auth/__init__.py index 2b1d61a..7e5daa3 100644 --- a/src/seamless_auth/__init__.py +++ b/src/seamless_auth/__init__.py @@ -6,6 +6,9 @@ Which routes it serves, and what each does to the session, comes from the auth API's adapter manifest. +:class:`ResourceServer` is the verify-only mode, for an API that only receives +OpenID Provider access tokens: no cookies, no service secret, no auth routes. + The framework bindings live in :mod:`seamless_auth.fastapi` and :mod:`seamless_auth.django`. """ @@ -15,21 +18,43 @@ from ._delivery import Delivery from ._http import AuthRequest, AuthResponse, Headers, ResponseCookie from ._manifest import MANIFEST_PATH, Manifest, ManifestError, ManifestRoute +from ._resource_server import ( + BearerError, + BearerRequest, + Confirm, + Guard, + ResourceServer, + VerifiedUser, + require_role, + require_scope, + require_step_up, + role_grants_access, +) __all__ = [ "MANIFEST_PATH", "Adapter", "AuthRequest", "AuthResponse", + "BearerError", + "BearerRequest", + "Confirm", "CrossSiteRequest", "Delivery", "EnrollmentRequired", + "Guard", "Headers", "Manifest", "ManifestError", "ManifestRoute", + "ResourceServer", "ResponseCookie", "TrustedProxies", "Unauthenticated", "User", + "VerifiedUser", + "require_role", + "require_scope", + "require_step_up", + "role_grants_access", ] diff --git a/src/seamless_auth/_adapter.py b/src/seamless_auth/_adapter.py index a5b6a35..6ad36fb 100644 --- a/src/seamless_auth/_adapter.py +++ b/src/seamless_auth/_adapter.py @@ -265,7 +265,7 @@ def __init__( self._deliver = _sync_deliver(deliver) if deliver is not None else None self._delivered_kinds = MESSAGE_KINDS | (NOTICE_KINDS if deliver_account_notices else set()) self._resolve_client_ip = resolve_client_ip - self._jwks = JwksCache(url, self._client) + self._jwks = JwksCache(f"{url}/.well-known/jwks.json", self._client) self._manifest = ManifestSource(url, self._client, not disable_manifest_fetch) self._refreshes = Refresher() self._proxy_token: tuple[str, float] | None = None diff --git a/src/seamless_auth/_jwks.py b/src/seamless_auth/_jwks.py index 263387f..18b3de7 100644 --- a/src/seamless_auth/_jwks.py +++ b/src/seamless_auth/_jwks.py @@ -6,6 +6,7 @@ import logging import threading import time +from dataclasses import dataclass from typing import Any import httpx2 @@ -33,16 +34,26 @@ class TokenError(Exception): """A token the auth API was supposed to have signed did not verify.""" +@dataclass(frozen=True) +class Jwk: + """A published RSA key, with the JWK members that constrain its use.""" + + key: RSAPublicKey + params: dict[str, Any] + + class JwksCache: - def __init__(self, auth_server_url: str, client: httpx2.Client) -> None: - self._url = f"{auth_server_url}/.well-known/jwks.json" + """The key set at ``url``, fetched on first use and kept fresh.""" + + def __init__(self, url: str, client: httpx2.Client) -> None: + self._url = url self._client = client self._lock = threading.Lock() - self._keys: dict[str, Any] | None = None + self._keys: dict[str, Jwk] | None = None self._fetched_at: float | None = None self._attempted_at: float | None = None - def _key(self, kid: str) -> Any: + def key(self, kid: str) -> Jwk: with self._lock: now = time.monotonic() fresh = self._fetched_at is not None and now - self._fetched_at < MAX_AGE @@ -66,12 +77,12 @@ def _key(self, kid: str) -> Any: raise TokenError(f"unknown key id {kid!r}") return self._keys[kid] - def _fetch(self) -> dict[str, Any]: + def _fetch(self) -> dict[str, Jwk]: with self._client.stream("GET", self._url, timeout=FETCH_TIMEOUT) as res: if res.status_code != 200: raise TokenError(f"HTTP {res.status_code}") body = read_limited(res, MAX_JWKS_BYTES) - keys: dict[str, Any] = {} + keys: dict[str, Jwk] = {} for jwk in json.loads(body).get("keys", []): if not isinstance(jwk, dict) or jwk.get("kty") != "RSA": continue @@ -84,7 +95,8 @@ def _fetch(self) -> dict[str, Any]: if isinstance(key, RSAPrivateKey): key = key.public_key() if isinstance(key, RSAPublicKey): - keys[str(jwk.get("kid", ""))] = key + params = {k: jwk[k] for k in ("kid", "kty", "use", "alg", "key_ops") if k in jwk} + keys[str(jwk.get("kid", ""))] = Jwk(key, params) return keys def verify(self, token: str, issuer: str, audience: str) -> Claims: @@ -97,7 +109,7 @@ def verify(self, token: str, issuer: str, audience: str) -> Claims: if header.get("alg") != "RS256": raise TokenError("unexpected algorithm") kid = header.get("kid") - key = self._key(kid if isinstance(kid, str) else "") + key = self.key(kid if isinstance(kid, str) else "").key try: claims: Claims = jwt.decode( token, diff --git a/src/seamless_auth/_resource_server.py b/src/seamless_auth/_resource_server.py new file mode 100644 index 0000000..5b27d57 --- /dev/null +++ b/src/seamless_auth/_resource_server.py @@ -0,0 +1,518 @@ +"""Verify-only resource server mode: OpenID Provider access tokens (RFC 9068) as +``Authorization: Bearer``, with audience, scope, role and step-up guards.""" + +from __future__ import annotations + +import base64 +import binascii +import json +import logging +import math +import re +import threading +import time +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass, field +from typing import Any, TypeGuard +from urllib.parse import urlsplit + +import httpx2 +from jwt.algorithms import RSAAlgorithm + +from ._http import Headers +from ._jwks import FETCH_TIMEOUT, JwksCache, TokenError, read_limited +from ._jwt import CLOCK_SKEW_SECONDS + +log = logging.getLogger("seamless_auth") + +DISCOVERY_PATH = "/.well-known/openid-configuration" +MAX_DISCOVERY_BYTES = 256 * 1024 +# A failed discovery holds off the next attempt this long, so a provider that is +# down is not asked again on every request. +DISCOVERY_RETRY = 5.0 + +_ACCESS_TOKEN_TYPES = ("at+jwt", "application/at+jwt") +_SEGMENT = re.compile(r"^[A-Za-z0-9_-]+$") +_WHITESPACE = re.compile(r"[ \t\r\n\f\v]+") +_RS256 = RSAAlgorithm(RSAAlgorithm.SHA256) + + +class BearerError(Exception): + """A request a verify-only guard refused, with the answer to give it.""" + + def __init__(self, status: int, body: dict[str, Any], www_authenticate: str | None) -> None: + super().__init__(body["error"]) + self.status = status + self.body = body + self.www_authenticate = www_authenticate + + @property + def error(self) -> str: + return str(self.body["error"]) + + def headers(self) -> dict[str, str]: + return {"WWW-Authenticate": self.www_authenticate} if self.www_authenticate else {} + + +def missing_token() -> BearerError: + return BearerError(401, {"error": "missing_token"}, "Bearer") + + +def invalid_token() -> BearerError: + return BearerError(401, {"error": "invalid_token"}, 'Bearer error="invalid_token"') + + +@dataclass(frozen=True) +class VerifiedUser: + """The caller an OpenID Provider access token names. A client credentials token + has ``sub == client_id`` and no user claims.""" + + sub: str + client_id: str + scopes: tuple[str, ...] = () + roles: tuple[str, ...] = () + sid: str | None = None + org_id: str | None = None + acr: str | None = None + amr: tuple[str, ...] = () + auth_time: float | None = None + exp: float = 0 + iat: float | None = None + #: When the token was verified, by the verifier's clock. Step-up ``max_age`` + #: is measured from here. + verified_at: float = 0 + claims: dict[str, Any] = field(default_factory=dict, repr=False, compare=False) + token: str = field(default="", repr=False) + + +class BearerRequest: + """What a confirmation hook may need of the request: its method, headers, and + absolute URL (built on first read).""" + + def __init__(self, method: str, headers: Headers, url: str | Callable[[], str]) -> None: + self.method = method + self.headers = headers + self._url = url + + @property + def url(self) -> str: + if callable(self._url): + self._url = self._url() + return self._url + + +#: Called with a token's ``cnf`` claim and the request it came on (``None`` when the +#: token is verified on its own). Returning ``True`` accepts the binding. +Confirm = Callable[[Any, BearerRequest | None], bool] + + +class ResourceServer: + """Verifies OpenID Provider access tokens for one API. + + ``issuer`` is the provider's issuer and ``audience`` this API's resource + identifier. Keys come from ``jwks_uri``, or from the issuer's discovery + document when it is not given. A token carrying ``cnf`` is refused unless + ``confirm`` accepts it. + """ + + def __init__( + self, + issuer: str, + audience: str, + *, + jwks_uri: str | None = None, + http_client: httpx2.Client | None = None, + confirm: Confirm | None = None, + clock: Callable[[], float] | None = None, + ) -> None: + if not isinstance(issuer, str) or not _http_url(issuer): + raise ValueError("seamless-auth: issuer must be an absolute http(s) URL") + if not isinstance(audience, str) or not audience: + raise ValueError("seamless-auth: audience is required") + if jwks_uri is not None and not _http_url(jwks_uri): + raise ValueError("seamless-auth: jwks_uri must be an absolute http(s) URL") + self.issuer = issuer + self.audience = audience + self._client = http_client or httpx2.Client(timeout=FETCH_TIMEOUT, follow_redirects=False) + self._confirm = confirm + self._clock = clock or time.time + self._lock = threading.Lock() + self._jwks = JwksCache(jwks_uri, self._client) if jwks_uri else None + self._discovery_attempted_at: float | None = None + + def __repr__(self) -> str: + return f"ResourceServer(issuer={self.issuer!r}, audience={self.audience!r})" + + def authenticate( + self, authorization: str | None, request: BearerRequest | None = None + ) -> VerifiedUser: + """The user an ``Authorization`` header carries. Raises :class:`BearerError`, + ``missing_token`` or ``invalid_token``.""" + token = bearer_token(authorization) + if token is None: + raise missing_token() + return self.verify(token, request) + + def verify(self, token: str, request: BearerRequest | None = None) -> VerifiedUser: + """Verifies a token. Raises :class:`BearerError` ``invalid_token``.""" + try: + return self._verify(token, request) + except TokenError as err: + log.debug("[seamless-auth] Bearer token refused: %s", err) + raise invalid_token() from None + + def _keys(self) -> JwksCache: + with self._lock: + if self._jwks is not None: + return self._jwks + now = time.monotonic() + if ( + self._discovery_attempted_at is not None + and now - self._discovery_attempted_at < DISCOVERY_RETRY + ): + raise TokenError("discovery failed recently") + self._discovery_attempted_at = now + try: + self._jwks = JwksCache(self._discover(), self._client) + except Exception as err: + log.warning("[seamless-auth] OpenID discovery for %s failed: %s", self.issuer, err) + raise TokenError(f"discovery: {err}") from err + return self._jwks + + def _discover(self) -> str: + url = self.issuer.rstrip("/") + DISCOVERY_PATH + with self._client.stream("GET", url, timeout=FETCH_TIMEOUT) as res: + if res.status_code != 200: + raise TokenError(f"HTTP {res.status_code}") + body = read_limited(res, MAX_DISCOVERY_BYTES) + doc = _json_object(body) + if doc.get("issuer") != self.issuer: + raise TokenError("discovery document names another issuer") + jwks_uri = doc.get("jwks_uri") + if not isinstance(jwks_uri, str) or not _http_url(jwks_uri): + raise TokenError("discovery document has no http(s) jwks_uri") + if urlsplit(self.issuer).scheme == "https" and urlsplit(jwks_uri).scheme != "https": + raise TokenError("jwks_uri is not https under an https issuer") + return jwks_uri + + def _verify(self, token: str, request: BearerRequest | None) -> VerifiedUser: + segments = token.split(".") + if len(segments) != 3 or not all(_SEGMENT.match(s) for s in segments): + raise TokenError("not a compact JWS") + header = _json_object(_b64decode(segments[0])) + if header.get("alg") != "RS256": + raise TokenError("unexpected algorithm") + typ = header.get("typ") + if not isinstance(typ, str) or not typ.isascii() or typ.lower() not in _ACCESS_TOKEN_TYPES: + raise TokenError("not an access token") + kid = header.get("kid") + if not isinstance(kid, str) or not kid: + raise TokenError("no key id") + if "crit" in header: + raise TokenError("critical header parameters are not supported") + + jwk = self._keys().key(kid) + if jwk.params.get("kty") != "RSA": + raise TokenError("not an RSA key") + if "use" in jwk.params and jwk.params["use"] != "sig": + raise TokenError("key is not for signatures") + if "alg" in jwk.params and jwk.params["alg"] != "RS256": + raise TokenError("key is for another algorithm") + signing_input = f"{segments[0]}.{segments[1]}".encode("ascii") + try: + signed = _RS256.verify(signing_input, jwk.key, _b64decode(segments[2])) + except ValueError as err: + raise TokenError("bad signature") from err + if not signed: + raise TokenError("bad signature") + + claims = _json_object(_b64decode(segments[1])) + if claims.get("iss") != self.issuer: + raise TokenError("unexpected issuer") + aud = claims.get("aud") + if not (aud == self.audience or (isinstance(aud, list) and self.audience in aud)): + raise TokenError("unexpected audience") + + now = self._clock() + exp = claims.get("exp") + if not _is_number(exp) or not now < exp + CLOCK_SKEW_SECONDS: + raise TokenError("expired") + if "nbf" in claims: + nbf = claims["nbf"] + if not _is_number(nbf) or not now + CLOCK_SKEW_SECONDS >= nbf: + raise TokenError("not yet valid") + + sub = claims.get("sub") + client_id = claims.get("client_id") + if not isinstance(sub, str) or not sub: + raise TokenError("no subject") + if not isinstance(client_id, str) or not client_id: + raise TokenError("no client_id") + # A first-party session token carries typ; one must never pass as an access + # token for an API (ADR D11). + if "typ" in claims: + raise TokenError("first-party token") + + scope = _optional(claims, "scope", _is_str) + acr = _optional(claims, "acr", _is_str) + sid = _optional(claims, "sid", _is_str) + amr = _optional(claims, "amr", _is_str_list) + roles = _optional(claims, "roles", _is_str_list) + auth_time = _optional(claims, "auth_time", _is_number) + iat = claims.get("iat") if _is_number(claims.get("iat")) else None + org_id = claims.get("org_id") + if org_id is not None and not isinstance(org_id, str): + raise TokenError("org_id is not a string") + + cnf = claims.get("cnf") + if cnf is not None and not self._confirmed(cnf, request): + raise TokenError("confirmation (cnf) not accepted") + + return VerifiedUser( + sub=sub, + client_id=client_id, + scopes=tuple(s for s in (scope or "").split(" ") if s), + roles=tuple(roles or ()), + sid=sid, + org_id=org_id, + acr=acr, + amr=tuple(amr or ()), + auth_time=auth_time, + exp=exp, + iat=iat, + verified_at=now, + claims=claims, + token=token, + ) + + def _confirmed(self, cnf: Any, request: BearerRequest | None) -> bool: + if self._confirm is None: + return False + try: + return self._confirm(cnf, request) is True + except Exception as err: + log.warning("[seamless-auth] The confirmation hook raised (%s).", err) + return False + + +def bearer_token(authorization: str | None) -> str | None: + """The token of a ``Bearer`` authorization header, or ``None`` without exactly + one.""" + if authorization is None: + return None + parts = _WHITESPACE.split(authorization.strip()) + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + return parts[1] + + +class Guard: + """A check on a verified user, run after verification in declaration order.""" + + def check(self, user: VerifiedUser) -> None: + raise NotImplementedError + + +class _ScopeGuard(Guard): + def __init__(self, scopes: tuple[str, ...]) -> None: + self.scopes = scopes + + def check(self, user: VerifiedUser) -> None: + if not all(s in user.scopes for s in self.scopes): + scope = " ".join(self.scopes) + raise BearerError( + 403, + {"error": "insufficient_scope", "scope": scope}, + f'Bearer error="insufficient_scope", scope="{scope}"', + ) + + +class _RoleGuard(Guard): + def __init__(self, roles: tuple[str, ...]) -> None: + self.roles = roles + + def check(self, user: VerifiedUser) -> None: + if not any(role_grants_access(g, r) for r in self.roles for g in user.roles): + raise BearerError(403, {"error": "insufficient_role"}, None) + + +class _StepUpGuard(Guard): + def __init__( + self, + acr_values: tuple[str, ...] | None, + max_age: int | None, + amr: tuple[str, ...] | None, + ) -> None: + self.acr_values = acr_values + self.max_age = max_age + self.amr = amr + accepted = set(acr_values or ()) + if "phr" in accepted: + # A hardware-bound passkey (phrh) is a stronger phishing-resistant one. + accepted.add("phrh") + self._accepted = accepted + + def check(self, user: VerifiedUser) -> None: + level_ok = (self.acr_values is None or user.acr in self._accepted) and ( + self.amr is None or all(m in user.amr for m in self.amr) + ) + recent = self.max_age is None or ( + user.auth_time is not None and user.verified_at - user.auth_time <= self.max_age + ) + if level_ok and recent: + return + description = ( + "More recent authentication is required" + if level_ok + else "A different authentication level is required" + ) + body: dict[str, Any] = { + "error": "insufficient_user_authentication", + "error_description": description, + } + header = ( + f'Bearer error="insufficient_user_authentication", error_description="{description}"' + ) + if self.acr_values is not None: + body["acr_values"] = " ".join(self.acr_values) + header += f', acr_values="{body["acr_values"]}"' + if self.max_age is not None: + body["max_age"] = self.max_age + header += f', max_age="{self.max_age}"' + raise BearerError(401, body, header) + + +def require_scope(*scopes: str) -> Guard: + """Every listed scope must be granted, exactly. Otherwise 403 + ``insufficient_scope``.""" + _check_names("require_scope", scopes) + return _ScopeGuard(tuple(scopes)) + + +def require_role(*roles: str) -> Guard: + """Any one listed role must be granted, by :func:`role_grants_access`. Otherwise + 403 ``insufficient_role``.""" + _check_names("require_role", roles) + return _RoleGuard(tuple(roles)) + + +def require_step_up( + *, + acr_values: Sequence[str] | None = None, + max_age: int | None = None, + amr: Sequence[str] | None = None, +) -> Guard: + """The authentication behind the token must be strong enough and recent enough + (RFC 9470). Otherwise 401 ``insufficient_user_authentication`` with the + challenge the client needs to sign in again. + + ``acr_values``: the token's ``acr`` must be one of them (``phr`` also accepts + ``phrh``). ``amr``: every listed method must be in the token's ``amr``. + ``max_age``: at most this many seconds since ``auth_time``. + """ + if acr_values is None and max_age is None and amr is None: + raise ValueError("seamless-auth: require_step_up needs acr_values, max_age or amr") + if max_age is not None and ( + not isinstance(max_age, int) or isinstance(max_age, bool) or max_age < 0 + ): + raise ValueError("seamless-auth: max_age must be a non-negative integer") + if acr_values is not None: + _check_names("require_step_up acr_values", _as_tuple(acr_values)) + if amr is not None: + _check_names("require_step_up amr", _as_tuple(amr)) + return _StepUpGuard( + _as_tuple(acr_values) if acr_values is not None else None, + max_age, + _as_tuple(amr) if amr is not None else None, + ) + + +def role_grants_access(granted_role: str, required_role: str) -> bool: + """Whether a granted role satisfies a required one, as ``roleGrantsAccess`` in + ``@seamless-auth/types``: ``x:*`` grants ``x`` and everything under it, an + unscoped role grants everything beneath it, and ``p:write`` implies ``p:read``.""" + granted = granted_role.strip() + required = required_role.strip() + if not granted or not required: + return False + if granted == required: + return True + if granted.endswith(":*"): + prefix = granted[:-2] + return required == prefix or required.startswith(f"{prefix}:") + if ":" not in required: + return False + if ":" not in granted: + return required.startswith(f"{granted}:") + granted_parts = granted.split(":") + required_parts = required.split(":") + return ( + granted_parts[-1] == "write" + and required_parts[-1] == "read" + and granted_parts[:-1] == required_parts[:-1] + ) + + +def _as_tuple(values: Iterable[str]) -> tuple[str, ...]: + if isinstance(values, str): + raise TypeError("seamless-auth: pass a list of values, not a string") + return tuple(values) + + +def _check_names(what: str, names: tuple[str, ...]) -> None: + if not names: + raise ValueError(f"seamless-auth: {what} needs at least one value") + if not all(isinstance(n, str) and n for n in names): + raise ValueError(f"seamless-auth: {what} values must be non-empty strings") + + +def _http_url(value: str) -> bool: + try: + parts = urlsplit(value) + except ValueError: + return False + return parts.scheme in ("http", "https") and bool(parts.netloc) + + +def _b64decode(segment: str) -> bytes: + try: + return base64.urlsafe_b64decode(segment + "=" * (-len(segment) % 4)) + except (binascii.Error, ValueError) as err: + raise TokenError("bad base64url") from err + + +def _reject_constant(name: str) -> Any: + raise ValueError(f"{name} is not JSON") + + +def _json_object(raw: bytes) -> dict[str, Any]: + try: + value = json.loads(raw.decode("utf-8"), parse_constant=_reject_constant) + except (UnicodeDecodeError, ValueError, RecursionError) as err: + raise TokenError("not JSON") from err + if not isinstance(value, dict): + raise TokenError("not a JSON object") + return value + + +def _is_number(value: Any) -> TypeGuard[float]: + if isinstance(value, bool): + return False + return isinstance(value, int) or (isinstance(value, float) and math.isfinite(value)) + + +def _is_str(value: Any) -> bool: + return isinstance(value, str) + + +def _is_str_list(value: Any) -> bool: + return isinstance(value, list) and all(isinstance(v, str) for v in value) + + +def _optional(claims: dict[str, Any], name: str, valid: Callable[[Any], bool]) -> Any: + if name not in claims: + return None + value = claims[name] + if not valid(value): + raise TokenError(f"{name} has the wrong type") + return value diff --git a/src/seamless_auth/django.py b/src/seamless_auth/django.py index eb9c289..1c6a98a 100644 --- a/src/seamless_auth/django.py +++ b/src/seamless_auth/django.py @@ -22,6 +22,25 @@ def me(request): ``SEAMLESS_AUTH`` takes the keyword arguments of :class:`seamless_auth.Adapter`. ``deliver`` and ``resolve_client_ip`` may be dotted paths to callables. + +Verify-only, for an API that only receives OpenID Provider access tokens:: + + # settings.py + SEAMLESS_RESOURCE_SERVER = { + "issuer": "https://login.example.com", + "audience": "https://api.example.com", + } + + # views.py + from seamless_auth.django import bearer_user_of, require_bearer, require_scope + + @require_bearer + @require_scope("orders:read") + def orders(request): + return JsonResponse({"sub": bearer_user_of(request).sub}) + +``SEAMLESS_RESOURCE_SERVER`` takes the keyword arguments of +:class:`seamless_auth.ResourceServer`; ``confirm`` may be a dotted path. """ from __future__ import annotations @@ -50,19 +69,37 @@ def me(request): User, ) from ._http import AuthRequest, AuthResponse, Headers +from ._resource_server import ( + BearerError, + BearerRequest, + Guard, + ResourceServer, + VerifiedUser, + missing_token, +) +from ._resource_server import require_role as _role_guard +from ._resource_server import require_scope as _scope_guard +from ._resource_server import require_step_up as _step_up_guard __all__ = [ "auth_view", + "bearer_user_of", "console_urlpatterns", "console_view", "get_adapter", + "get_resource_server", "require_auth", + "require_bearer", + "require_role", + "require_scope", + "require_step_up", "urlpatterns", ] _PATH_SAFE = "/:@!$&'()*+,;=-._~" _lock = threading.Lock() _adapter: Adapter | None = None +_resource_server: ResourceServer | None = None F = TypeVar("F", bound=Callable[..., Any]) @@ -89,6 +126,30 @@ def _reset_adapter() -> None: _adapter = None +def get_resource_server() -> ResourceServer: + """The verifier configured by ``settings.SEAMLESS_RESOURCE_SERVER``, built on + first use.""" + global _resource_server # noqa: PLW0603 + with _lock: + if _resource_server is None: + options = dict(getattr(settings, "SEAMLESS_RESOURCE_SERVER", None) or {}) + if not options: + raise ImproperlyConfigured( + "seamless-auth: set SEAMLESS_RESOURCE_SERVER in settings" + ) + if isinstance(options.get("confirm"), str): + options["confirm"] = import_string(options["confirm"]) + _resource_server = ResourceServer(**options) + return _resource_server + + +def _reset_resource_server() -> None: + """Forgets the configured verifier, for tests that change settings.""" + global _resource_server # noqa: PLW0603 + with _lock: + _resource_server = None + + @csrf_exempt def auth_view(request: HttpRequest, path: str = "") -> HttpResponse: """The auth routes. Exempt from Django's CSRF middleware: the adapter refuses @@ -167,6 +228,103 @@ def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: return cast(F, wrapper) +@overload +def require_bearer(view: F) -> F: ... +@overload +def require_bearer(*, server: ResourceServer | None = None) -> Callable[[F], F]: ... + + +def require_bearer(view: F | None = None, *, server: ResourceServer | None = None) -> Any: + """Verifies the request's OpenID Provider access token and puts the + :class:`~seamless_auth.VerifiedUser` on ``request.seamless_bearer``. Answers 401 + ``missing_token`` or ``invalid_token`` otherwise. Uses ``server``, or the one + ``settings.SEAMLESS_RESOURCE_SERVER`` configures. Put it above the + :func:`require_scope`, :func:`require_role` and :func:`require_step_up` + decorators; they run top to bottom after it. Works on sync and async views.""" + + def check(request: HttpRequest) -> HttpResponse | None: + values = request.headers.get("Authorization") + bearer = BearerRequest( + request.method or "GET", _headers(request), request.build_absolute_uri + ) + try: + user = (server or get_resource_server()).authenticate(values, bearer) + except BearerError as err: + return _bearer_response(err) + request.seamless_bearer = user # type: ignore[attr-defined] + return None + + if view is None: + return lambda inner: _wrap(inner, check, blocking=True) + return _wrap(view, check, blocking=True) + + +def require_scope(*scopes: str) -> Callable[[F], F]: + """Under :func:`require_bearer`: every listed scope must be granted, else 403 + ``insufficient_scope``.""" + return _guarded(_scope_guard(*scopes)) + + +def require_role(*roles: str) -> Callable[[F], F]: + """Under :func:`require_bearer`: any one listed role must be granted, else 403 + ``insufficient_role``.""" + return _guarded(_role_guard(*roles)) + + +def require_step_up( + *, + acr_values: list[str] | tuple[str, ...] | None = None, + max_age: int | None = None, + amr: list[str] | tuple[str, ...] | None = None, +) -> Callable[[F], F]: + """Under :func:`require_bearer`: the RFC 9470 step-up check, else 401 + ``insufficient_user_authentication`` with the challenge.""" + return _guarded(_step_up_guard(acr_values=acr_values, max_age=max_age, amr=amr)) + + +def bearer_user_of(request: HttpRequest) -> VerifiedUser: + """The user :func:`require_bearer` put on the request.""" + return cast(VerifiedUser, request.seamless_bearer) # type: ignore[attr-defined] + + +def _guarded(guard: Guard) -> Callable[[F], F]: + def check(request: HttpRequest) -> HttpResponse | None: + user = getattr(request, "seamless_bearer", None) + try: + if not isinstance(user, VerifiedUser): + raise missing_token() + guard.check(user) + except BearerError as err: + return _bearer_response(err) + return None + + return lambda view: _wrap(view, check, blocking=False) + + +def _wrap(view: F, check: Callable[[HttpRequest], HttpResponse | None], *, blocking: bool) -> F: + if inspect.iscoroutinefunction(view): + + @functools.wraps(view) + async def async_wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: + refused = await sync_to_async(check)(request) if blocking else check(request) + return refused or await view(request, *args, **kwargs) + + return cast(F, async_wrapper) + + @functools.wraps(view) + def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> Any: + return check(request) or view(request, *args, **kwargs) + + return cast(F, wrapper) + + +def _bearer_response(err: BearerError) -> JsonResponse: + response = JsonResponse(err.body, status=err.status) + if err.www_authenticate: + response["WWW-Authenticate"] = err.www_authenticate + return response + + def user_of(request: HttpRequest) -> User: """The user :func:`require_auth` put on the request.""" return cast(User, request.seamless_user) # type: ignore[attr-defined] diff --git a/src/seamless_auth/fastapi.py b/src/seamless_auth/fastapi.py index c9d5425..91e9b75 100644 --- a/src/seamless_auth/fastapi.py +++ b/src/seamless_auth/fastapi.py @@ -15,16 +15,29 @@ @app.get("/api/me") def me(user: User = Depends(current_user)): return {"id": user.id} + +Verify-only, for an API that only receives OpenID Provider access tokens:: + + from seamless_auth import ResourceServer, VerifiedUser, require_scope + from seamless_auth.fastapi import RequireBearer, add_bearer_error_handler + + rs = ResourceServer(issuer="https://login.example.com", audience="https://api.example.com") + app = FastAPI() + add_bearer_error_handler(app) + + @app.get("/orders") + def orders(user: VerifiedUser = Depends(RequireBearer(rs, require_scope("orders:read")))): + return {"sub": user.sub} """ from __future__ import annotations from urllib.parse import quote -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, FastAPI, HTTPException, Request from starlette.background import BackgroundTask from starlette.concurrency import run_in_threadpool -from starlette.responses import Response, StreamingResponse +from starlette.responses import JSONResponse, Response, StreamingResponse from ._adapter import ( MAX_BODY_BYTES, @@ -35,8 +48,17 @@ def me(user: User = Depends(current_user)): User, ) from ._http import AuthRequest, AuthResponse, Headers - -__all__ = ["RequireUser", "auth_router", "console_router"] +from ._resource_server import BearerError, BearerRequest, Guard, ResourceServer, VerifiedUser + +__all__ = [ + "BearerHTTPException", + "RequireBearer", + "RequireUser", + "add_bearer_error_handler", + "auth_router", + "bearer_exception_handler", + "console_router", +] _METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] # What a path segment may keep when a raw path has to be rebuilt from a decoded one. @@ -102,6 +124,53 @@ def __call__(self, request: Request) -> User: raise HTTPException(status_code=401, detail="unauthenticated") from None +class BearerHTTPException(HTTPException): + """A verify-only refusal. :func:`bearer_exception_handler` answers it with the + exact JSON body; without the handler FastAPI still sends its status and + ``WWW-Authenticate``, with the body under ``detail``.""" + + def __init__(self, error: BearerError) -> None: + super().__init__(error.status, detail=error.body, headers=error.headers() or None) + self.body = error.body + + +def bearer_exception_handler(request: Request, exc: Exception) -> Response: + if not isinstance(exc, BearerHTTPException): + raise exc + return JSONResponse(exc.body, status_code=exc.status_code, headers=exc.headers) + + +def add_bearer_error_handler(app: FastAPI) -> None: + """Answers :class:`RequireBearer` refusals with the shared verify-only bodies, + such as ``{"error": "insufficient_scope", "scope": "orders:read"}``.""" + app.add_exception_handler(BearerHTTPException, bearer_exception_handler) + + +class RequireBearer: + """A dependency that verifies the request's OpenID Provider access token, runs + ``guards`` in order, and returns the :class:`~seamless_auth.VerifiedUser`. + + A plain function, so FastAPI runs it on its threadpool: the first request may + fetch the provider's discovery document and key set. + """ + + def __init__(self, server: ResourceServer, *guards: Guard) -> None: + self._server = server + self._guards = guards + + def __call__(self, request: Request) -> VerifiedUser: + values = request.headers.getlist("authorization") + bearer = BearerRequest(request.method, _headers(request), lambda: str(request.url)) + try: + # More than one Authorization header is not one token. + user = self._server.authenticate(values[0] if len(values) == 1 else None, bearer) + for guard in self._guards: + guard.check(user) + except BearerError as err: + raise BearerHTTPException(err) from None + return user + + def _headers(request: Request) -> Headers: return Headers((k.decode("latin-1"), v.decode("latin-1")) for k, v in request.headers.raw) diff --git a/tests/data/verify_only_vectors.json b/tests/data/verify_only_vectors.json new file mode 100644 index 0000000..8c0a71c --- /dev/null +++ b/tests/data/verify_only_vectors.json @@ -0,0 +1,1011 @@ +{ + "version": 1, + "issuer": "https://login.example.test", + "audience": "https://api.example.test", + "now": 1798761600, + "clock_skew_seconds": 5, + "jwks": { + "keys": [ + { + "kty": "RSA", + "kid": "vo-1", + "n": "pHmBvGmvIIiw1bwTVR05FfigV6lmtx_8EECJfV3EzwLUPwUjQ_BxW4MigfitdmU10gC5mSIWI0LbL7NU8NGe3C5tLyUVxziqv0A5BcyQWZU_MhpNG3_TcjnIUfpMOy4HJBt5_kFnt2GYJJ-WEhuxy29etfL3Y-mJMxOjo3zhSQsufNLbhQ9wBEaIEcaEn7j16lYmb_7-vb2_VKb9d-r2LbxET2n7TqaS3dzLWepoWY9B7CyzEQ_CvzjD0rM99cwbywSYOaCuZCy73NB67BhKWJF0LWJCxV8BGCBcRJNIM7mGXsa27jm5efDdcKb8RD6MFlCCyjamWUt9vls2oe286Q", + "e": "AQAB", + "alg": "RS256", + "use": "sig" + }, + { + "kty": "RSA", + "kid": "vo-2", + "n": "0OtuAS3knU3BsVh4bSRip3FUTlLhDFm1uHCmXkTwDsMMsPAG2k9zUrcrlGDAxpeCVSJ-d2e6YhuFEG4uaxtSpHv820om58eHP71UbgTujSWequ5fphBhmERyeL-rC0WM9R_MPTNQKXpS8n7H3wnbmYga-SWwstB5udmuGiI31F4mUKyz5lzq8lsAFsKTX7WeL_nX_5SsuVTr_FgbHQgZ69ire0rasxKFjY9vJgTmxAyzg8qmJqhLUq-rSWdOtmJJ8gJ3mfuTKu9ZFabsZvXcbuetiDDCB6ZN-wWErTgN4EjBNoNjQPIWfz-N5HuONonQNsei_yue38xehxQ-UX1nfw", + "e": "AQAB", + "alg": "RS256" + }, + { + "kty": "RSA", + "kid": "vo-enc", + "n": "4MDzTM8v9tOpzfqp6-FdTbfC2lDTeqmjvzXFFiDZn13T_AsESweN0I5cnaAxI7P2QDn6399c2UqihTu5Ru6CsZGsdjVPnVue8iL4Yn6QCMuKGHOKKiiyCjC34K-dmyv8SEe6Io7sjhCeBNQredjVkhxWsA0DfF4M51tu3kZDriAZYMiEcNvJwnNcXR_nleD-kXM80pcj6G1mOlcDuRNfsseVG2CPtkaxufjD5gdHSjd8MVW1CXm7BsSctvAuxYtV-7FnObHOfvU_HWA7WYvx_Yox-KAuFsEUb3UYVpDxc49Jl0WCmlp5PS7uadBXbLOhC6KzKv7rPfrwqwVGAlusZQ", + "e": "AQAB", + "alg": "RS256", + "use": "enc" + }, + { + "kty": "RSA", + "kid": "vo-rs512", + "n": "xvr8RXq7s7KPJw5M11H5Mo3DfH2J33FR2ZYjGqD5JNKE1wAY13RtTLUKuqVRp00lqDiQNtQrwNI5JE5faW_guS_J0peZ9qgd_-_7RyGv4FGTFjl_gK86tUsIeIt8sciMBnMyR-jTMmegzSGUAIIqYgSn8JIIg4i3oHbRPiwz9Mts8QrsAoZV3upZrFKI4WIXdahul15MwJLnxT-ZZyg3rweUtL06_Cgj_ELCabn8ktE_x7BMzpU7-zDiDPqAvjEQrHT0P8ZAA0D9xJMRu-BiJjv2hQgrNx7p_4XUPM6VONUYw_3mLIdQCUrY-TBT2rDXMG7fgdMbHrgmaWDLDDgv9Q", + "e": "AQAB", + "alg": "RS512", + "use": "sig" + } + ] + }, + "tokens": { + "user_passkey": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "user_otp": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "user_stale_passkey": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjEwMDB9.DdcQTX7OMKjCh9J5WCDRM2PU0qaR_w96J8k80iL__Wr6VFPSt1uJvCFBJLwqV8I-3HTqFNQnfvvjp9sRpLqMRMy7uVTzKcna2i78oax5RmHOhyMm7jO83VtWUFQuZNAe_dT-lf343NdV1WQUZ3GaSfe97k3DL2V2WPih55eN0iLeVsgZiaiXQXNZ4_ufu-6im8u57OJL20gW7wQUskG6gJxO35FUMuyVwmwuZqcSMwYytDtqauOCi5pmbdR_nMoBoZ5zUCELC8-ASOQuYhWzyql8JlYvs0QLgJL_5EavPK8K1wJGsuDWVbT3UdqxoWudvbADjxT6tis7T_RUNlOl_A", + "user_no_auth_time": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdfQ.ViRLofmK9S2uzQpyFRyzOFUPHoUncEsZbH7Z6AXk-YBxknban1xBsUehRb9pWhaa12irWo-yshv6_KwWjUsekG2wsGfi6dPwILeq273nQrkCZM1KS0WZAhHpYDP-tKqsj6nxzj7mfLZNtWBbCoHDC0ux0gAOTlHN28ak8_LOxyVeYVP1wqVb70MoEIX4SIuKbgVfYGLW90iSRGO2kMeGbGFDNz2dfbvQAbhk3U7pkcsYo3Br4rB_VL96Ro8RZfEiHQfrepo4Y8duHBkJ3nPHBFuGQnreNXOf7rpz0f-NijAeBY46jUaTsbvfLezS3h7VmR4t-POPwhnPhCSNCRXb2A", + "client_credentials": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "aud_array": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6WyJodHRwczovL290aGVyLmV4YW1wbGUudGVzdCIsImh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCJdLCJleHAiOjE3OTg3NjUyMDAsImlhdCI6MTc5ODc2MTQ4MCwianRpIjoianRpLTEiLCJzdWIiOiJ1c2VyLTQ1NiIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.QlVLJDlHGj_BrghYRHtid1CI4qnxBItSnfMj1yCtfudvSjbOPPIHCtCBPkJcuP2Id3xbPK6BhLDXdV1BedJ8fCwCnveDt4uuKcxzHGxl9uEYM8nMZDkf91-TvVES_-6lLC_w-6Yq_Xr1Ckjl5RU7xXO6WqwDc2akPnK2cwJfD49JHbUWCdGZeA_zi8EvyD6EYuMO3VHrGf-qQBJ-51cFxkbQKXgM6lqj0yKUN-L2DSqENJI2D96q29f5DkKrPCaHdTetsJbn3thRQiiM4gP9kpHE2Zbce0thSGmbZ1rLUzaf_NJbeb0XXBgH4GJn348mX_B9TW-9xpQ9qx1smUPSpw", + "typ_application": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhcHBsaWNhdGlvbi9hdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.DotIEIUyyCMmBrdElqABxaQVJj9yj2FeRR08pddeZCIJ2N1zhD6jWMjVQ-uetJNpHcCgXhVtqC18GMEuECMAGYnfCEQvRVxKOctuLr7YjNQSoFoexpufumOJssl0MrQThU64O07F580zoDt82gUcjiXbnq_avOchcr8GdwhBTZGhY-bGxfZmBR_AgxhHo1G3wRgfzMiA1BSMhewAmq1_i0opNP1VHRwfKzftmwHVBh1f3in0jGIwnyuk-2Ua_4e2Ys_R0LeTxDWsSg1hmKrAW_5O0IAhgFYqz8fvHJRWkBRP_7YDuW5uGebh-ZwP9hbS1PuztbyrKPq0Pfwo9hT6TA", + "typ_uppercase": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJBVCtKV1QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.hyvmjVNzRPAKa8tbKe2v0RNOZhlZVmBCg8jibmWi3uobD_vMLG4CY_L3RkRHTjRtd_AFSp2-MIhmTxL4Lpywr-OlV39A6xFBmjbsFF9dOxF-YMJMjPQiVyROIKNT1rBJbcFO9lPvHpygBBJeWEdFwhDYOv0ZhirLzIe5AjPBW6pCVuRdedaw25-_47yRNCNDXwhlOktlOo9Bjj7GHexk7eoUhrIjIKasNLokvauobEVEy_f1jdW5ixgRwGvY3bE9hxNOGRPDuS8fSl9K20kUbo0pl2B6azD94r7eCZiB55_KsaoJS65vFpFP3CxDmZHwnCfR0jkc30sD68L604bpGw", + "exp_within_skew": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2MTU5NywiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.dY8tDe4e8Oy1eCIALjUxVatbN9-4DCwzBxKJyXxQSZ7coIFUqX9zOtrJ0kfaJK323EzUo53-ghBBav9Y_RO9EXAtZ5dYJ_AkV-CCUGGOM25MeYB9fKVG70HwAuD5r6QUd_1e4vQpovCb-u6vPKtvXB4F7QuilxEP1v5mny-P7EHVVgmfj_C2oIRl8gKmR5R_1HBSqRcRG8VnKwEutXwI6ZKXTpqkI2dRsr0ByNsWpsPSDUg-vym29DTTn_uE2cYGjkrS2t5yhXaXUn-U-IOpjn8RoCp9PxWUmx0wIvfO9DGT9oBKyTyyIgatJ8Gbrue8iZf0rYPEr4ZtBjWvO_nmww", + "nbf_within_skew": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsIm5iZiI6MTc5ODc2MTYwM30.mjkLKbVrXsnAIHUim624VIt_9B86kXp0qfPfHTT_xIxTD1AE0UwJ7VZOF2Cqex33epMkyeiHviK33PYU1Duck6zWX2wQJEAr0U3jYg2cKRTjN3SoFOuITRt6yN7KJFGsBzpnRFAlPWeW-Ohhu5YHEMrpa5fifIgUdZ6apecK24Wo2fCv7IzBrYBZHH4_BOvdLaZTYOVAVH9_Vj6fZ8kPXn5mmXyyrglyQZNqVI3Nt1s2mpjTXQTBgBxIRljo0bCZcd8LKbO8O1Zp_Ec8mkjuYbe6aHTY1J8JP9N55Q19upm0kJwI_FjfvB2HA6pRGrCdGuqO4QESVo-g7JWqnX2FYQ", + "rotated_key": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTIiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.wU-gJk69ZVZAK0tGl5cIqwkeUORLq_VsQssk4BdmdO4VbtSuXJ0UwV1jRf7gJiCfhUuidnFDPpCFaGEMOhmttepcd3jzpiv4kGHzb_ER41DNLSiKbLDJN4gTtIqlQdldRkClju-CG6WFkBKvJsjTqvuWsUhwcCNxF45rIkyvvvKQ57JuanhGdQNCQYPVInUYtZXb8Uhka1K-q9IgkfDM3uqWnfYkqCKdgT9cKBsAQshhZrX-qxgrRTN7s5ddU9ne-XosgnDAfTw--OLQPDwlTMSVO9gNbL0d_jSAD81POd_DHzV5D5rMQtsh5E0_h2TUMfseW_LkZLhOjL16qjnzhg", + "org_id_null": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsIm9yZ19pZCI6bnVsbH0.k_mUUrd1G9UYcgZ6XyuPZ_Ucpq1qtk4qvhu5bFKBpxnRAC8hszEScDrSrdVcmIftLZZU4oDHm2U8nuDTd8fY52S82r_Nqq89Wx7a4bz9we1mossEiOewLWLd7I_HNeVxO6qalhDB2OIG2tfjBjIXLh1vyRJScT_iEYvi5aG_Wbwage7xF7QVbHZW3kSENuBqyHqeI4WQ20nJKDajwBKC7v93LnlcnmbA0nt3nUXlT20BZm6V3I0fEn_jIggUsOKb2GoxjLdBTWn2nq23TZidFw8hZ1plg9Xm54PwjH-kfALMvblGTesbJs7FkcuXEC6uhFffmozAqkhJ16DsNd5rdw", + "unknown_claims": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsImVtYWlsIjoiYUBleGFtcGxlLnRlc3QiLCJleHRyYSI6eyJ4IjoxfX0.O1XwduZOcBJnkKxXRU0OO6HiHbZHqE7lJ98RtlnyYSbpwSLBzg3ZhVxnaI2YKyWuL_nw7-X4r6Gc2xAzTWZhjn8PrgM1wGIAqBVBrEXuxMgWaxxmJ5t7Aae2-E1lrsV5OOsLP9sdGuORgV6RkqXqm_EYJElX7fZOh_yFChDjwpMFnBCncoMo3NuJ-kU60f4pZ1QF-ZRmUREb0h5y0JTrkre3m9SyVT1R80szToKsWnyemshyb53tTvEaX-7YiQprSiwWzb2v33QrmzN-Fqsg0HkWyPHkIEmFXaA7Xh2IaIx7KKHXZZluJO4gHUn5jPyUinSoeCBT-udbmkHr-61byA", + "wrong_aud": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vb3RoZXIuZXhhbXBsZS50ZXN0IiwiZXhwIjoxNzk4NzY1MjAwLCJpYXQiOjE3OTg3NjE0ODAsImp0aSI6Imp0aS0xIiwic3ViIjoidXNlci00NTYiLCJjbGllbnRfaWQiOiJhcHAtd2ViIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.C8TTWbSpI6AK2qOM08s2xikpLDL6AxlqWVOnQ--evBt7WzZHUO6QU07jbKFbZLoRRGJ2qN0moY5eIcrgRojbW1b-Eho7Ln3_RDoaDL_-mrAnkXl6_oBHKRO2nqtqIm6q0x7dZhwMvzf_DjRLfUaFK039WY00SLEyBQH_r_9x9XEuUclGlsZtdVTzkX-2eHgEOPsF9iwlIYhcBSXMjr1clPMeiufrUB8L2_cwwl018NGz1ZO7nFR8l-2eJqEdoS3PnsrGtoj2fs7cleHfmQb21sUwrWPy4KsHVvoavbsAtM4qWDz9YMTXYg6LMZIKF0joZHl6N-fO--3lloeJ4eC1MA", + "aud_array_without": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6WyJodHRwczovL290aGVyLmV4YW1wbGUudGVzdCJdLCJleHAiOjE3OTg3NjUyMDAsImlhdCI6MTc5ODc2MTQ4MCwianRpIjoianRpLTEiLCJzdWIiOiJ1c2VyLTQ1NiIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.HH3BZRlkiFAj3UpzepMczavOn-CerpRBqJgL9AG6QoHpYjHvGicjfYIporKFPCxQDc41N9tRnvUpLsT9bQYLry4iBVCYLDEqyHSeXjXNiMbqrSc4ISuhN-8ypbDdsc_u3pWio_o9iakjLxm9ZhgNdYEMAH-I3CnvUqQnY2OnJVwEarvCBv4KiSNvsPykHmAL2q8rNRQKuoSr64ZNK1T58x2oCHC7NyMyHcuUHAWHuVfiTlNCOdkb-rFd9ghvl4rLRpNDvOhnDQz53mbaPiSdbrY_NtLIyFNwe62obNqXkt4m1V8jAF1ErFhSMH1yS92uCqjiyE-pOuYfdVmmJPMu_A", + "aud_missing": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.fa4nBG-vbm4BVMLPOaQySPUWZU28HYyvle70C_BjaWcR_eV3_d_N98Of7ferz0OxFreCTugizXmUSpb0ZZj17SSI8rt_ovHXPkHwG7aU1Zaff8fp7txLElulSbL1iAU_FIK7fJMAiyh7HuNlK5JjzoxwsdO014LZtEWqPWxu9vZ9YhN47HY-Kzx-5YDWBJgmnxdCyEU8qvvdrTYfunIau6B9yUGxhI8OOTCKWtuMcalAN9IqzmKFyyI0zEdl9QZhNYXeCA1AhKjrpEVuOW4lINzxE-cewMkaZotBSrHN7GFw3ReeyrX7mtPl49ss_hZgKyiZqbYemZ86uDK-W1DmgA", + "wrong_iss": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2V2aWwuZXhhbXBsZS50ZXN0IiwiYXVkIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS50ZXN0IiwiZXhwIjoxNzk4NzY1MjAwLCJpYXQiOjE3OTg3NjE0ODAsImp0aSI6Imp0aS0xIiwic3ViIjoidXNlci00NTYiLCJjbGllbnRfaWQiOiJhcHAtd2ViIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.Ea4pvJ74g9nw9t8kg9WdEKgGR_4BLG4bAG_2n2kb7PkmnIRZHQK8oXg5u6myVPDuJfeUlaYo6tBLPNgKJX_vLCc8uEucvqZC2MHBkKYEJkRR5noA34X82z1GTHXxLAkPLgguqVbeJIss0PER_tlcU7weNr9Kpyb2SguxuqrwgPoo_LRVa1EB3Qn_oX_orLi-svCfH-w0k2PqNlbS80gMaws6IQy_MPDmOJzZfLboNGkrf9bXp8b0j-nTwq7z8fIoWHmizpLXeuYMQy5e4wtaOppDpsS3FWoVc02TsvznbY07l7wi_It4IgDAu4uZ-ZnPqTJaof_uxtp4JAM4Nv6Dfw", + "iss_trailing_slash": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdC8iLCJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLnRlc3QiLCJleHAiOjE3OTg3NjUyMDAsImlhdCI6MTc5ODc2MTQ4MCwianRpIjoianRpLTEiLCJzdWIiOiJ1c2VyLTQ1NiIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.Pc5cnJj7icerbMQjfiCgg_NqbSDE4npQVDtgGQikV6x5oEsSOVVEfp8X7zuhdljXQHdgtf1qSTS4K37DEMoo_-milUKyAb4GtpzMcp4TBq45MgkC5mXOh_TxCfu5b45CTXWmNF3uO9bwNulGGe6S6sTONx-ceYdll4X0i1IErUo-ucZWqGtF3ldqiL4Cvw5k3hI7vm2vSZEOEJI6AIiGcR0C4nbU2mv0UfqGHYY8wgP8XNzEuCpf0ujjoI-tqwyAJ7mEikuEY3HUixzj1jprZnphFtb9o4UjZYla4m48CSmEGH2118srUUcQ7Dk1I_iZcPSUOzlFRYZgzFW8LoI3Ug", + "iss_missing": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJhdWQiOiJodHRwczovL2FwaS5leGFtcGxlLnRlc3QiLCJleHAiOjE3OTg3NjUyMDAsImlhdCI6MTc5ODc2MTQ4MCwianRpIjoianRpLTEiLCJzdWIiOiJ1c2VyLTQ1NiIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.GR8by5iv2S492ZIk2sPMfsyidD5sWY65C07WT5X7Uxql0d2ziZZW54EijNSrgo2J2V_3_ef_sCDUFjo8nQrOo3okwTo6S8dIa3EMNaZNydVSni6wFAduMr8LDLjPefzSGH28qYNFrK4sTEyXYEzsVciGUuevrjMYCP9VOlZQH7QlSLxU5QYEqeoH0LrboNPFqr4nfpiK0N9UerfxaaJqFFvUybjNDt2hj1h8tVPQBgYFOgcxZxYbtO6EW4qGK2ch87YqR3NpK9a83vc4sUeSPPHCb2AhFtWvUsedZiMWADDf7KMCccOKSacRdIeRsMOrL4PIYZf6HcLKudBT4RZdmw", + "expired": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2MTU0MCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.EHGExwM8o7uCLrFolhwJ4P9mU4V6aW_wR2O3Ezt75jMjhkzBqGivaX5L5xHQOsMdbU_yg_54OxGjBEDoQ4RLz3nqfBf-VhjgJNyGjzfQaJ-KlMUFN0ZtM5zf_8-vRBy1zfTJF6ebzDs8ILxMzJTcrQq5U0DOp0xXO20rzYR84Us46Gs4sprpxHlFJrnEeoQl6LoHrPiMl19jarNHAFmiy-K4TooZ8Zhl0Pb6ML29FJucLc0dL8pBZ8C5PeWVBQ6ZO8jnvfR-_v7gxytMaH9W-8QbF7dzDHz3XSmZUhAEY1vpP676j6idsccG3TY_Q9KKaw47Px3bV7Hv3ubzrwXaRQ", + "exp_missing": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImlhdCI6MTc5ODc2MTQ4MCwianRpIjoianRpLTEiLCJzdWIiOiJ1c2VyLTQ1NiIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.cFRPlVw2IKurkDRim8sB08Sgku5CiTyY5o6MqYsCp_WxVqq4MYRMHU0cjXBIhZC_WQypMNTbDSh8s56yhUxhSyf-7KLZvAz6E9eLWC5pxn0dfB2JjiMTTLmHW_kDgsTVhJ2UUx_WxPCg0obhXofpERObH7IGsBfrG0baH190Ljw6fU3XIuKZ3H0hddXfnOa2ZDx03AQdZqj_uVqlB_4J3t8M0TAzGIkPqQpqnLqBsXBHl6Ab7u-eLmT9gggn7Um64QTC1cv4N56wMK4WF8neXVqdkSI5A8Tp_bJrpM3W7ITc_MJsAHQ5G6gRJaFKUmwy9AY0ugreLRO2QdM_jKmUvg", + "exp_string": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6IjE3OTg3NjUyMDAiLCJpYXQiOjE3OTg3NjE0ODAsImp0aSI6Imp0aS0xIiwic3ViIjoidXNlci00NTYiLCJjbGllbnRfaWQiOiJhcHAtd2ViIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.PxTKezFhdGdffa3vP9y_0axSKnWj0asLipfEZZMY25bFuhBE86frTYyRJnNjNI6KKuQGfvIgec4Xc0ocvmcf5LP5YgTcZWIWHSI7RknzetYQZljaa0sWD5SvsAEk5B2_Yf8_6TtEHhWqLkyDhmL9foemkQIo36-JCeF2tmlq-K_y_Mm2-2r1e9aLFF3D9M0n42EoPiushFJ4BLum3r-41MoxLrfGqUA6OyyKQ8oYiLBqusc0bEl7K0lnzcPnLA86-qH8cQ3V4nEZ7rHtEKy09M3NzJXML1r4yLN8i4UHSzBOt7uRYywGmww-QSK2eNnqJGTAPO4WpGT92dS-BccVMA", + "nbf_future": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsIm5iZiI6MTc5ODc2MTY2MH0.kmuj-E63cv6QxzO4Ay5doNXw_5sOl2g2nEUl-B13TcLmacSrWtMTEJOyWZyWzPHwaGcOtWVzkvUMR5aDHliVdjMC9BL8fwKhfng-9nX8RKyFOtcvhpc0tV1CqHZcd4-kfExaDjvaw_w4v-aUjKopaPyYdNgJ2h8Z2ZdjlIWkTZXPWq75SL81l-4Wdpue-WRE3HVcPJFFcimijZrXIb4MiDK9_WLRQuloCICDjGro-4Lho9isdkC9kOgp2F50p-2r_c9sec12YfCEQHE3pWoZYgK_7Vsef7yLRQBn73rwCZ8eDjpqh90UlFh1xzEaR610jtdU2YSPpNfWTeSmZOIvuA", + "sub_missing": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.HYphGz39bxaVuyjeTGJZxFH3AjezYt9Vd26_rWC0NOYOI4VzUQt0HbJVSNUHuFJrhbctPcAHnAzO84jAKarVjbutexJmnS8Ch91mfDftIOq_zCevROVD5wCSQ4wajvbdX1xi2AyJrl-tY6ZYjHQRufcftVLI9nL_KAQkGFsJyKZCYXxqTOAgNJgEYSNdoBHdlBZvgsHYRrT2GzFhXKM3JpAFlEKW7AUpXgKDWa_vyJ3ewJyphZEY29vCP8Aflrc9RqTP1xOKdUY82HrZeZIIRuRupd4rBKMEeEtM-9q4doufpVAaVMoRxtZtyTLuhJJ_zcOsLy2yDvsvs6faQfe1fg", + "sub_empty": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6IiIsImNsaWVudF9pZCI6ImFwcC13ZWIiLCJzY29wZSI6Im9yZGVyczpyZWFkIiwic2lkIjoic2Vzcy0yIiwicm9sZXMiOlsibWVtYmVyIl0sImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.Yo2PTs7BW69Z_5eyiwvLoxR5Jjl_gnt0pNkGqsjYdxI8YU5nxpnAy9PLYOYmJsdNNVKG2Y5QfZML1kttDc-AaSRM7mll-dk3dkPHzOisWydvGgnu2dbQTCVqPub_D1h7gPXjdpjSXEjcdRyLkod9EFqAt_KkSqp3U2uqdTuYQ6n0zDPHdj-x6ylPbuoc0h6Wlgq15_1TqtZoQuXls-UqOrmcJ3Rgqp1Lqn8lnZUq1yrtsyGoCEnY3UPvmv24ipZLtCGrfheWCR-muc25rABGxr2EtMOORUYewIN1jSbVSqq4u94hTqnENxhS_QdW-IUyPnvvWcZQYyT76dZ1Xs8-hw", + "client_id_missing": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2Iiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.FkP-zydhrMxCbeOUvuPn5Jn4KSExaaWYEfl_4NLhbyjvVwKA-p6tcgfW7V0xVpjA-mfps0D-t0b9FRGnj2mdYFnSPqVdU_FST_GyWE0NWk93WbanZTX_Y0wRnb4k-sceJg1tgR_VhHuliZUi_nB0J0G4kbt6LDMlEUA_0YW_q3BnkOFPzOhkhNcQqokhGMgwTtH3cmxHWAY1iZord6T7zu7tDnSZfPlBTH4ve97Eho9lpkiweNQPLtTreZm8Sjk-2gKK_pdIqglxSJHS0PqQOxyz3_j6wBtfzoaOeakBI4Gj_DNTZXmZYZr4aA8lmRcUOGnYiBct351azK3Zvanv7g", + "client_id_empty": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.TFQob_XWTh_MXFENRw8KkNBbcMok0FP0NKs11S-4Mltb67Cbo1m52BkCmtX-vZYFur1hvGznQnyyj99NySaNac2RlCAIcL6t5GABa_RcuwZYW1CvVLqGwN6gb2FFIzWfe1Escr-KS9Klp0hNagyZvAP0r7WMM8ZbcYtG76ZKhqY-fBxmumsCYJEZ9-je-RaGWoAA1RhXPZGooFaEG2xkFS58fvxnZVLazz1btVVch8cungtqRCM4NMlY2snJCb1qwA-J9LIvuymjOjlAzOMcJbxIT2e0mXpD3SV6jpbbvhtV1SL0A4yJRdAX0ss6jW3a_Pso3ieGEMWQlXMPqOsA1w", + "typ_jwt": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.BY0PTsV2S-jb_GNe8c2qtDeAeNfB14qnhlO0v6m_Ei7ZCqaBcQdHKQCxpRMnQJE_QHXBT0DD93_ktt6Ixq0BPAwfAw8hX0NN7JRMzA_N6h0f95L6YASZGAGt6eQHMnSDUxNpNoXCcRktBwZ5f36_hWGZ6cfjz5AnMEtV4Jv-9CDAI7-dE8G4tIPbsA4cirhlfnJo726E9UU1asa174hkSeVpmwvog8uqPInA8VneV4tNMsWkKJRIubcDrFa1vsxyz4snot79QE5sFtj_VPjM0Fci9Sd-BLnNrZV7e9NqPOYY60GzbRS5mb5NEfn4a7fkL4bfJUZjAraLCH-yAfu1EQ", + "typ_missing": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.UXE-Srmm4ydLTc-CjhAQWruK6jGbMZQVw9YSvOh4ljeHw65fTVkhVDyf6rvZYLoJlE1myV8PEjlKX3FRWAgpKTzzD7tnccTlb4WIKu_409MMyZVWarGc3fakHZiI5Y-xElpgAWtCjWMtxsYPvLfHjjFfr32Ym-1fD6TPfeEn0A964fA1bno0-8lW3xlFHYWBIMruDBukcGnsxF87_4kgfd8gLRnALwbpcSUVmHWjJxFVrfa8-vYUeBntBC-BtTAjWjTfLC8pLLNEn-FX7mZIF1BKDb9iyG_ZfTSMROLg87z5tFmR5R9tKYdYbnup7lYWSW60d9tRxumEgKjFB5putg", + "alg_none": "eyJhbGciOiJub25lIiwia2lkIjoidm8tMSIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.", + "alg_hs256": "eyJhbGciOiJIUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.cpmIVphXRVpX1TWW4VhKBuGhOlhFNGj0O-PwQe6zWsU", + "alg_rs512_header": "eyJhbGciOiJSUzUxMiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.odX9FNMzziVxb10AA8TIUAIBkv1IseXQhx8ALYu5syBD5YP2Mdn7vkUAJpv9f4xUUDfUuYFbW8XVaRu_aUVtSqPeTZ92mQqPzmQaMSco9V1uAnw6X26AV9Q2auCl88gSqG6AHfL4hSU-49teC9LtQzoLwDgUDM-EYucjpc-ZYTqEUCeAjlO-bFquX8yrGxMK0Zrq89mTtB0pJwE9cvEoa7NWn0Utr7pcpJdYIeD0AfqklqNu5fXdEInIKoRP4jCBgDJJ1gA0ZdlDEwB9Wl_q1R7qJLCScH_3tLxNYRF_U_uuAim9Cz0x3sI6Vq5WlRtu41Vxueo3bhK9hzOyA8hqJg", + "kid_unknown": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLW1pc3NpbmciLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.dBreblE9qB9tjf5aXOfW8chpalv4LWd-Wb81qzevspVk_lT19vTIVEA5CNU5cCKRrxWNTLsoerr90a0BKNe2dxFqH_TzkVmpcQy5sXsr4ssbV-AIaq3ILFbUbMm0UK_KQ5tNp0MsbzmyBbxN4cR-xJWwHlHk7glqOTCcsIcuyPTrextckZYt8QVDuDQOXb7G88_29csA1UDCxpl-ETXg4VC8JVXEUjZp5nOI_W65kultxx-gFyJa3cBjzyBBeIjHSCZFdBb7XSkIVWKFWgguY8p_pE2fEg3fyRMz3lsXS5zv-j2PPdlwmTuJGPe3j2bVHXAdRskj4DTNPOVKJuNxBQ", + "kid_missing": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.nyWJgLWWHAG8yLw6JOEx_hOB2e_LaiAdUGS7zNYNTTnAh4zR5RMGFGPc7qBxQ6-_naCnCpgqs3OXCQtmHL9FpcbvDpIGKbtwul1RNs4NZxQL-XoGYTmihzAKsZXXf3rOfRAE9cP6caBqPZwn3qvjGslTvq-uc7S3hmJyXBpUwgBqzg64FADcYB_AIE3I4dULK-a-qSXvIu0VWoBy58gRxOcoXUoQJeQvjg4g-aUFu2RJy-VW0gPplMH9e_drw1Zqx8KUV9OB8N3Z3R2FLzecdlr26MlLa1dXi0Lvwjqghwbx_SZxMh0B8SHK7wn5ZjFpZ9J6CyIKKdL-e5l_Nh-NyQ", + "untrusted_key": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.b4WTjkEvcPc7bKh35MUQIPrMEjsKmYKb7wPIVaQO3nU5VVpBVSwCcRwoNK_ekqcnPKghzMSooQrtVsdUdLkXuvv3OGfmRLIpG2c5UZb-FarZwEEbiZ_KQi0aWrBnYgjAbP-OQYK4bHzJhnIXOwooHdKiM2HYCoQkIPUquisFtYCrnKJD3M2wg78lKFB1dqOq5yMV6ZLN8MpRKr9CRrUI4iaAhmBlDlDuZji_ca04JKLz-hFr7PCVtY638819sQX1mjSB3CZt62HMhZ1BVL0WLV9XAf8h-2s2rFeP23R639vPMIpf-cYWquf5cnkKp86BFKt6fIdCGmIHV-vN0HyJZA", + "tampered_payload": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsiYWRtaW4iXSwiYWNyIjoicGhyaCIsImFtciI6WyJod2siLCJ1c2VyIiwibWZhIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "key_use_enc": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLWVuYyIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.pC27m70O7q6wKK1SDL4MuXDrLvy5ehCs3ctWxFDZvc3JMVDiCIvgYUvGQ4GTleJrePdsdqpy05moGlvNPwgwEhYyShIr_0FYpJnRI6jd3Gfv-eW_gOk1HnJccayz0mGV52yW4tc2fsU-10ffjaTg5AJCFJaf5ZCXi6dvy-gZNd6W_PkzphKNQfKRirQgTQRUhNRWHuA-uMG2aae58KakNmGjbcvcR-RLeuY1Z21DB9tgQ4tDIu7xlDQXcQDXJAl6F68gurHA7ZdsiuBjUSX-tIGvApQ6m5Ui8f4CfhUEJxBrvLrcHpm9pYit7knd1j-pp3D3FQ7Rm0w-9QggcavSaA", + "key_alg_rs512": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLXJzNTEyIiwidHlwIjoiYXQrand0In0.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.QnjXlP52dnHxfhI3kMp4eBucbmhZv11ev-VEiT17paBXC9njuKxRnTMXpqTi9TZXSdq1xoTKb65GxKdF_336KmRHxNTWLpDVLhWO9iu0HzujRfo2LtdIIHQcxvxCkDXiEsbCjyspo2h7XYp-FXBXVFWCkOVN7LRUqbq2Tn-vzGOgAdLcHArVPFCYVXkJ5F-d0nw1pHg2ISqIjwgvR5cUyvglpweloEmhS4sNQk7hDsXmie6EJwkZNaH57fKuR2ZC6mLNNwJ6iiahBcWUDfqaZeze_JfoQqCu0uQKWz_fb5_sUhl8hgKOoSQldDzSqYSq_7F-jVarXCSjEp4tvuZ59Q", + "crit_header": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QiLCJjcml0IjpbImV4cCJdfQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.d2wSo3xyz0YvBFoDgVwgRpUlAOj6QEPrgIQsUYY1gV5QY1u4aDKI0Wf9nlmSHCdbqNfpO8psGMYrKcLXdUy0RaaVRbzQp1gVrJgX7iMq9B66tNXAa_ADuysJVZIi2BCa7MlaAg-9AGSryk8IWBxsR4G3NZJbniD-gF6c2o0jrJW0sWx6q-hEktAtwjfj6uBoVRuJr9J-b__j5EfwWcLHuK_v7Xyz9vOSe50G_Zdz8hXPAYZFrl-LVHKOJ96Oym8Worghhev2c3tYfQKyQiJpSJldH10QLeZVxQbiPXE1hilGVrHUxeTiwrk1FASSe6GD0PdvPMejzPRHujr9wC31wA", + "cnf_bound": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsImNuZiI6eyJqa3QiOiIwWmNPQ09SWk5ZeS1EV3BxcTMwalp5SkdIVE4wZDJIZ2xCVjN1aWd1QTRJIn19.jp21FkP3hvW3QdsiosHMSMJubj2gO_IU-p-J-jXjwrK9dDlvyqUv6zSQjgXUyDmDI6epf-tiwT61GNdjCGdqPUHSdADtvKvelqCmSc4j-e5ISyBmUrNM_AhP9xK_TyAqW0nNNe2oGZXrI_1Y0NReCK7Z0DAFJjDz8xVFsaaSYzEyLOHABS8dB0K88egCyO93KJ3VrxIOBCaSQd2t8ZTnrz6Pfz96d0sUoTW-ufjhdYrC-OatomFBwdhEaTJUscHZUfPVn2Wm9KK66doC8uBn17h9YBH6UNCxKNrPV7ZiihgNS0Z5-TvbyGNlsLR755zz9PezO9nM_cWnr2Ls2ZChnQ", + "first_party_typ": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsInR5cCI6ImFjY2VzcyJ9.FZFKtotuWCMwW9wbZ9fqyGFgGT_OKqyFD37ikjLX76hRMfpiO3RVkTH23RhxOpSpT740G-S3CthkByLPnDVC-2t_FgN0gJ8TxT7Cl7RoUrbzBQU3wO2pboVk-DcyqIpCRRawFo-8cxYBKulLDwqSBNFjBOFl6Jz4GjJAfzMjqvhO9QochvEmGL9DKYO_MZKcXCXGcF1j0VE1Wzk86kcrrzRbjrjCcabvCm7MkspGcsQ7HiU4B-KzHN8o2zJF7PBzrWWZU9NUXV4ZDdrfPGqlbjkuu0laVvx9mtqZeQybt5KSgTAj-8gPX9XTjhICpqVvgf45NSpNmIaF0me9DlaWjg", + "scope_array": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjpbIm9yZGVyczpyZWFkIl0sInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.dv9f1F0MgbmdRkiUY_uIDGonoG6AcIINEi3wx3TfjIxkU9ItilM3Ib7UCh5soN-y8aqLYBfuU7YGNFFXXaBXCxja7ZWrbrrm0p_w1QgEdbWhtRrJChaUYZLDMxjD1ViBelFWbPiWu6FJCjTMQQFI2OYtd2pyVken0Xp631_KiFgrc1HBFX_zVKhkTPvRqHiCTfvemVCKeZznGhUKmh_WJ2iGsrYobm6UUlntxElu3_8xfcZuQE3-7CUG6xW5TJ3_2nrGO0EwtTTgQjjVDq9mwVPLmdkSe6t5FqDLqqdariQ1H_1PGX3XwoRK0RGDWkUIzQQJaOmdcUPTGqVdtfzPwg", + "amr_string": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjoib3RwIiwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.fbpXW0hJN2Unl6GzCP40yZF_4N9FRNCWu5FWemT2DazDAgCdHTHjL_TyJHoP27EI9kSKszLSaoDIiQ1GyHQehrhaFZ1Hs0LDw6bIP4nZUUmuex5EsdwMVxWM5UKQuF8ySiDszgSwcXpP2kolk2Q6OJwJDAbgXMYW3OQf03Kf4LT9oU3_HhStyUSlJG9n1bY93MJzgtsnA_vUorJKcIWcLgL1EOux7U3VinchJDbJlRy57kVFJQ-axjoJ3fXHSz9c_aG2oL7IMbmizE1A5KuspOduJ_tjqeYjBgo57AGRjc0e4bM5kTmNGUh3OW82eKwrLNGVlueQOzUOjgsOANh-QQ", + "acr_number": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODAsImFjciI6MX0.hodWQPyoNUqfmIdjvE-UWoNCrbBoHgngrpnN57Eey13sw11llSPpxKl9YVkYB6uRzPzuyXDFk3WMlIHSbPjlLFNKH7otUZrPzmG_mJy_gU2-uNNPLnOalo7YoQf9sQvv67FqRmhQkXd3afXTeh2j5IeAQRtZMjpDF6gz38bIXxCMwGAictldQbKV1LczaB_DuY-K0yV5QKVP6j0kBYUCf0KdoiDM0-Ysfv3TLMdWI-EKPnMa7ifv3dw-v1dX3uaO7k5u2RW9igu5wVB-adRGJ6aq1D-IMKKxtsVf2dZ-LBlDiZ9nGsZ0moZdUZ1X2DMA8-JOU7lPzfGwuR7XS9pqLQ", + "auth_time_string": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOiIxNzk4NzYxNDgwIn0.flY7aXqFgEQKUzrwJk9DNptfzb80dLB6WWwuKIcT8M3F1QEAg6yDXXvY9XtGTgcE8GzyaBOAwUNAUYQkBY410H1nuzoS65Rv4BI90dt96Smh9jjBk_GQmMi_tlNo3TMRDsU6fupURgTZLd7geVcTeuj9tGVdcxYsxh9nC_OEgwPg5ZrkYfpBZz37Nod-pIwG2-7LIXi9EsNLjC4OtLKK8T1CQG6_i35kTqWDhfp1DcCvmDthXzMV6-MCnjzfgbilFDv4vygtL9mlWgnV_DPoPrfUMoTWWC5fS4jWrIUbZzgrqj2ucS0M1K__-9GBb-G5eWtltTfpmZ7QDrVGnFv6hA", + "roles_string": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6Im1lbWJlciIsImFtciI6WyJvdHAiXSwiYXV0aF90aW1lIjoxNzk4NzYxNDgwfQ.Tkckc35QE8qGou79AbB23c23nRyF5UxOlqpOOQm2pZAGvypEWqIFqapz5rdgv9z32QzXzvEq9Z4WA5Jpv--2QETz6rwmBl5Xqsjtf6jOWZncfhxrVUtJ-e7I_JH-r1LaDKncIWPlen6mJVCc76VuRETDNAZZzaNBBTmn0lamarzcx8hi8nsEc10s4ryfDDzHBt00smwpzOai--BdeYrGrI2uaoeUtSr-SV19zusavTk6rIAoiOOJoYyLyYeBf2Avpc5wL3HW6W6CRqmo6R6IFAP3QzeiHafXbjVI2D5l4h0cKTqi2pvSagJ36mAAs6QSBCFPqYERlntuJmSJrw1Ghw", + "sid_number": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOjcsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.PzrzNZuVyQJ3HwtEhVhVKZdiZhkh2dmSGniwWnC6LZ_UoZJwIv9inNXJdWWLY4SvHjN7XwpB8Ai2RvGsttHPTEBWin2nGKDXGIw2DJm6J4BUkZh_hEdouj4W_gL5qSu0zehZvFydxa9E13w7jNub2Oftqa2KMpAtqdtbout222ow80Txg2IXYKY59GrmRD7EKDdudgwEe_1Y5TYXQ1bu2kAwSFY0VZf7MP8f_E9Crmg5P-EH80EgNms-OzH_rhqCysjvXEQxivheRoqIP0Wjyow04Xr_Hmx9zMkNbteM1N_VG1iplu3Lohzj1NW3LEllhAaLaPGT7bFCXZw2X70SBw", + "malformed": "not-a-jwt", + "two_segments": "eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9" + }, + "verify": [ + { + "name": "user_passkey", + "valid": true, + "user": { + "sub": "user-123", + "client_id": "app-web", + "scopes": [ + "openid", + "orders:read", + "orders:write" + ], + "roles": [ + "orders:write", + "member" + ], + "sid": "sess-1", + "org_id": "org-9", + "acr": "phrh", + "amr": [ + "hwk", + "user", + "mfa" + ], + "auth_time": 1798761480 + } + }, + { + "name": "user_otp", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "user_stale_passkey", + "valid": true, + "user": { + "sub": "user-789", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "orders:read" + ], + "sid": "sess-3", + "org_id": null, + "acr": "phr", + "amr": [ + "swk", + "user", + "mfa" + ], + "auth_time": 1798761000 + } + }, + { + "name": "user_no_auth_time", + "valid": true, + "user": { + "sub": "user-789", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "orders:read" + ], + "sid": "sess-3", + "org_id": null, + "acr": "phr", + "amr": [ + "swk", + "user", + "mfa" + ], + "auth_time": null + } + }, + { + "name": "client_credentials", + "valid": true, + "user": { + "sub": "svc-reports", + "client_id": "svc-reports", + "scopes": [ + "reports:read" + ], + "roles": [], + "sid": null, + "org_id": null, + "acr": null, + "amr": [], + "auth_time": null + } + }, + { + "name": "aud_array", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "typ_application", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "typ_uppercase", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "exp_within_skew", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "nbf_within_skew", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "rotated_key", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "org_id_null", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "unknown_claims", + "valid": true, + "user": { + "sub": "user-456", + "client_id": "app-web", + "scopes": [ + "orders:read" + ], + "roles": [ + "member" + ], + "sid": "sess-2", + "org_id": null, + "acr": null, + "amr": [ + "otp" + ], + "auth_time": 1798761480 + } + }, + { + "name": "wrong_aud", + "valid": false + }, + { + "name": "aud_array_without", + "valid": false + }, + { + "name": "aud_missing", + "valid": false + }, + { + "name": "wrong_iss", + "valid": false + }, + { + "name": "iss_trailing_slash", + "valid": false + }, + { + "name": "iss_missing", + "valid": false + }, + { + "name": "expired", + "valid": false + }, + { + "name": "exp_missing", + "valid": false + }, + { + "name": "exp_string", + "valid": false + }, + { + "name": "nbf_future", + "valid": false + }, + { + "name": "sub_missing", + "valid": false + }, + { + "name": "sub_empty", + "valid": false + }, + { + "name": "client_id_missing", + "valid": false + }, + { + "name": "client_id_empty", + "valid": false + }, + { + "name": "typ_jwt", + "valid": false + }, + { + "name": "typ_missing", + "valid": false + }, + { + "name": "alg_none", + "valid": false + }, + { + "name": "alg_hs256", + "valid": false + }, + { + "name": "alg_rs512_header", + "valid": false + }, + { + "name": "kid_unknown", + "valid": false + }, + { + "name": "kid_missing", + "valid": false + }, + { + "name": "untrusted_key", + "valid": false + }, + { + "name": "tampered_payload", + "valid": false + }, + { + "name": "key_use_enc", + "valid": false + }, + { + "name": "key_alg_rs512", + "valid": false + }, + { + "name": "crit_header", + "valid": false + }, + { + "name": "cnf_bound", + "valid": false + }, + { + "name": "first_party_typ", + "valid": false + }, + { + "name": "scope_array", + "valid": false + }, + { + "name": "amr_string", + "valid": false + }, + { + "name": "acr_number", + "valid": false + }, + { + "name": "auth_time_string", + "valid": false + }, + { + "name": "roles_string", + "valid": false + }, + { + "name": "sid_number", + "valid": false + }, + { + "name": "malformed", + "valid": false + }, + { + "name": "two_segments", + "valid": false + } + ], + "requests": [ + { + "name": "no_authorization", + "authorization": null, + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "basic_scheme", + "authorization": "Basic dXNlcjpwYXNz", + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "bearer_without_token", + "authorization": "Bearer", + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "bearer_two_tokens", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ extra", + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "lowercase_scheme", + "authorization": "bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": null, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "valid", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": null, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "expired", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2MTU0MCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.EHGExwM8o7uCLrFolhwJ4P9mU4V6aW_wR2O3Ezt75jMjhkzBqGivaX5L5xHQOsMdbU_yg_54OxGjBEDoQ4RLz3nqfBf-VhjgJNyGjzfQaJ-KlMUFN0ZtM5zf_8-vRBy1zfTJF6ebzDs8ILxMzJTcrQq5U0DOp0xXO20rzYR84Us46Gs4sprpxHlFJrnEeoQl6LoHrPiMl19jarNHAFmiy-K4TooZ8Zhl0Pb6ML29FJucLc0dL8pBZ8C5PeWVBQ6ZO8jnvfR-_v7gxytMaH9W-8QbF7dzDHz3XSmZUhAEY1vpP676j6idsccG3TY_Q9KKaw47Px3bV7Hv3ubzrwXaRQ", + "guard": null, + "expect": { + "status": 401, + "body": { + "error": "invalid_token" + }, + "www_authenticate": "Bearer error=\"invalid_token\"" + } + }, + { + "name": "invalid_token_before_scope", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vb3RoZXIuZXhhbXBsZS50ZXN0IiwiZXhwIjoxNzk4NzY1MjAwLCJpYXQiOjE3OTg3NjE0ODAsImp0aSI6Imp0aS0xIiwic3ViIjoidXNlci00NTYiLCJjbGllbnRfaWQiOiJhcHAtd2ViIiwic2NvcGUiOiJvcmRlcnM6cmVhZCIsInNpZCI6InNlc3MtMiIsInJvbGVzIjpbIm1lbWJlciJdLCJhbXIiOlsib3RwIl0sImF1dGhfdGltZSI6MTc5ODc2MTQ4MH0.C8TTWbSpI6AK2qOM08s2xikpLDL6AxlqWVOnQ--evBt7WzZHUO6QU07jbKFbZLoRRGJ2qN0moY5eIcrgRojbW1b-Eho7Ln3_RDoaDL_-mrAnkXl6_oBHKRO2nqtqIm6q0x7dZhwMvzf_DjRLfUaFK039WY00SLEyBQH_r_9x9XEuUclGlsZtdVTzkX-2eHgEOPsF9iwlIYhcBSXMjr1clPMeiufrUB8L2_cwwl018NGz1ZO7nFR8l-2eJqEdoS3PnsrGtoj2fs7cleHfmQb21sUwrWPy4KsHVvoavbsAtM4qWDz9YMTXYg6LMZIKF0joZHl6N-fO--3lloeJ4eC1MA", + "guard": { + "kind": "scope", + "scopes": [ + "orders:read" + ] + }, + "expect": { + "status": 401, + "body": { + "error": "invalid_token" + }, + "www_authenticate": "Bearer error=\"invalid_token\"" + } + }, + { + "name": "missing_token_before_step_up", + "authorization": null, + "guard": { + "kind": "step_up", + "acr_values": [ + "phr" + ], + "max_age": 300 + }, + "expect": { + "status": 401, + "body": { + "error": "missing_token" + }, + "www_authenticate": "Bearer" + } + }, + { + "name": "scope_present", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "scope", + "scopes": [ + "orders:write" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "scope_absent", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": { + "kind": "scope", + "scopes": [ + "orders:write" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "insufficient_scope", + "scope": "orders:write" + }, + "www_authenticate": "Bearer error=\"insufficient_scope\", scope=\"orders:write\"" + } + }, + { + "name": "scope_all_required", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": { + "kind": "scope", + "scopes": [ + "orders:read", + "orders:write" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "insufficient_scope", + "scope": "orders:read orders:write" + }, + "www_authenticate": "Bearer error=\"insufficient_scope\", scope=\"orders:read orders:write\"" + } + }, + { + "name": "scope_exact_match_only", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "scope", + "scopes": [ + "orders" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "insufficient_scope", + "scope": "orders" + }, + "www_authenticate": "Bearer error=\"insufficient_scope\", scope=\"orders\"" + } + }, + { + "name": "scope_client_credentials", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "guard": { + "kind": "scope", + "scopes": [ + "reports:read" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "role_write_implies_read", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "role", + "roles": [ + "orders:read" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "role_any_of", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "role", + "roles": [ + "admin", + "member" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "role_absent", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": { + "kind": "role", + "roles": [ + "admin" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "insufficient_role" + }, + "www_authenticate": null + } + }, + { + "name": "role_read_does_not_imply_write", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjEwMDB9.DdcQTX7OMKjCh9J5WCDRM2PU0qaR_w96J8k80iL__Wr6VFPSt1uJvCFBJLwqV8I-3HTqFNQnfvvjp9sRpLqMRMy7uVTzKcna2i78oax5RmHOhyMm7jO83VtWUFQuZNAe_dT-lf343NdV1WQUZ3GaSfe97k3DL2V2WPih55eN0iLeVsgZiaiXQXNZ4_ufu-6im8u57OJL20gW7wQUskG6gJxO35FUMuyVwmwuZqcSMwYytDtqauOCi5pmbdR_nMoBoZ5zUCELC8-ASOQuYhWzyql8JlYvs0QLgJL_5EavPK8K1wJGsuDWVbT3UdqxoWudvbADjxT6tis7T_RUNlOl_A", + "guard": { + "kind": "role", + "roles": [ + "orders:write" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "insufficient_role" + }, + "www_authenticate": null + } + }, + { + "name": "role_unscoped_grants_beneath", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": { + "kind": "role", + "roles": [ + "member:billing" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "role_client_without_roles", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "guard": { + "kind": "role", + "roles": [ + "member" + ] + }, + "expect": { + "status": 403, + "body": { + "error": "insufficient_role" + }, + "www_authenticate": null + } + }, + { + "name": "step_up_recent_device_bound_passkey", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "step_up", + "acr_values": [ + "phr" + ], + "max_age": 300 + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "step_up_stale_passkey", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjEwMDB9.DdcQTX7OMKjCh9J5WCDRM2PU0qaR_w96J8k80iL__Wr6VFPSt1uJvCFBJLwqV8I-3HTqFNQnfvvjp9sRpLqMRMy7uVTzKcna2i78oax5RmHOhyMm7jO83VtWUFQuZNAe_dT-lf343NdV1WQUZ3GaSfe97k3DL2V2WPih55eN0iLeVsgZiaiXQXNZ4_ufu-6im8u57OJL20gW7wQUskG6gJxO35FUMuyVwmwuZqcSMwYytDtqauOCi5pmbdR_nMoBoZ5zUCELC8-ASOQuYhWzyql8JlYvs0QLgJL_5EavPK8K1wJGsuDWVbT3UdqxoWudvbADjxT6tis7T_RUNlOl_A", + "guard": { + "kind": "step_up", + "acr_values": [ + "phr" + ], + "max_age": 300 + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "More recent authentication is required", + "acr_values": "phr", + "max_age": 300 + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"More recent authentication is required\", acr_values=\"phr\", max_age=\"300\"" + } + }, + { + "name": "step_up_otp_only", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": { + "kind": "step_up", + "acr_values": [ + "phr" + ], + "max_age": 300 + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "A different authentication level is required", + "acr_values": "phr", + "max_age": 300 + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"A different authentication level is required\", acr_values=\"phr\", max_age=\"300\"" + } + }, + { + "name": "step_up_no_auth_time", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdfQ.ViRLofmK9S2uzQpyFRyzOFUPHoUncEsZbH7Z6AXk-YBxknban1xBsUehRb9pWhaa12irWo-yshv6_KwWjUsekG2wsGfi6dPwILeq273nQrkCZM1KS0WZAhHpYDP-tKqsj6nxzj7mfLZNtWBbCoHDC0ux0gAOTlHN28ak8_LOxyVeYVP1wqVb70MoEIX4SIuKbgVfYGLW90iSRGO2kMeGbGFDNz2dfbvQAbhk3U7pkcsYo3Br4rB_VL96Ro8RZfEiHQfrepo4Y8duHBkJ3nPHBFuGQnreNXOf7rpz0f-NijAeBY46jUaTsbvfLezS3h7VmR4t-POPwhnPhCSNCRXb2A", + "guard": { + "kind": "step_up", + "max_age": 300 + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "More recent authentication is required", + "max_age": 300 + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"More recent authentication is required\", max_age=\"300\"" + } + }, + { + "name": "step_up_phrh_required", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "step_up", + "acr_values": [ + "phrh" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "step_up_phr_does_not_satisfy_phrh", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjEwMDB9.DdcQTX7OMKjCh9J5WCDRM2PU0qaR_w96J8k80iL__Wr6VFPSt1uJvCFBJLwqV8I-3HTqFNQnfvvjp9sRpLqMRMy7uVTzKcna2i78oax5RmHOhyMm7jO83VtWUFQuZNAe_dT-lf343NdV1WQUZ3GaSfe97k3DL2V2WPih55eN0iLeVsgZiaiXQXNZ4_ufu-6im8u57OJL20gW7wQUskG6gJxO35FUMuyVwmwuZqcSMwYytDtqauOCi5pmbdR_nMoBoZ5zUCELC8-ASOQuYhWzyql8JlYvs0QLgJL_5EavPK8K1wJGsuDWVbT3UdqxoWudvbADjxT6tis7T_RUNlOl_A", + "guard": { + "kind": "step_up", + "acr_values": [ + "phrh" + ] + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "A different authentication level is required", + "acr_values": "phrh" + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"A different authentication level is required\", acr_values=\"phrh\"" + } + }, + { + "name": "step_up_amr_present", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "step_up", + "amr": [ + "hwk" + ] + }, + "expect": { + "status": 200, + "body": null, + "www_authenticate": null + } + }, + { + "name": "step_up_amr_absent", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNDU2IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTIiLCJyb2xlcyI6WyJtZW1iZXIiXSwiYW1yIjpbIm90cCJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.Q9__hloZFJqysviQX_klUPKyZjdLSWcV0YQyFMMRzneBiHjEKMEuAVH3MLNMOZV1kp3SY_BrPb9ihRQULVYNIJu9og-kv0G0Wcd7at6RfNwfr41mY5BXA4r35FUgMOwdHvuSG0MMWUWeStzKCUgxlz7MV5S5Ye9ahl8G0Gb0dQHKV7x0W2t3a098oRseQvu2PjA67ad5W7HBSIArRSp0Z5mdq-adYYmcTIf-kZtEyFsp25gNUqoHrLZVoXyIfGwdMhWF9BVEtVAa47QiK13L5kulko4tJbTSA5gEDqfyer4Fcp8KXAcEr92VASYMqm-y50rZHYoOV7NCPaKjL3AhUQ", + "guard": { + "kind": "step_up", + "amr": [ + "hwk" + ] + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "A different authentication level is required" + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"A different authentication level is required\"" + } + }, + { + "name": "step_up_amr_all_required", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItNzg5IiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3JkZXJzOnJlYWQiLCJzaWQiOiJzZXNzLTMiLCJyb2xlcyI6WyJvcmRlcnM6cmVhZCJdLCJhY3IiOiJwaHIiLCJhbXIiOlsic3drIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjEwMDB9.DdcQTX7OMKjCh9J5WCDRM2PU0qaR_w96J8k80iL__Wr6VFPSt1uJvCFBJLwqV8I-3HTqFNQnfvvjp9sRpLqMRMy7uVTzKcna2i78oax5RmHOhyMm7jO83VtWUFQuZNAe_dT-lf343NdV1WQUZ3GaSfe97k3DL2V2WPih55eN0iLeVsgZiaiXQXNZ4_ufu-6im8u57OJL20gW7wQUskG6gJxO35FUMuyVwmwuZqcSMwYytDtqauOCi5pmbdR_nMoBoZ5zUCELC8-ASOQuYhWzyql8JlYvs0QLgJL_5EavPK8K1wJGsuDWVbT3UdqxoWudvbADjxT6tis7T_RUNlOl_A", + "guard": { + "kind": "step_up", + "amr": [ + "swk", + "hwk" + ] + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "A different authentication level is required" + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"A different authentication level is required\"" + } + }, + { + "name": "step_up_client_credentials", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InN2Yy1yZXBvcnRzIiwiY2xpZW50X2lkIjoic3ZjLXJlcG9ydHMiLCJzY29wZSI6InJlcG9ydHM6cmVhZCJ9.PaT0ELDOlyKU5q_6CMxkjZYyz2jYhB1djlGIB0ebLcDBZO2gJDna2AxJTa4QKmREIcJOZAMGsyzAp8ojLNeGdJXuClbGNxHaStrPyLdu5sYyypWqTdy3sHBXaf04YjE6CmaREcNt6TymXF6n52BdgxCjVDfsmDJ30JCCQ-RCCf1HfLZHVnf8Y_I19R4lzJ1SuDVENi6x_LVIGxg0FGF4WkVq0_6lXItF01nAGb1Rg-GrfaYBBf-SEWiYedUHd7tUXH3btZeZ20g4XlBqifHonax9MBJpOeyOUG_NNDHYoSBj7tV3Qk5Vwb9exuSrJMroW33mjvqSakRc9PD67X5tqw", + "guard": { + "kind": "step_up", + "max_age": 300 + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "More recent authentication is required", + "max_age": 300 + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"More recent authentication is required\", max_age=\"300\"" + } + }, + { + "name": "step_up_max_age_zero", + "authorization": "Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6InZvLTEiLCJ0eXAiOiJhdCtqd3QifQ.eyJpc3MiOiJodHRwczovL2xvZ2luLmV4YW1wbGUudGVzdCIsImF1ZCI6Imh0dHBzOi8vYXBpLmV4YW1wbGUudGVzdCIsImV4cCI6MTc5ODc2NTIwMCwiaWF0IjoxNzk4NzYxNDgwLCJqdGkiOiJqdGktMSIsInN1YiI6InVzZXItMTIzIiwiY2xpZW50X2lkIjoiYXBwLXdlYiIsInNjb3BlIjoib3BlbmlkIG9yZGVyczpyZWFkIG9yZGVyczp3cml0ZSIsInNpZCI6InNlc3MtMSIsIm9yZ19pZCI6Im9yZy05Iiwicm9sZXMiOlsib3JkZXJzOndyaXRlIiwibWVtYmVyIl0sImFjciI6InBocmgiLCJhbXIiOlsiaHdrIiwidXNlciIsIm1mYSJdLCJhdXRoX3RpbWUiOjE3OTg3NjE0ODB9.KfHFZpgrLcSwnOn81Wu49u5ihVB8BhLjf2DnUCtN4BOsOkUOQRtYV31DCevRzcN_FBFMpxt-b5WXOKAGml6nouXOAntr26DKBkeHuFU9LsVmZHOtxtNGy3VpuLK2qhVizzrD-Ol8MffP0fuPhV827itGV9Aea9XD6Mdi1sM54wYgvWjvpd0BChdTHPFQY4eq6GpsmTUSOnee-k0WiwbgKnML0kFlejQ_d8Qo4M1De10zV6d87s12h5MP8VTm4RJWgVmEzIhH_Och5sx77GIS-0IrLV6aKV8sil3HbKvKzKsWKwTCpIvBnyY2hEC98q5vv07074-O5r-Q4aMGPWJahA", + "guard": { + "kind": "step_up", + "max_age": 0 + }, + "expect": { + "status": 401, + "body": { + "error": "insufficient_user_authentication", + "error_description": "More recent authentication is required", + "max_age": 0 + }, + "www_authenticate": "Bearer error=\"insufficient_user_authentication\", error_description=\"More recent authentication is required\", max_age=\"0\"" + } + } + ] +} diff --git a/tests/test_verify_only.py b/tests/test_verify_only.py new file mode 100644 index 0000000..fda6869 --- /dev/null +++ b/tests/test_verify_only.py @@ -0,0 +1,382 @@ +from __future__ import annotations + +from typing import Any + +import pytest +from verify_only_support import ( + AUDIENCE, + ISSUER, + JWKS_URI, + NOW, + TOKENS, + VECTORS, + Provider, + user_fields, +) + +from seamless_auth import ( + BearerError, + BearerRequest, + Headers, + ResourceServer, + VerifiedUser, + require_role, + require_scope, + require_step_up, + role_grants_access, +) +from seamless_auth._resource_server import bearer_token + + +@pytest.mark.parametrize("vector", VECTORS["verify"], ids=lambda v: v["name"]) +def test_verify_vectors(vector: dict[str, Any]) -> None: + server = Provider().server(jwks_uri=JWKS_URI) + token = TOKENS[vector["name"]] + if vector["valid"]: + assert user_fields(server.verify(token)) == vector["user"] + else: + with pytest.raises(BearerError) as err: + server.verify(token) + assert err.value.status == 401 + assert err.value.body == {"error": "invalid_token"} + assert err.value.www_authenticate == 'Bearer error="invalid_token"' + + +def test_every_token_has_a_verify_vector() -> None: + assert {v["name"] for v in VECTORS["verify"]} == set(TOKENS) + + +def test_the_vectors_verify_through_discovery_too() -> None: + provider = Provider() + server = provider.server() + assert server.verify(TOKENS["user_passkey"]).sub == "user-123" + with pytest.raises(BearerError): + server.verify(TOKENS["wrong_aud"]) + assert provider.paths.count("/.well-known/openid-configuration") == 1 + + +def test_the_user_keeps_the_token_and_its_times() -> None: + user = Provider().server(jwks_uri=JWKS_URI).verify(TOKENS["user_passkey"]) + assert user.token == TOKENS["user_passkey"] + assert user.verified_at == NOW + assert user.exp > NOW + assert user.claims["client_id"] == "app-web" + assert TOKENS["user_passkey"] not in repr(user) + + +def test_discovery_happens_once() -> None: + provider = Provider() + server = provider.server() + for name in ("user_passkey", "user_otp", "client_credentials"): + server.verify(TOKENS[name]) + assert provider.paths.count("/.well-known/openid-configuration") == 1 + assert provider.paths.count("/jwks") == 1 + + +def test_an_issuer_with_a_trailing_slash_is_discovered_without_it_and_compared_with_it() -> None: + provider = Provider(discovery={"issuer": f"{ISSUER}/", "jwks_uri": JWKS_URI}) + server = provider.server(issuer=f"{ISSUER}/") + assert server.verify(TOKENS["iss_trailing_slash"]).sub == "user-456" + with pytest.raises(BearerError): + server.verify(TOKENS["user_otp"]) + assert provider.paths[0] == "/.well-known/openid-configuration" + + +@pytest.mark.parametrize( + "discovery", + [ + {"issuer": "https://evil.example.test", "jwks_uri": JWKS_URI}, + {"issuer": f"{ISSUER}/", "jwks_uri": JWKS_URI}, + {"jwks_uri": JWKS_URI}, + {"issuer": ISSUER}, + {"issuer": ISSUER, "jwks_uri": 7}, + {"issuer": ISSUER, "jwks_uri": "ftp://login.example.test/jwks"}, + {"issuer": ISSUER, "jwks_uri": "/jwks"}, + {"issuer": ISSUER, "jwks_uri": "http://login.example.test/jwks"}, + [], + "not an object", + ], +) +def test_a_bad_discovery_document_is_invalid_token(discovery: Any) -> None: + provider = Provider(discovery=discovery) + with pytest.raises(BearerError) as err: + provider.server().verify(TOKENS["user_passkey"]) + assert err.value.error == "invalid_token" + assert "/jwks" not in provider.paths + + +JWKS_URI_HTTP = "http://keys.test/jwks" + + +def test_an_http_jwks_uri_is_fine_under_an_http_issuer() -> None: + import httpx2 + + seen: list[str] = [] + + def handle(request: httpx2.Request) -> httpx2.Response: + seen.append(str(request.url)) + if request.url.path == "/.well-known/openid-configuration": + return httpx2.Response( + 200, json={"issuer": "http://login.test", "jwks_uri": JWKS_URI_HTTP} + ) + return httpx2.Response(200, json=VECTORS["jwks"]) + + client = httpx2.Client(transport=httpx2.MockTransport(handle)) + server = ResourceServer("http://login.test", AUDIENCE, http_client=client) + with pytest.raises(BearerError): + server.verify(TOKENS["user_passkey"]) + assert seen == ["http://login.test/.well-known/openid-configuration", JWKS_URI_HTTP] + + +def test_a_failed_discovery_is_401_and_retried_at_most_every_five_seconds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = [1000.0] + monkeypatch.setattr("seamless_auth._resource_server.time.monotonic", lambda: clock[0]) + provider = Provider(discovery_status=503) + server = provider.server() + for _ in range(3): + with pytest.raises(BearerError) as err: + server.verify(TOKENS["user_passkey"]) + assert err.value.status == 401 + assert provider.paths.count("/.well-known/openid-configuration") == 1 + + clock[0] += 5 + provider.discovery_status = 200 + assert server.verify(TOKENS["user_passkey"]).sub == "user-123" + assert provider.paths.count("/.well-known/openid-configuration") == 2 + + +def test_an_unreachable_provider_is_401_not_500() -> None: + def fail(_request: Any) -> Any: + raise OSError("connection refused") + + import httpx2 + + client = httpx2.Client(transport=httpx2.MockTransport(fail)) + server = ResourceServer(ISSUER, AUDIENCE, http_client=client) + with pytest.raises(BearerError) as err: + server.verify(TOKENS["user_passkey"]) + assert err.value.error == "invalid_token" + + +def test_a_jwks_failure_is_invalid_token() -> None: + provider = Provider(jwks_status=500) + with pytest.raises(BearerError) as err: + provider.server().verify(TOKENS["user_passkey"]) + assert err.value.error == "invalid_token" + + +def test_a_large_discovery_document_is_refused() -> None: + provider = Provider( + discovery={"issuer": ISSUER, "jwks_uri": JWKS_URI, "pad": "x" * 300_000}, + ) + with pytest.raises(BearerError): + provider.server().verify(TOKENS["user_passkey"]) + + +@pytest.mark.parametrize( + ("options", "message"), + [ + ({"issuer": "", "audience": AUDIENCE}, "issuer"), + ({"issuer": "login.example.test", "audience": AUDIENCE}, "issuer"), + ({"issuer": "ftp://login.example.test", "audience": AUDIENCE}, "issuer"), + ({"issuer": "https://", "audience": AUDIENCE}, "issuer"), + ({"issuer": ISSUER, "audience": ""}, "audience"), + ({"issuer": ISSUER, "audience": None}, "audience"), + ({"issuer": ISSUER, "audience": AUDIENCE, "jwks_uri": "/jwks"}, "jwks_uri"), + ], +) +def test_construction_errors(options: dict[str, Any], message: str) -> None: + with pytest.raises(ValueError, match=message): + ResourceServer(**options) + + +def test_a_cnf_token_is_refused_without_a_hook() -> None: + with pytest.raises(BearerError): + Provider().server(jwks_uri=JWKS_URI).verify(TOKENS["cnf_bound"]) + + +def test_the_confirmation_hook_accepts_a_cnf_token() -> None: + seen: list[tuple[Any, BearerRequest | None]] = [] + + def confirm(cnf: Any, request: BearerRequest | None) -> bool: + seen.append((cnf, request)) + return True + + server = Provider().server(jwks_uri=JWKS_URI, confirm=confirm) + request = BearerRequest("GET", Headers([("dpop", "proof")]), lambda: "https://api/x") + user = server.authenticate(f"Bearer {TOKENS['cnf_bound']}", request) + assert user.sub == "user-456" + assert seen[0][0] == {"jkt": "0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I"} + assert seen[0][1] is request + assert request.url == "https://api/x" + assert request.headers.get("DPoP") == "proof" + + +def test_a_token_without_cnf_never_reaches_the_hook() -> None: + calls: list[Any] = [] + server = Provider().server(jwks_uri=JWKS_URI, confirm=lambda c, r: calls.append(c) or True) + server.verify(TOKENS["user_otp"]) + assert calls == [] + + +@pytest.mark.parametrize("answer", [False, None, 1, "yes"]) +def test_the_hook_must_answer_true(answer: Any) -> None: + server = Provider().server(jwks_uri=JWKS_URI, confirm=lambda c, r: answer) + with pytest.raises(BearerError): + server.verify(TOKENS["cnf_bound"]) + + +def test_a_hook_that_raises_refuses_the_token() -> None: + def confirm(cnf: Any, request: BearerRequest | None) -> bool: + raise RuntimeError("no proof") + + with pytest.raises(BearerError): + Provider().server(jwks_uri=JWKS_URI, confirm=confirm).verify(TOKENS["cnf_bound"]) + + +def _segment(raw: bytes) -> str: + import base64 + + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +_NESTED = _segment(b"[" * 100_000) +_NAN = _segment(b'{"alg":NaN}') +_NOT_UTF8 = _segment(bytes([0xFF])) + + +@pytest.mark.parametrize( + "token", + [ + "a.b.c", + "..", + f"{_NESTED}.e30.c2ln", + f"{_NAN}.e30.c2ln", + f"{_NOT_UTF8}.e30.c2ln", + "e30.e30.c2ln=", + "e30.e30.c2l+", + "e30.e30.c2ln.", + ], +) +def test_garbage_is_invalid_token_not_an_error(token: str) -> None: + with pytest.raises(BearerError) as err: + Provider().server(jwks_uri=JWKS_URI).verify(token) + assert err.value.error == "invalid_token" + + +def test_a_short_signature_is_invalid_token() -> None: + header, payload, _ = TOKENS["user_passkey"].split(".") + with pytest.raises(BearerError): + Provider().server(jwks_uri=JWKS_URI).verify(f"{header}.{payload}.AAAA") + + +def test_time_follows_the_clock() -> None: + later = Provider().server(jwks_uri=JWKS_URI, clock=lambda: float(NOW + 7200)) + with pytest.raises(BearerError): + later.verify(TOKENS["user_passkey"]) + + +@pytest.mark.parametrize( + ("header", "token"), + [ + (None, None), + ("", None), + ("Bearer", None), + ("Bearer a b", None), + ("Basic a", None), + ("Bearer a", "a"), + ("bEaReR a", "a"), + (" Bearer \t a ", "a"), + ], +) +def test_bearer_token(header: str | None, token: str | None) -> None: + assert bearer_token(header) == token + + +def _user(**claims: Any) -> VerifiedUser: + base: dict[str, Any] = {"sub": "u", "client_id": "c", "verified_at": float(NOW)} + return VerifiedUser(**{**base, **claims}) + + +def test_require_scope_needs_every_scope() -> None: + guard = require_scope("a", "b") + guard.check(_user(scopes=("b", "a", "c"))) + with pytest.raises(BearerError) as err: + guard.check(_user(scopes=("a",))) + assert err.value.status == 403 + assert err.value.body == {"error": "insufficient_scope", "scope": "a b"} + + +def test_require_role_needs_any_role() -> None: + require_role("admin", "billing:read").check(_user(roles=("billing:write",))) + with pytest.raises(BearerError) as err: + require_role("admin").check(_user(roles=("member",))) + assert err.value.headers() == {} + + +def test_step_up_measures_max_age_with_no_leeway() -> None: + guard = require_step_up(max_age=60) + guard.check(_user(auth_time=NOW - 60)) + with pytest.raises(BearerError): + guard.check(_user(auth_time=NOW - 61)) + + +def test_step_up_reports_the_level_when_both_fail() -> None: + with pytest.raises(BearerError) as err: + require_step_up(acr_values=["phr"], max_age=10).check(_user(acr="x", auth_time=0)) + assert err.value.body["error_description"] == "A different authentication level is required" + + +@pytest.mark.parametrize( + "options", + [ + {}, + {"max_age": -1}, + {"max_age": 1.5}, + {"max_age": True}, + {"acr_values": []}, + {"acr_values": "phr"}, + {"amr": []}, + {"amr": [""]}, + ], +) +def test_step_up_construction_errors(options: dict[str, Any]) -> None: + with pytest.raises((ValueError, TypeError)): + require_step_up(**options) + + +def test_scope_and_role_construction_errors() -> None: + with pytest.raises(ValueError, match="at least one"): + require_scope() + with pytest.raises(ValueError, match="at least one"): + require_role() + with pytest.raises(ValueError, match="non-empty"): + require_scope("") + with pytest.raises(ValueError, match="non-empty"): + require_role("admin", "") + + +@pytest.mark.parametrize( + ("granted", "required", "expected"), + [ + ("admin", "admin", True), + ("billing:*", "billing", True), + ("billing:*", "billing:invoices:read", True), + ("billing:*", "users:read", False), + ("billing", "billing:invoices", True), + ("billing", "billingx:invoices", False), + ("billing:invoices:write", "billing:invoices:read", True), + ("billing:invoices:read", "billing:invoices:write", False), + ("billing:write", "users:read", False), + ("billing:invoices", "billing", False), + ("", "admin", False), + ("admin", " ", False), + (" admin ", "admin", True), + ("admin", "user", False), + ("a:b", "a:c", False), + ], +) +def test_role_grants_access(granted: str, required: str, expected: bool) -> None: + assert role_grants_access(granted, required) is expected diff --git a/tests/test_verify_only_django_urls.py b/tests/test_verify_only_django_urls.py new file mode 100644 index 0000000..5cc1a5d --- /dev/null +++ b/tests/test_verify_only_django_urls.py @@ -0,0 +1,72 @@ +"""The URLconf the Django verify-only tests run against: one sync and one async +view per shared request vector.""" + +from typing import Any + +from django.http import HttpRequest, JsonResponse +from django.urls import path +from verify_only_support import VECTORS + +from seamless_auth.django import ( + bearer_user_of, + require_bearer, + require_role, + require_scope, + require_step_up, +) + + +def _decorate(view: Any, guard: dict[str, Any] | None) -> Any: + if guard is not None: + if guard["kind"] == "scope": + view = require_scope(*guard["scopes"])(view) + elif guard["kind"] == "role": + view = require_role(*guard["roles"])(view) + else: + view = require_step_up( + acr_values=guard.get("acr_values"), + max_age=guard.get("max_age"), + amr=guard.get("amr"), + )(view) + return require_bearer(view) + + +def ok(request: HttpRequest) -> JsonResponse: + return JsonResponse({"ok": True}) + + +async def ok_async(request: HttpRequest) -> JsonResponse: + return JsonResponse({"ok": True}) + + +@require_scope("orders:read") +def unverified(request: HttpRequest) -> JsonResponse: + return JsonResponse({"ok": True}) + + +@require_bearer +@require_scope("orders:read") +@require_scope("reports:read") +def ordered(request: HttpRequest) -> JsonResponse: + return JsonResponse({"ok": True}) + + +@require_bearer +def me(request: HttpRequest) -> JsonResponse: + user = bearer_user_of(request) + return JsonResponse({"sub": user.sub, "scopes": list(user.scopes)}) + + +urlpatterns = [ + *( + path(f"sync/{i}", _decorate(ok, r["guard"]), name=f"vo-sync-{i}") + for i, r in enumerate(VECTORS["requests"]) + ), + *( + path(f"async/{i}", _decorate(ok_async, r["guard"]), name=f"vo-async-{i}") + for i, r in enumerate(VECTORS["requests"]) + ), + path("unverified", unverified), + path("ordered", ordered), + path("me", me), +] diff --git a/tests/test_verify_only_frameworks.py b/tests/test_verify_only_frameworks.py new file mode 100644 index 0000000..38e1d85 --- /dev/null +++ b/tests/test_verify_only_frameworks.py @@ -0,0 +1,172 @@ +"""The shared request vectors through the real FastAPI and Django stacks.""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from django.conf import settings +from verify_only_support import VECTORS, Provider, guard_of + +if not settings.configured: + settings.configure( + SECRET_KEY="django-tests-only", + ALLOWED_HOSTS=["*"], + ROOT_URLCONF="test_django_urls", + MIDDLEWARE=["django.middleware.csrf.CsrfViewMiddleware"], + INSTALLED_APPS=[], + ) + import django + + django.setup() + +from django.test import Client, override_settings +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from seamless_auth import VerifiedUser, require_scope +from seamless_auth.django import _reset_resource_server +from seamless_auth.fastapi import RequireBearer, add_bearer_error_handler + +REQUESTS: list[dict[str, Any]] = VECTORS["requests"] + + +def test_request_vector_names_are_unique() -> None: + assert len({r["name"] for r in REQUESTS}) == len(REQUESTS) + + +def _headers(vector: dict[str, Any]) -> dict[str, str]: + auth = vector["authorization"] + return {} if auth is None else {"Authorization": auth} + + +def _check(vector: dict[str, Any], status: int, body: Any, www: str | None) -> None: + expect = vector["expect"] + assert status == expect["status"] + assert www == expect["www_authenticate"] + if expect["body"] is None: + assert body == {"ok": True} + else: + assert body == expect["body"] + + +@pytest.fixture(scope="module") +def fastapi_client() -> TestClient: + server = Provider().server() + app = FastAPI() + add_bearer_error_handler(app) + for i, vector in enumerate(REQUESTS): + dependency = RequireBearer(server, *guard_of(vector["guard"])) + + def handler(user: VerifiedUser = Depends(dependency)) -> dict[str, bool]: # noqa: B008 + return {"ok": True} + + app.add_api_route(f"/vo/{i}", handler, methods=["GET"]) + return TestClient(app) + + +@pytest.mark.parametrize( + ("index", "vector"), list(enumerate(REQUESTS)), ids=[r["name"] for r in REQUESTS] +) +def test_fastapi_request_vectors( + fastapi_client: TestClient, index: int, vector: dict[str, Any] +) -> None: + r = fastapi_client.get(f"/vo/{index}", headers=_headers(vector)) + _check(vector, r.status_code, r.json(), r.headers.get("www-authenticate")) + + +@pytest.fixture +def django_client() -> Iterator[Client]: + _reset_resource_server() + provider = Provider() + options = { + "issuer": VECTORS["issuer"], + "audience": VECTORS["audience"], + "http_client": provider.client(), + "clock": lambda: float(VECTORS["now"]), + } + with override_settings( + SEAMLESS_RESOURCE_SERVER=options, ROOT_URLCONF="test_verify_only_django_urls" + ): + yield Client(enforce_csrf_checks=True) + _reset_resource_server() + + +@pytest.mark.parametrize( + ("index", "vector"), list(enumerate(REQUESTS)), ids=[r["name"] for r in REQUESTS] +) +@pytest.mark.parametrize("flavour", ["sync", "async"]) +def test_django_request_vectors( + django_client: Client, flavour: str, index: int, vector: dict[str, Any] +) -> None: + r = django_client.get(f"/{flavour}/{index}", headers=_headers(vector)) + _check(vector, r.status_code, r.json(), r.headers.get("WWW-Authenticate")) + + +def test_a_django_guard_without_require_bearer_answers_missing_token( + django_client: Client, +) -> None: + r = django_client.get("/unverified", headers={"Authorization": "Bearer x"}) + assert r.status_code == 401 + assert r.json() == {"error": "missing_token"} + assert r.headers["WWW-Authenticate"] == "Bearer" + + +def test_django_runs_guards_in_declaration_order(django_client: Client) -> None: + token = VECTORS["tokens"]["client_credentials"] + r = django_client.get("/ordered", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 + assert r.json() == {"error": "insufficient_scope", "scope": "orders:read"} + + +def test_django_puts_the_user_on_the_request(django_client: Client) -> None: + token = VECTORS["tokens"]["user_passkey"] + r = django_client.get("/me", headers={"Authorization": f"Bearer {token}"}) + assert r.json() == {"sub": "user-123", "scopes": ["openid", "orders:read", "orders:write"]} + + +def test_fastapi_runs_guards_in_declaration_order() -> None: + server = Provider().server() + app = FastAPI() + add_bearer_error_handler(app) + + guarded = RequireBearer(server, require_scope("orders:read"), require_scope("reports:read")) + + @app.get("/ordered") + def ordered(user: VerifiedUser = Depends(guarded)) -> dict[str, str]: # noqa: B008 + return {"sub": user.sub} + + token = VECTORS["tokens"]["client_credentials"] + r = TestClient(app).get("/ordered", headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 403 + assert r.json()["scope"] == "orders:read" + + +def test_fastapi_without_the_handler_still_challenges() -> None: + app = FastAPI() + + @app.get("/x") + def x(user: VerifiedUser = Depends(RequireBearer(Provider().server()))) -> dict[str, str]: # noqa: B008 + return {"sub": user.sub} + + r = TestClient(app).get("/x") + assert r.status_code == 401 + assert r.headers["www-authenticate"] == "Bearer" + assert r.json() == {"detail": {"error": "missing_token"}} + + +def test_fastapi_two_authorization_headers_are_no_token() -> None: + server = Provider().server() + app = FastAPI() + add_bearer_error_handler(app) + + @app.get("/x") + def x(user: VerifiedUser = Depends(RequireBearer(server))) -> dict[str, str]: # noqa: B008 + return {"sub": user.sub} + + token = VECTORS["tokens"]["user_passkey"] + auth = f"Bearer {token}" + r = TestClient(app).get("/x", headers=[("authorization", auth), ("authorization", auth)]) + assert r.status_code == 401 + assert r.json() == {"error": "missing_token"} diff --git a/tests/verify_only_support.py b/tests/verify_only_support.py new file mode 100644 index 0000000..c496510 --- /dev/null +++ b/tests/verify_only_support.py @@ -0,0 +1,95 @@ +"""The shared verify-only vectors and a provider that serves their keys. + +data/verify_only_vectors.json is copied byte for byte from +fells-code/seamless-auth-server, packages/core/tests/fixtures/verify-only/vectors.json, +so every adapter is held to the same tokens and answers. Do not edit it here. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import httpx2 + +from seamless_auth import ( + Guard, + ResourceServer, + VerifiedUser, + require_role, + require_scope, + require_step_up, +) + +VECTORS: dict[str, Any] = json.loads( + (Path(__file__).parent / "data" / "verify_only_vectors.json").read_text() +) +ISSUER: str = VECTORS["issuer"] +AUDIENCE: str = VECTORS["audience"] +NOW: int = VECTORS["now"] +TOKENS: dict[str, str] = VECTORS["tokens"] +JWKS_URI = f"{ISSUER}/jwks" +USER_FIELDS = ("sub", "client_id", "scopes", "roles", "sid", "org_id", "acr", "amr", "auth_time") + + +@dataclass +class Provider: + """An OpenID Provider's discovery document and key set, over a mock transport.""" + + discovery: Any = field( + default_factory=lambda: {"issuer": ISSUER, "jwks_uri": JWKS_URI}, + ) + discovery_status: int = 200 + jwks_status: int = 200 + paths: list[str] = field(default_factory=list) + + def handle(self, request: httpx2.Request) -> httpx2.Response: + url = str(request.url) + self.paths.append(request.url.path) + if url == f"{ISSUER}/.well-known/openid-configuration": + return httpx2.Response(self.discovery_status, json=self.discovery) + if url == JWKS_URI: + return httpx2.Response(self.jwks_status, json=VECTORS["jwks"]) + return httpx2.Response(404) + + def client(self) -> httpx2.Client: + return httpx2.Client(transport=httpx2.MockTransport(self.handle)) + + def server(self, **options: Any) -> ResourceServer: + settings: dict[str, Any] = { + "issuer": ISSUER, + "audience": AUDIENCE, + "http_client": self.client(), + "clock": lambda: float(NOW), + **options, + } + return ResourceServer(**settings) + + +def user_fields(user: VerifiedUser) -> dict[str, Any]: + out: dict[str, Any] = {} + for name in USER_FIELDS: + value = getattr(user, name) + out[name] = list(value) if isinstance(value, tuple) else value + return out + + +def guard_of(spec: dict[str, Any] | None) -> list[Guard]: + if spec is None: + return [] + if spec["kind"] == "scope": + return [require_scope(*spec["scopes"])] + if spec["kind"] == "role": + return [require_role(*spec["roles"])] + assert spec["kind"] == "step_up" + return [ + require_step_up( + acr_values=spec.get("acr_values"), max_age=spec.get("max_age"), amr=spec.get("amr") + ) + ] + + +def request_ids() -> list[str]: + return [r["name"] for r in VECTORS["requests"]]