Bind Better Auth sessions to devices

This commit is contained in:
2026-05-09 03:46:15 -04:00
parent 0c64a7721e
commit 2dda91c50b
6 changed files with 88 additions and 6 deletions
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS better_auth_session_device_context (
session_id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL,
device_id TEXT NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES better_auth_session (id) ON DELETE CASCADE,
FOREIGN KEY (user_id, device_id) REFERENCES user_devices (user_id, device_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_better_auth_session_device_context_user_device
ON better_auth_session_device_context (user_id, device_id);
+14 -3
View File
@@ -6,9 +6,15 @@ const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]{32,4096}$/;
const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
const BETTER_AUTH_SESSION_QUERY = ` const BETTER_AUTH_SESSION_QUERY = `
SELECT id, userId, expiresAt SELECT
FROM better_auth_session session.id,
WHERE token = ? session.userId,
session.expiresAt,
device_context.device_id AS deviceId
FROM better_auth_session AS session
LEFT JOIN better_auth_session_device_context AS device_context
ON device_context.session_id = session.id
WHERE session.token = ?
`; `;
export interface AuthContext { export interface AuthContext {
@@ -23,6 +29,7 @@ interface BetterAuthSessionRow extends Record<string, unknown> {
id: unknown; id: unknown;
userId: unknown; userId: unknown;
expiresAt: unknown; expiresAt: unknown;
deviceId?: unknown;
} }
export type AuthErrorCode = export type AuthErrorCode =
@@ -115,6 +122,10 @@ async function readBetterAuthSessionContext(
tokenHash, tokenHash,
expiresAt: timestampField(row, "expiresAt"), expiresAt: timestampField(row, "expiresAt"),
}; };
const deviceId = optionalStringField(row, "deviceId");
if (deviceId !== undefined) {
session.deviceId = deviceIdValue(deviceId);
}
if (Date.parse(session.expiresAt) <= now.getTime()) { if (Date.parse(session.expiresAt) <= now.getTime()) {
throw new AuthError("session_expired"); throw new AuthError("session_expired");
} }
+30
View File
@@ -166,6 +166,24 @@ const DEVICE_REVOKE_QUERY = `
revoked_at = COALESCE(revoked_at, ?) revoked_at = COALESCE(revoked_at, ?)
WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL
`; `;
const SESSION_DEVICE_CONTEXT_UPSERT_QUERY = `
INSERT INTO better_auth_session_device_context (
session_id,
user_id,
device_id,
updated_at
)
SELECT ?, ?, ?, ?
WHERE EXISTS (
SELECT 1
FROM better_auth_session
WHERE id = ? AND userId = ?
)
ON CONFLICT(session_id) DO UPDATE SET
user_id = excluded.user_id,
device_id = excluded.device_id,
updated_at = excluded.updated_at
`;
export async function deviceListDocument( export async function deviceListDocument(
env: Env, env: Env,
@@ -208,6 +226,7 @@ export async function registerDeviceDocument(
if (row === null) { if (row === null) {
throw new DevicePersistenceError("device_registration_missing"); throw new DevicePersistenceError("device_registration_missing");
} }
await bindSessionDeviceContext(env, context, registration.deviceId, nowSeconds);
return { return {
version: 1, version: 1,
@@ -216,6 +235,17 @@ export async function registerDeviceDocument(
}; };
} }
async function bindSessionDeviceContext(
env: Env,
context: AuthContext,
deviceId: string,
nowSeconds: number,
): Promise<void> {
await env.ELY_DB.prepare(SESSION_DEVICE_CONTEXT_UPSERT_QUERY)
.bind(context.sessionId, context.userId, deviceId, nowSeconds, context.sessionId, context.userId)
.run();
}
export async function approveDeviceDocument( export async function approveDeviceDocument(
request: Request, request: Request,
env: Env, env: Env,
+20 -3
View File
@@ -213,7 +213,12 @@ describe("api controls", () => {
rateLimitKeys, rateLimitKeys,
d1: testD1Database({ d1: testD1Database({
firstRows: [ firstRows: [
{ id: "session-01", userId: "user-01", expiresAt: "2099-01-01T00:00:00.000Z" }, {
id: "session-01",
userId: "user-01",
expiresAt: "2099-01-01T00:00:00.000Z",
deviceId: "device-01",
},
], ],
queries: d1Queries, queries: d1Queries,
binds: d1Binds, binds: d1Binds,
@@ -223,12 +228,23 @@ describe("api controls", () => {
["GET"], ["GET"],
(context) => (context) =>
Promise.resolve( Promise.resolve(
jsonResponse({ user_id: context.userId, session_id: context.sessionId }, 200), jsonResponse(
{
user_id: context.userId,
session_id: context.sessionId,
device_id: context.deviceId,
},
200,
),
), ),
); );
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { user_id: "user-01", session_id: "session-01" }); assert.deepEqual(await response.json(), {
user_id: "user-01",
session_id: "session-01",
device_id: "device-01",
});
assert.deepEqual(rateLimitKeys, [`local:devices.list:bearer:${tokenHash}`]); assert.deepEqual(rateLimitKeys, [`local:devices.list:bearer:${tokenHash}`]);
assert.deepEqual(kvReads, [authSessionCacheKvKey("local", tokenHash)]); assert.deepEqual(kvReads, [authSessionCacheKvKey("local", tokenHash)]);
assert.equal(d1Queries.length, 1); assert.equal(d1Queries.length, 1);
@@ -243,6 +259,7 @@ describe("api controls", () => {
"", "",
"user-01", "user-01",
]); ]);
assert.equal(auditEvents[0]?.blobs?.[7], "device-01");
}); });
it("rejects expired authenticated sessions", async () => { it("rejects expired authenticated sessions", async () => {
+2
View File
@@ -207,6 +207,7 @@ describe("device routes", () => {
assert.ok(d1.queries[0]?.includes("INSERT INTO user_devices")); assert.ok(d1.queries[0]?.includes("INSERT INTO user_devices"));
assert.ok(d1.queries[0]?.includes("ON CONFLICT(user_id, idempotency_key) DO NOTHING")); assert.ok(d1.queries[0]?.includes("ON CONFLICT(user_id, idempotency_key) DO NOTHING"));
assert.ok(d1.queries[1]?.includes("WHERE user_id = ? AND idempotency_key = ?")); assert.ok(d1.queries[1]?.includes("WHERE user_id = ? AND idempotency_key = ?"));
assert.ok(d1.queries[2]?.includes("better_auth_session_device_context"));
assert.deepEqual(d1.binds[0]?.slice(0, 5), [ assert.deepEqual(d1.binds[0]?.slice(0, 5), [
"user-01", "user-01",
"device-01", "device-01",
@@ -218,6 +219,7 @@ describe("device routes", () => {
assert.equal(typeof d1.binds[0]?.[6], "number"); assert.equal(typeof d1.binds[0]?.[6], "number");
assert.equal(d1.binds[0]?.[7], IDEMPOTENCY_KEY); assert.equal(d1.binds[0]?.[7], IDEMPOTENCY_KEY);
assert.deepEqual(d1.binds[1], ["user-01", IDEMPOTENCY_KEY]); assert.deepEqual(d1.binds[1], ["user-01", IDEMPOTENCY_KEY]);
assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]);
}); });
it("rejects invalid device registration payloads before D1 writes", async () => { it("rejects invalid device registration payloads before D1 writes", async () => {
+11
View File
@@ -13,6 +13,7 @@ const EXPECTED_MIGRATIONS = [
"0004_releases.sql", "0004_releases.sql",
"0005_audit.sql", "0005_audit.sql",
"0006_better_auth.sql", "0006_better_auth.sql",
"0007_better_auth_session_device_context.sql",
]; ];
const USER_SCOPED_TABLES = [ const USER_SCOPED_TABLES = [
"user_devices", "user_devices",
@@ -39,6 +40,7 @@ describe("D1 migrations", () => {
"audit_events", "audit_events",
"better_auth_account", "better_auth_account",
"better_auth_session", "better_auth_session",
"better_auth_session_device_context",
"better_auth_user", "better_auth_user",
"better_auth_verification", "better_auth_verification",
"device_approvals", "device_approvals",
@@ -104,6 +106,15 @@ describe("D1 migrations", () => {
]), ]),
[], [],
); );
assert.deepEqual(
requiredColumns(databasePath, "better_auth_session_device_context", [
"session_id",
"user_id",
"device_id",
"updated_at",
]),
[],
);
}); });
}); });