This page is accessible only to logged-in users.
23 |
24 | 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 | ()
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 | Auth.signOut()}>
31 | Logout
32 |
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 |
Auth.federatedSignIn()}
33 | >
34 | Login
35 | You are Not logged in.
36 |
37 | ) : (
38 |
Auth.signOut()}>
39 | Logout
40 | You are logged in.
41 |
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 |
25 | Logout
26 |
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 |
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 |
30 | reset allowed IP
31 |
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 |
52 | set allowed IP
53 |
54 |
55 | reset
56 |
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 |
--------------------------------------------------------------------------------