[grade=B] fix(auth): generalize cross-host SSO handoff
Codex deployment review: urn:ump:sufisot7ewy33mhjude3ly6wxcjizagt42ywaicwve6qufqdtvbq Caddy path-order correction: urn:ump:o6apvvpvhynkouii4cl5ghxpprrwtilrg2dejdy2lqsupoktc6tq
This commit is contained in:
@@ -0,0 +1,84 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||||
|
|
||||||
|
function createApp({ redeem = true } = {}) {
|
||||||
|
const app = express();
|
||||||
|
const session = {
|
||||||
|
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||||
|
setCookieHostOnly: jest.fn((res) => {
|
||||||
|
res.setHeader('Set-Cookie', 'dashcaddy_session=test; Path=/; HttpOnly; Secure; SameSite=Lax');
|
||||||
|
}),
|
||||||
|
isValid: jest.fn().mockReturnValue(true),
|
||||||
|
};
|
||||||
|
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 router = createSsoRouter({
|
||||||
|
totpConfig: { enabled: true, sessionDuration: '24h' },
|
||||||
|
session,
|
||||||
|
asyncHandler,
|
||||||
|
errorResponse,
|
||||||
|
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||||
|
getAppSession: jest.fn(),
|
||||||
|
appSessionCache: new Map(),
|
||||||
|
credentialManager: { retrieve: jest.fn() },
|
||||||
|
fetchT: jest.fn(),
|
||||||
|
getServiceById: jest.fn(),
|
||||||
|
licenseManager: {
|
||||||
|
hasFeature: jest.fn().mockReturnValue(true),
|
||||||
|
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||||
|
},
|
||||||
|
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||||
|
});
|
||||||
|
app.use('/api/v1', router);
|
||||||
|
return { app, session };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('cross-host SSO exchange redirect', () => {
|
||||||
|
test('sets a host-only cookie and redirects to a relative service path', async () => {
|
||||||
|
const { app, session } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: 'one-time', return: '/settings?tab=network#dns' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(303);
|
||||||
|
expect(res.headers.location).toBe('/settings?tab=network#dns');
|
||||||
|
expect(res.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||||
|
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
'https://evil.example/phish',
|
||||||
|
'//evil.example/phish',
|
||||||
|
'/\\evil.example/phish',
|
||||||
|
])('rejects cross-origin return value %s', async (returnValue) => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: 'one-time', return: returnValue });
|
||||||
|
|
||||||
|
expect(res.status).toBe(303);
|
||||||
|
expect(res.headers.location).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the existing JSON exchange behavior when no return is supplied', async () => {
|
||||||
|
const { app } = createApp();
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: 'one-time' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toMatchObject({ success: true, authenticated: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not set a cookie or redirect for an invalid token', async () => {
|
||||||
|
const { app, session } = createApp({ redeem: false });
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/v1/auth/sso-exchange')
|
||||||
|
.query({ token: 'bad', return: '/settings' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.headers['set-cookie']).toBeUndefined();
|
||||||
|
expect(session.setCookieHostOnly).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -219,6 +219,18 @@ module.exports = function(deps) {
|
|||||||
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
return errorResponse(res, 401, 'Invalid or expired handoff token');
|
||||||
}
|
}
|
||||||
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
session.setCookieHostOnly(res, totpConfig.sessionDuration);
|
||||||
|
if (req.query.return) {
|
||||||
|
let returnPath = '/';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(req.query.return, 'https://dashcaddy.invalid');
|
||||||
|
if (parsed.origin === 'https://dashcaddy.invalid') {
|
||||||
|
returnPath = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Invalid or cross-origin return values fall back to the service root.
|
||||||
|
}
|
||||||
|
return res.redirect(303, returnPath);
|
||||||
|
}
|
||||||
ok(res, { authenticated: true });
|
ok(res, { authenticated: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+69
-69
File diff suppressed because one or more lines are too long
+20
-5
@@ -35,6 +35,24 @@
|
|||||||
if (overlay) overlay.classList.remove('show');
|
if (overlay) overlay.classList.remove('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildSsoHandoffTarget(redirect, token) {
|
||||||
|
const parsed = new URL(redirect, 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) return null;
|
||||||
|
if (!token) return parsed.toString();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// Setup digit input UX
|
// Setup digit input UX
|
||||||
const container = document.getElementById('totp-digits');
|
const container = document.getElementById('totp-digits');
|
||||||
if (container) {
|
if (container) {
|
||||||
@@ -98,11 +116,8 @@
|
|||||||
// see our session cookie no matter how it's built, so instead we
|
// see our session cookie no matter how it's built, so instead we
|
||||||
// hand it a one-time token in the URL; its login page exchanges
|
// hand it a one-time token in the URL; its login page exchanges
|
||||||
// that for its own host-only cookie via /auth/sso-exchange.
|
// that for its own host-only cookie via /auth/sso-exchange.
|
||||||
let target = redirect;
|
const target = buildSsoHandoffTarget(redirect, data.ssoToken);
|
||||||
if (data.ssoToken) {
|
if (!target) return;
|
||||||
const sep = redirect.includes('?') ? '&' : '?';
|
|
||||||
target = redirect + sep + 'dc_token=' + encodeURIComponent(data.ssoToken);
|
|
||||||
}
|
|
||||||
window.location.href = target;
|
window.location.href = target;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-3a6da6cb72';
|
const CACHE = 'dashcaddy-shell-4912a7d0d0';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
@@ -7,6 +7,23 @@ const test = require('node:test');
|
|||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'auth-gate.js'), 'utf8');
|
const source = fs.readFileSync(path.join(__dirname, '..', 'js', 'auth-gate.js'), 'utf8');
|
||||||
|
const totpSource = fs.readFileSync(path.join(__dirname, '..', 'js', 'totp-auth.js'), 'utf8');
|
||||||
|
|
||||||
|
function buildHandoffTarget(returnUrl, token, tld = '.sami') {
|
||||||
|
const start = totpSource.indexOf(' function buildSsoHandoffTarget');
|
||||||
|
const end = totpSource.indexOf('\n\n // Setup digit input UX', start);
|
||||||
|
assert.notEqual(start, -1, 'handoff builder must exist');
|
||||||
|
assert.notEqual(end, -1, 'handoff builder boundary must exist');
|
||||||
|
const functionSource = totpSource.slice(start, end);
|
||||||
|
const context = {
|
||||||
|
URL,
|
||||||
|
SITE: { tld },
|
||||||
|
window: { location: { origin: 'https://status.sami' } },
|
||||||
|
};
|
||||||
|
const sandbox = { ...context, input: returnUrl, token, result: undefined };
|
||||||
|
vm.runInNewContext(`${functionSource}\nresult = buildSsoHandoffTarget(input, token);`, sandbox);
|
||||||
|
return sandbox.result;
|
||||||
|
}
|
||||||
|
|
||||||
function capturedRedirect(returnUrl, tld = '.sami') {
|
function capturedRedirect(returnUrl, tld = '.sami') {
|
||||||
const stored = new Map();
|
const stored = new Map();
|
||||||
@@ -59,3 +76,20 @@ test('accepts relative same-origin paths and protocol-relative HTTPS private hos
|
|||||||
test('normalizes a configured TLD without a leading dot', () => {
|
test('normalizes a configured TLD without a leading dot', () => {
|
||||||
assert.equal(capturedRedirect('https://plex.sami/web/', 'sami'), 'https://plex.sami/web/');
|
assert.equal(capturedRedirect('https://plex.sami/web/', 'sami'), 'https://plex.sami/web/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builds the generic cross-host SSO landing URL and preserves the final path', () => {
|
||||||
|
assert.equal(
|
||||||
|
buildHandoffTarget('https://router.sami/config?tab=network#dns', 'one-time'),
|
||||||
|
'https://router.sami/dashcaddy-sso?token=one-time&return=%2Fconfig%3Ftab%3Dnetwork%23dns',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not create cross-host handoffs for plaintext or lookalike destinations', () => {
|
||||||
|
assert.equal(buildHandoffTarget('http://router.sami/', 'one-time'), null);
|
||||||
|
assert.equal(buildHandoffTarget('https://router.sami.evil.example/', 'one-time'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same-origin and tokenless destinations keep their direct URL', () => {
|
||||||
|
assert.equal(buildHandoffTarget('/settings', 'one-time'), 'https://status.sami/settings');
|
||||||
|
assert.equal(buildHandoffTarget('https://router.sami/config', ''), 'https://router.sami/config');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user