[grade=B] feat(auth): onboard missing credentials into encrypted vault
This commit is contained in:
@@ -1,8 +1,21 @@
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
const request = require('supertest');
|
||||
const createSsoRouter = require('../routes/auth/sso-gate');
|
||||
|
||||
function createApp({ redeem = true, valid = true } = {}) {
|
||||
function loadCredentialVaultHandoff() {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '..', '..', 'status', 'js', 'credential-vault-handoff.js'),
|
||||
'utf8',
|
||||
);
|
||||
const window = { location: { origin: 'https://status.sami' } };
|
||||
vm.runInNewContext(source, { window, SITE: { tld: '.sami' }, URL });
|
||||
return window.DCCredentialVault;
|
||||
}
|
||||
|
||||
function createApp({ redeem = true, valid = true, storedCredentials = {}, dashboardHost = 'status.sami' } = {}) {
|
||||
const app = express();
|
||||
const session = {
|
||||
redeemHandoffToken: jest.fn((token) => (typeof redeem === 'function' ? redeem(token) : redeem)),
|
||||
@@ -22,14 +35,15 @@ function createApp({ redeem = true, valid = true } = {}) {
|
||||
log: { warn: jest.fn(), info: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||
getAppSession: jest.fn(),
|
||||
appSessionCache: new Map(),
|
||||
credentialManager: { retrieve: jest.fn() },
|
||||
credentialManager: { retrieve: jest.fn((key) => Promise.resolve(storedCredentials[key] || null)) },
|
||||
fetchT: jest.fn(),
|
||||
getServiceById: jest.fn(),
|
||||
getServiceById: jest.fn((id) => Promise.resolve({ id, url: `https://${id}.sami` })),
|
||||
licenseManager: {
|
||||
hasFeature: jest.fn().mockReturnValue(true),
|
||||
requirePremium: jest.fn(() => (_req, _res, next) => next()),
|
||||
},
|
||||
servicesStateManager: { read: jest.fn().mockResolvedValue([]) },
|
||||
siteConfig: { dashboardHost },
|
||||
});
|
||||
app.use('/api/v1', router);
|
||||
return { app, session };
|
||||
@@ -45,7 +59,7 @@ describe('cross-host SSO exchange redirect', () => {
|
||||
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');
|
||||
expect(session.redeemHandoffToken).toHaveBeenCalledWith('one-time', '127.0.0.1');
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -88,17 +102,18 @@ 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')
|
||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||
.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);
|
||||
expect(session.createHandoffToken).toHaveBeenCalledWith('plex.sami');
|
||||
});
|
||||
|
||||
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');
|
||||
const res = await request(app).get('/api/v1/auth/sso-handoff?serviceId=plex');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(session.createHandoffToken).not.toHaveBeenCalled();
|
||||
@@ -110,7 +125,7 @@ describe('existing-session SSO handoff', () => {
|
||||
const { app } = createApp({ redeem: redeemOnce });
|
||||
|
||||
const mint = await request(app)
|
||||
.get('/api/v1/auth/sso-handoff')
|
||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
const exchange = await request(app)
|
||||
.get('/api/v1/auth/sso-exchange')
|
||||
@@ -127,3 +142,60 @@ describe('existing-session SSO handoff', () => {
|
||||
expect(replay.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypted-vault credential onboarding', () => {
|
||||
test('app-token identifies missing credentials as a form requirement', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
.get('/api/v1/auth/app-token/plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
|
||||
expect(res.status).toBe(428);
|
||||
expect(res.body).toMatchObject({
|
||||
success: false,
|
||||
credentialsRequired: true,
|
||||
serviceId: 'plex',
|
||||
});
|
||||
});
|
||||
|
||||
test('service login page sends missing credentials to the encrypted vault form', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain("if(j.credentialsRequired){vault('plex');return}");
|
||||
expect(res.text).toContain("dashboardOrigin+'?credentials='");
|
||||
});
|
||||
|
||||
test('service login page derives the vault origin from trusted dashboard config', async () => {
|
||||
const { app } = createApp({ dashboardHost: 'dashboard.home' });
|
||||
const res = await request(app).get('/api/v1/auth/login-page?service=plex');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toContain('dashboardOrigin="https://dashboard.home"');
|
||||
});
|
||||
|
||||
test('full vault-save handoff lifecycle reaches exchange, cookie, and final service path', async () => {
|
||||
const issued = new Set(['fresh-sso-handoff-token']);
|
||||
const { app } = createApp({ redeem: (token) => issued.delete(token) });
|
||||
const mint = await request(app)
|
||||
.get('/api/v1/auth/sso-handoff?serviceId=plex')
|
||||
.set('Cookie', 'dashcaddy_session=valid-session');
|
||||
|
||||
const vault = loadCredentialVaultHandoff();
|
||||
const target = new URL(vault.buildHandoffTarget(
|
||||
'https://plex.sami/web/?direct=1#home',
|
||||
mint.body.ssoToken,
|
||||
'plex',
|
||||
));
|
||||
// The shared Caddy snippet rewrites /dashcaddy-sso to the canonical API
|
||||
// route while preserving the token and relative return query.
|
||||
const exchange = await request(app).get('/api/v1/auth/sso-exchange' + target.search);
|
||||
|
||||
expect(target.pathname).toBe('/dashcaddy-sso');
|
||||
expect(exchange.status).toBe(303);
|
||||
expect(exchange.headers.location).toBe('/web/?direct=1#home');
|
||||
expect(exchange.headers['set-cookie'][0]).toContain('dashcaddy_session=');
|
||||
expect(exchange.headers['set-cookie'][0]).not.toMatch(/Domain=/i);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user