claims du jeton */ public function validate(string $jwt): array { JWT::$leeway = (int) config('signature.addin.leeway_seconds', 120); try { $claims = (array) JWT::decode($jwt, $this->keys()); } catch (Throwable $e) { // Rotation des clés Entra : un seul nouvel essai avec des clés fraîches. Cache::forget('entra-jwks'); try { $claims = (array) JWT::decode($jwt, $this->keys()); } catch (Throwable) { throw new RuntimeException('Jeton invalide : '.$e->getMessage()); } } $tid = $claims['tid'] ?? null; if (! $tid || ! Tenant::where('entra_tenant_id', $tid)->where('active', true)->exists()) { throw new RuntimeException('Tenant non autorisé'); } $issuers = ["https://login.microsoftonline.com/{$tid}/v2.0", "https://sts.windows.net/{$tid}/"]; if (! in_array($claims['iss'] ?? null, $issuers, true)) { throw new RuntimeException('Émetteur inattendu'); } $audiences = (array) config('signature.addin.audiences'); if (! $audiences || ! in_array($claims['aud'] ?? null, $audiences, true)) { throw new RuntimeException('Audience inattendue : reçue « '.($claims['aud'] ?? 'aucune').' », attendue(s) « '.implode(', ', $audiences).' »'); } $scopes = explode(' ', (string) ($claims['scp'] ?? '')); if (! in_array(config('signature.addin.required_scope'), $scopes, true)) { throw new RuntimeException('Scope manquant : reçu « '.($claims['scp'] ?? 'aucun').' »'); } if (empty($claims['oid'])) { throw new RuntimeException('Identifiant utilisateur absent'); } return $claims; } private function keys(): array { $jwks = Cache::remember('entra-jwks', config('signature.addin.jwks_cache_seconds', 86400), function () { $res = Http::timeout(10)->get('https://login.microsoftonline.com/common/discovery/v2.0/keys'); if (! $res->successful()) { throw new RuntimeException('Clés Entra indisponibles'); } return $res->json(); }); return JWK::parseKeySet($jwks, 'RS256'); } }