feat(auth): recover cross-signal authorization with provision secrets

A client that could not authorize against a foreign signal server was
redirected into a dead end with no way to retry, so servers joined from
another signal route became unreachable.

The home server now stores a per-user provision secret, clients keep it in
their own store, and a recovery service records why authorization failed per
server URL. Invite, server browser, and chat room surface that reason and
offer a retry instead of silently redirecting.
This commit is contained in:
2026-08-14 03:19:29 +02:00
parent e49b3ec112
commit f9e8538c80
37 changed files with 1877 additions and 244 deletions
@@ -5,10 +5,22 @@ import {
} from 'vitest';
import {
ProvisionUsernameCollisionError,
buildProvisionPlan,
buildProvisionUsernameCandidates,
shortHomeUserId
shortHomeUserId,
shouldAdoptCanonicalSecret
} from './signal-server-provision.rules';
const HOME_USER_ID = 'a3f2b1c4-5678-90ab-cdef-1234567890ab';
function plan(canonical: string | null, deviceLocal: string | null) {
return buildProvisionPlan({
preferredUsername: 'alice',
homeUserId: HOME_USER_ID,
secrets: { canonical, deviceLocal }
}).map((attempt) => `${attempt.kind}:${attempt.username}:${attempt.secretSource}`);
}
describe('signal-server-provision.rules', () => {
it('derives a stable short id from a home user uuid', () => {
expect(shortHomeUserId('a3f2b1c4-5678-90ab-cdef-1234567890ab')).toBe('a3f2b1');
@@ -26,6 +38,54 @@ describe('signal-server-provision.rules', () => {
).toEqual(['alice-a3f2b1']);
});
it('signs in to the preferred username before ever trying the suffixed one', () => {
expect(plan('canonical-secret', null)).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('tries the legacy device secret before giving the username up as someone else\'s', () => {
expect(plan('canonical-secret', 'legacy-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'login:alice:device-local',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical',
'login:alice-a3f2b1:device-local'
]);
});
it('never registers a suffixed duplicate without a canonical secret', () => {
expect(plan(null, 'legacy-secret')).toEqual([
'register:alice:device-local',
'login:alice:device-local',
'login:alice-a3f2b1:device-local'
]);
});
it('produces no attempts when no secret is available at all', () => {
expect(plan(null, null)).toEqual([]);
});
it('does not repeat the canonical secret as a legacy attempt', () => {
expect(plan('same-secret', 'same-secret')).toEqual([
'register:alice:canonical',
'login:alice:canonical',
'register:alice-a3f2b1:canonical',
'login:alice-a3f2b1:canonical'
]);
});
it('adopts the canonical secret only after a legacy login', () => {
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, 'canonical')).toBe(true);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'canonical' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'register', secretSource: 'device-local' }, 'canonical')).toBe(false);
expect(shouldAdoptCanonicalSecret({ kind: 'login', secretSource: 'device-local' }, null)).toBe(false);
});
it('exposes attempted usernames on collision errors', () => {
const error = new ProvisionUsernameCollisionError('https://signal.example.com', ['alice', 'alice-a3f2b1']);