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:
+61
-1
@@ -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']);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user