openvibers/openvibe.sources Commits

Merchant sign-in through openvibe-sdk/sso (#209)

OP OpenVibers committed 7 days ago · 93483fffb5 · 4 files, +74 −46

* Merchant sign-in through openvibe-sdk/sso

* Serve /auth/health on the public host

Changes

ddiff --git a/deploy/nginx/sources.openvibe.network.conf b/deploy/nginx/sources.openvibe.network.conf
iindex 76f79f3..7b76a13 100644
---- a/deploy/nginx/sources.openvibe.network.conf
++++ b/deploy/nginx/sources.openvibe.network.conf
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex b6fa769..d173bf5 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -93,6 +93,16 @@ function createApp({ config, db, registry, items, ingest, scheduler, auth, outbo
app.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());
ddiff --git a/server/merchant-auth.js b/server/merchant-auth.js
iindex e5c69d6..357d359 100644
---- a/server/merchant-auth.js
++++ b/server/merchant-auth.js
@@@ -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 = globalThi
const 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 = globalThi
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 {
-- 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 };
ddiff --git a/test/merchant-pages.test.js b/test/merchant-pages.test.js
iindex 489c4af..51fade4 100644
---- a/test/merchant-pages.test.js
++++ b/test/merchant-pages.test.js
@@@ -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('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(); });