Files
immich-folio/lib/__tests__/auth.test.ts
Ralksta ad8b09700b release: v0.19.0
Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
2026-09-29 22:50:30 +02:00

381 lines
15 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
},
{
// Taken offline with `enabled: false`, and asks for nothing.
name: 'Offline',
slug: 'offline',
albumIds: ['00000000-0000-0000-0000-000000000006'],
enabled: false,
},
{
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',
},
{
// A leftover bcrypt hash: refused outright, whatever the attempt.
name: 'Legacy',
slug: 'legacy',
albumIds: ['00000000-0000-0000-0000-000000000007'],
password: '$2b$10$abcdefghijklmnopqrstuuPjJ7x1b0c9vX8rZ0Yx0mZ1n2o3p4q5r',
},
],
}),
}));
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);
});
});
/**
* A key with an scrypt hash took ~50ms to answer, every other key ~2ms — the
* early return skipped hashing. Identical reply bodies did not help: the clock
* alone told a prober which pages, albums and journal entries are locked,
* drafts included. Every attempt now runs exactly one scrypt. Counting the
* calls keeps the test independent of how fast the machine is.
*/
describe('authenticate — one scrypt per attempt, whatever the key', () => {
const attempts: [string, () => Promise<string | null>][] = [
['scrypt hash, right password', () => authenticate('hashed', 'scrypted-pass')],
['scrypt hash, wrong password', () => authenticate('hashed', 'wrong')],
['plaintext, right password', () => authenticate('private', 'secret123')],
['plaintext, wrong password', () => authenticate('private', 'wrong')],
['key without a password', () => authenticate('public', 'anything')],
['unknown key', () => authenticate('no-such-page', 'anything')],
['unknown journal entry', () => authenticate('no-such-entry', 'x', 'journal')],
['unknown album', () => authenticate('00000000-0000-0000-0000-00000000dead', 'x', 'album')],
['legacy bcrypt hash', () => authenticate('legacy', 'anything')],
];
it.each(attempts)('%s', async (_label, attempt) => {
const scrypt = vi.spyOn(crypto, 'scrypt');
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
await attempt();
expect(scrypt).toHaveBeenCalledTimes(1);
} finally {
scrypt.mockRestore();
error.mockRestore();
warn.mockRestore();
}
});
});
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);
});
// `enabled: false` takes the subpage offline; its albums have no route left.
it('refuses an album whose only subpage is offline', () => {
expect(isAlbumReachable('00000000-0000-0000-0000-000000000006', none)).toBe(false);
});
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');
});
});