Skip to content

Commit 8c84c97

Browse files
os-zhuangclaude
andauthored
feat(auth): IP allow-list — network gating on auth routes (ADR-0069 D5, P2) (#2401)
- service-settings: new `allowed_ip_ranges` setting (Network group; CIDR/IP list). - plugin-auth: a Hono middleware registered ahead of the better-auth handler rejects auth requests from a client IP outside the ranges with 403 IP_NOT_ALLOWED. Client IP read trust-proxy-aware (x-forwarded-for first hop / cf-connecting-ip / x-real-ip). /config + /bootstrap-status exempt so a blocked client still gets a clean login + error. Fails OPEN when the IP can't be determined (missing proxy header = no-op, not a lockout). IPv4 CIDR + exact match (ipMatchesRange + isClientIpAllowed). Default-off / additive; ADR-0049. Verified live (dogfood, allowed=203.0.113.0/24): sign-in with XFF 8.8.8.8 -> 403 IP_NOT_ALLOWED; XFF 203.0.113.50 -> 200 + token; /config from a disallowed IP -> 200 (login renders); no XFF -> 200 (fail-open, admin not locked out). Unit: plugin-auth 208 + service-settings 129 green; full build incl. strict DTS green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d7a88df commit 8c84c97

6 files changed

Lines changed: 161 additions & 1 deletion

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/service-settings': minor
3+
'@objectstack/plugin-auth': minor
4+
---
5+
6+
Auth: IP allow-list — network gating on the auth routes (ADR-0069 D5, P2)
7+
8+
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.
9+
10+
- Client IP is read trust-proxy-aware from `x-forwarded-for` (first hop) / `cf-connecting-ip` / `x-real-ip`.
11+
- The public render helpers (`/config`, `/bootstrap-status`) are exempt so a blocked client still gets a clean login page + a clear error.
12+
- **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.
13+
- IPv4 CIDR (`a.b.c.d/n`) + exact IPv4/IPv6 matching.
14+
15+
Default-off / additive; per ADR-0049 the setting ships with its enforcement.

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

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

66
// Mock better-auth so we can control the handler behaviour
77
vi.mock('better-auth', () => ({
@@ -2162,4 +2162,43 @@ describe('AuthManager', () => {
21622162
expect(engine.find).not.toHaveBeenCalled();
21632163
});
21642164
});
2165+
2166+
// ADR-0069 D5 — IP allow-list (network gating).
2167+
describe('IP allow-list (ADR-0069 D5)', () => {
2168+
const SECRET = 'test-secret-at-least-32-chars-long';
2169+
const mgr = (extra: any = {}) => {
2170+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
2171+
const m = new AuthManager({ secret: SECRET, baseUrl: 'http://localhost:3000', ...extra });
2172+
warn.mockRestore();
2173+
return m;
2174+
};
2175+
2176+
it('ipMatchesRange handles IPv4 CIDR + exact', () => {
2177+
expect(ipMatchesRange('203.0.113.5', '203.0.113.0/24')).toBe(true);
2178+
expect(ipMatchesRange('203.0.114.5', '203.0.113.0/24')).toBe(false);
2179+
expect(ipMatchesRange('10.0.0.1', '10.0.0.1')).toBe(true);
2180+
expect(ipMatchesRange('10.0.0.2', '10.0.0.1')).toBe(false);
2181+
expect(ipMatchesRange('192.168.1.42', '192.168.1.42/32')).toBe(true);
2182+
expect(ipMatchesRange('1.2.3.4', '0.0.0.0/0')).toBe(true);
2183+
});
2184+
2185+
it('allows any IP when no ranges are configured', () => {
2186+
const m = mgr({});
2187+
expect(m.isClientIpAllowed('8.8.8.8')).toBe(true);
2188+
expect(m.isClientIpAllowed(undefined)).toBe(true);
2189+
});
2190+
2191+
it('allows an IP inside a configured range, blocks one outside', () => {
2192+
const m = mgr({ allowedIpRanges: ['203.0.113.0/24', '10.0.0.5'] });
2193+
expect(m.isClientIpAllowed('203.0.113.99')).toBe(true);
2194+
expect(m.isClientIpAllowed('10.0.0.5')).toBe(true);
2195+
expect(m.isClientIpAllowed('8.8.8.8')).toBe(false);
2196+
});
2197+
2198+
it('fails OPEN when the client IP cannot be determined (no proxy header)', () => {
2199+
const m = mgr({ allowedIpRanges: ['203.0.113.0/24'] });
2200+
expect(m.isClientIpAllowed(undefined)).toBe(true);
2201+
expect(m.isClientIpAllowed('')).toBe(true);
2202+
});
2203+
});
21652204
});

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,15 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
332332
sessionAbsoluteMaxHours?: number;
333333
maxConcurrentSessions?: number;
334334

335+
/**
336+
* ADR-0069 D5 — network gating. When non-empty, auth requests (sign-in,
337+
* session) from a client IP outside these CIDR / exact ranges are rejected
338+
* with `IP_NOT_ALLOWED` at the auth-route middleware. Requires a trusted proxy
339+
* to set `x-forwarded-for` / `cf-connecting-ip`; fails OPEN when the client IP
340+
* can't be determined (so a missing proxy header is a no-op, not a lockout).
341+
*/
342+
allowedIpRanges?: string[];
343+
335344
/**
336345
* ADR-0069 D2 — better-auth-native per-IP rate limiting, passed through to
337346
* better-auth's core `rateLimit`. The settings bind tightens `customRules`
@@ -353,6 +362,33 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
353362
* - Passkeys
354363
* - Organization/teams
355364
*/
365+
/** ADR-0069 D5 — parse a dotted-quad IPv4 to a uint32, or null when not IPv4. */
366+
function ipv4ToInt(ip: string): number | null {
367+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip.trim());
368+
if (!m) return null;
369+
const p = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
370+
if (p.some((n) => n > 255)) return null;
371+
return (((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]) >>> 0;
372+
}
373+
374+
/** ADR-0069 D5 — does `ip` match `range` (IPv4 CIDR `a.b.c.d/n`, or exact IP)? */
375+
export function ipMatchesRange(ip: string, range: string): boolean {
376+
const r = (range || '').trim();
377+
if (!r) return false;
378+
if (r.includes('/')) {
379+
const [base, bitsStr] = r.split('/');
380+
const bits = Number(bitsStr);
381+
const ipInt = ipv4ToInt(ip);
382+
const baseInt = ipv4ToInt(base);
383+
if (ipInt === null || baseInt === null || !(bits >= 0 && bits <= 32)) {
384+
return ip.trim() === base.trim(); // non-IPv4-CIDR → exact-match fallback
385+
}
386+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
387+
return ((ipInt & mask) >>> 0) === ((baseInt & mask) >>> 0);
388+
}
389+
return ip.trim() === r;
390+
}
391+
356392
export class AuthManager {
357393
private auth: Auth<any> | null = null;
358394
private config: AuthManagerOptions;
@@ -2756,6 +2792,19 @@ export class AuthManager {
27562792
}
27572793
}
27582794

2795+
/**
2796+
* ADR-0069 D5 — is `ip` within the configured allow-list? True (allow) when no
2797+
* ranges are configured, OR when the IP can't be determined (fail-open so a
2798+
* misconfigured proxy never locks everyone out — an admin enabling this must
2799+
* ensure forwarded headers are trusted). Supports IPv4 CIDR + exact IPv4/IPv6.
2800+
*/
2801+
public isClientIpAllowed(ip: string | undefined): boolean {
2802+
const ranges = this.config.allowedIpRanges;
2803+
if (!ranges || ranges.length === 0) return true;
2804+
if (!ip) return true; // undetermined → fail-open
2805+
return ranges.some((r) => ipMatchesRange(ip, r));
2806+
}
2807+
27592808
/**
27602809
* Returns the data engine wired into this auth manager. Used by route
27612810
* handlers (e.g. bootstrap-status) that need to query identity tables

packages/plugins/plugin-auth/src/auth-plugin.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,13 @@ describe('AuthPlugin', () => {
707707
expect((manager as any).config.mfaGracePeriodDays).toBe(14);
708708
});
709709

710+
it('binds allowed_ip_ranges into a parsed list (ADR-0069 D5)', async () => {
711+
const { manager } = await bootWithAuthSettings({
712+
allowed_ip_ranges: { value: '203.0.113.0/24, 10.0.0.5\n192.168.1.1', source: 'global' },
713+
});
714+
expect((manager as any).config.allowedIpRanges).toEqual(['203.0.113.0/24', '10.0.0.5', '192.168.1.1']);
715+
});
716+
710717
it('binds session-control settings (ADR-0069 D4)', async () => {
711718
const { manager } = await bootWithAuthSettings({
712719
session_idle_timeout_minutes: { value: 15, source: 'global' },

packages/plugins/plugin-auth/src/auth-plugin.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,15 @@ export class AuthPlugin implements Plugin {
598598
if (n !== undefined) patch.maxConcurrentSessions = n;
599599
}
600600

601+
// Network gating (ADR-0069 D5) — parse the CIDR/IP textarea into a list.
602+
if (isExplicit('allowed_ip_ranges')) {
603+
const raw = asTrimmedString(values.allowed_ip_ranges) ?? '';
604+
patch.allowedIpRanges = raw
605+
.split(/[\n,]+/)
606+
.map((r) => r.trim())
607+
.filter(Boolean);
608+
}
609+
601610
// Anti-abuse (ADR-0069 D2) — account lockout (custom, per-identity)
602611
// and rate-limit tuning (better-auth-native, per-IP). `asPositiveInt`
603612
// rejects 0/malformed; lockout_threshold uses a non-negative reader so
@@ -796,6 +805,32 @@ export class AuthPlugin implements Plugin {
796805

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

808+
// ── ADR-0069 D5 — network gating (IP allow-list) ──────────────────────
809+
// Reject auth requests from a client IP outside the configured ranges,
810+
// BEFORE they reach better-auth. Registered first so it runs ahead of the
811+
// routes below. The public render helpers (/config, /bootstrap-status) are
812+
// exempt so a blocked client still gets a clean login page + error. No-op
813+
// (and no IP parse) when no ranges are configured.
814+
if (typeof rawApp.use === 'function') rawApp.use(`${basePath}/*`, async (c: any, next: any) => {
815+
const mgr = this.authManager;
816+
if (!mgr || typeof mgr.isClientIpAllowed !== 'function') return next();
817+
const path: string = c.req.path || '';
818+
if (path.endsWith('/config') || path.endsWith('/bootstrap-status')) return next();
819+
const fwd = c.req.header('x-forwarded-for');
820+
const ip =
821+
(typeof fwd === 'string' && fwd.split(',')[0].trim()) ||
822+
c.req.header('cf-connecting-ip') ||
823+
c.req.header('x-real-ip') ||
824+
undefined;
825+
if (!mgr.isClientIpAllowed(ip)) {
826+
return c.json(
827+
{ success: false, error: { code: 'IP_NOT_ALLOWED', message: 'Sign-in is not allowed from your network.' } },
828+
403,
829+
);
830+
}
831+
return next();
832+
});
833+
799834
// Register /config before the wildcard so it takes precedence.
800835
// better-auth has no /config endpoint, so without this explicit route
801836
// the wildcard below forwards the request and better-auth returns 404.

packages/services/service-settings/src/manifests/auth.manifest.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,21 @@ const manifest = {
266266
description: 'Cap simultaneous signed-in sessions per user; the oldest are signed out past the cap. 0 = unlimited.',
267267
},
268268

269+
{
270+
type: 'group',
271+
id: 'network',
272+
label: 'Network',
273+
required: false,
274+
description: 'Restrict where users can authenticate from.',
275+
},
276+
{
277+
type: 'textarea',
278+
key: 'allowed_ip_ranges',
279+
label: 'Allowed IP ranges',
280+
required: false,
281+
description:
282+
'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.',
283+
},
269284
{
270285
type: 'group',
271286
id: 'social',

0 commit comments

Comments
 (0)