sveltekit-auth-example 1.0.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.
@@ -0,0 +1,152 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import type { Readable, Writable } from 'svelte/store'
3
+ import { config } from '$lib/config'
4
+
5
+ type Page = Readable<{
6
+ url: URL;
7
+ params: Record<string, string>;
8
+ stuff: App.Stuff;
9
+ status: number;
10
+ error: Error | null;
11
+ }>
12
+
13
+ export default function useAuth(page: Page, session: Writable<any>, goto) {
14
+
15
+ // Required to use session.set()
16
+ let sessionValue
17
+ session.subscribe(value => {
18
+ sessionValue = value
19
+ })
20
+
21
+ let referrer
22
+ page.subscribe(value => {
23
+ referrer = value.url.searchParams.get('referrer')
24
+ })
25
+
26
+ const loadScript = () => new Promise( (resolve, reject) => {
27
+ const script = document.createElement('script')
28
+ script.id = 'gsiScript'
29
+ script.async = true
30
+ script.src = 'https://accounts.google.com/gsi/client'
31
+ script.onerror = (error) => reject(error)
32
+ script.onload = () => resolve(script)
33
+ document.body.appendChild(script)
34
+ })
35
+
36
+ function googleAccountsIdInitialize() {
37
+ return window.google.accounts.id.initialize({
38
+ client_id: config.googleClientId,
39
+ callback: googleCallback
40
+ })
41
+ }
42
+
43
+ function googleAccountsIdRenderButton(htmlId: string) {
44
+ return window.google.accounts.id.renderButton(
45
+ document.getElementById(htmlId), {
46
+ theme: 'filled_blue',
47
+ size: 'large',
48
+ width: '367'
49
+ }
50
+ )
51
+ }
52
+
53
+ function initializeSignInWithGoogle(htmlId?: string) {
54
+ googleAccountsIdInitialize()
55
+
56
+ if (htmlId) {
57
+ return googleAccountsIdRenderButton(htmlId)
58
+ }
59
+
60
+ if (!sessionValue.user) window.google.accounts.id.prompt()
61
+ }
62
+
63
+ function setSessionUser(user: User | null) {
64
+ session.update(s => ({
65
+ ...s,
66
+ user
67
+ }))
68
+ }
69
+
70
+ async function googleCallback(response) {
71
+ const res = await fetch('/auth/google', {
72
+ method: 'POST',
73
+ headers: {
74
+ 'Content-Type': 'application/json'
75
+ },
76
+ body: JSON.stringify({ token: response.credential })
77
+ })
78
+ const fromEndpoint = await res.json()
79
+
80
+ if (res.ok) {
81
+ session.set({ user: fromEndpoint.user })
82
+ const { role } = fromEndpoint.user
83
+ if (referrer) return goto(referrer)
84
+ if (role === 'teacher') return goto('/teachers')
85
+ if (role === 'admin') return goto('/admin')
86
+ // Don't stay on login if successfully authenticated
87
+ if (window.location.pathname === '/login') goto('/')
88
+ }
89
+ }
90
+
91
+ async function registerLocal(user: User) {
92
+ try {
93
+ const res = await fetch('/auth/register', {
94
+ method: 'POST',
95
+ body: JSON.stringify(user),
96
+ headers: {
97
+ 'Content-Type': 'application/json'
98
+ }
99
+ })
100
+ const fromEndpoint = await res.json()
101
+ if (res.ok) {
102
+ session.set({ user: fromEndpoint.user })
103
+ goto('/')
104
+ } else {
105
+ throw new Error(fromEndpoint.message)
106
+ }
107
+ } catch (err) {
108
+ console.error('Login error', err)
109
+ throw new Error(err.message)
110
+ }
111
+ }
112
+
113
+ async function loginLocal(credentials) {
114
+ try {
115
+ const res = await fetch('/auth/login', {
116
+ method: 'POST',
117
+ body: JSON.stringify(credentials),
118
+ headers: {
119
+ 'Content-Type': 'application/json'
120
+ }
121
+ })
122
+ const fromEndpoint = await res.json()
123
+ if (res.ok) {
124
+ setSessionUser(fromEndpoint.user)
125
+ const { role } = fromEndpoint.user
126
+ if (referrer) return goto(referrer)
127
+ if (role === 'teacher') return goto('/teachers')
128
+ if (role === 'admin') return goto('/admin')
129
+ return goto('/')
130
+ } else {
131
+ throw new Error(fromEndpoint.message)
132
+ }
133
+ } catch (err) {
134
+ console.error('Login error', err)
135
+ throw new Error(err.message)
136
+ }
137
+ }
138
+
139
+ async function logout() {
140
+ // Request server delete httpOnly cookie called session
141
+ const url = '/auth/logout'
142
+ const res = await fetch(url, {
143
+ method: 'POST'
144
+ })
145
+ if (res.ok) {
146
+ session.set({}) // delete session.user from
147
+ goto('/login')
148
+ } else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
149
+ }
150
+
151
+ return { loadScript, initializeSignInWithGoogle, registerLocal, loginLocal, logout }
152
+ }
@@ -0,0 +1,4 @@
1
+ // Only include data that is not sensitive
2
+ export const config = {
3
+ googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID
4
+ }
@@ -0,0 +1,10 @@
1
+ type FocusResult = (formName: HTMLFormElement) => void
2
+
3
+ export const focusOnFirstError: FocusResult = (form: HTMLFormElement) => {
4
+ for(const field of form.elements) {
5
+ if (!field.checkValidity()) {
6
+ field.focus()
7
+ break
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,16 @@
1
+ <script lang="ts" context="module">
2
+ export const load = ({ error, status }) => {
3
+ return {
4
+ props: {
5
+ errorMessage: `${status}: ${error.message}`
6
+ }
7
+ }
8
+ }
9
+ </script>
10
+
11
+ <script lang="ts">
12
+ export let errorMessage
13
+ </script>
14
+
15
+ <h1>Error</h1>
16
+ <h4>{errorMessage}</h4>
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import { goto } from '$app/navigation'
4
+ import { page, session } from '$app/stores'
5
+ import { toast } from '../stores'
6
+ import useAuth from '$lib/auth'
7
+
8
+ // Vue.js Composition API style
9
+ const { loadScript, initializeSignInWithGoogle, logout } = useAuth(page, session, goto)
10
+
11
+ let sessionValue
12
+ session.subscribe(value => {
13
+ sessionValue = value
14
+ })
15
+
16
+ let Toast
17
+
18
+ onMount(async() => {
19
+ await import('bootstrap/js/dist/collapse')
20
+ Toast = (await import('bootstrap/js/dist/toast')).default
21
+ await loadScript()
22
+ initializeSignInWithGoogle()
23
+ })
24
+
25
+ const openToast = (open: boolean) => {
26
+ if (open) {
27
+ const toastDiv = <HTMLDivElement> document.getElementById('authToast')
28
+ const t = new Toast(toastDiv)
29
+ t.show()
30
+ }
31
+ }
32
+
33
+ $: openToast($toast.isOpen)
34
+ </script>
35
+
36
+ <nav class="navbar navbar-expand-lg navbar-light bg-light">
37
+ <div class="container">
38
+ <a class="navbar-brand" href="/">Auth</a>
39
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain" aria-controls="navbarMain" aria-expanded="false" aria-label="Toggle navigation">
40
+ <span class="navbar-toggler-icon"></span>
41
+ </button>
42
+ <div class="collapse navbar-collapse" id="navbarMain">
43
+ <div class="navbar-nav">
44
+ <a class="nav-link active" aria-current="page" href="/">Home</a>
45
+ <a class="nav-link" href="/info">Info</a>
46
+ <a class="nav-link" class:d-none={!$session.user} href="/profile">Profile</a>
47
+ <a class="nav-link" class:d-none={!$session.user || $session.user?.role !== 'admin'} href="/admin">Admin</a>
48
+ <a class="nav-link" class:d-none={!$session.user || $session.user?.role === 'student'} href="/teachers">Teachers</a>
49
+ <a class="nav-link" class:d-none={!!$session.user} href="/login">Login</a>
50
+ <a on:click|preventDefault={logout} class="nav-link" class:d-none={!$session.user} href={'#'}>Logout</a>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </nav>
55
+
56
+ <main class="container">
57
+ <slot/>
58
+
59
+ <div id="authToast" class="toast position-fixed top-0 end-0 m-3" role="alert" aria-live="assertive" aria-atomic="true">
60
+ <div class="toast-header bg-primary text-white">
61
+ <strong class="me-auto">{$toast.title}</strong>
62
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
63
+ </div>
64
+ <div class="toast-body">
65
+ {$toast.body}
66
+ </div>
67
+ </div>
68
+
69
+ </main>
70
+
71
+ <style lang="scss" global>
72
+ // Load Bootstrap's SCSS
73
+ @import 'bootstrap/scss/bootstrap';
74
+
75
+ // Make Retina displays crisper
76
+ * {
77
+ -webkit-font-smoothing: antialiased;
78
+ -moz-osx-font-smoothing: grayscale;
79
+ }
80
+
81
+ .toast {
82
+ z-index: 9999;
83
+ }
84
+ </style>
@@ -0,0 +1,17 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+ import dotenv from 'dotenv'
3
+ import type { QueryResult} from 'pg'
4
+ import pg from 'pg'
5
+
6
+ dotenv.config()
7
+
8
+ const pgNativePool = new pg.native.Pool({
9
+ max: 10, // default
10
+ connectionString: process.env['DATABASE_URL'],
11
+ ssl: {
12
+ rejectUnauthorized: false
13
+ }
14
+ })
15
+
16
+ type PostgresQueryResult = (sql: string, params?: any[]) => Promise<QueryResult<any>>
17
+ export const query: PostgresQueryResult = (sql, params?) => pgNativePool.query(sql, params)
@@ -0,0 +1,29 @@
1
+ import dotenv from 'dotenv'
2
+
3
+ dotenv.config()
4
+
5
+ const SEND_IN_BLUE_KEY = process.env['SEND_IN_BLUE_KEY']
6
+ const SEND_IN_BLUE_URL = process.env['SEND_IN_BLUE_URL']
7
+ const SEND_IN_BLUE_FROM = <MessageAddressee> JSON.parse(process.env['SEND_IN_BLUE_FROM'])
8
+ const SEND_IN_BLUE_ADMINS = <MessageAddressee> JSON.parse(process.env['SEND_IN_BLUE_ADMINS'])
9
+
10
+ // POST or PUT submission to SendInBlue
11
+ const submit = async (method, url, data) => {
12
+ const response: Response = await fetch(`${SEND_IN_BLUE_URL}${url}`, {
13
+ method,
14
+ headers: {
15
+ 'Content-Type': 'application/json',
16
+ 'api-key': SEND_IN_BLUE_KEY
17
+ },
18
+ body: JSON.stringify(data)
19
+ })
20
+ if (!response.ok) {
21
+ console.error('Error from SendInBlue:', response)
22
+ throw new Error(`Error communicating with SendInBlue.`)
23
+ }
24
+ }
25
+
26
+ const sender = SEND_IN_BLUE_FROM
27
+ const to = SEND_IN_BLUE_ADMINS
28
+
29
+ export const sendMessage = async (message: Message) => submit('POST', '/v3/smtp/email', { sender, to: [to], ...message })
@@ -0,0 +1,40 @@
1
+ <script context="module" lang="ts">
2
+ import type { Load } from '@sveltejs/kit'
3
+
4
+ export const load: Load = async ({ session }) => {
5
+ const authorized = ['admin']
6
+ if (!authorized.includes(session.user?.role)) {
7
+ return {
8
+ status: 302,
9
+ redirect: '/login?referrer=/admin'
10
+ }
11
+ }
12
+
13
+ const url = '/api/v1/admin'
14
+ const res = await fetch(url, {
15
+ method: 'GET',
16
+ headers: { 'Content-Type': 'application/json' }
17
+ })
18
+
19
+ // if !res.ok, error is returned as message
20
+ const { message } = await res.json()
21
+ return {
22
+ props: {
23
+ message
24
+ }
25
+ }
26
+
27
+ }
28
+ </script>
29
+
30
+ <script lang="ts">
31
+ export let message
32
+ </script>
33
+
34
+ <svelte:head>
35
+ <title>Administration</title>
36
+ </svelte:head>
37
+
38
+ <h1>Admin</h1>
39
+ <h4>Admin Role</h4>
40
+ <p>{message}</p>
@@ -0,0 +1,21 @@
1
+ import type { RequestHandler } from '@sveltejs/kit'
2
+
3
+ export const get: RequestHandler = async event => {
4
+ const authorized = ['admin']
5
+
6
+ if (!event.locals.user || !authorized.includes(event.locals.user.role)) {
7
+ return {
8
+ status: 401,
9
+ body: {
10
+ message: 'Unauthorized - admin role required.'
11
+ }
12
+ }
13
+ }
14
+
15
+ return {
16
+ status: 200,
17
+ body: {
18
+ message: 'Admin-only content from endpoint.'
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,21 @@
1
+ import type { RequestHandler } from '@sveltejs/kit'
2
+
3
+ export const get: RequestHandler = async event=> {
4
+ const authorized = ['admin', 'teacher']
5
+
6
+ if (!event.locals.user || !authorized.includes(event.locals.user.role)) {
7
+ return {
8
+ status: 401,
9
+ body: {
10
+ message: 'Unauthorized - admin or teacher role required.'
11
+ }
12
+ }
13
+ }
14
+
15
+ return {
16
+ status: 200,
17
+ body: {
18
+ message: 'Teachers or Admin-only content from endpoint.'
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,36 @@
1
+ import type { RequestHandler } from '@sveltejs/kit'
2
+ import { query } from '../../_db'
3
+
4
+ export const put: RequestHandler = async event => {
5
+ const authorized = ['admin', 'teacher', 'student']
6
+
7
+ if (!event.locals.user || !authorized.includes(event.locals.user.role)) {
8
+ return {
9
+ status: 401,
10
+ body: {
11
+ message: 'Unauthorized - must be logged-in.'
12
+ }
13
+ }
14
+ }
15
+
16
+ const sql = `CALL update_user($1, $2);`
17
+ try {
18
+ // Only permit update of the authenticated user
19
+ const body = await event.request.json()
20
+ await query(sql, [event.locals.user.id, JSON.stringify(body)])
21
+ } catch (error) {
22
+ return {
23
+ status: 503,
24
+ body: {
25
+ message: 'Could not communicate with database.'
26
+ }
27
+ }
28
+ }
29
+
30
+ return {
31
+ status: 200,
32
+ body: {
33
+ message: 'Successfully updated user profile.'
34
+ }
35
+ }
36
+ }
@@ -0,0 +1,82 @@
1
+ import type { RequestHandler } from '@sveltejs/kit'
2
+ import { query } from '../_db'
3
+
4
+ export const post: RequestHandler = async event => {
5
+ const { slug } = event.params
6
+
7
+ let result
8
+ let sql
9
+
10
+ try {
11
+ switch (slug) {
12
+ case 'login':
13
+ sql = `SELECT authenticate($1) AS "authenticationResult";`
14
+ break
15
+ case 'register':
16
+ sql = `SELECT register($1) AS "authenticationResult";`
17
+ break
18
+ case 'logout':
19
+ if (event.locals.user) { // if user is null, they are logged out anyway (session might have ended)
20
+ sql = `CALL delete_session($1);`
21
+ result = await query(sql, [event.locals.user.id])
22
+ }
23
+ return {
24
+ status: 200,
25
+ headers: {
26
+ 'Set-Cookie': `session=; Path=/; SameSite=Lax; HttpOnly; Expires=${new Date().toUTCString()}`
27
+ },
28
+ body: {
29
+ message: 'Logout successful.'
30
+ }
31
+ }
32
+ default:
33
+ return {
34
+ status: 404,
35
+ body: {
36
+ message: 'Invalid endpoint.',
37
+ user: null
38
+ }
39
+ }
40
+ }
41
+
42
+ // Only /auth/login and /auth/register at this point
43
+ const body = await event.request.json()
44
+ result = await query(sql, [JSON.stringify(body)])
45
+
46
+ } catch (error) {
47
+ return {
48
+ status: 503,
49
+ body: {
50
+ message: 'Could not communicate with database.',
51
+ user: null
52
+ }
53
+ }
54
+ }
55
+
56
+ const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
57
+
58
+ if (!authenticationResult.user) {
59
+ return {
60
+ status: authenticationResult.statusCode,
61
+ body: {
62
+ message: authenticationResult.status,
63
+ user: null,
64
+ sessionId: null
65
+ }
66
+ }
67
+ }
68
+
69
+ // Prevent hooks.ts:handle() from deleting cookie we just set
70
+ event.locals.user = authenticationResult.user
71
+
72
+ return {
73
+ status: authenticationResult.statusCode,
74
+ headers: { // database expires sessions in 2 hours (could do it here too)
75
+ 'Set-Cookie': `session=${authenticationResult.sessionId}; Path=/; SameSite=Lax; HttpOnly;`
76
+ },
77
+ body: {
78
+ message: authenticationResult.status,
79
+ user: authenticationResult.user,
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,42 @@
1
+ import type { RequestHandler } from '@sveltejs/kit'
2
+ import type { Secret } from 'jsonwebtoken'
3
+ import jwt from 'jsonwebtoken'
4
+ import dotenv from 'dotenv'
5
+ import { query } from '../_db'
6
+ import { sendMessage } from '../_send-in-blue'
7
+
8
+ dotenv.config()
9
+ const DOMAIN = process.env['DOMAIN']
10
+ const JWT_SECRET: Secret = process.env['JWT_SECRET']
11
+
12
+ export const post: RequestHandler = async event => {
13
+ const body = await event.request.json()
14
+ const sql = `SELECT id as "userId" FROM users WHERE email = $1 LIMIT 1;`
15
+ const { rows } = await query(sql, [body.email])
16
+
17
+ if (rows.length > 0) {
18
+ const { userId } = rows[0]
19
+ // Create JWT with userId expiring in 30 mins
20
+ const secret = JWT_SECRET
21
+ const token = jwt.sign({ subject: userId }, secret, {
22
+ expiresIn: '30m'
23
+ })
24
+
25
+ // Email URL with token to user
26
+ const message: Message = {
27
+ // sender: JSON.parse(<string> VITE_EMAIL_FROM),
28
+ to: [{ email: body.email }],
29
+ subject: 'Password reset',
30
+ tags: ['account'],
31
+ htmlContent: `
32
+ <a href="${DOMAIN}/auth/reset/${token}">Reset my password</a>. Your browser will open and ask you to provide a
33
+ new password with a confirmation then redirect you to your login page.
34
+ `
35
+ }
36
+ sendMessage(message)
37
+ }
38
+
39
+ return {
40
+ status: 204
41
+ }
42
+ }
@@ -0,0 +1,65 @@
1
+ import type { RequestHandler } from '@sveltejs/kit'
2
+ import { OAuth2Client } from 'google-auth-library'
3
+ import { query } from '../_db';
4
+ import { config } from '$lib/config'
5
+
6
+ // Verify JWT per https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
7
+ async function getGoogleUserFromJWT(token: string): Promise<User> {
8
+ try {
9
+ const clientId = config.googleClientId
10
+ const client = new OAuth2Client(clientId)
11
+ const ticket = await client.verifyIdToken({
12
+ idToken: token,
13
+ audience: clientId
14
+ });
15
+ const payload = ticket.getPayload()
16
+ return {
17
+ firstName: payload['given_name'],
18
+ lastName: payload['family_name'],
19
+ email: payload['email']
20
+ }
21
+ } catch (error) {
22
+ throw new Error(`Google user could not be authenticated: ${error.message}`)
23
+ }
24
+ }
25
+
26
+ // Upsert user and get session ID
27
+ async function upsertGoogleUser(user: User): Promise<UserSession> {
28
+ try {
29
+ const sql = `SELECT start_gmail_user_session($1) AS user_session;`
30
+ const { rows } = await query(sql, [JSON.stringify(user)])
31
+ return <UserSession> rows[0].user_session
32
+ } catch (error) {
33
+ throw new Error(`GMail user could not be upserted: ${error.message}`)
34
+ }
35
+ }
36
+
37
+ // Returns local user if Google user authenticated (and authorized our app)
38
+ export const post: RequestHandler = async event => {
39
+ try {
40
+ const { token } = await event.request.json()
41
+ const user = await getGoogleUserFromJWT(token)
42
+ const userSession = await upsertGoogleUser(user)
43
+
44
+ // Prevent hooks.ts's handler() from deleting cookie thinking no one has authenticated
45
+ event.locals.user = userSession.user
46
+
47
+ return {
48
+ status: 200,
49
+ headers: { // database expires sessions in 2 hours
50
+ 'Set-Cookie': `session=${userSession.id}; Path=/; SameSite=Lax; HttpOnly;`
51
+ },
52
+ body: {
53
+ message: 'Successful Google Sign-In.',
54
+ user: userSession.user
55
+ }
56
+ }
57
+ } catch (error) {
58
+ return { // session cookie deleted by hooks.js handle()
59
+ status: 401,
60
+ body: {
61
+ message: error.message
62
+ }
63
+ }
64
+ }
65
+ }