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.
Files changed (51) hide show
  1. package/.env.example +5 -3
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +24 -18
  4. package/db_create.sql +0 -1
  5. package/db_schema.sql +3 -3
  6. package/package.json +8 -5
  7. package/src/app.d.ts +125 -61
  8. package/src/app.html +6 -0
  9. package/src/hooks.server.unit.test.ts +163 -0
  10. package/src/lib/Turnstile.svelte +53 -0
  11. package/src/lib/app-state.svelte.unit.test.ts +73 -0
  12. package/src/lib/auth-redirect.unit.test.ts +92 -0
  13. package/src/lib/fetch-interceptor.unit.test.ts +99 -0
  14. package/src/lib/focus.unit.test.ts +91 -0
  15. package/src/lib/google.ts +41 -17
  16. package/src/lib/google.unit.test.ts +189 -0
  17. package/src/lib/server/brevo.ts +179 -0
  18. package/src/lib/server/brevo.unit.test.ts +186 -0
  19. package/src/lib/server/db.unit.test.ts +91 -0
  20. package/src/lib/server/email/mfa-code.ts +6 -6
  21. package/src/lib/server/email/mfa-code.unit.test.ts +81 -0
  22. package/src/lib/server/email/password-reset.ts +7 -7
  23. package/src/lib/server/email/password-reset.unit.test.ts +70 -0
  24. package/src/lib/server/email/verify-email.ts +7 -7
  25. package/src/lib/server/email/verify-email.unit.test.ts +79 -0
  26. package/src/lib/server/turnstile.ts +29 -0
  27. package/src/lib/server/turnstile.unit.test.ts +111 -0
  28. package/src/routes/api/v1/user/+server.unit.test.ts +114 -0
  29. package/src/routes/auth/[slug]/+server.unit.test.ts +15 -0
  30. package/src/routes/auth/forgot/+server.ts +9 -2
  31. package/src/routes/auth/forgot/+server.unit.test.ts +110 -0
  32. package/src/routes/auth/google/+server.unit.test.ts +132 -0
  33. package/src/routes/auth/login/+server.ts +8 -3
  34. package/src/routes/auth/login/+server.unit.test.ts +221 -0
  35. package/src/routes/auth/logout/+server.unit.test.ts +85 -0
  36. package/src/routes/auth/mfa/+server.ts +8 -3
  37. package/src/routes/auth/mfa/+server.unit.test.ts +153 -0
  38. package/src/routes/auth/register/+server.ts +14 -3
  39. package/src/routes/auth/register/+server.unit.test.ts +182 -0
  40. package/src/routes/auth/reset/+server.ts +8 -2
  41. package/src/routes/auth/reset/+server.unit.test.ts +139 -0
  42. package/src/routes/auth/reset/[token]/+page.svelte +11 -1
  43. package/src/routes/auth/verify/[token]/+server.ts +2 -2
  44. package/src/routes/auth/verify/[token]/+server.unit.test.ts +124 -0
  45. package/src/routes/forgot/+page.svelte +9 -1
  46. package/src/routes/login/+page.svelte +28 -5
  47. package/src/routes/register/+page.svelte +11 -1
  48. package/src/service-worker.unit.test.ts +228 -0
  49. package/svelte.config.js +4 -2
  50. package/vite.config.ts +5 -10
  51. package/src/lib/server/sendgrid.ts +0 -22
@@ -2,6 +2,7 @@
2
2
  import { onMount } from 'svelte'
3
3
  import { focusOnFirstError } from '$lib/focus'
4
4
  import { initializeGoogleAccounts, renderGoogleButton } from '$lib/google'
5
+ import Turnstile from '$lib/Turnstile.svelte'
5
6
 
6
7
  let focusedField: HTMLInputElement | undefined = $state()
7
8
  // Pattern stored as a variable to avoid Svelte parsing `{8,}` as a template expression
@@ -24,6 +25,8 @@
24
25
  let emailVerificationSent = $state(false)
25
26
 
26
27
  let formEl: HTMLFormElement | undefined = $state()
28
+ let turnstileToken = $state('')
29
+ let turnstile: Turnstile | undefined = $state()
27
30
 
28
31
  /**
29
32
  * Validates the registration form and, if valid, delegates to {@link registerLocal}.
@@ -41,12 +44,17 @@
41
44
  }
42
45
 
43
46
  if (form.checkValidity()) {
47
+ if (!turnstileToken) {
48
+ message = 'Please complete the security challenge.'
49
+ return
50
+ }
44
51
  try {
45
52
  await registerLocal(user)
46
53
  } catch (err) {
47
54
  if (err instanceof Error) {
48
55
  message = err.message
49
56
  console.log('Login error', message)
57
+ turnstile?.reset()
50
58
  }
51
59
  }
52
60
  } else {
@@ -76,7 +84,7 @@
76
84
  try {
77
85
  const res = await fetch('/auth/register', {
78
86
  method: 'POST',
79
- body: JSON.stringify(user), // server ignores user.role - always set it to 'student' (lowest priv)
87
+ body: JSON.stringify({ ...user, turnstileToken }), // server ignores user.role - always set it to 'student' (lowest priv)
80
88
  headers: {
81
89
  'Content-Type': 'application/json'
82
90
  }
@@ -269,6 +277,8 @@
269
277
  <p class="tw:text-red-600">{message}</p>
270
278
  {/if}
271
279
 
280
+ <Turnstile bind:this={turnstile} bind:token={turnstileToken} />
281
+
272
282
  <button type="submit" class="btn-primary" disabled={loading}>
273
283
  {loading ? 'Creating account...' : 'Register'}
274
284
  </button>
@@ -0,0 +1,228 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'
3
+
4
+ vi.mock('$service-worker', () => ({
5
+ build: ['/_app/app.js'],
6
+ files: ['/favicon.png'],
7
+ version: 'v1'
8
+ }))
9
+
10
+ const CACHE_NAME = 'cache-v1'
11
+ const ASSETS = ['/_app/app.js', '/favicon.png']
12
+
13
+ const mockCache = {
14
+ addAll: vi.fn(),
15
+ match: vi.fn(),
16
+ put: vi.fn()
17
+ }
18
+
19
+ const mockCaches = {
20
+ open: vi.fn(),
21
+ keys: vi.fn(),
22
+ delete: vi.fn()
23
+ }
24
+
25
+ const swHandlers: Record<string, Function> = {}
26
+
27
+ beforeAll(async () => {
28
+ vi.stubGlobal('caches', mockCaches)
29
+
30
+ // Spy on addEventListener to capture the handlers the SW registers
31
+ vi.spyOn(window, 'addEventListener').mockImplementation((type: string, handler: any) => {
32
+ swHandlers[type] = handler
33
+ })
34
+
35
+ // Reset module registry so the SW file re-evaluates and re-registers its listeners
36
+ vi.resetModules()
37
+ await import('./service-worker')
38
+
39
+ vi.restoreAllMocks()
40
+ })
41
+
42
+ beforeEach(() => {
43
+ vi.resetAllMocks()
44
+ mockCaches.open.mockResolvedValue(mockCache)
45
+ mockCaches.delete.mockResolvedValue(true)
46
+ mockCache.addAll.mockResolvedValue(undefined)
47
+ mockCache.match.mockResolvedValue(null)
48
+ })
49
+
50
+ afterEach(() => {
51
+ vi.restoreAllMocks()
52
+ })
53
+
54
+ function makeExtendableEvent() {
55
+ return { waitUntil: vi.fn() }
56
+ }
57
+
58
+ function makeFetchEvent(url: string, method = 'GET') {
59
+ return { request: new Request(url, { method }), respondWith: vi.fn() }
60
+ }
61
+
62
+ // ── install ────────────────────────────────────────────────────────────────────
63
+
64
+ describe('install event', () => {
65
+ it('registers an install handler', () => {
66
+ expect(swHandlers.install).toBeTypeOf('function')
67
+ })
68
+
69
+ it('calls event.waitUntil with a promise', () => {
70
+ const event = makeExtendableEvent()
71
+ swHandlers.install(event)
72
+ expect(event.waitUntil).toHaveBeenCalledOnce()
73
+ expect(event.waitUntil.mock.calls[0][0]).toBeInstanceOf(Promise)
74
+ })
75
+
76
+ it('opens the versioned cache', async () => {
77
+ const event = makeExtendableEvent()
78
+ swHandlers.install(event)
79
+ await event.waitUntil.mock.calls[0][0]
80
+ expect(mockCaches.open).toHaveBeenCalledWith(CACHE_NAME)
81
+ })
82
+
83
+ it('pre-caches all build and static assets', async () => {
84
+ const event = makeExtendableEvent()
85
+ swHandlers.install(event)
86
+ await event.waitUntil.mock.calls[0][0]
87
+ expect(mockCache.addAll).toHaveBeenCalledWith(ASSETS)
88
+ })
89
+ })
90
+
91
+ // ── activate ───────────────────────────────────────────────────────────────────
92
+
93
+ describe('activate event', () => {
94
+ it('registers an activate handler', () => {
95
+ expect(swHandlers.activate).toBeTypeOf('function')
96
+ })
97
+
98
+ it('deletes old caches', async () => {
99
+ mockCaches.keys.mockResolvedValue([CACHE_NAME, 'cache-old'])
100
+ const event = makeExtendableEvent()
101
+ swHandlers.activate(event)
102
+ await event.waitUntil.mock.calls[0][0]
103
+ expect(mockCaches.delete).toHaveBeenCalledWith('cache-old')
104
+ })
105
+
106
+ it('does not delete the current cache', async () => {
107
+ mockCaches.keys.mockResolvedValue([CACHE_NAME, 'cache-old'])
108
+ const event = makeExtendableEvent()
109
+ swHandlers.activate(event)
110
+ await event.waitUntil.mock.calls[0][0]
111
+ expect(mockCaches.delete).not.toHaveBeenCalledWith(CACHE_NAME)
112
+ })
113
+
114
+ it('deletes multiple old caches', async () => {
115
+ mockCaches.keys.mockResolvedValue([CACHE_NAME, 'cache-v0', 'cache-v0.5'])
116
+ const event = makeExtendableEvent()
117
+ swHandlers.activate(event)
118
+ await event.waitUntil.mock.calls[0][0]
119
+ expect(mockCaches.delete).toHaveBeenCalledTimes(2)
120
+ })
121
+
122
+ it('does nothing when there are no old caches', async () => {
123
+ mockCaches.keys.mockResolvedValue([CACHE_NAME])
124
+ const event = makeExtendableEvent()
125
+ swHandlers.activate(event)
126
+ await event.waitUntil.mock.calls[0][0]
127
+ expect(mockCaches.delete).not.toHaveBeenCalled()
128
+ })
129
+ })
130
+
131
+ // ── fetch ──────────────────────────────────────────────────────────────────────
132
+
133
+ describe('fetch event', () => {
134
+ it('registers a fetch handler', () => {
135
+ expect(swHandlers.fetch).toBeTypeOf('function')
136
+ })
137
+
138
+ it('ignores non-GET requests', () => {
139
+ const event = makeFetchEvent('http://localhost/page', 'POST')
140
+ swHandlers.fetch(event)
141
+ expect(event.respondWith).not.toHaveBeenCalled()
142
+ })
143
+
144
+ it('bypasses /api requests', () => {
145
+ const event = makeFetchEvent('http://localhost/api/v1/users')
146
+ swHandlers.fetch(event)
147
+ expect(event.respondWith).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('bypasses /auth requests', () => {
151
+ const event = makeFetchEvent('http://localhost/auth/login')
152
+ swHandlers.fetch(event)
153
+ expect(event.respondWith).not.toHaveBeenCalled()
154
+ })
155
+
156
+ it('calls respondWith for a normal GET request', () => {
157
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('ok', { status: 200 }))
158
+ const event = makeFetchEvent('http://localhost/about')
159
+ swHandlers.fetch(event)
160
+ expect(event.respondWith).toHaveBeenCalledOnce()
161
+ })
162
+
163
+ it('serves a pre-cached ASSET from cache', async () => {
164
+ const cached = new Response('<script>', { status: 200 })
165
+ mockCache.match.mockResolvedValue(cached)
166
+
167
+ const event = makeFetchEvent('http://localhost/_app/app.js')
168
+ swHandlers.fetch(event)
169
+
170
+ const result = await event.respondWith.mock.calls[0][0]
171
+ expect(result).toBe(cached)
172
+ })
173
+
174
+ it('falls back to network when pre-cached ASSET is not in cache', async () => {
175
+ const networkResponse = new Response('script', { status: 200 })
176
+ mockCache.match.mockResolvedValue(undefined)
177
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(networkResponse)
178
+
179
+ const event = makeFetchEvent('http://localhost/_app/app.js')
180
+ swHandlers.fetch(event)
181
+
182
+ const result = await event.respondWith.mock.calls[0][0]
183
+ expect(result).toBe(networkResponse)
184
+ })
185
+
186
+ it('caches 200 network responses for unknown paths', async () => {
187
+ const networkResponse = new Response('page html', { status: 200 })
188
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(networkResponse)
189
+
190
+ const event = makeFetchEvent('http://localhost/about')
191
+ swHandlers.fetch(event)
192
+ await event.respondWith.mock.calls[0][0]
193
+
194
+ expect(mockCache.put).toHaveBeenCalledWith(event.request, expect.any(Response))
195
+ })
196
+
197
+ it('does not cache non-200 responses', async () => {
198
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 404 }))
199
+
200
+ const event = makeFetchEvent('http://localhost/missing')
201
+ swHandlers.fetch(event)
202
+ await event.respondWith.mock.calls[0][0]
203
+
204
+ expect(mockCache.put).not.toHaveBeenCalled()
205
+ })
206
+
207
+ it('falls back to cache when the network fails', async () => {
208
+ const cachedFallback = new Response('cached page', { status: 200 })
209
+ mockCache.match.mockResolvedValue(cachedFallback)
210
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
211
+
212
+ const event = makeFetchEvent('http://localhost/about')
213
+ swHandlers.fetch(event)
214
+
215
+ const result = await event.respondWith.mock.calls[0][0]
216
+ expect(result).toBe(cachedFallback)
217
+ })
218
+
219
+ it('throws when network fails and cache is empty', async () => {
220
+ mockCache.match.mockResolvedValue(undefined)
221
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline'))
222
+
223
+ const event = makeFetchEvent('http://localhost/about')
224
+ swHandlers.fetch(event)
225
+
226
+ await expect(event.respondWith.mock.calls[0][0]).rejects.toThrow('offline')
227
+ })
228
+ })
package/svelte.config.js CHANGED
@@ -8,7 +8,8 @@ const baseCsp = [
8
8
  'https://www.gstatic.com/recaptcha/', // recaptcha
9
9
  'https://accounts.google.com/gsi/', // sign-in w/google
10
10
  'https://www.google.com/recaptcha/', // recapatcha
11
- 'https://fonts.gstatic.com/' // recaptcha fonts
11
+ 'https://fonts.gstatic.com/', // recaptcha fonts
12
+ 'https://challenges.cloudflare.com/' // turnstile
12
13
  ]
13
14
 
14
15
  if (!production) baseCsp.push('ws://localhost:3000')
@@ -41,7 +42,8 @@ const config = {
41
42
  'img-src': ['data:', 'blob:', ...baseCsp],
42
43
  'style-src': ['unsafe-inline', ...baseCsp],
43
44
  'object-src': ['none'],
44
- 'base-uri': ['self']
45
+ 'base-uri': ['self'],
46
+ 'frame-src': ['https://challenges.cloudflare.com/', 'https://accounts.google.com/']
45
47
  }
46
48
  },
47
49
  files: {
package/vite.config.ts CHANGED
@@ -1,18 +1,13 @@
1
+ import devtoolsJson from 'vite-plugin-devtools-json'
1
2
  import { sveltekit } from '@sveltejs/kit/vite'
2
- import { defineConfig } from 'vite'
3
+ import { defineConfig } from 'vitest/config'
3
4
  import tailwindcss from '@tailwindcss/vite'
4
5
 
5
6
  export default defineConfig({
6
- build: {
7
- sourcemap: process.env.NODE_ENV !== 'production'
8
- },
9
- plugins: [sveltekit(), tailwindcss()],
7
+ build: { sourcemap: process.env.NODE_ENV !== 'production' },
8
+ plugins: [sveltekit(), tailwindcss(), devtoolsJson()],
10
9
  test: {
11
10
  include: ['src/**/*.unit.test.ts', 'tests/**/*.unit.test.ts']
12
11
  },
13
- server: {
14
- host: 'localhost',
15
- port: 3000,
16
- open: 'http://localhost:3000'
17
- }
12
+ server: { host: 'localhost', port: 3000, open: 'http://localhost:3000' }
18
13
  })
@@ -1,22 +0,0 @@
1
- import type { MailDataRequired } from '@sendgrid/mail'
2
- import sgMail from '@sendgrid/mail'
3
- import { env } from '$env/dynamic/private'
4
-
5
- /**
6
- * Sends a transactional email via the SendGrid API.
7
- *
8
- * Merges the provided message with the default sender configured in the environment.
9
- * Any field in `message` (including `from`) will override the default.
10
- *
11
- * @param message - Partial SendGrid mail data. Must include at minimum `to`, `subject`, and `html` or `text`.
12
- * @throws {Error} If the SendGrid API key is missing or the API request fails.
13
- */
14
- export const sendMessage = async (message: Partial<MailDataRequired>) => {
15
- const { SENDGRID_SENDER, SENDGRID_KEY } = env
16
- sgMail.setApiKey(SENDGRID_KEY)
17
- const completeMessage = <MailDataRequired>{
18
- from: SENDGRID_SENDER, // default sender can be altered
19
- ...message
20
- }
21
- await sgMail.send(completeMessage)
22
- }