├── .commitlintrc.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .idea ├── .gitignore ├── misc.xml ├── modules.xml ├── next-fortress.iml └── vcs.xml ├── .prettierrc ├── .releaserc.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.js ├── example ├── .env.local.sample ├── .eslintrc ├── .gitignore ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── public │ ├── favicon.ico │ └── vercel.svg ├── src │ ├── lib │ │ ├── AuthContext.ts │ │ ├── admin.ts │ │ ├── authReducer.ts │ │ └── firebase.ts │ ├── middleware.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ ├── auth │ │ │ │ ├── [...auth0].ts │ │ │ │ └── logout.ts │ │ │ └── firebase │ │ │ │ ├── create-token.ts │ │ │ │ └── destroy-token.ts │ │ ├── auth0 │ │ │ ├── authed.tsx │ │ │ └── index.tsx │ │ ├── cognito │ │ │ ├── authed.tsx │ │ │ └── index.tsx │ │ ├── firebase │ │ │ ├── authed.tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── ip │ │ │ ├── admin.tsx │ │ │ └── index.tsx │ └── styles │ │ ├── Home.module.css │ │ └── globals.css ├── tsconfig.json └── yarn.lock ├── package.json ├── src ├── __tests__ │ ├── auth0.spec.ts │ ├── cognito.spec.ts │ ├── firebase.spec.ts │ ├── handle-fallback.spec.ts │ └── ip.spec.ts ├── auth0.ts ├── cognito.ts ├── constants.ts ├── firebase.ts ├── handle-fallback.ts ├── index.ts ├── ip.ts └── types.ts ├── tsconfig.json ├── vitest.config.ts └── yarn.lock /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@commitlint/config-conventional" 4 | ] 5 | } -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - beta 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | Test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 16.x 20 | registry-url: https://registry.npmjs.org 21 | - uses: actions/cache@v2 22 | with: 23 | path: "**/node_modules" 24 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 25 | - name: Install dependencies 💿 26 | run: yarn install --frozen-lockfile 27 | - name: Run Tests 🧪 28 | run: yarn test:coverage 29 | - name: Report coverage (client) 📏 30 | uses: codecov/codecov-action@v2 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | 34 | Publish: 35 | runs-on: ubuntu-latest 36 | needs: 37 | - Test 38 | if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' }} 39 | steps: 40 | - uses: actions/checkout@v2 41 | - name: Use Node.js 42 | uses: actions/setup-node@v2 43 | with: 44 | node-version: 16.x 45 | registry-url: https://registry.npmjs.org 46 | - uses: actions/cache@v2 47 | with: 48 | path: "**/node_modules" 49 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 50 | - name: Install dependencies 💿 51 | run: yarn install --frozen-lockfile 52 | - name: Publish 🚀 53 | run: yarn run semantic-release 54 | env: 55 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # os 2 | .DS_Store 3 | 4 | # editor 5 | .idea 6 | .vscode 7 | 8 | # node 9 | node_modules 10 | npm-debug.log* 11 | 12 | # project 13 | coverage 14 | build 15 | dist 16 | /*.d.ts 17 | 18 | # log 19 | *.log -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/next-fortress.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": true, 9 | "arrowParens": "always" 10 | } 11 | -------------------------------------------------------------------------------- /.releaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "branches": ["main", {"name": "beta", "prerelease": true}] 3 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributions 2 | 3 | Thanks for considering contributing to this project! :dizzy: 4 | 5 | These guidelines will help you send a pull request. 6 | 7 | If you're submitting an issue instead, please skip this document. 8 | 9 | If your pull request is related to a typo or the documentation being unclear, please click on the relevant page's `Edit` 10 | button (pencil icon) and directly suggest a correction instead. 11 | 12 | This project was made with your goodwill. The simplest way to give back is by starring and sharing it online. 13 | 14 | Everyone is welcome regardless of personal background. 15 | 16 | ## Development process 17 | 18 | First fork and clone the repository. 19 | 20 | Run: 21 | 22 | ```bash 23 | yarn 24 | ``` 25 | 26 | Make sure everything is correctly setup with: 27 | 28 | ```bash 29 | yarn test 30 | ``` 31 | 32 | ## How to write commit messages 33 | 34 | We use [Conventional Commit messages](https://www.conventionalcommits.org/) to automate version management. 35 | 36 | Most common commit message prefixes are: 37 | 38 | * `fix:` which represents bug fixes, and generate a patch release. 39 | * `feat:` which represents a new feature, and generate a minor release. 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 AijiUejima 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![codecov](https://codecov.io/gh/aiji42/next-fortress/branch/main/graph/badge.svg?token=HG8SOQXGCN)](https://codecov.io/gh/aiji42/next-fortress) 2 | [![npm version](https://badge.fury.io/js/next-fortress.svg)](https://badge.fury.io/js/next-fortress) 3 | 4 | # :japanese_castle: next-fortress 5 | 6 | This package is a Next.js plugin that provides server-side access control for users when they are in a non-authenticated state. 7 | 8 | IPs, Firebase, Amazon Cognito and Auth0 are used to determine authentication, and when a user is in a non-authenticated state, it is possible to redirect or rewrite. 9 | 10 | This plugin uses Next.js v12 middleware to control access with edge functions, which makes it faster and reduces client-side code. 11 | 12 | ## Example 13 | 14 | [next-fortress example](https://next-fortress.vercel.app) 15 | 16 | ## Require 17 | 18 | - Using Next.js >=12 19 | 20 | This plugin depends on the middleware of Next.js v12. If you are using Next.js v11 or earlier, please use [next-fortress v2](https://www.npmjs.com/package/next-fortress/v/2.2.2). 21 | 22 | ## Installation 23 | 24 | ``` 25 | npm install --save next-fortress 26 | ``` 27 | 28 | ## Usage 29 | 30 | All functions define their own `fallback` and use it as an argument. `fallback` is a control command in the unauthenticated state to select `rewrite`, `redirect`, and the middleware function. 31 | ```ts 32 | type Fallback = Middleware | { 33 | type: 'rewrite'; 34 | destination: string; 35 | } | { 36 | type: 'redirect'; 37 | destination: string; 38 | statusCode?: 301 | 302 | 303 | 307 | 308; 39 | }; 40 | 41 | type Middleware = (request: NextRequest, event?: NextFetchEvent) => Response | undefined; 42 | ``` 43 | 44 | ### Control by IP address 45 | 46 | [example](https://next-fortress.vercel.app/ip) 47 | 48 | ```ts 49 | // middleware.ts 50 | import { makeIPInspector } from 'next-fortress/ip' 51 | 52 | /* 53 | type IPs = string | Array 54 | type makeIPInspector = (allowedIPs: IPs, fallback: Fallback) => Middleware 55 | // IP can be specified in CIDR format. You can also specify multiple IPs in an array. 56 | */ 57 | export const middleware = makeIPInspector('123.123.123.123/32', { 58 | type: 'redirect', 59 | destination: '/' 60 | }) 61 | 62 | export const config = { 63 | matcher: ['/admin/:path*'], 64 | } 65 | ``` 66 | 67 | ### Control by Firebase 68 | 69 | [example](https://next-fortress.vercel.app/firebase) 70 | 71 | 72 | ```ts 73 | // middleware.ts 74 | import { makeFirebaseInspector } from 'next-fortress/firebase' 75 | 76 | /* 77 | type makeFirebaseInspector = ( 78 | fallback: Fallback, 79 | customHandler?: (payload: any) => boolean 80 | ) => AsyncMiddleware 81 | */ 82 | export const middleware = makeFirebaseInspector( 83 | { type: 'redirect', destination: '/signin' } 84 | ) 85 | 86 | export const config = { 87 | matcher: ['/mypage/:path*'], 88 | } 89 | ``` 90 | 91 | Put the Firebase user token into the cookie using the following example. 92 | ```ts 93 | // cient side code (for example /pages/_app.tsx) 94 | import { FIREBASE_COOKIE_KEY } from 'next-fortress/constants' 95 | 96 | firebase.auth().onAuthStateChanged(function (user) { 97 | if (user) { 98 | // User is signed in. 99 | user 100 | .getIdToken() 101 | .then((token) => document.cookie = `${FIREBASE_COOKIE_KEY}=${token}; path=/`) 102 | } else { 103 | // User is signed out. 104 | document.cookie = `${FIREBASE_COOKIE_KEY}=; path=/; expires=${ 105 | new Date('1999-12-31T23:59:59Z').toUTCString() 106 | }` 107 | } 108 | }) 109 | ``` 110 | 111 | For the second argument of `makeFirebaseInspector`, you can pass a payload inspection function. This is useful, for example, if you want to ignore some authentication providers, or if you need to ensure that the email has been verified. 112 | If this function returns false, it will enter the fallback case. 113 | ```ts 114 | // middleware.ts 115 | import { makeFirebaseInspector } from 'next-fortress/firebase' 116 | 117 | // Redirect for anonymous users. 118 | export const middleware = makeFirebaseInspector( 119 | { type: 'redirect', destination: '/signin' }, 120 | (payload) => payload.firebase.sign_in_provider !== 'anonymous' 121 | ) 122 | 123 | export const config = { 124 | matcher: ['/mypage/:path*'], 125 | } 126 | ``` 127 | 128 | **NOTE** 129 | - If you want to specify the cookie key, use the environment variable `FORTRESS_FIREBASE_COOKIE_KEY`. 130 | - If you use [session cookies](https://firebase.google.com/docs/auth/admin/manage-cookies) to share authentication data with the server side, set the environment variable `FORTRESS_FIREBASE_MODE` to `session`. 131 | 132 | ### Control by Amazon Cognito 133 | 134 | [example](https://next-fortress.vercel.app/cognito) 135 | 136 | ```ts 137 | // middleware.ts 138 | import { makeCognitoInspector } from 'next-fortress/cognito' 139 | 140 | /* 141 | type UserPoolParams = { 142 | region: string; 143 | userPoolId: string; 144 | userPoolWebClientId: string; 145 | } 146 | 147 | type makeCognitoInspector = ( 148 | fallback: Fallback, 149 | params: UserPoolParams, 150 | customHandler?: (payload: any) => boolean 151 | ) => AsyncMiddleware 152 | */ 153 | export const middleware = makeCognitoInspector( 154 | { type: 'redirect', destination: '/signin' }, 155 | { 156 | region: process.env.COGNITO_REGION, 157 | userPoolId: process.env.COGNITO_USER_POOL_ID, 158 | userPoolWebClientId: process.env.COGNITO_USER_POOL_WEB_CLIENT_ID, 159 | } 160 | ) 161 | 162 | export const config = { 163 | matcher: ['/mypage/:path*'], 164 | } 165 | ``` 166 | 167 | Add `ssr: true` option to `Amplify.configure` to handle the Cognito cookies on the edge. 168 | ```ts 169 | // cient side code (for example /pages/_app.tsx) 170 | import Amplify from 'aws-amplify' 171 | 172 | Amplify.configure({ 173 | aws_cognito_identity_pool_id: process.env.NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID, 174 | // ...omitted 175 | ssr: true // this line 176 | }) 177 | ``` 178 | 179 | For the 3rd argument of `makeCognitoInspector`, you can pass a payload inspection function. This is useful, for example, if you want to ignore some authentication providers, or if you need to ensure that the email has been verified. 180 | If this function returns false, it will enter the fallback case. 181 | ```ts 182 | // middleware.ts 183 | import { makeCognitoInspector } from 'next-fortress/cognito' 184 | 185 | // Fallback if the email address is not verified. 186 | export const middleware = makeCognitoInspector( 187 | { type: 'redirect', destination: '/signin' }, 188 | { 189 | region: process.env.COGNITO_REGION, 190 | userPoolId: process.env.COGNITO_USER_POOL_ID, 191 | userPoolWebClientId: process.env.COGNITO_USER_POOL_WEB_CLIENT_ID, 192 | }, 193 | (payload) => payload.email_verified 194 | ) 195 | 196 | export const config = { 197 | matcher: ['/mypage/:path*'], 198 | } 199 | ``` 200 | 201 | ### Control by Auth0 202 | 203 | [example](https://next-fortress.vercel.app/auth0) 204 | 205 | ```ts 206 | // middleware.ts 207 | import { makeAuth0Inspector } from 'next-fortress/auth0' 208 | 209 | /* 210 | type makeAuth0Inspector = ( 211 | fallback: Fallback, 212 | apiEndpoint: string, 213 | customHandler?: (payload: any) => boolean 214 | ) => AsyncMiddleware; 215 | */ 216 | export const middleware = makeAuth0Inspector( 217 | { type: 'redirect', destination: '/singin' }, 218 | '/api/auth/me' // api endpoint for auth0 profile 219 | ) 220 | 221 | export const config = { 222 | matcher: ['/mypage/:path*'], 223 | } 224 | ``` 225 | 226 | To use Auth0, the api root must have an endpoint. [@auth0/nextjs-auth0](https://github.com/auth0/nextjs-auth0#basic-setup) 227 | ```ts 228 | // /pages/api/auth/[auth0].ts 229 | import { handleAuth } from '@auth0/nextjs-auth0' 230 | 231 | export default handleAuth() 232 | ``` 233 | 234 | For the third argument of `makeAuth0Inspector`, you can pass a payload inspection function. This is useful, for example, if you need to ensure that the email has been verified. 235 | If this function returns false, it will enter the fallback case. 236 | ```ts 237 | // middleware.ts 238 | import { makeAuth0Inspector } from 'next-fortress/auth0' 239 | 240 | // Fallback if the email address is not verified. 241 | export const middleware = makeAuth0Inspector( 242 | { type: 'redirect', destination: '/singin' }, 243 | '/api/auth/me', 244 | (payload) => payload.email_verified 245 | ) 246 | 247 | export const config = { 248 | matcher: ['/mypage/:path*'], 249 | } 250 | ``` 251 | 252 | ## Contributing 253 | Please read [CONTRIBUTING.md](https://github.com/aiji42/next-fortress/blob/main/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 254 | 255 | ## License 256 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/aiji42/next-fortress/blob/main/LICENSE) file for details 257 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import { buildSync } from 'esbuild' 2 | import * as fs from 'fs' 3 | 4 | const readdirRecursively = (dir, files = []) => { 5 | const entries = fs.readdirSync(dir, { withFileTypes: true }) 6 | const dirs = [] 7 | for (const dirent of entries) { 8 | if (dirent.isDirectory()) dirs.push(`${dir}/${dirent.name}`) 9 | if (dirent.isFile()) files.push(`${dir}/${dirent.name}`) 10 | } 11 | for (const d of dirs) { 12 | files = readdirRecursively(d, files) 13 | } 14 | return files 15 | } 16 | 17 | buildSync({ 18 | entryPoints: readdirRecursively('./src').filter( 19 | (n) => n.match(/\.(ts|tsx)$/) && !n.includes('__tests__') 20 | ), 21 | outdir: './dist', 22 | format: 'esm', 23 | target: 'esnext' 24 | }) 25 | -------------------------------------------------------------------------------- /example/.env.local.sample: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_FIREBASE_API_KEY= 2 | NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN= 3 | NEXT_PUBLIC_FIREBASE_PROJECT_ID= 4 | NEXT_PUBLIC_FIREBASE_STORAGE_BAKET= 5 | NEXT_PUBLIC_FIREBASE_MESSAGE_SENDER_ID= 6 | NEXT_PUBLIC_FIREBASE_APP_ID= 7 | FIREBASE_PRIVATE_KEY= 8 | FIREBASE_CLIENT_EMAIL= 9 | NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID= 10 | NEXT_PUBLIC_COGNITO_REGION= 11 | NEXT_PUBLIC_COGNITO_USER_POOL_ID= 12 | NEXT_PUBLIC_COGNITO_USER_POOL_WEB_CLIENT_ID= 13 | NEXT_PUBLIC_COGNITO_DOMAIN= 14 | AUTH0_SECRET= 15 | AUTH0_BASE_URL= 16 | AUTH0_ISSUER_BASE_URL= 17 | AUTH0_CLIENT_ID= 18 | AUTH0_CLIENT_SECRET= -------------------------------------------------------------------------------- /example/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals"] 3 | } 4 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | 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). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.tsx`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | 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. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | AUTH0_BASE_URL: 4 | process.env.AUTH0_BASE_URL || 5 | (process.env.VERCEL_URL 6 | ? `https://${process.env.VERCEL_URL}` 7 | : 'http://localhost:3000'), 8 | FORTRESS_FIREBASE_COOKIE_KEY: 'session', 9 | FORTRESS_FIREBASE_MODE: 'session' 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@auth0/nextjs-auth0": "^1.9.1", 13 | "@geist-ui/react": "^2.2.5", 14 | "@geist-ui/react-icons": "^1.0.1", 15 | "aws-amplify": "^4.3.27", 16 | "firebase": "^9.9.0", 17 | "firebase-admin": "^11.0.0", 18 | "js-cookie": "^3.0.1", 19 | "next": "12.2.2", 20 | "next-fortress": "5.0.1", 21 | "nookies": "^2.5.2", 22 | "react": "^18.2.0", 23 | "react-dom": "18.2.0" 24 | }, 25 | "devDependencies": { 26 | "@types/js-cookie": "^3.0.2", 27 | "@types/react": "18.0.15", 28 | "eslint": "8.19.0", 29 | "eslint-config-next": "12.2.2", 30 | "typescript": "4.7.4" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aiji42/next-fortress/2426a918f0e432b3e4ad4e3457848458a6f0ad99/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /example/src/lib/AuthContext.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | const AuthContext = React.createContext({}) 3 | export default AuthContext 4 | -------------------------------------------------------------------------------- /example/src/lib/admin.ts: -------------------------------------------------------------------------------- 1 | import admin from 'firebase-admin' 2 | 3 | const serviceAccount: admin.ServiceAccount = { 4 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 5 | clientEmail: process.env.FIREBASE_CLIENT_EMAIL, 6 | privateKey: (process.env.FIREBASE_PRIVATE_KEY || '').replace(/\\n/g, '\n') 7 | } 8 | 9 | export const firebaseAdmin = 10 | admin.apps[0] || 11 | admin.initializeApp({ 12 | credential: admin.credential.cert(serviceAccount) 13 | }) 14 | -------------------------------------------------------------------------------- /example/src/lib/authReducer.ts: -------------------------------------------------------------------------------- 1 | const initialState = {} 2 | 3 | const reducer = (state: any, action: any) => { 4 | switch (action.type) { 5 | case 'login': 6 | action.payload.user.getIdToken().then((id: string) => { 7 | return fetch('/api/firebase/create-token', { 8 | method: 'POST', 9 | body: JSON.stringify({ id }) 10 | }) 11 | }) 12 | return action.payload.user 13 | case 'logout': 14 | fetch('/api/firebase/destroy-token', { 15 | method: 'POST' 16 | }).then(() => { 17 | document.location.reload() 18 | }) 19 | 20 | return initialState 21 | default: 22 | return state 23 | } 24 | } 25 | 26 | export default { 27 | initialState, 28 | reducer 29 | } 30 | -------------------------------------------------------------------------------- /example/src/lib/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app' 2 | import { 3 | getAuth, 4 | GoogleAuthProvider, 5 | signInWithPopup, 6 | onAuthStateChanged, 7 | signInAnonymously, 8 | signOut 9 | } from 'firebase/auth' 10 | 11 | export const config = { 12 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 13 | authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, 14 | projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, 15 | storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET, 16 | messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID, 17 | appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID 18 | } 19 | 20 | const firebaseApp = initializeApp(config) 21 | 22 | export const auth = getAuth(firebaseApp) 23 | 24 | export const login = () => { 25 | const provider = new GoogleAuthProvider() 26 | signInWithPopup(auth, provider).then(console.log).catch(console.error) 27 | } 28 | 29 | export const listenAuthState = (dispatch: any) => { 30 | return onAuthStateChanged(auth, async function (user) { 31 | if (user) { 32 | dispatch({ 33 | type: 'login', 34 | payload: { 35 | user 36 | } 37 | }) 38 | return 39 | } 40 | await signInAnonymously(auth) 41 | dispatch({ 42 | type: 'logout' 43 | }) 44 | }) 45 | } 46 | 47 | export const firebaseUser = () => { 48 | return auth.currentUser 49 | } 50 | 51 | export const logout = () => { 52 | signOut(auth).then(console.log) 53 | } 54 | -------------------------------------------------------------------------------- /example/src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { makeAuth0Inspector } from 'next-fortress/auth0' 2 | import { NextMiddleware, NextResponse } from 'next/server' 3 | import { makeCognitoInspector } from 'next-fortress/cognito' 4 | import { makeFirebaseInspector } from 'next-fortress/firebase' 5 | import { makeIPInspector } from 'next-fortress/ip' 6 | 7 | export const middleware: NextMiddleware = async (request, event) => { 8 | if (request.nextUrl.pathname.startsWith('/auth0')) { 9 | return makeAuth0Inspector( 10 | { type: 'redirect', destination: '/auth0' }, 11 | '/api/auth/me' 12 | )(request, event) 13 | } 14 | 15 | if (request.nextUrl.pathname.startsWith('/cognito')) { 16 | const region = process.env.NEXT_PUBLIC_COGNITO_REGION ?? '' 17 | const userPoolId = process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID ?? '' 18 | const userPoolWebClientId = 19 | process.env.NEXT_PUBLIC_COGNITO_USER_POOL_WEB_CLIENT_ID ?? '' 20 | return makeCognitoInspector( 21 | { type: 'redirect', destination: '/cognito' }, 22 | { region, userPoolId, userPoolWebClientId } 23 | )(request, event) 24 | } 25 | 26 | if (request.nextUrl.pathname.startsWith('/firebase')) { 27 | return makeFirebaseInspector( 28 | { 29 | type: 'redirect', 30 | destination: '/firebase' 31 | }, 32 | (res) => res.firebase.sign_in_provider !== 'anonymous' 33 | )(request, event) 34 | } 35 | 36 | if (request.nextUrl.pathname.startsWith('/ip')) { 37 | if (!request.nextUrl.pathname.includes('admin')) return 38 | const ips = request.cookies.get('__allowed_ips') 39 | if (!ips) { 40 | if (request.method === 'OPTIONS') return new NextResponse(null) 41 | const url = request.nextUrl.clone() 42 | url.pathname = '/ip' 43 | return NextResponse.redirect(url) 44 | } 45 | 46 | return makeIPInspector(ips.split(','), { 47 | type: 'redirect', 48 | destination: '/ip' 49 | })(request, event) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /example/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from 'next/app' 2 | import AuthContext from '../lib/AuthContext' 3 | import authReducer from '../lib/authReducer' 4 | import { listenAuthState } from '../lib/firebase' 5 | import { useEffect, useReducer } from 'react' 6 | import '../styles/globals.css' 7 | import Amplify from 'aws-amplify' 8 | import { UserProvider } from '@auth0/nextjs-auth0' 9 | import { 10 | GeistProvider, 11 | CssBaseline, 12 | Page, 13 | Text, 14 | Link, 15 | Grid 16 | } from '@geist-ui/react' 17 | import Image from 'next/image' 18 | import Github from '@geist-ui/react-icons/github' 19 | 20 | Amplify.configure({ 21 | aws_cognito_identity_pool_id: 22 | process.env.NEXT_PUBLIC_COGNITO_IDENTITY_POOL_ID, 23 | aws_cognito_region: process.env.NEXT_PUBLIC_COGNITO_REGION, 24 | aws_user_pools_id: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID, 25 | aws_user_pools_web_client_id: 26 | process.env.NEXT_PUBLIC_COGNITO_USER_POOL_WEB_CLIENT_ID, 27 | oauth: { 28 | domain: process.env.NEXT_PUBLIC_COGNITO_DOMAIN, 29 | scope: ['openid', 'phone', 'email'], 30 | redirectSignIn: 31 | process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' 32 | ? 'https://next-fortress.vercel.app/cognito' 33 | : process.env.NEXT_PUBLIC_VERCEL_URL 34 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}/cognito` 35 | : 'http://localhost:3000/cognito', 36 | redirectSignOut: 37 | process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' 38 | ? 'https://next-fortress.vercel.app/cognito' 39 | : process.env.NEXT_PUBLIC_VERCEL_URL 40 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}/cognito` 41 | : 'http://localhost:3000/cognito', 42 | responseType: 'code' 43 | }, 44 | ssr: true 45 | }) 46 | 47 | function MyApp({ Component, pageProps }: AppProps) { 48 | const [state, dispatch] = useReducer( 49 | authReducer.reducer, 50 | authReducer.initialState 51 | ) 52 | useEffect(() => { 53 | listenAuthState(dispatch) 54 | }, []) 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 🏯 Next Fortress 67 | 68 | 69 | 70 | 71 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | ) 114 | } 115 | export default MyApp 116 | -------------------------------------------------------------------------------- /example/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { 2 | Html, 3 | Head, 4 | Main, 5 | NextScript, 6 | DocumentContext 7 | } from 'next/document' 8 | import { CssBaseline } from '@geist-ui/react' 9 | 10 | // @ts-ignore 11 | class MyDocument extends Document { 12 | static async getInitialProps(ctx: DocumentContext) { 13 | const initialProps = await Document.getInitialProps(ctx) 14 | const styles = CssBaseline.flush() 15 | 16 | return { 17 | ...initialProps, 18 | styles: ( 19 | <> 20 | {initialProps.styles} 21 | {styles} 22 | 23 | ) 24 | } 25 | } 26 | 27 | render() { 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | 38 | ) 39 | } 40 | } 41 | 42 | export default MyDocument 43 | -------------------------------------------------------------------------------- /example/src/pages/api/auth/[...auth0].ts: -------------------------------------------------------------------------------- 1 | import { handleAuth } from '@auth0/nextjs-auth0' 2 | 3 | export default handleAuth() 4 | -------------------------------------------------------------------------------- /example/src/pages/api/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { handleLogout } from '@auth0/nextjs-auth0' 2 | import { NextApiHandler } from 'next' 3 | 4 | const handler: NextApiHandler = (req, res) => { 5 | return handleLogout(req, res, { returnTo: '/auth0' }) 6 | } 7 | 8 | export default handler 9 | -------------------------------------------------------------------------------- /example/src/pages/api/firebase/create-token.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next' 2 | import { setCookie } from 'nookies' 3 | import { firebaseAdmin } from '../../../lib/admin' 4 | 5 | const handler: NextApiHandler = async (req, res) => { 6 | if (req.method !== 'POST') return res.status(404).send('Not Found') 7 | 8 | const auth = firebaseAdmin.auth() 9 | 10 | const expiresIn = 60 * 60 * 24 * 1000 11 | 12 | const id = (JSON.parse(req.body).id || '').toString() 13 | 14 | const sessionCookie = await auth.createSessionCookie(id, { expiresIn }) 15 | 16 | const options = { 17 | maxAge: expiresIn / 1000, 18 | httpOnly: true, 19 | secure: true, 20 | path: '/' 21 | } 22 | 23 | setCookie({ res }, 'session', sessionCookie, options) 24 | 25 | res.send(JSON.stringify({ status: 'success' })) 26 | } 27 | 28 | export default handler 29 | -------------------------------------------------------------------------------- /example/src/pages/api/firebase/destroy-token.ts: -------------------------------------------------------------------------------- 1 | import { NextApiHandler } from 'next' 2 | import { destroyCookie } from 'nookies' 3 | 4 | const handler: NextApiHandler = async (req, res) => { 5 | if (req.method !== 'POST') return res.status(404).send('Not Found') 6 | 7 | destroyCookie({ res }, 'session', { 8 | path: '/' 9 | }) 10 | 11 | res.send(JSON.stringify({ status: 'success' })) 12 | } 13 | 14 | export default handler 15 | -------------------------------------------------------------------------------- /example/src/pages/auth0/authed.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../../styles/Home.module.css' 2 | import Head from 'next/head' 3 | import { VFC } from 'react' 4 | import { useUser } from '@auth0/nextjs-auth0' 5 | import { Text } from '@geist-ui/react' 6 | 7 | const Authed: VFC = () => { 8 | const { user } = useUser() 9 | return ( 10 | <> 11 | 12 | My Page | Auth0 Example | Next Fortress 13 | 14 | 15 | 16 | My Page | Auth0 Example 17 | 18 | 19 |

20 | Hi! {user?.name} 21 |

22 |

This page is accessible only to logged-in users.

23 | 24 | 29 | 30 | ) 31 | } 32 | 33 | export default Authed 34 | -------------------------------------------------------------------------------- /example/src/pages/auth0/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../../styles/Home.module.css' 2 | import Head from 'next/head' 3 | import { VFC } from 'react' 4 | import { useUser } from '@auth0/nextjs-auth0' 5 | import { Text } from '@geist-ui/react' 6 | import Link from 'next/link' 7 | 8 | const IndexPage: VFC = () => { 9 | const { user } = useUser() 10 | return ( 11 | <> 12 | 13 | Auth0 Example | Next Fortress 14 | 15 | 16 | 17 | Auth0 example 18 | 19 | 20 |

This page can be accessed by anyone, with or without a login.

21 |

You can access My Page only when you are logged in.

22 | 23 |
24 | {!user ? ( 25 | 26 |

Login

27 |

You are Not logged in.

28 |
29 | ) : ( 30 | 31 |

Logout

32 |

You are logged in.

33 |
34 | )} 35 | 36 | 37 |
38 |

Go My Page →

39 | {!user &&

(Not Allowed)

} 40 |
41 | 42 |
43 | 44 | ) 45 | } 46 | 47 | export default IndexPage 48 | -------------------------------------------------------------------------------- /example/src/pages/cognito/authed.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../../styles/Home.module.css' 2 | import Head from 'next/head' 3 | import { useEffect, useState, VFC } from 'react' 4 | import { Auth } from 'aws-amplify' 5 | import { Text } from '@geist-ui/react' 6 | 7 | const Authed: VFC = () => { 8 | const [session, setSession] = useState() 9 | useEffect(() => { 10 | Auth.currentAuthenticatedUser() 11 | .then(setSession) 12 | .catch(() => setSession(undefined)) 13 | }, []) 14 | return ( 15 | <> 16 | 17 | My Page | Amazon Cognito Example | Next Fortress 18 | 19 | 20 | 21 | My Page | Amazon Cognito Example 22 | 23 | 24 |

25 | Hi! {session?.username} 26 |

27 |

This page is accessible only to logged-in users.

28 | 29 |
30 | 33 |
34 | 35 | ) 36 | } 37 | 38 | export default Authed 39 | -------------------------------------------------------------------------------- /example/src/pages/cognito/index.tsx: -------------------------------------------------------------------------------- 1 | import styles from '../../styles/Home.module.css' 2 | import Head from 'next/head' 3 | import { useEffect, useState, VFC } from 'react' 4 | import { Auth } from 'aws-amplify' 5 | import { Text } from '@geist-ui/react' 6 | import Link from 'next/link' 7 | 8 | const IndexPage: VFC = () => { 9 | const [login, setLogin] = useState(false) 10 | useEffect(() => { 11 | Auth.currentAuthenticatedUser() 12 | .then(() => setLogin(true)) 13 | .catch(() => setLogin(false)) 14 | }, []) 15 | return ( 16 | <> 17 | 18 | Amazon Cognito Example | Next Fortress 19 | 20 | 21 | 22 | Amazon Cognito example 23 | 24 | 25 |

This page can be accessed by anyone, with or without a login.

26 |

You can access My Page only when you are logged in.

27 | 28 |
29 | {!login ? ( 30 | 37 | ) : ( 38 | 42 | )} 43 | 44 | 45 |
46 |

Go My Page →

47 | {!login &&

(Not Allowed)

} 48 |
49 | 50 |
51 | 52 | ) 53 | } 54 | 55 | export default IndexPage 56 | -------------------------------------------------------------------------------- /example/src/pages/firebase/authed.tsx: -------------------------------------------------------------------------------- 1 | import { logout, auth } from '../../lib/firebase' 2 | import styles from '../../styles/Home.module.css' 3 | import Head from 'next/head' 4 | import { VFC } from 'react' 5 | import { Text } from '@geist-ui/react' 6 | 7 | const Authed: VFC = () => { 8 | return ( 9 | <> 10 | 11 | My Page | Firebase Example | Next Fortress 12 | 13 | 14 | 15 | My Page | Firebase example 16 | 17 | 18 |

19 | Hi! {auth.currentUser?.displayName} 20 |

21 |

This page is accessible only to logged-in users.

22 | 23 |
24 | 27 |
28 | 29 | ) 30 | } 31 | 32 | export default Authed 33 | -------------------------------------------------------------------------------- /example/src/pages/firebase/index.tsx: -------------------------------------------------------------------------------- 1 | import { login, logout, auth } from '../../lib/firebase' 2 | import styles from '../../styles/Home.module.css' 3 | import Head from 'next/head' 4 | import Link from 'next/link' 5 | import { VFC } from 'react' 6 | import { Text } from '@geist-ui/react' 7 | 8 | const IndexPage: VFC = () => { 9 | return ( 10 | <> 11 | 12 | Firebase Example | Next Fortress 13 | 14 | 15 | 16 | Firebase example 17 | 18 | 19 |

This page can be accessed by anyone, with or without a login.

20 |

You can access My Page only when you are logged in.

21 | 22 |
23 | {!auth.currentUser || auth.currentUser.isAnonymous ? ( 24 | 28 | ) : ( 29 | 33 | )} 34 | 35 | 36 | 37 |

Go My Page →

38 | {(!auth.currentUser || auth.currentUser.isAnonymous) && ( 39 |

(Not Allowed)

40 | )} 41 |
42 | 43 |
44 | 45 | ) 46 | } 47 | 48 | export default IndexPage 49 | -------------------------------------------------------------------------------- /example/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import styles from '../styles/Home.module.css' 3 | import Link from 'next/link' 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 | 9 | Next Fortress 10 | 11 | 12 |
13 | 14 |
15 |

IP Protect →

16 |
17 | 18 | 19 | 20 |
21 |

Firebase →

22 |
23 | 24 | 25 | 26 |
27 |

Amazon Cognito →

28 |
29 | 30 | 31 | 32 |
33 |

Auth0 →

34 |
35 | 36 |
37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /example/src/pages/ip/admin.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { VFC } from 'react' 3 | import { Button, Spacer, Text } from '@geist-ui/react' 4 | import Cookies from 'js-cookie' 5 | import { useRouter } from 'next/router' 6 | 7 | const Page: VFC = () => { 8 | const router = useRouter() 9 | const resetIPToCookie = () => { 10 | Cookies.remove('__allowed_ips') 11 | router.reload() 12 | } 13 | 14 | return ( 15 | <> 16 | 17 | Admin | IP Protect Example | Next Fortress 18 | 19 | 20 | 21 | Admin | IP protect example 22 | 23 | 24 |

Your IP address is allowed to access.

25 | 26 | Allowed IPs: {Cookies.get('__allowed_ips')} 27 | 28 | 29 | 32 | 33 | ) 34 | } 35 | 36 | export default Page 37 | -------------------------------------------------------------------------------- /example/src/pages/ip/index.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | import { useEffect, VFC } from 'react' 3 | import Cookies from 'js-cookie' 4 | import { Button, Text, Spacer, Input, useInput, Link } from '@geist-ui/react' 5 | import NextLink from 'next/link' 6 | 7 | const IndexPage: VFC = () => { 8 | const { state: ips, setState: setIps, reset, bindings } = useInput('') 9 | useEffect(() => { 10 | const cookie = Cookies.get('__allowed_ips') 11 | cookie && setIps(cookie) 12 | }, []) 13 | const setIPToCookie = () => { 14 | Cookies.set('__allowed_ips', ips, { path: '/' }) 15 | } 16 | const resetIPToCookie = () => { 17 | Cookies.remove('__allowed_ips') 18 | reset() 19 | } 20 | 21 | return ( 22 | <> 23 | 24 | IP Protect Example | Next Fortress 25 | 26 | 27 | 28 | IP protect example 29 | 30 | 31 |

32 | This page can be accessed by anyone. The admin page can only be reached 33 | from allowed IPs. 34 |

35 |

36 | First, try to go to the Admin page without entering anything (access 37 | will be denied because you do not have an allowed IP). After that, enter 38 | the IP you want to allow and go to the admin page again. 39 |

40 | 41 | 42 | 43 | 44 | Go to admin page 45 | 46 | 47 | 48 | 49 | 50 | 51 | 54 | 57 | 58 | ) 59 | } 60 | 61 | export default IndexPage 62 | -------------------------------------------------------------------------------- /example/src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | align-items: center; 5 | height: 100vh; 6 | } 7 | 8 | .main { 9 | padding: 5rem 0; 10 | flex: 1; 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | } 16 | 17 | .footer { 18 | width: 100%; 19 | height: 100px; 20 | border-top: 1px solid #eaeaea; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | 26 | .footer a { 27 | display: flex; 28 | justify-content: center; 29 | align-items: center; 30 | flex-grow: 1; 31 | } 32 | 33 | .title a { 34 | color: #0070f3; 35 | text-decoration: none; 36 | } 37 | 38 | .title a:hover, 39 | .title a:focus, 40 | .title a:active { 41 | text-decoration: underline; 42 | } 43 | 44 | .title { 45 | margin: 0; 46 | line-height: 1.15; 47 | font-size: 4rem; 48 | } 49 | 50 | .title, 51 | .description { 52 | text-align: center; 53 | } 54 | 55 | .description { 56 | line-height: 1.5; 57 | font-size: 1.5rem; 58 | } 59 | 60 | .code { 61 | background: #fafafa; 62 | border-radius: 5px; 63 | padding: 0.75rem; 64 | font-size: 1.1rem; 65 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 66 | Bitstream Vera Sans Mono, Courier New, monospace; 67 | } 68 | 69 | .grid { 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | flex-wrap: wrap; 74 | max-width: 800px; 75 | margin-top: 3rem; 76 | } 77 | 78 | .card { 79 | margin: 1rem; 80 | padding: 1.5rem; 81 | text-align: center; 82 | color: inherit; 83 | text-decoration: none; 84 | border: 1px solid #eaeaea; 85 | border-radius: 10px; 86 | transition: color 0.15s ease, border-color 0.15s ease; 87 | width: 45%; 88 | cursor: pointer; 89 | } 90 | 91 | .card:hover, 92 | .card:focus, 93 | .card:active { 94 | color: #0070f3; 95 | border-color: #0070f3; 96 | } 97 | 98 | .card h2 { 99 | margin: 0 0 1rem 0; 100 | font-size: 1.5rem; 101 | } 102 | 103 | .card p { 104 | margin: 0; 105 | font-size: 1.25rem; 106 | line-height: 1.5; 107 | } 108 | 109 | .logo { 110 | height: 1em; 111 | margin-left: 0.5rem; 112 | } 113 | 114 | @media (max-width: 600px) { 115 | .grid { 116 | width: 100%; 117 | flex-direction: column; 118 | } 119 | .card { 120 | width: 90%; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /example/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve", 20 | "incremental": true 21 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-fortress", 3 | "version": "0.0.0-development", 4 | "description": "This is a Next.js plugin that blocks, redirects, or displays a dummy page for accesses that are not authenticated.", 5 | "module": "./dist/index.js", 6 | "types": "./dist/index.d.ts", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/index.d.ts", 11 | "import": "./dist/index.js" 12 | }, 13 | "./*": { 14 | "types": "./*.d.ts", 15 | "import": "./dist/*.js" 16 | } 17 | }, 18 | "files": [ 19 | "dist", 20 | "*.d.ts" 21 | ], 22 | "repository": "git@github.com:aiji42/next-fortress.git", 23 | "author": "aiji42 (https://twitter.com/aiji42_dev)", 24 | "license": "MIT", 25 | "keywords": [ 26 | "next.js", 27 | "next", 28 | "react", 29 | "plugins", 30 | "access controll", 31 | "content block" 32 | ], 33 | "bugs": { 34 | "url": "https://github.com/aiji42/next-fortress/issues" 35 | }, 36 | "homepage": "https://github.com/aiji42/next-fortress#readme", 37 | "scripts": { 38 | "test": "vitest run", 39 | "test:coverage": "vitest run --coverage", 40 | "build": "node build.js && npx tsc --declaration --emitDeclarationOnly --declarationDir './dist' && npx tsc --declaration --emitDeclarationOnly --declarationDir './'", 41 | "prepare": "husky install", 42 | "semantic-release": "semantic-release", 43 | "prepack": "yarn build" 44 | }, 45 | "peerDependencies": { 46 | "next": ">=12.2.0" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^17.0.3", 50 | "@commitlint/config-conventional": "^17.0.3", 51 | "@edge-runtime/vm": "^1.1.0-beta.11", 52 | "@types/netmask": "^1.0.30", 53 | "@types/node": "^18.0.3", 54 | "c8": "^7.11.3", 55 | "esbuild": "^0.14.49", 56 | "fetch-mock": "^9.11.0", 57 | "husky": "^8.0.1", 58 | "lint-staged": "^13.0.3", 59 | "next": "^12.2.2", 60 | "prettier": "^2.7.1", 61 | "semantic-release": "^19.0.3", 62 | "typescript": "^4.7.4", 63 | "vitest": "^0.18.0" 64 | }, 65 | "dependencies": { 66 | "jose": "^4.8.3", 67 | "netmask": "^2.0.2" 68 | }, 69 | "lint-staged": { 70 | "*.{js,ts}": [ 71 | "prettier --write" 72 | ] 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/__tests__/auth0.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, beforeEach, test, expect } from 'vitest' 2 | import { makeAuth0Inspector } from '../auth0' 3 | import { handleFallback } from '../handle-fallback' 4 | import { Fallback } from '../types' 5 | import { NextFetchEvent, NextRequest } from 'next/server' 6 | import fetchMock from 'fetch-mock' 7 | 8 | vi.mock('../handle-fallback', () => ({ 9 | handleFallback: vi.fn() 10 | })) 11 | 12 | const event = {} as NextFetchEvent 13 | 14 | fetchMock 15 | .get('/api/auth/me', { 16 | status: 200, 17 | body: { 18 | email_verified: true 19 | } 20 | }) 21 | .get('/api/auth/failed/me', { 22 | status: 401 23 | }) 24 | .get('https://authed.com/api/auth/me', { 25 | status: 200, 26 | body: { 27 | email_verified: true 28 | } 29 | }) 30 | .get('https://not.authed.com/api/auth/me', { 31 | status: 401 32 | }) 33 | 34 | const fallback: Fallback = { type: 'redirect', destination: '/foo' } 35 | 36 | const headers = { 37 | get: () => '' 38 | } 39 | 40 | describe('makeAuth0Inspector', () => { 41 | beforeEach(() => { 42 | vi.resetAllMocks() 43 | }) 44 | 45 | describe('dose not have nextUrl origin', () => { 46 | test('not logged in', async () => { 47 | const req = { headers, nextUrl: { origin: '' } } as unknown as NextRequest 48 | await makeAuth0Inspector(fallback, '/api/auth/failed/me')(req, event) 49 | 50 | expect(handleFallback).toBeCalledWith(fallback, req, event) 51 | }) 52 | 53 | test('does not have cookie', async () => { 54 | const noCookieReq = { 55 | headers: { get: () => undefined }, 56 | nextUrl: { origin: '' } 57 | } as unknown as NextRequest 58 | await makeAuth0Inspector(fallback, '/api/auth/failed/me')( 59 | noCookieReq, 60 | event 61 | ) 62 | 63 | expect(handleFallback).toBeCalledWith(fallback, noCookieReq, event) 64 | }) 65 | 66 | test('logged in', async () => { 67 | await makeAuth0Inspector(fallback, '/api/auth/me')( 68 | { headers, nextUrl: { origin: '' } } as unknown as NextRequest, 69 | event 70 | ) 71 | 72 | expect(handleFallback).not.toBeCalled() 73 | }) 74 | 75 | test('the domain of api endpoint is specified', async () => { 76 | const req = { headers, nextUrl: { origin: '' } } as unknown as NextRequest 77 | await makeAuth0Inspector(fallback, 'https://not.authed.com/api/auth/me')( 78 | req, 79 | event 80 | ) 81 | 82 | expect(handleFallback).toBeCalledWith(fallback, req, event) 83 | }) 84 | 85 | test('logged in and passed custom handler', async () => { 86 | await makeAuth0Inspector( 87 | fallback, 88 | '/api/auth/me', 89 | (res) => !!res.email_verified 90 | )({ headers, nextUrl: { origin: '' } } as unknown as NextRequest, event) 91 | 92 | expect(handleFallback).not.toBeCalled() 93 | }) 94 | }) 95 | 96 | describe('has nextUrl origin', () => { 97 | test('not logged in', async () => { 98 | const req = { 99 | headers, 100 | nextUrl: { origin: 'https://not.authed.com' } 101 | } as unknown as NextRequest 102 | await makeAuth0Inspector(fallback, '/api/auth/me')(req, event) 103 | 104 | expect(handleFallback).toBeCalledWith(fallback, req, event) 105 | }) 106 | 107 | test('logged in', async () => { 108 | await makeAuth0Inspector(fallback, '/api/auth/me')( 109 | { 110 | headers, 111 | nextUrl: { origin: 'https://authed.com' } 112 | } as unknown as NextRequest, 113 | event 114 | ) 115 | 116 | expect(handleFallback).not.toBeCalled() 117 | }) 118 | }) 119 | }) 120 | -------------------------------------------------------------------------------- /src/__tests__/cognito.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, beforeEach, test, expect, Mock } from 'vitest' 2 | import { makeCognitoInspector } from '../cognito' 3 | import { NextFetchEvent, NextRequest } from 'next/server' 4 | import { handleFallback } from '../handle-fallback' 5 | import { Fallback } from '../types' 6 | import { decodeProtectedHeader, jwtVerify } from 'jose' 7 | import fetchMock from 'fetch-mock' 8 | import { 9 | Cookies, 10 | NextCookies 11 | } from 'next/dist/server/web/spec-extension/cookies' 12 | 13 | vi.mock('jose', () => ({ 14 | importJWK: vi.fn(), 15 | decodeProtectedHeader: vi.fn(), 16 | jwtVerify: vi.fn() 17 | })) 18 | 19 | fetchMock.get( 20 | 'https://cognito-idp.ap-northeast-1.amazonaws.com/xxx/.well-known/jwks.json', 21 | { 22 | status: 200, 23 | body: { 24 | keys: [ 25 | { 26 | alg: 'RS256', 27 | e: 'AQAB', 28 | kid: 'kid1', 29 | kty: 'RSA', 30 | n: 'n1', 31 | use: 'sig' 32 | }, 33 | { 34 | alg: 'RS256', 35 | e: 'AQAB', 36 | kid: 'kid', 37 | kty: 'RSA', 38 | n: 'n2', 39 | use: 'sig' 40 | } 41 | ] 42 | } 43 | } 44 | ) 45 | 46 | const cognitoParams = { 47 | region: 'ap-northeast-1', 48 | userPoolId: 'xxx', 49 | userPoolWebClientId: 'yyy' 50 | } 51 | 52 | vi.mock('../handle-fallback', () => ({ 53 | handleFallback: vi.fn() 54 | })) 55 | 56 | const fallback: Fallback = { type: 'redirect', destination: '/foo' } 57 | 58 | const event = {} as NextFetchEvent 59 | 60 | describe('makeCognitoInspector', () => { 61 | beforeEach(() => { 62 | vi.resetAllMocks() 63 | }) 64 | 65 | test('has no cookies', async () => { 66 | const cookies = new Cookies() 67 | await makeCognitoInspector(fallback, cognitoParams)( 68 | { cookies } as NextRequest, 69 | event 70 | ) 71 | 72 | expect(handleFallback).toBeCalledWith(fallback, { cookies }, event) 73 | }) 74 | 75 | test('has the firebase cookie', async () => { 76 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 77 | kid: 'kid1' 78 | }) 79 | ;(jwtVerify as Mock).mockReturnValue( 80 | new Promise((resolve) => resolve(true)) 81 | ) 82 | const cookies = new Cookies() 83 | cookies.set('CognitoIdentityServiceProvider.yyy.userName.idToken', 'x.x.x') 84 | await makeCognitoInspector(fallback, cognitoParams)( 85 | { 86 | cookies 87 | } as unknown as NextRequest, 88 | event 89 | ) 90 | 91 | expect(handleFallback).not.toBeCalled() 92 | }) 93 | 94 | test('has the firebase cookie and passed custom handler', async () => { 95 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 96 | kid: 'kid1' 97 | }) 98 | ;(jwtVerify as Mock).mockReturnValue( 99 | new Promise((resolve) => 100 | resolve({ 101 | payload: { 102 | email_verified: true 103 | } 104 | }) 105 | ) 106 | ) 107 | const cookies = new Cookies() 108 | cookies.set('CognitoIdentityServiceProvider.yyy.userName.idToken', 'x.x.x') 109 | await makeCognitoInspector( 110 | fallback, 111 | cognitoParams, 112 | (res) => !!res.email_verified 113 | )( 114 | { 115 | cookies 116 | } as unknown as NextRequest, 117 | event 118 | ) 119 | 120 | expect(handleFallback).not.toBeCalled() 121 | }) 122 | 123 | test("has the cognito cookie, but it's not valid.", async () => { 124 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 125 | kid: 'kid1' 126 | }) 127 | ;(jwtVerify as Mock).mockReturnValue( 128 | new Promise((resolve, reject) => reject(false)) 129 | ) 130 | const token = 'x.y.z' 131 | const cookies = new Cookies() 132 | cookies.set('CognitoIdentityServiceProvider.yyy.userName.idToken', token) 133 | await makeCognitoInspector(fallback, cognitoParams)( 134 | { 135 | cookies 136 | } as unknown as NextRequest, 137 | event 138 | ) 139 | 140 | expect(handleFallback).toBeCalledWith( 141 | fallback, 142 | { 143 | cookies 144 | }, 145 | event 146 | ) 147 | }) 148 | 149 | test('jwks expired.', async () => { 150 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 151 | kid: 'kid3' 152 | }) 153 | const token = 'x.y.z' 154 | const cookies = new Cookies() 155 | cookies.set('CognitoIdentityServiceProvider.yyy.userName.idToken', token) 156 | await makeCognitoInspector(fallback, cognitoParams)( 157 | { 158 | cookies 159 | } as unknown as NextRequest, 160 | event 161 | ) 162 | 163 | expect(handleFallback).toBeCalledWith( 164 | fallback, 165 | { 166 | cookies 167 | }, 168 | event 169 | ) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /src/__tests__/firebase.spec.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment edge-runtime 3 | */ 4 | import { vi, describe, beforeEach, test, expect, Mock } from 'vitest' 5 | import { makeFirebaseInspector } from '../firebase' 6 | import type { NextFetchEvent } from 'next/server' 7 | const { NextRequest } = require('next/server') 8 | import { handleFallback } from '../handle-fallback' 9 | import { Fallback } from '../types' 10 | import * as fetchMock from 'fetch-mock' 11 | import { decodeProtectedHeader, jwtVerify, importX509 } from 'jose' 12 | 13 | vi.mock('jose', () => ({ 14 | importJWK: vi.fn(), 15 | decodeProtectedHeader: vi.fn(), 16 | jwtVerify: vi.fn(), 17 | importX509: vi.fn() 18 | })) 19 | 20 | fetchMock 21 | .get( 22 | 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', 23 | { 24 | status: 200, 25 | body: { 26 | kid1: 'xxxxxxxxxx', 27 | kid2: 'yyyyyyyyyy' 28 | } 29 | } 30 | ) 31 | .get( 32 | 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', 33 | { 34 | status: 200, 35 | body: { 36 | kid3: 'zzzzzzzzzz' 37 | } 38 | } 39 | ) 40 | 41 | vi.mock('../handle-fallback', () => ({ 42 | handleFallback: vi.fn() 43 | })) 44 | 45 | const event = {} as NextFetchEvent 46 | 47 | const fallback: Fallback = { type: 'redirect', destination: '/foo' } 48 | 49 | const originalEnv = { ...process.env } 50 | 51 | describe('makeFirebaseInspector', () => { 52 | beforeEach(() => { 53 | vi.resetAllMocks() 54 | process.env = originalEnv 55 | }) 56 | 57 | test('has no cookies', async () => { 58 | const req = new NextRequest('https://example.com') 59 | await makeFirebaseInspector(fallback)(req, event) 60 | 61 | expect(handleFallback).toBeCalledWith(fallback, req, event) 62 | }) 63 | 64 | test('has the firebase cookie', async () => { 65 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 66 | kid: 'kid1' 67 | }) 68 | ;(jwtVerify as Mock).mockReturnValue( 69 | new Promise((resolve) => resolve(true)) 70 | ) 71 | const req = new NextRequest('https://example.com') 72 | req.cookies.set('__fortressFirebaseSession', 'x.x.x') 73 | await makeFirebaseInspector(fallback)(req, event) 74 | 75 | expect(handleFallback).not.toBeCalled() 76 | }) 77 | 78 | test('has the firebase cookie by custom key', async () => { 79 | process.env = { 80 | ...process.env, 81 | FORTRESS_FIREBASE_COOKIE_KEY: 'session' 82 | } 83 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 84 | kid: 'kid1' 85 | }) 86 | ;(jwtVerify as Mock).mockReturnValue( 87 | new Promise((resolve) => resolve(true)) 88 | ) 89 | const req = new NextRequest('https://example.com') 90 | req.cookies.set('session', 'x.x.x') 91 | await makeFirebaseInspector(fallback)(req, event) 92 | 93 | expect(handleFallback).not.toBeCalled() 94 | }) 95 | 96 | test('has the firebase cookie and passed custom handler', async () => { 97 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 98 | kid: 'kid1' 99 | }) 100 | ;(jwtVerify as Mock).mockReturnValue( 101 | new Promise((resolve) => 102 | resolve({ 103 | payload: { 104 | firebase: { 105 | sign_in_provider: 'google.com' 106 | } 107 | } 108 | }) 109 | ) 110 | ) 111 | const req = new NextRequest('https://example.com') 112 | req.cookies.set('__fortressFirebaseSession', 'x.x.x') 113 | await makeFirebaseInspector( 114 | fallback, 115 | (res) => res.firebase.sign_in_provider === 'google.com' 116 | )(req, event) 117 | 118 | expect(handleFallback).not.toBeCalled() 119 | }) 120 | 121 | test("has the firebase cookie, but it's not valid.", async () => { 122 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 123 | kid: 'kid1' 124 | }) 125 | ;(jwtVerify as Mock).mockReturnValue( 126 | new Promise((resolve, reject) => reject(false)) 127 | ) 128 | const token = 'x.y.z' 129 | const req = new NextRequest('https://example.com') 130 | req.cookies.set('__fortressFirebaseSession', token) 131 | await makeFirebaseInspector(fallback)(req, event) 132 | 133 | expect(handleFallback).toBeCalledWith(fallback, req, event) 134 | }) 135 | 136 | test('jwks expired.', async () => { 137 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 138 | kid: 'kid3' 139 | }) 140 | const token = 'x.y.z' 141 | const req = new NextRequest('https://example.com') 142 | req.cookies.set('__fortressFirebaseSession', token) 143 | await makeFirebaseInspector(fallback)(req, event) 144 | 145 | expect(handleFallback).toBeCalledWith(fallback, req, event) 146 | expect(importX509).toBeCalledWith(undefined, 'RS256') 147 | }) 148 | 149 | test('session cookie mode', async () => { 150 | process.env = { 151 | ...process.env, 152 | FORTRESS_FIREBASE_MODE: 'session' 153 | } 154 | ;(decodeProtectedHeader as Mock).mockReturnValue({ 155 | kid: 'kid3' 156 | }) 157 | ;(jwtVerify as Mock).mockReturnValue( 158 | new Promise((resolve) => resolve(true)) 159 | ) 160 | const token = 'x.y.z' 161 | const req = new NextRequest('https://example.com') 162 | req.cookies.set('__fortressFirebaseSession', token) 163 | await makeFirebaseInspector(fallback)(req, event) 164 | 165 | expect(handleFallback).not.toBeCalled() 166 | expect(importX509).toBeCalledWith('zzzzzzzzzz', 'RS256') 167 | }) 168 | }) 169 | -------------------------------------------------------------------------------- /src/__tests__/handle-fallback.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, beforeEach, test, expect } from 'vitest' 2 | import { handleFallback } from '../handle-fallback' 3 | import { NextFetchEvent, NextRequest, NextResponse } from 'next/server' 4 | 5 | vi.mock('next/server', () => ({ 6 | NextResponse: vi.fn() 7 | })) 8 | 9 | const dummyRequest = { 10 | nextUrl: { 11 | pathname: '/', 12 | clone: () => ({ 13 | pathname: '/' 14 | }) 15 | } 16 | } as NextRequest 17 | const dummyEvent = {} as NextFetchEvent 18 | 19 | describe('handleFallback', () => { 20 | beforeEach(() => { 21 | vi.resetAllMocks() 22 | }) 23 | 24 | test('passed function', () => { 25 | const fallback = vi.fn() 26 | handleFallback(fallback, dummyRequest, dummyEvent) 27 | 28 | expect(fallback).toBeCalledWith(dummyRequest, dummyEvent) 29 | }) 30 | 31 | test('handle preflight', () => { 32 | NextResponse.redirect = vi.fn() 33 | handleFallback( 34 | { type: 'redirect', destination: '/foo/bar' }, 35 | { ...dummyRequest, method: 'OPTIONS' } as unknown as NextRequest, 36 | dummyEvent 37 | ) 38 | expect(NextResponse.redirect).not.toBeCalled() 39 | expect(NextResponse).toBeCalledWith(null) 40 | }) 41 | 42 | test('passed redirect option', () => { 43 | NextResponse.redirect = vi.fn() 44 | handleFallback( 45 | { type: 'redirect', destination: '/foo/bar' }, 46 | dummyRequest, 47 | dummyEvent 48 | ) 49 | expect(NextResponse.redirect).toBeCalledWith( 50 | { pathname: '/foo/bar' }, 51 | undefined 52 | ) 53 | 54 | handleFallback( 55 | { type: 'redirect', destination: '/foo/baz', statusCode: 301 }, 56 | dummyRequest, 57 | dummyEvent 58 | ) 59 | expect(NextResponse.redirect).toBeCalledWith({ pathname: '/foo/baz' }, 301) 60 | }) 61 | 62 | test('prevent redirection loop', () => { 63 | const res = handleFallback( 64 | { type: 'redirect', destination: '/foo/bar' }, 65 | { 66 | nextUrl: { pathname: '/foo/bar' } 67 | } as NextRequest, 68 | dummyEvent 69 | ) 70 | expect(res).toBeUndefined() 71 | }) 72 | 73 | test('passed rewrite option', () => { 74 | NextResponse.rewrite = vi.fn() 75 | handleFallback( 76 | { type: 'rewrite', destination: '/foo/bar' }, 77 | dummyRequest, 78 | dummyEvent 79 | ) 80 | expect(NextResponse.rewrite).toBeCalledWith({ pathname: '/foo/bar' }) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/__tests__/ip.spec.ts: -------------------------------------------------------------------------------- 1 | import { vi, describe, beforeEach, test, expect, it } from 'vitest' 2 | import { makeIPInspector } from '../ip' 3 | import { NextFetchEvent, NextRequest } from 'next/server' 4 | import { handleFallback } from '../handle-fallback' 5 | import { Fallback } from '../types' 6 | 7 | vi.mock('../handle-fallback', () => ({ 8 | handleFallback: vi.fn() 9 | })) 10 | 11 | const event = {} as NextFetchEvent 12 | 13 | const fallback: Fallback = { type: 'redirect', destination: '/foo' } 14 | 15 | describe('makeIPInspector', () => { 16 | beforeEach(() => { 17 | vi.resetAllMocks() 18 | }) 19 | 20 | test('matched with allowedIp', () => { 21 | const request = { 22 | ip: '10.0.0.1' 23 | } as NextRequest 24 | makeIPInspector('10.0.0.1/32', fallback)(request, event) 25 | expect(handleFallback).not.toBeCalled() 26 | }) 27 | 28 | test('not matched with allowedIp', () => { 29 | const request = { 30 | ip: '10.0.0.2' 31 | } as NextRequest 32 | makeIPInspector('10.0.0.1/32', fallback)(request, event) 33 | expect(handleFallback).toBeCalledWith( 34 | { 35 | type: 'redirect', 36 | destination: '/foo' 37 | }, 38 | request, 39 | event 40 | ) 41 | }) 42 | 43 | it('must work with IP array', () => { 44 | const request = { 45 | ip: '11.0.0.1' 46 | } as NextRequest 47 | makeIPInspector(['11.0.0.1/32', '10.0.0.1/32'], fallback)(request, event) 48 | expect(handleFallback).not.toBeCalled() 49 | }) 50 | 51 | test('does not have IP', () => { 52 | const request = { 53 | ip: '' 54 | } as NextRequest 55 | makeIPInspector('10.0.0.1/32', fallback)(request, event) 56 | expect(handleFallback).toBeCalledWith(fallback, request, event) 57 | }) 58 | 59 | test('IP cidr', () => { 60 | makeIPInspector(['11.0.0.0/16', '10.0.0.1/32'], fallback)( 61 | { 62 | ip: '11.0.255.255' 63 | } as NextRequest, 64 | event 65 | ) 66 | expect(handleFallback).not.toBeCalled() 67 | 68 | makeIPInspector(['11.0.0.0/16', '10.0.0.1/32'], fallback)( 69 | { 70 | ip: '11.1.255.255' 71 | } as NextRequest, 72 | event 73 | ) 74 | expect(handleFallback).toBeCalledWith( 75 | fallback, 76 | { 77 | ip: '11.1.255.255' 78 | }, 79 | event 80 | ) 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /src/auth0.ts: -------------------------------------------------------------------------------- 1 | import { Fallback } from './types' 2 | import { NextRequest, NextMiddleware } from 'next/server' 3 | import { handleFallback } from './handle-fallback' 4 | 5 | export const makeAuth0Inspector = ( 6 | fallback: Fallback, 7 | apiEndpoint: string, 8 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 9 | customHandler?: (payload: any) => boolean 10 | ): NextMiddleware => { 11 | return async (request, event) => { 12 | const ok = await verifyAuth0Session(request, apiEndpoint, customHandler) 13 | if (ok) return 14 | return handleFallback(fallback, request, event) 15 | } 16 | } 17 | 18 | const verifyAuth0Session = async ( 19 | req: NextRequest, 20 | apiEndpoint: string, 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | customHandler?: (payload: any) => boolean 23 | ): Promise => { 24 | const res = await fetch( 25 | (/^\//.test(apiEndpoint) ? req.nextUrl.origin : '') + apiEndpoint, 26 | { 27 | headers: { cookie: req.headers.get('cookie') ?? '' } 28 | } 29 | ) 30 | 31 | return !res.ok ? false : customHandler?.(await res.json()) ?? true 32 | } 33 | -------------------------------------------------------------------------------- /src/cognito.ts: -------------------------------------------------------------------------------- 1 | import { Fallback } from './types' 2 | import { NextRequest, NextMiddleware } from 'next/server' 3 | import { handleFallback } from './handle-fallback' 4 | import { decodeProtectedHeader, importJWK, JWK, jwtVerify } from 'jose' 5 | 6 | type UserPoolParams = { 7 | region: string 8 | userPoolId: string 9 | userPoolWebClientId: string 10 | } 11 | 12 | export const makeCognitoInspector = ( 13 | fallback: Fallback, 14 | params: UserPoolParams, 15 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 16 | customHandler?: (payload: any) => boolean 17 | ): NextMiddleware => { 18 | return async (request, event) => { 19 | const ok = await verifyCognitoAuthenticatedUser( 20 | request, 21 | params, 22 | customHandler 23 | ) 24 | if (ok) return 25 | return handleFallback(fallback, request, event) 26 | } 27 | } 28 | 29 | const verifyCognitoAuthenticatedUser = async ( 30 | req: NextRequest, 31 | { region, userPoolId: poolId, userPoolWebClientId: clientId }: UserPoolParams, 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 33 | customHandler?: (payload: any) => boolean 34 | ): Promise => { 35 | const tokenKey = [...req.cookies.keys()].find((key) => 36 | new RegExp( 37 | `CognitoIdentityServiceProvider\\.${clientId}\\..+\\.idToken` 38 | ).test(key) 39 | ) 40 | if (!tokenKey) return false 41 | const token = req.cookies.get(tokenKey) 42 | 43 | if (!token) return false 44 | 45 | const { keys }: { keys: JWK[] } = await fetch( 46 | `https://cognito-idp.${region}.amazonaws.com/${poolId}/.well-known/jwks.json` 47 | ).then((res) => res.json()) 48 | 49 | const { kid } = decodeProtectedHeader(token) 50 | const jwk = keys.find((key) => key.kid === kid) 51 | if (!jwk) return false 52 | 53 | return jwtVerify(token, await importJWK(jwk)) 54 | .then((res) => customHandler?.(res.payload) ?? true) 55 | .catch(() => false) 56 | } 57 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const FIREBASE_COOKIE_KEY = '__fortressFirebaseSession' 2 | -------------------------------------------------------------------------------- /src/firebase.ts: -------------------------------------------------------------------------------- 1 | import { Fallback } from './types' 2 | import { FIREBASE_COOKIE_KEY } from './constants' 3 | import { NextRequest, NextMiddleware } from 'next/server' 4 | import { handleFallback } from './handle-fallback' 5 | import { decodeProtectedHeader, jwtVerify, importX509 } from 'jose' 6 | 7 | export const makeFirebaseInspector = ( 8 | fallback: Fallback, 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | customHandler?: (payload: any) => boolean 11 | ): NextMiddleware => { 12 | return async (request, event) => { 13 | const ok = await verifyFirebaseIdToken(request, customHandler) 14 | if (ok) return 15 | return handleFallback(fallback, request, event) 16 | } 17 | } 18 | 19 | const verifyFirebaseIdToken = async ( 20 | req: NextRequest, 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | customHandler?: (payload: any) => boolean 23 | ): Promise => { 24 | const cookieKey = 25 | process.env.FORTRESS_FIREBASE_COOKIE_KEY ?? FIREBASE_COOKIE_KEY 26 | const token = req.cookies.get(cookieKey) 27 | if (!token) return false 28 | 29 | const endpoint = 30 | process.env.FORTRESS_FIREBASE_MODE === 'session' 31 | ? 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys' 32 | : 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com' 33 | 34 | try { 35 | const keys: Record = await fetch(endpoint).then((res) => 36 | res.json() 37 | ) 38 | const { kid = '' } = decodeProtectedHeader(token) 39 | 40 | return jwtVerify(token, await importX509(keys[kid], 'RS256')) 41 | .then((res) => customHandler?.(res.payload) ?? true) 42 | .catch(() => false) 43 | } catch (_) { 44 | return false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/handle-fallback.ts: -------------------------------------------------------------------------------- 1 | import { Fallback } from './types' 2 | import { NextFetchEvent, NextRequest, NextResponse } from 'next/server' 3 | import { NextMiddleware } from 'next/dist/server/web/types' 4 | 5 | export const handleFallback = ( 6 | fallback: Fallback, 7 | request: NextRequest, 8 | event: NextFetchEvent 9 | ): ReturnType => { 10 | if (typeof fallback === 'function') return fallback(request, event) 11 | if (request.method === 'OPTIONS') return new NextResponse(null) 12 | if (fallback.type === 'rewrite') { 13 | const url = request.nextUrl.clone() 14 | url.pathname = fallback.destination 15 | return NextResponse.rewrite(url) 16 | } 17 | 18 | if (request.nextUrl.pathname !== fallback.destination) { 19 | const url = request.nextUrl.clone() 20 | url.pathname = fallback.destination 21 | return NextResponse.redirect(url, fallback.statusCode) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { makeIPInspector } from './ip' 2 | export { makeFirebaseInspector } from './firebase' 3 | export { makeAuth0Inspector } from './auth0' 4 | export { makeCognitoInspector } from './cognito' 5 | export { FIREBASE_COOKIE_KEY } from './constants' 6 | -------------------------------------------------------------------------------- /src/ip.ts: -------------------------------------------------------------------------------- 1 | import { Fallback } from './types' 2 | import { Netmask } from 'netmask' 3 | import { NextRequest, NextMiddleware } from 'next/server' 4 | import { handleFallback } from './handle-fallback' 5 | 6 | type IPs = string | Array 7 | 8 | export const makeIPInspector = ( 9 | allowedIPs: IPs, 10 | fallback: Fallback 11 | ): NextMiddleware => { 12 | return (request, event) => { 13 | const ok = inspectIp(allowedIPs, request.ip) 14 | if (ok) return 15 | return handleFallback(fallback, request, event) 16 | } 17 | } 18 | 19 | const inspectIp = (ips: IPs, target: NextRequest['ip']): boolean => { 20 | if (!target) return false 21 | return (Array.isArray(ips) ? ips : [ips]).some((ip) => { 22 | const block = new Netmask(ip) 23 | return block.contains(target) 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { NextMiddleware } from 'next/server' 2 | 3 | export type Fallback = 4 | | NextMiddleware 5 | | { type: 'rewrite'; destination: string } 6 | | { 7 | type: 'redirect' 8 | destination: string 9 | statusCode?: 301 | 302 | 303 | 307 | 308 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "es2022", 5 | "lib": ["esnext", "dom"], 6 | "strict": true, 7 | "moduleResolution": "node", 8 | "esModuleInterop": true 9 | }, 10 | "include": ["src/**/*.ts"], 11 | "exclude": ["**/__tests__/**/*"] 12 | } 13 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | reporter: ['text', 'json', 'html', 'lcov'] 7 | } 8 | } 9 | }) 10 | --------------------------------------------------------------------------------