├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.cjs ├── src ├── app.d.ts ├── app.html ├── app.postcss ├── hooks.server.ts ├── lib │ ├── auth │ │ ├── cookie.ts │ │ ├── index.ts │ │ └── pocketbase.ts │ ├── constants.server.ts │ └── stores │ │ └── session.ts └── routes │ ├── (authenticated) │ ├── +layout.svelte │ ├── +layout.ts │ ├── dashboard │ │ ├── +page.svelte │ │ └── +page.ts │ └── settings │ │ ├── +page.svelte │ │ └── +page.ts │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.svelte │ ├── Footer.svelte │ ├── Header.svelte │ ├── api │ └── me │ │ └── +server.ts │ ├── login │ ├── +page.server.ts │ ├── +page.svelte │ └── +page.ts │ ├── logout │ └── +page.server.ts │ └── signup │ ├── +page.server.ts │ ├── +page.svelte │ └── +page.ts ├── static └── favicon.png ├── svelte.config.js ├── tailwind.config.cjs ├── tsconfig.json └── vite.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = tab 3 | 4 | [package.json] 5 | indent_style = space 6 | indent_size = 2 7 | 8 | [*.md] 9 | indent_style = space 10 | indent_size = 2 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "prettier", 8 | ], 9 | plugins: ["svelte3", "@typescript-eslint", "neverthrow"], 10 | ignorePatterns: ["*.cjs"], 11 | overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }], 12 | settings: { 13 | "svelte3/typescript": () => require("typescript"), 14 | }, 15 | parserOptions: { 16 | sourceType: "module", 17 | ecmaVersion: 2020, 18 | }, 19 | env: { 20 | browser: true, 21 | es2017: true, 22 | node: true, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # PocketBase 11 | pb_data 12 | /pocketbase -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "pluginSearchDirs": ["."], 3 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SvelteKit Auth Example 2 | 3 | ![image](https://user-images.githubusercontent.com/157695/190524032-cc22bf37-de46-4d9b-aa05-1c2ef7fca60f.png) 4 | 5 | > An example SvelteKit app implementing a variety of authentication backends 6 | 7 | [**View the demo**](https://sveltekit-auth-example.pages.dev) 8 | 9 | **NOTE: this is very much a work in progress!** 10 | 11 | This project is designed as a sample implementation reference for getting authentication setup using SvelteKit. At this stage, I'd recommend just using it as something to refer to when building out your own app. 12 | 13 | ### Tools: 14 | 15 | - SvelteKit 16 | - TypeScript 17 | - TailwindCSS 18 | - DaisyUI for basic UI components 19 | - svelte-fa for FontAwesome icons 20 | - neverthrow for elegantly handling exceptions 21 | 22 | ### Features: 23 | 24 | - Form actions to login and signup 25 | - Store the users's auth token in a cookie 26 | - Fetch the user in the `handle` hook in `hooks.server` 27 | - Implementation of a basic session store 28 | - Use route (groups) to protect pages 29 | - Authenticate API endpoints via an auth token (`Authorization: Bearer ` header) 30 | - Log out 31 | 32 | ## Setup 33 | 34 | ```shell 35 | npm install 36 | ``` 37 | 38 | ## Development 39 | 40 | ```shell 41 | npm run dev 42 | 43 | # Run with debug logging: 44 | DEBUG="app:*" npm run dev 45 | ``` 46 | 47 | To debug in the browser, open up the `Console` in DevTools and type: 48 | 49 | ```js 50 | localStorage.debug = "app:*"; 51 | ``` 52 | 53 | ### Using auth adapters 54 | 55 | This project is built in a way to abstract the authentication layer so that you can pick and choose which type of auth you want to use. 56 | 57 | Right now, we support the following auth adapters: 58 | 59 | - `cookie` - Stores users and the auth token in a cookie. The is purely for demo purposes as it means we don't need any backend. You should NOT use this in production. 60 | - `pocketbase` - Uses [PocketBase](https://pocketbase.io) as the backend. All you need to do is follow their setup guide and then run `./pocketbase serve` and you should be up and running. 61 | 62 | You can enable the adapter you want by commenting out the adapter you want in `src/lib/auth/index.ts`. Make sure all other adapters are commented out. 63 | 64 | Then just configure your adapter backend and run the dev server! 65 | 66 | ## License 67 | 68 | MIT 69 | 70 | ## Credits 71 | 72 | Copyright Dana Woodman 2022 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sveltekit-auth-example", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev --port 8765", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --check . && eslint .", 12 | "format": "prettier --write ." 13 | }, 14 | "devDependencies": { 15 | "@fortawesome/free-solid-svg-icons": "6.2.0", 16 | "@sveltejs/adapter-auto": "1.0.0-next.75", 17 | "@sveltejs/kit": "1.0.0-next.484", 18 | "@tailwindcss/typography": "0.5.7", 19 | "@types/debug": "4.1.7", 20 | "@typescript-eslint/eslint-plugin": "^5.27.0", 21 | "@typescript-eslint/parser": "^5.27.0", 22 | "autoprefixer": "^10.4.7", 23 | "daisyui": "2.27.0", 24 | "debug": "4.3.4", 25 | "eslint": "^8.16.0", 26 | "eslint-config-prettier": "^8.3.0", 27 | "eslint-plugin-neverthrow": "1.1.4", 28 | "eslint-plugin-svelte3": "^4.0.0", 29 | "neverthrow": "5.0.0", 30 | "pocketbase": "0.7.0", 31 | "postcss": "^8.4.14", 32 | "postcss-load-config": "^4.0.1", 33 | "prettier": "^2.6.2", 34 | "prettier-plugin-svelte": "^2.7.0", 35 | "svelte": "^3.44.0", 36 | "svelte-check": "^2.7.1", 37 | "svelte-fa": "3.0.3", 38 | "svelte-preprocess": "^4.10.7", 39 | "tailwindcss": "^3.1.5", 40 | "ts-pattern": "4.0.5", 41 | "tslib": "^2.3.1", 42 | "typescript": "^4.7.4", 43 | "vite": "3.1.1" 44 | }, 45 | "type": "module" 46 | } 47 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss'); 2 | const autoprefixer = require('autoprefixer'); 3 | 4 | const config = { 5 | plugins: [ 6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 7 | tailwindcss(), 8 | //But others, like autoprefixer, need to run after, 9 | autoprefixer 10 | ] 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See: https://kit.svelte.dev/docs/types#app 2 | // import { Result} from "neverthrow"; 3 | 4 | declare namespace App { 5 | interface Locals { 6 | user?: User; 7 | } 8 | // interface PageData { } 9 | // interface PageError {} 10 | // interface Platform {} 11 | } 12 | 13 | interface User { 14 | id?: string; 15 | email: string; 16 | password?: string; 17 | token?: string; 18 | [key: string]: any; 19 | } 20 | 21 | type AuthResponse = Result; 22 | 23 | interface AuthAdapter { 24 | login(props: { 25 | email: string; 26 | password: string; 27 | // TEMPORARY 28 | opts?: any; 29 | }): Promise; 30 | signup(props: { 31 | email: string; 32 | password: string; 33 | password_confirm: string; 34 | // TEMPORARY 35 | opts?: any; 36 | }): Promise; 37 | validate_session(props: { 38 | token: string; 39 | // TEMPORARY 40 | opts?: any; 41 | }): Promise; 42 | logout(props: { 43 | token: string; 44 | // TEMPORARY 45 | opts?: any; 46 | }): Promise>; 47 | } 48 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app.postcss: -------------------------------------------------------------------------------- 1 | /* Write your global styles here, in PostCSS syntax */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | .btn { 7 | @apply flex-nowrap; 8 | } 9 | a.btn { 10 | @apply no-underline; 11 | } 12 | .input { 13 | @apply text-base; 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "$lib/auth"; 2 | import type { Handle } from "@sveltejs/kit"; 3 | import debug from "debug"; 4 | 5 | const log = debug("app:hooks.server"); 6 | 7 | export const handle: Handle = async ({ event, resolve }) => { 8 | // Grab the auth_token from the cookies for non-API request: 9 | const cookie_token = event.cookies.get("auth_token") as string; 10 | 11 | // Grab the `Authorization: Bearer ` header for API requests: 12 | const bearer_token = event.request.headers 13 | .get("Authorization") 14 | ?.split(" ")[1]; 15 | const token = cookie_token ?? bearer_token; 16 | 17 | log("token:", token); 18 | 19 | if (token) { 20 | const resp = await auth.validate_session({ 21 | token, 22 | opts: { cookies: event.cookies }, 23 | }); 24 | 25 | log("resp:", resp); 26 | 27 | if (resp.isOk()) { 28 | event.locals.user = resp.value; 29 | } else { 30 | // TODO: show error to user? 31 | console.error("Error validating session:", resp.error); 32 | } 33 | } 34 | 35 | return resolve(event); 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/auth/cookie.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * NOTE: You don't want to actually store the user in the cookie 3 | * we're doing this for demo purposes only so we don't need a database. 4 | */ 5 | import type { Cookies } from "@sveltejs/kit"; 6 | import debug from "debug"; 7 | import { err, ok } from "neverthrow"; 8 | 9 | const log = debug("app:lib:auth:cookie"); 10 | 11 | const seed_user: User = { 12 | id: "seed-user-id", 13 | email: "a@b.com", 14 | password: "asdfasdf", 15 | token: "seed-user-sesion-token", 16 | }; 17 | 18 | const one_day = 60 * 60 * 24; 19 | const maxAge = one_day * 365; 20 | 21 | export const cookie: AuthAdapter = { 22 | async validate_session({ token, opts }) { 23 | const [_, session_token] = token.split(":"); 24 | 25 | // TODO: add Zod 26 | if (!opts?.cookies) throw new Error("must pass cookies in to options"); 27 | if (!token) return err(new Error("no token provided")); 28 | 29 | const users = get_users(opts.cookies); 30 | 31 | log("users:", users); 32 | 33 | const user = users.find((user: User) => user.token === session_token); 34 | 35 | if (!user) return err(new Error("no user found")); 36 | 37 | return ok(user); 38 | }, 39 | async login({ email, password, opts }) { 40 | // TODO: add Zod 41 | if (!opts?.cookies) 42 | return err(new Error("must pass cookies in to options")); 43 | if (!email) return err(new Error("email is required")); 44 | if (!password) return err(new Error("password is required")); 45 | 46 | const users = get_users(opts.cookies); 47 | const user = users.find( 48 | (u) => u.email === email && u.password === password 49 | ); 50 | 51 | if (!user) return err(new Error("no user found")); 52 | 53 | user.token = generate_token(); 54 | 55 | set_users( 56 | opts.cookies, 57 | users.map((u) => { 58 | if (u.id === user.id) u.token = user.token ?? ""; 59 | return u; 60 | }) 61 | ); 62 | 63 | return ok(user); 64 | }, 65 | 66 | async signup({ email, password, password_confirm, opts }) { 67 | // TODO: add Zod 68 | if (!opts?.cookies) 69 | return err(new Error("must pass cookies in to options")); 70 | if (!email) return err(new Error("email is required")); 71 | if (!password) return err(new Error("password is required")); 72 | if (password !== password_confirm) 73 | return err(new Error("passwords do not match")); 74 | 75 | const token = generate_token(); 76 | const user = { id: generate_token(), email, password, token }; 77 | const users = get_users(opts.cookies); 78 | 79 | set_users(opts.cookies, [...users, user]); 80 | 81 | return ok(user); 82 | }, 83 | 84 | async logout({ token, opts }) { 85 | if (!opts?.cookies) 86 | return err(new Error("must pass cookies in to options")); 87 | // const token = cookies.get("auth_token") as string; 88 | opts.cookies.delete("auth_token", { path: "/" }); 89 | 90 | // Remove token from the user 91 | set_users( 92 | opts.cookies, 93 | get_users(opts.cookies).map((u) => { 94 | if (u.token === token) u.token = undefined; 95 | return u; 96 | }) 97 | ); 98 | 99 | return; 100 | }, 101 | }; 102 | 103 | function get_users(cookies: Cookies): User[] { 104 | const stored = cookies.get("users"); 105 | if (stored) return JSON.parse(stored); 106 | return [seed_user]; 107 | } 108 | 109 | function set_users(cookies: Cookies, users: User[]) { 110 | cookies.set("users", JSON.stringify(users), { path: "/", maxAge }); 111 | } 112 | 113 | function generate_token() { 114 | return Math.random().toString(36).slice(2); 115 | } 116 | -------------------------------------------------------------------------------- /src/lib/auth/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Uncomment the auth adapter you'd like to use. 3 | * Please visit the readme for more information on how to use the adapters. 4 | */ 5 | export { cookie as auth } from "./cookie"; 6 | // export { pocketbase as auth } from "./pocketbase"; 7 | -------------------------------------------------------------------------------- /src/lib/auth/pocketbase.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | import { err, ok, ResultAsync } from "neverthrow"; 3 | import type { User as PocketBaseUser } from "pocketbase"; 4 | 5 | const log = debug("app:lib:auth:pocketbase"); 6 | 7 | const POCKETBASE_API_URL = "http://127.0.0.1:8090/api"; 8 | 9 | export const pocketbase: AuthAdapter = { 10 | async login({ email, password }) { 11 | // TODO: add Zod 12 | const resp = await pocketbase_request({ 13 | path: "/users/auth-via-email", 14 | method: "POST", 15 | body: { email, password }, 16 | fallback_error_message: "error logging in", 17 | }); 18 | 19 | log("[login] resp:", resp); 20 | 21 | if (resp.isErr()) return err(resp.error); 22 | if (!("user" in resp.value)) 23 | return err(new Error(resp.value?.message ?? "no user found")); 24 | 25 | const { user, token } = resp.value; 26 | 27 | log("[login] user:", user); 28 | 29 | return ok({ ...user, token }); 30 | }, 31 | 32 | async signup({ email, password, password_confirm }) { 33 | // TODO: add Zod 34 | const resp = await pocketbase_request({ 35 | path: "/users", 36 | method: "POST", 37 | body: { email, password, passwordConfirm: password_confirm }, 38 | fallback_error_message: "error logging in", 39 | }); 40 | 41 | log("[signup] resp:", resp); 42 | 43 | if (resp.isErr()) return err(resp.error); 44 | 45 | if ("message" in resp.value) 46 | return err(new Error(resp.value.message ?? "unknown signup error")); 47 | 48 | const user = resp.value; 49 | 50 | if (!("id" in user) || !("email" in user)) 51 | return err(new Error("no user found")); 52 | 53 | log("[signup] signed up user:", user); 54 | 55 | return ok(user); 56 | }, 57 | 58 | async validate_session({ token }) { 59 | // TODO: add Zod 60 | const [user_id, session_token] = token.split(":"); 61 | 62 | log("[validate_session] id:", user_id); 63 | // log("[validate_session] token:", session_token); 64 | 65 | const resp = await pocketbase_request({ 66 | path: `/users/${user_id}`, 67 | headers: { Authorization: "User " + session_token }, 68 | }); 69 | 70 | log("[validate_session] resp:", resp); 71 | 72 | if (resp.isErr()) return err(resp.error); 73 | 74 | const user = resp.value; 75 | if (!user) return err(new Error("no user found")); 76 | 77 | log("[validate_session] user:", user); 78 | 79 | return ok(user); 80 | }, 81 | 82 | async logout() { 83 | // This is a non-op because PocketBase doesn't have a logout endpoint. 84 | // since it uses JWTs. 85 | return; 86 | }, 87 | }; 88 | 89 | async function pocketbase_request({ 90 | path, 91 | method = "GET", 92 | body = null, 93 | headers = {}, 94 | fallback_error_message = "unknown error", 95 | }: { 96 | path: string; 97 | method?: string; 98 | body?: any; 99 | headers?: any; 100 | fallback_error_message?: string; 101 | }) { 102 | const url = POCKETBASE_API_URL + path; 103 | 104 | log("url:", url); 105 | 106 | const init: RequestInit = { 107 | method, 108 | ...(body ? { body: JSON.stringify(body) } : {}), 109 | headers: { ...headers, "Content-Type": "application/json" }, 110 | }; 111 | 112 | log("init:", init); 113 | 114 | const request = fetch(url, init).then((r) => r.json()); 115 | 116 | return ResultAsync.fromPromise( 117 | request, 118 | () => new Error(fallback_error_message) 119 | ); 120 | } 121 | 122 | interface ErrorResponse { 123 | message?: string; 124 | code?: number; 125 | } 126 | 127 | type SignupResponse = PocketBaseUser | ErrorResponse; 128 | 129 | type LoginResponse = 130 | | { 131 | token: string; 132 | user: PocketBaseUser; 133 | } 134 | | ErrorResponse; 135 | -------------------------------------------------------------------------------- /src/lib/constants.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from "$env/dynamic/private"; 2 | const one_day = 60 * 60 * 24; 3 | 4 | export const AUTH_TOKEN_EXPIRY_SECONDS = Number( 5 | env?.AUTH_TOKEN_EXPIRY_SECONDS ?? one_day * 365 6 | ); 7 | -------------------------------------------------------------------------------- /src/lib/stores/session.ts: -------------------------------------------------------------------------------- 1 | import debug from "debug"; 2 | import { writable } from "svelte/store"; 3 | 4 | const log = debug("app:lib:stores:session"); 5 | 6 | interface Session { 7 | user?: User | null; 8 | } 9 | export const session = writable({ user: null }); 10 | 11 | session.subscribe((session) => log("session:", session)); 12 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/+layout.ts: -------------------------------------------------------------------------------- 1 | import { session } from "$lib/stores/session"; 2 | import { redirect } from "@sveltejs/kit"; 3 | import debug from "debug"; 4 | import { get } from "svelte/store"; 5 | import type { LayoutServerLoadEvent } from "./$types"; 6 | 7 | const log = debug("app:routes:(authenticated):layout"); 8 | 9 | export async function load(event: LayoutServerLoadEvent) { 10 | const parent_user = (await event.parent())?.user; 11 | const locals_user = event.locals?.user; 12 | const session_user = get(session)?.user; 13 | 14 | log("parent_user:", parent_user); 15 | log("locals_user:", locals_user); 16 | log("session_user:", session_user); 17 | 18 | const user = session_user || locals_user || parent_user; 19 | 20 | log("user:", user); 21 | 22 | if (!user) { 23 | log("no user, redirecting to /login"); 24 | throw redirect(301, "/login"); 25 | } 26 | return { user }; 27 | } 28 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/dashboard/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

Dashboard

9 |

🎉 Hello there {data.user?.email}, you're logged in!

10 |
    11 |
  • Settings - Another authenticated route.
  • 12 |
  • 13 | /api/me to get your 14 | user data in an authenticated API request. 15 |
      16 |
    • 17 | Note that if you want to make this request outside of the browser, you 18 | can pass in a Authorization: Bearer <TOKEN> 19 | header in your request (try the value 20 | seed-user-sesion-token to get our fake demo user's info). 21 |
    • 22 |
    23 |
  • 24 |
  • 25 | 26 | Log Out - Clears the 27 | user's token and redirects them back to the homepage. 28 |
  • 29 |
30 |
31 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/dashboard/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | return { title: "Dashboard" }; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/settings/+page.svelte: -------------------------------------------------------------------------------- 1 |
2 |

Settings

3 |

4 | 8 |

9 |
10 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/settings/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | return { title: "Settings" }; 3 | } 4 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoadEvent } from "./$types"; 2 | 3 | export async function load(event: LayoutServerLoadEvent) { 4 | const user = event.locals?.user; 5 | if (!user) return { user: null }; 6 | delete user.token; 7 | return { user }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | {title}SvelteKit Auth Demo 24 | 25 | 26 |
27 | 28 |
29 | 30 |
31 | 32 |