diff --git a/apps/cjs-test/package.json b/apps/cjs-test/package.json index ed9154272..ae563be8b 100644 --- a/apps/cjs-test/package.json +++ b/apps/cjs-test/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev --port 8106", "build": "next build", - "start": "next start", + "start": "next start --port 8106", "lint": "next lint" }, "dependencies": { diff --git a/apps/demo/package.json b/apps/demo/package.json index 81de01bcf..cdd6a4607 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -8,7 +8,7 @@ "clean": "rimraf .next && rimraf node_modules", "dev": "next dev --port 8103", "build": "next build", - "start": "next start", + "start": "next start --port 8103", "lint": "next lint" }, "dependencies": { diff --git a/apps/middleware/.env b/apps/middleware/.env new file mode 100644 index 000000000..76923dccb --- /dev/null +++ b/apps/middleware/.env @@ -0,0 +1,4 @@ +NEXT_PUBLIC_STACK_URL=# enter your stack endpoint here, e.g. http://localhost:8101 +NEXT_PUBLIC_STACK_PROJECT_ID=# enter your stack project id here +NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# enter your stack publishable client key here +STACK_SECRET_SERVER_KEY=# enter your stack secret server key here diff --git a/apps/middleware/.eslintrc.js b/apps/middleware/.eslintrc.js new file mode 100644 index 000000000..28b80fbd2 --- /dev/null +++ b/apps/middleware/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "extends": [ + "../../eslint-configs/defaults.js", + "../../eslint-configs/next.js", + ], + "ignorePatterns": ['/*', '!/src'] +}; diff --git a/apps/middleware/.gitignore b/apps/middleware/.gitignore new file mode 100644 index 000000000..fd3dbb571 --- /dev/null +++ b/apps/middleware/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/middleware/LICENSE b/apps/middleware/LICENSE new file mode 100644 index 000000000..0ec883d12 --- /dev/null +++ b/apps/middleware/LICENSE @@ -0,0 +1,7 @@ +Copyright 2024 Stackframe + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/apps/middleware/README.md b/apps/middleware/README.md new file mode 100644 index 000000000..c4033664f --- /dev/null +++ b/apps/middleware/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/apps/middleware/next.config.mjs b/apps/middleware/next.config.mjs new file mode 100644 index 000000000..189d52374 --- /dev/null +++ b/apps/middleware/next.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + experimental: { + ppr: true, + }, +}; + +export default nextConfig; diff --git a/apps/middleware/package.json b/apps/middleware/package.json new file mode 100644 index 000000000..4e99d9df1 --- /dev/null +++ b/apps/middleware/package.json @@ -0,0 +1,25 @@ +{ + "name": "middleware-demo", + "version": "0.1.4", + "private": true, + "scripts": { + "dev": "next dev --port 8107", + "build": "next build", + "start": "next start --port 8107", + "lint": "next lint" + }, + "dependencies": { + "next": "^14.3.0-canary.26", + "react": "^18", + "react-dom": "^18", + "@stackframe/stack": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.3", + "typescript": "^5" + } +} diff --git a/apps/middleware/src/app/handler/[...stack]/page.tsx b/apps/middleware/src/app/handler/[...stack]/page.tsx new file mode 100644 index 000000000..bb1bc2ebc --- /dev/null +++ b/apps/middleware/src/app/handler/[...stack]/page.tsx @@ -0,0 +1,10 @@ +import { StackHandler } from "@stackframe/stack"; +import { stackServerApp } from "../../../stack"; + +export default function Handler(props: any) { + return ( +
+ +
+ ); +} diff --git a/apps/middleware/src/app/layout.tsx b/apps/middleware/src/app/layout.tsx new file mode 100644 index 000000000..b41c82dc8 --- /dev/null +++ b/apps/middleware/src/app/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; +import { StackProvider, StackTheme } from "@stackframe/stack"; +import { stackServerApp } from "../stack"; +import { Inter } from "next/font/google"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Stack Auth Middleware Demo", + description: "A demo of Stack's middleware capabilities.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode, +}>) { + return ( + + + + + {children} + + + + + ); +} diff --git a/apps/middleware/src/app/loading.tsx b/apps/middleware/src/app/loading.tsx new file mode 100644 index 000000000..048c6d72c --- /dev/null +++ b/apps/middleware/src/app/loading.tsx @@ -0,0 +1,3 @@ +export default function Loading() { + return <>; +} diff --git a/apps/middleware/src/app/page.tsx b/apps/middleware/src/app/page.tsx new file mode 100644 index 000000000..1cf3118a9 --- /dev/null +++ b/apps/middleware/src/app/page.tsx @@ -0,0 +1,12 @@ +import { stackServerApp } from "@/stack"; +import Link from "next/link"; + +export default async function Home() { + return ( +
+ Page 1 (not protected)
+ Current login status: {await stackServerApp.getUser() ? 'Logged in' : 'Not logged in'}
+ Go to Page 2 +
+ ); +} diff --git a/apps/middleware/src/app/protected/page.tsx b/apps/middleware/src/app/protected/page.tsx new file mode 100644 index 000000000..b44ad8e42 --- /dev/null +++ b/apps/middleware/src/app/protected/page.tsx @@ -0,0 +1,12 @@ +import { stackServerApp } from "@/stack"; +import Link from "next/link"; + +export default function Home() { + return ( +
+ Page 2 (protected by middleware)
+ Sign out
+ Go to Page 1 +
+ ); +} diff --git a/apps/middleware/src/middleware.tsx b/apps/middleware/src/middleware.tsx new file mode 100644 index 000000000..4321910e1 --- /dev/null +++ b/apps/middleware/src/middleware.tsx @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { stackServerApp } from './stack'; + +export async function middleware(request: NextRequest) { + console.log('Running middleware on URL: ', request.url); + + // fetch the user object, and redirect if not logged in + const user = await stackServerApp.getUser(); + if (!user) { + console.log('User in middleware is not logged in. Redirecting to /handler/signin'); + return NextResponse.redirect(new URL('/handler/signin', request.url)); + } + + console.log('User in middleware is logged in. ID: ', user.id); + + return NextResponse.next(); +} + +export const config = { + matcher: '/protected/:path*', +}; diff --git a/apps/middleware/src/stack.tsx b/apps/middleware/src/stack.tsx new file mode 100644 index 000000000..8b73c2b70 --- /dev/null +++ b/apps/middleware/src/stack.tsx @@ -0,0 +1,8 @@ +import "server-only"; + +import { StackServerApp } from "@stackframe/stack"; + +export const stackServerApp = new StackServerApp({ + tokenStore: "nextjs-cookie", +}); +(stackServerApp as any).__DEMO_ENABLE_SLIGHT_FETCH_DELAY = true; diff --git a/apps/middleware/tsconfig.json b/apps/middleware/tsconfig.json new file mode 100644 index 000000000..f48e7ee6f --- /dev/null +++ b/apps/middleware/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "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": { + "@/*": [ + "./src/*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/partial-prerendering/package.json b/apps/partial-prerendering/package.json index 69edd248e..3637bad09 100644 --- a/apps/partial-prerendering/package.json +++ b/apps/partial-prerendering/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev --port 8105", "build": "next build", - "start": "next start", + "start": "next start --port 8105", "lint": "next lint" }, "dependencies": { diff --git a/docs/package.json b/docs/package.json index 68de16c1f..a98a78420 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docusaurus": "dotenv -c -- docusaurus", "clean": "pnpm docusaurus clear && rimraf node_modules", "dev": "pnpm docusaurus start --port 8104 --no-open", - "start": "pnpm docusaurus start", + "start": "pnpm docusaurus start --port 8104 --no-open", "build": "pnpm docusaurus build", "swizzle": "pnpm docusaurus swizzle", "deploy": "pnpm docusaurus deploy", diff --git a/package.json b/package.json index 38ecd7394..eefff3c1d 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:app": "turbo run dev --continue --filter=@stackframe/dev-app...", "dev:server": "turbo run dev --continue --filter=@stackframe/stack-server...", "dev:email": "turbo run email --continue --filter=@stackframe/stack-server...", + "start": "turbo run start --parallel --continue", "start:server": "turbo run start --continue --filter=@stackframe/stack-server...", "docs": "turbo run dev --parallel --continue --filter=stack-docs...", "lint": "turbo run lint --no-cache -- --max-warnings=0", diff --git a/packages/stack-sc/src/index.client.ts b/packages/stack-sc/src/index.client.ts index f6770495a..c6bbe4ad8 100644 --- a/packages/stack-sc/src/index.client.ts +++ b/packages/stack-sc/src/index.client.ts @@ -1 +1 @@ -export const cookies = undefined; \ No newline at end of file +export { cookies } from 'next/headers'; diff --git a/packages/stack-server/src/middleware.tsx b/packages/stack-server/src/middleware.tsx index 4cf764d0e..00e822916 100644 --- a/packages/stack-server/src/middleware.tsx +++ b/packages/stack-server/src/middleware.tsx @@ -5,14 +5,22 @@ import type { NextRequest } from 'next/server'; import { deindent } from '@stackframe/stack-shared/dist/utils/strings'; const corsAllowedRequestHeaders = [ + // General 'authorization', 'content-type', 'x-stack-project-id', + 'x-stack-override-error-status', + + // Project auth + 'x-stack-request-type', 'x-stack-publishable-client-key', 'x-stack-secret-server-key', 'x-stack-super-secret-admin-key', 'x-stack-admin-access-token', - 'x-stack-override-error-status', + + // User auth + 'x-stack-user-refresh-token', + 'x-stack-user-access-token', ]; const corsAllowedResponseHeaders = [ diff --git a/packages/stack-shared/src/utils/errors.tsx b/packages/stack-shared/src/utils/errors.tsx index 05ca5a1fa..976c8060c 100644 --- a/packages/stack-shared/src/utils/errors.tsx +++ b/packages/stack-shared/src/utils/errors.tsx @@ -35,7 +35,12 @@ export function registerErrorSink(sink: (location: string, error: unknown) => vo } errorSinks.add(sink); } -registerErrorSink((location, ...args) => console.error(`Error in ${location}:`, ...args)); +registerErrorSink((location, ...args) => { + console.error(`Error in ${location}:`, ...args); + if (process.env.NODE_ENV === "development") { + debugger; + } +}); export function captureError(location: string, error: unknown): void { for (const sink of errorSinks) { diff --git a/packages/stack/src/components-page/oauth-callback.tsx b/packages/stack/src/components-page/oauth-callback.tsx index 2f39dab2c..69bd3b058 100644 --- a/packages/stack/src/components-page/oauth-callback.tsx +++ b/packages/stack/src/components-page/oauth-callback.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import { useStackApp } from ".."; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import MessageCard from "../components/message-card"; @@ -8,12 +8,23 @@ import MessageCard from "../components/message-card"; export default function OAuthCallback () { const app = useStackApp(); const called = useRef(false); + const [error, setError] = useState(null); useEffect(() => runAsynchronously(async () => { if (called.current) return; called.current = true; - await app.callOAuthCallback(); + try { + await app.callOAuthCallback(); + } catch (e: any) { + setError(e); + } }), []); - return ; + return + {error ?
+

Something went wrong while processing the OAuth callback:

+
{JSON.stringify(error, null, 2)}
+

This is most likely an error in Stack. Please report it.

+
: null} +
; } diff --git a/packages/stack/src/lib/auth.ts b/packages/stack/src/lib/auth.ts index a51cc5adb..bf08d8074 100644 --- a/packages/stack/src/lib/auth.ts +++ b/packages/stack/src/lib/auth.ts @@ -2,7 +2,7 @@ import { StackClientInterface } from "@stackframe/stack-shared"; import { saveVerifierAndState, getVerifierAndState } from "./cookie"; import { constructRedirectUrl } from "../utils/url"; import { TokenStore } from "@stackframe/stack-shared/dist/interface/clientInterface"; -import { neverResolve } from "@stackframe/stack-shared/dist/utils/promises"; +import { neverResolve, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; export async function signInWithOAuth( diff --git a/packages/stack/src/lib/cookie.ts b/packages/stack/src/lib/cookie.ts index a2cdf82f2..aa49df1f4 100644 --- a/packages/stack/src/lib/cookie.ts +++ b/packages/stack/src/lib/cookie.ts @@ -2,12 +2,20 @@ import { generateRandomCodeVerifier, generateRandomState, calculatePKCECodeChall import Cookies from "js-cookie"; import { cookies as rscCookies } from '@stackframe/stack-sc'; +function isCookieUnavailableError(e: any) { + const allowedMessageSnippets = ["was called outside a request scope", "cookies() expects to have requestAsyncStorage"]; + return typeof e?.message === "string" && allowedMessageSnippets.some(msg => e.message.includes(msg)); +} + export function getCookie(name: string): string | null { - // TODO the differentiating factor should be RCC vs. RSC, not whether it's a client - if (rscCookies) { + try { return rscCookies().get(name)?.value ?? null; - } else { - return Cookies.get(name) ?? null; + } catch (e: any) { + if (isCookieUnavailableError(e)) { + return Cookies.get(name) ?? null; + } else { + throw e; + } } } @@ -20,18 +28,31 @@ export function setOrDeleteCookie(name: string, value: string | null) { } export function deleteCookie(name: string) { - if (rscCookies) { + try { rscCookies().delete(name); - } else { - Cookies.remove(name); + } catch (e: any) { + if (isCookieUnavailableError(e)) { + Cookies.remove(name); + } else { + throw e; + } } } -export function setCookie(name: string, value: string) { - if (rscCookies) { - rscCookies().set(name, value); - } else { - Cookies.set(name, value, { secure: window.location.protocol === "https:" }); +export function setCookie(name: string, value: string, options: { maxAge?: number } = {}) { + try { + rscCookies().set(name, value, { + maxAge: options.maxAge, + }); + } catch (e: any) { + if (isCookieUnavailableError(e)) { + Cookies.set(name, value, { + secure: window.location.protocol === "https:", + expires: options.maxAge === undefined ? undefined : new Date(Date.now() + (options.maxAge) * 1000), + }); + } else { + throw e; + } } } diff --git a/packages/stack/src/lib/stack-app.ts b/packages/stack/src/lib/stack-app.ts index 06c65d651..e063c7d50 100644 --- a/packages/stack/src/lib/stack-app.ts +++ b/packages/stack/src/lib/stack-app.ts @@ -638,10 +638,12 @@ class _StackClientAppImpl