Files
immich-folio/lib/__tests__/auth.test.ts
Ralksta a813433891 release: v0.13.0 (#535)
Twenty-nine commits since v0.12.0. See CHANGELOG.md for the full list
and the upgrade notes.

Ten additions — the SEO package (site URL, robots.txt, sitemap.xml,
JSON-LD), slideshow mode, opt-in original downloads, copy-link,
previous/next album navigation, alt text from the Immich description,
per-album location precision, the update notice, a Help tab, and a
subpage cover grid separate from its photo grid. Two fixes: CJK album
slugs, and the lightbox control bar.

Photo of the Day (#476) was built on `dev` and reverted as not planned;
it never appears in a release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01DmsHVRL9yQvckUB1ZpVWre

---------

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>
2026-08-25 10:08:12 +02:00

261 lines
9.6 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,
findSubpageBySlug,
} 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);
});
});