Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/adr-0069-d5-ip-allowlist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/service-settings': minor
'@objectstack/plugin-auth': minor
---

Auth: IP allow-list — network gating on the auth routes (ADR-0069 D5, P2)

Adds an `allowed_ip_ranges` auth setting (CIDR ranges or exact IPs; empty = no restriction). A Hono middleware registered ahead of the better-auth handler in the auth-route registration rejects auth requests from a client IP outside the ranges with `403 IP_NOT_ALLOWED`, before they reach better-auth.

- Client IP is read trust-proxy-aware from `x-forwarded-for` (first hop) / `cf-connecting-ip` / `x-real-ip`.
- The public render helpers (`/config`, `/bootstrap-status`) are exempt so a blocked client still gets a clean login page + a clear error.
- **Fails OPEN** when the client IP can't be determined (no proxy header), so a misconfigured proxy is a no-op rather than a lockout — an admin enabling this must ensure forwarded headers are trusted.
- IPv4 CIDR (`a.b.c.d/n`) + exact IPv4/IPv6 matching.

Default-off / additive; per ADR-0049 the setting ships with its enforcement.
41 changes: 40 additions & 1 deletion packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { AuthManager } from './auth-manager';
import { AuthManager, ipMatchesRange } from './auth-manager';

// Mock better-auth so we can control the handler behaviour
vi.mock('better-auth', () => ({
Expand Down Expand Up @@ -2121,4 +2121,43 @@ describe('AuthManager', () => {
expect(engine.find).not.toHaveBeenCalled();
});
});

// ADR-0069 D5 — IP allow-list (network gating).
describe('IP allow-list (ADR-0069 D5)', () => {
const SECRET = 'test-secret-at-least-32-chars-long';
const mgr = (extra: any = {}) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', ...extra });
warn.mockRestore();
return m;
};

it('ipMatchesRange handles IPv4 CIDR + exact', () => {
expect(ipMatchesRange('203.0.113.5', '203.0.113.0/24')).toBe(true);
expect(ipMatchesRange('203.0.114.5', '203.0.113.0/24')).toBe(false);
expect(ipMatchesRange('10.0.0.1', '10.0.0.1')).toBe(true);
expect(ipMatchesRange('10.0.0.2', '10.0.0.1')).toBe(false);
expect(ipMatchesRange('192.168.1.42', '192.168.1.42/32')).toBe(true);
expect(ipMatchesRange('1.2.3.4', '0.0.0.0/0')).toBe(true);
});

it('allows any IP when no ranges are configured', () => {
const m = mgr({});
expect(m.isClientIpAllowed('8.8.8.8')).toBe(true);
expect(m.isClientIpAllowed(undefined)).toBe(true);
});

it('allows an IP inside a configured range, blocks one outside', () => {
const m = mgr({ allowedIpRanges: ['203.0.113.0/24', '10.0.0.5'] });
expect(m.isClientIpAllowed('203.0.113.99')).toBe(true);
expect(m.isClientIpAllowed('10.0.0.5')).toBe(true);
expect(m.isClientIpAllowed('8.8.8.8')).toBe(false);
});

it('fails OPEN when the client IP cannot be determined (no proxy header)', () => {
const m = mgr({ allowedIpRanges: ['203.0.113.0/24'] });
expect(m.isClientIpAllowed(undefined)).toBe(true);
expect(m.isClientIpAllowed('')).toBe(true);
});
});
});
49 changes: 49 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,15 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
sessionAbsoluteMaxHours?: number;
maxConcurrentSessions?: number;

/**
* ADR-0069 D5 — network gating. When non-empty, auth requests (sign-in,
* session) from a client IP outside these CIDR / exact ranges are rejected
* with `IP_NOT_ALLOWED` at the auth-route middleware. Requires a trusted proxy
* to set `x-forwarded-for` / `cf-connecting-ip`; fails OPEN when the client IP
* can't be determined (so a missing proxy header is a no-op, not a lockout).
*/
allowedIpRanges?: string[];

/**
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
Expand All @@ -353,6 +362,33 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
* - Passkeys
* - Organization/teams
*/
/** ADR-0069 D5 — parse a dotted-quad IPv4 to a uint32, or null when not IPv4. */
function ipv4ToInt(ip: string): number | null {
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
if (!m) return null;
const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
if (p.some((n) => n > 255)) return null;
return (((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]) >>> 0;
}

/** ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? */
export function ipMatchesRange(ip: string, range: string): boolean {
const r = (range || '').trim();
if (!r) return false;
if (r.includes('/')) {
const [base, bitsStr] = r.split('/');
const bits = Number(bitsStr);
const ipInt = ipv4ToInt(ip);
const baseInt = ipv4ToInt(base);
if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
return ip.trim() === base.trim(); // non-IPv4-CIDR → exact-match fallback
}
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
return ((ipInt & mask) >>> 0) === ((baseInt & mask) >>> 0);
}
return ip.trim() === r;
}

export class AuthManager {
private auth: Auth<any> | null = null;
private config: AuthManagerOptions;
Expand Down Expand Up @@ -2753,6 +2789,19 @@ export class AuthManager {
}
}

/**
* ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no
* ranges are configured, OR when the IP can't be determined (fail-open so a
* misconfigured proxy never locks everyone out — an admin enabling this must
* ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6.
*/
public isClientIpAllowed(ip: string | undefined): boolean {
const ranges = this.config.allowedIpRanges;
if (!ranges || ranges.length === 0) return true;
if (!ip) return true; // undetermined → fail-open
return ranges.some((r) => ipMatchesRange(ip, r));
}

/**
* Returns the data engine wired into this auth manager. Used by route
* handlers (e.g. bootstrap-status) that need to query identity tables
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,13 @@ describe('AuthPlugin', () => {
expect((manager as any).config.mfaGracePeriodDays).toBe(14);
});

it('binds allowed_ip_ranges into a parsed list (ADR-0069 D5)', async () => {
const { manager } = await bootWithAuthSettings({
allowed_ip_ranges: { value: '203.0.113.0/24, 10.0.0.5\n192.168.1.1', source: 'global' },
});
expect((manager as any).config.allowedIpRanges).toEqual(['203.0.113.0/24', '10.0.0.5', '192.168.1.1']);
});

it('binds session-control settings (ADR-0069 D4)', async () => {
const { manager } = await bootWithAuthSettings({
session_idle_timeout_minutes: { value: 15, source: 'global' },
Expand Down
35 changes: 35 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,15 @@ export class AuthPlugin implements Plugin {
if (n !== undefined) patch.maxConcurrentSessions = n;
}

// Network gating (ADR-0069 D5) — parse the CIDR/IP textarea into a list.
if (isExplicit('allowed_ip_ranges')) {
const raw = asTrimmedString(values.allowed_ip_ranges) ?? '';
patch.allowedIpRanges = raw
.split(/[\n,]+/)
.map((r) => r.trim())
.filter(Boolean);
}

// Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity)
// and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt`
// rejects 0/malformed; lockout_threshold uses a non-negative reader so
Expand Down Expand Up @@ -796,6 +805,32 @@ export class AuthPlugin implements Plugin {

const rawApp = (httpServer as any).getRawApp();

// ── ADR-0069 D5 — network gating (IP allow-list) ──────────────────────
// Reject auth requests from a client IP outside the configured ranges,
// BEFORE they reach better-auth. Registered first so it runs ahead of the
// routes below. The public render helpers (/config, /bootstrap-status) are
// exempt so a blocked client still gets a clean login page + error. No-op
// (and no IP parse) when no ranges are configured.
if (typeof rawApp.use === 'function') rawApp.use(`${basePath}/*`, async (c: any, next: any) => {
const mgr = this.authManager;
if (!mgr || typeof mgr.isClientIpAllowed !== 'function') return next();
const path: string = c.req.path || '';
if (path.endsWith('/config') || path.endsWith('/bootstrap-status')) return next();
const fwd = c.req.header('x-forwarded-for');
const ip =
(typeof fwd === 'string' && fwd.split(',')[0].trim()) ||
c.req.header('cf-connecting-ip') ||
c.req.header('x-real-ip') ||
undefined;
if (!mgr.isClientIpAllowed(ip)) {
return c.json(
{ success: false, error: { code: 'IP_NOT_ALLOWED', message: 'Sign-in is not allowed from your network.' } },
403,
);
}
return next();
});

// Register /config before the wildcard so it takes precedence.
// better-auth has no /config endpoint, so without this explicit route
// the wildcard below forwards the request and better-auth returns 404.
Expand Down
15 changes: 15 additions & 0 deletions packages/services/service-settings/src/manifests/auth.manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,21 @@ const manifest = {
description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited.',
},

{
type: 'group',
id: 'network',
label: 'Network',
required: false,
description: 'Restrict where users can authenticate from.',
},
{
type: 'textarea',
key: 'allowed_ip_ranges',
label: 'Allowed IP ranges',
required: false,
description:
'CIDR ranges or exact IPs (one per line, or comma-separated), e.g. 203.0.113.0/24. When set, sign-in from outside these ranges is rejected. Empty = no restriction. Requires a trusted proxy to set X-Forwarded-For.',
},
{
type: 'group',
id: 'social',
Expand Down