Skip to content

Commit 1df8559

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-a4f92k
2 parents 7cf38c4 + 3230bdb commit 1df8559

15 files changed

Lines changed: 839 additions & 41 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a session stores its client address in RFC 5952 text, and is born with `last_activity_at`
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
Setup › Sessions printed a localhost session's IP as `0000:0000:0000:0000:0000:0000:0000:0000` and left Last Activity empty until the session's first request. Both are fixed in the data a sign-in stores; the list still prints the columns as plain text.
11+
12+
- **The address is the client's, in RFC 5952 text.** `sys_session.ip_address` is now the address the server established for the request (the client-address trust rule: forwarded headers only under `server.trustProxy`, the connection's peer otherwise), with IPv6 compressed — `::1`, `2001:db8::1` — and IPv4 unchanged. better-auth's own reader used to fill the column with its rate-limit bucket instead: every IPv6 address expanded to eight groups and masked to its /64 network, so `::1` read as `0000:…:0000` and `2001:db8::1` as `2001:0db8:0000:0000:0000:0000:0000:0000`. An IPv4-mapped address is stored as the IPv4 address it maps (`::ffff:203.0.113.7` → `203.0.113.7`), so an IPv4 client reads the same whether a dual-stack or an IPv4 listener accepted it; `last_login_ip` and the refused-sign-in audit rows, which carried the mapped text, now carry that one too. A session minted with no established address (a programmatic `auth.api.*` call) keeps better-auth's value, in the same text. An unparseable value is stored as it came.
13+
- **One text on every row of a sign-in.** The `login_failed` / `account_locked` audit rows and `sys_user.last_login_ip` carry the same text as the session row and its `login` row. `signInClientFingerprint(ctx, establishedAddress?)` takes the established address as an optional second argument.
14+
- **`last_activity_at` is written at creation**, equal to `created_at`. The activity heartbeat is unchanged; its first write now comes on the first request more than a minute after sign-in rather than on the very first request.
15+
- **Unchanged:** better-auth's per-address rate limiter still keys on its own bucket, the ADR-0069 D5 allow-list still judges the trust rule's answer as it came, and existing session rows are not rewritten.

‎packages/plugins/plugin-auth/src/auth-manager-database-hooks.ts‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ import {
5656
import { withSystemReadContext } from './objectql-adapter.js';
5757
import { emitAuthSessionAuditEvent, loginEventFor, logoutEventFor } from './auth-session-audit.js';
5858
import { holdPrincipalSessionExpiry } from './principal-session-mint.js';
59+
import { canonicalClientAddress } from './client-address-canonical.js';
60+
import { readRequestClientAddress } from './request-client-address.js';
5961

6062
/**
6163
* The `AuthManager` members the functions below read — every one of them, and
@@ -266,7 +268,7 @@ export function composeDatabaseHooks(
266268
return; // never break session create
267269
}
268270
};
269-
const sessionBefore =
271+
const sessionActiveOrgBefore =
270272
this.config.autoActiveOrganization !== false
271273
? async (session: any, ctx: any) => {
272274
let draft = session;
@@ -282,12 +284,37 @@ export function composeDatabaseHooks(
282284
}
283285
: hostSessionBefore;
284286

287+
// [#26638] The session row's `ip_address` is the address the server
288+
// established for this request (the client-address trust rule,
289+
// `client-address.ts`, carried in the request state by `handleRequest`), in
290+
// its stored text (`client-address-canonical.ts`). better-auth fills the
291+
// field from its own reader, which re-derives that address and stores its
292+
// rate-limit bucket instead: an IPv6 address expanded and masked to its /64
293+
// network, an IPv4-mapped one unmapped. No established address (a
294+
// programmatic `auth.api.*` call) ⇒ better-auth's value, in the same text.
295+
// It runs FIRST, so the steps below — the host's included — see the
296+
// captured address, and a host that sets `ipAddress` itself still wins.
297+
const captureSessionAddress = async (session: any) => {
298+
const derived = typeof session?.ipAddress === 'string' ? session.ipAddress : undefined;
299+
const ipAddress = canonicalClientAddress((await readRequestClientAddress()) ?? derived);
300+
return ipAddress === derived ? session : { ...session, ipAddress };
301+
};
302+
const sessionBefore = async (session: any, ctx: any) => {
303+
const captured = await captureSessionAddress(session);
304+
const result = sessionActiveOrgBefore ? await sessionActiveOrgBefore(captured, ctx) : undefined;
305+
if (result === false) return false;
306+
if (result && typeof result === 'object' && 'data' in result) {
307+
return { data: { ...captured, ...(result as any).data } };
308+
}
309+
return captured === session ? result : { data: captured };
310+
};
311+
285312
// [#8144] The `login` / `logout` audit writers (#7675 sub-issue A). See
286313
// `auth-session-audit.ts` for why these two hooks and no others, and why
287314
// logout reads the endpoint path from the hook's own `ctx` argument rather
288315
// than from the ambient auth context.
289316
//
290-
// Host hooks chain FIRST and keep their result, exactly as `sessionBefore`
317+
// Host hooks chain FIRST and keep their result, exactly as `sessionActiveOrgBefore`
291318
// above does: the audit row is an observation, never a participant in what
292319
// better-auth decides. Both emitters are awaited but cannot throw
293320
// (`emitAuthSessionAuditEvent` swallows), so neither can turn a valid
@@ -378,7 +405,7 @@ export function composeDatabaseHooks(
378405
// `user.create.before` is a seam only a CREATION traverses, so a later
379406
// email UPDATE to the owner address structurally cannot inherit the
380407
// stamp. Host hook chains FIRST and keeps its result shape, exactly as
381-
// `sessionBefore` above does; a host `false` (refuse the creation) is
408+
// `sessionActiveOrgBefore` above does; a host `false` (refuse the creation) is
382409
// honoured before the stamp is even consumed.
383410
const hostUserBefore = (host as any)?.user?.create?.before;
384411
const userBefore = async (user: any, ctx: any) => {
@@ -426,10 +453,7 @@ export function composeDatabaseHooks(
426453
...((host as any)?.session ?? {}),
427454
create: {
428455
...((host as any)?.session?.create ?? {}),
429-
// `sessionBefore` is undefined only when `autoActiveOrganization` is
430-
// off AND the host supplied none — spread it in conditionally so the
431-
// key is absent rather than explicitly `undefined`.
432-
...(sessionBefore ? { before: sessionBefore } : {}),
456+
before: sessionBefore,
433457
after: sessionCreateAfter,
434458
},
435459
update: {

‎packages/plugins/plugin-auth/src/auth-manager-sign-in-lockout.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ import {
4747
signInFailureEventFor,
4848
SIGN_IN_EMAIL_PATH,
4949
} from './auth-session-audit.js';
50+
import { canonicalClientAddress } from './client-address-canonical.js';
51+
import { readRequestClientAddress } from './request-client-address.js';
5052

5153
/**
5254
* The `AuthManager` members the functions below read — every one of them, and
@@ -87,7 +89,7 @@ export async function assertAccountNotLocked(this: Pick<SignInLockoutHost, 'conf
8789
signInFailureEventFor(lockedUserId, {
8890
path: SIGN_IN_EMAIL_PATH,
8991
reason: 'ACCOUNT_LOCKED',
90-
fingerprint: signInClientFingerprint(ctx),
92+
fingerprint: signInClientFingerprint(ctx, await readRequestClientAddress()),
9193
}),
9294
);
9395
}
@@ -164,7 +166,7 @@ export async function auditRefusedSignIn(
164166
if (!sink) return;
165167
const userId = lockStamped?.userId ?? (await this.resolveSignInAccountId(email));
166168
if (!userId) return;
167-
const fingerprint = signInClientFingerprint(ctx);
169+
const fingerprint = signInClientFingerprint(ctx, await readRequestClientAddress());
168170
await emitAuthSessionAuditEvent(
169171
sink,
170172
signInFailureEventFor(userId, {
@@ -242,9 +244,11 @@ export async function stampLastLogin(this: Pick<SignInLockoutHost, 'getDataEngin
242244
try {
243245
const SYSTEM_CTX = { isSystem: true, attributedUserId: userId, positions: [], permissions: [] };
244246
const patch: Record<string, unknown> = { id: userId, last_login_at: new Date() };
245-
// Cap to the column width (IPv6 textual max 45) — a malformed/oversized
246-
// forwarded header must not blow up the write.
247-
if (ip) patch.last_login_ip = ip.slice(0, 45);
247+
// [#26638] In the RFC 5952 text the session row stores for the same
248+
// sign-in (`client-address-canonical.ts`). Cap to the column width (IPv6
249+
// textual max 45) — a malformed/oversized forwarded header must not blow
250+
// up the write.
251+
if (ip) patch.last_login_ip = canonicalClientAddress(ip).slice(0, 45);
248252
await engine.update('sys_user', patch, { context: SYSTEM_CTX } as any);
249253
} catch (e) {
250254
// Never break the auth response. [#12981] But this write is the ONLY

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5078,7 +5078,10 @@ describe('AuthManager', () => {
50785078
dataEngine: makeEngine([OWNER_ROW]),
50795079
autoActiveOrganization: false,
50805080
});
5081-
expect(hooks.session?.create?.before).toBeUndefined();
5081+
// [#26638] `create.before` is always installed (it stamps the session's
5082+
// client address); with the default off it fills no organization.
5083+
const result = await hooks.session.create.before({ userId: 'u1' });
5084+
expect(result?.data?.activeOrganizationId).toBeUndefined();
50825085
});
50835086

50845087
it('never breaks session create on engine errors', async () => {

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import { registerLastAdminGuard } from './last-admin-guard.js';
6868
import { registerApiKeyRestoreGuard } from './api-key-restore-guard.js';
6969
import { registerMembershipEndedSessionTrigger } from './membership-ended-session.js';
7070
import { registerMemberRoleCanonicalization } from './member-role-canonical.js';
71+
import { registerSessionFirstActivity } from './session-first-activity.js';
7172
import { registerExternalMembershipGuard } from './external-membership.js';
7273
import { SYS_USER_PROFILE_EDIT_FIELDS } from './sys-user-writable-fields.js';
7374
import { MANAGED_EXTENSION_EDITABLE_FIELDS } from './managed-extension-fields.js';
@@ -999,6 +1000,20 @@ export class AuthPlugin implements Plugin {
9991000
}
10001001
}
10011002

1003+
// [#26638] A session is born with `last_activity_at` = the `created_at`
1004+
// it is stored with, after ObjectQL's own stamp (session-first-activity.ts).
1005+
// Registered here rather than on `kernel:ready`: the development admin
1006+
// seed's sign-up, itself a `kernel:ready` hook, mints a session as well.
1007+
// An engine that cannot take hooks (mock mode) stores no session row.
1008+
try {
1009+
const ql = ctx.getService<IObjectQLEngine>('objectql');
1010+
if (ql && typeof ql.registerHook === 'function') {
1011+
registerSessionFirstActivity(ql, { packageId: 'com.objectstack.plugin-auth.session-first-activity' });
1012+
}
1013+
} catch {
1014+
// No `objectql` service (mock mode): nothing to register on.
1015+
}
1016+
10021017
// [#8009] Move any provider row still holding a CLEARTEXT OIDC client
10031018
// secret in `oidc_config` into the encrypted column. Registered
10041019
// unconditionally (not under `registerRoutes`) because the disposition of

‎packages/plugins/plugin-auth/src/auth-session-audit.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,11 +158,10 @@ describe('[#8144] session.create.after records a login', () => {
158158
expect(recordAuthEvent).toHaveBeenCalledTimes(1);
159159
});
160160

161-
it('is wired even when autoActiveOrganization is off (which removes create.before)', async () => {
161+
it('is wired even when autoActiveOrganization is off (which removes the active-organization default)', async () => {
162162
const { hooks, recordAuthEvent } = hooksWithSink({ autoActiveOrganization: false });
163163
// The pre-#8144 shape omitted the whole `session` block when there was no
164164
// `before` hook to install — the audit writer must not inherit that gate.
165-
expect(hooks.session?.create?.before).toBeUndefined();
166165
await hooks.session.create.after(SESSION, { path: '/sign-in/email' });
167166
expect(recordAuthEvent).toHaveBeenCalledTimes(1);
168167
});

‎packages/plugins/plugin-auth/src/auth-session-audit.ts‎

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@
7070
*
7171
* Both name the account the sign-in resolved to, the outcome (the action plus
7272
* the refusal's machine code), and the client fingerprint read by the same rule
73-
* better-auth applies when it stamps a session row — so a refused attempt and a
74-
* successful one from the same client carry the same `ip_address`.
73+
* the session hook applies when it stamps a session row — so a refused attempt
74+
* and a successful one from the same client carry the same `ip_address`.
7575
*
7676
* ⛔ Neither event carries anything the caller typed. The password is never
7777
* read here, and the submitted identifier is never recorded either: the row
@@ -81,6 +81,7 @@
8181
*/
8282

8383
import { getIP } from '@better-auth/core/utils/ip';
84+
import { canonicalClientAddress } from './client-address-canonical.js';
8485

8586
/**
8687
* The audit sink's shape, declared LOCALLY so plugin-auth takes no runtime
@@ -205,26 +206,32 @@ export interface SignInClientFingerprint {
205206
}
206207

207208
/**
208-
* Read the client fingerprint of a sign-in request by better-auth's OWN rule —
209-
* the one `internalAdapter.createSession` applies when it stamps a session row
210-
* (`getIP(headers, options)` over the endpoint's headers, and the
211-
* `user-agent` header), which is where every `login` row's `ip_address` comes
212-
* from. A refused sign-in mints no session row to copy them from, so the same
209+
* Read the client fingerprint of a sign-in request by the rule a session row
210+
* is stamped by, which is where every `login` row's `ip_address` comes from:
211+
* `establishedAddress` — the address the server established for the request,
212+
* which the caller reads from `handleRequest`'s request state
213+
* (`readRequestClientAddress`) — else better-auth's own
214+
* `getIP(headers, options)` over the endpoint's headers, either in its
215+
* RFC 5952 text (`client-address-canonical.ts`); and the `user-agent` header.
216+
* A refused sign-in mints no session row to copy them from, so the same
213217
* resolution is applied to the same request instead. ⛔ Not a second IP rule:
214-
* whatever better-auth's `advanced.ipAddress` configuration answers for a
215-
* session row, it answers here.
218+
* the session hook (`auth-manager-database-hooks.ts`, `session.create.before`)
219+
* answers the same two sources in the same order.
216220
*/
217-
export function signInClientFingerprint(ctx: unknown): SignInClientFingerprint {
221+
export function signInClientFingerprint(ctx: unknown, establishedAddress?: string): SignInClientFingerprint {
218222
const c = ctx as { headers?: unknown; request?: { headers?: unknown }; context?: { options?: unknown } } | null;
219223
const headers = (c?.headers ?? c?.request?.headers) as Headers | undefined;
220224
if (!headers || typeof (headers as Headers).get !== 'function') return {};
221-
let ipAddress: string | undefined;
222-
try {
223-
const options = c?.context?.options as Parameters<typeof getIP>[1] | undefined;
224-
ipAddress = options ? str(getIP(headers, options)) : undefined;
225-
} catch {
226-
ipAddress = undefined;
225+
let ipAddress = str(establishedAddress);
226+
if (!ipAddress) {
227+
try {
228+
const options = c?.context?.options as Parameters<typeof getIP>[1] | undefined;
229+
ipAddress = options ? str(getIP(headers, options)) : undefined;
230+
} catch {
231+
ipAddress = undefined;
232+
}
227233
}
234+
ipAddress = canonicalClientAddress(ipAddress);
228235
const userAgent = str(headers.get('user-agent'));
229236
return {
230237
...(ipAddress ? { ipAddress } : {}),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The stored text of a client address (`./client-address-canonical.ts`):
4+
// an IPv4-mapped address unmapped, RFC 5952 for other IPv6, the value as it
5+
// came otherwise. The integration half —
6+
// that every seam storing a sign-in's address goes through it — is
7+
// `session-client-address-capture.test.ts`.
8+
9+
import { describe, it, expect } from 'vitest';
10+
import { canonicalClientAddress } from './client-address-canonical';
11+
12+
describe('canonicalClientAddress', () => {
13+
it.each([
14+
// The four forms the card names.
15+
['the unspecified address in full', '0000:0000:0000:0000:0000:0000:0000:0000', '::'],
16+
['loopback in full', '0000:0000:0000:0000:0000:0000:0000:0001', '::1'],
17+
['a documentation address with a zero run', '2001:0db8:0000:0000:0000:0000:0000:0001', '2001:db8::1'],
18+
['IPv4', '203.0.113.7', '203.0.113.7'],
19+
// RFC 5952 §4.
20+
['uppercase hexadecimal (§4.3)', '2001:DB8::ABCD', '2001:db8::abcd'],
21+
['leading zeros in a group (§4.1)', '2001:0db8::0001', '2001:db8::1'],
22+
['the longest zero run, not the first (§4.2.3)', '2001:0:0:1:0:0:0:1', '2001:0:0:1::1'],
23+
['the first of two equal runs (§4.2.3)', '2001:db8:0:0:1:0:0:1', '2001:db8::1:0:0:1'],
24+
['a single zero group is not compressed (§4.2.2)', '2001:db8:0:1:1:1:1:1', '2001:db8:0:1:1:1:1:1'],
25+
['a misplaced :: is moved to the longest run', '2001:db8::1:0:0:0:1', '2001:db8:0:1::1'],
26+
['already canonical', '::1', '::1'],
27+
// An IPv4-mapped address is the IPv4 address it maps.
28+
['IPv4-mapped', '::ffff:203.0.113.7', '203.0.113.7'],
29+
['IPv4-mapped loopback (a local dual-stack listener)', '::ffff:127.0.0.1', '127.0.0.1'],
30+
['IPv4-mapped in hexadecimal', '0:0:0:0:0:FFFF:CB00:7107', '203.0.113.7'],
31+
['IPv4-mapped in full, uppercase', '0000:0000:0000:0000:0000:FFFF:203.0.113.7', '203.0.113.7'],
32+
['IPv4-mapped with a zone drops the zone', '::ffff:203.0.113.7%eth0', '203.0.113.7'],
33+
['::ffff:0:0/96 only: the IPv4-translated prefix is not unmapped', '::ffff:0:203.0.113.7', '::ffff:0:cb00:7107'],
34+
['an embedded IPv4 that is not mapped is written in hexadecimal', '64:ff9b::203.0.113.7', '64:ff9b::cb00:7107'],
35+
// A zone identifier is kept as it came.
36+
['a zone identifier', 'FE80:0:0:0:0:0:0:1%eth0', 'fe80::1%eth0'],
37+
])('%s: %s → %s', (_label, input, expected) => {
38+
expect(canonicalClientAddress(input)).toBe(expected);
39+
});
40+
41+
it.each([
42+
['an unparseable string', 'not-an-address'],
43+
['a bracketed address', '[::1]'],
44+
['an address with a port', '203.0.113.7:443'],
45+
['surrounding whitespace', ' ::1 '],
46+
['the empty string', ''],
47+
])('%s is returned exactly as it came', (_label, input) => {
48+
expect(canonicalClientAddress(input)).toBe(input);
49+
});
50+
51+
it('undefined stays undefined', () => {
52+
expect(canonicalClientAddress(undefined)).toBeUndefined();
53+
});
54+
});

0 commit comments

Comments
 (0)