376 lines
21 KiB
JavaScript
376 lines
21 KiB
JavaScript
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
|
|
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
|
|
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
|
|
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
|
|
// cause: oauth4webapi's errors carry token bodies and claims there.
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { join, relative, isAbsolute, resolve } from 'node:path';
|
|
import { checkConsole } from './sso-config.mjs';
|
|
|
|
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
|
|
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
|
|
// installs nothing). A real copy installs it from the root lock.
|
|
let oauth;
|
|
|
|
export const SESSION_COOKIE = '__Host-mosaic_session';
|
|
export const LOGIN_COOKIE = '__Host-mosaic_login';
|
|
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
|
|
const LOGIN_MAX_AGE = 600;
|
|
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
|
|
const MAX_SESSIONS = 512, MAX_LOGINS = 128, MAX_ACCOUNT_SESSIONS = 8;
|
|
const FETCH_TIMEOUT = 10000;
|
|
const RETRY_CAP = 300000;
|
|
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'user-revoked', 'account-revoked', 'discovery-failed', 'jwks-failed']);
|
|
|
|
const sha256 = value => createHash('sha256').update(value).digest('hex');
|
|
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
|
|
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
|
|
|
|
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
|
|
// start calls this. A refusal names the provider and the check, never the path's contents.
|
|
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
|
|
const where = `console.sso provider ${provider.id} client secret slot`;
|
|
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
|
|
let st;
|
|
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
|
|
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
|
|
let fd, real, text;
|
|
try {
|
|
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
st = fstatSync(fd);
|
|
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
|
|
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
|
|
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
|
|
real = realpathSync(file);
|
|
for (const root of [repo, checkout, copies].filter(Boolean)) {
|
|
let base;
|
|
try { base = realpathSync(root); } catch { base = resolve(root); }
|
|
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
|
|
}
|
|
text = readFileSync(fd, 'utf8');
|
|
} catch (err) {
|
|
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
|
|
} finally {
|
|
if (fd !== undefined) closeSync(fd);
|
|
}
|
|
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
|
|
return secret;
|
|
}
|
|
|
|
export function cookies(req, name) {
|
|
const found = [];
|
|
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
const at = part.indexOf('=');
|
|
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
|
|
}
|
|
return found;
|
|
}
|
|
|
|
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
|
|
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
|
|
function usableKey(jwk, alg) {
|
|
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
|
|
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
|
|
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
|
|
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
|
|
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
|
|
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
|
|
if (crv && jwk.crv !== crv) return false;
|
|
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
|
|
return true;
|
|
}
|
|
|
|
class Unreachable extends Error {}
|
|
class Refused extends Error {}
|
|
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
|
|
|
|
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
|
|
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
|
|
async function discover(p, fetchOptions) {
|
|
let response, text;
|
|
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
|
try {
|
|
response = await oauth.discoveryRequest(new URL(p.issuer), options());
|
|
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
|
|
text = await response.text();
|
|
JSON.parse(text);
|
|
} catch { throw new Unreachable(); }
|
|
let as;
|
|
try {
|
|
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
|
|
} catch { throw new Refused(); }
|
|
if (as.issuer !== p.issuer) throw new Refused();
|
|
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
|
|
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
|
|
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
|
|
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
|
|
let jwks;
|
|
try {
|
|
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
|
|
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
|
if (res.status !== 200) throw new Error();
|
|
jwks = JSON.parse(await res.text());
|
|
} catch { throw new Unreachable(); }
|
|
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
|
|
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
|
|
if (!usable.length) throw new Refused();
|
|
return as;
|
|
}
|
|
|
|
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
|
|
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
|
|
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
|
|
// `users` (FED R6, #1800) is consoleAccess() from access.mjs, or null: a subject that is not the
|
|
// owner's signs in as the access-file user with a live grant, and each request checks it again.
|
|
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000, users = null, accounts = null } = {}) {
|
|
checkConsole({ sso: config });
|
|
oauth ??= await import('oauth4webapi');
|
|
const publicOrigin = config.publicOrigin;
|
|
const publicHost = new URL(publicOrigin).host;
|
|
const redirectUri = `${publicOrigin}/auth/callback`;
|
|
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
|
|
const sessions = new Map(), logins = new Map();
|
|
const timers = new Set();
|
|
let closed = false;
|
|
const refuse = (code, provider, extra = '') => {
|
|
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
|
|
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
|
|
};
|
|
|
|
const providers = new Map(config.providers.map(p => {
|
|
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
|
|
return [p.id, {
|
|
config: p, state: 'unreachable', as: null, jwks: {},
|
|
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
|
|
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
|
|
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
|
|
}];
|
|
}));
|
|
|
|
async function attempt(entry, atStart) {
|
|
try {
|
|
entry.as = await discover(entry.config, fetchOptions);
|
|
entry.jwks = {};
|
|
entry.state = 'ready';
|
|
} catch (err) {
|
|
if (err instanceof Refused) {
|
|
entry.state = 'refused';
|
|
refuse('discovery-failed', entry.config.id);
|
|
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
|
|
return;
|
|
}
|
|
entry.state = 'unreachable';
|
|
refuse('discovery-failed', entry.config.id);
|
|
throw err;
|
|
}
|
|
}
|
|
function retry(entry, delay) {
|
|
if (closed) return;
|
|
const timer = setTimeout(async () => {
|
|
timers.delete(timer);
|
|
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
|
|
}, delay);
|
|
timer.unref();
|
|
timers.add(timer);
|
|
}
|
|
for (const entry of providers.values()) {
|
|
try { await attempt(entry, true); } catch (err) {
|
|
if (!(err instanceof Unreachable)) throw err;
|
|
retry(entry, retryFirst);
|
|
}
|
|
}
|
|
|
|
const page = (res, status, title, html, headers = {}) => {
|
|
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
|
|
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
|
|
};
|
|
const redirect = (res, location, setCookie = []) => {
|
|
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
|
|
res.end();
|
|
};
|
|
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
|
|
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
|
|
const remember = (map, key, value, cap) => {
|
|
while (map.size >= cap) map.delete(map.keys().next().value);
|
|
map.set(key, value);
|
|
};
|
|
|
|
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
|
|
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
|
|
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
|
|
function returnURL(ret) {
|
|
const home = `${publicOrigin}/`;
|
|
try {
|
|
if (typeof ret !== 'string' || !ret) return home;
|
|
const u = new URL(ret, publicOrigin);
|
|
if (u.origin !== publicOrigin) return home;
|
|
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
|
|
return out.origin === publicOrigin ? out.href : home;
|
|
} catch { return home; }
|
|
}
|
|
|
|
async function start(res, entry, ret) {
|
|
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
|
|
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
|
|
const id = randomBytes(32).toString('base64url');
|
|
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
|
|
const url = new URL(entry.as.authorization_endpoint);
|
|
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
|
|
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
|
|
}
|
|
|
|
function login(res, query) {
|
|
const asked = query.get('provider');
|
|
const ret = query.get('return');
|
|
if (asked === null && providers.size > 1) {
|
|
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
|
|
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
|
|
}
|
|
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
|
|
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
|
|
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
|
|
return start(res, entry, ret);
|
|
}
|
|
|
|
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
|
|
async function callback(res, query, req) {
|
|
const clear = [expire(LOGIN_COOKIE)];
|
|
const ids = cookies(req, LOGIN_COOKIE);
|
|
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
|
const record = key === null ? undefined : logins.get(key);
|
|
if (key !== null) logins.delete(key);
|
|
// The provider comes from the record, never from the query.
|
|
const entry = record && providers.get(record.provider);
|
|
if (!record || record.expires <= Date.now() || !entry) {
|
|
refuse('login-record-missing', record?.provider ?? 'none');
|
|
return failed(res, clear);
|
|
}
|
|
const pid = entry.config.id;
|
|
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
|
|
let params;
|
|
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
|
|
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
|
|
return failed(res, clear);
|
|
}
|
|
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
|
|
let response;
|
|
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
|
|
refuse('grant-failed', pid); return failed(res, clear);
|
|
}
|
|
let result, claims;
|
|
try {
|
|
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
|
|
claims = oauth.getValidatedIdTokenClaims(result);
|
|
} catch (err) {
|
|
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
|
|
return failed(res, clear);
|
|
}
|
|
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
|
|
// Response object. Its throw ends the callback.
|
|
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
|
|
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
|
|
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
|
|
return failed(res, clear);
|
|
}
|
|
// Step 6: our own checks on top of the library's.
|
|
let header;
|
|
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
|
|
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
|
|
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
|
|
// Account authority and the legacy root grant are independent. A configured
|
|
// owner subject alone is never an account and cannot bypass a refused store.
|
|
let account = null;
|
|
try { account = accounts?.load().users.find(u => u.state === 'active' && u.subjects.some(s => s.provider === pid && s.sub === claims.sub)) ?? null; } catch { /* fail closed for accounts */ }
|
|
const user = typeof claims.sub === 'string' ? users?.find({ provider: pid, sub: claims.sub }) ?? null : null;
|
|
const rootOwner = account?.instanceRole === 'operator' && entry.subjects.has(claims.sub);
|
|
if (!account && user === null) {
|
|
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
|
|
return failed(res, clear);
|
|
}
|
|
const name = account?.name ?? user;
|
|
const id = randomBytes(32).toString('base64url'), now = Date.now();
|
|
if (account) {
|
|
const own = [...sessions].filter(([, s]) => s.account === account.id);
|
|
while (own.length >= MAX_ACCOUNT_SESSIONS) sessions.delete(own.shift()[0]);
|
|
}
|
|
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name,
|
|
...(account ? { account: account.id, instanceRole: account.instanceRole, authGen: account.authGen } : {}),
|
|
rootOwner, ...(user !== null ? { user } : {}), idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
|
|
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
|
|
}
|
|
|
|
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
|
|
function session(req, { touch = false } = {}) {
|
|
const ids = cookies(req, SESSION_COOKIE);
|
|
if (ids.length !== 1) return null;
|
|
const key = sha256(ids[0]);
|
|
const s = sessions.get(key);
|
|
if (!s) return null;
|
|
const now = Date.now();
|
|
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
|
|
if (s.account !== undefined) {
|
|
let account;
|
|
try { account = accounts?.load().users.find(u => u.id === s.account); } catch { /* refuse */ }
|
|
if (!account || account.state !== 'active' || account.authGen !== s.authGen || account.instanceRole !== s.instanceRole || !account.subjects.some(x => x.provider === s.provider && x.sub === s.sub)) {
|
|
refuse('account-revoked', s.provider);
|
|
// Never reacquire account authority on this cookie, even after re-enable.
|
|
delete s.account; delete s.instanceRole; delete s.authGen; s.rootOwner = false;
|
|
}
|
|
}
|
|
if (s.user !== undefined && !users?.check(s)) {
|
|
refuse('user-revoked', s.provider, ` user=${JSON.stringify(s.user)}`);
|
|
delete s.user;
|
|
}
|
|
if (s.account === undefined && s.user === undefined) { sessions.delete(key); return null; }
|
|
if (touch) s.seen = now;
|
|
return s;
|
|
}
|
|
|
|
function logout(req, res) {
|
|
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
|
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
|
|
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
|
|
}
|
|
const ids = cookies(req, SESSION_COOKIE);
|
|
const key = ids.length === 1 ? sha256(ids[0]) : null;
|
|
const s = key === null ? undefined : sessions.get(key);
|
|
if (key !== null) sessions.delete(key);
|
|
const entry = s && providers.get(s.provider);
|
|
let location = `${publicOrigin}/auth/signed-out`;
|
|
if (entry?.as?.end_session_endpoint) {
|
|
const url = new URL(entry.as.end_session_endpoint);
|
|
url.searchParams.set('id_token_hint', s.idToken);
|
|
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
|
|
url.searchParams.set('client_id', entry.config.clientId);
|
|
location = url.href;
|
|
}
|
|
return redirect(res, location, [expire(SESSION_COOKIE)]);
|
|
}
|
|
|
|
// `/auth/*` on the human listener; the caller has already checked the Host.
|
|
async function handle(req, res, path, search) {
|
|
const query = new URLSearchParams(search);
|
|
if (path === '/auth/logout') {
|
|
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
|
|
return logout(req, res);
|
|
}
|
|
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
|
|
if (path === '/auth/login') return login(res, query);
|
|
if (path === '/auth/callback') return callback(res, query, req);
|
|
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
|
|
return page(res, 404, 'Not found', '');
|
|
}
|
|
|
|
return {
|
|
mode: 'accounts', publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
|
|
chatGrants: session => users?.chatGrants?.(session) ?? [],
|
|
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
|
|
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
|
|
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
|
|
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
|
|
};
|
|
}
|