[grade=B] fix(auth): reuse valid session for cross-host SSO
This commit is contained in:
@@ -2,14 +2,15 @@ const express = require('express');
|
|||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||||
|
|
||||||
function createApp({ redeem = true } = {}) {
|
function createApp({ redeem = true, valid = true } = {}) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const session = {
|
const session = {
|
||||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||||
setCookieHostOnly: jest.fn((res) => {
|
setCookieHostOnly: jest.fn((res) => {
|
||||||
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||||
}),
|
}),
|
||||||
isValid: jest.fn().mockReturnValue(true),
|
isValid: jest.fn().mockReturnValue(valid),
|
||||||
|
createHandoffToken: jest.fn().mockReturnValue('fresh-sso-handoff-token'),
|
||||||
};
|
};
|
||||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
const errorResponse = (res, status, message, extra = {}) => res.status(status).json({ success: false, error: message, ...extra });
|
||||||
@@ -82,3 +83,47 @@ describe('cross-host SSO exchange redirect', () => {
|
|||||||
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('existing-session SSO handoff', () => {
|
||||||
|
test('mints a handoff token without asking for TOTP again', async () => {
|
||||||
|
const { app, session } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ success: true, ssoToken: 'fresh-sso-handoff-token' });
|
||||||
|
expect(session.createHandoffToken).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to mint a handoff token without a valid session', async () => {
|
||||||
|
const { app, session } = createApp({ valid: false });
|
||||||
|
const res = await request(app).get('/api/v1/auth/sso-handoff');
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('completes the full mint, exchange, cookie, redirect lifecycle', async () => {
|
||||||
|
const issued = new Set(['fresh-sso-handoff-token']);
|
||||||
|
const redeemOnce = (token) => issued.delete(token);
|
||||||
|
const { app } = createApp({ redeem: redeemOnce });
|
||||||
|
|
||||||
|
const mint = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-handoff')
|
||||||
|
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||||
|
const exchange = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||||
|
|
||||||
|
expect(exchange.status).toBe(303);
|
||||||
|
expect(exchange.headers.location).toBe('/web/');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||||
|
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
|
|
||||||
|
const replay = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: mint.body.ssoToken, return: '/web/' });
|
||||||
|
expect(replay.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -203,8 +203,22 @@ module.exports = function(deps) {
|
|||||||
}
|
}
|
||||||
}, 'auth-app-token'));
|
}, 'auth-app-token'));
|
||||||
|
|
||||||
|
// A browser that already has a valid status.sami session must not be asked
|
||||||
|
// for TOTP again just because it opened another private-TLD service host.
|
||||||
|
// Mint a fresh one-time token that the target host can exchange for its own
|
||||||
|
// host-only cookie. This route is intentionally session-protected both by
|
||||||
|
// the global middleware and here (defence in depth).
|
||||||
|
router.get('/auth/sso-handoff', (req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
if (!session.isValid(req)) {
|
||||||
|
return errorResponse(res, 401, 'Session expired or invalid');
|
||||||
|
}
|
||||||
|
ok(res, { ssoToken: session.createHandoffToken() });
|
||||||
|
});
|
||||||
|
|
||||||
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
// Cross-subdomain SSO handoff: exchanges a short-lived single-use token
|
||||||
// (minted by /totp/verify) for a HOST-ONLY session cookie on whichever
|
// (minted by /totp/verify or /auth/sso-handoff) for a HOST-ONLY session
|
||||||
|
// cookie on whichever
|
||||||
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
// *.sami origin calls this. Needed because Domain=.sami cookies are
|
||||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||||
// browsers treat "sami" as the effective public suffix and refuse to set
|
// browsers treat "sami" as the effective public suffix and refuse to set
|
||||||
|
|||||||
Vendored
+77
-77
File diff suppressed because one or more lines are too long
+42
-2
@@ -267,6 +267,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSsoHandoffTarget(returnUrl, token) {
|
||||||
|
const parsed = new URL(returnUrl, window.location.origin);
|
||||||
|
if (parsed.origin === window.location.origin) return parsed.toString();
|
||||||
|
|
||||||
|
const suffix = SITE.tld.startsWith('.') ? SITE.tld : `.${SITE.tld}`;
|
||||||
|
const isPrivateHost = parsed.hostname === suffix.slice(1) || parsed.hostname.endsWith(suffix);
|
||||||
|
if (parsed.protocol !== 'https:' || !isPrivateHost || !token) return null;
|
||||||
|
|
||||||
|
const returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
parsed.pathname = '/dashcaddy-sso';
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.searchParams.set('token', token);
|
||||||
|
parsed.searchParams.set('return', returnPath);
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeExistingSession(returnUrl) {
|
||||||
|
if (!returnUrl || !isAllowedReturnUrl(returnUrl)) return false;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/auth/sso-handoff', {
|
||||||
|
credentials: 'include',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
if (!res.ok) return false;
|
||||||
|
const data = await res.json();
|
||||||
|
const target = data.success && buildSsoHandoffTarget(returnUrl, data.ssoToken);
|
||||||
|
if (!target) return false;
|
||||||
|
try { sessionStorage.removeItem('totp_redirect'); } catch (_) {}
|
||||||
|
window.location.replace(target);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.get('auth') === 'required') {
|
if (urlParams.get('auth') === 'required') {
|
||||||
// Preserve the gated service destination so submitTotpCode() can append
|
// Preserve the gated service destination so submitTotpCode() can append
|
||||||
@@ -277,8 +313,12 @@
|
|||||||
}
|
}
|
||||||
// Clean URL — happens after we've captured the redirect
|
// Clean URL — happens after we've captured the redirect
|
||||||
window.history.replaceState({}, '', window.location.pathname);
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
// Show on next tick so the DOM (the .totp-card) is ready
|
// Reuse the valid status.sami session first. Only show the TOTP/provider
|
||||||
setTimeout(show, 0);
|
// challenge when that session is genuinely absent or expired.
|
||||||
|
setTimeout(async () => {
|
||||||
|
if (await resumeExistingSession(returnUrl)) return;
|
||||||
|
await show();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose for hot-trigger from other modules (e.g. logout)
|
// Expose for hot-trigger from other modules (e.g. logout)
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-a24ef15882';
|
const CACHE = 'dashcaddy-shell-5dbd809d3b';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -93,3 +93,41 @@ test('same-origin and tokenless destinations keep their direct URL', () => {
|
|||||||
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||||
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an existing status.sami session returns to a service without another TOTP prompt', async () => {
|
||||||
|
const query = new URLSearchParams({ auth: 'required', return: 'https://plex.sami/web/' });
|
||||||
|
let scheduled;
|
||||||
|
let redirected;
|
||||||
|
const location = {
|
||||||
|
origin: 'https://status.sami',
|
||||||
|
pathname: '/',
|
||||||
|
search: `?${query.toString()}`,
|
||||||
|
replace(value) { redirected = value; },
|
||||||
|
};
|
||||||
|
const context = {
|
||||||
|
URL,
|
||||||
|
URLSearchParams,
|
||||||
|
SITE: { tld: '.sami' },
|
||||||
|
sessionStorage: { setItem() {} },
|
||||||
|
document: { getElementById() { return null; } },
|
||||||
|
setTimeout(fn) { scheduled = fn; },
|
||||||
|
console,
|
||||||
|
fetch: async (url) => {
|
||||||
|
assert.equal(url, '/api/v1/auth/sso-handoff');
|
||||||
|
return { ok: true, json: async () => ({ success: true, ssoToken: 'existing-session-token' }) };
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
location,
|
||||||
|
history: { replaceState() {} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
context.window.window = context.window;
|
||||||
|
vm.runInNewContext(source, context, { filename: 'auth-gate.js' });
|
||||||
|
|
||||||
|
assert.equal(typeof scheduled, 'function');
|
||||||
|
await scheduled();
|
||||||
|
assert.equal(
|
||||||
|
redirected,
|
||||||
|
'https://plex.sami/dashcaddy-sso?token=existing-session-token&return=%2Fweb%2F',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user