= 8.1 with ext-openssl, ext-curl, ext-json. * - Copy this file unchanged into each app (e.g. lib/sassly-auth/SasslyAuthClient.php). * Never edit it per app; upgrade by replacing the whole file and checking VERSION. * - Protocol contract: references/contract.md in the sassly-auth-integration skill. * * Typical use (see the skill templates for complete route files): * * session_start(); * $auth = new SasslyAuthClient([...]); * // GET /auth/login * $auth->redirectToLogin('/dashboard'); * // GET /auth/callback * $identity = $auth->handleCallback(); // throws SasslyAuthException * $auth->startSession($identity); // after the app accepted the user * // every protected request * if ($auth->check() !== SasslyAuthClient::VALID) { ...redirect to /auth/login... } * // POST /auth/logout * $url = $auth->prepareLogout(); session_destroy(); header('Location: ' . $url); */ declare(strict_types=1); final class SasslyAuthException extends RuntimeException { /** Machine-readable reason, e.g. 'state_mismatch', 'invalid_grant'. See troubleshooting.md. */ public readonly string $reason; /** OAuth error code sent by auth.sassly.ai, when there is one (e.g. 'login_required'). */ public readonly ?string $oauthError; /** Sanitized local path saved with the login attempt (callback errors only). */ public readonly ?string $returnTo; public function __construct( string $reason, string $message = '', ?string $oauthError = null, ?string $returnTo = null, ?Throwable $previous = null ) { parent::__construct($message !== '' ? $message : $reason, 0, $previous); $this->reason = $reason; $this->oauthError = $oauthError; $this->returnTo = $returnTo; } } final class SasslyAuthClient { public const VERSION = '1.0.0'; /** check() results */ public const NONE = 'none'; // no Sassly login in this PHP session public const VALID = 'valid'; // logged in and (re)validated public const ENDED = 'ended'; // central session ended (logout, revoke, expiry); local data cleared private const PENDING_TTL = 600; // seconds a login attempt stays usable private const PENDING_MAX = 5; // parallel login attempts kept per PHP session (tabs) private const JWKS_TTL = 3600; // seconds to trust cached JWKS private const JWKS_MIN_REFETCH = 30; // never refetch JWKS more often than this private const RETRY_AFTER_ERROR = 60; // seconds before retrying a failed revalidation private string $issuer; private string $clientId; private string $clientSecret; private string $redirectUri; private ?string $postLogoutRedirectUri; private string $scope; private string $cacheDir; private string $sessionKey; private int $revalidateInterval; private int $maxStale; private int $httpTimeout; private ?string $caFile; private int $leeway; private bool $allowInsecureHttp; /** @var (callable(): int)|null */ private $clock; /** @var (callable(string, string, array): void)|null */ private $logger; /** @var array{fetched_at:int, keys:array}|null */ private ?array $jwksMemo = null; /** * Config keys: * issuer (required) e.g. https://auth.sassly.ai (no trailing slash needed) * client_id (required) e.g. onix * client_secret (required) from `php bin/auth client:create` * redirect_uri (required) e.g. https://onix.sassly.ai/auth/callback (registered exactly) * post_logout_redirect_uri (optional) registered exactly, e.g. https://onix.sassly.ai/ * scope default 'openid email profile' * cache_dir default sys_get_temp_dir().'/sassly-auth'; must be writable by PHP-FPM * session_key default '_sassly_auth' (key inside $_SESSION) * revalidate_interval default 300 s โ€” how often the central session is re-checked * max_stale default 1800 s โ€” keep users in while auth.sassly.ai is unreachable * http_timeout default 5 s * ca_file optional CA bundle path (local dev with self-signed certs) * leeway default 60 s clock skew * allow_insecure_http default false; true allows http:// only for localhost/127.0.0.1 (tests) * clock optional callable returning unix time (tests) * logger optional callable(string $level, string $message, array $context) */ public function __construct(array $config) { $this->allowInsecureHttp = (bool)($config['allow_insecure_http'] ?? false); $issuer = rtrim(trim((string)($config['issuer'] ?? '')), '/'); $this->assertUrl($issuer, 'issuer', true); $this->issuer = $issuer; $clientId = (string)($config['client_id'] ?? ''); if (!preg_match('/^[a-z0-9][a-z0-9-]{1,39}\z/', $clientId)) { throw new SasslyAuthException('config_invalid', "Config 'client_id' must match [a-z0-9][a-z0-9-]{1,39}"); } $this->clientId = $clientId; $secret = (string)($config['client_secret'] ?? ''); if ($secret === '') { throw new SasslyAuthException('config_invalid', "Config 'client_secret' is empty"); } $this->clientSecret = $secret; $redirectUri = (string)($config['redirect_uri'] ?? ''); $this->assertUrl($redirectUri, 'redirect_uri'); $this->redirectUri = $redirectUri; $postLogout = $config['post_logout_redirect_uri'] ?? null; if ($postLogout !== null && $postLogout !== '') { $this->assertUrl((string)$postLogout, 'post_logout_redirect_uri'); $this->postLogoutRedirectUri = (string)$postLogout; } else { $this->postLogoutRedirectUri = null; } $this->scope = (string)($config['scope'] ?? 'openid email profile'); if (!in_array('openid', explode(' ', $this->scope), true)) { throw new SasslyAuthException('config_invalid', "Config 'scope' must contain 'openid'"); } $this->sessionKey = (string)($config['session_key'] ?? '_sassly_auth'); $this->revalidateInterval = max(30, (int)($config['revalidate_interval'] ?? 300)); $this->maxStale = max($this->revalidateInterval, (int)($config['max_stale'] ?? 1800)); $this->httpTimeout = max(1, (int)($config['http_timeout'] ?? 5)); $this->leeway = max(0, (int)($config['leeway'] ?? 60)); $ca = $config['ca_file'] ?? null; $this->caFile = ($ca !== null && $ca !== '') ? (string)$ca : null; $this->clock = isset($config['clock']) && is_callable($config['clock']) ? $config['clock'] : null; $this->logger = isset($config['logger']) && is_callable($config['logger']) ? $config['logger'] : null; $cacheDir = rtrim((string)($config['cache_dir'] ?? (sys_get_temp_dir() . '/sassly-auth')), '/'); if (!is_dir($cacheDir)) { @mkdir($cacheDir, 0700, true); } if (!is_dir($cacheDir) || !is_writable($cacheDir)) { $this->log('warning', 'cache_dir is not writable; JWKS will be fetched on every verification', ['cache_dir' => $cacheDir]); $cacheDir = ''; } $this->cacheDir = $cacheDir; } // ------------------------------------------------------------------ login /** * Build the auth.sassly.ai authorize URL and remember the attempt (state, nonce, PKCE) in the session. * * @param string $returnTo local path to land on after login (sanitized; external URLs are ignored) * @param array{prompt?: 'none'|'login', login_hint?: string} $options */ public function buildLoginUrl(string $returnTo = '/', array $options = []): string { $bag = $this->readBag(); $now = $this->now(); $pending = $this->prunePending($bag['pending'] ?? [], $now); $state = self::b64url(random_bytes(32)); $nonce = self::b64url(random_bytes(32)); $verifier = self::b64url(random_bytes(48)); // 64 chars, within PKCE's 43..128 $pending[$state] = ['n' => $nonce, 'v' => $verifier, 'r' => self::sanitizeReturnTo($returnTo), 't' => $now]; if (count($pending) > self::PENDING_MAX) { uasort($pending, static fn(array $a, array $b): int => $a['t'] <=> $b['t']); $pending = array_slice($pending, -self::PENDING_MAX, null, true); } $bag['pending'] = $pending; $this->writeBag($bag); $params = [ 'response_type' => 'code', 'client_id' => $this->clientId, 'redirect_uri' => $this->redirectUri, 'scope' => $this->scope, 'state' => $state, 'nonce' => $nonce, 'code_challenge' => self::pkceChallenge($verifier), 'code_challenge_method' => 'S256', ]; if (isset($options['prompt'])) { if (!in_array($options['prompt'], ['none', 'login'], true)) { throw new SasslyAuthException('config_invalid', "prompt must be 'none' or 'login'"); } $params['prompt'] = $options['prompt']; } if (isset($options['login_hint']) && $options['login_hint'] !== '') { $params['login_hint'] = (string)$options['login_hint']; } return $this->issuer . '/oauth/authorize?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); } /** Send the browser to auth.sassly.ai. Never returns. */ public function redirectToLogin(string $returnTo = '/', array $options = []): never { $url = $this->buildLoginUrl($returnTo, $options); if (headers_sent($file, $line)) { throw new SasslyAuthException('headers_sent', "Output started at $file:$line; cannot redirect to auth.sassly.ai"); } header('Cache-Control: no-store'); header('Location: ' . $url, true, 302); exit; } /** * Validate the callback (state, iss, code), exchange the code, verify the ID token. * Does NOT log the user in: call startSession($identity) after the app accepted the user. * * @param array|null $query defaults to $_GET * @return array{sub:string, email:?string, email_verified:bool, name:?string, picture:?string, * sid:string, auth_time:int, return_to:string, claims:array, tokens:array} * @throws SasslyAuthException */ public function handleCallback(?array $query = null): array { $q = $query ?? $_GET; $bag = $this->readBag(); $now = $this->now(); $state = is_string($q['state'] ?? null) ? $q['state'] : ''; if ($state === '') { throw new SasslyAuthException('state_missing', 'Callback has no state parameter'); } $pending = $this->prunePending($bag['pending'] ?? [], $now); $attempt = $pending[$state] ?? null; unset($pending[$state]); // single use, whatever happens next $bag['pending'] = $pending; $this->writeBag($bag); if (!is_array($attempt)) { throw new SasslyAuthException('state_mismatch', 'Unknown or expired login attempt. Start the login again.'); } $returnTo = (string)$attempt['r']; try { if (isset($q['iss']) && $q['iss'] !== $this->issuer) { throw new SasslyAuthException('iss_mismatch', 'Callback iss does not match the configured issuer'); } if (isset($q['error'])) { $err = is_string($q['error']) ? $q['error'] : 'unknown_error'; $desc = is_string($q['error_description'] ?? null) ? ': ' . $q['error_description'] : ''; throw new SasslyAuthException('authorize_error', 'auth.sassly.ai returned ' . $err . $desc, $err); } $code = is_string($q['code'] ?? null) ? $q['code'] : ''; if ($code === '') { throw new SasslyAuthException('code_missing', 'Callback has no code parameter'); } $tok = $this->tokenRequest([ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri, 'code_verifier' => (string)$attempt['v'], ]); foreach (['access_token', 'id_token', 'refresh_token'] as $k) { if (!is_string($tok[$k] ?? null) || $tok[$k] === '') { throw new SasslyAuthException('token_response_invalid', "Token response has no $k (is the refresh_token grant enabled for this client?)"); } } if (strcasecmp((string)($tok['token_type'] ?? ''), 'Bearer') !== 0) { throw new SasslyAuthException('token_response_invalid', 'token_type must be Bearer'); } $claims = $this->verifyJwt($tok['id_token'], 'JWT', $this->clientId); if (!is_string($claims['nonce'] ?? null) || !hash_equals((string)$attempt['n'], $claims['nonce'])) { throw new SasslyAuthException('token_invalid', 'ID token nonce does not match the login attempt'); } if (!is_string($claims['sub'] ?? null) || $claims['sub'] === '' || !is_string($claims['sid'] ?? null) || $claims['sid'] === '') { throw new SasslyAuthException('token_invalid', 'ID token has no sub or sid'); } } catch (SasslyAuthException $e) { throw new SasslyAuthException($e->reason, $e->getMessage(), $e->oauthError, $returnTo, $e); } $expiresIn = max(0, (int)($tok['expires_in'] ?? 0)); $email = is_string($claims['email'] ?? null) ? strtolower(trim($claims['email'])) : null; return [ 'sub' => $claims['sub'], 'email' => $email, 'email_verified' => ($claims['email_verified'] ?? false) === true, 'name' => is_string($claims['name'] ?? null) ? $claims['name'] : null, 'picture' => is_string($claims['picture'] ?? null) ? $claims['picture'] : null, 'sid' => $claims['sid'], 'auth_time' => (int)($claims['auth_time'] ?? 0), 'return_to' => $returnTo, 'claims' => $claims, 'tokens' => [ 'id_token' => $tok['id_token'], 'access_token' => $tok['access_token'], 'refresh_token' => $tok['refresh_token'], 'access_expires_at' => $now + $expiresIn, ], ]; } /** * Log the user in locally: regenerates the PHP session id (anti-fixation) and stores the tokens. * Call only after the app decided the user may enter. */ public function startSession(array $identity): void { if (!is_string($identity['sub'] ?? null) || !is_array($identity['tokens'] ?? null) || !is_string($identity['tokens']['refresh_token'] ?? null)) { throw new SasslyAuthException('identity_invalid', 'startSession() needs the array returned by handleCallback()'); } $bag = $this->readBag(); if (headers_sent($file, $line)) { throw new SasslyAuthException('headers_sent', "Output started at $file:$line before startSession(); remove it (BOM, whitespace, echo)"); } if (!session_regenerate_id(true)) { throw new SasslyAuthException('session_regenerate_failed', 'session_regenerate_id() failed'); } $now = $this->now(); $t = $identity['tokens']; $this->writeBag([ 'v' => 1, 'sub' => $identity['sub'], 'sid' => (string)$identity['sid'], 'email' => $identity['email'] ?? null, 'name' => $identity['name'] ?? null, 'picture' => $identity['picture'] ?? null, 'id_token' => (string)$t['id_token'], 'access_token' => (string)$t['access_token'], 'access_expires_at' => (int)$t['access_expires_at'], 'refresh_token' => (string)$t['refresh_token'], 'login_at' => $now, 'last_ok_at' => $now, 'next_check_at' => $now + $this->revalidateInterval, 'pending' => $this->prunePending($bag['pending'] ?? [], $now), // keep other tabs' attempts ]); } // ------------------------------------------------------------ per request /** * Is this PHP session logged in to Sassly? Re-validates with auth.sassly.ai every * revalidate_interval seconds (refresh_token grant). Returns NONE, VALID or ENDED. * On ENDED the local Sassly data is already cleared; the app must clear its own login too. * If auth.sassly.ai is unreachable the session stays VALID for at most max_stale seconds. */ public function check(): string { $bag = $this->readBag(); if (($bag['v'] ?? null) !== 1 || !is_string($bag['sub'] ?? null) || !is_string($bag['refresh_token'] ?? null)) { return self::NONE; } $now = $this->now(); if ($now < (int)($bag['next_check_at'] ?? 0)) { return self::VALID; } try { $tok = $this->tokenRequest(['grant_type' => 'refresh_token', 'refresh_token' => $bag['refresh_token']]); if (!is_string($tok['access_token'] ?? null) || $tok['access_token'] === '') { throw new SasslyAuthException('token_response_invalid', 'Refresh response has no access_token'); } $bag['access_token'] = $tok['access_token']; $bag['access_expires_at'] = $now + max(0, (int)($tok['expires_in'] ?? 0)); if (is_string($tok['refresh_token'] ?? null) && $tok['refresh_token'] !== '') { $bag['refresh_token'] = $tok['refresh_token']; // only sent when the server rotates } $bag['last_ok_at'] = $now; $bag['next_check_at'] = $now + $this->revalidateInterval; $this->writeBag($bag); return self::VALID; } catch (SasslyAuthException $e) { if ($e->reason === 'invalid_grant') { $this->log('info', 'Sassly session ended', ['sub' => $bag['sub']]); $this->clearLocal(); return self::ENDED; } $this->log('warning', 'Sassly revalidation failed: ' . $e->getMessage(), ['reason' => $e->reason]); if ($now - (int)($bag['last_ok_at'] ?? 0) > $this->maxStale) { $this->clearLocal(); return self::ENDED; } $bag['next_check_at'] = $now + self::RETRY_AFTER_ERROR; $this->writeBag($bag); return self::VALID; } } /** sub/sid/email/name/picture of the logged-in user, or null. Does not re-validate. */ public function identity(): ?array { $bag = $this->readBag(); if (($bag['v'] ?? null) !== 1 || !is_string($bag['sub'] ?? null)) { return null; } return [ 'sub' => $bag['sub'], 'sid' => $bag['sid'] ?? null, 'email' => $bag['email'] ?? null, 'name' => $bag['name'] ?? null, 'picture' => $bag['picture'] ?? null, ]; } /** * A non-expired access token for calling Sassly APIs (audience 'sassly-api'), refreshing it when * needed. Returns null when not logged in or when auth.sassly.ai cannot be reached. */ public function accessToken(): ?string { if ($this->check() !== self::VALID) { return null; } $bag = $this->readBag(); if ($this->now() < (int)$bag['access_expires_at'] - 30) { return (string)$bag['access_token']; } $bag['next_check_at'] = 0; // force a refresh now $this->writeBag($bag); if ($this->check() !== self::VALID) { return null; } $bag = $this->readBag(); return $this->now() < (int)$bag['access_expires_at'] - 30 ? (string)$bag['access_token'] : null; } // ------------------------------------------------------------------ logout /** * Forget the local Sassly login and return the auth.sassly.ai logout URL (ends the central session * for every app in this browser). Then destroy the app session and redirect to the returned URL. * * @param string|null $idTokenHint ID token to use when this PHP session has none of its own * (e.g. a user the app refused at callback). Without any hint * auth.sassly.ai asks the user to confirm the logout. */ public function prepareLogout(?string $state = null, ?string $idTokenHint = null): string { $bag = $this->readBag(); $params = ['client_id' => $this->clientId]; $hint = is_string($bag['id_token'] ?? null) && $bag['id_token'] !== '' ? $bag['id_token'] : $idTokenHint; if ($hint !== null && $hint !== '') { $params['id_token_hint'] = $hint; } if ($this->postLogoutRedirectUri !== null) { $params['post_logout_redirect_uri'] = $this->postLogoutRedirectUri; } if ($state !== null && $state !== '') { $params['state'] = $state; } $this->clearLocal(); return $this->issuer . '/oauth/logout?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986); } /** Remove the Sassly login data from the PHP session (login attempts in other tabs are kept). */ public function clearLocal(): void { $bag = $this->readBag(); $this->writeBag(['pending' => $bag['pending'] ?? []]); } // ------------------------------------------------------------------- APIs /** * Verify an access token sent to an API (Authorization: Bearer ...). Returns its claims. * @throws SasslyAuthException reason 'token_invalid' or 'insufficient_scope' */ public function verifyAccessToken(string $jwt, string $audience = 'sassly-api', ?string $requiredScope = null): array { $claims = $this->verifyJwt($jwt, 'at+jwt', $audience); if (!is_string($claims['sub'] ?? null) || $claims['sub'] === '') { throw new SasslyAuthException('token_invalid', 'Access token has no sub'); } if ($requiredScope !== null) { $scopes = explode(' ', (string)($claims['scope'] ?? '')); if (!in_array($requiredScope, $scopes, true)) { throw new SasslyAuthException('insufficient_scope', "Access token lacks scope '$requiredScope'"); } } return $claims; } /** The Bearer token of the current request, or null. Works behind Nginx/PHP-FPM and Apache. */ public static function bearerTokenFromRequest(): ?string { $h = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? ''; if ($h === '' && function_exists('getallheaders')) { foreach ((array)getallheaders() as $name => $value) { if (strcasecmp((string)$name, 'Authorization') === 0) { $h = (string)$value; break; } } } if (preg_match('/^\s*Bearer\s+([A-Za-z0-9\-_.~+\/]+=*)\s*$/i', (string)$h, $m)) { return $m[1]; } return null; } // ---------------------------------------------------------------- helpers /** * Keep only safe local paths: must start with a single '/', no '//' or '\', no control chars. * Anything else becomes $fallback (prevents open redirects). */ public static function sanitizeReturnTo(mixed $path, string $fallback = '/'): string { if (!is_string($path) || $path === '' || strlen($path) > 2000) { return $fallback; } if ($path[0] !== '/' || (strlen($path) > 1 && ($path[1] === '/' || $path[1] === '\\'))) { return $fallback; } if (str_contains($path, '\\') || preg_match('/[\x00-\x1F\x7F]/', $path)) { return $fallback; } return $path; } // --------------------------------------------------------------- internals private function now(): int { return $this->clock !== null ? (int)($this->clock)() : time(); } private function readBag(): array { if (session_status() !== PHP_SESSION_ACTIVE) { throw new SasslyAuthException('session_not_started', 'Call session_start() before using SasslyAuthClient'); } $bag = $_SESSION[$this->sessionKey] ?? []; return is_array($bag) ? $bag : []; } private function writeBag(array $bag): void { if (session_status() !== PHP_SESSION_ACTIVE) { throw new SasslyAuthException('session_not_started', 'Call session_start() before using SasslyAuthClient'); } $_SESSION[$this->sessionKey] = $bag; } private function prunePending(mixed $pending, int $now): array { if (!is_array($pending)) { return []; } $out = []; foreach ($pending as $state => $p) { if (is_array($p) && isset($p['n'], $p['v'], $p['r'], $p['t']) && ($now - (int)$p['t']) <= self::PENDING_TTL) { $out[(string)$state] = $p; } } return $out; } private function tokenRequest(array $fields): array { $res = $this->httpRequest('POST', $this->issuer . '/oauth/token', $fields, true); $json = json_decode($res['body'], true); if ($res['status'] === 200) { if (!is_array($json)) { throw new SasslyAuthException('token_response_invalid', 'Token endpoint returned non-JSON'); } return $json; } $err = is_array($json) && is_string($json['error'] ?? null) ? $json['error'] : null; $desc = is_array($json) && is_string($json['error_description'] ?? null) ? $json['error_description'] : ''; if ($err === 'invalid_grant') { throw new SasslyAuthException('invalid_grant', trim('invalid_grant ' . $desc), 'invalid_grant'); } if ($err === 'invalid_client' || $res['status'] === 401) { throw new SasslyAuthException('invalid_client', 'Client authentication failed: check client_id/client_secret', 'invalid_client'); } if ($res['status'] >= 500 || $res['status'] === 0) { throw new SasslyAuthException('server_error', 'auth.sassly.ai returned HTTP ' . $res['status']); } throw new SasslyAuthException('token_error', trim('Token request failed: HTTP ' . $res['status'] . ' ' . ($err ?? '') . ' ' . $desc), $err); } /** @return array{status:int, body:string} */ private function httpRequest(string $method, string $url, ?array $form, bool $clientAuth): array { $ch = curl_init($url); if ($ch === false) { throw new SasslyAuthException('network_error', 'curl_init failed'); } $headers = ['Accept: application/json']; $opts = [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => min(3, $this->httpTimeout), CURLOPT_TIMEOUT => $this->httpTimeout, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_USERAGENT => 'SasslyAuthClient/' . self::VERSION . ' PHP/' . PHP_VERSION, ]; if (defined('CURLOPT_PROTOCOLS_STR')) { $opts[constant('CURLOPT_PROTOCOLS_STR')] = $this->allowInsecureHttp ? 'https,http' : 'https'; } else { $opts[CURLOPT_PROTOCOLS] = $this->allowInsecureHttp ? (CURLPROTO_HTTPS | CURLPROTO_HTTP) : CURLPROTO_HTTPS; } if ($this->caFile !== null) { $opts[CURLOPT_CAINFO] = $this->caFile; } if ($method === 'POST') { $opts[CURLOPT_POST] = true; $opts[CURLOPT_POSTFIELDS] = http_build_query($form ?? [], '', '&'); $headers[] = 'Content-Type: application/x-www-form-urlencoded'; } if ($clientAuth) { // RFC 6749 ยง2.3.1: form-urlencode id and secret, then Base64. $headers[] = 'Authorization: Basic ' . base64_encode(urlencode($this->clientId) . ':' . urlencode($this->clientSecret)); } $opts[CURLOPT_HTTPHEADER] = $headers; curl_setopt_array($ch, $opts); $body = curl_exec($ch); if ($body === false) { throw new SasslyAuthException('network_error', 'HTTP request to ' . parse_url($url, PHP_URL_HOST) . ' failed (curl ' . curl_errno($ch) . '): ' . curl_error($ch)); } return ['status' => (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE), 'body' => (string)$body]; } /** Verify signature (RS256 via JWKS), typ, iss, aud, exp, nbf, iat. Returns the payload. */ private function verifyJwt(string $jwt, string $expectedTyp, string $expectedAud): array { $parts = explode('.', $jwt); if (count($parts) !== 3) { throw new SasslyAuthException('token_invalid', 'Malformed JWT'); } [$h64, $p64, $s64] = $parts; $header = self::jsonObject(self::b64urlDecode($h64)); $payload = self::jsonObject(self::b64urlDecode($p64)); $sig = self::b64urlDecode($s64); if ($header === null || $payload === null || $sig === null || $sig === '') { throw new SasslyAuthException('token_invalid', 'Malformed JWT'); } if (($header['alg'] ?? null) !== 'RS256') { throw new SasslyAuthException('token_invalid', 'JWT alg must be RS256'); } $typ = strtolower((string)($header['typ'] ?? '')); if (str_starts_with($typ, 'application/')) { $typ = substr($typ, 12); } if ($typ !== strtolower($expectedTyp)) { throw new SasslyAuthException('token_invalid', "JWT typ must be $expectedTyp"); } $kid = $header['kid'] ?? null; if (!is_string($kid) || $kid === '') { throw new SasslyAuthException('token_invalid', 'JWT has no kid'); } $key = openssl_pkey_get_public($this->publicKeyPem($kid)); if ($key === false) { throw new SasslyAuthException('token_invalid', 'Signing key cannot be loaded'); } if (openssl_verify($h64 . '.' . $p64, $sig, $key, OPENSSL_ALGO_SHA256) !== 1) { throw new SasslyAuthException('token_invalid', 'JWT signature is invalid'); } $now = $this->now(); if (($payload['iss'] ?? null) !== $this->issuer) { throw new SasslyAuthException('token_invalid', 'JWT iss mismatch'); } $aud = $payload['aud'] ?? null; $auds = is_array($aud) ? $aud : [$aud]; if (!in_array($expectedAud, $auds, true)) { throw new SasslyAuthException('token_invalid', 'JWT aud mismatch'); } if ($expectedTyp === 'JWT' && is_array($aud) && count($aud) > 1 && ($payload['azp'] ?? null) !== $this->clientId) { throw new SasslyAuthException('token_invalid', 'ID token azp mismatch'); } if (!is_int($payload['exp'] ?? null) || $now > $payload['exp'] + $this->leeway) { throw new SasslyAuthException('token_invalid', 'JWT expired'); } if (isset($payload['nbf']) && (!is_int($payload['nbf']) || $payload['nbf'] > $now + $this->leeway)) { throw new SasslyAuthException('token_invalid', 'JWT not valid yet'); } if (!is_int($payload['iat'] ?? null) || $payload['iat'] > $now + $this->leeway) { throw new SasslyAuthException('token_invalid', 'JWT iat is missing or in the future'); } return $payload; } private function publicKeyPem(string $kid): string { $jwk = self::findJwk($this->loadJwks(false), $kid); if ($jwk === null) { $jwk = self::findJwk($this->loadJwks(true), $kid); // key rotated: refetch once } if ($jwk === null) { throw new SasslyAuthException('token_invalid', "Unknown signing key (kid $kid)"); } return self::jwkToPem($jwk); } private function loadJwks(bool $forceRefresh): array { $now = $this->now(); $file = $this->cacheDir !== '' ? $this->cacheDir . '/jwks-' . substr(hash('sha256', $this->issuer), 0, 16) . '.json' : null; $cached = $this->jwksMemo; if ($cached === null && $file !== null && is_file($file)) { $raw = @file_get_contents($file); $data = is_string($raw) ? json_decode($raw, true) : null; if (is_array($data) && is_int($data['fetched_at'] ?? null) && is_array($data['keys'] ?? null)) { $cached = $data; } } if ($cached !== null) { $age = $now - $cached['fetched_at']; $fresh = $age >= 0 && $age < self::JWKS_TTL; $mayRefetch = $age < 0 || $age >= self::JWKS_MIN_REFETCH; if (($fresh && !$forceRefresh) || ($forceRefresh && !$mayRefetch)) { $this->jwksMemo = $cached; return $cached['keys']; } } try { $res = $this->httpRequest('GET', $this->issuer . '/.well-known/jwks.json', null, false); $json = json_decode($res['body'], true); if ($res['status'] !== 200 || !is_array($json) || !is_array($json['keys'] ?? null)) { throw new SasslyAuthException('jwks_unavailable', 'JWKS endpoint returned HTTP ' . $res['status']); } } catch (SasslyAuthException $e) { if ($cached !== null) { $this->log('warning', 'JWKS refresh failed, using cached keys: ' . $e->getMessage()); $this->jwksMemo = $cached; return $cached['keys']; } throw new SasslyAuthException('jwks_unavailable', 'Cannot fetch JWKS: ' . $e->getMessage(), null, null, $e); } $data = ['fetched_at' => $now, 'keys' => array_values($json['keys'])]; $this->jwksMemo = $data; if ($file !== null) { $tmp = $file . '.' . bin2hex(random_bytes(4)) . '.tmp'; if (@file_put_contents($tmp, json_encode($data, JSON_UNESCAPED_SLASHES)) !== false) { @rename($tmp, $file); // atomic replace } else { $this->log('warning', 'Cannot write JWKS cache', ['file' => $file]); } } return $data['keys']; } private static function findJwk(array $keys, string $kid): ?array { foreach ($keys as $k) { if (is_array($k) && ($k['kid'] ?? null) === $kid && ($k['kty'] ?? null) === 'RSA' && (!isset($k['use']) || $k['use'] === 'sig') && (!isset($k['alg']) || $k['alg'] === 'RS256') && is_string($k['n'] ?? null) && is_string($k['e'] ?? null)) { return $k; } } return null; } /** RSA JWK (n, e) -> PEM SubjectPublicKeyInfo. */ private static function jwkToPem(array $jwk): string { $n = self::b64urlDecode((string)$jwk['n']); $e = self::b64urlDecode((string)$jwk['e']); if ($n === null || $e === null || strlen(ltrim($n, "\x00")) < 256) { throw new SasslyAuthException('token_invalid', 'JWK is not a valid RSA key of at least 2048 bits'); } $rsaPublicKey = self::derSequence(self::derInteger($n) . self::derInteger($e)); $algorithm = self::derSequence("\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01" . "\x05\x00"); // rsaEncryption, NULL $bitString = "\x03" . self::derLength(strlen($rsaPublicKey) + 1) . "\x00" . $rsaPublicKey; $spki = self::derSequence($algorithm . $bitString); return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), 64, "\n") . "-----END PUBLIC KEY-----\n"; } private static function derLength(int $len): string { if ($len < 0x80) { return chr($len); } $bytes = ltrim(pack('N', $len), "\x00"); return chr(0x80 | strlen($bytes)) . $bytes; } private static function derInteger(string $bytes): string { $bytes = ltrim($bytes, "\x00"); if ($bytes === '' || ord($bytes[0]) > 0x7f) { $bytes = "\x00" . $bytes; } return "\x02" . self::derLength(strlen($bytes)) . $bytes; } private static function derSequence(string $inner): string { return "\x30" . self::derLength(strlen($inner)) . $inner; } private static function pkceChallenge(string $verifier): string { return self::b64url(hash('sha256', $verifier, true)); } private static function b64url(string $bin): string { return rtrim(strtr(base64_encode($bin), '+/', '-_'), '='); } private static function b64urlDecode(string $s): ?string { if ($s === '' || !preg_match('/^[A-Za-z0-9_-]+\z/', $s) || strlen($s) % 4 === 1) { return null; } $out = base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4), true); return $out === false ? null : $out; } private static function jsonObject(?string $json): ?array { if ($json === null) { return null; } $v = json_decode($json, true); return is_array($v) && !array_is_list($v) ? $v : null; } private function assertUrl(string $url, string $field, bool $isIssuer = false): void { $p = parse_url($url); if ($url === '' || !is_array($p) || empty($p['scheme']) || empty($p['host'])) { throw new SasslyAuthException('config_invalid', "Config '$field' must be an absolute URL"); } $scheme = strtolower($p['scheme']); $host = strtolower($p['host']); $loopback = in_array($host, ['localhost', '127.0.0.1', '[::1]'], true); if ($scheme !== 'https' && !($scheme === 'http' && $this->allowInsecureHttp && $loopback)) { throw new SasslyAuthException('config_invalid', "Config '$field' must use https (http only for localhost with allow_insecure_http)"); } if (isset($p['fragment']) || ($isIssuer && isset($p['query']))) { throw new SasslyAuthException('config_invalid', "Config '$field' must not contain " . ($isIssuer ? 'a query or fragment' : 'a fragment')); } } private function log(string $level, string $message, array $context = []): void { if ($this->logger !== null) { ($this->logger)($level, $message, $context); return; } if ($level === 'warning' || $level === 'error') { error_log('[SasslyAuth] ' . $level . ': ' . $message . ($context ? ' ' . json_encode($context, JSON_UNESCAPED_SLASHES) : '')); } } }