okengine 0.4.3 → 0.5.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 +3 -1
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/gate.mdx +187 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/get-started/basic-usage.mdx +4 -3
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +1 -1
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +128 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +116 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/fx.mdx +20 -5
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +117 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/index.ts +11 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +1 -0
- package/src/kernel/fx.ts +60 -0
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +12 -0
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/config-source.test.ts +1 -1
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/index.ts +31 -0
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey.ts +216 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/security-headers.test.ts +14 -14
- package/src/plugins/two-factor.ts +249 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +1 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialize Gate auth HTTP surfaces as real {@link Binding}s.
|
|
3
|
+
*
|
|
4
|
+
* Must be pushed into `adopted` + the smart router **before**
|
|
5
|
+
* `assertHttpGatePosture` — never registry `flows[]` alone.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { gate, type PolicyGateDecl } from "../elements/gate.ts";
|
|
10
|
+
import type { GateDecl } from "../elements/gate/declare.ts";
|
|
11
|
+
import { fail } from "../kernel/errors.ts";
|
|
12
|
+
import { flow, type AnyFlowDef } from "../kernel/flow.ts";
|
|
13
|
+
import type { Binding } from "../kernel/on.ts";
|
|
14
|
+
import { http, normalizeTrigger, type HttpTrigger, type Trigger } from "../kernel/triggers.ts";
|
|
15
|
+
import type { ResolvedGateAuth } from "./config.ts";
|
|
16
|
+
import { BreachCheckError } from "./breach-check.ts";
|
|
17
|
+
import {
|
|
18
|
+
authenticateUser,
|
|
19
|
+
createIdentityStore,
|
|
20
|
+
createUserWithPassword,
|
|
21
|
+
getUserById,
|
|
22
|
+
IdentityError,
|
|
23
|
+
type IdentityStore,
|
|
24
|
+
} from "./identity.ts";
|
|
25
|
+
import { PasswordPolicyError } from "./password-policy.ts";
|
|
26
|
+
import {
|
|
27
|
+
createSessionStore,
|
|
28
|
+
issueSessionWithScopes,
|
|
29
|
+
revokeFamily,
|
|
30
|
+
rotateRefresh,
|
|
31
|
+
SessionError,
|
|
32
|
+
type SessionCrypto,
|
|
33
|
+
type SessionStore,
|
|
34
|
+
} from "./sessions.ts";
|
|
35
|
+
import { touchRateLimit, type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
|
|
36
|
+
|
|
37
|
+
/** Built-in policy: verified user session (for `/auth/me` and step-up surfaces). */
|
|
38
|
+
export const AUTH_SESSION_GATE: PolicyGateDecl = gate.policy(
|
|
39
|
+
"auth.session",
|
|
40
|
+
({ auth }) => !!auth.userId && auth.verified === true,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Built-in policy: session within `freshAge` (step-up). Requires `meta.sessionCreatedAt`. */
|
|
44
|
+
export function createFreshSessionGate(freshAgeMs: number): GateDecl {
|
|
45
|
+
return gate.policy("auth.fresh", (ctx) => {
|
|
46
|
+
if (!ctx.auth.userId || ctx.auth.verified !== true) return false;
|
|
47
|
+
const createdAt = ctx.meta?.sessionCreatedAt;
|
|
48
|
+
if (typeof createdAt !== "number") return false;
|
|
49
|
+
return Date.now() - createdAt <= freshAgeMs;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Auth rate presets (stricter on credential paths). */
|
|
54
|
+
export const AUTH_RATE_PRESETS = {
|
|
55
|
+
signIn: { max: 5, per: "1m", keyBy: "ip" },
|
|
56
|
+
signUp: { max: 10, per: "1m", keyBy: "ip" },
|
|
57
|
+
refresh: { max: 30, per: "1m", keyBy: "ip" },
|
|
58
|
+
otp: { max: 5, per: "1m", keyBy: "ip" },
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
/** Runtime context shared by auth Flows. */
|
|
62
|
+
export interface AuthRuntimeContext {
|
|
63
|
+
readonly config: ResolvedGateAuth;
|
|
64
|
+
readonly sessions: SessionStore;
|
|
65
|
+
readonly identities: IdentityStore;
|
|
66
|
+
readonly loginAttempts: LoginAttemptBag;
|
|
67
|
+
readonly authGates: readonly GateDecl[];
|
|
68
|
+
now(): number;
|
|
69
|
+
sessionCrypto(): SessionCrypto;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Result of {@link createAuthHttpBindings}. */
|
|
73
|
+
export interface AuthHttpMaterialization {
|
|
74
|
+
readonly bindings: readonly Binding[];
|
|
75
|
+
readonly flows: {
|
|
76
|
+
readonly refresh: AnyFlowDef;
|
|
77
|
+
readonly revoke: AnyFlowDef;
|
|
78
|
+
readonly me: AnyFlowDef;
|
|
79
|
+
readonly signInEmail?: AnyFlowDef;
|
|
80
|
+
readonly signUpEmail?: AnyFlowDef;
|
|
81
|
+
};
|
|
82
|
+
readonly ctx: AuthRuntimeContext;
|
|
83
|
+
/** Rate + policy gates to merge into boot `gates`. */
|
|
84
|
+
readonly authGates: readonly GateDecl[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const SessionTokensOut = z.object({
|
|
88
|
+
accessToken: z.string(),
|
|
89
|
+
refreshToken: z.string(),
|
|
90
|
+
accessExpiresAt: z.number(),
|
|
91
|
+
userId: z.string().optional(),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const RefreshIn = z.object({
|
|
95
|
+
refreshToken: z.string().min(1),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const RevokeIn = z.object({
|
|
99
|
+
refreshToken: z.string().min(1).optional(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const EmailPasswordIn = z.object({
|
|
103
|
+
email: z.string().min(3),
|
|
104
|
+
password: z.string().min(1),
|
|
105
|
+
name: z.string().optional(),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const AuthFailed = z.object({
|
|
109
|
+
reason: z.string().optional(),
|
|
110
|
+
/** Password-policy failure details (`reason: "password_policy"`). */
|
|
111
|
+
reasons: z.array(z.string()).optional(),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const AuthRateLimited = z.object({
|
|
115
|
+
reason: z.string(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const MeOut = z.object({
|
|
119
|
+
userId: z.string(),
|
|
120
|
+
email: z.string(),
|
|
121
|
+
name: z.string(),
|
|
122
|
+
emailVerified: z.boolean(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build rate gate decls for auth paths (when rate limiting enabled).
|
|
127
|
+
*
|
|
128
|
+
* @param enabled - Whether presets are active
|
|
129
|
+
*/
|
|
130
|
+
export function createAuthRateGates(enabled: boolean): GateDecl[] {
|
|
131
|
+
if (!enabled) return [];
|
|
132
|
+
return [
|
|
133
|
+
gate.rate({
|
|
134
|
+
max: AUTH_RATE_PRESETS.signIn.max,
|
|
135
|
+
per: AUTH_RATE_PRESETS.signIn.per,
|
|
136
|
+
keyBy: AUTH_RATE_PRESETS.signIn.keyBy,
|
|
137
|
+
description: "Auth sign-in rate limit",
|
|
138
|
+
}),
|
|
139
|
+
gate.rate({
|
|
140
|
+
max: AUTH_RATE_PRESETS.signUp.max,
|
|
141
|
+
per: AUTH_RATE_PRESETS.signUp.per,
|
|
142
|
+
keyBy: AUTH_RATE_PRESETS.signUp.keyBy,
|
|
143
|
+
description: "Auth sign-up rate limit",
|
|
144
|
+
}),
|
|
145
|
+
gate.rate({
|
|
146
|
+
max: AUTH_RATE_PRESETS.refresh.max,
|
|
147
|
+
per: AUTH_RATE_PRESETS.refresh.per,
|
|
148
|
+
keyBy: AUTH_RATE_PRESETS.refresh.keyBy,
|
|
149
|
+
description: "Auth refresh rate limit",
|
|
150
|
+
}),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Policy gates contributed by Gate auth (session + optional fresh).
|
|
156
|
+
*
|
|
157
|
+
* @param config - Resolved auth
|
|
158
|
+
*/
|
|
159
|
+
export function createAuthPolicyGates(config: ResolvedGateAuth): GateDecl[] {
|
|
160
|
+
return [AUTH_SESSION_GATE, createFreshSessionGate(config.session.freshAgeMs)];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Materialize auth HTTP Bindings for the same `adopted` / router / posture path.
|
|
165
|
+
*
|
|
166
|
+
* @param config - Resolved gate.auth
|
|
167
|
+
* @param options - Rate-limit enable + optional shared stores
|
|
168
|
+
*/
|
|
169
|
+
export function createAuthHttpBindings(
|
|
170
|
+
config: ResolvedGateAuth,
|
|
171
|
+
options: {
|
|
172
|
+
readonly rateLimitEnabled?: boolean;
|
|
173
|
+
readonly sessions?: SessionStore;
|
|
174
|
+
readonly identities?: IdentityStore;
|
|
175
|
+
} = {},
|
|
176
|
+
): AuthHttpMaterialization {
|
|
177
|
+
const sessions = options.sessions ?? config.sessions ?? createSessionStore();
|
|
178
|
+
const identities = options.identities ?? createIdentityStore();
|
|
179
|
+
const loginAttempts = createLoginAttemptBag();
|
|
180
|
+
const rateGates = createAuthRateGates(options.rateLimitEnabled !== false);
|
|
181
|
+
const policyGates = createAuthPolicyGates(config);
|
|
182
|
+
const authGates = [...policyGates, ...rateGates];
|
|
183
|
+
const signInRate = rateGates[0];
|
|
184
|
+
const signUpRate = rateGates[1];
|
|
185
|
+
const refreshRate = rateGates[2];
|
|
186
|
+
|
|
187
|
+
const ctx: AuthRuntimeContext = {
|
|
188
|
+
config,
|
|
189
|
+
sessions,
|
|
190
|
+
identities,
|
|
191
|
+
loginAttempts,
|
|
192
|
+
authGates,
|
|
193
|
+
now: () => (config.now ?? (() => Date.now()))(),
|
|
194
|
+
sessionCrypto: () => ({
|
|
195
|
+
secret: config.secret,
|
|
196
|
+
now: () => ctx.now(),
|
|
197
|
+
accessTtlMs: config.session.accessTtlMs,
|
|
198
|
+
refreshTtlMs: config.session.refreshTtlMs,
|
|
199
|
+
idleTtlMs: config.session.idleTtlMs,
|
|
200
|
+
absoluteTtlMs: config.session.absoluteTtlMs,
|
|
201
|
+
singleSessionPerUser: config.session.singleSessionPerUser,
|
|
202
|
+
audience: config.audience,
|
|
203
|
+
}),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const base = config.basePath;
|
|
207
|
+
|
|
208
|
+
const refresh = flow({
|
|
209
|
+
name: "auth.refresh",
|
|
210
|
+
unit: "auth",
|
|
211
|
+
plane: "user",
|
|
212
|
+
in: RefreshIn,
|
|
213
|
+
out: SessionTokensOut,
|
|
214
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
215
|
+
do: async (input) => {
|
|
216
|
+
try {
|
|
217
|
+
const issued = await rotateRefresh(sessions, ctx.sessionCrypto(), input.refreshToken);
|
|
218
|
+
return {
|
|
219
|
+
accessToken: issued.accessToken,
|
|
220
|
+
refreshToken: issued.refreshToken,
|
|
221
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
222
|
+
userId: issued.session.principalId,
|
|
223
|
+
};
|
|
224
|
+
} catch (err) {
|
|
225
|
+
if (err instanceof SessionError) {
|
|
226
|
+
// Enumeration-safe: same error shape for unknown / reused / expired.
|
|
227
|
+
return fail("AuthFailed", { reason: "invalid_refresh" });
|
|
228
|
+
}
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const revoke = flow({
|
|
235
|
+
name: "auth.revoke",
|
|
236
|
+
unit: "auth",
|
|
237
|
+
plane: "user",
|
|
238
|
+
in: RevokeIn,
|
|
239
|
+
out: z.object({ ok: z.literal(true) }),
|
|
240
|
+
errors: { AuthFailed },
|
|
241
|
+
do: async (input) => {
|
|
242
|
+
if (input.refreshToken) {
|
|
243
|
+
try {
|
|
244
|
+
const hash = await hashRefresh(input.refreshToken);
|
|
245
|
+
const row = [...sessions.refresh.values()].find((r) => r.hash === hash);
|
|
246
|
+
if (row) revokeFamily(sessions, row.familyId, ctx.now());
|
|
247
|
+
} catch {
|
|
248
|
+
/* ignore — revoke is idempotent */
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { ok: true as const };
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const me = flow({
|
|
256
|
+
name: "auth.me",
|
|
257
|
+
unit: "auth",
|
|
258
|
+
plane: "user",
|
|
259
|
+
out: MeOut,
|
|
260
|
+
errors: { AuthFailed },
|
|
261
|
+
do: (_input, fx) => {
|
|
262
|
+
const userId = fx.auth.userId;
|
|
263
|
+
if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
|
|
264
|
+
const user = getUserById(identities, userId);
|
|
265
|
+
if (!user) return fail("AuthFailed", { reason: "unauthenticated" });
|
|
266
|
+
return {
|
|
267
|
+
userId: user.id,
|
|
268
|
+
email: user.email,
|
|
269
|
+
name: user.name,
|
|
270
|
+
emailVerified: user.emailVerified,
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const flows: {
|
|
276
|
+
refresh: AnyFlowDef;
|
|
277
|
+
revoke: AnyFlowDef;
|
|
278
|
+
me: AnyFlowDef;
|
|
279
|
+
signInEmail?: AnyFlowDef;
|
|
280
|
+
signUpEmail?: AnyFlowDef;
|
|
281
|
+
} = { refresh, revoke, me };
|
|
282
|
+
const bindings: Binding[] = [
|
|
283
|
+
bindAuthHttp(
|
|
284
|
+
http.post(`${base}/refresh`).gate(gate.public, ...(refreshRate ? [refreshRate] : [])),
|
|
285
|
+
refresh,
|
|
286
|
+
),
|
|
287
|
+
bindAuthHttp(http.post(`${base}/revoke`).gate(gate.public), revoke),
|
|
288
|
+
bindAuthHttp(http.get(`${base}/me`).gate(AUTH_SESSION_GATE), me),
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
if (config.emailAndPassword.enabled) {
|
|
292
|
+
const signInEmail = flow({
|
|
293
|
+
name: "auth.signInEmail",
|
|
294
|
+
unit: "auth",
|
|
295
|
+
plane: "user",
|
|
296
|
+
in: EmailPasswordIn,
|
|
297
|
+
out: SessionTokensOut,
|
|
298
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
299
|
+
do: async (input) => {
|
|
300
|
+
const key = input.email.trim().toLowerCase();
|
|
301
|
+
let bag = loginAttempts.get(key);
|
|
302
|
+
if (!bag) {
|
|
303
|
+
bag = [];
|
|
304
|
+
loginAttempts.set(key, bag);
|
|
305
|
+
}
|
|
306
|
+
if (touchRateLimit(bag, ctx.now()) === "rate_limited") {
|
|
307
|
+
return fail("AuthRateLimited", {
|
|
308
|
+
reason: "too many sign-in attempts; retry later",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const user = await authenticateUser(identities, input.email, input.password);
|
|
312
|
+
// Enumeration hygiene: identical failure for unknown email / bad password.
|
|
313
|
+
if (!user) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
314
|
+
if (config.emailAndPassword.requireEmailVerification && !user.emailVerified) {
|
|
315
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
316
|
+
}
|
|
317
|
+
const issued = await issueSessionWithScopes(sessions, ctx.sessionCrypto(), {
|
|
318
|
+
id: user.id,
|
|
319
|
+
plane: "user",
|
|
320
|
+
scopes: [],
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
accessToken: issued.accessToken,
|
|
324
|
+
refreshToken: issued.refreshToken,
|
|
325
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
326
|
+
userId: user.id,
|
|
327
|
+
};
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const signUpEmail = flow({
|
|
332
|
+
name: "auth.signUpEmail",
|
|
333
|
+
unit: "auth",
|
|
334
|
+
plane: "user",
|
|
335
|
+
in: EmailPasswordIn,
|
|
336
|
+
out: SessionTokensOut,
|
|
337
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
338
|
+
do: async (input) => {
|
|
339
|
+
try {
|
|
340
|
+
const user = await createUserWithPassword(identities, {
|
|
341
|
+
email: input.email,
|
|
342
|
+
password: input.password,
|
|
343
|
+
name: input.name,
|
|
344
|
+
passwordPolicy: config.passwordPolicy,
|
|
345
|
+
passwordHash: config.password,
|
|
346
|
+
breachCheck: config.breachCheck,
|
|
347
|
+
now: () => ctx.now(),
|
|
348
|
+
});
|
|
349
|
+
if (config.hooks?.user?.create?.after) {
|
|
350
|
+
await config.hooks.user.create.after({
|
|
351
|
+
id: user.id,
|
|
352
|
+
email: user.email,
|
|
353
|
+
name: user.name,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
const issued = await issueSessionWithScopes(sessions, ctx.sessionCrypto(), {
|
|
357
|
+
id: user.id,
|
|
358
|
+
plane: "user",
|
|
359
|
+
scopes: [],
|
|
360
|
+
});
|
|
361
|
+
return {
|
|
362
|
+
accessToken: issued.accessToken,
|
|
363
|
+
refreshToken: issued.refreshToken,
|
|
364
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
365
|
+
userId: user.id,
|
|
366
|
+
};
|
|
367
|
+
} catch (err) {
|
|
368
|
+
if (err instanceof PasswordPolicyError) {
|
|
369
|
+
return fail("AuthFailed", {
|
|
370
|
+
reason: "password_policy",
|
|
371
|
+
reasons: [...err.reasons],
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
if (err instanceof BreachCheckError) {
|
|
375
|
+
return fail("AuthFailed", { reason: "password_breached" });
|
|
376
|
+
}
|
|
377
|
+
if (err instanceof IdentityError) {
|
|
378
|
+
// Enumeration-safe on email_taken — same shape as generic failure.
|
|
379
|
+
if (err.code === "email_taken") {
|
|
380
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
381
|
+
}
|
|
382
|
+
return fail("AuthFailed", { reason: err.code });
|
|
383
|
+
}
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
flows.signInEmail = signInEmail;
|
|
390
|
+
flows.signUpEmail = signUpEmail;
|
|
391
|
+
bindings.push(
|
|
392
|
+
bindAuthHttp(
|
|
393
|
+
http.post(`${base}/sign-in/email`).gate(gate.public, ...(signInRate ? [signInRate] : [])),
|
|
394
|
+
signInEmail,
|
|
395
|
+
),
|
|
396
|
+
bindAuthHttp(
|
|
397
|
+
http.post(`${base}/sign-up/email`).gate(gate.public, ...(signUpRate ? [signUpRate] : [])),
|
|
398
|
+
signUpEmail,
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return { bindings, flows, ctx, authGates };
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Whether a session is still within the fresh-age window.
|
|
408
|
+
*
|
|
409
|
+
* @param createdAt - Session creation epoch-ms
|
|
410
|
+
* @param freshAgeMs - Fresh window
|
|
411
|
+
* @param now - Clock
|
|
412
|
+
*/
|
|
413
|
+
export function isSessionFresh(
|
|
414
|
+
createdAt: number,
|
|
415
|
+
freshAgeMs: number,
|
|
416
|
+
now: number = Date.now(),
|
|
417
|
+
): boolean {
|
|
418
|
+
return now - createdAt <= freshAgeMs;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Bind without touching the global `on` registry.
|
|
423
|
+
*
|
|
424
|
+
* @param trigger - HTTP trigger (must already carry posture)
|
|
425
|
+
* @param flowDef - Flow
|
|
426
|
+
*/
|
|
427
|
+
export function bindAuthHttp(trigger: HttpTrigger, flowDef: AnyFlowDef): Binding {
|
|
428
|
+
const withPosture = trigger.gates.length > 0 ? trigger : trigger.gate(gate.public);
|
|
429
|
+
const normalized = normalizeTrigger(withPosture);
|
|
430
|
+
const list = flowDef.triggers as Trigger[];
|
|
431
|
+
list.push(normalized);
|
|
432
|
+
(flowDef as { $trigger: Trigger }).$trigger = normalized;
|
|
433
|
+
return { trigger: normalized, flow: flowDef };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function hashRefresh(raw: string): Promise<string> {
|
|
437
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw));
|
|
438
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
439
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable password breach check — optional Have I Been Pwned k-anonymity helper.
|
|
3
|
+
*
|
|
4
|
+
* HIBP Pwned Passwords range API (no API key):
|
|
5
|
+
* `GET https://api.pwnedpasswords.com/range/{5-char-SHA1-prefix}`
|
|
6
|
+
* with required `User-Agent`. Client matches the remaining suffix locally.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Returns `true` when the password should be rejected as breached. */
|
|
10
|
+
export type BreachCheckFn = (password: string) => Promise<boolean>;
|
|
11
|
+
|
|
12
|
+
/** Behaviour when the breach-check network call fails. */
|
|
13
|
+
export type BreachCheckErrorMode = "reject" | "allow";
|
|
14
|
+
|
|
15
|
+
/** Options for {@link createHibpBreachCheck}. */
|
|
16
|
+
export interface HibpBreachCheckOptions {
|
|
17
|
+
/** Required identifying User-Agent (HIBP terms). */
|
|
18
|
+
readonly userAgent: string;
|
|
19
|
+
/** Injectable fetch (tests). Defaults to global `fetch`. */
|
|
20
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
21
|
+
/** Send `Add-Padding: true` (recommended). Default true. */
|
|
22
|
+
readonly padding?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* When the range API errors / is unreachable.
|
|
25
|
+
* Default `"reject"` (fail closed while breach check is enabled).
|
|
26
|
+
*/
|
|
27
|
+
readonly onError?: BreachCheckErrorMode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HIBP_RANGE_URL = "https://api.pwnedpasswords.com/range/";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* SHA-1 hex digest of a UTF-8 string (uppercase).
|
|
34
|
+
*
|
|
35
|
+
* @param password - Plaintext
|
|
36
|
+
*/
|
|
37
|
+
export async function sha1HexUpper(password: string): Promise<string> {
|
|
38
|
+
const data = new TextEncoder().encode(password);
|
|
39
|
+
const digest = await crypto.subtle.digest("SHA-1", data);
|
|
40
|
+
return [...new Uint8Array(digest)]
|
|
41
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
42
|
+
.join("")
|
|
43
|
+
.toUpperCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Create a Have I Been Pwned k-anonymity breach checker.
|
|
48
|
+
*
|
|
49
|
+
* Never sends the password or full hash — only the first 5 hex chars of SHA-1.
|
|
50
|
+
*
|
|
51
|
+
* @param options - User-Agent and fetch knobs
|
|
52
|
+
*/
|
|
53
|
+
export function createHibpBreachCheck(options: HibpBreachCheckOptions): BreachCheckFn {
|
|
54
|
+
const fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
55
|
+
const onError = options.onError ?? "reject";
|
|
56
|
+
const padding = options.padding !== false;
|
|
57
|
+
|
|
58
|
+
return async (password) => {
|
|
59
|
+
const full = await sha1HexUpper(password);
|
|
60
|
+
const prefix = full.slice(0, 5);
|
|
61
|
+
const suffix = full.slice(5);
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetchFn(`${HIBP_RANGE_URL}${prefix}`, {
|
|
64
|
+
headers: {
|
|
65
|
+
"User-Agent": options.userAgent,
|
|
66
|
+
...(padding ? { "Add-Padding": "true" } : {}),
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
if (onError === "allow") return false;
|
|
71
|
+
throw new BreachCheckError(`HIBP range HTTP ${res.status}`);
|
|
72
|
+
}
|
|
73
|
+
const body = await res.text();
|
|
74
|
+
for (const line of body.split("\n")) {
|
|
75
|
+
const [hashSuffix, countStr] = line.trim().split(":");
|
|
76
|
+
if (!hashSuffix || hashSuffix.length === 0) continue;
|
|
77
|
+
const count = Number(countStr ?? "0");
|
|
78
|
+
// Padded rows always have count 0 — discard.
|
|
79
|
+
if (count === 0) continue;
|
|
80
|
+
if (hashSuffix.toUpperCase() === suffix) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof BreachCheckError) throw err;
|
|
85
|
+
if (onError === "allow") return false;
|
|
86
|
+
throw new BreachCheckError(err instanceof Error ? err.message : "HIBP range request failed");
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run an optional breach check. No-op when `check` is omitted.
|
|
93
|
+
*
|
|
94
|
+
* @param password - Plaintext
|
|
95
|
+
* @param check - Pluggable checker (`true` = breached)
|
|
96
|
+
*/
|
|
97
|
+
export async function assertNotBreached(
|
|
98
|
+
password: string,
|
|
99
|
+
check: BreachCheckFn | undefined,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
if (!check) return;
|
|
102
|
+
const breached = await check(password);
|
|
103
|
+
if (breached) throw new BreachCheckError("password appears in a known breach");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Breach check rejected the password or failed closed. */
|
|
107
|
+
export class BreachCheckError extends Error {
|
|
108
|
+
constructor(message: string) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "BreachCheckError";
|
|
111
|
+
}
|
|
112
|
+
}
|