openvibers/openvibe.bot Commits

Sign-in: the OAuth half is openvibe-sdk/sso (#302)

OP OpenVibers committed 7 days ago · 19f84dc0b5 · 5 files, +188 −52

The authorize URL, the PKCE S256 pair (new here: this copied layer had none),
the code, refresh and FedCM grants and the new GET /auth/health come from
openvibe-sdk/sso's createSsoClient. The cookies are the ones this site has
always used (ov_token, ov_refresh, ov_sso_hint, and the flow cookies on /auth
plus ov_oauth_verifier), the state is compared with the flow cookie in
constant time, next is still sanitized by this repo's sanitizer, the viewer middleware
and every permission check are untouched, and the redirect URI is unchanged.

The old OpenVibeAuthClient (openvibe-shared/auth-client) is gone from the
session layer: server/network.js keeps only the offline verification of the
Network user token.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 24d1f9a..20eae37 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -175,7 +175,7 @@ function createApp(opts = {}) {
// A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.
app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks, claims }));
-- app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl, log }));
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
// refuses, so these three public pages get the front page's allowances (HOME_CSP). The `profile` is the
ddiff --git a/server/network.js b/server/network.js
iindex 434443f..464e6aa 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -15,7 +15,6 @@
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* names of subjects the robot's owner added.
*/
--const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
const { createNetworkKeys, verifyUserToken } = require('openvibe-sdk/auth');
const { serviceAuth } = require('openvibe-contracts');
const { BotError } = require('./util');
@@@ -25,16 +24,8 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
return createNetworkKeys({ network: config.network.internalUrl, publicKey: config.network.publicKey || null, fetch: fetchImpl, log });
}
--/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */
++/** Offline verification of the Network user JWT (the OAuth half is openvibe-sdk/sso's, in server/web/session.js). */
function createUserAuth(config, keys) {
-- const client = new OpenVibeAuthClient({
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- redirectUri: config.oauth.redirectUri,
-- publicKey: null,
-- authBase: config.network.url,
-- internalBase: config.network.internalUrl,
-- });
/**
* Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals
* and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)
@@@ -48,7 +39,7 @@ function createUserAuth(config, keys) {
if (claims.actor_type === 'node') return null;
return claims;
}
-- return { client, verify };
++ return { verify };
}
/**
ddiff --git a/server/web/session.js b/server/web/session.js
iindex 72cd83b..70bbc73 100644
---- a/server/web/session.js
++++ b/server/web/session.js
@@@ -6,23 +6,37 @@
* https://openvibe.bot/auth/callback. The panel (built by Opus) signs people in through this; the
* control WebSocket accepts the `ov_token` cookie this sets.
*
++ * The OAuth half is openvibe-sdk/sso's (createSsoClient): the authorize URL, the PKCE S256 pair (new
++ * here: this copied layer had none), the code, refresh and FedCM grants and the new GET /auth/health.
++ * The state is compared with the flow cookie in constant time, next is still sanitized by this repo's
++ * sanitizer, the viewer resolver and every permission check are untouched, and the redirect URI is
++ * unchanged.
++ *
* GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
-- * GET /auth/callback → server-side code exchange, session cookies
++ * GET /auth/callback → server-side code exchange with the PKCE verifier, session cookies
* POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
++ * GET /auth/health → does the Network accept this OAuth client? 503 only when it refuses it
* GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
* GET /auth/me → the signed-in user (offline JWT verification)
* POST /auth/refresh → rotate tokens
++ *
++ * Cookies (host-only, the names this site has always used): ov_token (access JWT, Lax, JS-readable
++ * for the navbar), ov_refresh (httpOnly, Path=/auth), ov_sso_hint and the short-lived flow cookies on
++ * /auth (ov_oauth_state, ov_oauth_next, ov_oauth_silent plus the PKCE verifier in ov_oauth_verifier).
*/
const express = require('express');
const crypto = require('crypto');
++const { createSsoClient, failurePage } = require('openvibe-sdk/sso');
const { userPrincipal } = require('../api/auth');
const ACCESS_COOKIE = 'ov_token';
const REFRESH_COOKIE = 'ov_refresh';
const HINT_COOKIE = 'ov_sso_hint';
const STATE_COOKIE = 'ov_oauth_state';
++const VERIFIER_COOKIE = 'ov_oauth_verifier';
const NEXT_COOKIE = 'ov_oauth_next';
const SILENT_COOKIE = 'ov_oauth_silent';
++const SITE_NAME = 'OpenVibe.Bot';
function claimsToUser(claims) {
if (!claims) return null;
@@@ -56,6 +70,13 @@ function withParam(target, key, value) {
return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
}
++/** Compare two strings in constant time (the state must match the browser that started the flow). */
++function sameString(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ return x.length === y.length && x.length > 0 && crypto.timingSafeEqual(x, y);
++}
++
/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
function viewerMiddleware(userAuth) {
return (req, _res, next) => {
@@@ -68,9 +89,25 @@ function viewerMiddleware(userAuth) {
};
}
--function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {
++function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch, log = console } = {}) {
const router = express.Router();
const secure = config.cookies.secure;
++ // The OAuth half is the SDK's: the authorize URL, the PKCE S256 pair, the token grants and the health check.
++ const sdkSso = createSsoClient({
++ site: 'bot',
++ siteName: SITE_NAME,
++ baseUrl: config.baseUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ redirectUri: config.oauth.redirectUri,
++ scope: config.oauth.scope,
++ networkUrl: config.network.url,
++ networkInternalUrl: config.network.internalUrl,
++ issuer: config.network.issuer,
++ secureCookies: secure,
++ fetch: fetchImpl,
++ log,
++ });
const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
@@@ -85,44 +122,18 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
}
-- function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
--
-- async function tokenGrant(body) {
-- let lastErr = null;
-- for (const base of [config.network.internalUrl, config.network.url]) {
-- if (!base) continue;
-- try {
-- const res = await fetchImpl(`${base}/oauth/token`, {
-- method: 'POST', headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
-- signal: AbortSignal.timeout(10_000),
-- });
-- const data = await res.json().catch(() => ({}));
-- if (!res.ok) {
-- const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
-- err.status = res.status; err.error = data.error || 'invalid_grant';
-- throw err;
-- }
-- return data;
-- } catch (err) {
-- lastErr = err;
-- if (err.status && err.status < 500) throw err;
-- }
-- }
-- throw lastErr || new Error('Network unreachable');
-- }
++ function clearFlow(res) { for (const c of [STATE_COOKIE, VERIFIER_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
router.get('/login', async (req, res) => {
const silent = !!req.query.silent && req.query.silent !== '0';
if (silent && await userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
-- const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
-- let target = url;
-- if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
-- res.cookie(STATE_COOKIE, state, flowOpts());
++ const started = sdkSso.authorizeUrl({ silent });
++ res.cookie(STATE_COOKIE, started.state, flowOpts());
++ res.cookie(VERIFIER_COOKIE, started.verifier, flowOpts());
const next = sanitizeNext(req.query.next, config);
if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
-- res.redirect(target);
++ res.redirect(started.url);
});
router.get('/callback', async (req, res) => {
@@@ -136,20 +147,38 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
}
if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
const expected = req.cookies && req.cookies[STATE_COOKIE];
++ const verifier = req.cookies && req.cookies[VERIFIER_COOKIE];
clearFlow(res);
-- if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
++ if (!verifier || !sameString(state, expected)) {
return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
}
try {
-- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
++ const data = await sdkSso.tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code: String(code), code_verifier: verifier });
setSession(res, data.access_token, data.refresh_token);
return res.redirect(next);
} catch (err) {
-- console.error('[Bot] code exchange failed:', err.message);
++ if (err.status === 401 || err.error === 'invalid_client' || err.error === 'unauthorized_client') {
++ log.error(`[Bot] the Network refuses this site's OAuth client (${err.error || err.status}): check OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET`);
++ return res.status(500).type('html').send(failurePage({
++ siteName: SITE_NAME,
++ title: 'Sign-in isn\'t working on OpenVibe.Bot right now',
++ text: 'This is a problem on our side, not with your account, and it has been logged. Please try again a little later.',
++ retryHref: `/auth/login?next=${encodeURIComponent(next)}`,
++ backHref: next || '/',
++ }));
++ }
++ log.error(`[Bot] code exchange failed: ${err.message}`);
return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
}
});
++ // GET /auth/health: does the Network accept this OAuth client? 503 only when it refuses it.
++ router.get('/health', async (_req, res) => {
++ const client = await sdkSso.checkClient();
++ res.set('Cache-Control', 'no-store');
++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
++ });
++
const fedcmBody = express.json({ limit: '16kb' });
router.post('/fedcm', fedcmBody, async (req, res) => {
const { token, nonce } = req.body || {};
@@@ -160,7 +189,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
try {
-- const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ const data = await sdkSso.tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
setSession(res, data.access_token, data.refresh_token);
return res.json({ ok: true, user: data.user || claimsToUser(await userAuth.verify(data.access_token)) });
@@@ -197,7 +226,7 @@ function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch }
const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
if (!refresh) return res.status(401).json({ error: 'No refresh token' });
try {
-- const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
++ const data = await sdkSso.tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
setSession(res, data.access_token, data.refresh_token);
return res.json({ token: data.access_token, user: claimsToUser(await userAuth.verify(data.access_token)) });
} catch (err) {
ddiff --git a/test/auth.test.js b/test/auth.test.js
nnew file mode 100644
iindex 0000000..323689f
---- /dev/null
++++ b/test/auth.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * The sign-in (openvibe-sdk/sso): the authorize URL carries PKCE S256, the flow cookies are the ones this site
++ * has always used (ov_oauth_state, ov_oauth_next, ov_oauth_silent and the PKCE verifier in ov_oauth_verifier),
++ * the state is compared with the flow cookie in constant time, next is sanitized at the login and at the
++ * callback, and GET /auth/health is 503 only when the Network refuses this site's OAuth client.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const get = (p, headers = {}) => fetch(t.base + p, { redirect: 'manual', headers });
++ const cookies = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]);
++ const cookieHeader = (res) => cookies(res).join('; ');
++ const flow = (res, name) => res.headers.getSetCookie().find((c) => c.startsWith(name + '=')) || '';
++
++ await check('the sign-in starts at the Network with PKCE S256 and the flow cookies this site has always used', async () => {
++ const res = await get('/auth/login?next=%2Frobots');
++ assert.strictEqual(res.status, 302);
++ const u = new URL(res.headers.get('location'));
++ assert.deepStrictEqual([u.pathname, u.searchParams.get('client_id'), u.searchParams.get('redirect_uri'), u.searchParams.get('response_type')],
++ ['/oauth/authorize', 'bot', 'http://bot.test/auth/callback', 'code']);
++ assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256', 'PKCE S256 from openvibe-sdk/sso');
++ const challenge = u.searchParams.get('code_challenge');
++ assert.match(challenge, /^[A-Za-z0-9_-]{43}$/, 'the code challenge');
++ const state = u.searchParams.get('state');
++ assert.ok(state && state.length >= 16, 'the state');
++ assert.match(flow(res, 'ov_oauth_state'), new RegExp('^ov_oauth_state=' + state));
++ assert.match(flow(res, 'ov_oauth_verifier'), /Path=\/auth/i, 'the verifier stays in a flow cookie of its own');
++ // express encodes the cookie's value: the path arrives percent-encoded.
++ const nextCookie = flow(res, 'ov_oauth_next').split(';')[0].split('=')[1];
++ assert.strictEqual(decodeURIComponent(nextCookie), '/robots');
++ // The challenge is the SHA-256 of the verifier in the cookie: the Network checks exactly that.
++ const verifier = flow(res, 'ov_oauth_verifier').split(';')[0].split('=')[1];
++ assert.strictEqual(crypto.createHash('sha256').update(verifier).digest('base64url'), challenge);
++ });
++
++ await check('a callback whose state does not match the flow cookie is refused, and no session is set', async () => {
++ const started = await get('/auth/login');
++ const res = await get('/auth/callback?code=whatever&state=not-the-state', { Cookie: cookieHeader(started) });
++ assert.strictEqual(res.status, 400);
++ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');
++ });
++
++ await check('a callback without the verifier cookie is refused too (the code could not be exchanged)', async () => {
++ const started = await get('/auth/login');
++ const u = new URL(started.headers.get('location'));
++ const without = cookies(started).filter((c) => !c.startsWith('ov_oauth_verifier=')).join('; ');
++ const res = await get('/auth/callback?code=whatever&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: without });
++ assert.strictEqual(res.status, 400);
++ assert.ok(!res.headers.getSetCookie().some((c) => c.startsWith('ov_token=')), 'no session');
++ });
++
++ await check('a sign-in with a code the Network issued sets the session and returns to next', async () => {
++ const started = await get('/auth/login?next=%2Frobots');
++ const u = new URL(started.headers.get('location'));
++ const code = t.network.issueCode();
++ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')), { Cookie: cookieHeader(started) });
++ assert.strictEqual(res.status, 302);
++ assert.strictEqual(res.headers.get('location'), '/robots');
++ const set = res.headers.getSetCookie();
++ assert.ok(set.some((c) => c.startsWith('ov_token=')), 'the session cookie, with the name this site has always used');
++ assert.ok(set.some((c) => c.startsWith('ov_refresh=')), 'the refresh cookie');
++ });
++
++ await check('next is sanitized: off-site never, neither at the login nor at the callback', async () => {
++ const started = await get('/auth/login?next=https%3A%2F%2Fevil.com%2Fx');
++ const offSite = flow(started, 'ov_oauth_next').split(';')[0].split('=')[1];
++ assert.ok(!offSite, 'an off-site next is not kept');
++ const again = await get('/auth/login');
++ const u = new URL(again.headers.get('location'));
++ const code = t.network.issueCode();
++ const res = await get('/auth/callback?code=' + code + '&state=' + encodeURIComponent(u.searchParams.get('state')),
++ { Cookie: cookieHeader(again) + '; ov_oauth_next=//evil.com/x' });
++ assert.strictEqual(res.status, 302);
++ assert.strictEqual(res.headers.get('location'), '/', 'a tampered next goes home');
++ });
++
++ await check('GET /auth/health: 200 while the Network accepts this client', async () => {
++ const res = await get('/auth/health');
++ assert.strictEqual(res.status, 200);
++ const json = await res.json();
++ assert.deepStrictEqual([json.ok, json.client], [true, 'ok']);
++ });
++
++ const broken = await boot({ env: { OV_OAUTH_CLIENT_SECRET: 'not-the-secret' } });
++ await check('a client the Network refuses is a 503, and only then', async () => {
++ const res = await fetch(broken.base + '/auth/health', { redirect: 'manual' });
++ assert.strictEqual(res.status, 503);
++ const json = await res.json();
++ assert.deepStrictEqual([json.ok, json.client], [false, 'invalid']);
++ });
++ await broken.close();
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex eaf3e9f..798f811 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -46,6 +46,8 @@ async function startNetwork() {
let n = 100;
// Every client-credentials request Network's /oauth/token served: { client_id, audience, scope }.
const tokenCalls = [];
++ // The sign-in codes Network issued (openvibe-sdk/sso exchanges one with the PKCE verifier): code → tokens.
++ const codes = new Map();
function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {
const now = Math.floor(Date.now() / 1000);
@@@ -115,9 +117,18 @@ async function startNetwork() {
if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/api/health') return send(res, 200, { ok: true });
if (req.url === '/oauth/token' && req.method === 'POST') {
-- const body = Object.fromEntries(new URLSearchParams(await readBody(req)));
++ // The Network takes the token grant as JSON (openvibe-sdk/sso) or as a form (the service clients).
++ const text = await readBody(req);
++ const body = text.trim().startsWith('{') ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
tokenCalls.push({ client_id: body.client_id, audience: body.audience || null, scope: body.scope || null });
if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
++ // A sign-in's code: the Network answers invalid_grant for a code it never issued.
++ if (body.grant_type === 'authorization_code') {
++ const c = codes.get(String(body.code || ''));
++ if (!c) return send(res, 400, { error: 'invalid_grant', error_description: 'unknown or expired code' });
++ codes.delete(String(body.code));
++ return send(res, 200, { access_token: c.access_token, refresh_token: c.refresh_token || null, token_type: 'Bearer', expires_in: 300 });
++ }
const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
}
@@@ -175,7 +186,13 @@ async function startNetwork() {
});
const url = await listen(server);
issuer = url;
-- return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
++ /** A sign-in code the stand-in Network accepts: the code the browser comes back with. */
++ function issueCode({ access_token = null, refresh_token = 'refresh_test' } = {}) {
++ const code = `code_${crypto.randomBytes(8).toString('hex')}`;
++ codes.set(code, { access_token: access_token || signUser({ subject: 'usr_test', username: 'someone' }), refresh_token });
++ return code;
++ }
++ return { url, publicPem, signService, signUser, newUser, addNode, signNode, nodes, revokes, pairings, statuses, tokenCalls, issueCode, failPairings, users, failIdentity: (status) => { identityFailure = status; }, close: () => new Promise((r) => server.close(r)) };
}
async function startEvents() {