Files
immich-folio/lib/__tests__/auth.test.ts
Ralksta 89a8fac564 release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
2026-09-23 12:09:08 +02:00

327 lines
12 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from 'vitest';
import crypto from 'crypto';
// Mock config to provide a predictable API key and subpage config
vi.mock('@/lib/config', () => ({
getConfig: () => ({
immich: { apiKey: 'test-api-key-for-auth-tests' },
authSecret: 'test-auth-secret-32-chars-long-min',
standaloneAlbums: ['00000000-0000-0000-0000-000000000004'],
albumPasswords: { '00000000-0000-0000-0000-000000000005': 'albumpass' },
subpages: [
{
name: 'Private',
slug: 'private',
albumIds: [
'00000000-0000-0000-0000-000000000001',
'00000000-0000-0000-0000-000000000004',
'00000000-0000-0000-0000-000000000005',
],
password: 'secret123',
},
{
name: 'Public',
slug: 'public',
albumIds: ['00000000-0000-0000-0000-000000000002'],
// no password
},
{
name: 'Hashed',
slug: 'hashed',
albumIds: ['00000000-0000-0000-0000-000000000003'],
// scrypt hash of 'scrypted-pass' with a fixed salt — exercises the
// verifyScrypt path rather than the deprecated plaintext fallback.
password:
'scrypt:0123456789abcdef0123456789abcdef:4850eef18a09612ef11fdcd943bf64ce98d0eca2b0f677f9c0c51d1f648b8a101cf7ea36b6f94cfc05236b2fda6fffabd0929dc7e14c07626522fba68bf26de5',
},
],
}),
}));
import {
isProtected,
authenticate,
isAuthenticated,
isAlbumReachable,
withoutLockedAlbums,
findSubpageBySlug,
isHttpsRequest,
} from '@/lib/auth';
describe('findSubpageBySlug', () => {
it('returns the subpage config for a known slug', () => {
const sp = findSubpageBySlug('private');
expect(sp).toBeDefined();
expect(sp?.name).toBe('Private');
});
it('returns undefined for an unknown slug', () => {
expect(findSubpageBySlug('nonexistent')).toBeUndefined();
});
});
describe('isProtected', () => {
it('returns true for a password-protected subpage', () => {
expect(isProtected('private')).toBe(true);
});
it('returns false for a non-protected subpage', () => {
expect(isProtected('public')).toBe(false);
});
it('returns false for an unknown slug', () => {
expect(isProtected('unknown')).toBe(false);
});
});
describe('authenticate', () => {
it('returns a Set-Cookie string for the correct password', async () => {
const cookie = await authenticate('private', 'secret123');
expect(cookie).toBeTruthy();
expect(cookie).toContain('lb_auth_private=');
expect(cookie).toContain('HttpOnly');
expect(cookie).toContain('SameSite=Strict');
});
it('returns null for the wrong password', async () => {
await expect(authenticate('private', 'wrongpass')).resolves.toBeNull();
});
it('returns null for a non-protected slug', async () => {
await expect(authenticate('public', 'anything')).resolves.toBeNull();
});
it('accepts a correct scrypt-hashed password', async () => {
await expect(authenticate('hashed', 'scrypted-pass')).resolves.toContain('lb_auth_hashed=');
});
it('rejects a wrong password against a scrypt hash', async () => {
await expect(authenticate('hashed', 'wrong')).resolves.toBeNull();
});
});
/**
* scryptSync costs ~23ms of blocked main thread per call. The /api/auth limit
* is keyed on the client IP, and at the default TRUSTED_PROXY_HOPS=0 that IP
* comes from a spoofable header — so unlimited buckets, each buying 23ms of
* dead process. The async variant runs on the libuv threadpool instead.
*/
describe('password hashing does not stall the process', () => {
it('never calls the synchronous scrypt', async () => {
const sync = vi.spyOn(crypto, 'scryptSync');
await authenticate('hashed', 'scrypted-pass');
expect(sync).not.toHaveBeenCalled();
sync.mockRestore();
});
it('leaves timers free to fire while hashing', async () => {
let ticks = 0;
const interval = setInterval(() => ticks++, 2);
await authenticate('hashed', 'scrypted-pass');
clearInterval(interval);
// The synchronous variant yields to nothing for the whole hash, so a 2ms
// interval gets at most one tick after it finishes. Async leaves room for
// many. A slower machine makes the hash longer, i.e. more ticks — so this
// fails on a blocked loop, not on a busy one.
expect(ticks).toBeGreaterThan(3);
});
});
describe('isAuthenticated', () => {
it('returns true when a valid cookie is present', async () => {
// First authenticate to get the expected token
const cookie = (await authenticate('private', 'secret123'))!;
const token = cookie.split('=')[1].split(';')[0];
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
expect(isAuthenticated('private', getCookie)).toBe(true);
});
it('returns false when no cookie is present', () => {
const getCookie = () => undefined;
expect(isAuthenticated('private', getCookie)).toBe(false);
});
it('returns false for an invalid token', () => {
const getCookie = (name: string) => (name === 'lb_auth_private' ? 'badtoken' : undefined);
expect(isAuthenticated('private', getCookie)).toBe(false);
});
it('returns true for non-protected pages (no auth needed)', () => {
const getCookie = () => undefined;
expect(isAuthenticated('public', getCookie)).toBe(true);
});
});
describe('token expiry', () => {
async function tokenFor(slug: string, password: string): Promise<string> {
return (await authenticate(slug, password))!.split('=')[1].split(';')[0];
}
afterEach(() => {
vi.useRealTimers();
});
it('rejects a token once its embedded expiry has passed', async () => {
const token = await tokenFor('private', 'secret123');
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
expect(isAuthenticated('private', getCookie)).toBe(true);
// Previously the 24h lifetime lived only in the cookie's Max-Age, so a
// captured token replayed via curl worked forever.
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 25 * 60 * 60 * 1000);
expect(isAuthenticated('private', getCookie)).toBe(false);
});
it('still accepts a token just before it expires', async () => {
const token = await tokenFor('private', 'secret123');
const getCookie = (name: string) => (name === 'lb_auth_private' ? token : undefined);
vi.useFakeTimers();
vi.setSystemTime(Date.now() + 23 * 60 * 60 * 1000);
expect(isAuthenticated('private', getCookie)).toBe(true);
});
it('rejects a token whose expiry was tampered with', async () => {
const token = await tokenFor('private', 'secret123');
const [, sig] = token.split('.');
const farFuture = Date.now() + 365 * 24 * 60 * 60 * 1000;
// The expiry is covered by the HMAC, so extending it invalidates the token.
const getCookie = (name: string) =>
name === 'lb_auth_private' ? `${farFuture}.${sig}` : undefined;
expect(isAuthenticated('private', getCookie)).toBe(false);
});
it('rejects malformed and legacy tokens without throwing', () => {
for (const value of ['', '.', 'abc', 'abc.def', `${Date.now() + 1000}.`, 'deadbeef']) {
const getCookie = (name: string) => (name === 'lb_auth_private' ? value : undefined);
expect(() => isAuthenticated('private', getCookie)).not.toThrow();
expect(isAuthenticated('private', getCookie)).toBe(false);
}
});
it('sets a cookie Max-Age consistent with the embedded expiry', async () => {
const cookie = (await authenticate('private', 'secret123'))!;
const maxAge = Number(cookie.match(/Max-Age=(\d+)/)![1]);
const exp = Number(cookie.split('=')[1].split(';')[0].split('.')[0]);
expect(Math.abs(exp - (Date.now() + maxAge * 1000))).toBeLessThan(2000);
});
});
/**
* A route handler is reached without ever passing through the page that would
* have asked for the subpage password, so it has to ask the same question
* itself (#475).
*/
describe('isAlbumReachable', () => {
const IN_PRIVATE = '00000000-0000-0000-0000-000000000001';
const IN_PUBLIC = '00000000-0000-0000-0000-000000000002';
const ALSO_STANDALONE = '00000000-0000-0000-0000-000000000004';
const OWN_PASSWORD = '00000000-0000-0000-0000-000000000005';
const none = () => undefined;
async function cookieFor(key: string, password: string, type: 'subpage' | 'album' = 'subpage') {
const header = (await authenticate(key, password, type))!;
const token = header.split('=')[1].split(';')[0];
const name = header.split('=')[0];
return (asked: string) => (asked === name ? token : undefined);
}
it('allows an album in a subpage that asks for nothing', () => {
expect(isAlbumReachable(IN_PUBLIC, none)).toBe(true);
});
it('refuses an album whose only route is a protected subpage', () => {
expect(isAlbumReachable(IN_PRIVATE, none)).toBe(false);
});
it('allows it once that subpage has been unlocked', async () => {
const getCookie = await cookieFor('private', 'secret123');
expect(isAlbumReachable(IN_PRIVATE, getCookie)).toBe(true);
});
// Listing an album on the home page publishes it, whatever else it is in.
it('allows a standalone album even when a protected subpage also lists it', () => {
expect(isAlbumReachable(ALSO_STANDALONE, none)).toBe(true);
});
it('still refuses an album that carries its own password', async () => {
const subpageOnly = await cookieFor('private', 'secret123');
expect(isAlbumReachable(OWN_PASSWORD, subpageOnly)).toBe(false);
});
it('allows that one once its own password is given too', async () => {
const subpage = await cookieFor('private', 'secret123');
const album = await cookieFor(OWN_PASSWORD, 'albumpass', 'album');
expect(isAlbumReachable(OWN_PASSWORD, (n) => subpage(n) ?? album(n))).toBe(true);
});
});
/**
* The essay layout puts the photographs of several albums on one page and
* never passes through an album's own gate, so it has to leave out the ones
* that are still locked.
*/
describe('withoutLockedAlbums', () => {
const OPEN = { id: '00000000-0000-0000-0000-000000000002' };
const LOCKED = { id: '00000000-0000-0000-0000-000000000005' };
it('keeps an album that has no password of its own', () => {
expect(withoutLockedAlbums([OPEN], () => undefined)).toEqual([OPEN]);
});
it('leaves out an album whose password has not been given', () => {
expect(withoutLockedAlbums([OPEN, LOCKED], () => undefined)).toEqual([OPEN]);
});
it('brings it back once it has been unlocked, in the original order', async () => {
const header = (await authenticate(LOCKED.id, 'albumpass', 'album'))!;
const [name, rest] = header.split('=');
const token = rest.split(';')[0];
const getCookie = (asked: string) => (asked === name ? token : undefined);
expect(withoutLockedAlbums([LOCKED, OPEN], getCookie)).toEqual([LOCKED, OPEN]);
});
});
/**
* The `Secure` flag follows the request, not NODE_ENV (#664): the Docker image
* always runs in production, and a browser drops a `Secure` cookie set over
* plain HTTP — the password was accepted and every next request answered 401.
*/
describe('isHttpsRequest', () => {
const req = (url: string, proto?: string) =>
new Request(url, { headers: proto ? { 'x-forwarded-proto': proto } : {} });
it('reads the scheme of a direct request', () => {
expect(isHttpsRequest(req('https://folio.example/api/auth'))).toBe(true);
expect(isHttpsRequest(req('http://192.168.1.10:7211/api/auth'))).toBe(false);
});
it('prefers the scheme a reverse proxy reports', () => {
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https'))).toBe(true);
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'HTTPS'))).toBe(true);
expect(isHttpsRequest(req('https://folio.example/api/auth', 'http'))).toBe(false);
});
it('takes the first entry of a chained header', () => {
expect(isHttpsRequest(req('http://folio:3000/api/auth', 'https, http'))).toBe(true);
});
});
describe('authenticate — Secure flag', () => {
it('marks the cookie Secure by default', async () => {
expect(await authenticate('private', 'secret123')).toMatch(/; Secure$/);
});
it('leaves Secure off for a plain-HTTP visitor', async () => {
const cookie = await authenticate('private', 'secret123', 'subpage', false);
expect(cookie).toContain('lb_auth_private=');
expect(cookie).not.toContain('Secure');
});
});