diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4214c8 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Auth0 SPA configuration. +# These are PUBLIC, non-secret identifiers (they ship to the browser in the +# Authorization Code + PKCE flow). Copy this file to `.env` for local dev, and +# set the same variables in your Cloudflare build environment for deploys. +PUBLIC_AUTH0_DOMAIN=id.letsbuilda.dev +PUBLIC_AUTH0_CLIENT_ID=THoRnVJxMOfLxMAW0kaNW8nuP1q8q3qQ diff --git a/.gitignore b/.gitignore index 16d54bb..28f22ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,9 @@ # build output dist/ +.wrangler/ # generated types .astro/ +worker-configuration.d.ts # dependencies node_modules/ diff --git a/README.md b/README.md index 87b813a..5991171 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,26 @@ All commands are run from the root of the project, from a terminal: | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` | | `npm run astro -- --help` | Get help using the Astro CLI | +## ๐Ÿ” Auth0 configuration + +Authentication uses the [`@auth0/auth0-spa-js`](https://github.com/auth0/auth0-spa-js) +SDK (Authorization Code + PKCE). Configuration comes from two **public, +non-secret** environment variables: + +| Variable | Description | +| :----------------------- | :--------------------------- | +| `PUBLIC_AUTH0_DOMAIN` | Auth0 tenant domain | +| `PUBLIC_AUTH0_CLIENT_ID` | Auth0 SPA application client ID | + +Copy `.env.example` to `.env` for local development, and set the same variables +in the Cloudflare build environment for deploys (Astro inlines `PUBLIC_*` values +at build time). + +In the Auth0 application (type: **Single Page Application**), add both your local +(`http://localhost:4321`) and production (`https://letsbuilda.dev`) origins to +**Allowed Callback URLs**, **Allowed Logout URLs**, and **Allowed Web Origins**, +and enable **Refresh Token Rotation**. + ## ๐Ÿ‘€ Want to learn more? Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat). diff --git a/package-lock.json b/package-lock.json index 480fd01..522c7ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@biomejs/biome": "2.4.15", + "@types/node": "^24.13.2", "wrangler": "^4.95.0" }, "engines": { @@ -2296,6 +2297,16 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/react": { "version": "19.2.15", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", @@ -5443,6 +5454,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", diff --git a/package.json b/package.json index f47b461..41e7bdf 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,9 @@ "react-dom": "^19.2.6" }, "devDependencies": { - "wrangler": "^4.95.0", - "@biomejs/biome": "2.4.15" + "@biomejs/biome": "2.4.15", + "@types/node": "^24.13.2", + "wrangler": "^4.95.0" }, "engines": { "node": ">=24.12.0" diff --git a/public/auth_config.json b/public/auth_config.json deleted file mode 100644 index 841be81..0000000 --- a/public/auth_config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "domain": "id.letsbuilda.dev", - "clientId": "THoRnVJxMOfLxMAW0kaNW8nuP1q8q3qQ" -} diff --git a/public/css/main.css b/public/css/main.css index ae61671..4cff3b7 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -1,8 +1,87 @@ -.hidden { - display: none; +:root { + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + line-height: 1.5; + color: #1a1a1a; } -label { - margin-bottom: 10px; - display: block; +body { + margin: 0; +} + +nav { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem; + border-bottom: 1px solid #e2e2e2; +} + +nav a { + color: #2563eb; + text-decoration: none; +} + +nav a:hover { + text-decoration: underline; +} + +.nav-spacer { + flex: 1 1 auto; +} + +nav button { + cursor: pointer; + padding: 0.4rem 0.9rem; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #f9fafb; + font: inherit; +} + +nav button:hover { + background: #f3f4f6; +} + +main { + max-width: 40rem; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.profile-card { + display: grid; + gap: 1rem; + padding: 1.5rem; + border: 1px solid #e2e2e2; + border-radius: 12px; + box-shadow: 0 1px 3px rgb(0 0 0 / 8%); +} + +.profile-card__avatar { + width: 96px; + height: 96px; + border-radius: 50%; + object-fit: cover; + background: #f3f4f6; +} + +.profile-card__name { + margin: 0; +} + +.profile-card__fields { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.5rem 1.5rem; + margin: 0; +} + +.profile-card__fields dt { + font-weight: 600; + color: #4b5563; +} + +.profile-card__fields dd { + margin: 0; + word-break: break-word; } diff --git a/public/js/app.js b/public/js/app.js deleted file mode 100644 index b35841a..0000000 --- a/public/js/app.js +++ /dev/null @@ -1,68 +0,0 @@ -let auth0Client = null; - -const fetchAuthConfig = () => fetch("/auth_config.json"); - -const configureClient = async () => { - const response = await fetchAuthConfig(); - const config = await response.json(); - - auth0Client = await auth0.createAuth0Client({ - domain: config.domain, - clientId: config.clientId, - }); -}; - -window.onload = async () => { - await configureClient(); - - updateUI(); - - const isAuthenticated = await auth0Client.isAuthenticated(); - - if (isAuthenticated) { - return; - } - - const query = window.location.search; - if (query.includes("code=") && query.includes("state=")) { - await auth0Client.handleRedirectCallback(); - updateUI(); - window.history.replaceState({}, document.title, "/"); - } -}; - -const updateUI = async () => { - const isAuthenticated = await auth0Client.isAuthenticated(); - - document.getElementById("btn-logout").disabled = !isAuthenticated; - document.getElementById("btn-login").disabled = isAuthenticated; - - if (isAuthenticated) { - document.getElementById("gated-content").classList.remove("hidden"); - - document.getElementById("ipt-access-token").innerHTML = - await auth0Client.getTokenSilently(); - - document.getElementById("ipt-user-profile").textContent = JSON.stringify( - await auth0Client.getUser(), - ); - } else { - document.getElementById("gated-content").classList.add("hidden"); - } -}; - -const login = async () => { - await auth0Client.loginWithRedirect({ - authorizationParams: { - redirect_uri: window.location.origin, - }, - }); -}; - -const logout = () => { - auth0Client.logout({ - logoutParams: { - returnTo: window.location.origin, - }, - }); -}; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..765ab4b --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + /** Auth0 tenant domain, e.g. `id.letsbuilda.dev`. Public, non-secret. */ + readonly PUBLIC_AUTH0_DOMAIN: string; + /** Auth0 SPA application client ID. Public, non-secret. */ + readonly PUBLIC_AUTH0_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..3c922ea --- /dev/null +++ b/src/layouts/BaseLayout.astro @@ -0,0 +1,48 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + {title} + + + + + + +
+ +
+ + + + diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..3802978 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,102 @@ +import { + type Auth0Client, + createAuth0Client, + type User, +} from "@auth0/auth0-spa-js"; + +// Both values are public, non-secret identifiers for this SPA's Authorization +// Code + PKCE flow. They are inlined at build time from PUBLIC_* environment +// variables (see .env.example). +const domain = import.meta.env.PUBLIC_AUTH0_DOMAIN; +const clientId = import.meta.env.PUBLIC_AUTH0_CLIENT_ID; + +if (!domain || !clientId) { + throw new Error( + "Missing Auth0 configuration. Set PUBLIC_AUTH0_DOMAIN and PUBLIC_AUTH0_CLIENT_ID.", + ); +} + +// State carried across the login redirect so we can return the user to the +// page they started from (Auth0 always redirects back to the origin). +interface AppState { + returnTo?: string; +} + +let clientPromise: Promise | null = null; + +// Finish the login redirect if this page received it, then forward the user to +// wherever they began. Safe to call on every page load. +async function handleRedirect(client: Auth0Client): Promise { + const query = window.location.search; + if (!query.includes("code=") || !query.includes("state=")) { + return; + } + + const { appState } = await client.handleRedirectCallback(); + window.history.replaceState({}, document.title, window.location.pathname); + + const returnTo = appState?.returnTo; + if (returnTo && returnTo !== window.location.pathname) { + window.location.assign(returnTo); + } +} + +// Create the client once per page load and finish any login redirect before the +// promise resolves, so every caller observes the post-login state regardless of +// the order in which page scripts run. +async function bootstrap(): Promise { + const client = await createAuth0Client({ + domain, + clientId, + // Persist the session across this multi-page site's full page reloads and + // renew it with rotating refresh tokens instead of the hidden-iframe flow + // that modern browsers increasingly block. + useRefreshTokens: true, + cacheLocation: "localstorage", + authorizationParams: { + redirect_uri: window.location.origin, + scope: "openid profile email", + }, + }); + + await handleRedirect(client); + return client; +} + +function getClient(): Promise { + if (!clientPromise) { + clientPromise = bootstrap(); + } + return clientPromise; +} + +// Initialise auth for the current page (processing any login redirect) and +// report whether the visitor is logged in. Call once per page from the layout. +export async function initPage(): Promise { + const client = await getClient(); + return client.isAuthenticated(); +} + +export async function isAuthenticated(): Promise { + const client = await getClient(); + return client.isAuthenticated(); +} + +export async function getUser(): Promise { + const client = await getClient(); + return client.getUser(); +} + +// Redirect to Auth0 to log in, returning to `returnTo` afterwards. +export async function login( + returnTo: string = window.location.pathname, +): Promise { + const client = await getClient(); + await client.loginWithRedirect({ appState: { returnTo } }); +} + +// Log out and return to the site root. +export async function logout(): Promise { + const client = await getClient(); + await client.logout({ logoutParams: { returnTo: window.location.origin } }); +} diff --git a/src/pages/account.astro b/src/pages/account.astro new file mode 100644 index 0000000..2388fb1 --- /dev/null +++ b/src/pages/account.astro @@ -0,0 +1,74 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; +--- + + +

Loading your account…

+ + + + +
diff --git a/src/pages/index.astro b/src/pages/index.astro index bb91294..23606d8 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,39 +1,21 @@ --- - +import BaseLayout from "../layouts/BaseLayout.astro"; --- - - - - - SPA SDK Sample - - + +

Welcome to letsbuilda.dev

+

Let's build a... something. Glad you're here.

- -

SPA Authentication Sample

-

Welcome to our page!

- - + + - + - - - + const authed = await isAuthenticated(); + const id = authed ? "account-cta" : "login-cta"; + document.getElementById(id)?.removeAttribute("hidden"); + +