The auth seam read `process.env.AUTH_ENFORCE === 'true'`, so an absent env var meant NO authentication: any fresh container, local run, DR restore or redeploy that dropped the variable served /api/0pt/* anonymously, taking the tenant from the caller-supplied X-Tenant-Id header. 0pt.app is deployed and was answering anonymously (verified: an invalid Bearer token plus an arbitrary X-Tenant-Id returned 200), so this was a live hole, not latent. * authEnforced() now returns true unless AUTH_ENFORCE is explicitly "false" (trimmed, case-insensitive). Unset/empty/misspelled => ENFORCED. * assertAuthConfigSafe() throws AuthMisconfigurationError when NODE_ENV=production and enforcement resolved off, naming AUTH_ENFORCE. It runs at middleware module load (startup) and on the env-driven request path, so a production instance can neither boot nor serve a single unauthenticated request with auth switched off. * .env.example no longer teaches AUTH_ENFORCE=false; README's Deploy section states the fail-closed posture (no DEPLOY.md in this repo). * 7 new tests, incl. the regression case: with AUTH_ENFORCE unset and no `enforce` option, an unauthenticated request is rejected with 401. No test was inverted or deleted -- none asserted the open default; the three enforce:false cases now describe the explicit local-dev escape hatch. Tests: 121 passed / 1 skipped before, 128 passed / 1 skipped after. Typecheck and build clean; advisory lint unchanged (16 pre-existing problems). Scope: auth default only. The matcher still covers /api/0pt/* alone, and the handlers still take tenant_id from the query/body rather than the verified principal -- both described in the PR body, neither fixed here.
201 lines
7.4 KiB
TypeScript
201 lines
7.4 KiB
TypeScript
/**
|
|
* Unit tests for src/auth/context.ts -- the auth seam. Verification is
|
|
* injected (opts.verify) so these tests are pure and network-free.
|
|
*
|
|
* Three groups:
|
|
* 1. the ENV DEFAULT (the fail-closed contract: absent AUTH_ENFORCE means
|
|
* an unauthenticated request is REJECTED, and production refuses to run
|
|
* with auth switched off),
|
|
* 2. enforced-mode decision logic,
|
|
* 3. the explicit `AUTH_ENFORCE=false` local-development escape hatch.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import {
|
|
resolveAuthContext,
|
|
authEnforced,
|
|
assertAuthConfigSafe,
|
|
AuthMisconfigurationError,
|
|
AUTH_ENFORCE_VAR,
|
|
type OptRequestContext,
|
|
} from '@/src/auth/context';
|
|
import type { VerifiedPrincipal } from '@/src/auth/zitadel';
|
|
|
|
const principal: VerifiedPrincipal = {
|
|
sub: 'user_1',
|
|
tenantId: 'tenant_x',
|
|
claims: { sub: 'user_1', iss: 'https://id-customers.siax.io' },
|
|
};
|
|
|
|
const okVerify = vi.fn(async () => principal);
|
|
const failVerify = vi.fn(async () => {
|
|
throw new Error('token_verification_failed');
|
|
});
|
|
|
|
function h(init: Record<string, string>): Headers {
|
|
return new Headers(init);
|
|
}
|
|
|
|
describe('auth default -- fail-closed (no AUTH_ENFORCE in the environment)', () => {
|
|
const saved = { auth: process.env.AUTH_ENFORCE, node: process.env.NODE_ENV };
|
|
|
|
beforeEach(() => {
|
|
delete process.env.AUTH_ENFORCE;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (saved.auth === undefined) delete process.env.AUTH_ENFORCE;
|
|
else process.env.AUTH_ENFORCE = saved.auth;
|
|
if (saved.node === undefined) delete process.env.NODE_ENV;
|
|
else process.env.NODE_ENV = saved.node;
|
|
});
|
|
|
|
it('REGRESSION: with the variable unset, an unauthenticated request is REJECTED (401)', async () => {
|
|
// No `enforce` option -> the decision comes from the environment, which is
|
|
// exactly the fresh-container / DR-restore / dropped-env-var case.
|
|
const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_attacker' }), { verify: okVerify });
|
|
expect(d.ok).toBe(false);
|
|
if (!d.ok) {
|
|
expect(d.status).toBe(401);
|
|
expect(d.code).toBe('OPT_UNAUTHENTICATED');
|
|
}
|
|
});
|
|
|
|
it('with the variable unset, an invalid token is REJECTED (401) rather than ignored', async () => {
|
|
const d = await resolveAuthContext(h({ authorization: 'Bearer bad.tok.en' }), { verify: failVerify });
|
|
expect(d.ok).toBe(false);
|
|
if (!d.ok) expect(d.status).toBe(401);
|
|
});
|
|
|
|
it('authEnforced() is true when the variable is absent, empty, or misspelled', () => {
|
|
expect(authEnforced({})).toBe(true);
|
|
expect(authEnforced({ AUTH_ENFORCE: '' })).toBe(true);
|
|
expect(authEnforced({ AUTH_ENFORCE: 'flase' })).toBe(true);
|
|
expect(authEnforced({ AUTH_ENFORCE: '0' })).toBe(true);
|
|
expect(authEnforced({ AUTH_ENFORCE: 'no' })).toBe(true);
|
|
expect(authEnforced({ AUTH_ENFORCE: 'true' })).toBe(true);
|
|
});
|
|
|
|
it('only the literal string "false" opts out', () => {
|
|
expect(authEnforced({ AUTH_ENFORCE: 'false' })).toBe(false);
|
|
expect(authEnforced({ AUTH_ENFORCE: 'FALSE' })).toBe(false);
|
|
expect(authEnforced({ AUTH_ENFORCE: ' false ' })).toBe(false);
|
|
});
|
|
|
|
it('assertAuthConfigSafe throws in production when enforcement is off, naming the variable', () => {
|
|
expect(() => assertAuthConfigSafe({ NODE_ENV: 'production', AUTH_ENFORCE: 'false' })).toThrow(
|
|
AuthMisconfigurationError,
|
|
);
|
|
expect(() => assertAuthConfigSafe({ NODE_ENV: 'production', AUTH_ENFORCE: 'false' })).toThrow(
|
|
new RegExp(AUTH_ENFORCE_VAR),
|
|
);
|
|
});
|
|
|
|
it('assertAuthConfigSafe is silent when production enforces, or when off outside production', () => {
|
|
expect(() => assertAuthConfigSafe({ NODE_ENV: 'production' })).not.toThrow();
|
|
expect(() => assertAuthConfigSafe({ NODE_ENV: 'production', AUTH_ENFORCE: 'true' })).not.toThrow();
|
|
expect(() => assertAuthConfigSafe({ NODE_ENV: 'development', AUTH_ENFORCE: 'false' })).not.toThrow();
|
|
});
|
|
|
|
it('a production process with AUTH_ENFORCE=false cannot serve a request at all (throws)', async () => {
|
|
process.env.NODE_ENV = 'production';
|
|
process.env.AUTH_ENFORCE = 'false';
|
|
await expect(
|
|
resolveAuthContext(h({ 'x-tenant-id': 'tenant_attacker' }), { verify: okVerify }),
|
|
).rejects.toBeInstanceOf(AuthMisconfigurationError);
|
|
});
|
|
});
|
|
|
|
describe('resolveAuthContext -- enforced mode (the default)', () => {
|
|
it('401s when no bearer token is present', async () => {
|
|
const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_x' }), { enforce: true, verify: okVerify });
|
|
expect(d.ok).toBe(false);
|
|
if (!d.ok) {
|
|
expect(d.status).toBe(401);
|
|
expect(d.code).toBe('OPT_UNAUTHENTICATED');
|
|
}
|
|
});
|
|
|
|
it('401s when the token fails verification', async () => {
|
|
const d = await resolveAuthContext(h({ authorization: 'Bearer bad.tok.en' }), {
|
|
enforce: true,
|
|
verify: failVerify,
|
|
});
|
|
expect(d.ok).toBe(false);
|
|
if (!d.ok) expect(d.status).toBe(401);
|
|
});
|
|
|
|
it('attaches the verified principal and derives tenant from the TOKEN', async () => {
|
|
const d = await resolveAuthContext(h({ authorization: 'Bearer good.tok.en' }), {
|
|
enforce: true,
|
|
verify: okVerify,
|
|
});
|
|
expect(d.ok).toBe(true);
|
|
if (d.ok) {
|
|
expect(d.ctx.principal).toEqual(principal);
|
|
expect(d.ctx.tenantId).toBe('tenant_x');
|
|
expect(d.ctx.actorId).toBe('user_1');
|
|
}
|
|
});
|
|
|
|
it('403s when X-Tenant-Id disagrees with the token tenant', async () => {
|
|
const d = await resolveAuthContext(
|
|
h({ authorization: 'Bearer good.tok.en', 'x-tenant-id': 'tenant_evil' }),
|
|
{ enforce: true, verify: okVerify },
|
|
);
|
|
expect(d.ok).toBe(false);
|
|
if (!d.ok) {
|
|
expect(d.status).toBe(403);
|
|
expect(d.code).toBe('OPT_ACCESS_DENIED');
|
|
expect(d.details).toMatchObject({ header_tenant: 'tenant_evil', token_tenant: 'tenant_x' });
|
|
}
|
|
});
|
|
|
|
it('allows a matching X-Tenant-Id', async () => {
|
|
const d = await resolveAuthContext(
|
|
h({ authorization: 'Bearer good.tok.en', 'x-tenant-id': 'tenant_x' }),
|
|
{ enforce: true, verify: okVerify },
|
|
);
|
|
expect(d.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('resolveAuthContext -- explicit AUTH_ENFORCE=false escape hatch (local dev only)', () => {
|
|
it('never rejects when no token is present', async () => {
|
|
const d = await resolveAuthContext(h({ 'x-tenant-id': 'tenant_header' }), {
|
|
enforce: false,
|
|
verify: okVerify,
|
|
});
|
|
expect(d.ok).toBe(true);
|
|
if (d.ok) {
|
|
expect((d.ctx as OptRequestContext).principal).toBeUndefined();
|
|
expect(d.ctx.tenantId).toBe('tenant_header');
|
|
}
|
|
});
|
|
|
|
it('never rejects even when a present token fails verification (logs a warning)', async () => {
|
|
const logger = { warn: vi.fn() };
|
|
const d = await resolveAuthContext(h({ authorization: 'Bearer bad.tok.en' }), {
|
|
enforce: false,
|
|
verify: failVerify,
|
|
logger,
|
|
});
|
|
expect(d.ok).toBe(true);
|
|
if (d.ok) expect(d.ctx.principal).toBeUndefined();
|
|
expect(logger.warn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('attaches the principal when a present token verifies, without overriding a supplied tenant', async () => {
|
|
const d = await resolveAuthContext(
|
|
h({ authorization: 'Bearer good.tok.en', 'x-tenant-id': 'tenant_header' }),
|
|
{ enforce: false, verify: okVerify },
|
|
);
|
|
expect(d.ok).toBe(true);
|
|
if (d.ok) {
|
|
expect(d.ctx.principal).toEqual(principal);
|
|
// the caller-supplied header tenant is preserved in this mode
|
|
expect(d.ctx.tenantId).toBe('tenant_header');
|
|
}
|
|
});
|
|
});
|