Middleware support (#29)

This commit is contained in:
Stan Wohlwend 2024-05-05 17:33:36 +02:00 committed by GitHub
parent a108bb11a4
commit 74f3fe2e49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 368 additions and 27 deletions

View File

@ -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": {

View File

@ -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": {

4
apps/middleware/.env Normal file
View File

@ -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

View File

@ -0,0 +1,7 @@
module.exports = {
"extends": [
"../../eslint-configs/defaults.js",
"../../eslint-configs/next.js",
],
"ignorePatterns": ['/*', '!/src']
};

36
apps/middleware/.gitignore vendored Normal file
View File

@ -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

7
apps/middleware/LICENSE Normal file
View File

@ -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.

36
apps/middleware/README.md Normal file
View File

@ -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.

View File

@ -0,0 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;

View File

@ -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"
}
}

View File

@ -0,0 +1,10 @@
import { StackHandler } from "@stackframe/stack";
import { stackServerApp } from "../../../stack";
export default function Handler(props: any) {
return (
<div style={{ backgroundColor: "white", borderRadius: 4 }}>
<StackHandler app={stackServerApp} {...props} />
</div>
);
}

View File

@ -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 (
<html lang="en">
<body className={inter.className}>
<StackProvider app={stackServerApp}>
<StackTheme>
{children}
</StackTheme>
</StackProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,3 @@
export default function Loading() {
return <></>;
}

View File

@ -0,0 +1,12 @@
import { stackServerApp } from "@/stack";
import Link from "next/link";
export default async function Home() {
return (
<main>
Page 1 (not protected)<br />
Current login status: {await stackServerApp.getUser() ? 'Logged in' : 'Not logged in'}<br />
<Link href="/protected">Go to Page 2</Link>
</main>
);
}

View File

@ -0,0 +1,12 @@
import { stackServerApp } from "@/stack";
import Link from "next/link";
export default function Home() {
return (
<main>
Page 2 (protected by middleware)<br />
<Link href="/handler/signout">Sign out</Link><br />
<Link href="/">Go to Page 1</Link>
</main>
);
}

View File

@ -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*',
};

View File

@ -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;

View File

@ -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"
]
}

View File

@ -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": {

View File

@ -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",

View File

@ -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",

View File

@ -1 +1 @@
export const cookies = undefined;
export { cookies } from 'next/headers';

View File

@ -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 = [

View File

@ -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) {

View File

@ -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<unknown>(null);
useEffect(() => runAsynchronously(async () => {
if (called.current) return;
called.current = true;
await app.callOAuthCallback();
try {
await app.callOAuthCallback();
} catch (e: any) {
setError(e);
}
}), []);
return <MessageCard title='Redirecting...' fullPage />;
return <MessageCard title='Redirecting...' fullPage>
{error ? <div>
<p>Something went wrong while processing the OAuth callback:</p>
<pre>{JSON.stringify(error, null, 2)}</pre>
<p>This is most likely an error in Stack. Please report it.</p>
</div> : null}
</MessageCard>;
}

View File

@ -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(

View File

@ -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;
}
}
}

View File

@ -638,10 +638,12 @@ class _StackClientAppImpl<HasTokenStore extends boolean, ProjectId extends strin
this._ensurePersistentTokenStore();
const tokenStore = getTokenStore(this._tokenStoreOptions);
const result = await callOAuthCallback(this._interface, tokenStore, this.urls.oauthCallback);
if (result?.newUser) {
window.location.replace(this.urls.afterSignUp);
} else {
window.location.replace(this.urls.afterSignIn);
if (result) {
if (result.newUser) {
window.location.replace(this.urls.afterSignUp);
} else {
window.location.replace(this.urls.afterSignIn);
}
}
await neverResolve();
}

View File

@ -177,6 +177,40 @@ importers:
specifier: 7.4.1
version: 7.4.1
apps/middleware:
dependencies:
'@stackframe/stack':
specifier: workspace:*
version: link:../../packages/stack
next:
specifier: ^14.3.0-canary.26
version: 14.3.0-canary.38(@babel/core@7.24.0)(react-dom@18.2.0)(react@18.2.0)
react:
specifier: ^18
version: 18.2.0
react-dom:
specifier: ^18
version: 18.2.0(react@18.2.0)
devDependencies:
'@types/node':
specifier: ^20
version: 20.11.18
'@types/react':
specifier: ^18
version: 18.2.66
'@types/react-dom':
specifier: ^18
version: 18.2.18
eslint:
specifier: ^8
version: 8.30.0
eslint-config-next:
specifier: 14.2.3
version: 14.2.3(eslint@8.30.0)(typescript@5.3.3)
typescript:
specifier: ^5
version: 5.3.3
apps/partial-prerendering:
dependencies:
'@stackframe/stack':