diff --git a/examples/with-auth/.env.example b/examples/with-auth/.env.example index 63def6c5c..81cb71f57 100644 --- a/examples/with-auth/.env.example +++ b/examples/with-auth/.env.example @@ -1,3 +1,6 @@ -SESSION_SECRET = myverylonguniquesecretthatkeepsthingssafe +# Generate using `openssl rand -hex 32` +SESSION_SECRET = myverylongsessionsecretkeythatishouldchange + +# Get credentials by creating an application at https://discord.com/developers/applications DISCORD_ID = DISCORD_SECRET = diff --git a/examples/with-auth/README.md b/examples/with-auth/README.md index cfa58c8fa..42bfa99d3 100644 --- a/examples/with-auth/README.md +++ b/examples/with-auth/README.md @@ -1,55 +1,41 @@ -# SolidStart +# SolidStart Template -Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com); +The **with-auth** example demonstrates native, context-based authentication featuring OAuth and email-password login. -## Creating a project +## Installation -```bash -# create a new project in the current directory -npm init solid@latest +Generate the **with-auth** template using your preferred package manager -# create a new project in my-app -npm init solid@latest my-app +```bash +# using npm +npm create solid@latest -- -s -t with-auth ``` -## Developing - -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: - ```bash -npm run dev - -# or start the server and open the app in a new browser tab -npm run dev -- --open +# using pnpm +pnpm create solid@latest -s -t with-auth ``` -## Env Vars - -Rename the example file and add your Discord OAuth credentials: - ```bash -# rename example environment file -cp .env.example .env +# using bun +bun create solid@latest --s --t with-auth ``` -Edit `.env` with your values: +## Configuration -```dotenv -DISCORD_ID=your-discord-client-id -DISCORD_SECRET=your-discord-client-secret -``` +1. Rename `.env.example` to `.env` -1. Create an application at [https://discord.com/developers/applications](https://discord.com/developers/applications) to obtain your client ID and secret. -2. In the app's **OAuth2 → Redirects** settings, add: +2. For Discord OAuth2 to work, update `.env` with your credentials: - ```text - http://localhost:3000/api/oauth/discord + ```dotenv + DISCORD_ID=your-discord-client-id + DISCORD_SECRET=your-discord-client-secret ``` -For more details on the [start-oauth](https://github.com/thomasbuilds/start-oauth) integration, see the repository. - -## Building - -Solid apps are built with _presets_, which optimise your project for deployment to different environments. + - Create a Discord application at [discord.com/developers/applications](https://discord.com/developers/applications) to get your Client ID and Secret. + - In the app's **OAuth2 → URL Generator** or **Redirects** section, add the following redirect URI: + ``` + http://localhost:3000/api/oauth/discord + ``` -By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`. +3. To configure additional providers, refer to the [start-oauth](https://github.com/thomasbuilds/start-oauth#README) documentation diff --git a/examples/with-auth/app.config.ts b/examples/with-auth/app.config.ts index 23629d1fc..1405382b2 100644 --- a/examples/with-auth/app.config.ts +++ b/examples/with-auth/app.config.ts @@ -2,5 +2,7 @@ import { defineConfig } from "@solidjs/start/config"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ + ssr: true, // false for client-side rendering only + server: { preset: "" }, // your deployment vite: { plugins: [tailwindcss()] } }); diff --git a/examples/with-auth/package.json b/examples/with-auth/package.json index f0d25d1d4..00d3a6faa 100644 --- a/examples/with-auth/package.json +++ b/examples/with-auth/package.json @@ -7,16 +7,17 @@ "start": "vinxi start" }, "dependencies": { + "@solidjs/meta": "^0.29.4", "@solidjs/router": "^0.15.3", "@solidjs/start": "^1.1.7", - "solid-js": "^1.9.7", - "start-oauth": "^1.2.4", - "unstorage": "1.16.1", + "solid-js": "^1.9.9", + "start-oauth": "^1.3.0", + "unstorage": "1.17.1", "vinxi": "^0.5.8" }, "devDependencies": { - "@tailwindcss/vite": "^4.1.11", - "tailwindcss": "^4.1.11" + "@tailwindcss/vite": "^4.1.13", + "tailwindcss": "^4.1.13" }, "engines": { "node": ">=22" diff --git a/examples/with-auth/public/favicon.ico b/examples/with-auth/public/favicon.ico deleted file mode 100644 index fb282da07..000000000 Binary files a/examples/with-auth/public/favicon.ico and /dev/null differ diff --git a/examples/with-auth/public/favicon.svg b/examples/with-auth/public/favicon.svg new file mode 100644 index 000000000..e9a615cff --- /dev/null +++ b/examples/with-auth/public/favicon.svg @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/with-auth/src/app.css b/examples/with-auth/src/app.css index f1d8c73cd..591cb1901 100644 --- a/examples/with-auth/src/app.css +++ b/examples/with-auth/src/app.css @@ -1 +1,17 @@ @import "tailwindcss"; + +#app { + user-select: none; +} + +main { + @apply flex flex-col items-center justify-center min-h-screen bg-gray-50 gap-8 px-4; +} + +h1 { + @apply uppercase text-6xl text-sky-700 font-thin; +} + +button { + cursor: pointer; +} diff --git a/examples/with-auth/src/app.tsx b/examples/with-auth/src/app.tsx index 66b99b713..8a2ec4c38 100644 --- a/examples/with-auth/src/app.tsx +++ b/examples/with-auth/src/app.tsx @@ -1,10 +1,11 @@ -// @refresh reload import { type RouteDefinition, Router } from "@solidjs/router"; import { FileRoutes } from "@solidjs/start/router"; +import { MetaProvider } from "@solidjs/meta"; import { Suspense } from "solid-js"; -import { querySession } from "./lib"; -import Session from "./lib/Context"; +import { querySession } from "./auth"; +import Auth from "./components/Context"; import Nav from "./components/Nav"; +import ErrorNotification from "./components/Error"; import "./app.css"; export const route: RouteDefinition = { @@ -15,12 +16,15 @@ export default function App() { return ( ( - - - - {props.children} - - + + + + + {props.children} + + + + )} > diff --git a/examples/with-auth/src/lib/db.ts b/examples/with-auth/src/auth/db.ts similarity index 100% rename from examples/with-auth/src/lib/db.ts rename to examples/with-auth/src/auth/db.ts diff --git a/examples/with-auth/src/lib/index.ts b/examples/with-auth/src/auth/index.ts similarity index 72% rename from examples/with-auth/src/lib/index.ts rename to examples/with-auth/src/auth/index.ts index 4cc92954f..e6fb46fde 100644 --- a/examples/with-auth/src/lib/index.ts +++ b/examples/with-auth/src/auth/index.ts @@ -1,10 +1,10 @@ import { action, query, redirect } from "@solidjs/router"; -import { getSession, passwordSignIn } from "./server"; +import { getSession, passwordLogin } from "./server"; -// Define which routes require authentication +// Define routes that require being logged in const PROTECTED_ROUTES = ["/"]; -const isProtectedRoute = (path: string) => +const isProtected = (path: string) => PROTECTED_ROUTES.some(route => route.endsWith("/*") ? path.startsWith(route.slice(0, -2)) @@ -16,17 +16,17 @@ export const querySession = query(async (path: string) => { const { data } = await getSession(); if (path === "/login" && data.id) return redirect("/"); if (data.id) return data; - if (isProtectedRoute(path)) throw redirect(`/login?redirect=${path}`); + if (isProtected(path)) throw redirect(`/login?redirect=${path}`); return null; }, "session"); -export const passwdSignIn = action(async (formData: FormData) => { +export const formLogin = action(async (formData: FormData) => { "use server"; const email = formData.get("email"); const password = formData.get("password"); if (typeof email !== "string" || typeof password !== "string") return new Error("Email and password are required"); - return await passwordSignIn(email.trim().toLowerCase(), password); + return await passwordLogin(email.trim().toLowerCase(), password); }); export const logout = action(async () => { diff --git a/examples/with-auth/src/auth/server.ts b/examples/with-auth/src/auth/server.ts new file mode 100644 index 000000000..3ff787230 --- /dev/null +++ b/examples/with-auth/src/auth/server.ts @@ -0,0 +1,74 @@ +import { redirect } from "@solidjs/router"; +import { useSession } from "vinxi/http"; +import { getRandomValues, subtle, timingSafeEqual } from "crypto"; +import { createUser, findUser } from "./db"; + +export interface Session { + id: number; + email: string; +} + +export const getSession = () => + useSession({ + password: process.env.SESSION_SECRET! + }); + +export async function createSession(user: Session, redirectTo?: string) { + const validDest = redirectTo?.[0] === "/" && redirectTo[1] !== "/"; + const session = await getSession(); + await session.update(user); + return redirect(validDest ? redirectTo : "/"); +} + +async function createHash(password: string) { + const salt = getRandomValues(new Uint8Array(16)); + const saltHex = Buffer.from(salt).toString("hex"); + const key = await subtle.deriveBits( + { + name: "PBKDF2", + salt, + iterations: 100_000, + hash: "SHA-512" + }, + await subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, [ + "deriveBits" + ]), + 512 + ); + const hash = Buffer.from(key).toString("hex"); + return `${saltHex}:${hash}`; +} + +async function checkPassword(storedPassword: string, providedPassword: string) { + const [storedSalt, storedHash] = storedPassword.split(":"); + if (!storedSalt || !storedHash) throw new Error("Invalid stored password format"); + const key = await subtle.deriveBits( + { + name: "PBKDF2", + salt: Buffer.from(storedSalt, "hex"), + iterations: 100_000, + hash: "SHA-512" + }, + await subtle.importKey("raw", new TextEncoder().encode(providedPassword), "PBKDF2", false, [ + "deriveBits" + ]), + 512 + ); + const hash = Buffer.from(key); + const stored = Buffer.from(storedHash, "hex"); + if (stored.length !== hash.length || !timingSafeEqual(stored, hash)) + throw new Error("Invalid email or password"); +} + +export async function passwordLogin(email: string, password: string) { + let user = await findUser({ email }); + if (!user) + user = await createUser({ + email, + password: await createHash(password) + }); + else if (!user.password) + throw new Error("Account exists via OAuth. Sign in with your OAuth provider"); + else await checkPassword(user.password, password); + return createSession(user); +} diff --git a/examples/with-auth/src/components/Context.tsx b/examples/with-auth/src/components/Context.tsx new file mode 100644 index 000000000..53a4c1e14 --- /dev/null +++ b/examples/with-auth/src/components/Context.tsx @@ -0,0 +1,28 @@ +import { createAsync, useLocation, type AccessorWithLatest } from "@solidjs/router"; +import { createContext, useContext, type ParentProps } from "solid-js"; +import { logout, querySession } from "../auth"; +import type { Session } from "../auth/server"; + +const Context = createContext<{ + session: AccessorWithLatest; + signedIn: () => boolean; + logout: typeof logout; +}>(); + +export default function Auth(props: ParentProps) { + const location = useLocation(); + const session = createAsync(() => querySession(location.pathname), { + deferStream: true + }); + const signedIn = () => Boolean(session()?.id); + + return ( + {props.children} + ); +} + +export function useAuth() { + const context = useContext(Context); + if (!context) throw new Error("useAuth must be used within Auth context"); + return context; +} diff --git a/examples/with-auth/src/components/Counter.tsx b/examples/with-auth/src/components/Counter.tsx index e58ec8c0f..54ee4ec0d 100644 --- a/examples/with-auth/src/components/Counter.tsx +++ b/examples/with-auth/src/components/Counter.tsx @@ -5,7 +5,7 @@ export default function Counter() { return ( setCount(prev => prev + 1)} > Clicks: {count()} diff --git a/examples/with-auth/src/components/Error.tsx b/examples/with-auth/src/components/Error.tsx new file mode 100644 index 000000000..f1a72d760 --- /dev/null +++ b/examples/with-auth/src/components/Error.tsx @@ -0,0 +1,33 @@ +import { useSearchParams } from "@solidjs/router"; +import { createEffect, onCleanup, Show } from "solid-js"; +import { X } from "./Icons"; + +export default function ErrorNotification() { + const [searchParams, setSearchParams] = useSearchParams(); + + createEffect(() => { + if (searchParams.error) { + const timer = setTimeout(() => setSearchParams({ error: "" }), 5000); + onCleanup(() => clearTimeout(timer)); + } + }); + + return ( + + {msg => ( + + )} + + ); +} diff --git a/examples/with-auth/src/components/Icons.tsx b/examples/with-auth/src/components/Icons.tsx new file mode 100644 index 000000000..50f276353 --- /dev/null +++ b/examples/with-auth/src/components/Icons.tsx @@ -0,0 +1,22 @@ +type Icon = { class: string }; + +export const Discord = (props: Icon) => ( + + + +); + +export const X = (props: Icon) => ( + + + + +); diff --git a/examples/with-auth/src/components/Nav.tsx b/examples/with-auth/src/components/Nav.tsx index ee8bb8da7..7b0adad23 100644 --- a/examples/with-auth/src/components/Nav.tsx +++ b/examples/with-auth/src/components/Nav.tsx @@ -1,37 +1,50 @@ import { useMatch } from "@solidjs/router"; import { Show } from "solid-js"; -import { useSession } from "~/lib/Context"; +import { useAuth } from "~/components/Context"; export default function Nav() { - const { signedIn, signOut } = useSession(); + const { signedIn, logout } = useAuth(); const isHome = useMatch(() => "/"); const isAbout = useMatch(() => "/about"); return ( - - - - Home - - - About - - - Login}> - - Logout - - - - + + + Home + + + About + + + Login + + } + > + + + Sign Out + + + ); } diff --git a/examples/with-auth/src/entry-client.tsx b/examples/with-auth/src/entry-client.tsx index 0ca4e3c30..93211d714 100644 --- a/examples/with-auth/src/entry-client.tsx +++ b/examples/with-auth/src/entry-client.tsx @@ -1,4 +1,3 @@ -// @refresh reload import { mount, StartClient } from "@solidjs/start/client"; mount(() => , document.getElementById("app")!); diff --git a/examples/with-auth/src/entry-server.tsx b/examples/with-auth/src/entry-server.tsx index 401eff83f..68882e933 100644 --- a/examples/with-auth/src/entry-server.tsx +++ b/examples/with-auth/src/entry-server.tsx @@ -1,4 +1,3 @@ -// @refresh reload import { createHandler, StartServer } from "@solidjs/start/server"; export default createHandler(() => ( @@ -7,8 +6,9 @@ export default createHandler(() => ( + - + {assets} diff --git a/examples/with-auth/src/lib/Context.tsx b/examples/with-auth/src/lib/Context.tsx deleted file mode 100644 index a9e49acfd..000000000 --- a/examples/with-auth/src/lib/Context.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { createContext, useContext, type ParentProps } from "solid-js"; -import { type AccessorWithLatest, useLocation, createAsync, useAction } from "@solidjs/router"; -import { querySession, logout } from "."; -import type { Session } from "./server"; - -const Context = createContext<{ - session: AccessorWithLatest; - signedIn: () => boolean; - signOut: () => Promise; -}>(); - -export default function Session(props: ParentProps) { - const location = useLocation(); - const session = createAsync(() => querySession(location.pathname), { - deferStream: true - }); - const signOut = useAction(logout); - const signedIn = () => Boolean(session()?.id); - - return ( - {props.children} - ); -} - -export function useSession() { - const context = useContext(Context); - if (!context) throw new Error("useSession must be used within Session context"); - return context; -} diff --git a/examples/with-auth/src/lib/server.ts b/examples/with-auth/src/lib/server.ts deleted file mode 100644 index 0c67c5bd1..000000000 --- a/examples/with-auth/src/lib/server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useSession } from "vinxi/http"; -import { redirect } from "@solidjs/router"; -import { createUser, findUser } from "./db"; - -export interface Session { - id: number; - email: string; -} - -export const getSession = () => - useSession({ - password: process.env.SESSION_SECRET! - }); - -export const createSession = async (user: Session, redirectTo?: string) => { - const validDest = redirectTo?.[0] === "/" && redirectTo[1] !== "/"; - const session = await getSession(); - await session.update(user); - return redirect(validDest ? redirectTo : "/"); -}; - -export const oauthSignIn = async (email: string, redirectTo?: string) => { - let user = await findUser({ email }); - if (!user) user = await createUser({ email }); - return createSession(user, redirectTo); -}; - -export const passwordSignIn = async (email: string, password: string) => { - let user = await findUser({ email }); - if (!user) { - user = await createUser({ email, password }); - } else if (!user.password) { - throw new Error("Account exists via OAuth. Sign in with your OAuth provider"); - } else if (user.password !== password) { - throw new Error("Invalid email or password"); - } - return createSession(user); -}; diff --git a/examples/with-auth/src/routes/[...404].tsx b/examples/with-auth/src/routes/[...404].tsx index 03e9484e6..b45332ee2 100644 --- a/examples/with-auth/src/routes/[...404].tsx +++ b/examples/with-auth/src/routes/[...404].tsx @@ -1,25 +1,17 @@ -import { A } from "@solidjs/router"; +import { Title } from "@solidjs/meta"; export default function NotFound() { return ( - - Not Found - - Visit{" "} - - solidjs.com - {" "} - to learn how to build Solid apps. - - - - Home - - {" - "} - - About Page - - + + Page Not Found + Not Found + Sorry, the page you’re looking for doesn't exist + + Go Home + ); } diff --git a/examples/with-auth/src/routes/about.tsx b/examples/with-auth/src/routes/about.tsx index 2b8dbf0a0..ae50cf755 100644 --- a/examples/with-auth/src/routes/about.tsx +++ b/examples/with-auth/src/routes/about.tsx @@ -1,26 +1,19 @@ -import { A } from "@solidjs/router"; +import { Title } from "@solidjs/meta"; import Counter from "~/components/Counter"; export default function About() { return ( - - - About Page - - - Visit{" "} - - solidjs.com - {" "} - to learn how to build Solid apps. - - - - Home - {" "} - - About Page - - + + About Page + About Page + + + Visit{" "} + + start.solidjs.com + {" "} + to learn more on SolidStart + ); } diff --git a/examples/with-auth/src/routes/api/oauth/[...oauth].ts b/examples/with-auth/src/routes/api/oauth/[...oauth].ts index 13007a31e..f45f73569 100644 --- a/examples/with-auth/src/routes/api/oauth/[...oauth].ts +++ b/examples/with-auth/src/routes/api/oauth/[...oauth].ts @@ -1,13 +1,16 @@ -import OAuth, { type Configuration } from "start-oauth"; -import { oauthSignIn } from "~/lib/server"; +import OAuth from "start-oauth"; +import { createUser, findUser } from "~/auth/db"; +import { createSession } from "~/auth/server"; -const config: Configuration = { +export const GET = OAuth({ password: process.env.SESSION_SECRET!, discord: { id: process.env.DISCORD_ID!, secret: process.env.DISCORD_SECRET! }, - handler: async ({ email }, redirectTo) => oauthSignIn(email, redirectTo) -}; - -export const GET = OAuth(config); + async handler({ email }, redirectTo) { + let user = await findUser({ email }); + if (!user) user = await createUser({ email }); + return createSession(user, redirectTo); + } +}); diff --git a/examples/with-auth/src/routes/index.tsx b/examples/with-auth/src/routes/index.tsx index 4dc60ab6d..36524df0b 100644 --- a/examples/with-auth/src/routes/index.tsx +++ b/examples/with-auth/src/routes/index.tsx @@ -1,22 +1,15 @@ -import { useSession } from "~/lib/Context"; -import { A } from "@solidjs/router"; -import Counter from "~/components/Counter"; +import { Title } from "@solidjs/meta"; +import { useAuth } from "~/components/Context"; export default function Home() { - const { session } = useSession(); + const { session } = useAuth(); return ( - - - Hello World! - Your email is {session()?.email} - - - - About Page - - - + + Home + Hello World + + You are signed in as {session()?.email} ); } diff --git a/examples/with-auth/src/routes/login.tsx b/examples/with-auth/src/routes/login.tsx index 2a36a6dcd..b5ad86e99 100644 --- a/examples/with-auth/src/routes/login.tsx +++ b/examples/with-auth/src/routes/login.tsx @@ -1,73 +1,77 @@ +import { Title } from "@solidjs/meta"; import { useSubmission } from "@solidjs/router"; import { Show } from "solid-js"; -import useOAuthLogin from "start-oauth/client"; -import { passwdSignIn } from "~/lib"; +import { useOAuthLogin } from "start-oauth"; +import { formLogin } from "~/auth"; +import { Discord } from "~/components/Icons"; export default function Login() { + const login = useOAuthLogin(); + return ( - - - Sign in - - + + Sign In + Sign in + + + + + Or continue with + + + + + Sign in with Discord + ); } -function Password() { - const status = useSubmission(passwdSignIn); +function PasswordLogin() { + const submission = useSubmission(formLogin); return ( - - - Email + + + Email - - Password + + Password - Sign in + Submit - - {({ message }) => {message}} + + {({ message }) => {message}} ); } - -function OAuth() { - const login = useOAuthLogin(); - - return ( - - Or sign in with: - - - Discord - - - - ); -}
- Visit{" "} - - solidjs.com - {" "} - to learn how to build Solid apps. -
- - Home - - {" - "} - - About Page - -
- - Home - {" "} - - About Page -
+ Visit{" "} + + start.solidjs.com + {" "} + to learn more on SolidStart +
- - About Page - -
{message}
Or sign in with: