feat: Security
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import {
|
||||
SESSION_EXPIRED_ERROR_CODE,
|
||||
collectSessionTokenLookupUrls,
|
||||
hasValidPersistedSession
|
||||
} from './auth-session.rules';
|
||||
|
||||
describe('auth-session.rules', () => {
|
||||
const user = {
|
||||
homeSignalServerUrl: 'https://signal.example.com'
|
||||
} as Pick<User, 'homeSignalServerUrl'>;
|
||||
|
||||
it('collects home and active server urls without duplicates', () => {
|
||||
expect(collectSessionTokenLookupUrls(user, 'https://signal.example.com')).toEqual([
|
||||
'https://signal.example.com'
|
||||
]);
|
||||
expect(collectSessionTokenLookupUrls(user, 'http://localhost:3001')).toEqual([
|
||||
'http://localhost:3001',
|
||||
'https://signal.example.com'
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a valid token for a known server url', () => {
|
||||
const getToken = (url: string) => (url === 'https://signal.example.com' ? 'token-1' : null);
|
||||
|
||||
expect(hasValidPersistedSession(user, 'https://signal.example.com', getToken)).toBe(true);
|
||||
expect(hasValidPersistedSession(user, 'http://localhost:3001', getToken)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects persisted users without any valid session token', () => {
|
||||
expect(hasValidPersistedSession(user, 'https://signal.example.com', () => null)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to any stored token when preferred urls are missing', () => {
|
||||
expect(
|
||||
hasValidPersistedSession(
|
||||
{} as Pick<User, 'homeSignalServerUrl'>,
|
||||
null,
|
||||
() => null,
|
||||
() => true
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('exports a stable session-expired error code', () => {
|
||||
expect(SESSION_EXPIRED_ERROR_CODE).toBe('SESSION_EXPIRED');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
export const SESSION_EXPIRED_ERROR_CODE = 'SESSION_EXPIRED';
|
||||
|
||||
function normalizeServerUrl(serverUrl: string): string {
|
||||
return serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function collectSessionTokenLookupUrls(
|
||||
user: Pick<User, 'homeSignalServerUrl'>,
|
||||
activeServerUrl?: string | null
|
||||
): string[] {
|
||||
const urls = new Set<string>();
|
||||
|
||||
if (activeServerUrl?.trim()) {
|
||||
urls.add(normalizeServerUrl(activeServerUrl));
|
||||
}
|
||||
|
||||
if (user.homeSignalServerUrl?.trim()) {
|
||||
urls.add(normalizeServerUrl(user.homeSignalServerUrl));
|
||||
}
|
||||
|
||||
return [...urls];
|
||||
}
|
||||
|
||||
export function hasValidSessionTokenForUrls(
|
||||
urls: readonly string[],
|
||||
getToken: (serverUrl: string) => string | null
|
||||
): boolean {
|
||||
return urls.some((url) => !!getToken(url));
|
||||
}
|
||||
|
||||
export function hasValidPersistedSession(
|
||||
user: Pick<User, 'homeSignalServerUrl'>,
|
||||
activeServerUrl: string | null | undefined,
|
||||
getToken: (serverUrl: string) => string | null,
|
||||
hasAnyValidToken?: () => boolean
|
||||
): boolean {
|
||||
const preferredUrls = collectSessionTokenLookupUrls(user, activeServerUrl);
|
||||
|
||||
if (preferredUrls.length > 0 && hasValidSessionTokenForUrls(preferredUrls, getToken)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasAnyValidToken?.() ?? false;
|
||||
}
|
||||
@@ -8,4 +8,8 @@ export interface LoginResponse {
|
||||
username: string;
|
||||
/** Human-readable display name. */
|
||||
displayName: string;
|
||||
/** Opaque session token for authenticated API and WebSocket identify calls. */
|
||||
token: string;
|
||||
/** Unix timestamp (ms) when the session token expires. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user