openvibers/openvibe.sources Commits
Merchant sign-in through openvibe-sdk/sso (#209)
Changes
@@@ -10,6 +10,7 @@## /merchant* the merchant opt-in pages (signed-in merchants add, prove and withdraw a store)## /shared/* this release's pinned kit.css, kit-web.js and theme-loader.js## /api/health, /api/ready, /release.json++## /auth/health does the Network accept its OAuth client for the merchant sign-in (503 when it refuses)## /robots.txt disallow everything## and keeps the rest off the public edge:## /api/v1/* service-to-service API (every route needs a service token): loopback only.@@@ -65,6 +66,7 @@ server {location = /api/health { proxy_pass http://127.0.0.1:4720; }location = /api/ready { proxy_pass http://127.0.0.1:4720; }location = /release.json { proxy_pass http://127.0.0.1:4720; }++ location = /auth/health { proxy_pass http://127.0.0.1:4720; }# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).location ~* ^/metrics(/|$) { return 404; }@@@ -93,6 +93,16 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outboapp.use(sourcesRouter({ db, registry, ingest, auth, limits }));app.use(itemsRouter({ db, registry, items, auth, outbox, now, limits }));++ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it. Sources has++ // no site-wide sign-in; the merchant flow is the one OAuth client, so it is the one this checks.++ if (merchantAuth) {++ app.get('/auth/health', async (_req, res) => {++ const client = await merchantAuth.checkClient();++ res.setHeader('Cache-Control', 'no-store');++ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });++ });++ }++// Merchant opt-in (plan §4.4 channel 1): the JSON read/write API, and the signed-in pages that are its front door.// /shared carries this release's own kit.css/kit-web.js/theme-loader.js, so the pages do not depend on another host.app.use('/shared', require('openvibe-shared/serve').handler());@@@ -13,13 +13,19 @@* The OAuth client (`OV_OAUTH_CLIENT_ID`, default `sources`) must be registered at OpenVibe.Network with* `<BASE_URL>/merchant/callback` as a redirect URI. Forms carry an HMAC CSRF token bound to the merchant subject;* cookies are httpOnly, SameSite=Lax, Secure in production.++ *++ * The handshake itself (authorize URL with state and PKCE S256, the code exchange, GET /auth/health) is++ * openvibe-sdk/sso's. The merchant's own session (`ov_token`, held by the pages, no refresh token) and its++ * CSRF token stay here: a merchant session is not a site-wide staff sign-in and must not share either.*/const crypto = require('crypto');const { http, ids } = require('openvibe-contracts');++const { createSsoClient } = require('openvibe-sdk/sso');const { checkCapability } = require('./auth');const ACCESS_COOKIE = 'ov_token';const STATE_COOKIE = 'ov_merchant_state';++const VERIFIER_COOKIE = 'ov_merchant_verifier';const NEXT_COOKIE = 'ov_merchant_next';const FLOW_MAX_AGE = 10 * 60 * 1000;const SESSION_MAX_AGE = 24 * 3600 * 1000;@@@ -50,22 +56,26 @@ function safeNext(value) {return n;}--/** `/oauth/authorize` on the public Network, with the state the callback checks. */--function authorizeUrl(config, state) {-- const u = new URL('/oauth/authorize', config.networkUrl);-- u.searchParams.set('client_id', config.oauth.clientId);-- u.searchParams.set('redirect_uri', config.oauth.redirectUri);-- u.searchParams.set('response_type', 'code');-- u.searchParams.set('scope', config.oauth.scope || 'profile');-- u.searchParams.set('state', state);-- return u.toString();--}--function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThis.fetch }) {const secret = config.merchant.sessionSecret;const secure = Boolean(config.cookies && config.cookies.secure);const baseCookie = { sameSite: 'lax', secure, httpOnly: true, path: '/' };const flowCookie = { sameSite: 'lax', secure, httpOnly: true, path: '/merchant', maxAge: FLOW_MAX_AGE };++ const sso = createSsoClient({++ site: 'sources',++ siteName: 'OpenVibe.Sources',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ networkUrl: config.networkUrl,++ networkInternalUrl: config.networkInternalUrl,++ issuer: config.issuer,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope || 'profile',++ secureCookies: secure,++ fetch: fetchImpl,++ log,++ });const csrfFor = (subject) => crypto.createHmac('sha256', secret).update(`sources-merchant:${subject}`).digest('base64url').slice(0, 32);@@@ -154,52 +164,35 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThiconst next = safeNext(req.query.next);const existing = await resolvePage(req);if (existing) return res.redirect(303, next);-- const state = crypto.randomBytes(16).toString('hex');-- res.cookie(STATE_COOKIE, state, flowCookie);++ // openvibe-sdk/sso: state + PKCE S256. The verifier rides the same /merchant flow cookie.++ const started = sso.authorizeUrl();++ res.cookie(STATE_COOKIE, started.state, flowCookie);++ res.cookie(VERIFIER_COOKIE, started.verifier, flowCookie);res.cookie(NEXT_COOKIE, next, flowCookie);-- return res.redirect(authorizeUrl(config, state));-- }---- async function tokenGrant(body) {-- let lastErr = null;-- for (const base of [config.networkInternalUrl, config.networkUrl]) {-- 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');++ return res.redirect(started.url);}async function callback(req, res) {const { code, state, error } = req.query;const next = safeNext(cookie(req, NEXT_COOKIE));const expected = cookie(req, STATE_COOKIE);++ const verifier = cookie(req, VERIFIER_COOKIE);res.clearCookie(STATE_COOKIE, { ...flowCookie, maxAge: undefined });++ res.clearCookie(VERIFIER_COOKIE, { ...flowCookie, maxAge: undefined });res.clearCookie(NEXT_COOKIE, { ...flowCookie, maxAge: undefined });if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}`);-- if (!code || !state || !expected || String(state).length !== String(expected).length++ // The state (constant time) and the PKCE verifier must both come from this browser.++ if (!code || !state || !expected || !verifier || String(state).length !== String(expected).length|| !crypto.timingSafeEqual(Buffer.from(String(state)), Buffer.from(String(expected)))) {return res.status(400).type('html').send('<!doctype html><meta charset="utf-8"><title>Sign-in failed</title><p>The sign-in response did not match this browser. <a href="/merchant/login">Try again</a>.</p>');}try {-- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });++ const data = await sso.tokenGrant({++ grant_type: 'authorization_code',++ redirect_uri: config.oauth.redirectUri,++ code: String(code),++ code_verifier: verifier,++ });const u = await auth.verifyUser(data.access_token);if (!u) throw Object.assign(new Error('the Network returned a token without a person subject'), { status: 401 });setSession(res, data.access_token);@@@ -215,11 +208,16 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThireturn res.redirect(303, '/merchant');}++ /** 'ok' | 'invalid' | 'unknown': does the Network accept this site's OAuth client? (GET /auth/health) */++ function checkClient() {++ return sso.checkClient();++ }++return {-- api, page, requirePage, login, callback, logout,++ api, page, requirePage, login, callback, logout, checkClient,resolvePage, checkCsrf, csrfFor, setSession, clearSession,-- ACCESS_COOKIE, STATE_COOKIE, NEXT_COOKIE, safeNext, authorizeUrl,++ ACCESS_COOKIE, STATE_COOKIE, NEXT_COOKIE, safeNext,};}--module.exports = { createMerchantAuth, authorizeUrl, safeNext, decodePayload, cookie, bearer };++module.exports = { createMerchantAuth, safeNext, decodePayload, cookie, bearer };@@@ -132,7 +132,25 @@ t('sign-in redirects to the Network OAuth client with the merchant callback', asassert.strictEqual(location.searchParams.get('client_id'), 'sources');assert.strictEqual(location.searchParams.get('redirect_uri'), 'https://sources.test/merchant/callback');assert.ok(location.searchParams.get('state'));++ assert.strictEqual(location.searchParams.get('code_challenge_method'), 'S256', 'PKCE from openvibe-sdk/sso');++ assert.match(location.searchParams.get('code_challenge'), /^[0-9A-Za-z_-]{43}$/);assert.match(r.headers.get('set-cookie') || '', /ov_merchant_state=/);++ assert.match(r.headers.get('set-cookie') || '', /ov_merchant_verifier=/);++});++++t('GET /auth/health answers whether the Network accepts the sources client', async () => {++ const { createMerchantAuth } = require('../server/merchant-auth');++ const config = {++ baseUrl: 'https://sources.test', networkUrl: 'https://network.test', networkInternalUrl: 'https://network.test',++ issuer: 'https://network.test', cookies: { secure: true }, merchant: { sessionSecret: 's' },++ oauth: { clientId: 'sources', clientSecret: '', redirectUri: 'https://sources.test/merchant/callback', scope: 'profile' },++ };++ const answer = (status, body) => createMerchantAuth({++ config, auth: { verifyUser: async () => null },++ fetchImpl: async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }),++ });++ assert.strictEqual(await answer(400, { error: 'invalid_grant' }).checkClient(), 'ok', 'a code that never existed: the client is fine');++ assert.strictEqual(await answer(401, { error: 'invalid_client' }).checkClient(), 'invalid', 'the Network refuses the client');});t('done', async () => { await svc.stop(); });
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
## /merchant* the merchant opt-in pages (signed-in merchants add, prove and withdraw a store)
## /merchant* the merchant opt-in pages (signed-in merchants add, prove and withdraw a store)
## /shared/* this release's pinned kit.css, kit-web.js and theme-loader.js
## /shared/* this release's pinned kit.css, kit-web.js and theme-loader.js
## /api/health, /api/ready, /release.json
## /api/health, /api/ready, /release.json
+## /auth/health does the Network accept its OAuth client for the merchant sign-in (503 when it refuses)
## /robots.txt disallow everything
## /robots.txt disallow everything
## and keeps the rest off the public edge:
## and keeps the rest off the public edge:
## /api/v1/* service-to-service API (every route needs a service token): loopback only.
## /api/v1/* service-to-service API (every route needs a service token): loopback only.
@@ -65,6 +66,7 @@ server {
@@ -65,6 +66,7 @@ server {
location = /api/health { proxy_pass http://127.0.0.1:4720; }
location = /api/health { proxy_pass http://127.0.0.1:4720; }
location = /api/ready { proxy_pass http://127.0.0.1:4720; }
location = /api/ready { proxy_pass http://127.0.0.1:4720; }
location = /release.json { proxy_pass http://127.0.0.1:4720; }
location = /release.json { proxy_pass http://127.0.0.1:4720; }
+ location = /auth/health { proxy_pass http://127.0.0.1:4720; }
# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
# Metrics are for host-local scrapers only (http://127.0.0.1:4720/metrics).
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
@@ -93,6 +93,16 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
@@ -93,6 +93,16 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
app.use(sourcesRouter({ db, registry, ingest, auth, limits }));
app.use(sourcesRouter({ db, registry, ingest, auth, limits }));
app.use(itemsRouter({ db, registry, items, auth, outbox, now, limits }));
app.use(itemsRouter({ db, registry, items, auth, outbox, now, limits }));
+ // GET /auth/health: does the Network accept this site's OAuth client? 503 only when it refuses it. Sources has
+ // no site-wide sign-in; the merchant flow is the one OAuth client, so it is the one this checks.
+ if (merchantAuth) {
+ app.get('/auth/health', async (_req, res) => {
+ const client = await merchantAuth.checkClient();
+ res.setHeader('Cache-Control', 'no-store');
+ return res.status(client === 'invalid' ? 503 : 200).json({ ok: client !== 'invalid', client });
+ });
+ }
+
// Merchant opt-in (plan §4.4 channel 1): the JSON read/write API, and the signed-in pages that are its front door.
// Merchant opt-in (plan §4.4 channel 1): the JSON read/write API, and the signed-in pages that are its front door.
// /shared carries this release's own kit.css/kit-web.js/theme-loader.js, so the pages do not depend on another host.
// /shared carries this release's own kit.css/kit-web.js/theme-loader.js, so the pages do not depend on another host.
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -13,13 +13,19 @@
@@ -13,13 +13,19 @@
* The OAuth client (`OV_OAUTH_CLIENT_ID`, default `sources`) must be registered at OpenVibe.Network with
* The OAuth client (`OV_OAUTH_CLIENT_ID`, default `sources`) must be registered at OpenVibe.Network with
* `<BASE_URL>/merchant/callback` as a redirect URI. Forms carry an HMAC CSRF token bound to the merchant subject;
* `<BASE_URL>/merchant/callback` as a redirect URI. Forms carry an HMAC CSRF token bound to the merchant subject;
* cookies are httpOnly, SameSite=Lax, Secure in production.
* cookies are httpOnly, SameSite=Lax, Secure in production.
+ *
+ * The handshake itself (authorize URL with state and PKCE S256, the code exchange, GET /auth/health) is
+ * openvibe-sdk/sso's. The merchant's own session (`ov_token`, held by the pages, no refresh token) and its
+ * CSRF token stay here: a merchant session is not a site-wide staff sign-in and must not share either.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { http, ids } = require('openvibe-contracts');
const { http, ids } = require('openvibe-contracts');
+const { createSsoClient } = require('openvibe-sdk/sso');
const { checkCapability } = require('./auth');
const { checkCapability } = require('./auth');
const ACCESS_COOKIE = 'ov_token';
const ACCESS_COOKIE = 'ov_token';
const STATE_COOKIE = 'ov_merchant_state';
const STATE_COOKIE = 'ov_merchant_state';
+const VERIFIER_COOKIE = 'ov_merchant_verifier';
const NEXT_COOKIE = 'ov_merchant_next';
const NEXT_COOKIE = 'ov_merchant_next';
const FLOW_MAX_AGE = 10 * 60 * 1000;
const FLOW_MAX_AGE = 10 * 60 * 1000;
const SESSION_MAX_AGE = 24 * 3600 * 1000;
const SESSION_MAX_AGE = 24 * 3600 * 1000;
@@ -50,22 +56,26 @@ function safeNext(value) {
@@ -50,22 +56,26 @@ function safeNext(value) {
return n;
return n;
}
}
-/** `/oauth/authorize` on the public Network, with the state the callback checks. */
-function authorizeUrl(config, state) {
- const u = new URL('/oauth/authorize', config.networkUrl);
- u.searchParams.set('client_id', config.oauth.clientId);
- u.searchParams.set('redirect_uri', config.oauth.redirectUri);
- u.searchParams.set('response_type', 'code');
- u.searchParams.set('scope', config.oauth.scope || 'profile');
- u.searchParams.set('state', state);
- return u.toString();
-}
-
function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThis.fetch }) {
function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThis.fetch }) {
const secret = config.merchant.sessionSecret;
const secret = config.merchant.sessionSecret;
const secure = Boolean(config.cookies && config.cookies.secure);
const secure = Boolean(config.cookies && config.cookies.secure);
const baseCookie = { sameSite: 'lax', secure, httpOnly: true, path: '/' };
const baseCookie = { sameSite: 'lax', secure, httpOnly: true, path: '/' };
const flowCookie = { sameSite: 'lax', secure, httpOnly: true, path: '/merchant', maxAge: FLOW_MAX_AGE };
const flowCookie = { sameSite: 'lax', secure, httpOnly: true, path: '/merchant', maxAge: FLOW_MAX_AGE };
+ const sso = createSsoClient({
+ site: 'sources',
+ siteName: 'OpenVibe.Sources',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ networkUrl: config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl,
+ issuer: config.issuer,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope || 'profile',
+ secureCookies: secure,
+ fetch: fetchImpl,
+ log,
+ });
const csrfFor = (subject) => crypto.createHmac('sha256', secret).update(`sources-merchant:${subject}`).digest('base64url').slice(0, 32);
const csrfFor = (subject) => crypto.createHmac('sha256', secret).update(`sources-merchant:${subject}`).digest('base64url').slice(0, 32);
@@ -154,52 +164,35 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
@@ -154,52 +164,35 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
const next = safeNext(req.query.next);
const next = safeNext(req.query.next);
const existing = await resolvePage(req);
const existing = await resolvePage(req);
if (existing) return res.redirect(303, next);
if (existing) return res.redirect(303, next);
- const state = crypto.randomBytes(16).toString('hex');
- res.cookie(STATE_COOKIE, state, flowCookie);
+ // openvibe-sdk/sso: state + PKCE S256. The verifier rides the same /merchant flow cookie.
+ const started = sso.authorizeUrl();
+ res.cookie(STATE_COOKIE, started.state, flowCookie);
+ res.cookie(VERIFIER_COOKIE, started.verifier, flowCookie);
res.cookie(NEXT_COOKIE, next, flowCookie);
res.cookie(NEXT_COOKIE, next, flowCookie);
- return res.redirect(authorizeUrl(config, state));
- }
-
- async function tokenGrant(body) {
- let lastErr = null;
- for (const base of [config.networkInternalUrl, config.networkUrl]) {
- 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');
+ return res.redirect(started.url);
}
}
async function callback(req, res) {
async function callback(req, res) {
const { code, state, error } = req.query;
const { code, state, error } = req.query;
const next = safeNext(cookie(req, NEXT_COOKIE));
const next = safeNext(cookie(req, NEXT_COOKIE));
const expected = cookie(req, STATE_COOKIE);
const expected = cookie(req, STATE_COOKIE);
+ const verifier = cookie(req, VERIFIER_COOKIE);
res.clearCookie(STATE_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(STATE_COOKIE, { ...flowCookie, maxAge: undefined });
+ res.clearCookie(VERIFIER_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(NEXT_COOKIE, { ...flowCookie, maxAge: undefined });
res.clearCookie(NEXT_COOKIE, { ...flowCookie, maxAge: undefined });
if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}`);
if (error) return res.redirect(303, `/merchant?auth_error=${encodeURIComponent(String(error).slice(0, 80))}`);
- if (!code || !state || !expected || String(state).length !== String(expected).length
+ // The state (constant time) and the PKCE verifier must both come from this browser.
+ if (!code || !state || !expected || !verifier || String(state).length !== String(expected).length
|| !crypto.timingSafeEqual(Buffer.from(String(state)), Buffer.from(String(expected)))) {
|| !crypto.timingSafeEqual(Buffer.from(String(state)), Buffer.from(String(expected)))) {
return res.status(400).type('html').send('<!doctype html><meta charset="utf-8"><title>Sign-in failed</title><p>The sign-in response did not match this browser. <a href="/merchant/login">Try again</a>.</p>');
return res.status(400).type('html').send('<!doctype html><meta charset="utf-8"><title>Sign-in failed</title><p>The sign-in response did not match this browser. <a href="/merchant/login">Try again</a>.</p>');
}
}
try {
try {
- const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
+ const data = await sso.tokenGrant({
+ grant_type: 'authorization_code',
+ redirect_uri: config.oauth.redirectUri,
+ code: String(code),
+ code_verifier: verifier,
+ });
const u = await auth.verifyUser(data.access_token);
const u = await auth.verifyUser(data.access_token);
if (!u) throw Object.assign(new Error('the Network returned a token without a person subject'), { status: 401 });
if (!u) throw Object.assign(new Error('the Network returned a token without a person subject'), { status: 401 });
setSession(res, data.access_token);
setSession(res, data.access_token);
@@ -215,11 +208,16 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
@@ -215,11 +208,16 @@ function createMerchantAuth({ config, auth, log = console, fetchImpl = globalThi
return res.redirect(303, '/merchant');
return res.redirect(303, '/merchant');
}
}
+ /** 'ok' | 'invalid' | 'unknown': does the Network accept this site's OAuth client? (GET /auth/health) */
+ function checkClient() {
+ return sso.checkClient();
+ }
+
return {
return {
- api, page, requirePage, login, callback, logout,
+ api, page, requirePage, login, callback, logout, checkClient,
resolvePage, checkCsrf, csrfFor, setSession, clearSession,
resolvePage, checkCsrf, csrfFor, setSession, clearSession,
- ACCESS_COOKIE, STATE_COOKIE, NEXT_COOKIE, safeNext, authorizeUrl,
+ ACCESS_COOKIE, STATE_COOKIE, NEXT_COOKIE, safeNext,
};
};
}
}
-module.exports = { createMerchantAuth, authorizeUrl, safeNext, decodePayload, cookie, bearer };
+module.exports = { createMerchantAuth, safeNext, decodePayload, cookie, bearer };
@@ -132,7 +132,25 @@ t('sign-in redirects to the Network OAuth client with the merchant callback', as
@@ -132,7 +132,25 @@ t('sign-in redirects to the Network OAuth client with the merchant callback', as
assert.strictEqual(location.searchParams.get('client_id'), 'sources');
assert.strictEqual(location.searchParams.get('client_id'), 'sources');
assert.strictEqual(location.searchParams.get('redirect_uri'), 'https://sources.test/merchant/callback');
assert.strictEqual(location.searchParams.get('redirect_uri'), 'https://sources.test/merchant/callback');
assert.ok(location.searchParams.get('state'));
assert.ok(location.searchParams.get('state'));
+ assert.strictEqual(location.searchParams.get('code_challenge_method'), 'S256', 'PKCE from openvibe-sdk/sso');
+ assert.match(location.searchParams.get('code_challenge'), /^[0-9A-Za-z_-]{43}$/);
assert.match(r.headers.get('set-cookie') || '', /ov_merchant_state=/);
assert.match(r.headers.get('set-cookie') || '', /ov_merchant_state=/);
+ assert.match(r.headers.get('set-cookie') || '', /ov_merchant_verifier=/);
+});
+
+t('GET /auth/health answers whether the Network accepts the sources client', async () => {
+ const { createMerchantAuth } = require('../server/merchant-auth');
+ const config = {
+ baseUrl: 'https://sources.test', networkUrl: 'https://network.test', networkInternalUrl: 'https://network.test',
+ issuer: 'https://network.test', cookies: { secure: true }, merchant: { sessionSecret: 's' },
+ oauth: { clientId: 'sources', clientSecret: '', redirectUri: 'https://sources.test/merchant/callback', scope: 'profile' },
+ };
+ const answer = (status, body) => createMerchantAuth({
+ config, auth: { verifyUser: async () => null },
+ fetchImpl: async () => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }),
+ });
+ assert.strictEqual(await answer(400, { error: 'invalid_grant' }).checkClient(), 'ok', 'a code that never existed: the client is fine');
+ assert.strictEqual(await answer(401, { error: 'invalid_client' }).checkClient(), 'invalid', 'the Network refuses the client');
});
});
t('done', async () => { await svc.stop(); });
t('done', async () => { await svc.stop(); });