@remit/auth-service 0.0.15 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -4
- package/src/auth.ts +155 -6
- package/src/config.ts +22 -7
- package/src/identity-store.ts +286 -0
- package/src/index.ts +17 -0
- package/src/kill-switch.test.ts +179 -0
- package/src/people.test.ts +309 -0
- package/src/people.ts +541 -0
- package/src/rate-limit-log.test.ts +19 -14
- package/src/rate-limit-log.ts +1 -1
- package/src/rate-limit.test.ts +15 -8
- package/src/schema/auth-schema-sqlite.ts +48 -2
- package/src/signup.test.ts +5 -21
- package/src/verifier.ts +2 -0
- package/src/schema/meta-schema-sqlite.ts +0 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/auth-service",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "better-auth identity service for the self-host stack",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"./verifier": {
|
|
18
18
|
"types": "./src/verifier.ts",
|
|
19
19
|
"default": "./src/verifier.ts"
|
|
20
|
+
},
|
|
21
|
+
"./schema": {
|
|
22
|
+
"types": "./src/schema/auth-schema-sqlite.ts",
|
|
23
|
+
"default": "./src/schema/auth-schema-sqlite.ts"
|
|
24
|
+
},
|
|
25
|
+
"./people": {
|
|
26
|
+
"types": "./src/people.ts",
|
|
27
|
+
"default": "./src/people.ts"
|
|
20
28
|
}
|
|
21
29
|
},
|
|
22
30
|
"scripts": {
|
|
@@ -29,12 +37,15 @@
|
|
|
29
37
|
"tsx": "*"
|
|
30
38
|
},
|
|
31
39
|
"dependencies": {
|
|
32
|
-
"@remit/logger
|
|
33
|
-
"better-auth": "^1.
|
|
40
|
+
"@remit/logger": "*",
|
|
41
|
+
"better-auth": "^1.7.12",
|
|
34
42
|
"better-sqlite3": "^12.11.1",
|
|
35
43
|
"drizzle-orm": "^0.45.2",
|
|
36
44
|
"jose": "^6.2.3",
|
|
37
|
-
"@types/better-sqlite3": "^7.6.13"
|
|
45
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
46
|
+
"@remit/data-ports": "*",
|
|
47
|
+
"@remit/drizzle-sqlite-schema": "*",
|
|
48
|
+
"@remit/api-openapi-types": "*"
|
|
38
49
|
},
|
|
39
50
|
"license": "MIT",
|
|
40
51
|
"publishConfig": {
|
package/src/auth.ts
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
|
+
import { logger } from "@remit/logger";
|
|
1
2
|
import type { Auth as BetterAuthInstance } from "better-auth";
|
|
2
|
-
import { betterAuth } from "better-auth";
|
|
3
|
+
import { betterAuth, type GenericEndpointContext } from "better-auth";
|
|
3
4
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
import { APIError, createAuthMiddleware, isAPIError } from "better-auth/api";
|
|
4
6
|
import { jwt } from "better-auth/plugins";
|
|
7
|
+
import {
|
|
8
|
+
deleteSessions,
|
|
9
|
+
type IdentityStore,
|
|
10
|
+
isUserDisabled,
|
|
11
|
+
markSignedIn,
|
|
12
|
+
sessionUserOf,
|
|
13
|
+
} from "./identity-store.js";
|
|
5
14
|
import * as authSchemaSqlite from "./schema/auth-schema-sqlite.js";
|
|
6
15
|
|
|
7
16
|
export interface AuthConfig {
|
|
8
17
|
/**
|
|
9
|
-
* Path to the
|
|
10
|
-
*
|
|
11
|
-
*
|
|
18
|
+
* Path to the app database file (#1420). The identity tables live beside
|
|
19
|
+
* the app tables and are driven through the drizzle adapter's `sqlite`
|
|
20
|
+
* provider.
|
|
12
21
|
*/
|
|
13
22
|
connectionString: string;
|
|
14
23
|
secret: string;
|
|
@@ -20,6 +29,7 @@ export interface AuthConfig {
|
|
|
20
29
|
* `disableSignUp`; accounts are then provisioned out-of-band.
|
|
21
30
|
*/
|
|
22
31
|
selfSignUpEnabled: boolean;
|
|
32
|
+
signInEnabled: boolean;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
// The drizzle adapter better-auth runs against. better-sqlite3 and its drizzle
|
|
@@ -52,6 +62,88 @@ const buildAuthAdapter = async (config: AuthConfig) => {
|
|
|
52
62
|
*/
|
|
53
63
|
export const CLIENT_IP_HEADER = "x-remit-client-ip";
|
|
54
64
|
|
|
65
|
+
export const SIGN_IN_DISABLED = {
|
|
66
|
+
code: "SIGN_IN_DISABLED",
|
|
67
|
+
message: "Sign-in is turned off on this server.",
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
export const ACCOUNT_DISABLED = {
|
|
71
|
+
code: "ACCOUNT_DISABLED",
|
|
72
|
+
message: "This account is disabled.",
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
const RESET_TOKEN_TTL_SECONDS = 24 * 60 * 60;
|
|
76
|
+
|
|
77
|
+
const UNKNOWN = "unknown";
|
|
78
|
+
|
|
79
|
+
const clientIpOf = (ctx: GenericEndpointContext | null): string =>
|
|
80
|
+
ctx?.request?.headers.get(CLIENT_IP_HEADER) ??
|
|
81
|
+
ctx?.headers?.get(CLIENT_IP_HEADER) ??
|
|
82
|
+
UNKNOWN;
|
|
83
|
+
|
|
84
|
+
const SESSION_PATHS: ReadonlySet<string> = new Set(["/token", "/get-session"]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The kill switch, then the disabled check on the two paths that carry a
|
|
88
|
+
* session forward. A sign-in that commits its session just after a disable
|
|
89
|
+
* would otherwise keep minting tokens; here that user's sessions go and the
|
|
90
|
+
* mint is refused.
|
|
91
|
+
*/
|
|
92
|
+
const gate = (store: IdentityStore, signInEnabled: boolean) =>
|
|
93
|
+
createAuthMiddleware(async (ctx) => {
|
|
94
|
+
if (!signInEnabled && ctx.path !== "/jwks") {
|
|
95
|
+
throw new APIError("FORBIDDEN", { ...SIGN_IN_DISABLED });
|
|
96
|
+
}
|
|
97
|
+
if (!SESSION_PATHS.has(ctx.path)) return;
|
|
98
|
+
const sessionToken = await ctx.getSignedCookie(
|
|
99
|
+
ctx.context.authCookies.sessionToken.name,
|
|
100
|
+
ctx.context.secret,
|
|
101
|
+
);
|
|
102
|
+
if (!sessionToken) return;
|
|
103
|
+
const user = sessionUserOf(store, sessionToken);
|
|
104
|
+
if (!user?.disabled) return;
|
|
105
|
+
store.immediate(() => {
|
|
106
|
+
deleteSessions(store, user.userId);
|
|
107
|
+
store.audit({
|
|
108
|
+
actor: user.userId,
|
|
109
|
+
target: user.userId,
|
|
110
|
+
action: "SignInRefusedDisabled",
|
|
111
|
+
outcome: "RefusedDisabled",
|
|
112
|
+
clientIp: clientIpOf(ctx),
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
throw new APIError("FORBIDDEN", { ...ACCOUNT_DISABLED });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Every redeem of a reset link leaves a log line carrying its Origin, and a
|
|
120
|
+
* refused one an audit record. A refused token names no user.
|
|
121
|
+
*/
|
|
122
|
+
const resetAudit = (store: IdentityStore) =>
|
|
123
|
+
createAuthMiddleware(async (ctx) => {
|
|
124
|
+
if (ctx.path !== "/reset-password") return;
|
|
125
|
+
const refused = isAPIError(ctx.context.returned);
|
|
126
|
+
// biome-ignore lint/plugin/no-logger-info: a reset redeem is an audit-grade signal
|
|
127
|
+
logger.info(
|
|
128
|
+
{
|
|
129
|
+
origin: ctx.request?.headers.get("origin") ?? null,
|
|
130
|
+
clientIp: clientIpOf(ctx),
|
|
131
|
+
accepted: !refused,
|
|
132
|
+
},
|
|
133
|
+
"Password reset redeem",
|
|
134
|
+
);
|
|
135
|
+
if (!refused) return;
|
|
136
|
+
store.immediate(() =>
|
|
137
|
+
store.audit({
|
|
138
|
+
actor: UNKNOWN,
|
|
139
|
+
target: UNKNOWN,
|
|
140
|
+
action: "PasswordResetRefused",
|
|
141
|
+
outcome: "RefusedToken",
|
|
142
|
+
clientIp: clientIpOf(ctx),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
55
147
|
const intEnv = (name: string, fallback: number): number => {
|
|
56
148
|
const raw = process.env[name];
|
|
57
149
|
if (!raw) return fallback;
|
|
@@ -75,6 +167,7 @@ const intEnv = (name: string, fallback: number): number => {
|
|
|
75
167
|
// the public type stable across any consumer's dependency graph.
|
|
76
168
|
export const createAuth = async (
|
|
77
169
|
config: AuthConfig,
|
|
170
|
+
store: IdentityStore,
|
|
78
171
|
): Promise<BetterAuthInstance> => {
|
|
79
172
|
const database = await buildAuthAdapter(config);
|
|
80
173
|
|
|
@@ -87,6 +180,45 @@ export const createAuth = async (
|
|
|
87
180
|
enabled: true,
|
|
88
181
|
autoSignIn: true,
|
|
89
182
|
disableSignUp: !config.selfSignUpEnabled,
|
|
183
|
+
resetPasswordTokenExpiresIn: RESET_TOKEN_TTL_SECONDS,
|
|
184
|
+
revokeSessionsOnPasswordReset: true,
|
|
185
|
+
onPasswordReset: async ({ user }, request) => {
|
|
186
|
+
store.immediate(() =>
|
|
187
|
+
store.audit({
|
|
188
|
+
actor: user.id,
|
|
189
|
+
target: user.id,
|
|
190
|
+
action: "PasswordResetRedeemed",
|
|
191
|
+
outcome: "Succeeded",
|
|
192
|
+
clientIp: request?.headers.get(CLIENT_IP_HEADER) ?? UNKNOWN,
|
|
193
|
+
}),
|
|
194
|
+
);
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
hooks: {
|
|
198
|
+
before: gate(store, config.signInEnabled),
|
|
199
|
+
after: resetAudit(store),
|
|
200
|
+
},
|
|
201
|
+
databaseHooks: {
|
|
202
|
+
session: {
|
|
203
|
+
create: {
|
|
204
|
+
before: async (session, ctx) => {
|
|
205
|
+
if (!isUserDisabled(store.db, session.userId)) return;
|
|
206
|
+
store.immediate(() =>
|
|
207
|
+
store.audit({
|
|
208
|
+
actor: session.userId,
|
|
209
|
+
target: session.userId,
|
|
210
|
+
action: "SignInRefusedDisabled",
|
|
211
|
+
outcome: "RefusedDisabled",
|
|
212
|
+
clientIp: clientIpOf(ctx),
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
throw new APIError("FORBIDDEN", { ...ACCOUNT_DISABLED });
|
|
216
|
+
},
|
|
217
|
+
after: async (session) => {
|
|
218
|
+
markSignedIn(store, session.userId);
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
},
|
|
90
222
|
},
|
|
91
223
|
// Listing one header, and not better-auth's `x-forwarded-for` default,
|
|
92
224
|
// is what keeps the resolved address out of a caller's reach: an
|
|
@@ -118,10 +250,27 @@ export const createAuth = async (
|
|
|
118
250
|
},
|
|
119
251
|
},
|
|
120
252
|
},
|
|
121
|
-
user: {
|
|
253
|
+
user: {
|
|
254
|
+
modelName: "auth_user",
|
|
255
|
+
additionalFields: {
|
|
256
|
+
disabled: {
|
|
257
|
+
type: "boolean",
|
|
258
|
+
required: false,
|
|
259
|
+
defaultValue: false,
|
|
260
|
+
input: false,
|
|
261
|
+
returned: false,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
122
265
|
session: { modelName: "auth_session" },
|
|
123
266
|
account: { modelName: "auth_account" },
|
|
124
|
-
verification: {
|
|
267
|
+
verification: {
|
|
268
|
+
modelName: "auth_verification",
|
|
269
|
+
storeIdentifier: {
|
|
270
|
+
default: "plain",
|
|
271
|
+
overrides: { "reset-password": "hashed" },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
125
274
|
plugins: [
|
|
126
275
|
jwt({
|
|
127
276
|
schema: { jwks: { modelName: "auth_jwks" } },
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,11 @@ const required = (name: string, value: string | undefined): string => {
|
|
|
11
11
|
return value;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
+
const isSwitchOn = (raw: string | undefined): boolean => {
|
|
15
|
+
const value = (raw ?? "").trim().toLowerCase();
|
|
16
|
+
return value !== "false" && value !== "0" && value !== "no";
|
|
17
|
+
};
|
|
18
|
+
|
|
14
19
|
/**
|
|
15
20
|
* Whether self-service signup is open on the better-auth tier. Mirrors the
|
|
16
21
|
* Cognito user pool's `selfSignUpEnabled` prop: enabled means users can
|
|
@@ -23,10 +28,16 @@ const required = (name: string, value: string | undefined): string => {
|
|
|
23
28
|
*/
|
|
24
29
|
export const resolveSelfSignUpEnabled = (
|
|
25
30
|
raw: string | undefined = process.env.SELF_SIGN_UP_ENABLED,
|
|
26
|
-
): boolean =>
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
): boolean => isSwitchOn(raw);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The self-host kill switch. `SIGN_IN_ENABLED` reads like
|
|
35
|
+
* `SELF_SIGN_UP_ENABLED`: unset or any other value is on, and an explicit
|
|
36
|
+
* `false`/`0`/`no` turns every sign-in, token mint and session read off.
|
|
37
|
+
*/
|
|
38
|
+
export const resolveSignInEnabled = (
|
|
39
|
+
raw: string | undefined = process.env.SIGN_IN_ENABLED,
|
|
40
|
+
): boolean => isSwitchOn(raw);
|
|
30
41
|
|
|
31
42
|
export interface DataConnectionConfig {
|
|
32
43
|
connectionString: string;
|
|
@@ -34,11 +45,14 @@ export interface DataConnectionConfig {
|
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* Resolve where identity data lives, from the same env vars the rest of the
|
|
37
|
-
* self-host stack reads (RFC 036 D5). The identity tables
|
|
38
|
-
* database
|
|
48
|
+
* self-host stack reads (RFC 036 D5). The identity tables live in the app
|
|
49
|
+
* database, so the locator is `SQLITE_APP_DB_PATH`.
|
|
39
50
|
*/
|
|
40
51
|
export const resolveDataConnectionConfig = (): DataConnectionConfig => ({
|
|
41
|
-
connectionString: required(
|
|
52
|
+
connectionString: required(
|
|
53
|
+
"SQLITE_APP_DB_PATH",
|
|
54
|
+
process.env.SQLITE_APP_DB_PATH,
|
|
55
|
+
),
|
|
42
56
|
});
|
|
43
57
|
|
|
44
58
|
/**
|
|
@@ -56,6 +70,7 @@ export const resolveAuthConfig = (): AuthConfig => {
|
|
|
56
70
|
.map((origin) => origin.trim())
|
|
57
71
|
.filter((origin) => origin.length > 0),
|
|
58
72
|
selfSignUpEnabled: resolveSelfSignUpEnabled(),
|
|
73
|
+
signInEnabled: resolveSignInEnabled(),
|
|
59
74
|
};
|
|
60
75
|
};
|
|
61
76
|
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { logger } from "@remit/logger";
|
|
3
|
+
import { and, desc, eq, inArray, isNull, lt } from "drizzle-orm";
|
|
4
|
+
import type {
|
|
5
|
+
BetterSQLite3Database,
|
|
6
|
+
drizzle,
|
|
7
|
+
} from "drizzle-orm/better-sqlite3";
|
|
8
|
+
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
9
|
+
import {
|
|
10
|
+
auth_session,
|
|
11
|
+
auth_user,
|
|
12
|
+
identity_audit_event,
|
|
13
|
+
identity_step_up,
|
|
14
|
+
} from "./schema/auth-schema-sqlite.js";
|
|
15
|
+
|
|
16
|
+
export type AuditAction =
|
|
17
|
+
| "KillSwitchOn"
|
|
18
|
+
| "KillSwitchOff"
|
|
19
|
+
| "StepUpPassed"
|
|
20
|
+
| "StepUpFailed"
|
|
21
|
+
| "SessionsRevoked"
|
|
22
|
+
| "PasswordResetIssued"
|
|
23
|
+
| "PasswordResetRedeemed"
|
|
24
|
+
| "PasswordResetRefused"
|
|
25
|
+
| "UserDisabled"
|
|
26
|
+
| "UserEnabled"
|
|
27
|
+
| "SignInRefusedDisabled"
|
|
28
|
+
| "PeopleRequestRefused";
|
|
29
|
+
|
|
30
|
+
export type AuditOutcome =
|
|
31
|
+
| "Succeeded"
|
|
32
|
+
| "RefusedSelf"
|
|
33
|
+
| "RefusedCallerDisabled"
|
|
34
|
+
| "RefusedSignInOff"
|
|
35
|
+
| "RefusedForeignOrigin"
|
|
36
|
+
| "RefusedStepUp"
|
|
37
|
+
| "RefusedToken"
|
|
38
|
+
| "RefusedDisabled";
|
|
39
|
+
|
|
40
|
+
export interface AuditRecord {
|
|
41
|
+
actor: string;
|
|
42
|
+
target: string;
|
|
43
|
+
action: AuditAction;
|
|
44
|
+
outcome: AuditOutcome;
|
|
45
|
+
clientIp: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const HOST_CLIENT_IP = "host";
|
|
49
|
+
|
|
50
|
+
const AUDIT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
// The MCP connector's tables share the app database. auth-service cannot depend
|
|
53
|
+
// on mcp-server, so the four columns a revocation writes are declared here.
|
|
54
|
+
const mcp_oauth_consent = sqliteTable("mcp_oauth_consent", {
|
|
55
|
+
userId: text("user_id"),
|
|
56
|
+
});
|
|
57
|
+
const mcp_oauth_access_token = sqliteTable("mcp_oauth_access_token", {
|
|
58
|
+
userId: text("user_id"),
|
|
59
|
+
revoked: integer("revoked", { mode: "timestamp_ms" }),
|
|
60
|
+
});
|
|
61
|
+
const mcp_oauth_refresh_token = sqliteTable("mcp_oauth_refresh_token", {
|
|
62
|
+
userId: text("user_id"),
|
|
63
|
+
revoked: integer("revoked", { mode: "timestamp_ms" }),
|
|
64
|
+
});
|
|
65
|
+
const mcp_session = sqliteTable("mcp_session", {
|
|
66
|
+
userId: text("user_id"),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export type IdentityDb = ReturnType<typeof drizzle>;
|
|
70
|
+
|
|
71
|
+
type SqliteDb = BetterSQLite3Database<Record<string, unknown>>;
|
|
72
|
+
|
|
73
|
+
export interface IdentityStore {
|
|
74
|
+
db: IdentityDb;
|
|
75
|
+
immediate<T>(work: () => T): T;
|
|
76
|
+
audit(record: AuditRecord): string;
|
|
77
|
+
relabel(auditEventId: string, record: AuditRecord): void;
|
|
78
|
+
close(): void;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const logAudit = (record: AuditRecord): void => {
|
|
82
|
+
// biome-ignore lint/plugin/no-logger-info: an identity audit record is an audit-grade signal
|
|
83
|
+
logger.info({ audit: record }, "Identity audit event");
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const insertAudit = (db: SqliteDb, record: AuditRecord): string => {
|
|
87
|
+
const now = Date.now();
|
|
88
|
+
const auditEventId = randomUUID();
|
|
89
|
+
db.delete(identity_audit_event)
|
|
90
|
+
.where(
|
|
91
|
+
lt(identity_audit_event.occurredAt, new Date(now - AUDIT_RETENTION_MS)),
|
|
92
|
+
)
|
|
93
|
+
.run();
|
|
94
|
+
db.insert(identity_audit_event)
|
|
95
|
+
.values({ auditEventId, occurredAt: new Date(now), ...record })
|
|
96
|
+
.run();
|
|
97
|
+
return auditEventId;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const writeAudit = (db: SqliteDb, record: AuditRecord): void => {
|
|
101
|
+
insertAudit(db, record);
|
|
102
|
+
logAudit(record);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const identityStore = (db: IdentityDb): IdentityStore => {
|
|
106
|
+
let pending: AuditRecord[] | null = null;
|
|
107
|
+
const sqlite = db.$client;
|
|
108
|
+
|
|
109
|
+
const log = (record: AuditRecord): void => {
|
|
110
|
+
if (pending) pending.push(record);
|
|
111
|
+
else logAudit(record);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const audit = (record: AuditRecord): string => {
|
|
115
|
+
const auditEventId = insertAudit(db, record);
|
|
116
|
+
log(record);
|
|
117
|
+
return auditEventId;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const relabel = (auditEventId: string, record: AuditRecord): void => {
|
|
121
|
+
db.update(identity_audit_event)
|
|
122
|
+
.set({ action: record.action, outcome: record.outcome })
|
|
123
|
+
.where(eq(identity_audit_event.auditEventId, auditEventId))
|
|
124
|
+
.run();
|
|
125
|
+
log(record);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const immediate = <T>(work: () => T): T => {
|
|
129
|
+
pending = [];
|
|
130
|
+
const written = pending;
|
|
131
|
+
const result = (() => {
|
|
132
|
+
try {
|
|
133
|
+
return sqlite.transaction(work).immediate();
|
|
134
|
+
} finally {
|
|
135
|
+
pending = null;
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
for (const record of written) logAudit(record);
|
|
139
|
+
return result;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return { db, immediate, audit, relabel, close: () => sqlite.close() };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
export const openIdentityStore = async (
|
|
146
|
+
filename: string,
|
|
147
|
+
): Promise<IdentityStore> => {
|
|
148
|
+
const { default: Database } = await import("better-sqlite3");
|
|
149
|
+
const { drizzle } = await import("drizzle-orm/better-sqlite3");
|
|
150
|
+
const sqlite = new Database(filename);
|
|
151
|
+
sqlite.pragma("journal_mode = WAL");
|
|
152
|
+
sqlite.pragma("busy_timeout = 5000");
|
|
153
|
+
sqlite.pragma("synchronous = NORMAL");
|
|
154
|
+
sqlite.pragma("foreign_keys = ON");
|
|
155
|
+
return identityStore(drizzle(sqlite));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export interface UserRow {
|
|
159
|
+
userId: string;
|
|
160
|
+
email: string;
|
|
161
|
+
disabled: boolean;
|
|
162
|
+
createdAt: Date;
|
|
163
|
+
lastSignInAt: Date;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const findUser = (
|
|
167
|
+
store: IdentityStore,
|
|
168
|
+
userId: string,
|
|
169
|
+
): UserRow | undefined =>
|
|
170
|
+
store.db
|
|
171
|
+
.select({
|
|
172
|
+
userId: auth_user.id,
|
|
173
|
+
email: auth_user.email,
|
|
174
|
+
disabled: auth_user.disabled,
|
|
175
|
+
createdAt: auth_user.createdAt,
|
|
176
|
+
lastSignInAt: auth_user.lastSignInAt,
|
|
177
|
+
})
|
|
178
|
+
.from(auth_user)
|
|
179
|
+
.where(eq(auth_user.id, userId))
|
|
180
|
+
.get();
|
|
181
|
+
|
|
182
|
+
export const isUserDisabled = (db: SqliteDb, userId: string): boolean =>
|
|
183
|
+
db
|
|
184
|
+
.select({ disabled: auth_user.disabled })
|
|
185
|
+
.from(auth_user)
|
|
186
|
+
.where(eq(auth_user.id, userId))
|
|
187
|
+
.get()?.disabled === true;
|
|
188
|
+
|
|
189
|
+
export const sessionUserOf = (
|
|
190
|
+
store: IdentityStore,
|
|
191
|
+
sessionToken: string,
|
|
192
|
+
): { userId: string; disabled: boolean } | undefined =>
|
|
193
|
+
store.db
|
|
194
|
+
.select({ userId: auth_user.id, disabled: auth_user.disabled })
|
|
195
|
+
.from(auth_session)
|
|
196
|
+
.innerJoin(auth_user, eq(auth_user.id, auth_session.userId))
|
|
197
|
+
.where(eq(auth_session.token, sessionToken))
|
|
198
|
+
.get();
|
|
199
|
+
|
|
200
|
+
export const markSignedIn = (store: IdentityStore, userId: string): void => {
|
|
201
|
+
store.db
|
|
202
|
+
.update(auth_user)
|
|
203
|
+
.set({ lastSignInAt: new Date() })
|
|
204
|
+
.where(eq(auth_user.id, userId))
|
|
205
|
+
.run();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export const deleteSessions = (store: IdentityStore, userId: string): void => {
|
|
209
|
+
store.db.delete(auth_session).where(eq(auth_session.userId, userId)).run();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
export const revokeUser = (store: IdentityStore, userId: string): void => {
|
|
213
|
+
const { db } = store;
|
|
214
|
+
const revoked = new Date();
|
|
215
|
+
deleteSessions(store, userId);
|
|
216
|
+
db.delete(mcp_oauth_consent)
|
|
217
|
+
.where(eq(mcp_oauth_consent.userId, userId))
|
|
218
|
+
.run();
|
|
219
|
+
db.update(mcp_oauth_access_token)
|
|
220
|
+
.set({ revoked })
|
|
221
|
+
.where(
|
|
222
|
+
and(
|
|
223
|
+
eq(mcp_oauth_access_token.userId, userId),
|
|
224
|
+
isNull(mcp_oauth_access_token.revoked),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
.run();
|
|
228
|
+
db.update(mcp_oauth_refresh_token)
|
|
229
|
+
.set({ revoked })
|
|
230
|
+
.where(
|
|
231
|
+
and(
|
|
232
|
+
eq(mcp_oauth_refresh_token.userId, userId),
|
|
233
|
+
isNull(mcp_oauth_refresh_token.revoked),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
.run();
|
|
237
|
+
db.delete(mcp_session).where(eq(mcp_session.userId, userId)).run();
|
|
238
|
+
db.delete(identity_step_up)
|
|
239
|
+
.where(eq(identity_step_up.callerUserId, userId))
|
|
240
|
+
.run();
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const revokeEveryone = (store: IdentityStore): void => {
|
|
244
|
+
const { db } = store;
|
|
245
|
+
const revoked = new Date();
|
|
246
|
+
db.delete(auth_session).run();
|
|
247
|
+
db.delete(mcp_oauth_consent).run();
|
|
248
|
+
db.update(mcp_oauth_access_token)
|
|
249
|
+
.set({ revoked })
|
|
250
|
+
.where(isNull(mcp_oauth_access_token.revoked))
|
|
251
|
+
.run();
|
|
252
|
+
db.update(mcp_oauth_refresh_token)
|
|
253
|
+
.set({ revoked })
|
|
254
|
+
.where(isNull(mcp_oauth_refresh_token.revoked))
|
|
255
|
+
.run();
|
|
256
|
+
db.delete(mcp_session).run();
|
|
257
|
+
db.delete(identity_step_up).run();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export const applySignInSwitch = (
|
|
261
|
+
store: IdentityStore,
|
|
262
|
+
signInEnabled: boolean,
|
|
263
|
+
): void => {
|
|
264
|
+
store.immediate(() => {
|
|
265
|
+
if (!signInEnabled) revokeEveryone(store);
|
|
266
|
+
const latest = store.db
|
|
267
|
+
.select({ action: identity_audit_event.action })
|
|
268
|
+
.from(identity_audit_event)
|
|
269
|
+
.where(
|
|
270
|
+
inArray(identity_audit_event.action, ["KillSwitchOn", "KillSwitchOff"]),
|
|
271
|
+
)
|
|
272
|
+
.orderBy(desc(identity_audit_event.occurredAt))
|
|
273
|
+
.limit(1)
|
|
274
|
+
.get();
|
|
275
|
+
const state: AuditAction = signInEnabled ? "KillSwitchOff" : "KillSwitchOn";
|
|
276
|
+
const changed = latest ? latest.action !== state : !signInEnabled;
|
|
277
|
+
if (!changed) return;
|
|
278
|
+
store.audit({
|
|
279
|
+
actor: "env",
|
|
280
|
+
target: "all",
|
|
281
|
+
action: state,
|
|
282
|
+
outcome: "Succeeded",
|
|
283
|
+
clientIp: HOST_CLIENT_IP,
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { toNodeHandler } from "better-auth/node";
|
|
2
2
|
export {
|
|
3
|
+
ACCOUNT_DISABLED,
|
|
3
4
|
type Auth,
|
|
4
5
|
type AuthConfig,
|
|
5
6
|
CLIENT_IP_HEADER,
|
|
6
7
|
createAuth,
|
|
8
|
+
SIGN_IN_DISABLED,
|
|
7
9
|
} from "./auth.js";
|
|
8
10
|
export {
|
|
9
11
|
AUTH_BASE_PATH,
|
|
@@ -13,9 +15,24 @@ export {
|
|
|
13
15
|
resolveAuthConfig,
|
|
14
16
|
resolveDataConnectionConfig,
|
|
15
17
|
resolveSelfSignUpEnabled,
|
|
18
|
+
resolveSignInEnabled,
|
|
16
19
|
resolveVerifierConfig,
|
|
17
20
|
type VerifierConfig,
|
|
18
21
|
} from "./config.js";
|
|
22
|
+
export {
|
|
23
|
+
type AuditRecord,
|
|
24
|
+
HOST_CLIENT_IP,
|
|
25
|
+
type IdentityStore,
|
|
26
|
+
isUserDisabled,
|
|
27
|
+
openIdentityStore,
|
|
28
|
+
writeAudit,
|
|
29
|
+
} from "./identity-store.js";
|
|
30
|
+
export {
|
|
31
|
+
createPeopleDirectory,
|
|
32
|
+
type PeopleAdmin,
|
|
33
|
+
type PeopleConfig,
|
|
34
|
+
type PeopleDirectory,
|
|
35
|
+
} from "./people.js";
|
|
19
36
|
export {
|
|
20
37
|
type AuthFetchHandler,
|
|
21
38
|
withRateLimitLogging,
|