fix(companion): full password+TOTP login via canonical auth.sock — definitively working
Some checks are pending
License Headers / check (push) Waiting to run

Root cause of the module 401s: the companion proxied /api/v1/auth to the
AGGREGATOR, whose minted token's jti is never written to sessions.json, so
require_jwt rejected it. Route /api/v1/auth to auth.sock (canonical, session-
recorded). Implement the real two-step login like login.html: password →
access_token, or mfa_required → prompt TOTP code → /auth/login/mfa → access_token.
Capture the token as Bearer (+ the SSO cookie login sets). Ship the nginx vhost
in deploy/. SW→v4.

Proven end-to-end (temp viewer user): login returns access_token + sets
secubox_session; podcaster/wireguard/system → 200 via both Bearer and cookie.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-15 16:40:45 +02:00
parent ecd32dde65
commit 84514dcaca
3 changed files with 79 additions and 20 deletions

View File

@ -0,0 +1,26 @@
# SecuBox Companion PWA — nginx vhost (static + same-origin API proxy).
# Deploy: copy www/ to /data/companion/www, drop this in sites-enabled, reload.
# Route companion.gk2 via HAProxy `webui_direct` (like admin.gk2) so sbxwaf does
# not strip the SSO session cookie — see issue #861 to remove that bypass.
server {
listen 9080;
server_name companion.gk2.secubox.in;
root /data/companion/www;
index index.html;
add_header X-Content-Type-Options nosniff always;
gzip on; gzip_types application/javascript text/css application/json image/svg+xml application/manifest+json;
# AUTH must hit the canonical auth.sock (records the session jti in
# sessions.json) — NOT the aggregator copy, whose jti is never recorded → 401.
location /api/v1/auth/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/auth.sock:/api/v1/auth/; }
# own-socket modules
location /api/v1/podcaster/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/podcaster.sock:/; }
location /api/v1/billets/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/billets.sock:/; }
# everything else → aggregator (auth verify, waf, system, exposure, wireguard, certs, dns…)
location /api/v1/ { include /etc/nginx/snippets/secubox-proxy.conf; proxy_pass http://unix:/run/secubox/aggregator.sock:/api/v1/; }
location = /sw.js { add_header Cache-Control "no-cache"; }
location = /manifest.webmanifest { default_type application/manifest+json; }
location ~* \.woff2$ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location / { try_files $uri $uri/ /index.html; }
}

View File

@ -9,23 +9,33 @@
import { store } from './store.js';
import { el, toast } from './ui.js';
// The box login endpoint. Adjust to the real AUTH route if it differs.
const LOGIN_PATH = '/api/v1/auth/login'; // TODO(api): confirm exact AUTH login route/shape
// The canonical AUTH routes (must hit auth.sock — the companion vhost proxies
// /api/v1/auth there — so the session jti is written to sessions.json and
// require_jwt accepts the token/cookie).
const LOGIN_PATH = '/api/v1/auth/login';
const MFA_PATH = '/api/v1/auth/login/mfa';
async function login(url, username, password) {
const base = url.replace(/\/+$/, '');
/** Step 1 password. Returns the raw box response:
* {access_token} | {mfa_required, mfa_token} | {enrollment_required} | {setup_required} */
async function loginPassword(base, username, password) {
const r = await fetch(base + LOGIN_PATH, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
credentials: 'include', body: JSON.stringify({ username, password }),
});
if (!r.ok) throw new Error(r.status === 401 ? 'Invalid credentials' : `Login failed (HTTP ${r.status})`);
// secubox-auth is SSO-cookie based (#400): on success it sets the parent-domain
// `secubox_session` cookie, which require_jwt accepts on every module — so no
// bearer token is needed. Some builds ALSO return a token in the body; keep it
// if present, otherwise rely on the cookie (the browser sends it, credentials:
// 'include'). "No token" is NOT an error here.
const d = await r.json().catch(() => ({}));
return d.token || d.access_token || d.jwt || d.sbx_token || '';
if (!r.ok) throw new Error(d.detail || (r.status === 401 ? 'Invalid credentials' : `Login failed (HTTP ${r.status})`));
return d;
}
/** Step 2 — verify a TOTP code for an enrolled account → access_token. */
async function loginMfa(base, mfaToken, code) {
const r = await fetch(base + MFA_PATH, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + mfaToken },
credentials: 'include', body: JSON.stringify({ code }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Invalid code');
return d.access_token;
}
function field(label, input) { return el('div', {}, [el('label', { text: label }), input]); }
@ -36,8 +46,20 @@ export function pairingScreen(root) {
const url = el('input', { type: 'url', placeholder: 'https://box.example.in', value: store.pairedUrl() || location.origin, inputmode: 'url' });
const user = el('input', { type: 'text', placeholder: 'username', autocomplete: 'username' });
const pass = el('input', { type: 'password', placeholder: 'password', autocomplete: 'current-password' });
const mfa = el('input', { type: 'text', placeholder: '000000', inputmode: 'numeric', autocomplete: 'one-time-code' });
const mfaField = field('2FA code', mfa); mfaField.style.display = 'none';
const pin = el('input', { type: 'password', placeholder: 'choose a local PIN (unlocks this app)', inputmode: 'numeric', autocomplete: 'off' });
const btn = el('button.btn.primary', { type: 'submit', text: 'Pair with box' });
let mfaToken = null;
const base = () => url.value.replace(/\/+$/, '');
async function finalize(token) {
// token is the box access_token; api uses it as Bearer (+ the SSO cookie
// the login also set). Sealed with the PIN.
await store.pair({ url: base(), token: token || '', pin: pin.value });
toast('Paired ✓');
resolve({ url: base(), token: token || '' });
}
const form = el('form.card', {
style: 'max-width:420px;margin:8vh auto;',
@ -45,21 +67,32 @@ export function pairingScreen(root) {
e.preventDefault();
if (!url.value || !user.value || !pass.value) return toast('URL + credentials required', 'err');
if (store.hasCrypto() && (pin.value || '').length < 4) return toast('PIN must be ≥ 4 characters', 'err');
btn.disabled = true; btn.textContent = 'Pairing…';
btn.disabled = true; btn.textContent = mfaToken ? 'Verifying…' : 'Pairing…';
try {
const token = await login(url.value, user.value, pass.value);
await store.pair({ url: url.value.replace(/\/+$/, ''), token, pin: pin.value });
toast('Paired ✓');
resolve({ url: url.value.replace(/\/+$/, ''), token });
if (mfaToken) { // step 2: TOTP code
const token = await loginMfa(base(), mfaToken, mfa.value.trim());
return finalize(token);
}
const d = await loginPassword(base(), user.value, pass.value);
if (d.access_token) return finalize(d.access_token); // password-only
if (d.mfa_required && d.mfa_token) { // enrolled → ask code
mfaToken = d.mfa_token;
mfaField.style.display = ''; mfa.value = ''; mfa.focus();
btn.textContent = 'Verify code'; btn.disabled = false;
return toast('Enter your 2FA code');
}
if (d.enrollment_required) throw new Error('Enroll 2FA in the SecuBox web UI first, then pair here.');
if (d.setup_required) throw new Error('Password change required — use the SecuBox web UI first.');
throw new Error(d.detail || 'Login failed');
} catch (err) {
toast(err.message || 'Pairing failed', 'err');
btn.disabled = false; btn.textContent = 'Pair with box';
btn.disabled = false; btn.textContent = mfaToken ? 'Verify code' : 'Pair with box';
}
},
}, [
el('div.panel-head', {}, [el('span.dot'), el('h2', { text: 'Pair SecuBox Companion' })]),
el('p.muted', { style: 'font-size:.8rem;margin-top:-4px', text: 'Outbound-only. Works over LAN, WAN, or WireGuard/MESH. Credentials are exchanged once for a token, then sealed under your PIN.' }),
field('Box URL', url), field('Username', user), field('Password', pass),
el('p.muted', { style: 'font-size:.8rem;margin-top:-4px', text: 'Outbound-only. Works over LAN, WAN, or WireGuard/MESH. One SecuBox login (with 2FA if enabled), sealed under your PIN.' }),
field('Box URL', url), field('Username', user), field('Password', pass), mfaField,
store.hasCrypto() ? field('Local PIN', pin) : el('p.muted', { text: 'WebCrypto unavailable — token kept in memory only (session).' }),
el('div.row', { style: 'margin-top:14px' }, [btn]),
]);

View File

@ -7,7 +7,7 @@
// Same-origin app assets → stale-while-revalidate. Box API (cross-origin) →
// passthrough (the page handles offline reads/queue).
const VERSION = 'sbx-companion-v3';
const VERSION = 'sbx-companion-v4';
const SHELL = [
'./', './index.html', './manifest.webmanifest',
'./styles/charte.css', './styles/fonts.css',