Skip to content

Commit cb8988e

Browse files
fix(plugin-auth): store the session client address in RFC 5952 text and write last_activity_at at creation (#27003)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XWvbPrZ7kbgMnvn7DKaifV4)_ Fixes #26638 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** 登录时写进 `sys_session` 的两项数据。IP 地址存服务端认定的客户端地址,IPv6 按 RFC 5952 压缩(`::1`、`2001:db8::1`),IPv4 原样;`last_activity_at` 在会话创建时写入,等于 `created_at`。会话列表照旧显示纯文本,界面不动。 - **为什么改:** 本机登录的会话 IP 显示成 `0000:0000:…:0000`,令牌会话的「最后活动时间」是「—」(裁决 D:不加渲染面,修数据)。实测发现那串零不是「未指定地址」:better-auth 把 IPv6 展开并按 /64 掩码后才写库,`::1` 变成全零,`2001:db8::1` 变成 `2001:0db8:0000:…:0000`,丢了地址本身。 - **风险与回滚:** 只影响今后新建的会话,旧行不迁移。IPv4 映射地址(双栈监听下的 IPv4 客户端,`::ffff:203.0.113.7`)按席位裁决存成点分 IPv4 `203.0.113.7`,与 main 上会话列表的显示一致;`last_login_ip` 原来存映射写法,现在也是点分 IPv4。心跳首写从「登录后第一个请求」推迟到「登录满一分钟后的第一个请求」。回滚即还原本 PR。 - **先看:** `auth-manager-database-hooks.ts`(会话创建钩子取服务端认定的地址);`session-first-activity.ts`(为何是 ObjectQL 钩子而非适配器);`client-address-canonical.ts`(压缩规则)。 - **两分钟验证:** `pnpm --filter @objectstack/plugin-auth exec vitest run src/session-client-address-capture.test.ts src/session-first-activity.test.ts`,预期 17 例全绿(加上 `src/client-address-canonical.test.ts` 共 42 例)。 - **需要你做的:** 无,席位落地。 ## What changes Ruling 6455478505 (letter D, the maintainer's): no renderer faces, fix the data. 1. **The session row stores the client address in RFC 5952 text.** A new internal module, `client-address-canonical.ts`, holds the one canonicaliser: `isIPv6` from `node:net`, lowercase hex, no leading zeros, the longest run of two or more zero groups (the first on a tie) written `::`, an IPv4-mapped address (`::ffff:0:0/96`) stored as the dotted IPv4 address it maps (`::ffff:203.0.113.7` → `203.0.113.7`, per the seat ruling on this PR, comment 6457840890 on #26638), a zone identifier kept, IPv4 and anything unparseable returned exactly as it came (never throws, never refuses a sign-in). 2. **The address stored is the one the server established, not better-auth's derivation of it.** Measured on `origin/main` `912076f8`: `handleRequest` already hands better-auth the trust rule's answer (`client-address.ts`, through the platform-owned header in `client-address-vendor.ts`), but better-auth's `getIP` then re-derives it with `normalizeIP(ip, { ipv6Subnet: 64 })` and `internalAdapter.createSession` stores that: every IPv6 address expanded to eight groups and masked to its /64 network, an IPv4-mapped one unmapped. That is its rate-limit bucket, not an address: `::1` is stored as `0000:0000:0000:0000:0000:0000:0000:0000` (the card's "unspecified address" is loopback's /64), and `2001:db8::1` as `2001:0db8:0000:0000:0000:0000:0000:0000`. So `session.create.before` (now always composed, running before the host hook and the ADR-0093 D9 active-organization default) sets `ipAddress` to the canonical form of the address in the request state (`readRequestClientAddress`), falling back to better-auth's value, canonicalised, when no address was established (a programmatic `auth.api.*` call). A host hook that sets `ipAddress` itself still wins. 3. **Every path that stores a sign-in's address goes through the canonicaliser:** - the session row's `ip_address` (`auth-manager-database-hooks.ts`, `session.create.before`), which every session creation passes: password and social sign-in, sign-up auto sign-in, the development admin seed, impersonation and its bearer rotation, the principal-session mint — all are `internalAdapter.createSession`; - the `login` / `logout` audit rows, copied from that row (`auth-session-audit.ts`, unchanged); - the `login_failed` / `account_locked` fingerprint (`signInClientFingerprint`, now taking the established address as an optional second argument, read by both callers in `auth-manager-sign-in-lockout.ts`), so a refused and a successful sign-in from one client still carry one text; - ADR-0069 D7 `sys_user.last_login_ip` (`stampLastLogin`). 4. **`last_activity_at` is written at creation, equal to `created_at`** (`session-first-activity.ts`). better-auth's session model does not declare the column, so its `session.create.before` hook cannot set it (the adapter factory drops undeclared fields). The adapter could, but measured live on `os dev`: ObjectQL's own `beforeInsert` stamp (`sys_stamp_audit_insert`, priority 10) replaces the caller's `created_at` with the insert instant, so an adapter-side copy landed about 5 ms before the stored `created_at`. The hook is an ObjectQL `beforeInsert` on `sys_session` at priority 15, after that stamp, copying the stored value; it is registered in `AuthPlugin.start()`, not on `kernel:ready`, because the development admin seed (a `kernel:ready` hook) mints a session too. A `last_activity_at` the insert already carries is kept. The #25343 heartbeat throttle is untouched. Not changed: `user_agent` / `ip_address` stay plain text with no `format`; nothing under `ui/**`, `packages/spec/**` or `packages/platform-objects`; better-auth's own rate limiter keeps its /64 key; the ADR-0069 D5 allow-list still judges `resolveClientAddress`'s answer as it came (canonicalising inside the rule would change which exact IPv6 entries match, so the canonicaliser is applied only where an address is stored); existing rows are not migrated. ## Why Setup › Sessions printed a localhost session's IP as `0000:0000:0000:0000:0000:0000:0000:0000` and left Last Activity 「—」 for a session with no request after sign-in. The maintainer ruled that the cells stay raw text and the data is fixed. ## Tests - `client-address-canonical.test.ts` (new): the four forms the card names (unspecified and loopback in full, a documentation address with a zero run, IPv4), the RFC 5952 §4 rules one by one, the IPv4-mapped form unmapped in five spellings (mixed, loopback, hexadecimal, full uppercase, with a zone), the neighbouring `::ffff:0:0:0/96` prefix and an embedded IPv4 that are not mapped, a zone identifier, and five values returned exactly as they came (unparseable, bracketed, with a port, padded, empty). - `session-client-address-capture.test.ts` (new): the client-address suites' real-mount fixture (`AuthPlugin.registerAuthRoutes` on Hono in front of a real `AuthManager` on the installed better-auth). A refused then a successful sign-in from `::1`; from a full-form `X-Forwarded-For` behind a declared proxy; from `::ffff:203.0.113.7`; from `203.0.113.7`. Each asserts the session row, the `login` and `login_failed` rows and `last_login_ip`. - `session-first-activity.test.ts` (new): a real kernel (SQL driver, `ObjectQLPlugin` with its `created_at` stamp, `AuthPlugin`, `SecurityPlugin`). The development admin seed's session and a sign-in from `::1` are each born with `last_activity_at` equal to the stored `created_at`, the latter with `ip_address` `::1`; the heartbeat's first write comes only past the minute. - Updated: two pins that asserted `session.create.before` is absent when `autoActiveOrganization` is off (`auth-manager.test.ts`, `auth-session-audit.test.ts`). The hook is now always present; the first now asserts the behaviour its title names (no active organization is filled), the second keeps its point (the `login` writer is wired) without the absence line. ## Acceptance notes - **The mechanism hypothesis in the dispatch order did not hold, and the fix follows the ruling's intent instead.** Canonicalising inside `client-address.ts` changes nothing that is stored: better-auth re-expands and masks whatever it is handed. The canonicaliser lives in its own internal module because `client-address.ts` is a published entry the runtime's inbound rate limiter loads and is kept import-free, and because the D5 allow-list compares exact IPv6 entries as text. The ruling's examples (`::1`, `2001:db8::1`) are only reachable by storing the established address; canonicalising better-auth's masked value would store `::` and `2001:db8::` (ablation C below measures exactly that). - **`Clause-②: yes (widening)`**, re-measured against the ruling's `no`: `signInClientFingerprint` is exported from the package root (`export *` of `auth-session-audit.ts`) and gains an optional second parameter. `Metadata-protocol: none`, so the changeset is `patch`. - **Visible consequences:** an IPv4 client accepted by a dual-stack listener (`::ffff:203.0.113.7`) reads `203.0.113.7` on every row, as its session already did on `main` (better-auth unmapped it); `last_login_ip`, which carried the mapped text on `main`, now carries the dotted one. The order's line keeping RFC 5952 §5 mixed notation is superseded by the seat ruling (#26638 comment 6457840890): the maintainer's ruling keeps IPv4 unchanged. The heartbeat's first write now comes on the first request more than a minute after sign-in. - **Live IPv6 not measured:** this container has no IPv6 (a bare Node listener binds `0.0.0.0`), so the `os dev` probe covers IPv4 and `last_activity_at`; the `::1` path is covered through the real kernel and the real mount instead. - **Branch base:** `origin/main` `912076f8`; `git merge-tree` against `origin/main` `fd246c22c9` is clean, and no commit on `main` since touches `packages/plugins/plugin-auth/`. ## Verification All readings at head `41b24e18e3` unless stated. <details><summary>Red on origin/main (912076f + the new tests only, commit 193576f)</summary> ``` × a loopback IPv6 peer is stored as ::1, not as its expanded /64 network Expected: "::1" Received: "0000:0000:0000:0000:0000:0000:0000:0000" × a forwarded full-form IPv6 client address is stored compressed, … Expected: "2001:db8::1" Received: "2001:0db8:0000:0000:0000:0000:0000:0000" × an IPv4-mapped peer keeps the mixed notation of RFC 5952 §5 Expected: "::ffff:203.0.113.7" Received: "203.0.113.7" (the case before the seat ruling; re-read at 41b24e1 with the fix files restored to 912076f, the reworded case is red on last_login_ip alone: Expected: "203.0.113.7" Received: "::ffff:203.0.113.7" — 6 failed | 11 passed (17)) × the minted row carries last_activity_at equal to created_at (null) × the heartbeat's first beat still waits out its one-minute throttle Tests 5 failed | 32 passed (37) ``` </details> <details><summary>Green: plugin-auth suite and typecheck</summary> ``` pnpm --filter @objectstack/plugin-auth test Test Files 201 passed (201) Tests 3837 passed | 10 skipped (3847) os-verify-lock: VERDICT command-exit 0 pnpm --filter @objectstack/plugin-auth typecheck check:test-typecheck: OK — 10 file(s) / 88 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json os-verify-lock: VERDICT command-exit 0 eslint (the 12 changed .ts files at d0cf335, and the 4 touched again at 41b24e1): exit 0 ``` </details> <details><summary>Ablations (scripts/ablation-replace.mjs, each restored to the HEAD blob with an empty git diff HEAD; the suites import src/, so no build leg)</summary> ``` A auth-manager-database-hooks.ts: captureSessionAddress skipped 4 failed: session ip "0000:0000:0000:0000:0000:0000:0000:0000" / "2001:0db8:0000:…:0000" / "203.0.113.7", and the real-kernel "::1" case C auth-session-audit.ts: established address ignored by the fingerprint 3 failed: login_failed "::" / "2001:db8::" / "203.0.113.7" B auth-plugin.ts: registerSessionFirstActivity not called 3 failed: seed session, ::1 sign-in and heartbeat cases (last_activity_at null) (A, B, C read at d0cf335, before the seat ruling.) D client-address-canonical.ts: the unmap replaced by the §5 mixed notation (at 41b24e1's content) 6 failed: five IPv4-mapped unit cases ("::ffff:203.0.113.7" for "203.0.113.7", …) and the dual-stack sign-in case — 6 failed | 36 passed (42) ``` </details> <details><summary>Live os dev probe (showcase, --fresh, sign-in over 127.0.0.1, GET /api/v1/data/sys_session)</summary> ``` ip_address | created_at | last_activity_at | user_agent '' | 2026-10-28T00:23:18.450Z | 2026-10-28T00:23:18.450Z | '' (development admin seed's session) '127.0.0.1' | 2026-10-28T00:23:23.337Z | 2026-10-28T00:23:23.337Z | 'curl/8.5.0' ``` The adapter-side first attempt read `last_activity_at` 5 ms before `created_at` and left the seed's session null; both are what moved the write into the ObjectQL hook registered in `start()`. </details> <details><summary>Derived gates at 41b24e1 — 65 of 65 exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives 65 commands; the same 65 are derived on a local merge with origin/main fd246c2 (merge-tree clean, no upstream change under plugin-auth). Each was run after a full `pnpm build` of this head, exit code captured before any pipe: ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-tenant-audit-census.mjs :: exit 0 node scripts/check-tenant-audit-census.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:auth-mount-ledger :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:route-envelope :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `dispatch-gates --ran`: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero — every line carries its exit code). Also: `pnpm --filter @objectstack/plugin-auth test` 201 files / 3837 passed / 10 skipped, typecheck green, `eslint` on the 12 touched `.ts` files exit 0. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5m5t6zNbh2Xwovtric)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c79eda5 commit cb8988e

13 files changed

Lines changed: 683 additions & 34 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)