[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 createSsoRouter = require('../routes/auth/sso-gate');
|
||||
|
||||
function createApp({ redeem = true } = {}) {
|
||||
function createApp({ redeem = true, valid = true } = {}) {
|
||||
const app = express();
|
||||
const session = {
|
||||
redeemHandoffToken: jest.fn().mockReturnValue(redeem),
|
||||
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||
setCookieHostOnly: jest.fn((res) => {
|
||||
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 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();
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
// 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
|
||||
// (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
|
||||
// silently rejected by real browsers (.sami is an unregistered TLD, so
|
||||
// browsers treat "sami" as the effective public suffix and refuse to set
|
||||
|
||||
Reference in New Issue
Block a user