sveltekit-auth-example 5.6.1 → 5.8.3
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/.env.example +5 -3
- package/CHANGELOG.md +33 -0
- package/README.md +24 -18
- package/db_create.sql +0 -1
- package/db_schema.sql +3 -3
- package/package.json +8 -5
- package/src/app.d.ts +125 -61
- package/src/app.html +6 -0
- package/src/hooks.server.unit.test.ts +163 -0
- package/src/lib/Turnstile.svelte +53 -0
- package/src/lib/app-state.svelte.unit.test.ts +73 -0
- package/src/lib/auth-redirect.unit.test.ts +92 -0
- package/src/lib/fetch-interceptor.unit.test.ts +99 -0
- package/src/lib/focus.unit.test.ts +91 -0
- package/src/lib/google.ts +41 -17
- package/src/lib/google.unit.test.ts +189 -0
- package/src/lib/server/brevo.ts +179 -0
- package/src/lib/server/brevo.unit.test.ts +186 -0
- package/src/lib/server/db.unit.test.ts +91 -0
- package/src/lib/server/email/mfa-code.ts +6 -6
- package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
- package/src/lib/server/email/password-reset.ts +7 -7
- package/src/lib/server/email/password-reset.unit.test.ts +70 -0
- package/src/lib/server/email/verify-email.ts +7 -7
- package/src/lib/server/email/verify-email.unit.test.ts +79 -0
- package/src/lib/server/turnstile.ts +29 -0
- package/src/lib/server/turnstile.unit.test.ts +111 -0
- package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
- package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
- package/src/routes/auth/forgot/+server.ts +9 -2
- package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
- package/src/routes/auth/google/+server.unit.test.ts +132 -0
- package/src/routes/auth/login/+server.ts +8 -3
- package/src/routes/auth/login/+server.unit.test.ts +221 -0
- package/src/routes/auth/logout/+server.unit.test.ts +85 -0
- package/src/routes/auth/mfa/+server.ts +8 -3
- package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
- package/src/routes/auth/register/+server.ts +14 -3
- package/src/routes/auth/register/+server.unit.test.ts +182 -0
- package/src/routes/auth/reset/+server.ts +8 -2
- package/src/routes/auth/reset/+server.unit.test.ts +139 -0
- package/src/routes/auth/reset/[token]/+page.svelte +11 -1
- package/src/routes/auth/verify/[token]/+server.ts +2 -2
- package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
- package/src/routes/forgot/+page.svelte +9 -1
- package/src/routes/login/+page.svelte +28 -5
- package/src/routes/register/+page.svelte +11 -1
- package/src/service-worker.unit.test.ts +228 -0
- package/svelte.config.js +4 -2
- package/vite.config.ts +5 -10
- package/src/lib/server/sendgrid.ts +0 -22
package/.env.example
CHANGED
|
@@ -2,6 +2,8 @@ DATABASE_URL=postgres://REPLACE_WITH_USER:REPLACE_WITH_PASSWORD@localhost:5432/a
|
|
|
2
2
|
DATABASE_SSL=false
|
|
3
3
|
DOMAIN=http://localhost:3000
|
|
4
4
|
JWT_SECRET=replace_with_your_own
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
PUBLIC_GOOGLE_CLIENT_ID=
|
|
5
|
+
BREVO_KEY=replace_with_your_own
|
|
6
|
+
EMAIL=replace_with_your_own
|
|
7
|
+
PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
|
|
8
|
+
PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
|
|
9
|
+
TURNSTILE_SECRET_KEY=replace_with_your_own
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
- Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
|
|
4
4
|
|
|
5
|
+
# 5.8.3
|
|
6
|
+
|
|
7
|
+
- Add unit tests for all auth route handlers: `/auth/[slug]`, `/auth/forgot`, `/auth/google`, `/auth/login`, `/auth/logout`, `/auth/mfa`, `/auth/register`, `/auth/reset`, `/auth/verify/[token]`
|
|
8
|
+
- Switch all server-side env imports from `$env/static/private` to `$env/dynamic/private` so secrets are read at runtime rather than baked into the build
|
|
9
|
+
|
|
10
|
+
# 5.8.2
|
|
11
|
+
|
|
12
|
+
- Add Chrome DevTools
|
|
13
|
+
|
|
14
|
+
# 5.8.1
|
|
15
|
+
|
|
16
|
+
- Fix MFA verification code always showing "6-digit verification code required" for valid codes: replaced `form.checkValidity()` and `codeInput.checkValidity()` (which picked up Cloudflare Turnstile's injected form elements) with a direct JS regex test against the bound `mfaCode` value
|
|
17
|
+
- Move `<Turnstile>` outside the MFA `<form>` so its injected inputs are never part of the form's element collection
|
|
18
|
+
- Fix `google is not defined` crash on hard reload of `/login`: Google GSI script is loaded `async defer` and may not be ready when `onMount` fires; added `whenGoogleReady()` helper in `src/lib/google.ts` that polls until `google` is available (up to 10 s) before calling `initializeGoogleAccounts()` and `renderGoogleButton()`
|
|
19
|
+
|
|
20
|
+
# 5.8.0
|
|
21
|
+
|
|
22
|
+
- Replace SendGrid with Brevo for all transactional email (password reset, email verification, MFA code)
|
|
23
|
+
- New `src/lib/server/brevo.ts` sends email via the Brevo API with exponential-backoff retry logic (up to 4 attempts), 30-second per-request timeout, and `Retry-After` header support
|
|
24
|
+
- Email templates (`password-reset.ts`, `mfa-code.ts`, `verify-email.ts`) updated to use Brevo message format (`sender`, `to[]`, `htmlContent`, `tags`)
|
|
25
|
+
- Env vars changed: `SENDGRID_KEY` → `BREVO_KEY`, `SENDGRID_SENDER` → `EMAIL`
|
|
26
|
+
- `app.d.ts` `PrivateEnv` updated to reflect new env vars
|
|
27
|
+
- README updated with Brevo setup instructions and new env var names
|
|
28
|
+
|
|
29
|
+
# 5.7.0
|
|
30
|
+
|
|
31
|
+
- Add Cloudflare Turnstile CAPTCHA to login, register, forgot password, MFA, and password reset forms
|
|
32
|
+
- New `src/lib/Turnstile.svelte` reusable Svelte 5 component wraps the Turnstile widget with `reset()` export
|
|
33
|
+
- New `src/lib/server/turnstile.ts` server-side token verification utility (`verifyTurnstileToken`)
|
|
34
|
+
- All auth endpoints verify the Turnstile challenge token before processing requests
|
|
35
|
+
- New env vars: `PUBLIC_TURNSTILE_SITE_KEY` (client) and `TURNSTILE_SECRET_KEY` (server)
|
|
36
|
+
- Extend `app.d.ts` with `PublicEnv`, `PrivateEnv` (add `TURNSTILE_SECRET_KEY`), and `Window.turnstile` types; wrap in `declare global`
|
|
37
|
+
|
|
5
38
|
# 5.6.1
|
|
6
39
|
|
|
7
40
|
- Add JSDoc comments throughout the codebase (`app.d.ts`, server hooks, route handlers, lib utilities, Svelte components)
|
package/README.md
CHANGED
|
@@ -4,18 +4,19 @@
|
|
|
4
4
|
[](https://nodejs.org)
|
|
5
5
|
[](https://svelte.dev)
|
|
6
6
|
|
|
7
|
-
A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control,
|
|
7
|
+
A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, OWASP-compliant password hashing, and bot protection out of the box.
|
|
8
8
|
|
|
9
9
|
## Features
|
|
10
10
|
|
|
11
|
-
|
|
|
12
|
-
|
|
|
13
|
-
| ✅ Local accounts (email + password)
|
|
14
|
-
| ✅ Multi-factor authentication (MFA via email)
|
|
15
|
-
| ✅ Forgot password / email reset (
|
|
16
|
-
| ✅ Session management + timeout
|
|
17
|
-
| ✅ Role-based access control
|
|
18
|
-
| ✅ Content Security Policy (CSP)
|
|
11
|
+
| | |
|
|
12
|
+
| ------------------------------------------------ | --------------------------------------- |
|
|
13
|
+
| ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
|
|
14
|
+
| ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
|
|
15
|
+
| ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
|
|
16
|
+
| ✅ Session management + timeout | ✅ Rate limiting |
|
|
17
|
+
| ✅ Role-based access control | ✅ Password complexity enforcement |
|
|
18
|
+
| ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
|
|
19
|
+
| ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
|
|
19
20
|
|
|
20
21
|
## Stack
|
|
21
22
|
|
|
@@ -28,8 +29,9 @@ A complete, production-ready authentication and authorization starter for **Svel
|
|
|
28
29
|
|
|
29
30
|
- Node.js 24.14.0 or later
|
|
30
31
|
- PostgreSQL 16 or later
|
|
31
|
-
- A [
|
|
32
|
+
- A [Brevo](https://brevo.com) account (for transactional emails)
|
|
32
33
|
- A [Google API client ID](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) (for Sign in with Google)
|
|
34
|
+
- A [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) site key and secret key (for bot protection on auth forms)
|
|
33
35
|
|
|
34
36
|
## Setting up the project
|
|
35
37
|
|
|
@@ -49,7 +51,7 @@ bash db_create.sh
|
|
|
49
51
|
|
|
50
52
|
2. Create a **Google API client ID** per [these instructions](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid). Make sure you include `http://localhost:3000` and `http://localhost` in the Authorized JavaScript origins, and `http://localhost:3000/auth/google/callback` in the Authorized redirect URIs for your Client ID for Web application. **Do not access the site using http://127.0.0.1:3000** — use `http://localhost:3000` or it will not work.
|
|
51
53
|
|
|
52
|
-
3. [Create a free
|
|
54
|
+
3. [Create a free Brevo account](https://app.brevo.com/account/register) and generate an API Key under **SMTP & API** settings. Set `EMAIL` to the sender address verified in your Brevo account.
|
|
53
55
|
|
|
54
56
|
4. Create a **.env** file at the top level of the project with the following values (substituting your own id and PostgreSQL username and password):
|
|
55
57
|
|
|
@@ -58,9 +60,11 @@ DATABASE_URL=postgres://user:password@localhost:5432/auth
|
|
|
58
60
|
DATABASE_SSL=false
|
|
59
61
|
DOMAIN=http://localhost:3000
|
|
60
62
|
JWT_SECRET=replace_with_your_own
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
BREVO_KEY=replace_with_your_own
|
|
64
|
+
EMAIL=replace_with_your_own
|
|
63
65
|
PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
|
|
66
|
+
PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
|
|
67
|
+
TURNSTILE_SECRET_KEY=replace_with_your_own
|
|
64
68
|
```
|
|
65
69
|
|
|
66
70
|
## Run locally
|
|
@@ -84,11 +88,13 @@ yarn preview
|
|
|
84
88
|
|
|
85
89
|
The db_create.sql script adds three users to the database with obvious roles:
|
|
86
90
|
|
|
87
|
-
| Email | Password
|
|
88
|
-
| ------------------- |
|
|
89
|
-
| admin@example.com |
|
|
90
|
-
| teacher@example.com |
|
|
91
|
-
| student@example.com |
|
|
91
|
+
| Email | Password | Role |
|
|
92
|
+
| ------------------- | ------------ | ------- |
|
|
93
|
+
| admin@example.com | Admin1234! | admin |
|
|
94
|
+
| teacher@example.com | Teacher1234! | teacher |
|
|
95
|
+
| student@example.com | Student1234! | student |
|
|
96
|
+
|
|
97
|
+
> **MFA note:** Local account logins require a 6-digit code sent to the user's email address. To successfully log in with the seed accounts above, either update their email addresses in the database to your own (`UPDATE users SET email = 'you@yourdomain.com' WHERE email = 'admin@example.com';`), or retrieve the code directly from the `mfa_codes` table after submitting the login form (`SELECT code FROM mfa_codes;`).
|
|
92
98
|
|
|
93
99
|
## How it works
|
|
94
100
|
|
package/db_create.sql
CHANGED
package/db_schema.sql
CHANGED
|
@@ -357,13 +357,13 @@ $BODY$;
|
|
|
357
357
|
ALTER FUNCTION public.verify_mfa_code (text, text) OWNER TO auth;
|
|
358
358
|
|
|
359
359
|
CALL public.upsert_user (
|
|
360
|
-
'{"id":0, "role":"admin", "email":"admin@example.com", "password":"
|
|
360
|
+
'{"id":0, "role":"admin", "email":"admin@example.com", "password":"Admin1234!", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
|
|
361
361
|
);
|
|
362
362
|
|
|
363
363
|
CALL public.upsert_user (
|
|
364
|
-
'{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"
|
|
364
|
+
'{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"Teacher1234!", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
|
|
365
365
|
);
|
|
366
366
|
|
|
367
367
|
CALL public.upsert_user (
|
|
368
|
-
'{"id":0, "role":"student", "email":"student@example.com", "password":"
|
|
368
|
+
'{"id":0, "role":"student", "email":"student@example.com", "password":"Student1234!", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
|
|
369
369
|
);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sveltekit-auth-example",
|
|
3
3
|
"description": "SvelteKit Authentication Example",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.8.3",
|
|
5
5
|
"author": "Nate Stuyvesant",
|
|
6
6
|
"license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
|
|
7
7
|
"repository": {
|
|
@@ -49,28 +49,31 @@
|
|
|
49
49
|
"@sveltejs/adapter-node": "^5.5.4",
|
|
50
50
|
"@sveltejs/kit": "^2.54.0",
|
|
51
51
|
"@sveltejs/mcp": "^0.1.21",
|
|
52
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
52
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
53
53
|
"@tailwindcss/vite": "^4.2.1",
|
|
54
54
|
"@types/bootstrap": "5.2.10",
|
|
55
55
|
"@types/google.accounts": "^0.0.18",
|
|
56
56
|
"@types/jsonwebtoken": "^9.0.10",
|
|
57
57
|
"@types/pg": "^8.18.0",
|
|
58
|
+
"@vitest/coverage-v8": "4.1.0",
|
|
58
59
|
"eslint": "^10.0.3",
|
|
59
60
|
"eslint-config-prettier": "^10.1.8",
|
|
60
61
|
"eslint-plugin-svelte": "^3.15.2",
|
|
61
62
|
"globals": "^17.4.0",
|
|
63
|
+
"jsdom": "^28.1.0",
|
|
62
64
|
"playwright": "^1.58.2",
|
|
63
65
|
"prettier": "^3.8.1",
|
|
64
66
|
"prettier-plugin-sql": "^0.19.2",
|
|
65
67
|
"prettier-plugin-svelte": "^3.5.1",
|
|
66
68
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
|
67
|
-
"svelte": "^5.53.
|
|
69
|
+
"svelte": "^5.53.11",
|
|
68
70
|
"svelte-check": "^4.4.5",
|
|
69
71
|
"tslib": "^2.8.1",
|
|
70
72
|
"typescript": "^5.9.3",
|
|
71
73
|
"typescript-eslint": "^8.57.0",
|
|
72
|
-
"vite": "^
|
|
73
|
-
"
|
|
74
|
+
"vite": "^8.0.0",
|
|
75
|
+
"vite-plugin-devtools-json": "^1.0.0",
|
|
76
|
+
"vitest": "^4.1.0",
|
|
74
77
|
"vitest-browser-svelte": "^2.0.2"
|
|
75
78
|
},
|
|
76
79
|
"packageManager": "yarn@4.13.0"
|
package/src/app.d.ts
CHANGED
|
@@ -3,75 +3,139 @@
|
|
|
3
3
|
// See https://kit.svelte.dev/docs/types#app
|
|
4
4
|
// for information about these interfaces
|
|
5
5
|
// and what to do when importing types
|
|
6
|
-
declare
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
declare global {
|
|
7
|
+
declare namespace App {
|
|
8
|
+
/** Per-request server-side locals populated by `hooks.server.ts`. */
|
|
9
|
+
interface Locals {
|
|
10
|
+
/** The authenticated user, or `undefined` when no valid session exists. */
|
|
11
|
+
user: User | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// interface Platform {}
|
|
15
|
+
|
|
16
|
+
/** Error response returned by the Brevo email API. */
|
|
17
|
+
interface BrevoErrorResponse {
|
|
18
|
+
/** Optional machine‑readable error code. */
|
|
19
|
+
code?: string
|
|
20
|
+
/** Human‑readable error message. */
|
|
21
|
+
message?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Successful response returned by the Brevo email API. */
|
|
25
|
+
interface BrevoSuccessResponse {
|
|
26
|
+
/** Message identifier assigned by Brevo. */
|
|
27
|
+
messageId: string
|
|
28
|
+
}
|
|
12
29
|
|
|
13
|
-
|
|
30
|
+
/** Basic email address with optional display name. */
|
|
31
|
+
interface EmailAddress {
|
|
32
|
+
/** Email address string. */
|
|
33
|
+
email: string
|
|
34
|
+
/** Optional display name associated with the address. */
|
|
35
|
+
name?: string
|
|
36
|
+
}
|
|
14
37
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
/** Payload used when sending email via Brevo. */
|
|
39
|
+
interface EmailMessageBrevo {
|
|
40
|
+
/** Primary recipients. */
|
|
41
|
+
to: EmailAddress[]
|
|
42
|
+
/** Carbon‑copy recipients. */
|
|
43
|
+
cc?: EmailAddress[]
|
|
44
|
+
/** Blind carbon‑copy recipients. */
|
|
45
|
+
bcc?: EmailAddress[]
|
|
46
|
+
/** Sender address. */
|
|
47
|
+
sender: EmailAddress
|
|
48
|
+
/** Optional reply‑to address. */
|
|
49
|
+
replyTo?: EmailAddress
|
|
50
|
+
/** Subject line for the email. */
|
|
51
|
+
subject: string
|
|
52
|
+
/** Additional SMTP or provider‑specific headers. */
|
|
53
|
+
headers?: Record<string, string>
|
|
54
|
+
/** Tag names applied to the message. */
|
|
55
|
+
tags?: string[]
|
|
56
|
+
/** HTML content of the email. */
|
|
57
|
+
htmlContent?: string
|
|
58
|
+
/** Plain‑text content of the email. */
|
|
59
|
+
textContent?: string
|
|
60
|
+
/** Attachments encoded as base64 strings. */
|
|
61
|
+
attachment?: Array<{ content: string; name: string }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Private environment variables (server-side only). */
|
|
65
|
+
interface PrivateEnv {
|
|
66
|
+
// $env/static/private
|
|
67
|
+
/** PostgreSQL connection string. */
|
|
68
|
+
DATABASE_URL: string
|
|
69
|
+
/** Public-facing domain used to construct email links (e.g. `https://example.com`). */
|
|
70
|
+
DOMAIN: string
|
|
71
|
+
/** Secret key used to sign and verify JWTs. */
|
|
72
|
+
JWT_SECRET: string
|
|
73
|
+
/** Brevo (Sendinblue) API key. */
|
|
74
|
+
BREVO_KEY: string
|
|
75
|
+
/** Default sender email address for outgoing mail. */
|
|
76
|
+
EMAIL: string
|
|
77
|
+
/** Cloudflare Turnstile secret key used to verify challenge tokens server-side. */
|
|
78
|
+
TURNSTILE_SECRET_KEY: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Public environment variables (safe to expose to the client). */
|
|
82
|
+
interface PublicEnv {
|
|
83
|
+
// $env/static/public
|
|
84
|
+
/** Google OAuth 2.0 client ID for Google Sign-In. */
|
|
85
|
+
PUBLIC_GOOGLE_CLIENT_ID: string
|
|
86
|
+
/** Cloudflare Turnstile site key rendered in the browser widget. */
|
|
87
|
+
PUBLIC_TURNSTILE_SITE_KEY: string
|
|
88
|
+
}
|
|
28
89
|
}
|
|
29
90
|
|
|
30
|
-
/**
|
|
31
|
-
interface
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
91
|
+
/** Result returned by the `authenticate` and `register` SQL functions. */
|
|
92
|
+
interface AuthenticationResult {
|
|
93
|
+
/** HTTP status code to use when the operation fails. */
|
|
94
|
+
statusCode: NumericRange<400, 599>
|
|
95
|
+
/** Human-readable status message. */
|
|
96
|
+
status: string
|
|
97
|
+
/** The authenticated or registered user, if successful. */
|
|
98
|
+
user: User
|
|
99
|
+
/** The newly created session ID. */
|
|
100
|
+
sessionId: string
|
|
35
101
|
}
|
|
36
|
-
}
|
|
37
102
|
|
|
38
|
-
/**
|
|
39
|
-
interface
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
status: string
|
|
44
|
-
/** The authenticated or registered user, if successful. */
|
|
45
|
-
user: User
|
|
46
|
-
/** The newly created session ID. */
|
|
47
|
-
sessionId: string
|
|
48
|
-
}
|
|
103
|
+
/** Raw login credentials submitted by the user. */
|
|
104
|
+
interface Credentials {
|
|
105
|
+
email: string
|
|
106
|
+
password: string
|
|
107
|
+
}
|
|
49
108
|
|
|
50
|
-
/**
|
|
51
|
-
interface
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
109
|
+
/** Persistent properties stored in the database for a user account. */
|
|
110
|
+
interface UserProperties {
|
|
111
|
+
id: number
|
|
112
|
+
/** ISO-8601 datetime at which the current session expires. */
|
|
113
|
+
expires?: string
|
|
114
|
+
role: 'student' | 'teacher' | 'admin'
|
|
115
|
+
password?: string
|
|
116
|
+
firstName?: string
|
|
117
|
+
lastName?: string
|
|
118
|
+
email?: string
|
|
119
|
+
phone?: string
|
|
120
|
+
}
|
|
55
121
|
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
id: number
|
|
59
|
-
/** ISO-8601 datetime at which the current session expires. */
|
|
60
|
-
expires?: string
|
|
61
|
-
role: 'student' | 'teacher' | 'admin'
|
|
62
|
-
password?: string
|
|
63
|
-
firstName?: string
|
|
64
|
-
lastName?: string
|
|
65
|
-
email?: string
|
|
66
|
-
phone?: string
|
|
67
|
-
}
|
|
122
|
+
/** A user record, or `undefined`/`null` when unauthenticated. */
|
|
123
|
+
type User = UserProperties | undefined | null
|
|
68
124
|
|
|
69
|
-
/** A
|
|
70
|
-
|
|
125
|
+
/** A database session paired with its associated user. */
|
|
126
|
+
interface UserSession {
|
|
127
|
+
/** UUID session identifier stored in the `session` cookie. */
|
|
128
|
+
id: string
|
|
129
|
+
user: User
|
|
130
|
+
}
|
|
71
131
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
132
|
+
interface Window {
|
|
133
|
+
turnstile?: {
|
|
134
|
+
render: (container: HTMLElement, options: Record<string, unknown>) => string
|
|
135
|
+
reset: (widgetId: string) => void
|
|
136
|
+
remove: (widgetId: string) => void
|
|
137
|
+
}
|
|
138
|
+
}
|
|
77
139
|
}
|
|
140
|
+
|
|
141
|
+
export {}
|
package/src/app.html
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { error } from '@sveltejs/kit'
|
|
3
|
+
|
|
4
|
+
vi.mock('$lib/server/db', () => ({ query: vi.fn() }))
|
|
5
|
+
|
|
6
|
+
import { handle } from './hooks.server'
|
|
7
|
+
import { query } from '$lib/server/db'
|
|
8
|
+
|
|
9
|
+
const mockQuery = vi.mocked(query)
|
|
10
|
+
|
|
11
|
+
/** Build a minimal event object for the hook. */
|
|
12
|
+
function makeEvent({
|
|
13
|
+
pathname = '/page',
|
|
14
|
+
sessionCookie = undefined as string | undefined,
|
|
15
|
+
ip = '1.2.3.4'
|
|
16
|
+
} = {}) {
|
|
17
|
+
const cookieStore = new Map<string, string>()
|
|
18
|
+
if (sessionCookie) cookieStore.set('session', sessionCookie)
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
url: new URL(`http://localhost${pathname}`),
|
|
22
|
+
cookies: {
|
|
23
|
+
get: (name: string) => cookieStore.get(name),
|
|
24
|
+
delete: vi.fn()
|
|
25
|
+
},
|
|
26
|
+
locals: {} as Record<string, unknown>,
|
|
27
|
+
getClientAddress: () => ip
|
|
28
|
+
} as unknown as Parameters<typeof handle>[0]['event']
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolve = vi.fn(async (event: unknown) => new Response('ok', { status: 200 }))
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.resetAllMocks()
|
|
35
|
+
resolve.mockResolvedValue(new Response('ok', { status: 200 }))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ── Static asset bypass ────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe('static asset bypass', () => {
|
|
41
|
+
it('calls resolve immediately for /_app/ paths without touching the db', async () => {
|
|
42
|
+
const event = makeEvent({ pathname: '/_app/immutable/app.js' })
|
|
43
|
+
|
|
44
|
+
await handle({ event, resolve })
|
|
45
|
+
|
|
46
|
+
expect(resolve).toHaveBeenCalledOnce()
|
|
47
|
+
expect(mockQuery).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// ── Rate limiting ──────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
describe('rate limiting', () => {
|
|
54
|
+
it('allows requests under the limit', async () => {
|
|
55
|
+
const event = makeEvent({ pathname: '/auth/login', ip: '10.0.0.1' })
|
|
56
|
+
|
|
57
|
+
await expect(handle({ event, resolve })).resolves.not.toThrow()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('throws 429 after exceeding the request limit for a rate-limited path', async () => {
|
|
61
|
+
const ip = '10.0.0.99'
|
|
62
|
+
|
|
63
|
+
// Exhaust the 20-request allowance
|
|
64
|
+
for (let i = 0; i < 20; i++) {
|
|
65
|
+
await handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve }).catch(() => {})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
handle({ event: makeEvent({ pathname: '/auth/login', ip }), resolve })
|
|
70
|
+
).rejects.toMatchObject({ status: 429 })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('does not rate-limit non-auth paths', async () => {
|
|
74
|
+
const ip = '10.0.0.2'
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < 25; i++) {
|
|
77
|
+
await handle({ event: makeEvent({ pathname: '/about', ip }), resolve })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
expect(resolve).toHaveBeenCalledTimes(25)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ── Session handling ───────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe('session handling', () => {
|
|
87
|
+
it('attaches user to locals when a valid session cookie is present', async () => {
|
|
88
|
+
const user = { id: 1, role: 'admin' }
|
|
89
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
|
|
90
|
+
|
|
91
|
+
const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
|
|
92
|
+
|
|
93
|
+
await handle({ event, resolve })
|
|
94
|
+
|
|
95
|
+
expect(event.locals.user).toEqual(user)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('does not call query when no session cookie is present', async () => {
|
|
99
|
+
const event = makeEvent()
|
|
100
|
+
|
|
101
|
+
await handle({ event, resolve })
|
|
102
|
+
|
|
103
|
+
expect(mockQuery).not.toHaveBeenCalled()
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('deletes the session cookie when the session is not found in the db', async () => {
|
|
107
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: undefined }] } as any)
|
|
108
|
+
|
|
109
|
+
const event = makeEvent({ sessionCookie: 'stale-session-uuid' })
|
|
110
|
+
|
|
111
|
+
await handle({ event, resolve })
|
|
112
|
+
|
|
113
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('deletes the session cookie when no session cookie is present and locals.user is unset', async () => {
|
|
117
|
+
const event = makeEvent()
|
|
118
|
+
|
|
119
|
+
await handle({ event, resolve })
|
|
120
|
+
|
|
121
|
+
expect(event.cookies.delete).toHaveBeenCalledWith('session', { path: '/' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('does not delete the session cookie when a valid session exists', async () => {
|
|
125
|
+
const user = { id: 2, role: 'student' }
|
|
126
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: user }] } as any)
|
|
127
|
+
|
|
128
|
+
const event = makeEvent({ sessionCookie: 'valid-session-uuid' })
|
|
129
|
+
|
|
130
|
+
await handle({ event, resolve })
|
|
131
|
+
|
|
132
|
+
expect(event.cookies.delete).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('uses the named prepared statement for session lookup', async () => {
|
|
136
|
+
mockQuery.mockResolvedValue({ rows: [{ get_and_update_session: { id: 3 } }] } as any)
|
|
137
|
+
|
|
138
|
+
const event = makeEvent({ sessionCookie: 'some-uuid' })
|
|
139
|
+
|
|
140
|
+
await handle({ event, resolve })
|
|
141
|
+
|
|
142
|
+
expect(mockQuery).toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining('get_and_update_session'),
|
|
144
|
+
['some-uuid'],
|
|
145
|
+
'get-and-update-session'
|
|
146
|
+
)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// ── Response pass-through ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
describe('response', () => {
|
|
153
|
+
it('returns the response from resolve', async () => {
|
|
154
|
+
const fakeResponse = new Response('hello', { status: 200 })
|
|
155
|
+
resolve.mockResolvedValue(fakeResponse)
|
|
156
|
+
|
|
157
|
+
const event = makeEvent()
|
|
158
|
+
|
|
159
|
+
const result = await handle({ event, resolve })
|
|
160
|
+
|
|
161
|
+
expect(result).toBe(fakeResponse)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte'
|
|
3
|
+
import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
token: string
|
|
7
|
+
theme?: 'light' | 'dark' | 'auto'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { token = $bindable(''), theme = 'auto' }: Props = $props()
|
|
11
|
+
|
|
12
|
+
let container: HTMLDivElement | undefined = $state()
|
|
13
|
+
let widgetId: string | undefined
|
|
14
|
+
|
|
15
|
+
/** Resets the Turnstile widget and clears the token. Call this after a failed submission. */
|
|
16
|
+
export function reset() {
|
|
17
|
+
if (widgetId !== undefined && typeof window !== 'undefined' && window.turnstile) {
|
|
18
|
+
window.turnstile.reset(widgetId)
|
|
19
|
+
token = ''
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onMount(() => {
|
|
24
|
+
const tryRender = () => {
|
|
25
|
+
if (!container || !window.turnstile) {
|
|
26
|
+
setTimeout(tryRender, 50)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
widgetId = window.turnstile.render(container, {
|
|
30
|
+
sitekey: PUBLIC_TURNSTILE_SITE_KEY,
|
|
31
|
+
theme,
|
|
32
|
+
callback: (t: string) => {
|
|
33
|
+
token = t
|
|
34
|
+
},
|
|
35
|
+
'expired-callback': () => {
|
|
36
|
+
token = ''
|
|
37
|
+
},
|
|
38
|
+
'error-callback': () => {
|
|
39
|
+
token = ''
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
tryRender()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
onDestroy(() => {
|
|
47
|
+
if (widgetId !== undefined && window.turnstile) {
|
|
48
|
+
window.turnstile.remove(widgetId)
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<div bind:this={container}></div>
|