diff --git a/.github/actions/pack-sample/action.yml b/.github/actions/pack-sample/action.yml index c8b7a358..b995f706 100644 --- a/.github/actions/pack-sample/action.yml +++ b/.github/actions/pack-sample/action.yml @@ -11,6 +11,13 @@ inputs: version: description: Version being released (e.g. 1.2.3), used to name the zip file required: true + path: + description: > + Sample app directory, relative to the repo root. Defaults to samples//quickstart; + pass this for a sample that doesn't follow that convention (e.g. an integration sample + under samples/integrations//). + required: false + default: '' outputs: archive: @@ -26,6 +33,7 @@ runs: run: | SDK="${{ inputs.sdk }}" VERSION="${{ inputs.version }}" + SAMPLE_PATH="${{ inputs.path }}" DEST_DIR="$(pwd)" FOLDER="thunderid-${SDK}-sdk-sample-v${VERSION}" ARCHIVE="${FOLDER}.zip" @@ -33,7 +41,7 @@ runs: PACK_DIR="$(mktemp -d)" trap 'rm -rf "$PACK_DIR"' EXIT - cd "samples/${SDK}/quickstart" + cd "${SAMPLE_PATH:-samples/${SDK}/quickstart}" pnpm pack --pack-destination "${PACK_DIR}" cd "${DEST_DIR}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2bf94045..0ccf2092 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -255,13 +255,22 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: πŸ“¦ Pack Sample + id: pack + uses: ./.github/actions/pack-sample + with: + sdk: better-auth + version: ${{ steps.bump.outputs.version }} + path: samples/integrations/better-auth/nextjs + - name: πŸš€ Create GitHub Release env: GH_TOKEN: ${{ secrets.THUNDERID_AUTOMATION_BOT }} run: | gh release create "sdk/better-auth/v${{ steps.bump.outputs.version }}" \ --title "ThunderID Better Auth SDK v${{ steps.bump.outputs.version }}" \ - --generate-notes + --generate-notes \ + "${{ steps.pack.outputs.archive }}" # ── Level 1: depends on javascript ───────────────────────────────────────── release-browser: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6944857..c83f146f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -963,6 +963,37 @@ importers: specifier: ^4.21.2 version: 4.22.2(supports-color@10.2.2) + samples/integrations/better-auth/nextjs: + dependencies: + '@thunderid/better-auth': + specifier: workspace:* + version: link:../../../../packages/better-auth + better-auth: + specifier: ^1.7.2 + version: 1.7.2(next@15.5.23(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.1.10)(vue@3.5.41(typescript@5.9.3)) + next: + specifier: ^15.5.23 + version: 15.5.23(@babel/core@7.29.7(supports-color@10.2.2))(@playwright/test@1.60.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: + specifier: ^19.2.3 + version: 19.2.3 + react-dom: + specifier: ^19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/node': + specifier: ^24.7.2 + version: 24.7.2 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + samples/nextjs/quickstart: dependencies: '@thunderid/nextjs': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b7536c9..0e308a0b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - samples/*/* + - samples/integrations/*/* - tests/* allowBuilds: diff --git a/samples/integrations/better-auth/nextjs/.env.example b/samples/integrations/better-auth/nextjs/.env.example new file mode 100644 index 00000000..58f5a816 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/.env.example @@ -0,0 +1,15 @@ +# Secret used by Better Auth to sign/encrypt session cookies. Generate locally, not from the console. +BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32 +# Base URL this app is served from. Used to derive the OAuth callback URL below. +BETTER_AUTH_URL=http://localhost:3000 + +# ThunderID issuer URL, e.g. https://localhost:8090. The OIDC discovery URL is derived from +# this value: {THUNDERID_ISSUER}/.well-known/openid-configuration. +THUNDERID_ISSUER=https://localhost:8090 + +# OAuth 2.0 / OIDC client credentials from the application's Credentials tab in the console. +THUNDERID_CLIENT_ID=your-client-id-here +THUNDERID_CLIENT_SECRET=your-client-secret-here + +# DANGER: Disables ALL TLS verification. Only for local development with self-signed certs. NEVER use in production. +NODE_TLS_REJECT_UNAUTHORIZED=0 diff --git a/samples/integrations/better-auth/nextjs/.stackblitzrc b/samples/integrations/better-auth/nextjs/.stackblitzrc new file mode 100644 index 00000000..5f0a2000 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/.stackblitzrc @@ -0,0 +1,4 @@ +{ + "installDependencies": false, + "startCommand": "npm run prepare-dev && npm install && npm run dev" +} diff --git a/samples/integrations/better-auth/nextjs/README.md b/samples/integrations/better-auth/nextjs/README.md new file mode 100644 index 00000000..8ffecc86 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/README.md @@ -0,0 +1,69 @@ +# Better Auth + ThunderID Sample + +Open in StackBlitz + +A minimal Next.js 15 App Router application demonstrating [Better Auth](https://better-auth.com)'s +[Generic OAuth plugin](https://better-auth.com/docs/plugins/generic-oauth) configured for ThunderID via the +[`@thunderid/better-auth`](../../../../packages/better-auth) provider helper. + +All OAuth 2.0 / OIDC handling is performed by Better Auth itself β€” this sample has no ThunderID SDK +dependency, and keeps users/sessions in an in-memory store (`better-auth/adapters/memory`) that resets on +every server restart. Swap it for a real [database adapter](https://better-auth.com/docs/adapters) in a real +app. + +## Prerequisites + +- Node.js 18+ +- pnpm +- A ThunderID application with an OAuth 2.0 / OIDC (`authorization_code`) client + +## Getting started + +1. Copy the example environment file: + + ```bash + cp .env.example .env + ``` + +2. Fill in your ThunderID credentials in `.env`: + + ```dotenv + BETTER_AUTH_SECRET= + BETTER_AUTH_URL=http://localhost:3000 + THUNDERID_ISSUER=https://localhost:8090 + THUNDERID_CLIENT_ID= + THUNDERID_CLIENT_SECRET= + ``` + + `THUNDERID_CLIENT_ID` and `THUNDERID_CLIENT_SECRET` come from the application's Credentials tab in the + ThunderID console. + +3. Register the redirect URI on your ThunderID application (see the app's config notice in this sample for + the exact value to use): + + ``` + http://localhost:3000/api/auth/callback/thunderid + ``` + +4. Start the development server: + + ```bash + pnpm dev + ``` + + The app is now running at [http://localhost:3000](http://localhost:3000). Click **Sign in with + ThunderID** to try the flow. + +## How it works + +- [`lib/auth.ts`](./lib/auth.ts) β€” the Better Auth server instance. Registers the `genericOAuth` plugin with + the `thunderid()` helper from `@thunderid/better-auth`, which supplies the ThunderID issuer's discovery URL + and default `openid profile email` scopes. +- [`lib/auth-client.ts`](./lib/auth-client.ts) β€” the Better Auth React client. No client plugin is needed for + ThunderID; sign-in goes through the standard social-provider API. +- [`app/api/auth/[...all]/route.ts`](./app/api/auth/%5B...all%5D/route.ts) β€” the catch-all route handler that + exposes Better Auth's API via `toNextJsHandler`. +- [`app/page.tsx`](./app/page.tsx) β€” signs in with `authClient.signIn.social({provider: 'thunderid'})`, shows + the session via `authClient.useSession()`, and signs out with `authClient.signOut()`. +- [`app/components/ConfigNotice.tsx`](./app/components/ConfigNotice.tsx) β€” shown instead of the app when a + required environment variable is missing, with the exact redirect URI to register on ThunderID. diff --git a/samples/integrations/better-auth/nextjs/app/api/auth/[...all]/route.ts b/samples/integrations/better-auth/nextjs/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..c587fa41 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/api/auth/[...all]/route.ts @@ -0,0 +1,6 @@ +import { toNextJsHandler } from 'better-auth/next-js' +import { getAuth } from '../../../../lib/auth' + +export const { GET, POST } = toNextJsHandler({ + handler: (request) => getAuth().handler(request), +}) diff --git a/samples/integrations/better-auth/nextjs/app/components/ConfigNotice.tsx b/samples/integrations/better-auth/nextjs/app/components/ConfigNotice.tsx new file mode 100644 index 00000000..c3699b48 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/components/ConfigNotice.tsx @@ -0,0 +1,114 @@ +'use client' +import { useState } from 'react' +import BetterAuthLogo from './icons/BetterAuthLogo' +import ThunderMark from './ThunderMark' + +function MoonIcon() { + return ( + + + + ) +} + +function SunIcon() { + return ( + + + + + + + + ) +} + +export default function ConfigNotice({ missing }: { missing: string[] }) { + const [dark, setDark] = useState(false) + + const toggle = () => { + const next = !dark + setDark(next) + document.documentElement.classList.toggle('dark', next) + } + + return ( +
+ + +
+
+
+ +
+ +
+ + Setup required + +
+ +

Configuration needed

+ +

+ This sample can't reach ThunderID yet. Follow the steps + below, then restart the dev server. +

+ +
+
Step 1 · Set environment variables
+ +
    + {missing.map((key) => ( +
  • {key}
  • + ))} +
+ +

+ Copy .env.example to .env, fill in the + values from your ThunderID application, then run npm run dev again. +

+
+ +
+
Step 2 · Register the callback URL
+ +
+

+ Better Auth redirects the user to ThunderID and back via this route. In the{' '} + ThunderID Console, open this application and go + to Advanced Settings → OAuth2 Configuration, + then add the exact redirect URI below. +

+ +
+
+
Authorized redirect URI
+ http://localhost:3000/api/auth/callback/thunderid +
+
+
+
+ +

+ Need more info? Take a look at the{' '} + + Better Auth Generic OAuth plugin docs. + +

+
+
+
+ ) +} diff --git a/samples/integrations/better-auth/nextjs/app/components/HeroCtas.tsx b/samples/integrations/better-auth/nextjs/app/components/HeroCtas.tsx new file mode 100644 index 00000000..2a27b41f --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/components/HeroCtas.tsx @@ -0,0 +1,20 @@ +'use client' +import { useState } from 'react' +import { authClient } from '../../lib/auth-client' + +export default function HeroCtas() { + const [isLoading, setIsLoading] = useState(false) + + const signIn = async () => { + setIsLoading(true) + await authClient.signIn.social({ provider: 'thunderid', callbackURL: '/' }) + } + + return ( +
+ +
+ ) +} diff --git a/samples/integrations/better-auth/nextjs/app/components/Nav.tsx b/samples/integrations/better-auth/nextjs/app/components/Nav.tsx new file mode 100644 index 00000000..523474a2 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/components/Nav.tsx @@ -0,0 +1,64 @@ +'use client' +import { useState } from 'react' +import BetterAuthLogo from './icons/BetterAuthLogo' +import { authClient } from '../../lib/auth-client' + + +function MoonIcon() { + return ( + + + + ) +} + +function SunIcon() { + return ( + + + + + + + + ) +} + +export default function Nav() { + const [dark, setDark] = useState(false) + const { data: session } = authClient.useSession() + + const toggle = () => { + const next = !dark + setDark(next) + document.documentElement.classList.toggle('dark', next) + } + + const signIn = () => { + void authClient.signIn.social({ provider: 'thunderid', callbackURL: '/' }) + } + + const signOut = () => { + void authClient.signOut() + } + + return ( + + ) +} diff --git a/samples/integrations/better-auth/nextjs/app/components/ThunderMark.tsx b/samples/integrations/better-auth/nextjs/app/components/ThunderMark.tsx new file mode 100644 index 00000000..d8cf0ab0 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/components/ThunderMark.tsx @@ -0,0 +1,36 @@ +export default function ThunderMark({ + height = 40, + className, + darkMode = false, +}: { + height?: number + className?: string + darkMode?: boolean +}) { + const width = Math.round((height / 257) * 207) + + return ( + + + + + + ) +} diff --git a/samples/integrations/better-auth/nextjs/app/components/icons/BetterAuthLogo.tsx b/samples/integrations/better-auth/nextjs/app/components/icons/BetterAuthLogo.tsx new file mode 100644 index 00000000..cdd0bbb2 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/components/icons/BetterAuthLogo.tsx @@ -0,0 +1,11 @@ +export default function BetterAuthLogo({ size = 16, fill = 'currentColor' }: { size?: number; fill?: string }) { + return ( + + ) +} diff --git a/samples/integrations/better-auth/nextjs/app/globals.css b/samples/integrations/better-auth/nextjs/app/globals.css new file mode 100644 index 00000000..294110f2 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/globals.css @@ -0,0 +1,672 @@ +/* ── Reset ── */ +*, +::before, +::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Design tokens ── */ +:root { + --color-bg: #f7f9fc; + --color-card: #ffffff; + --color-border: #dde3ec; + --color-text: #05213f; + --color-text-muted: #5a7085; + --color-muted: #5a7085; + --color-primary: #3688ff; + --color-primary-hover: #1f72f0; + --color-nav-bg: rgba(247, 249, 252, 0.92); + --color-nav-border: #dde3ec; + --shadow-card: 0 1px 3px 0 rgba(5, 33, 63, 0.06), + 0 4px 12px 0 rgba(5, 33, 63, 0.08); + --font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', + sans-serif; + --radius-card: 12px; + --radius-btn: 8px; +} + +/* ── Dark mode ── */ +.dark, +html.dark { + --color-bg: #080f1c; + --color-card: #0f1a2d; + --color-border: rgba(255, 255, 255, 0.09); + --color-text: #E0EAFF; + --color-text-muted: rgba(224, 234, 255, 0.48); + --color-muted: rgba(224, 234, 255, 0.48); + --color-nav-bg: rgba(8, 15, 28, 0.92); +} + +/* ── Base ── */ +html, +body { + height: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--color-bg); + color: var(--color-text); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; +} + +/* ── App shell ── */ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--color-bg); + color: var(--color-text); + transition: background 0.2s, color 0.2s; +} + +/* ── Navigation ── */ +.nav { + position: fixed; + inset-block-start: 0; + inset-inline: 0; + height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + background: var(--color-nav-bg); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--color-nav-border); + z-index: 100; + transition: background 0.2s, border-color 0.2s; +} + +.nav-logo { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + color: var(--color-text); +} + +.wordmark-name { + font-size: 14px; + font-weight: 700; + letter-spacing: -0.03em; + color: var(--color-text); + line-height: 1; +} + +.nav-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.dark-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-btn); + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, background 0.15s, border-color 0.15s; + flex-shrink: 0; +} + +.dark-toggle:hover { + color: var(--color-text); + background: var(--color-border); +} + +/* ── Buttons ── */ +button { + font-family: var(--font-sans); + cursor: pointer; + border: none; + border-radius: var(--radius-btn); + font-weight: 600; + transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s; + line-height: 1; + white-space: nowrap; +} + +button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +.btn-primary, +.btn-outline { + display: inline-flex; + align-items: center; + justify-content: center; + height: 46px; + padding: 0 24px; + font-size: 15px; + text-decoration: none; +} + +.btn-primary { + background: var(--color-primary); + color: #ffffff; + border: 1.5px solid var(--color-primary); +} + +.btn-primary:hover { + opacity: 0.88; +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-outline { + background: transparent; + color: var(--color-primary); + border: 1.5px solid rgba(54, 136, 255, 0.4); +} + +.btn-outline:hover { + background: rgba(54, 136, 255, 0.08); +} + +/* Nav-sized buttons */ +.nav .btn-primary, +.nav .btn-outline { + height: 36px; + padding: 0 16px; + font-size: 14px; +} + +/* ── Hero section ── */ +.hero { + flex: 1; + padding: 96px 32px 80px; + min-height: 100vh; +} + +.hero-inner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 0; + max-width: 560px; + width: 100%; + margin: 0 auto; +} + +.hero-mark { + display: flex; + justify-content: center; + margin-bottom: 32px; +} + +.hero-badge { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-text-muted); + margin-bottom: 28px; +} + +.hero-badge-line { + display: block; + width: 24px; + height: 1px; + background: var(--color-border); + flex-shrink: 0; +} + +.hero-title { + font-size: 42px; + font-weight: 700; + letter-spacing: -0.05em; + color: var(--color-text); + line-height: 1.1; + margin: 0 0 18px; +} + +.hero-subtitle { + font-size: 16px; + color: var(--color-text-muted); + max-width: 420px; + line-height: 1.8; + letter-spacing: -0.01em; + margin: 0 auto 36px; +} + +.hero-ctas { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: center; + margin-bottom: 52px; +} + +.hero-divider { + width: 100%; + border: none; + border-top: 1px solid var(--color-border); + margin: 4px 0; +} + +/* ── Stats row ── */ +.hero-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + width: 100%; +} + +.hero-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 16px 8px; + position: relative; +} + +.hero-stat + .hero-stat::before { + content: ''; + position: absolute; + left: 0; + top: 20%; + height: 60%; + width: 1px; + background: var(--color-border); +} + +.hero-stat-value { + font-size: 15px; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; +} + +.hero-stat-label { + font-size: 12px; + color: var(--color-text-muted); +} + +/* ── Config notice ── */ +.config-badge { + color: #e88b3a; +} + +.config-list { + width: 100%; + max-width: 420px; + text-align: left; + list-style: none; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-btn); + padding: 0 16px; + margin: 4px 0 28px; +} + +.config-list-item { + padding: 10px 0; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; + color: var(--color-text); +} + +.config-list-item:not(:last-child) { + border-bottom: 1px solid var(--color-border); +} + +.config-hint { + font-size: 14px; + color: var(--color-text-muted); + line-height: 1.7; + max-width: 420px; + margin: 0 auto; +} + +.config-hint code { + background: rgba(54, 136, 255, 0.1); + border-radius: 4px; + padding: 2px 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 13px; +} + +.config-step { + width: 100%; + max-width: 420px; + margin: 0 auto 28px; +} + +.config-step-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--color-text-muted); + margin-bottom: 10px; + text-align: left; +} + +.config-step .config-list, +.config-step .config-hint { + margin-left: 0; + margin-right: 0; +} + +.config-box { + text-align: left; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + padding: 16px; +} + +.config-box-body { + font-size: 13px; + color: var(--color-text-muted); + line-height: 1.6; + margin-bottom: 14px; +} + +.config-value-group { + display: flex; + flex-direction: column; + gap: 12px; +} + +.config-value-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--color-text-muted); + margin-bottom: 4px; +} + +.config-value { + display: block; + overflow-x: auto; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--color-text); + background: rgba(54, 136, 255, 0.1); + border-radius: 4px; + padding: 8px 10px; +} + +.config-docs-note { + font-size: 13px; + color: var(--color-text-muted); + max-width: 420px; + margin: 0 auto; +} + +.config-docs-note a { + color: var(--color-primary); + font-weight: 600; +} + +/* ── User avatar ── */ +.user-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--color-primary); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + font-size: 17px; + font-weight: 700; + letter-spacing: 0.02em; + flex-shrink: 0; +} + +/* ── Signed-in home shell ── */ +.home-shell { + min-height: calc(100vh - 52px); + margin-top: 52px; +} + +/* ── Signed-in home page ── */ +.home-main { + width: 100%; + max-width: 900px; + margin: 0 auto; + padding: 52px 28px 80px; +} + +/* Greeting */ +.home-greeting { + display: flex; + align-items: flex-start; + gap: 16px; + margin-bottom: 36px; +} + +.home-avatar { + flex-shrink: 0; +} + +.home-greeting-text { + min-width: 0; +} + +.home-greeting-name { + margin: 0 0 6px; + font-size: 26px; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.04em; +} + +.home-greeting-meta { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--color-muted); + letter-spacing: -0.01em; + flex-wrap: wrap; +} + +.home-dot { + display: inline-block; + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--color-border); + flex-shrink: 0; +} + +.home-session-active { + display: inline-flex; + align-items: center; + gap: 5px; + color: #2fbd6b; +} + +.home-session-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #2fbd6b; + flex-shrink: 0; +} + +/* Session stats row */ +.home-stats { + display: flex; + gap: 0; + padding: 20px 0; + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + margin-bottom: 52px; +} + +.home-stat { + flex: 1; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.home-stat--bordered { + padding-left: 32px; + border-left: 1px solid var(--color-border); +} + +.home-stat-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: rgba(54, 136, 255, 0.1); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #3688ff; +} + +.home-stat-value { + font-size: 14px; + font-weight: 700; + color: var(--color-text); + letter-spacing: -0.02em; +} + +.home-stat-value--mono { + font-family: monospace; + letter-spacing: 0.01em; +} + +.home-stat-label { + font-size: 11px; + font-weight: 600; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.07em; + margin-top: 2px; +} + +/* What's next */ +.home-next-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.09em; + color: var(--color-muted); + margin-bottom: 20px; +} + +.home-next-list { + display: flex; + flex-direction: column; +} + +.home-next-item { + display: grid; + grid-template-columns: 28px 1fr auto; + gap: 16px; + align-items: start; + padding: 20px 0; + border-top: 1px solid var(--color-border); + text-decoration: none; + color: inherit; + cursor: pointer; + transition: background 0.2s; + border-radius: 4px; +} + +.home-next-item:hover .home-next-cta { + opacity: 1; +} + +.home-next-n { + font-size: 11px; + font-weight: 700; + color: #3688ff; + letter-spacing: 0.05em; + padding-top: 2px; + font-variant-numeric: tabular-nums; +} + +.home-next-body { + min-width: 0; +} + +.home-next-title { + font-size: 14px; + font-weight: 600; + color: var(--color-text); + letter-spacing: -0.02em; + margin-bottom: 4px; +} + +.home-next-desc { + font-size: 13px; + color: var(--color-muted); + line-height: 1.6; + letter-spacing: -0.01em; +} + +.home-next-cta { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; + font-weight: 600; + color: #3688ff; + white-space: nowrap; + opacity: 0.7; + transition: opacity 0.2s; + padding-top: 2px; +} + +/* ── Responsive ── */ +@media (max-width: 480px) { + .nav { + padding: 0 16px; + } + + .hero { + padding: 80px 16px 32px; + } + + .hero-title { + font-size: 2rem; + } + + .hero-stats { + grid-template-columns: 1fr; + } + + .hero-stat + .hero-stat::before { + top: 0; + left: 20%; + width: 60%; + height: 1px; + } + + .hero-ctas { + flex-direction: column; + align-items: stretch; + } +} diff --git a/samples/integrations/better-auth/nextjs/app/layout.tsx b/samples/integrations/better-auth/nextjs/app/layout.tsx new file mode 100644 index 00000000..a53c4ac1 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from 'next' +import ConfigNotice from './components/ConfigNotice' +import './globals.css' + +export const dynamic = 'force-dynamic' + +export const metadata: Metadata = { + title: 'Better Auth + ThunderID Sample', + description: 'Signing in to a Better Auth app via the ThunderID generic OAuth provider helper', +} + +const REQUIRED_ENV_VARS = [ + 'BETTER_AUTH_SECRET', + 'THUNDERID_ISSUER', + 'THUNDERID_CLIENT_ID', + 'THUNDERID_CLIENT_SECRET', +] + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + const missingEnvVars = REQUIRED_ENV_VARS.filter((key) => !process.env[key]) + + return ( + + + {missingEnvVars.length > 0 ? ( + + ) : ( + children + )} + + + ) +} diff --git a/samples/integrations/better-auth/nextjs/app/not-found.tsx b/samples/integrations/better-auth/nextjs/app/not-found.tsx new file mode 100644 index 00000000..53bd47dd --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/not-found.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link' + +export default function NotFound() { + return ( +
+
+

Page not found

+

The page you're looking for doesn't exist.

+ Return home +
+
+ ) +} diff --git a/samples/integrations/better-auth/nextjs/app/page.tsx b/samples/integrations/better-auth/nextjs/app/page.tsx new file mode 100644 index 00000000..a7480f69 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/app/page.tsx @@ -0,0 +1,246 @@ +'use client' +import { useEffect, useState } from 'react' +import HeroCtas from './components/HeroCtas' +import Nav from './components/Nav' +import ThunderMark from './components/ThunderMark' +import { authClient } from '../lib/auth-client' + +function greeting(name: string): string { + const h = new Date().getHours() + const tod = h < 12 ? 'morning' : h < 17 ? 'afternoon' : 'evening' + return `Good ${tod}, ${name}.` +} + +function formatTime(date: Date | string | undefined): string { + if (!date) return 'β€”' + return new Date(date).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +function formatCountdown(secsLeft: number): { text: string; color: string } { + if (secsLeft <= 0) return { text: 'Expired', color: '#d95757' } + if (secsLeft < 300) { + const m = Math.floor(secsLeft / 60), s = secsLeft % 60 + return { text: `${m}m ${s}s`, color: '#e88b3a' } + } + if (secsLeft < 3600) { + const m = Math.floor(secsLeft / 60), s = secsLeft % 60 + return { text: `${m}m ${s}s`, color: '#2fbd6b' } + } + const hr = Math.floor(secsLeft / 3600), m = Math.floor((secsLeft % 3600) / 60) + return { text: `${hr}h ${m}m`, color: '#2fbd6b' } +} + +const NEXT_STEPS = [ + { + n: '01', + title: 'Read the provider helper docs', + body: 'See every option the thunderid() helper accepts β€” scopes, PKCE, logout, sign-up gating.', + cta: 'View options', + href: 'https://github.com/thunder-id/javascript-sdks/tree/main/packages/better-auth#options', + }, + { + n: '02', + title: 'Learn the Generic OAuth plugin', + body: 'Understand how Better Auth performs the OAuth 2.0 / OIDC handshake with any provider.', + cta: 'Read guide', + href: 'https://better-auth.com/docs/plugins/generic-oauth', + }, + { + n: '03', + title: 'Swap in a real database', + body: 'This sample keeps users/sessions in memory. Point Better Auth at Postgres, SQLite, or Prisma.', + cta: 'Database adapters', + href: 'https://better-auth.com/docs/concepts/database', + }, + { + n: '04', + title: 'Explore ThunderID', + body: 'Set up an OAuth 2.0 / OIDC application and explore the rest of the ThunderID platform.', + cta: 'ThunderID docs', + href: 'https://thunderid.dev/', + }, +] + +function ClockIcon() { + return ( + + ) +} + +function TimerIcon() { + return ( + + ) +} + +function ProviderIcon() { + return ( + + ) +} + +function ArrowIcon() { + return ( + + ) +} + +function HomeContent({ user, session }: { + user: { name?: string | null; email: string; image?: string | null } + session: { createdAt: Date | string; expiresAt: Date | string } +}) { + const [now, setNow] = useState(Math.floor(Date.now() / 1000)) + + useEffect(() => { + const id = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 1000) + return () => clearInterval(id) + }, []) + + const givenName = user.name?.split(' ')[0] ?? user.email.split('@')[0] + const expiresAt = Math.floor(new Date(session.expiresAt).getTime() / 1000) + const secsLeft = Math.max(0, expiresAt - now) + const countdown = formatCountdown(secsLeft) + const initial = (user.name ?? user.email).charAt(0).toUpperCase() + + return ( +
+
+ {user.image ? ( + + ) : ( +
{initial}
+ )} +
+

{greeting(givenName)}

+
+ {user.email} + + + + Session active + +
+
+
+ +
+
+
+
+
{formatTime(session.createdAt)}
+
Signed in at
+
+
+
+
+
+
+ {countdown.text} +
+
Session expires in
+
+
+
+
+
+
ThunderID
+
OAuth provider
+
+
+
+ +
What's next
+ +
+ ) +} + +export default function HomePage() { + const { data: session, isPending } = authClient.useSession() + + return ( +
+
+ ) +} diff --git a/samples/integrations/better-auth/nextjs/lib/auth-client.ts b/samples/integrations/better-auth/nextjs/lib/auth-client.ts new file mode 100644 index 00000000..5c6d4796 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/lib/auth-client.ts @@ -0,0 +1,5 @@ +import { createAuthClient } from 'better-auth/react' + +// No client plugin is required for the ThunderID provider β€” the generic OAuth plugin +// exposes sign-in through Better Auth's standard social-provider API. +export const authClient = createAuthClient() diff --git a/samples/integrations/better-auth/nextjs/lib/auth.ts b/samples/integrations/better-auth/nextjs/lib/auth.ts new file mode 100644 index 00000000..c8c9cb66 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/lib/auth.ts @@ -0,0 +1,41 @@ +import { thunderid } from '@thunderid/better-auth' +import { betterAuth } from 'better-auth' +import { memoryAdapter, type MemoryDB } from 'better-auth/adapters/memory' +import { nextCookies } from 'better-auth/next-js' +import { genericOAuth } from 'better-auth/plugins' + +// In-memory store β€” fine for this sample, but resets on every server restart. +// Swap for a real database adapter (see https://better-auth.com/docs/adapters) in production. +// The memory adapter only auto-creates a table on its first write, but the OAuth callback reads +// "account" before ever writing to it, so every core table needs to exist upfront. +const db: MemoryDB = { user: [], session: [], account: [], verification: [] } + +function createAuth() { + return betterAuth({ + database: memoryAdapter(db), + plugins: [ + genericOAuth({ + config: [ + thunderid({ + clientId: process.env.THUNDERID_CLIENT_ID!, + clientSecret: process.env.THUNDERID_CLIENT_SECRET!, + issuer: process.env.THUNDERID_ISSUER!, + }), + ], + }), + // Must be listed last β€” it only applies cookies set by plugins earlier in this array. + nextCookies(), + ], + }) +} + +let instance: ReturnType | undefined + +// Built lazily, on the first request, rather than at module scope: the ThunderID issuer URL is +// required, and constructing eagerly would throw as soon as this module is imported β€” which +// happens during `next build`'s page-data collection even when nothing ever calls the handler, +// breaking the build for anyone who hasn't configured `.env` yet. +export function getAuth() { + instance ??= createAuth() + return instance +} diff --git a/samples/integrations/better-auth/nextjs/next-env.d.ts b/samples/integrations/better-auth/nextjs/next-env.d.ts new file mode 100644 index 00000000..830fb594 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/samples/integrations/better-auth/nextjs/next.config.ts b/samples/integrations/better-auth/nextjs/next.config.ts new file mode 100644 index 00000000..e4f5738a --- /dev/null +++ b/samples/integrations/better-auth/nextjs/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = {} + +export default nextConfig diff --git a/samples/integrations/better-auth/nextjs/package.json b/samples/integrations/better-auth/nextjs/package.json new file mode 100644 index 00000000..15c0f762 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/package.json @@ -0,0 +1,24 @@ +{ + "name": "@thunderid/better-auth-nextjs-sample", + "private": true, + "version": "0.0.0", + "scripts": { + "prepare-dev": "node scripts/prepare-dev.cjs", + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@thunderid/better-auth": "workspace:*", + "better-auth": "^1.7.2", + "next": "^15.5.23", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@types/node": "^24.7.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" + } +} diff --git a/samples/integrations/better-auth/nextjs/scripts/prepare-dev.cjs b/samples/integrations/better-auth/nextjs/scripts/prepare-dev.cjs new file mode 100644 index 00000000..8a3b1763 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/scripts/prepare-dev.cjs @@ -0,0 +1,36 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..'); + +const envExample = path.join(root, '.env.example'); +const envTarget = path.join(root, '.env'); + +// A plain `npm run prepare-dev` (e.g. from .stackblitzrc's startCommand) is a safe, +// idempotent copy-if-missing β€” it never overwrites an `.env` that's already there. +if (fs.existsSync(envExample) && !fs.existsSync(envTarget)) { + const envContent = fs + .readFileSync(envExample, 'utf8') + // Blank placeholder values (e.g. `your-client-id-here`, the sample base URL) so the + // copied .env still trips the app's missing-env-var check until real values are filled in. + .replace(/^([A-Z0-9_]+)=(your-\S*|generate-with-\S*|https:\/\/localhost:8090)$/gm, '$1='); + fs.writeFileSync(envTarget, envContent); +} + +const pkgPath = path.join(root, 'package.json'); +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + +for (const field of ['dependencies', 'devDependencies']) { + const deps = pkg[field]; + if (!deps) continue; + for (const [name, version] of Object.entries(deps)) { + if (version.startsWith('workspace:')) { + deps[name] = 'latest'; + } + } +} + +fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); diff --git a/samples/integrations/better-auth/nextjs/tsconfig.json b/samples/integrations/better-auth/nextjs/tsconfig.json new file mode 100644 index 00000000..6420eed1 --- /dev/null +++ b/samples/integrations/better-auth/nextjs/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}