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
|
@@ -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 '
|
|
3
|
+
import { defineConfig } from 'vitest/config'
|
|
3
4
|
import tailwindcss from '@tailwindcss/vite'
|
|
4
5
|
|
|
5
6
|
export default defineConfig({
|
|
6
|
-
build: {
|
|
7
|
-
|
|
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
|
-
}
|