@zeyolabs/kit 0.0.0-stage → 2026.11.6
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/README.md +102 -2
- package/auth/accounts/accounts.remote.ts +116 -0
- package/auth/accounts/accounts.ts +332 -0
- package/auth/accounts/handle.ts +102 -0
- package/auth/accounts/invites.ts +149 -0
- package/auth/accounts/permissions.ts +90 -0
- package/auth/accounts/schemas.ts +22 -0
- package/auth/config.ts +77 -0
- package/auth/emails.ts +57 -0
- package/auth/schema.ts +152 -0
- package/auth/sessions/handle.ts +124 -0
- package/auth/sessions/magic-link/magic-link.ts +71 -0
- package/auth/sessions/passkeys/client.ts +115 -0
- package/auth/sessions/passkeys/passkeys.ts +295 -0
- package/auth/sessions/sessions.ts +101 -0
- package/auth/sveltekit/loads.ts +59 -0
- package/auth/sveltekit/login.ts +28 -0
- package/auth/sveltekit/routes.ts +264 -0
- package/auth/users.ts +49 -0
- package/cloudflare/access.ts +134 -0
- package/cloudflare/amqp/client.ts +276 -0
- package/cloudflare/amqp/testing.ts +9 -0
- package/cloudflare/context.ts +66 -0
- package/cloudflare/dispatch/client.ts +30 -0
- package/cloudflare/email/base.ts +59 -0
- package/cloudflare/email/client.ts +51 -0
- package/cloudflare/email/dev.ts +14 -0
- package/cloudflare/email/smtp.ts +65 -0
- package/cloudflare/email/testing.ts +18 -0
- package/cloudflare/r2/client.ts +154 -0
- package/cloudflare/r2/testing.ts +43 -0
- package/cloudflare/rate-limiter.ts +65 -0
- package/cloudflare/wait-until.ts +12 -0
- package/cloudflare/workers/index.ts +311 -0
- package/cloudflare/workers/testing.ts +94 -0
- package/cloudflare/workers/ticking-do.ts +104 -0
- package/cloudflare/workers-ai/client.ts +30 -0
- package/cloudflare/workers-ai/testing.ts +17 -0
- package/cloudflare/workers-env.ts +12 -0
- package/eslint-config.js +115 -0
- package/monitoring/alerts/client.ts +48 -0
- package/monitoring/alerts/event-config.ts +33 -0
- package/monitoring/alerts/policy.ts +39 -0
- package/monitoring/alerts/registry.ts +44 -0
- package/monitoring/gates/client.ts +90 -0
- package/monitoring/gates/dev.ts +29 -0
- package/monitoring/gates/testing.ts +30 -0
- package/monitoring/logger/client.ts +66 -0
- package/monitoring/prometheus/client.ts +147 -0
- package/monitoring/prometheus/dev.ts +23 -0
- package/monitoring/prometheus/gen/remote_write_pb.ts +105 -0
- package/monitoring/prometheus/metrics.ts +180 -0
- package/monitoring/prometheus/testing.ts +19 -0
- package/monitoring/slack/api.ts +68 -0
- package/monitoring/slack/client.ts +33 -0
- package/monitoring/slack/events.ts +15 -0
- package/package.json +156 -5
- package/server/logger.ts +63 -0
- package/server/rate-limit-client.ts +88 -0
- package/server/rate-limit.ts +24 -0
- package/server/remote-functions.ts +139 -0
- package/server/result.ts +18 -0
- package/server/turnstile.ts +19 -0
- package/storage/clickhouse/client.ts +441 -0
- package/storage/clickhouse/compiler.ts +218 -0
- package/storage/clickhouse/config.ts +13 -0
- package/storage/clickhouse/dev.ts +18 -0
- package/storage/clickhouse/error.ts +61 -0
- package/storage/clickhouse/index.ts +4 -0
- package/storage/clickhouse/native-decoder.ts +192 -0
- package/storage/clickhouse/readers.ts +167 -0
- package/storage/clickhouse/retry.ts +30 -0
- package/storage/clickhouse/schema.ts +163 -0
- package/storage/clickhouse/testing.ts +81 -0
- package/storage/manticore/client.ts +165 -0
- package/storage/manticore/schema.ts +23 -0
- package/storage/postgres/client.ts +44 -0
- package/storage/postgres/testing.ts +275 -0
- package/storage/redis/client.ts +219 -0
- package/storage/redis/protocol.ts +119 -0
- package/storage/redis/schema.ts +17 -0
- package/storage/redis/testing.ts +122 -0
- package/utils/crypto.ts +36 -0
- package/utils/datetime.ts +338 -0
- package/utils/email.ts +173 -0
- package/utils/env.ts +171 -0
- package/utils/errors.ts +67 -0
- package/utils/form.ts +135 -0
- package/utils/http-status.ts +42 -0
- package/utils/money.ts +10 -0
- package/utils/public-host.ts +66 -0
- package/utils/schema.ts +176 -0
- package/utils/strings/format.ts +51 -0
- package/utils/strings/normalize.ts +129 -0
- package/utils/transport.ts +25 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { and, eq, gt, isNull, lt, or } from 'drizzle-orm';
|
|
2
|
+
|
|
3
|
+
import type { AuthDb } from '../../config';
|
|
4
|
+
|
|
5
|
+
import { Crypto } from '../../../utils/crypto';
|
|
6
|
+
import { authTokens } from '../../schema';
|
|
7
|
+
|
|
8
|
+
// Magic-link tokens. The emailed secret is 256 random bits; only its sha256 is stored, so a leaked
|
|
9
|
+
// row can't be replayed. A token is consumed by a single atomic UPDATE, so two clicks (or a mail
|
|
10
|
+
// scanner racing the user) can't both sign in.
|
|
11
|
+
|
|
12
|
+
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
/** Unused, unexpired links allowed per email at once: stops someone flooding an inbox. */
|
|
15
|
+
const MAX_LIVE_LINKS = 3;
|
|
16
|
+
|
|
17
|
+
/** A fresh token for `email`, or `null` when it already has `MAX_LIVE_LINKS` live ones. */
|
|
18
|
+
async function issueMagicLinkToken(
|
|
19
|
+
db: AuthDb,
|
|
20
|
+
email: string,
|
|
21
|
+
now = Date.now()
|
|
22
|
+
): Promise<null | string> {
|
|
23
|
+
// Housekeeping first: used or expired rows for this email are never needed again.
|
|
24
|
+
await db
|
|
25
|
+
.delete(authTokens)
|
|
26
|
+
.where(
|
|
27
|
+
and(eq(authTokens.email, email), or(lt(authTokens.expiresAt, now), gt(authTokens.usedAt, 0)))
|
|
28
|
+
);
|
|
29
|
+
const live = await db
|
|
30
|
+
.select({ tokenHash: authTokens.tokenHash })
|
|
31
|
+
.from(authTokens)
|
|
32
|
+
.where(eq(authTokens.email, email));
|
|
33
|
+
if (live.length >= MAX_LIVE_LINKS) return null;
|
|
34
|
+
|
|
35
|
+
const token = Crypto.getRandomSecret(32);
|
|
36
|
+
await db.insert(authTokens).values({
|
|
37
|
+
createdAt: now,
|
|
38
|
+
email,
|
|
39
|
+
expiresAt: now + MAGIC_LINK_TTL_MS,
|
|
40
|
+
tokenHash: Crypto.hashSecret(token),
|
|
41
|
+
});
|
|
42
|
+
return token;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Uses a token up and returns its email, or `null` if it is unknown, used or expired. */
|
|
46
|
+
async function consumeMagicLinkToken(
|
|
47
|
+
db: AuthDb,
|
|
48
|
+
token: string,
|
|
49
|
+
now = Date.now()
|
|
50
|
+
): Promise<null | string> {
|
|
51
|
+
if (!/^[0-9a-f]{64}$/.test(token)) return null;
|
|
52
|
+
const [row] = await db
|
|
53
|
+
.update(authTokens)
|
|
54
|
+
.set({ usedAt: now })
|
|
55
|
+
.where(
|
|
56
|
+
and(
|
|
57
|
+
eq(authTokens.tokenHash, Crypto.hashSecret(token)),
|
|
58
|
+
isNull(authTokens.usedAt),
|
|
59
|
+
gt(authTokens.expiresAt, now)
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
.returning({ email: authTokens.email });
|
|
63
|
+
return row?.email ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const MagicLink = {
|
|
67
|
+
consume: consumeMagicLinkToken,
|
|
68
|
+
issue: issueMagicLinkToken,
|
|
69
|
+
MAX_LIVE: MAX_LIVE_LINKS,
|
|
70
|
+
TTL_MS: MAGIC_LINK_TTL_MS,
|
|
71
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Browser side of the passkey endpoints in `sveltekit/routes.ts`. Uses the WebAuthn JSON helpers
|
|
2
|
+
// (`PublicKeyCredential.parse*OptionsFromJSON`, `credential.toJSON()`), available in every
|
|
3
|
+
// current browser. Import only from client code (event handlers), never at module scope on the
|
|
4
|
+
// server.
|
|
5
|
+
|
|
6
|
+
type JsonCredential = { toJSON(): unknown };
|
|
7
|
+
|
|
8
|
+
type PublicKeyCredentialStatic = {
|
|
9
|
+
isConditionalMediationAvailable?: () => Promise<boolean>;
|
|
10
|
+
parseCreationOptionsFromJSON(options: unknown): unknown;
|
|
11
|
+
parseRequestOptionsFromJSON(options: unknown): unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type CredentialsContainer = {
|
|
15
|
+
create(options: { publicKey: unknown }): Promise<JsonCredential | null>;
|
|
16
|
+
get(options: {
|
|
17
|
+
mediation?: 'conditional' | 'optional';
|
|
18
|
+
publicKey: unknown;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}): Promise<JsonCredential | null>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Whether this browser can use passkeys at all (and has the JSON helpers we rely on). */
|
|
24
|
+
function passkeysSupported(): boolean {
|
|
25
|
+
const pkc = (globalThis as { PublicKeyCredential?: PublicKeyCredentialStatic })
|
|
26
|
+
.PublicKeyCredential;
|
|
27
|
+
return typeof pkc?.parseRequestOptionsFromJSON === 'function';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whether the browser can offer passkeys from the email field's autofill. */
|
|
31
|
+
async function conditionalPasskeysSupported(): Promise<boolean> {
|
|
32
|
+
if (!passkeysSupported()) return false;
|
|
33
|
+
const pkc = (globalThis as unknown as { PublicKeyCredential: PublicKeyCredentialStatic })
|
|
34
|
+
.PublicKeyCredential;
|
|
35
|
+
return (await pkc.isConditionalMediationAvailable?.()) ?? false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class PasskeyClientError extends Error {
|
|
39
|
+
override name = 'PasskeyClientError';
|
|
40
|
+
constructor(
|
|
41
|
+
message: string,
|
|
42
|
+
/** The server asked for a fresh sign-in before adding a passkey. */
|
|
43
|
+
readonly reauth = false
|
|
44
|
+
) {
|
|
45
|
+
super(message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function post(path: string, body?: unknown): Promise<Record<string, unknown>> {
|
|
50
|
+
const response = await fetch(path, {
|
|
51
|
+
body: JSON.stringify(body ?? {}),
|
|
52
|
+
headers: { 'content-type': 'application/json' },
|
|
53
|
+
method: 'POST',
|
|
54
|
+
});
|
|
55
|
+
const data = (await response.json().catch(() => ({}))) as Record<string, unknown>;
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new PasskeyClientError(String(data.message ?? 'Request failed'), data.reauth === true);
|
|
58
|
+
}
|
|
59
|
+
return data;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function api() {
|
|
63
|
+
const g = globalThis as unknown as {
|
|
64
|
+
navigator: { credentials: CredentialsContainer };
|
|
65
|
+
PublicKeyCredential: PublicKeyCredentialStatic;
|
|
66
|
+
};
|
|
67
|
+
return { credentials: g.navigator.credentials, pkc: g.PublicKeyCredential };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when the user dismissed the browser prompt (not worth showing an error for). */
|
|
71
|
+
function isPasskeyCancel(err: unknown): boolean {
|
|
72
|
+
return err instanceof Error && (err.name === 'NotAllowedError' || err.name === 'AbortError');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Signs in with a passkey and returns where to go next. With `conditional`, the request waits
|
|
77
|
+
* quietly for the user to pick a passkey from the email field's autofill (the field needs
|
|
78
|
+
* `autocomplete="username webauthn"`); abort it with `signal` before starting a modal one.
|
|
79
|
+
*/
|
|
80
|
+
async function signInWithPasskey(
|
|
81
|
+
options: { conditional?: boolean; next?: string; signal?: AbortSignal } = {}
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
const { credentials, pkc } = api();
|
|
84
|
+
const publicKey = pkc.parseRequestOptionsFromJSON(await post('/auth/passkey/login-options'));
|
|
85
|
+
const credential = await credentials.get({
|
|
86
|
+
mediation: options.conditional ? 'conditional' : 'optional',
|
|
87
|
+
publicKey,
|
|
88
|
+
signal: options.signal,
|
|
89
|
+
});
|
|
90
|
+
if (!credential) throw new PasskeyClientError('No passkey was chosen');
|
|
91
|
+
const result = await post('/auth/passkey/login', {
|
|
92
|
+
credential: credential.toJSON(),
|
|
93
|
+
next: options.next,
|
|
94
|
+
});
|
|
95
|
+
return String(result.redirect ?? '/');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Creates a passkey for the signed-in user and saves it under `name`. */
|
|
99
|
+
async function addPasskey(name: string): Promise<void> {
|
|
100
|
+
const { credentials, pkc } = api();
|
|
101
|
+
const options = await post('/auth/passkey/register-options');
|
|
102
|
+
const credential = await credentials.create({
|
|
103
|
+
publicKey: pkc.parseCreationOptionsFromJSON(options),
|
|
104
|
+
});
|
|
105
|
+
if (!credential) throw new PasskeyClientError('No passkey was created');
|
|
106
|
+
await post('/auth/passkey/register', { credential: credential.toJSON(), name });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const PasskeyClient = {
|
|
110
|
+
add: addPasskey,
|
|
111
|
+
conditionalSupported: conditionalPasskeysSupported,
|
|
112
|
+
isCancel: isPasskeyCancel,
|
|
113
|
+
signIn: signInWithPasskey,
|
|
114
|
+
supported: passkeysSupported,
|
|
115
|
+
};
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodePKIXECDSASignature,
|
|
3
|
+
decodeSEC1PublicKey,
|
|
4
|
+
p256,
|
|
5
|
+
verifyECDSASignature,
|
|
6
|
+
} from '@oslojs/crypto/ecdsa';
|
|
7
|
+
import {
|
|
8
|
+
decodePKCS1RSAPublicKey,
|
|
9
|
+
RSAPublicKey,
|
|
10
|
+
sha256ObjectIdentifier,
|
|
11
|
+
verifyRSASSAPKCS1v15Signature,
|
|
12
|
+
} from '@oslojs/crypto/rsa';
|
|
13
|
+
import { sha256 } from '@oslojs/crypto/sha2';
|
|
14
|
+
import { constantTimeEqual } from '@oslojs/crypto/subtle';
|
|
15
|
+
import { decodeBase64urlIgnorePadding, encodeBase64urlNoPadding } from '@oslojs/encoding';
|
|
16
|
+
import {
|
|
17
|
+
ClientDataType,
|
|
18
|
+
coseAlgorithmES256,
|
|
19
|
+
coseAlgorithmRS256,
|
|
20
|
+
coseEllipticCurveP256,
|
|
21
|
+
COSEKeyType,
|
|
22
|
+
createAssertionSignatureMessage,
|
|
23
|
+
parseAttestationObject,
|
|
24
|
+
parseAuthenticatorData,
|
|
25
|
+
parseClientDataJSON,
|
|
26
|
+
} from '@oslojs/webauthn';
|
|
27
|
+
import { and, eq } from 'drizzle-orm';
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
|
|
30
|
+
import type { AuthDb } from '../../config';
|
|
31
|
+
import type { AuthUser } from '../sessions';
|
|
32
|
+
|
|
33
|
+
import { passkeys } from '../../schema';
|
|
34
|
+
|
|
35
|
+
// WebAuthn passkeys, verified server-side with oslo (pure JS, runs on Workers). Only what a
|
|
36
|
+
// passkey sign-in needs: no attestation (we ask for `none` and never trust a vendor's chain),
|
|
37
|
+
// user verification required, ES256 and RS256 keys. Options and responses travel as the
|
|
38
|
+
// WebAuthn JSON forms, so the browser side is `PublicKeyCredential.parse*OptionsFromJSON` and
|
|
39
|
+
// `credential.toJSON()` (see `client.ts`).
|
|
40
|
+
|
|
41
|
+
const ALGORITHMS = [coseAlgorithmES256, coseAlgorithmRS256];
|
|
42
|
+
|
|
43
|
+
/** Adding a passkey needs a session signed in this recently, so a stolen session cookie can't
|
|
44
|
+
* quietly plant a lasting credential. */
|
|
45
|
+
const FRESH_SESSION_MS = 15 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
/** The relying party: where the ceremony runs. `id` is the hostname (`origin` without scheme). */
|
|
48
|
+
export type RelyingParty = { id: string; name: string; origin: string };
|
|
49
|
+
|
|
50
|
+
export class PasskeyError extends Error {
|
|
51
|
+
override name = 'PasskeyError';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const RegistrationResponse = z.object({
|
|
55
|
+
id: z.string().min(1).max(1024),
|
|
56
|
+
rawId: z.string().min(1).max(1024),
|
|
57
|
+
response: z.object({
|
|
58
|
+
attestationObject: z.string().min(1),
|
|
59
|
+
clientDataJSON: z.string().min(1),
|
|
60
|
+
transports: z.array(z.string().max(32)).max(8).optional(),
|
|
61
|
+
}),
|
|
62
|
+
type: z.literal('public-key'),
|
|
63
|
+
});
|
|
64
|
+
export type RegistrationResponse = z.infer<typeof RegistrationResponse>;
|
|
65
|
+
|
|
66
|
+
export const AuthenticationResponse = z.object({
|
|
67
|
+
id: z.string().min(1).max(1024),
|
|
68
|
+
rawId: z.string().min(1).max(1024),
|
|
69
|
+
response: z.object({
|
|
70
|
+
authenticatorData: z.string().min(1),
|
|
71
|
+
clientDataJSON: z.string().min(1),
|
|
72
|
+
signature: z.string().min(1),
|
|
73
|
+
userHandle: z.string().nullish(),
|
|
74
|
+
}),
|
|
75
|
+
type: z.literal('public-key'),
|
|
76
|
+
});
|
|
77
|
+
export type AuthenticationResponse = z.infer<typeof AuthenticationResponse>;
|
|
78
|
+
|
|
79
|
+
/** A fresh challenge, base64url. The caller keeps it (a short-lived cookie) until the response. */
|
|
80
|
+
function newChallenge(): string {
|
|
81
|
+
return encodeBase64urlNoPadding(crypto.getRandomValues(new Uint8Array(32)));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `PublicKeyCredentialCreationOptionsJSON` for adding a passkey to `user`. */
|
|
85
|
+
function registrationOptions(
|
|
86
|
+
rp: RelyingParty,
|
|
87
|
+
user: AuthUser,
|
|
88
|
+
challenge: string,
|
|
89
|
+
existingIds: string[]
|
|
90
|
+
) {
|
|
91
|
+
return {
|
|
92
|
+
attestation: 'none',
|
|
93
|
+
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
|
94
|
+
challenge,
|
|
95
|
+
excludeCredentials: existingIds.map((id) => ({ id, type: 'public-key' })),
|
|
96
|
+
pubKeyCredParams: ALGORITHMS.map((alg) => ({ alg, type: 'public-key' })),
|
|
97
|
+
rp: { id: rp.id, name: rp.name },
|
|
98
|
+
timeout: 120_000,
|
|
99
|
+
user: {
|
|
100
|
+
displayName: user.email,
|
|
101
|
+
id: encodeBase64urlNoPadding(new TextEncoder().encode(String(user.id))),
|
|
102
|
+
name: user.email,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `PublicKeyCredentialRequestOptionsJSON` for a username-less (discoverable) sign-in. */
|
|
108
|
+
function authenticationOptions(rp: RelyingParty, challenge: string) {
|
|
109
|
+
return {
|
|
110
|
+
allowCredentials: [],
|
|
111
|
+
challenge,
|
|
112
|
+
rpId: rp.id,
|
|
113
|
+
timeout: 120_000,
|
|
114
|
+
userVerification: 'required',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A P-256 point as uncompressed SEC1 (`04 || x || y`, each coordinate left-padded to 32 bytes).
|
|
119
|
+
* oslo's `encodeSEC1Uncompressed` pads a coordinate with a leading zero byte on the wrong end,
|
|
120
|
+
* which breaks about 1 in 256 keys. */
|
|
121
|
+
function encodeP256Point(x: bigint, y: bigint): Uint8Array {
|
|
122
|
+
const out = new Uint8Array(65);
|
|
123
|
+
out[0] = 0x04;
|
|
124
|
+
for (const [offset, value] of [
|
|
125
|
+
[1, x],
|
|
126
|
+
[33, y],
|
|
127
|
+
] as const) {
|
|
128
|
+
let v = value;
|
|
129
|
+
for (let i = offset + 31; i >= offset; i--) {
|
|
130
|
+
out[i] = Number(v & 0xffn);
|
|
131
|
+
v >>= 8n;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function decode(value: string, what: string): Uint8Array {
|
|
138
|
+
try {
|
|
139
|
+
return decodeBase64urlIgnorePadding(value);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new PasskeyError(`Malformed ${what}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function checkClientData(
|
|
146
|
+
clientDataJSON: Uint8Array,
|
|
147
|
+
type: ClientDataType,
|
|
148
|
+
challenge: string,
|
|
149
|
+
rp: RelyingParty
|
|
150
|
+
) {
|
|
151
|
+
const clientData = parseClientDataJSON(clientDataJSON);
|
|
152
|
+
if (clientData.type !== type) throw new PasskeyError('Wrong ceremony type');
|
|
153
|
+
if (!constantTimeEqual(clientData.challenge, decode(challenge, 'challenge'))) {
|
|
154
|
+
throw new PasskeyError('Challenge mismatch');
|
|
155
|
+
}
|
|
156
|
+
if (clientData.origin !== rp.origin) throw new PasskeyError('Origin mismatch');
|
|
157
|
+
if (clientData.crossOrigin) throw new PasskeyError('Cross-origin ceremony');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Verifies a registration response and returns the credential to store. */
|
|
161
|
+
function verifyRegistration(response: RegistrationResponse, challenge: string, rp: RelyingParty) {
|
|
162
|
+
checkClientData(
|
|
163
|
+
decode(response.response.clientDataJSON, 'clientDataJSON'),
|
|
164
|
+
ClientDataType.Create,
|
|
165
|
+
challenge,
|
|
166
|
+
rp
|
|
167
|
+
);
|
|
168
|
+
const { authenticatorData } = parseAttestationObject(
|
|
169
|
+
decode(response.response.attestationObject, 'attestationObject')
|
|
170
|
+
);
|
|
171
|
+
if (!authenticatorData.verifyRelyingPartyIdHash(rp.id)) throw new PasskeyError('Wrong RP id');
|
|
172
|
+
if (!authenticatorData.userPresent || !authenticatorData.userVerified) {
|
|
173
|
+
throw new PasskeyError('User not verified');
|
|
174
|
+
}
|
|
175
|
+
const credential = authenticatorData.credential;
|
|
176
|
+
if (!credential) throw new PasskeyError('No credential');
|
|
177
|
+
const id = encodeBase64urlNoPadding(credential.id);
|
|
178
|
+
if (id !== response.rawId) throw new PasskeyError('Credential id mismatch');
|
|
179
|
+
|
|
180
|
+
const key = credential.publicKey;
|
|
181
|
+
const algorithm = key.algorithm();
|
|
182
|
+
let publicKey: Uint8Array;
|
|
183
|
+
if (algorithm === coseAlgorithmES256 && key.type() === COSEKeyType.EC2) {
|
|
184
|
+
const ec2 = key.ec2();
|
|
185
|
+
if (ec2.curve !== coseEllipticCurveP256) throw new PasskeyError('Unsupported curve');
|
|
186
|
+
publicKey = encodeP256Point(ec2.x, ec2.y);
|
|
187
|
+
} else if (algorithm === coseAlgorithmRS256 && key.type() === COSEKeyType.RSA) {
|
|
188
|
+
const rsa = key.rsa();
|
|
189
|
+
publicKey = new RSAPublicKey(rsa.n, rsa.e).encodePKCS1();
|
|
190
|
+
} else {
|
|
191
|
+
throw new PasskeyError('Unsupported algorithm');
|
|
192
|
+
}
|
|
193
|
+
const transports = (response.response.transports ?? []).join(',');
|
|
194
|
+
return { algorithm, id, publicKey, signCount: authenticatorData.signatureCounter, transports };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Verifies a sign-in response against the stored passkey; returns the new signature counter. */
|
|
198
|
+
function verifyAuthentication(
|
|
199
|
+
response: AuthenticationResponse,
|
|
200
|
+
challenge: string,
|
|
201
|
+
rp: RelyingParty,
|
|
202
|
+
stored: { algorithm: number; publicKey: Uint8Array; signCount: number }
|
|
203
|
+
): number {
|
|
204
|
+
const clientDataJSON = decode(response.response.clientDataJSON, 'clientDataJSON');
|
|
205
|
+
checkClientData(clientDataJSON, ClientDataType.Get, challenge, rp);
|
|
206
|
+
const rawAuthenticatorData = decode(response.response.authenticatorData, 'authenticatorData');
|
|
207
|
+
const authenticatorData = parseAuthenticatorData(rawAuthenticatorData);
|
|
208
|
+
if (!authenticatorData.verifyRelyingPartyIdHash(rp.id)) throw new PasskeyError('Wrong RP id');
|
|
209
|
+
if (!authenticatorData.userPresent || !authenticatorData.userVerified) {
|
|
210
|
+
throw new PasskeyError('User not verified');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const hash = sha256(createAssertionSignatureMessage(rawAuthenticatorData, clientDataJSON));
|
|
214
|
+
const signature = decode(response.response.signature, 'signature');
|
|
215
|
+
let valid = false;
|
|
216
|
+
try {
|
|
217
|
+
if (stored.algorithm === coseAlgorithmES256) {
|
|
218
|
+
const key = decodeSEC1PublicKey(p256, stored.publicKey);
|
|
219
|
+
valid = verifyECDSASignature(key, hash, decodePKIXECDSASignature(signature));
|
|
220
|
+
} else if (stored.algorithm === coseAlgorithmRS256) {
|
|
221
|
+
const key = decodePKCS1RSAPublicKey(stored.publicKey);
|
|
222
|
+
valid = verifyRSASSAPKCS1v15Signature(key, sha256ObjectIdentifier, hash, signature);
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
valid = false;
|
|
226
|
+
}
|
|
227
|
+
if (!valid) throw new PasskeyError('Bad signature');
|
|
228
|
+
|
|
229
|
+
// Authenticators that count (security keys) must move forward; synced passkeys always send 0.
|
|
230
|
+
const count = authenticatorData.signatureCounter;
|
|
231
|
+
if ((count !== 0 || stored.signCount !== 0) && count <= stored.signCount) {
|
|
232
|
+
throw new PasskeyError('Signature counter went backwards: possible cloned key');
|
|
233
|
+
}
|
|
234
|
+
return count;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function listPasskeys(db: AuthDb, userId: number) {
|
|
238
|
+
return db
|
|
239
|
+
.select({
|
|
240
|
+
createdAt: passkeys.createdAt,
|
|
241
|
+
id: passkeys.id,
|
|
242
|
+
lastUsedAt: passkeys.lastUsedAt,
|
|
243
|
+
name: passkeys.name,
|
|
244
|
+
})
|
|
245
|
+
.from(passkeys)
|
|
246
|
+
.where(eq(passkeys.userId, userId))
|
|
247
|
+
.orderBy(passkeys.createdAt);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function findPasskey(db: AuthDb, id: string) {
|
|
251
|
+
const [row] = await db.select().from(passkeys).where(eq(passkeys.id, id)).limit(1);
|
|
252
|
+
return row ?? null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function savePasskey(
|
|
256
|
+
db: AuthDb,
|
|
257
|
+
userId: number,
|
|
258
|
+
name: string,
|
|
259
|
+
credential: ReturnType<typeof verifyRegistration>,
|
|
260
|
+
now = Date.now()
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
await db.insert(passkeys).values({ ...credential, createdAt: now, name, userId });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function recordPasskeyUse(
|
|
266
|
+
db: AuthDb,
|
|
267
|
+
id: string,
|
|
268
|
+
signCount: number,
|
|
269
|
+
now = Date.now()
|
|
270
|
+
): Promise<void> {
|
|
271
|
+
await db.update(passkeys).set({ lastUsedAt: now, signCount }).where(eq(passkeys.id, id));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Deletes one of `userId`'s passkeys; `false` if it isn't theirs. */
|
|
275
|
+
async function deletePasskey(db: AuthDb, userId: number, id: string): Promise<boolean> {
|
|
276
|
+
const deleted = await db
|
|
277
|
+
.delete(passkeys)
|
|
278
|
+
.where(and(eq(passkeys.id, id), eq(passkeys.userId, userId)))
|
|
279
|
+
.returning({ id: passkeys.id });
|
|
280
|
+
return deleted.length > 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const Passkeys = {
|
|
284
|
+
authenticationOptions,
|
|
285
|
+
delete: deletePasskey,
|
|
286
|
+
find: findPasskey,
|
|
287
|
+
FRESH_SESSION_MS,
|
|
288
|
+
list: listPasskeys,
|
|
289
|
+
newChallenge,
|
|
290
|
+
recordUse: recordPasskeyUse,
|
|
291
|
+
registrationOptions,
|
|
292
|
+
save: savePasskey,
|
|
293
|
+
verifyAuthentication,
|
|
294
|
+
verifyRegistration,
|
|
295
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { and, eq, lt } from 'drizzle-orm';
|
|
2
|
+
|
|
3
|
+
import type { AuthDb } from '../config';
|
|
4
|
+
|
|
5
|
+
import { Crypto } from '../../utils/crypto';
|
|
6
|
+
import { sessions, users } from '../schema';
|
|
7
|
+
|
|
8
|
+
// Framework-agnostic session store: every function takes the db, so a plain Worker (no SvelteKit,
|
|
9
|
+
// no `configureAuth`) can use it too.
|
|
10
|
+
|
|
11
|
+
/** Session lifetime and sliding renewal: a session used with less than `RENEW_BELOW_MS` left is
|
|
12
|
+
* pushed out to a full `SESSION_LIFETIME_MS` again. */
|
|
13
|
+
const SESSION_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|
14
|
+
const SESSION_RENEW_BELOW_MS = 15 * 24 * 60 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
/** Session cookie: host-only (no `domain`), `secure` always (browsers treat localhost as secure). */
|
|
17
|
+
const sessionCookieAttributes = {
|
|
18
|
+
httpOnly: true,
|
|
19
|
+
path: '/',
|
|
20
|
+
sameSite: 'lax',
|
|
21
|
+
secure: true,
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
export type AuthSession = { createdAt: number; expiresAt: number; id: string };
|
|
25
|
+
export type AuthUser = { avatarUrl: null | string; email: string; id: number; name: string };
|
|
26
|
+
|
|
27
|
+
/** Split a `<id>.<secret>` session cookie value. `null` unless it is exactly two parts. */
|
|
28
|
+
function splitSessionToken(token: string): null | { id: string; secret: string } {
|
|
29
|
+
const parts = token.split('.');
|
|
30
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
|
|
31
|
+
return { id: parts[0], secret: parts[1] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function needsRenewal(expiresAt: number, now: number): boolean {
|
|
35
|
+
return expiresAt - now < SESSION_RENEW_BELOW_MS;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Inserts a session (hashed secret) and returns the `<id>.<secret>` cookie value. Also clears the
|
|
39
|
+
* user's expired rows, so the table doesn't grow forever. */
|
|
40
|
+
async function createSession(db: AuthDb, userId: number, now = Date.now()) {
|
|
41
|
+
const id = Crypto.getRandomSecret(16);
|
|
42
|
+
const secret = Crypto.getRandomSecret(32);
|
|
43
|
+
const expiresAt = now + SESSION_LIFETIME_MS;
|
|
44
|
+
await db.delete(sessions).where(and(eq(sessions.userId, userId), lt(sessions.expiresAt, now)));
|
|
45
|
+
await db.insert(sessions).values({
|
|
46
|
+
createdAt: now,
|
|
47
|
+
expiresAt,
|
|
48
|
+
id,
|
|
49
|
+
lastUsedAt: now,
|
|
50
|
+
secretHash: Crypto.hashSecret(secret),
|
|
51
|
+
userId,
|
|
52
|
+
});
|
|
53
|
+
return { expiresAt, id, token: `${id}.${secret}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The live session and its user for a cookie value, or `null` if unknown, forged or expired. */
|
|
57
|
+
async function validateSession(
|
|
58
|
+
db: AuthDb,
|
|
59
|
+
token: string,
|
|
60
|
+
now = Date.now()
|
|
61
|
+
): Promise<null | { session: AuthSession; user: AuthUser }> {
|
|
62
|
+
const split = splitSessionToken(token);
|
|
63
|
+
if (!split) return null;
|
|
64
|
+
const [row] = await db
|
|
65
|
+
.select({ session: sessions, user: users })
|
|
66
|
+
.from(sessions)
|
|
67
|
+
.innerJoin(users, eq(users.id, sessions.userId))
|
|
68
|
+
.where(eq(sessions.id, split.id))
|
|
69
|
+
.limit(1);
|
|
70
|
+
if (!row || !Crypto.verifySecret(split.secret, row.session.secretHash)) return null;
|
|
71
|
+
if (row.session.expiresAt <= now) return null;
|
|
72
|
+
const { avatarUrl, email, id, name } = row.user;
|
|
73
|
+
const { createdAt, expiresAt } = row.session;
|
|
74
|
+
return {
|
|
75
|
+
session: { createdAt, expiresAt, id: row.session.id },
|
|
76
|
+
user: { avatarUrl, email, id, name },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Pushes a session's expiry out to a full lifetime again; returns the new expiry. */
|
|
81
|
+
async function renewSession(db: AuthDb, sessionId: string, now = Date.now()) {
|
|
82
|
+
const expiresAt = now + SESSION_LIFETIME_MS;
|
|
83
|
+
await db.update(sessions).set({ expiresAt, lastUsedAt: now }).where(eq(sessions.id, sessionId));
|
|
84
|
+
return expiresAt;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function deleteSession(db: AuthDb, sessionId: string): Promise<void> {
|
|
88
|
+
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const Sessions = {
|
|
92
|
+
cookieAttributes: sessionCookieAttributes,
|
|
93
|
+
create: createSession,
|
|
94
|
+
delete: deleteSession,
|
|
95
|
+
LIFETIME_MS: SESSION_LIFETIME_MS,
|
|
96
|
+
needsRenewal,
|
|
97
|
+
renew: renewSession,
|
|
98
|
+
RENEW_BELOW_MS: SESSION_RENEW_BELOW_MS,
|
|
99
|
+
splitToken: splitSessionToken,
|
|
100
|
+
validate: validateSession,
|
|
101
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
|
|
3
|
+
import { redirect } from '@sveltejs/kit';
|
|
4
|
+
|
|
5
|
+
import { Invites } from '../accounts/invites';
|
|
6
|
+
import { Auth } from '../config';
|
|
7
|
+
import { AuthHandle } from '../sessions/handle';
|
|
8
|
+
import { Passkeys } from '../sessions/passkeys/passkeys';
|
|
9
|
+
import { Login } from './login';
|
|
10
|
+
|
|
11
|
+
type LoadEvent = Pick<RequestEvent, 'locals' | 'url'>;
|
|
12
|
+
|
|
13
|
+
/** The login page's server load: `export { loginLoad as load } from '@zeyolabs/kit/auth/sveltekit/loads'`.
|
|
14
|
+
* A signed-in visitor goes straight on to `next`, unless they arrived with a link token. */
|
|
15
|
+
function loginLoad(event: LoadEvent) {
|
|
16
|
+
const { accessEmail, user } = AuthHandle.locals(event as RequestEvent);
|
|
17
|
+
const params = event.url.searchParams;
|
|
18
|
+
const next = Login.safeNext(params.get('next') ?? '/');
|
|
19
|
+
const token = params.get('token');
|
|
20
|
+
if (user && !token) redirect(303, next);
|
|
21
|
+
return {
|
|
22
|
+
accessEmail,
|
|
23
|
+
error: Login.errorMessage(params.get('error')),
|
|
24
|
+
next,
|
|
25
|
+
sent: params.has('sent'),
|
|
26
|
+
token,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** For a settings page that manages passkeys. `fresh` says whether adding one is allowed now
|
|
31
|
+
* without signing in again. */
|
|
32
|
+
async function passkeysLoad(event: LoadEvent) {
|
|
33
|
+
const { session, user } = AuthHandle.locals(event as RequestEvent);
|
|
34
|
+
if (!session || !user) redirect(303, Auth.config().loginPath);
|
|
35
|
+
return {
|
|
36
|
+
fresh: Date.now() - session.createdAt <= Passkeys.FRESH_SESSION_MS,
|
|
37
|
+
passkeys: await Passkeys.list(Auth.config().db(), user.id),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The invite page's load (`/invite?token=`): who invited whom to what. Signed-out visitors are
|
|
42
|
+
* already sent to the login page by the auth handle and come back here. */
|
|
43
|
+
async function inviteLoad(event: LoadEvent) {
|
|
44
|
+
const { user } = AuthHandle.locals(event as RequestEvent);
|
|
45
|
+
const token = event.url.searchParams.get('token') ?? '';
|
|
46
|
+
const invite = await Invites.find(Auth.config().db(), token);
|
|
47
|
+
return {
|
|
48
|
+
error: event.url.searchParams.get('error'),
|
|
49
|
+
invite: invite && { accountName: invite.account.name, email: invite.email },
|
|
50
|
+
signedInAs: user?.email ?? null,
|
|
51
|
+
token,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const AuthLoads = {
|
|
56
|
+
invite: inviteLoad,
|
|
57
|
+
login: loginLoad,
|
|
58
|
+
passkeys: passkeysLoad,
|
|
59
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The login page's side of the auth routes: where to go after signing in, and error copy.
|
|
2
|
+
|
|
3
|
+
/** Login-page error codes; the app's login page maps them to copy with `loginErrorMessage`. */
|
|
4
|
+
export type LoginError = 'expired' | 'mismatch' | 'not_allowed' | 'passkey' | 'send';
|
|
5
|
+
|
|
6
|
+
const LOGIN_ERRORS: Record<LoginError, string> = {
|
|
7
|
+
expired: 'That sign-in link has expired or was already used. Request a new one.',
|
|
8
|
+
mismatch: 'Sign in with the same email you used for Cloudflare Access.',
|
|
9
|
+
not_allowed: 'That email is not allowed to sign in here.',
|
|
10
|
+
passkey: 'That passkey could not be verified. Try again or use an email link.',
|
|
11
|
+
send: 'We could not send the email. Check the address and try again.',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A same-site path to go to after sign-in: never another origin (`//x`, `/\x`, `https:`). */
|
|
15
|
+
function safeNext(next: unknown): string {
|
|
16
|
+
if (typeof next !== 'string' || !next.startsWith('/') || /^\/[/\\]/.test(next)) return '/';
|
|
17
|
+
return next;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Copy for a login page's `?error=` code; `null` for none or an unknown one. */
|
|
21
|
+
function loginErrorMessage(code: null | string): null | string {
|
|
22
|
+
return code && code in LOGIN_ERRORS ? LOGIN_ERRORS[code as LoginError] : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Login = {
|
|
26
|
+
errorMessage: loginErrorMessage,
|
|
27
|
+
safeNext,
|
|
28
|
+
};
|