mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
67 Commits
v4.0.0-bet
...
next-auth@
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29db75ad28 | ||
|
|
d348ca1dc1 | ||
|
|
d53e1ea6c4 | ||
|
|
e701342b1a | ||
|
|
8a133bf5fd | ||
|
|
35a3ea6620 | ||
|
|
289800fbb4 | ||
|
|
28eccc3e64 | ||
|
|
e16bf939a9 | ||
|
|
9b078c92b2 | ||
|
|
87f6f576b1 | ||
|
|
50584bdc4c | ||
|
|
b4429235c0 | ||
|
|
e1b297d06d | ||
|
|
ab764e3793 | ||
|
|
c8941e4b3e | ||
|
|
ead715219a | ||
|
|
8faa7553dd | ||
|
|
90a6a0084b | ||
|
|
cb844a2436 | ||
|
|
74558d6cc2 | ||
|
|
d03125a77b | ||
|
|
66d16f8bf4 | ||
|
|
be74dd0e7e | ||
|
|
9bf867ddcf | ||
|
|
0f460c22da | ||
|
|
887cb00877 | ||
|
|
75ca097ff7 | ||
|
|
bcb9383aec | ||
|
|
b953963101 | ||
|
|
4649f1968b | ||
|
|
45f4a69a4e | ||
|
|
2155c93a3c | ||
|
|
d5958571a4 | ||
|
|
ebecaa6a4b | ||
|
|
1c5173a818 | ||
|
|
35ce332cc6 | ||
|
|
ec295287f1 | ||
|
|
46978ac02f | ||
|
|
f546e550dd | ||
|
|
ac5b4db0f2 | ||
|
|
8bbffdd08c | ||
|
|
a22a0a36fd | ||
|
|
797272afe1 | ||
|
|
13e56bcf2f | ||
|
|
b0f7f87c04 | ||
|
|
9c0851c0f9 | ||
|
|
f5b3c29ab1 | ||
|
|
b4f2a0106a | ||
|
|
9c095b0532 | ||
|
|
0475964a0f | ||
|
|
ad6c13cdc9 | ||
|
|
591aa7cc7e | ||
|
|
9abb392b4e | ||
|
|
b89ae87fb1 | ||
|
|
3687d17724 | ||
|
|
b04ff82fb9 | ||
|
|
c11915ba9c | ||
|
|
24ee459f97 | ||
|
|
ac4851d238 | ||
|
|
84094b0ee7 | ||
|
|
f09ab4a04f | ||
|
|
067364381b | ||
|
|
6ee36b6842 | ||
|
|
5a89ab69d3 | ||
|
|
665445818e | ||
|
|
67cf2a11bb |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1,2 +1 @@
|
||||
/types/ @balazsorban44 @lluia
|
||||
/__tests__/ @lluia
|
||||
|
||||
10
.github/labeler.yml
vendored
10
.github/labeler.yml
vendored
@@ -2,15 +2,23 @@ test:
|
||||
- test/**/*
|
||||
- types/tests/**/*
|
||||
|
||||
documentation:
|
||||
- www/**/*
|
||||
- ./**/*.md
|
||||
|
||||
providers:
|
||||
- src/providers/**/*
|
||||
- www/docs/configuration/providers.md
|
||||
- test/integration/**/*
|
||||
|
||||
adapters:
|
||||
- src/adapters/**/*
|
||||
- www/docs/schemas/adapters.md
|
||||
|
||||
databases:
|
||||
- www/docs/schemas/*.md
|
||||
- test/docker/databases/**/*
|
||||
- www/docs/configuration/databases.md
|
||||
- test/fixtures/**/*
|
||||
|
||||
core:
|
||||
@@ -21,9 +29,11 @@ style:
|
||||
|
||||
client:
|
||||
- src/client/**/*
|
||||
- www/docs/getting-started/client.md
|
||||
|
||||
pages:
|
||||
- src/server/pages/**/*
|
||||
- www/docs/configuration/pages.md
|
||||
|
||||
TypeScript:
|
||||
- types/**/*
|
||||
|
||||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
- name: Run tests
|
||||
run: npm test -- --coverage --verbose
|
||||
run: npm test -- --coverage --verbose && npm run test:types
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
@@ -66,21 +66,11 @@ jobs:
|
||||
node-version: 16
|
||||
- name: Dependencies
|
||||
uses: bahmutov/npm-install@v1
|
||||
- name: Determine version
|
||||
uses: ./config/version-pr
|
||||
id: determine-version
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
- name: Publish to npm
|
||||
run: |
|
||||
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
|
||||
npm run version:pr
|
||||
npm publish --access public --tag experimental
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Comment version on PR
|
||||
uses: NejcZdovc/comment-pr@v1
|
||||
with:
|
||||
message: "🎉 Experimental release [published on npm](https://www.npmjs.com/package/next-auth/v/${{ env.VERSION }})!\n\n```sh\nnpm i next-auth@${{ env.VERSION }}\n```\n```sh\nyarn add next-auth@${{ env.VERSION }}\n```"
|
||||
env:
|
||||
VERSION: ${{ steps.determine-version.outputs.version }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
|
||||
32
.gitignore
vendored
32
.gitignore
vendored
@@ -20,29 +20,32 @@ node_modules
|
||||
.next
|
||||
/build
|
||||
/dist
|
||||
/www/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
/providers
|
||||
/src/providers/oauth-types.ts
|
||||
/client
|
||||
/css
|
||||
/lib
|
||||
/core
|
||||
/jwt
|
||||
/react
|
||||
www/providers.json
|
||||
src/providers/index.js
|
||||
/internals
|
||||
/adapters.d.ts
|
||||
/adapters.js
|
||||
/client.d.ts
|
||||
/client.js
|
||||
/index.d.ts
|
||||
/index.js
|
||||
/next
|
||||
/jwt.d.ts
|
||||
/jwt.js
|
||||
/providers.d.ts
|
||||
/providers.js
|
||||
/errors.js
|
||||
/errors.d.ts
|
||||
|
||||
# Development app
|
||||
app/src/css
|
||||
app/next-auth
|
||||
app/dist/css
|
||||
app/package-lock.json
|
||||
app/yarn.lock
|
||||
app/prisma/migrations
|
||||
app/prisma/dev.db*
|
||||
|
||||
# VS
|
||||
/.vs/slnx.sqlite-journal
|
||||
@@ -59,3 +62,8 @@ app/prisma/dev.db*
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
|
||||
# v4
|
||||
packages
|
||||
apps
|
||||
docs/providers.json
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# npx pretty-quick --staged
|
||||
npx pretty-quick --staged
|
||||
|
||||
@@ -25,7 +25,7 @@ Anyone can be a contributor. Either you found a typo, or you have an awesome fea
|
||||
|
||||
A quick guide on how to setup _next-auth_ locally to work on it and test out any changes:
|
||||
|
||||
The developer application requires you to use `npm@7`.
|
||||
The dev application requires you to use `npm@7`.
|
||||
|
||||
1. Clone the repo:
|
||||
|
||||
@@ -34,7 +34,7 @@ git clone git@github.com:nextauthjs/next-auth.git
|
||||
cd next-auth
|
||||
```
|
||||
|
||||
2. Install packages and set up the developer application:
|
||||
2. Install packages, set up the dev application:
|
||||
|
||||
```sh
|
||||
npm run dev:setup
|
||||
@@ -47,13 +47,13 @@ npm run dev:setup
|
||||
> NOTE: You can add any environment variables to .env.local that you would like to use in your dev app.
|
||||
> You can find the next-auth config under`app/pages/api/auth/[...nextauth].js`.
|
||||
|
||||
1. Start the developer application/server:
|
||||
1. Start the dev application/server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your developer application will be available on `http://localhost:3000`
|
||||
Your dev application will be available on `http://localhost:3000`
|
||||
|
||||
That's it! 🎉
|
||||
|
||||
@@ -61,9 +61,9 @@ If you need an example project to link to, you can use [next-auth-example](https
|
||||
|
||||
#### Hot reloading
|
||||
|
||||
When running `npm run dev`, you start a Next.js developer server on `http://localhost:3000`, which includes hot reloading out of the box. Make changes on any of the files in `src` and see the changes immediately.
|
||||
When running `npm run dev`, you start a Next.js dev server on `http://localhost:3000`, which includes hot reloading out of the box. Make changes on any of the files in `src` and see the changes immediately.
|
||||
|
||||
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered (using API routes). (Improving this through a PR is very welcome!)
|
||||
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered. (Improving this through a PR is very welcome!)
|
||||
|
||||
> NOTE: The setup is as follows: The development application lives inside the `app` folder, and whenever you make a change to the `src` folder in the root (where next-auth is), it gets copied into `app` every time (gitignored), so Next.js can pick them up and apply hot reloading. This is to avoid some annoying issues with how symlinks are working with different React builds, and also to provide a super-fast feedback loop while developing core features.
|
||||
|
||||
|
||||
51
README.md
51
README.md
@@ -67,15 +67,15 @@ NextAuth.js can be used with or without a database.
|
||||
|
||||
### Secure by default
|
||||
|
||||
- Promotes the use of passwordless sign-in mechanisms
|
||||
- Designed to be secure by default and encourage best practices for safeguarding user data
|
||||
- Uses Cross-Site Request Forgery (CSRF) Tokens on POST routes (sign in, sign out)
|
||||
- Promotes the use of passwordless sign in mechanisms
|
||||
- Designed to be secure by default and encourage best practice for safeguarding user data
|
||||
- Uses Cross Site Request Forgery Tokens on POST routes (sign in, sign out)
|
||||
- Default cookie policy aims for the most restrictive policy appropriate for each cookie
|
||||
- When JSON Web Tokens are enabled, they are signed by default (JWS) with HS512
|
||||
- Use JWT encryption (JWE) by setting the option `encryption: true` (defaults to A256GCM)
|
||||
- Auto-generates symmetric signing and encryption keys for developer convenience
|
||||
- Features tab/window syncing and session polling to support short lived sessions
|
||||
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org)
|
||||
- Features tab/window syncing and keepalive messages to support short lived sessions
|
||||
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org/)
|
||||
|
||||
Advanced options allow you to define your own routines to handle controlling what accounts are allowed to sign in, for encoding and decoding JSON Web Tokens and to set custom cookie security policies and session properties, so you can control who is able to sign in and how often sessions have to be re-validated.
|
||||
|
||||
@@ -83,12 +83,13 @@ Advanced options allow you to define your own routines to handle controlling wha
|
||||
|
||||
NextAuth.js comes with built-in types. For more information and usage, check out the [TypeScript section](https://next-auth.js.org/getting-started/typescript) in the documentation.
|
||||
|
||||
The package at `@types/next-auth` is now deprecated.
|
||||
|
||||
## Example
|
||||
|
||||
### Add API Route
|
||||
|
||||
```javascript
|
||||
// pages/api/auth/[...nextauth].js
|
||||
import NextAuth from "next-auth"
|
||||
import Providers from "next-auth/providers"
|
||||
|
||||
@@ -109,18 +110,18 @@ export default NextAuth({
|
||||
from: "<no-reply@example.com>",
|
||||
}),
|
||||
],
|
||||
// SQL or MongoDB database (or leave empty)
|
||||
database: process.env.DATABASE_URL,
|
||||
})
|
||||
```
|
||||
|
||||
### Add React Hook
|
||||
|
||||
The `useSession()` React Hook in the NextAuth.js client is the easiest way to check if someone is signed in.
|
||||
### Add React Component
|
||||
|
||||
```javascript
|
||||
import { useSession, signIn, signOut } from "next-auth/react"
|
||||
import { useSession, signIn, signOut } from "next-auth/client"
|
||||
|
||||
export default function Component() {
|
||||
const { data: session } = useSession()
|
||||
const [session, loading] = useSession()
|
||||
if (session) {
|
||||
return (
|
||||
<>
|
||||
@@ -138,26 +139,7 @@ export default function Component() {
|
||||
}
|
||||
```
|
||||
|
||||
### Share/configure session state
|
||||
|
||||
Use the `<SessionProvider>` to allows instances of `useSession()` to share the session object across components. It also takes care of keeping the session updated and synced between tabs/windows.
|
||||
|
||||
```jsx title="pages/_app.js"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
|
||||
export default function App({
|
||||
Component,
|
||||
pageProps: { session, ...pageProps }
|
||||
}) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Acknowledgments
|
||||
## Acknowledgements
|
||||
|
||||
[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors)
|
||||
|
||||
@@ -197,13 +179,6 @@ We're happy to announce we've recently created an [OpenCollective](https://openc
|
||||
<div>Checkly</div><br />
|
||||
<sub>☁️ Infrastructure Support</sub>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<a href="https://superblog.ai/" target="_blank">
|
||||
<img width="128px" src="https://d33wubrfki0l68.cloudfront.net/cdc4a3833bd878933fcc131655878dbf226ac1c5/10cd6/images/logo_bolt_small.png" alt="superblog Logo" />
|
||||
</a><br />
|
||||
<div>superblog</div><br />
|
||||
<sub>☁️ Infrastructure Support</sub>
|
||||
</td>
|
||||
</tr><tr></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -6,33 +6,19 @@ NEXTAUTH_URL=http://localhost:3000
|
||||
# You can use `openssl rand -hex 32` or
|
||||
# https://generate-secret.vercel.app/32 to generate a secret.
|
||||
# Note: Changing a secret may invalidate existing sessions
|
||||
# and/or verification tokens.
|
||||
SECRET=secret
|
||||
# and/or verificaion tokens.
|
||||
SECRET=
|
||||
|
||||
AUTH0_ID=
|
||||
AUTH0_DOMAIN=
|
||||
AUTH0_SECRET=
|
||||
AUTH0_ISSUER=
|
||||
|
||||
KEYCLOAK_ID=
|
||||
KEYCLOAK_SECRET=
|
||||
KEYCLOAK_ISSUER=
|
||||
|
||||
IDS4_ID=
|
||||
IDS4_SECRET=
|
||||
IDS4_ISSUER=
|
||||
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
|
||||
TWITCH_ID=
|
||||
TWITCH_SECRET=
|
||||
|
||||
TWITTER_ID=
|
||||
TWITTER_SECRET=
|
||||
|
||||
LINE_ID=
|
||||
LINE_SECRET=
|
||||
|
||||
# Example configuration for a Gmail account (will need SMTP enabled)
|
||||
EMAIL_SERVER=smtps://user@gmail.com:password@smtp.gmail.com:465
|
||||
EMAIL_FROM=user@gmail.com
|
||||
@@ -42,4 +28,4 @@ EMAIL_FROM=user@gmail.com
|
||||
# Postgres: DATABASE_URL=postgres://nextauth:password@127.0.0.1:5432/nextauth?synchronize=true
|
||||
# MySQL: DATABASE_URL=mysql://nextauth:password@127.0.0.1:3306/nextauth?synchronize=true
|
||||
# MongoDB: DATABASE_URL=mongodb://nextauth:password@127.0.0.1:27017/nextauth?synchronize=true
|
||||
DATABASE_URL=
|
||||
DATABASE_URL=
|
||||
@@ -1 +0,0 @@
|
||||
16
|
||||
@@ -1,18 +1,17 @@
|
||||
import { signIn } from "next-auth/react"
|
||||
import { signIn } from 'next-auth/client'
|
||||
|
||||
export default function AccessDenied() {
|
||||
export default function AccessDenied () {
|
||||
return (
|
||||
<>
|
||||
<h1>Access Denied</h1>
|
||||
<p>
|
||||
<a
|
||||
href="/api/auth/signin"
|
||||
href='/api/auth/signin'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signIn()
|
||||
}}
|
||||
>
|
||||
You must be signed in to view this page
|
||||
>You must be signed in to view this page
|
||||
</a>
|
||||
</p>
|
||||
</>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import Link from "next/link"
|
||||
import { signIn, signOut, useSession } from "next-auth/react"
|
||||
import styles from "./header.module.css"
|
||||
import Link from 'next/link'
|
||||
import { signIn, signOut, useSession } from 'next-auth/client'
|
||||
import styles from './header.module.css'
|
||||
|
||||
// The approach used in this component shows how to built a sign in and sign out
|
||||
// component that works on pages which support both client and server side
|
||||
// rendering, and avoids any flash incorrect content on initial page load.
|
||||
export default function Header() {
|
||||
const { data: session, status } = useSession()
|
||||
export default function Header () {
|
||||
const [session, loading] = useSession()
|
||||
|
||||
return (
|
||||
<header>
|
||||
<noscript>
|
||||
<style>{".nojs-show { opacity: 1; top: 0; }"}</style>
|
||||
<style>{'.nojs-show { opacity: 1; top: 0; }'}</style>
|
||||
</noscript>
|
||||
<div className={styles.signedInStatus}>
|
||||
<p
|
||||
className={`nojs-show ${
|
||||
!session && status === "loading" ? styles.loading : styles.loaded
|
||||
!session && loading ? styles.loading : styles.loaded
|
||||
}`}
|
||||
>
|
||||
{!session && (
|
||||
@@ -25,7 +25,7 @@ export default function Header() {
|
||||
You are not signed in
|
||||
</span>
|
||||
<a
|
||||
href="/api/auth/signin"
|
||||
href='/api/auth/signin'
|
||||
className={styles.buttonPrimary}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -39,16 +39,18 @@ export default function Header() {
|
||||
{session && (
|
||||
<>
|
||||
{session.user.image && (
|
||||
<img src={session.user.image} className={styles.avatar} />
|
||||
<span
|
||||
style={{ backgroundImage: `url(${session.user.image})` }}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.signedInText}>
|
||||
<small>Signed in as</small>
|
||||
<br />
|
||||
<strong>{session.user.email} </strong>
|
||||
{session.user.name ? `(${session.user.name})` : null}
|
||||
<strong>{session.user.email || session.user.name}</strong>
|
||||
</span>
|
||||
<a
|
||||
href="/api/auth/signout"
|
||||
href='/api/auth/signout'
|
||||
className={styles.button}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
@@ -64,42 +66,42 @@ export default function Header() {
|
||||
<nav>
|
||||
<ul className={styles.navItems}>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/">
|
||||
<Link href='/'>
|
||||
<a>Home</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/client">
|
||||
<Link href='/client'>
|
||||
<a>Client</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/server">
|
||||
<Link href='/server'>
|
||||
<a>Server</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/protected">
|
||||
<Link href='/protected'>
|
||||
<a>Protected</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/protected-ssr">
|
||||
<Link href='/protected-ssr'>
|
||||
<a>Protected(SSR)</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/api-example">
|
||||
<Link href='/api-example'>
|
||||
<a>API</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/credentials">
|
||||
<Link href='/credentials'>
|
||||
<a>Credentials</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href="/email">
|
||||
<Link href='/email'>
|
||||
<a>Email</a>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
5
app/jsconfig.json
Normal file
5
app/jsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
}
|
||||
}
|
||||
4
app/next-env.d.ts
vendored
4
app/next-env.d.ts
vendored
@@ -1,6 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
|
||||
@@ -6,19 +6,14 @@ module.exports = {
|
||||
...config.resolve,
|
||||
alias: {
|
||||
...config.resolve.alias,
|
||||
react: path.join(process.cwd(), "node_modules/react"),
|
||||
nodemailer: path.join(process.cwd(), "node_modules/nodemailer"),
|
||||
"react-dom": path.join(process.cwd(), "node_modules/react-dom"),
|
||||
"react/jsx-dev-runtime": path.join(
|
||||
process.cwd(),
|
||||
"node_modules/react/jsx-dev-runtime"
|
||||
),
|
||||
"next-auth$": path.join(process.cwd(), "next-auth/server"),
|
||||
"next-auth/client$": path.join(process.cwd(), "next-auth/client"),
|
||||
"next-auth/jwt$": path.join(process.cwd(), "next-auth/lib/jwt"),
|
||||
"next-auth/adapters": path.join(process.cwd(), "next-auth/adapters"),
|
||||
"next-auth/providers": path.join(process.cwd(), "next-auth/providers"),
|
||||
},
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
experimental: {
|
||||
externalDir: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,30 +4,22 @@
|
||||
"description": "NextAuth.js Developer app",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rm -rf .next",
|
||||
"dev": "npm-run-all --parallel dev:next watch:css copy:css ",
|
||||
"dev": "npm-run-all --parallel copy:app dev:css dev:next",
|
||||
"dev:next": "next dev",
|
||||
"copy:css": "cpx \"../css/**/*\" src/css --watch",
|
||||
"copy:app": "cpx \"../src/**/*\" next-auth --watch",
|
||||
"copy:css": "cpx \"../dist/css/**/*\" dist/css --watch",
|
||||
"watch:css": "cd .. && npm run watch:css",
|
||||
"start": "next start",
|
||||
"email": "npx fake-smtp-server",
|
||||
"start:email": "email"
|
||||
"dev:css": "npm-run-all --parallel watch:css copy:css",
|
||||
"start": "next start"
|
||||
},
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@next-auth/fauna-adapter": "0.2.2-next.4",
|
||||
"@next-auth/prisma-adapter": "0.5.2-next.5",
|
||||
"@prisma/client": "^2.29.1",
|
||||
"fake-smtp-server": "^0.8.0",
|
||||
"faunadb": "^4.3.0",
|
||||
"next": "^11.1.0",
|
||||
"nodemailer": "^6.6.3",
|
||||
"next": "^11.0.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cpx": "^1.5.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prisma": "^2.29.1"
|
||||
"npm-run-all": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
import { Provider } from "next-auth/client"
|
||||
import "./styles.css"
|
||||
|
||||
// Use the <Provider> to improve performance and allow components that call
|
||||
// `useSession()` anywhere in your application to access the `session` object.
|
||||
export default function App({ Component, pageProps }) {
|
||||
return (
|
||||
<SessionProvider session={pageProps.session}>
|
||||
<Provider
|
||||
// Provider options are not required but can be useful in situations where
|
||||
// you have a short session maxAge time. Shown here with default values.
|
||||
options={{
|
||||
// Client Max Age controls how often the useSession in the client should
|
||||
// contact the server to sync the session state. Value in seconds.
|
||||
// e.g.
|
||||
// * 0 - Disabled (always use cache value)
|
||||
// * 60 - Sync session state with server if it's older than 60 seconds
|
||||
clientMaxAge: 0,
|
||||
// Keep Alive tells windows / tabs that are signed in to keep sending
|
||||
// a keep alive request (which extends the current session expiry) to
|
||||
// prevent sessions in open windows from expiring. Value in seconds.
|
||||
//
|
||||
// Note: If a session has expired when keep alive is triggered, all open
|
||||
// windows / tabs will be updated to reflect the user is signed out.
|
||||
keepAlive: 0,
|
||||
}}
|
||||
session={pageProps.session}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</SessionProvider>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
91
app/pages/api/auth/[...nextauth].js
Normal file
91
app/pages/api/auth/[...nextauth].js
Normal file
@@ -0,0 +1,91 @@
|
||||
import NextAuth from "next-auth"
|
||||
import EmailProvider from "next-auth/providers/email"
|
||||
import GitHubProvider from "next-auth/providers/github"
|
||||
import Auth0Provider from "next-auth/providers/auth0"
|
||||
import TwitterProvider from "next-auth/providers/twitter"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
|
||||
// import Adapters from 'next-auth/adapters'
|
||||
// import { PrismaClient } from '@prisma/client'
|
||||
// const prisma = new PrismaClient()
|
||||
|
||||
export default NextAuth({
|
||||
// Used to debug https://github.com/nextauthjs/next-auth/issues/1664
|
||||
// cookies: {
|
||||
// csrfToken: {
|
||||
// name: 'next-auth.csrf-token',
|
||||
// options: {
|
||||
// httpOnly: true,
|
||||
// sameSite: 'none',
|
||||
// path: '/',
|
||||
// secure: true
|
||||
// }
|
||||
// },
|
||||
// pkceCodeVerifier: {
|
||||
// name: 'next-auth.pkce.code_verifier',
|
||||
// options: {
|
||||
// httpOnly: true,
|
||||
// sameSite: 'none',
|
||||
// path: '/',
|
||||
// secure: true
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
providers: [
|
||||
EmailProvider({
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
}),
|
||||
GitHubProvider({
|
||||
clientId: process.env.GITHUB_ID,
|
||||
clientSecret: process.env.GITHUB_SECRET,
|
||||
}),
|
||||
Auth0Provider({
|
||||
clientId: process.env.AUTH0_ID,
|
||||
clientSecret: process.env.AUTH0_SECRET,
|
||||
domain: process.env.AUTH0_DOMAIN,
|
||||
// Used to debug https://github.com/nextauthjs/next-auth/issues/1664
|
||||
// protection: ["pkce", "state"],
|
||||
// authorizationParams: {
|
||||
// response_mode: 'form_post'
|
||||
// }
|
||||
protection: "pkce",
|
||||
}),
|
||||
TwitterProvider({
|
||||
clientId: process.env.TWITTER_ID,
|
||||
clientSecret: process.env.TWITTER_SECRET,
|
||||
}),
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials, req) {
|
||||
if (credentials.password === "password") {
|
||||
return {
|
||||
id: 1,
|
||||
name: "Fill Murray",
|
||||
email: "bill@fillmurray.com",
|
||||
image: "https://www.fillmurray.com/64/64",
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
}),
|
||||
],
|
||||
jwt: {
|
||||
encryption: true,
|
||||
secret: process.env.SECRET,
|
||||
},
|
||||
debug: false,
|
||||
theme: "auto",
|
||||
|
||||
// Default Database Adapter (TypeORM)
|
||||
// database: process.env.DATABASE_URL
|
||||
|
||||
// Prisma Database Adapter
|
||||
// To configure this app to use the schema in `prisma/schema.prisma` run:
|
||||
// npx prisma generate
|
||||
// npx prisma migrate dev
|
||||
// adapter: Adapters.Prisma.Adapter({ prisma })
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import NextAuth, { NextAuthOptions } from "next-auth"
|
||||
import EmailProvider from "next-auth/providers/email"
|
||||
import GitHubProvider from "next-auth/providers/github"
|
||||
import Auth0Provider from "next-auth/providers/auth0"
|
||||
import KeycloakProvider from "next-auth/providers/keycloak"
|
||||
import TwitterProvider from "next-auth/providers/twitter"
|
||||
import CredentialsProvider from "next-auth/providers/credentials"
|
||||
import IDS4Provider from "next-auth/providers/identity-server4"
|
||||
import Twitch from "next-auth/providers/twitch"
|
||||
import GoogleProvider from "next-auth/providers/google"
|
||||
import FacebookProvider from "next-auth/providers/facebook"
|
||||
import FoursquareProvider from "next-auth/providers/foursquare"
|
||||
// import FreshbooksProvider from "next-auth/providers/freshbooks"
|
||||
import GitlabProvider from "next-auth/providers/gitlab"
|
||||
import InstagramProvider from "next-auth/providers/instagram"
|
||||
import LineProvider from "next-auth/providers/line"
|
||||
import LinkedInProvider from "next-auth/providers/linkedin"
|
||||
import MailchimpProvider from "next-auth/providers/mailchimp"
|
||||
import DiscordProvider from "next-auth/providers/discord"
|
||||
import AzureADProvider from "next-auth/providers/azure-ad"
|
||||
import SpotifyProvider from "next-auth/providers/spotify"
|
||||
import CognitoProvider from "next-auth/providers/cognito"
|
||||
import SlackProvider from "next-auth/providers/slack"
|
||||
import Okta from "next-auth/providers/okta"
|
||||
import AzureB2C from "next-auth/providers/azure-ad-b2c"
|
||||
|
||||
// import { PrismaAdapter } from "@next-auth/prisma-adapter"
|
||||
// import { PrismaClient } from "@prisma/client"
|
||||
// const prisma = new PrismaClient()
|
||||
// const adapter = PrismaAdapter(prisma)
|
||||
|
||||
// import { Client as FaunaClient } from "faunadb"
|
||||
// import { FaunaAdapter } from "@next-auth/fauna-adapter"
|
||||
|
||||
// const client = new FaunaClient({
|
||||
// secret: process.env.FAUNA_SECRET,
|
||||
// domain: process.env.FAUNA_DOMAIN,
|
||||
// })
|
||||
// const adapter = FaunaAdapter(client)
|
||||
export const authOptions: NextAuthOptions = {
|
||||
// adapter,
|
||||
providers: [
|
||||
// E-mail
|
||||
// Start fake e-mail server with `npm run start:email`
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
auth: null,
|
||||
secure: false,
|
||||
port: 1025,
|
||||
tls: { rejectUnauthorized: false },
|
||||
},
|
||||
}),
|
||||
// Credentials
|
||||
CredentialsProvider({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (credentials.password === "pw") {
|
||||
return {
|
||||
name: "Fill Murray",
|
||||
email: "bill@fillmurray.com",
|
||||
image: "https://www.fillmurray.com/64/64",
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
}),
|
||||
// OAuth 1
|
||||
TwitterProvider({
|
||||
clientId: process.env.TWITTER_ID,
|
||||
clientSecret: process.env.TWITTER_SECRET,
|
||||
}),
|
||||
// OAuth 2 / OIDC
|
||||
GitHubProvider({
|
||||
clientId: process.env.GITHUB_ID,
|
||||
clientSecret: process.env.GITHUB_SECRET,
|
||||
}),
|
||||
Auth0Provider({
|
||||
clientId: process.env.AUTH0_ID,
|
||||
clientSecret: process.env.AUTH0_SECRET,
|
||||
issuer: process.env.AUTH0_ISSUER,
|
||||
}),
|
||||
KeycloakProvider({
|
||||
clientId: process.env.KEYCLOAK_ID,
|
||||
clientSecret: process.env.KEYCLOAK_SECRET,
|
||||
issuer: process.env.KEYCLOAK_ISSUER,
|
||||
}),
|
||||
Twitch({
|
||||
clientId: process.env.TWITCH_ID,
|
||||
clientSecret: process.env.TWITCH_SECRET,
|
||||
}),
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_ID,
|
||||
clientSecret: process.env.GOOGLE_SECRET,
|
||||
}),
|
||||
FacebookProvider({
|
||||
clientId: process.env.FACEBOOK_ID,
|
||||
clientSecret: process.env.FACEBOOK_SECRET,
|
||||
}),
|
||||
FoursquareProvider({
|
||||
clientId: process.env.FOURSQUARE_ID,
|
||||
clientSecret: process.env.FOURSQUARE_SECRET,
|
||||
}),
|
||||
// FreshbooksProvider({
|
||||
// clientId: process.env.FRESHBOOKS_ID,
|
||||
// clientSecret: process.env.FRESHBOOKS_SECRET,
|
||||
// }),
|
||||
GitlabProvider({
|
||||
clientId: process.env.GITLAB_ID,
|
||||
clientSecret: process.env.GITLAB_SECRET,
|
||||
}),
|
||||
InstagramProvider({
|
||||
clientId: process.env.INSTAGRAM_ID,
|
||||
clientSecret: process.env.INSTAGRAM_SECRET,
|
||||
}),
|
||||
LineProvider({
|
||||
clientId: process.env.LINE_ID,
|
||||
clientSecret: process.env.LINE_SECRET,
|
||||
}),
|
||||
LinkedInProvider({
|
||||
clientId: process.env.LINKEDIN_ID,
|
||||
clientSecret: process.env.LINKEDIN_SECRET,
|
||||
}),
|
||||
MailchimpProvider({
|
||||
clientId: process.env.MAILCHIMP_ID,
|
||||
clientSecret: process.env.MAILCHIMP_SECRET,
|
||||
}),
|
||||
IDS4Provider({
|
||||
clientId: process.env.IDS4_ID,
|
||||
clientSecret: process.env.IDS4_SECRET,
|
||||
issuer: process.env.IDS4_ISSUER,
|
||||
}),
|
||||
DiscordProvider({
|
||||
clientId: process.env.DISCORD_ID,
|
||||
clientSecret: process.env.DISCORD_SECRET,
|
||||
}),
|
||||
AzureADProvider({
|
||||
clientId: process.env.AZURE_AD_CLIENT_ID,
|
||||
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
|
||||
tenantId: process.env.AZURE_AD_TENANT_ID,
|
||||
profilePhotoSize: 48,
|
||||
}),
|
||||
SpotifyProvider({
|
||||
clientId: process.env.SPOTIFY_ID,
|
||||
clientSecret: process.env.SPOTIFY_SECRET,
|
||||
}),
|
||||
CognitoProvider({
|
||||
clientId: process.env.COGNITO_ID,
|
||||
clientSecret: process.env.COGNITO_SECRET,
|
||||
issuer: process.env.COGNITO_ISSUER,
|
||||
}),
|
||||
Okta({
|
||||
clientId: process.env.OKTA_ID,
|
||||
clientSecret: process.env.OKTA_SECRET,
|
||||
issuer: process.env.OKTA_ISSUER,
|
||||
}),
|
||||
SlackProvider({
|
||||
clientId: process.env.SLACK_ID,
|
||||
clientSecret: process.env.SLACK_SECRET,
|
||||
}),
|
||||
AzureB2C({
|
||||
clientId: process.env.AZURE_B2C_ID,
|
||||
clientSecret: process.env.AZURE_B2C_SECRET,
|
||||
tenantId: process.env.AZURE_B2C_TENANT_ID,
|
||||
primaryUserFlow: process.env.AZURE_B2C_PRIMARY_USER_FLOW,
|
||||
}),
|
||||
],
|
||||
jwt: {
|
||||
encryption: true,
|
||||
secret: process.env.SECRET,
|
||||
},
|
||||
debug: true,
|
||||
theme: {
|
||||
colorScheme: "auto",
|
||||
logo: "https://next-auth.js.org/img/logo/logo-sm.png",
|
||||
brandColor: "#1786fb",
|
||||
},
|
||||
}
|
||||
|
||||
export default NextAuth(authOptions)
|
||||
@@ -1,5 +1,5 @@
|
||||
// This is an example of how to read a JSON Web Token from an API route
|
||||
import jwt from "next-auth/jwt"
|
||||
import jwt from 'next-auth/jwt'
|
||||
|
||||
const secret = process.env.SECRET
|
||||
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
// This is an example of to protect an API route
|
||||
import { getSession } from "next-auth/react"
|
||||
import { getSession } from 'next-auth/client'
|
||||
|
||||
export default async (req, res) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (session) {
|
||||
res.send({
|
||||
content:
|
||||
"This is protected content. You can access this content because you are signed in.",
|
||||
})
|
||||
res.send({ content: 'This is protected content. You can access this content because you are signed in.' })
|
||||
} else {
|
||||
res.send({
|
||||
error: "You must be sign in to view the protected content on this page.",
|
||||
})
|
||||
res.send({ error: 'You must be sign in to view the protected content on this page.' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// This is an example of how to access a session from an API route
|
||||
import { getSession } from "next-auth/react"
|
||||
import { getSession } from 'next-auth/client'
|
||||
|
||||
export default async (req, res) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
import * as React from "react"
|
||||
import { signIn, signOut, useSession } from "next-auth/react"
|
||||
import Layout from "components/layout"
|
||||
import * as React from 'react'
|
||||
import { signIn, signOut, useSession } from 'next-auth/client'
|
||||
import Layout from 'components/layout'
|
||||
|
||||
export default function Page() {
|
||||
export default function Page () {
|
||||
const [response, setResponse] = React.useState(null)
|
||||
const handleLogin = (options) => async () => {
|
||||
if (options.redirect) {
|
||||
return signIn("credentials", options)
|
||||
return signIn('credentials', options)
|
||||
}
|
||||
const response = await signIn("credentials", options)
|
||||
const response = await signIn('credentials', options)
|
||||
setResponse(response)
|
||||
}
|
||||
|
||||
@@ -21,22 +21,18 @@ export default function Page() {
|
||||
setResponse(response)
|
||||
}
|
||||
|
||||
const { data: session } = useSession()
|
||||
const [session] = useSession()
|
||||
|
||||
if (session) {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Test different flows for Credentials logout</h1>
|
||||
<span className="spacing">Default:</span>
|
||||
<button onClick={handleLogout({ redirect: true })}>Logout</button>
|
||||
<br />
|
||||
<span className="spacing">No redirect:</span>
|
||||
<button onClick={handleLogout({ redirect: false })}>Logout</button>
|
||||
<br />
|
||||
<span className='spacing'>Default:</span>
|
||||
<button onClick={handleLogout({ redirect: true })}>Logout</button><br />
|
||||
<span className='spacing'>No redirect:</span>
|
||||
<button onClick={handleLogout({ redirect: false })}>Logout</button><br />
|
||||
<p>Response:</p>
|
||||
<pre style={{ background: "#eee", padding: 16 }}>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -44,24 +40,14 @@ export default function Page() {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Test different flows for Credentials login</h1>
|
||||
<span className="spacing">Default:</span>
|
||||
<button onClick={handleLogin({ redirect: true, password: "password" })}>
|
||||
Login
|
||||
</button>
|
||||
<br />
|
||||
<span className="spacing">No redirect:</span>
|
||||
<button onClick={handleLogin({ redirect: false, password: "password" })}>
|
||||
Login
|
||||
</button>
|
||||
<br />
|
||||
<span className="spacing">No redirect, wrong password:</span>
|
||||
<button onClick={handleLogin({ redirect: false, password: "" })}>
|
||||
Login
|
||||
</button>
|
||||
<span className='spacing'>Default:</span>
|
||||
<button onClick={handleLogin({ redirect: true, password: 'password' })}>Login</button><br />
|
||||
<span className='spacing'>No redirect:</span>
|
||||
<button onClick={handleLogin({ redirect: false, password: 'password' })}>Login</button><br />
|
||||
<span className='spacing'>No redirect, wrong password:</span>
|
||||
<button onClick={handleLogin({ redirect: false, password: '' })}>Login</button>
|
||||
<p>Response:</p>
|
||||
<pre style={{ background: "#eee", padding: 16 }}>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
import * as React from "react"
|
||||
import { signIn, signOut, useSession } from "next-auth/react"
|
||||
import Layout from "components/layout"
|
||||
import * as React from 'react'
|
||||
import { signIn, signOut, useSession } from 'next-auth/client'
|
||||
import Layout from 'components/layout'
|
||||
|
||||
export default function Page() {
|
||||
export default function Page () {
|
||||
const [response, setResponse] = React.useState(null)
|
||||
const [email, setEmail] = React.useState("")
|
||||
const [email, setEmail] = React.useState('')
|
||||
|
||||
const handleChange = (event) => {
|
||||
setEmail(event.target.value)
|
||||
@@ -15,9 +15,9 @@ export default function Page() {
|
||||
event.preventDefault()
|
||||
|
||||
if (options.redirect) {
|
||||
return signIn("email", options)
|
||||
return signIn('email', options)
|
||||
}
|
||||
const response = await signIn("email", options)
|
||||
const response = await signIn('email', options)
|
||||
setResponse(response)
|
||||
}
|
||||
|
||||
@@ -29,22 +29,18 @@ export default function Page() {
|
||||
setResponse(response)
|
||||
}
|
||||
|
||||
const { data: session } = useSession()
|
||||
const [session] = useSession()
|
||||
|
||||
if (session) {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Test different flows for Email logout</h1>
|
||||
<span className="spacing">Default:</span>
|
||||
<button onClick={handleLogout({ redirect: true })}>Logout</button>
|
||||
<br />
|
||||
<span className="spacing">No redirect:</span>
|
||||
<button onClick={handleLogout({ redirect: false })}>Logout</button>
|
||||
<br />
|
||||
<span className='spacing'>Default:</span>
|
||||
<button onClick={handleLogout({ redirect: true })}>Logout</button><br />
|
||||
<span className='spacing'>No redirect:</span>
|
||||
<button onClick={handleLogout({ redirect: false })}>Logout</button><br />
|
||||
<p>Response:</p>
|
||||
<pre style={{ background: "#eee", padding: 16 }}>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -52,29 +48,20 @@ export default function Page() {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Test different flows for Email login</h1>
|
||||
<label className="spacing">
|
||||
Email address:{" "}
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</label>
|
||||
<br />
|
||||
<label className='spacing'>
|
||||
Email address:{' '}
|
||||
<input type='text' id='email' name='email' value={email} onChange={handleChange} />
|
||||
</label><br />
|
||||
<form onSubmit={handleLogin({ redirect: true, email })}>
|
||||
<span className="spacing">Default:</span>
|
||||
<button type="submit">Sign in with Email</button>
|
||||
<span className='spacing'>Default:</span>
|
||||
<button type='submit'>Sign in with Email</button>
|
||||
</form>
|
||||
<form onSubmit={handleLogin({ redirect: false, email })}>
|
||||
<span className="spacing">No redirect:</span>
|
||||
<button type="submit">Sign in with Email</button>
|
||||
<span className='spacing'>No redirect:</span>
|
||||
<button type='submit'>Sign in with Email</button>
|
||||
</form>
|
||||
<p>Response:</p>
|
||||
<pre style={{ background: "#eee", padding: 16 }}>
|
||||
{JSON.stringify(response, null, 2)}
|
||||
</pre>
|
||||
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,48 +1,37 @@
|
||||
// This is an example of how to protect content using server rendering
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "./api/auth/[...nextauth]"
|
||||
import Layout from "../components/layout"
|
||||
import AccessDenied from "../components/access-denied"
|
||||
import { getSession } from 'next-auth/client'
|
||||
import Layout from '../components/layout'
|
||||
import AccessDenied from '../components/access-denied'
|
||||
|
||||
export default function Page({ content, session }) {
|
||||
export default function Page ({ content, session }) {
|
||||
// If no session exists, display access denied message
|
||||
if (!session) {
|
||||
return (
|
||||
<Layout>
|
||||
<AccessDenied />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
if (!session) { return <Layout><AccessDenied /></Layout> }
|
||||
|
||||
// If session exists, display content
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Protected Page</h1>
|
||||
<p>
|
||||
<strong>{content}</strong>
|
||||
</p>
|
||||
<p><strong>{content}</strong></p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export async function getServerSideProps(context) {
|
||||
const session = await getServerSession(context, authOptions)
|
||||
export async function getServerSideProps (context) {
|
||||
const session = await getSession(context)
|
||||
let content = null
|
||||
|
||||
if (session) {
|
||||
const hostname = process.env.NEXTAUTH_URL || "http://localhost:3000"
|
||||
const hostname = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
const options = { headers: { cookie: context.req.headers.cookie } }
|
||||
const res = await fetch(`${hostname}/api/examples/protected`, options)
|
||||
const json = await res.json()
|
||||
if (json.content) {
|
||||
content = json.content
|
||||
}
|
||||
if (json.content) { content = json.content }
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
session,
|
||||
content,
|
||||
},
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,33 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import Layout from "../components/layout"
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSession } from 'next-auth/client'
|
||||
import Layout from '../components/layout'
|
||||
import AccessDenied from '../components/access-denied'
|
||||
|
||||
export default function Page() {
|
||||
const { status } = useSession({
|
||||
required: true,
|
||||
})
|
||||
export default function Page () {
|
||||
const [session, loading] = useSession()
|
||||
const [content, setContent] = useState()
|
||||
|
||||
// Fetch content from protected route
|
||||
useEffect(() => {
|
||||
if (status === "loading") return
|
||||
const fetchData = async () => {
|
||||
const res = await fetch("/api/examples/protected")
|
||||
const res = await fetch('/api/examples/protected')
|
||||
const json = await res.json()
|
||||
if (json.content) {
|
||||
setContent(json.content)
|
||||
}
|
||||
if (json.content) { setContent(json.content) }
|
||||
}
|
||||
fetchData()
|
||||
}, [status])
|
||||
}, [session])
|
||||
|
||||
if (status === "loading") return <Layout>Loading...</Layout>
|
||||
// When rendering client side don't display anything until loading is complete
|
||||
if (typeof window !== 'undefined' && loading) return null
|
||||
|
||||
// If no session exists, display access denied message
|
||||
if (!session) { return <Layout><AccessDenied /></Layout> }
|
||||
|
||||
// If session exists, display content
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Protected Page</h1>
|
||||
<p>
|
||||
<strong>{content}</strong>
|
||||
</p>
|
||||
<p><strong>{content}</strong></p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getSession } from "next-auth/react"
|
||||
import Layout from "../components/layout"
|
||||
import { getSession } from 'next-auth/client'
|
||||
import Layout from '../components/layout'
|
||||
|
||||
export default function Page() {
|
||||
export default function Page () {
|
||||
// As this page uses Server Side Rendering, the `session` will be already
|
||||
// populated on render without needing to go through a loading stage.
|
||||
// This is possible because of the shared context configured in `_app.js` that
|
||||
@@ -11,31 +11,27 @@ export default function Page() {
|
||||
<Layout>
|
||||
<h1>Server Side Rendering</h1>
|
||||
<p>
|
||||
This page uses the universal <strong>getSession()</strong> method in{" "}
|
||||
<strong>getServerSideProps()</strong>.
|
||||
This page uses the universal <strong>getSession()</strong> method in <strong>getServerSideProps()</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Using <strong>getSession()</strong> in{" "}
|
||||
<strong>getServerSideProps()</strong> is the recommended approach if you
|
||||
need to support Server Side Rendering with authentication.
|
||||
Using <strong>getSession()</strong> in <strong>getServerSideProps()</strong> is the recommended approach if you need to
|
||||
support Server Side Rendering with authentication.
|
||||
</p>
|
||||
<p>
|
||||
The advantage of Server Side Rendering is this page does not require
|
||||
client side JavaScript.
|
||||
The advantage of Server Side Rendering is this page does not require client side JavaScript.
|
||||
</p>
|
||||
<p>
|
||||
The disadvantage of Server Side Rendering is that this page is slower to
|
||||
render.
|
||||
The disadvantage of Server Side Rendering is that this page is slower to render.
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the `session` prop to use sessions with Server Side Rendering
|
||||
export async function getServerSideProps(context) {
|
||||
export async function getServerSideProps (context) {
|
||||
return {
|
||||
props: {
|
||||
session: await getSession(context),
|
||||
},
|
||||
session: await getSession(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,63 @@
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = "file:./dev.db"
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
refresh_token String?
|
||||
access_token String?
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String?
|
||||
session_state String?
|
||||
oauth_token_secret String?
|
||||
oauth_token String?
|
||||
id Int @default(autoincrement()) @id
|
||||
compoundId String @unique @map(name: "compound_id")
|
||||
userId Int @map(name: "user_id")
|
||||
providerType String @map(name: "provider_type")
|
||||
providerId String @map(name: "provider_id")
|
||||
providerAccountId String @map(name: "provider_account_id")
|
||||
refreshToken String? @map(name: "refresh_token")
|
||||
accessToken String? @map(name: "access_token")
|
||||
accessTokenExpires DateTime? @map(name: "access_token_expires")
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
@@index([providerAccountId], name: "providerAccountId")
|
||||
@@index([providerId], name: "providerId")
|
||||
@@index([userId], name: "userId")
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map(name: "accounts")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
sessionToken String @unique
|
||||
userId String
|
||||
id Int @default(autoincrement()) @id
|
||||
userId Int @map(name: "user_id")
|
||||
expires DateTime
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
sessionToken String @unique @map(name: "session_token")
|
||||
accessToken String @unique @map(name: "access_token")
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
@@map(name: "sessions")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
id Int @default(autoincrement()) @id
|
||||
name String?
|
||||
email String? @unique
|
||||
emailVerified DateTime?
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
image String?
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
model VerificationRequest {
|
||||
id Int @default(autoincrement()) @id
|
||||
identifier String
|
||||
token String @unique
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @default(now()) @map(name: "updated_at")
|
||||
|
||||
@@unique([identifier, token])
|
||||
}
|
||||
@@map(name: "verification_requests")
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"next-auth": [ "../src" ],
|
||||
"next-auth/*": [ "../src/*" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -2,55 +2,32 @@
|
||||
// https://nextjs.org/docs/getting-started#system-requirements
|
||||
// https://nextjs.org/docs/basic-features/supported-browsers-features
|
||||
|
||||
module.exports = (api) => {
|
||||
const isTest = api.env("test")
|
||||
if (isTest) {
|
||||
return {
|
||||
module.exports = {
|
||||
presets: [["@babel/preset-env", { targets: { node: "10.13" } }]],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-optional-catch-binding",
|
||||
"@babel/plugin-transform-runtime",
|
||||
],
|
||||
comments: false,
|
||||
overrides: [
|
||||
{
|
||||
test: ["../src/client/**"],
|
||||
presets: [["@babel/preset-env", { targets: { ie: "11" } }]],
|
||||
},
|
||||
{
|
||||
test: ["../src/server/pages/**"],
|
||||
presets: ["preact"],
|
||||
},
|
||||
{
|
||||
test: ["../src/**/*.test.js"],
|
||||
presets: [
|
||||
"@babel/preset-env",
|
||||
["@babel/preset-react", { runtime: "automatic" }],
|
||||
["@babel/preset-typescript", { isTSX: true, allExtensions: true }],
|
||||
[
|
||||
"@babel/preset-react",
|
||||
{
|
||||
runtime: "automatic",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { node: 12 } }],
|
||||
"@babel/preset-typescript",
|
||||
],
|
||||
plugins: [
|
||||
"@babel/plugin-proposal-optional-catch-binding",
|
||||
"@babel/plugin-transform-runtime",
|
||||
],
|
||||
ignore: [
|
||||
"../src/**/__tests__/**",
|
||||
"../src/adapters.ts",
|
||||
"../src/lib/types.ts",
|
||||
"../src/providers/oauth-types.ts",
|
||||
],
|
||||
comments: false,
|
||||
overrides: [
|
||||
{
|
||||
test: ["../src/react/index.tsx"],
|
||||
presets: [
|
||||
["@babel/preset-env", { targets: { ie: 11 } }],
|
||||
["@babel/preset-react", { runtime: "automatic" }],
|
||||
],
|
||||
},
|
||||
{
|
||||
test: ["../src/core/pages/*.tsx"],
|
||||
presets: ["preact"],
|
||||
plugins: [
|
||||
[
|
||||
"jsx-pragmatic",
|
||||
{
|
||||
module: "preact",
|
||||
export: "h",
|
||||
import: "h",
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
91
config/build.js
Normal file
91
config/build.js
Normal file
@@ -0,0 +1,91 @@
|
||||
const fs = require("fs-extra")
|
||||
const path = require("path")
|
||||
|
||||
const MODULE_ENTRIES = {
|
||||
SERVER: "index",
|
||||
CLIENT: "client",
|
||||
PROVIDERS: "providers",
|
||||
ADAPTERS: "adapters",
|
||||
JWT: "jwt",
|
||||
ERRORS: "errors",
|
||||
}
|
||||
|
||||
// Building submodule entries
|
||||
|
||||
const BUILD_TARGETS = {
|
||||
[`${MODULE_ENTRIES.SERVER}.js`]: "module.exports = require('./dist/server').default\n",
|
||||
[`${MODULE_ENTRIES.CLIENT}.js`]: "module.exports = require('./dist/client').default\n",
|
||||
[`${MODULE_ENTRIES.ADAPTERS}.js`]: "module.exports = require('./dist/adapters').default\n",
|
||||
[`${MODULE_ENTRIES.PROVIDERS}.js`]: "module.exports = require('./dist/providers').default\n",
|
||||
[`${MODULE_ENTRIES.JWT}.js`]: "module.exports = require('./dist/lib/jwt').default\n",
|
||||
[`${MODULE_ENTRIES.ERRORS}.js`]: "module.exports = require('./dist/lib/errors').default\n",
|
||||
}
|
||||
|
||||
Object.entries(BUILD_TARGETS).forEach(([target, content]) => {
|
||||
fs.writeFile(path.join(process.cwd(), target), content, (err) => {
|
||||
if (err) throw err
|
||||
console.log(`[build] created "${target}" in root folder`)
|
||||
})
|
||||
})
|
||||
|
||||
// Building types
|
||||
|
||||
const TYPES_TARGETS = [
|
||||
`${MODULE_ENTRIES.SERVER}.d.ts`,
|
||||
`${MODULE_ENTRIES.CLIENT}.d.ts`,
|
||||
`${MODULE_ENTRIES.ADAPTERS}.d.ts`,
|
||||
`${MODULE_ENTRIES.PROVIDERS}.d.ts`,
|
||||
`${MODULE_ENTRIES.JWT}.d.ts`,
|
||||
`${MODULE_ENTRIES.ERRORS}.d.ts`,
|
||||
"internals",
|
||||
]
|
||||
|
||||
TYPES_TARGETS.forEach((target) => {
|
||||
fs.copy(
|
||||
path.resolve("types", target),
|
||||
path.join(process.cwd(), target),
|
||||
(err) => {
|
||||
if (err) throw err
|
||||
console.log(`[build-types] copying "${target}" to root folder`)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Building providers
|
||||
|
||||
const providersDir = path.join(process.cwd(), "/src/providers")
|
||||
|
||||
const files = fs
|
||||
.readdirSync(providersDir, "utf8")
|
||||
.filter((file) => file !== "index.js")
|
||||
|
||||
let importLines = ""
|
||||
let exportLines = `export default {\n`
|
||||
files.forEach((file) => {
|
||||
const provider = fs.readFileSync(path.join(providersDir, file), "utf8")
|
||||
try {
|
||||
// NOTE: If this fails, the default export probably wasn't a named function.
|
||||
// Always use a named function as default export.
|
||||
// Eg.: export default function YourProvider ...
|
||||
const { functionName } = provider.match(
|
||||
/export default function (?<functionName>.+)\s?\(/
|
||||
).groups
|
||||
|
||||
importLines += `import ${functionName} from "./${file}"\n`
|
||||
exportLines += ` ${functionName},\n`
|
||||
} catch (error) {
|
||||
console.error(
|
||||
[
|
||||
`\nThe provider file '${file}' should have a single named default export`,
|
||||
"Example: 'export default function YourProvider'\n\n",
|
||||
].join("\n")
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
exportLines += `}\n`
|
||||
|
||||
fs.writeFile(
|
||||
path.join(process.cwd(), "src/providers/index.js"),
|
||||
[importLines, exportLines].join("\n")
|
||||
)
|
||||
@@ -1,18 +0,0 @@
|
||||
const path = require("path")
|
||||
const fs = require("fs")
|
||||
|
||||
const providersPath = path.join(process.cwd(), "/src/providers")
|
||||
|
||||
const files = fs.readdirSync(providersPath, "utf8")
|
||||
|
||||
const providers = files.map((file) => {
|
||||
const strippedProviderName = file.substring(0, file.indexOf("."))
|
||||
return `"${strippedProviderName}"`
|
||||
})
|
||||
|
||||
const result = `
|
||||
// THIS FILE IS AUTOGENERATED. DO NOT EDIT.
|
||||
export type OAuthProviderType =
|
||||
| ${providers.join("\n | ")}`
|
||||
|
||||
fs.writeFileSync(path.join(providersPath, "oauth-types.ts"), result)
|
||||
@@ -1,3 +1,2 @@
|
||||
import "regenerator-runtime/runtime"
|
||||
import "@testing-library/jest-dom"
|
||||
import "whatwg-fetch"
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
/** @type {import('@jest/types').Config.InitialOptions} */
|
||||
module.exports = {
|
||||
transform: {
|
||||
"\\.(js|jsx|ts|tsx)$": [
|
||||
"babel-jest",
|
||||
{ configFile: "./config/babel.config.js" },
|
||||
],
|
||||
"\\.js$": ["babel-jest", { configFile: "./config/babel.config.js" }],
|
||||
},
|
||||
rootDir: "../src",
|
||||
setupFilesAfterEnv: ["../config/jest-setup.js"],
|
||||
collectCoverageFrom: ["!client/__tests__/**"],
|
||||
testMatch: ["**/*.test.js"],
|
||||
coverageDirectory: "../coverage",
|
||||
testEnvironment: "jsdom",
|
||||
watchPlugins: [
|
||||
"jest-watch-typeahead/filename",
|
||||
"jest-watch-typeahead/testname",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
name: "Determine version"
|
||||
description: "Determines npm package version based on PR number and commit SHA"
|
||||
outputs:
|
||||
version:
|
||||
description: "npm package version"
|
||||
runs:
|
||||
using: "node12"
|
||||
main: "index.js"
|
||||
@@ -1,18 +0,0 @@
|
||||
const fs = require("fs-extra")
|
||||
const path = require("path")
|
||||
const core = require("@actions/core")
|
||||
|
||||
try {
|
||||
const packageJSONPath = path.join(process.cwd(), "package.json")
|
||||
const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, "utf8"))
|
||||
|
||||
const sha8 = process.env.GITHUB_SHA.substr(0, 8)
|
||||
const prNumber = process.env.PR_NUMBER
|
||||
|
||||
const packageVersion = `0.0.0-pr.${prNumber}.${sha8}`
|
||||
packageJSON.version = packageVersion
|
||||
core.setOutput("version", packageVersion)
|
||||
fs.writeFileSync(packageJSONPath, JSON.stringify(packageJSON))
|
||||
} catch (error) {
|
||||
core.setFailed(error.message)
|
||||
}
|
||||
@@ -5,13 +5,14 @@
|
||||
// To work around this issue, this script is a manual step that wraps CSS in a
|
||||
// JavaScript file that has the compiled CSS embedded in it, and exports only
|
||||
// a function that returns the CSS as a string.
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const pathToCss = path.join(__dirname, "../css/index.css")
|
||||
const css = fs.readFileSync(pathToCss, "utf8")
|
||||
const pathToCssJs = path.join(__dirname, '../dist/css/index.js')
|
||||
const pathToCss = path.join(__dirname, '../dist/css/index.css')
|
||||
|
||||
const css = fs.readFileSync(pathToCss, 'utf8')
|
||||
const cssWithEscapedQuotes = css.replace(/"/gm, '\\"')
|
||||
|
||||
const js = `module.exports = function() { return "${cssWithEscapedQuotes}" }`
|
||||
const pathToCssJs = path.join(__dirname, "../css/index.js")
|
||||
|
||||
fs.writeFileSync(pathToCssJs, js)
|
||||
|
||||
19340
package-lock.json
generated
19340
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
184
package.json
184
package.json
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "next-auth",
|
||||
"version": "0.0.0-semantically-released",
|
||||
"version": "3.29.9",
|
||||
"description": "Authentication for Next.js",
|
||||
"homepage": "https://next-auth.js.org",
|
||||
"repository": "https://github.com/nextauthjs/next-auth.git",
|
||||
"author": "Iain Collins <me@iaincollins.com>",
|
||||
"main": "index.js",
|
||||
"module": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"types": "./index.d.ts",
|
||||
"keywords": [
|
||||
"react",
|
||||
"nodejs",
|
||||
@@ -21,115 +20,115 @@
|
||||
"nextauth"
|
||||
],
|
||||
"exports": {
|
||||
".": "./index.js",
|
||||
"./jwt": "./jwt/index.js",
|
||||
"./react": "./react/index.js",
|
||||
"./core": "./core/index.js",
|
||||
"./next": "./next/index.js",
|
||||
"./client/_utils": "./client/_utils.js",
|
||||
"./providers/*": "./providers/*.js"
|
||||
".": "./dist/server/index.js",
|
||||
"./jwt": "./dist/lib/jwt.js",
|
||||
"./adapters": "./dist/adapters/index.js",
|
||||
"./client": "./dist/client/index.js",
|
||||
"./providers": "./dist/providers/index.js",
|
||||
"./providers/*": "./dist/providers/*.js",
|
||||
"./errors": "./dist/lib/errors.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "npm run build:js && npm run build:css",
|
||||
"clean": "rm -rf client css lib providers core jwt react next index.d.ts index.js adapters.d.ts",
|
||||
"build:js": "npm run clean && npm run generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"",
|
||||
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js",
|
||||
"dev:setup": "npm i && npm run generate-providers && npm run build:css && cd app && npm i",
|
||||
"build:js": "node ./config/build.js && babel --config-file ./config/babel.config.js src --out-dir dist",
|
||||
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
|
||||
"dev:setup": "npm i && npm run build:css && cd app && npm i",
|
||||
"dev": "cd app && npm run dev",
|
||||
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .",
|
||||
"watch": "npm run watch:js | npm run watch:css",
|
||||
"watch:js": "babel --config-file ./config/babel.config.js --watch src --out-dir dist",
|
||||
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
|
||||
"test": "jest --config ./config/jest.config.js",
|
||||
"test:ci": "npm run lint && npm run test -- --ci",
|
||||
"test:ci": "npm run lint && npm run test:types && npm run test -- --ci",
|
||||
"test:types": "dtslint types --onlyTestTsNext",
|
||||
"prepublishOnly": "npm run build",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"version:pr": "node ./config/version-pr",
|
||||
"generate-providers": "node ./config/generate-providers.js"
|
||||
"version:pr": "node ./config/version-pr"
|
||||
},
|
||||
"files": [
|
||||
"lib",
|
||||
"css",
|
||||
"jwt",
|
||||
"react",
|
||||
"next",
|
||||
"client",
|
||||
"providers",
|
||||
"core",
|
||||
"index.d.ts",
|
||||
"dist",
|
||||
"index.js",
|
||||
"adapters.d.ts"
|
||||
"index.d.ts",
|
||||
"providers.js",
|
||||
"providers.d.ts",
|
||||
"adapters.js",
|
||||
"adapters.d.ts",
|
||||
"client.js",
|
||||
"client.d.ts",
|
||||
"errors.js",
|
||||
"errors.d.ts",
|
||||
"jwt.js",
|
||||
"jwt.d.ts",
|
||||
"internals"
|
||||
],
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.15.4",
|
||||
"@panva/hkdf": "^1.0.0",
|
||||
"jose": "^4.1.2",
|
||||
"@babel/runtime": "^7.14.0",
|
||||
"@next-auth/prisma-legacy-adapter": "0.1.2",
|
||||
"@next-auth/typeorm-legacy-adapter": "0.1.4",
|
||||
"futoin-hkdf": "^1.3.2",
|
||||
"jose": "^1.27.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"nodemailer": "^6.4.16",
|
||||
"oauth": "^0.9.15",
|
||||
"openid-client": "^5.0.1",
|
||||
"preact": "^10.5.14",
|
||||
"preact-render-to-string": "^5.1.19",
|
||||
"uuid": "^8.3.2"
|
||||
"pkce-challenge": "^2.1.0",
|
||||
"preact": "^10.4.1",
|
||||
"preact-render-to-string": "^5.1.14",
|
||||
"querystring": "^0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"nodemailer": "^6.6.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"react": "^16.13.1 || ^17",
|
||||
"react-dom": "^16.13.1 || ^17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
"peerOptionalDependencies": {
|
||||
"mongodb": "^3.5.9",
|
||||
"mysql": "^2.18.1",
|
||||
"mssql": "^6.2.1",
|
||||
"pg": "^8.2.1",
|
||||
"@prisma/client": "^2.16.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actions/core": "^1.6.0",
|
||||
"@babel/cli": "^7.15.7",
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/plugin-proposal-optional-catch-binding": "^7.14.5",
|
||||
"@babel/plugin-transform-runtime": "^7.15.0",
|
||||
"@babel/preset-env": "^7.15.6",
|
||||
"@babel/preset-react": "^7.14.5",
|
||||
"@babel/preset-typescript": "^7.15.0",
|
||||
"@testing-library/jest-dom": "^5.14.1",
|
||||
"@testing-library/react": "^12.1.2",
|
||||
"@testing-library/react-hooks": "^7.0.2",
|
||||
"@testing-library/user-event": "^13.2.1",
|
||||
"@types/node": "^16.11.6",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/oauth": "^0.9.1",
|
||||
"@types/react": "^17.0.27",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.33.0",
|
||||
"autoprefixer": "^10.3.7",
|
||||
"babel-jest": "^27.3.0",
|
||||
"babel-plugin-jsx-pragmatic": "^1.0.2",
|
||||
"@babel/cli": "^7.8.4",
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/plugin-proposal-optional-catch-binding": "^7.14.2",
|
||||
"@babel/plugin-transform-runtime": "^7.13.15",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"@babel/preset-react": "^7.13.13",
|
||||
"@testing-library/jest-dom": "^5.12.0",
|
||||
"@testing-library/react": "^11.2.6",
|
||||
"@testing-library/user-event": "^13.1.9",
|
||||
"@types/nodemailer": "^6.4.2",
|
||||
"@types/react": "^17.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.22.0",
|
||||
"@typescript-eslint/parser": "^4.22.0",
|
||||
"autoprefixer": "^9.7.6",
|
||||
"babel-jest": "^26.6.3",
|
||||
"babel-preset-preact": "^2.0.0",
|
||||
"conventional-changelog-conventionalcommits": "4.6.1",
|
||||
"cssnano": "^5.0.8",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-config-standard-with-typescript": "^21.0.1",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jest": "^25.2.2",
|
||||
"conventional-changelog-conventionalcommits": "4.4.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"dotenv": "^8.2.0",
|
||||
"dtslint": "^4.0.8",
|
||||
"eslint": "^7.19.0",
|
||||
"eslint-config-prettier": "^8.2.0",
|
||||
"eslint-config-standard-with-typescript": "^19.0.1",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jest": "^24.3.6",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"fs-extra": "^10.0.0",
|
||||
"husky": "^7.0.2",
|
||||
"jest": "^27.3.0",
|
||||
"jest-watch-typeahead": "^1.0.0",
|
||||
"msw": "^0.35.0",
|
||||
"next": "v11.1.3-canary.0",
|
||||
"postcss-cli": "^9.0.1",
|
||||
"postcss-nested": "^5.0.6",
|
||||
"prettier": "^2.4.1",
|
||||
"pretty-quick": "^3.1.1",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"eslint-plugin-standard": "^5.0.0",
|
||||
"husky": "^6.0.0",
|
||||
"jest": "^26.6.3",
|
||||
"msw": "^0.28.2",
|
||||
"next": "^11.0.1",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"postcss-nested": "^4.2.1",
|
||||
"prettier": "^2.2.1",
|
||||
"pretty-quick": "^3.1.0",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"typescript": "^4.4.3",
|
||||
"typescript": "^4.1.3",
|
||||
"whatwg-fetch": "^3.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.19.0 || ^14.15.0 || ^16.13.0"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false
|
||||
},
|
||||
@@ -144,25 +143,18 @@
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"node_modules",
|
||||
"test",
|
||||
"next-env.d.ts",
|
||||
"types",
|
||||
"www",
|
||||
".next",
|
||||
"dist",
|
||||
"/core",
|
||||
"/react.js"
|
||||
"dist"
|
||||
],
|
||||
"globals": {
|
||||
"localStorage": "readonly",
|
||||
"location": "readonly",
|
||||
"fetch": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
"camelcase": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/strict-boolean-expressions": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
|
||||
109
src/adapters.ts
109
src/adapters.ts
@@ -1,109 +0,0 @@
|
||||
import { Account, User, Awaitable } from "."
|
||||
|
||||
export interface AdapterUser extends User {
|
||||
id: string
|
||||
emailVerified: Date | null
|
||||
}
|
||||
|
||||
export interface AdapterSession {
|
||||
id: string
|
||||
/** A randomly generated value that is used to get hold of the session. */
|
||||
sessionToken: string
|
||||
/** Used to connect the session to a particular user */
|
||||
userId: string
|
||||
expires: Date
|
||||
}
|
||||
|
||||
export interface VerificationToken {
|
||||
identifier: string
|
||||
expires: Date
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Using a custom adapter you can connect to any database backend or even several different databases.
|
||||
* Custom adapters created and maintained by our community can be found in the adapters repository.
|
||||
* Feel free to add a custom adapter from your project to the repository,
|
||||
* or even become a maintainer of a certain adapter.
|
||||
* Custom adapters can still be created and used in a project without being added to the repository.
|
||||
*
|
||||
* **Required methods**
|
||||
*
|
||||
* _(These methods are required for all sign in flows)_
|
||||
* - `createUser`
|
||||
* - `getUser`
|
||||
* - `getUserByEmail`
|
||||
* - `getUserByAccount`
|
||||
* - `linkAccount`
|
||||
* - `createSession`
|
||||
* - `getSessionAndUser`
|
||||
* - `updateSession`
|
||||
* - `deleteSession`
|
||||
* - `updateUser`
|
||||
*
|
||||
* _(Required to support email / passwordless sign in)_
|
||||
*
|
||||
* - `createVerificationToken`
|
||||
* - `useVerificationToken`
|
||||
*
|
||||
* **Unimplemented methods**
|
||||
*
|
||||
* _(These methods will be required in a future release, but are not yet invoked)_
|
||||
* - `deleteUser`
|
||||
* - `unlinkAccount`
|
||||
*
|
||||
* [Community adapters](https://github.com/nextauthjs/adapters) |
|
||||
* [Create a custom adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter)
|
||||
*/
|
||||
export interface Adapter {
|
||||
createUser: (user: Omit<AdapterUser, "id">) => Awaitable<AdapterUser>
|
||||
getUser: (id: string) => Awaitable<AdapterUser | null>
|
||||
getUserByEmail: (email: string) => Awaitable<AdapterUser | null>
|
||||
/** Using the provider id and the id of the user for a specific account, get the user. */
|
||||
getUserByAccount: (
|
||||
providerAccountId: Pick<Account, "provider" | "providerAccountId">
|
||||
) => Awaitable<AdapterUser | null>
|
||||
updateUser: (user: Partial<AdapterUser>) => Awaitable<AdapterUser>
|
||||
/** @todo Implement */
|
||||
deleteUser?: (
|
||||
userId: string
|
||||
) => Promise<void> | Awaitable<AdapterUser | null | undefined>
|
||||
linkAccount: (
|
||||
account: Account
|
||||
) => Promise<void> | Awaitable<Account | null | undefined>
|
||||
/** @todo Implement */
|
||||
unlinkAccount?: (
|
||||
providerAccountId: Pick<Account, "provider" | "providerAccountId">
|
||||
) => Promise<void> | Awaitable<Account | undefined>
|
||||
/** Creates a session for the user and returns it. */
|
||||
createSession: (session: {
|
||||
sessionToken: string
|
||||
userId: string
|
||||
expires: Date
|
||||
}) => Awaitable<AdapterSession>
|
||||
getSessionAndUser: (
|
||||
sessionToken: string
|
||||
) => Awaitable<{ session: AdapterSession; user: AdapterUser } | null>
|
||||
updateSession: (
|
||||
session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">
|
||||
) => Awaitable<AdapterSession | null | undefined>
|
||||
/**
|
||||
* Deletes a session from the database.
|
||||
* It is preferred that this method also returns the session
|
||||
* that is being deleted for logging purposes.
|
||||
*/
|
||||
deleteSession: (
|
||||
sessionToken: string
|
||||
) => Promise<void> | Awaitable<AdapterSession | null | undefined>
|
||||
createVerificationToken?: (
|
||||
verificationToken: VerificationToken
|
||||
) => Awaitable<VerificationToken | null | undefined>
|
||||
/**
|
||||
* Return verification token from the database
|
||||
* and delete it so it cannot be used again.
|
||||
*/
|
||||
useVerificationToken?: (params: {
|
||||
identifier: string
|
||||
token: string
|
||||
}) => Awaitable<VerificationToken | null>
|
||||
}
|
||||
36
src/adapters/error-handler.js
Normal file
36
src/adapters/error-handler.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { UnknownError } from "../lib/errors"
|
||||
|
||||
/**
|
||||
* Handles adapter induced errors.
|
||||
* @param {import("types/adapters").AdapterInstance} adapter
|
||||
* @param {import("types").LoggerInstance} logger
|
||||
* @return {import("types/adapters").AdapterInstance}
|
||||
*/
|
||||
export default function adapterErrorHandler(adapter, logger) {
|
||||
return Object.keys(adapter).reduce((acc, method) => {
|
||||
const name = capitalize(method)
|
||||
const code = upperSnake(name, adapter.displayName)
|
||||
|
||||
const adapterMethod = adapter[method]
|
||||
acc[method] = async (...args) => {
|
||||
try {
|
||||
logger.debug(code, ...args)
|
||||
return await adapterMethod(...args)
|
||||
} catch (error) {
|
||||
logger.error(`${code}_ERROR`, error)
|
||||
const e = new UnknownError(error)
|
||||
e.name = `${name}Error`
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
return `${s[0].toUpperCase()}${s.slice(1)}`
|
||||
}
|
||||
|
||||
function upperSnake(s, prefix = "ADAPTER") {
|
||||
return `${prefix}_${s.replace(/([A-Z])/g, "_$1")}`.toUpperCase()
|
||||
}
|
||||
10
src/adapters/index.js
Normal file
10
src/adapters/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import * as TypeORM from "./typeorm"
|
||||
import * as Prisma from "./prisma"
|
||||
|
||||
export { TypeORM, Prisma }
|
||||
|
||||
export default {
|
||||
Default: TypeORM.Adapter,
|
||||
TypeORM,
|
||||
Prisma,
|
||||
}
|
||||
6
src/adapters/prisma.js
Normal file
6
src/adapters/prisma.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
* Source code can be found at:
|
||||
* https://github.com/nextauthjs/adapters/tree/canary/packages/prisma-legacy
|
||||
*/
|
||||
|
||||
export { PrismaLegacyAdapter as Adapter } from "@next-auth/prisma-legacy-adapter"
|
||||
9
src/adapters/typeorm.js
Normal file
9
src/adapters/typeorm.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Source code can be found at:
|
||||
* https://github.com/nextauthjs/adapters/tree/canary/packages/typeorm-legacy
|
||||
*/
|
||||
|
||||
export {
|
||||
TypeORMLegacyAdapter as Adapter,
|
||||
Models,
|
||||
} from "@next-auth/typeorm-legacy-adapter"
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useState } from "react"
|
||||
import { rest } from "msw"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { server, mockSession } from "./helpers/mocks"
|
||||
import { printFetchCalls } from "./helpers/utils"
|
||||
import { SessionProvider, useSession, signOut, getSession } from "../../react"
|
||||
|
||||
const origDocumentVisibility = document.visibilityState
|
||||
const fetchSpy = jest.spyOn(global, "fetch")
|
||||
import { Provider, useSession } from ".."
|
||||
import userEvent from "@testing-library/user-event"
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
server.resetHandlers()
|
||||
changeTabVisibility(origDocumentVisibility)
|
||||
fetchSpy.mockClear()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
@@ -22,167 +19,46 @@ afterAll(() => {
|
||||
})
|
||||
|
||||
test("fetches the session once and re-uses it for different consumers", async () => {
|
||||
const sessionRouteCall = jest.fn()
|
||||
|
||||
server.use(
|
||||
rest.get("/api/auth/session", (req, res, ctx) => {
|
||||
sessionRouteCall()
|
||||
res(ctx.status(200), ctx.json(mockSession))
|
||||
})
|
||||
)
|
||||
|
||||
render(<ProviderFlow />)
|
||||
|
||||
expect(screen.getByTestId("session-1")).toHaveTextContent("loading")
|
||||
expect(screen.getByTestId("session-2")).toHaveTextContent("loading")
|
||||
await waitFor(() => {
|
||||
expect(sessionRouteCall).toHaveBeenCalledTimes(1)
|
||||
|
||||
return waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/auth/session",
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
const session1 = screen.getByTestId("session-1").textContent
|
||||
const session2 = screen.getByTestId("session-2").textContent
|
||||
const session1 = screen.getByTestId("session-consumer-1").textContent
|
||||
const session2 = screen.getByTestId("session-consumer-2").textContent
|
||||
|
||||
expect(session1).toEqual(session2)
|
||||
})
|
||||
})
|
||||
|
||||
test("when there's an existing session, it won't try to fetch a new one straightaway", async () => {
|
||||
render(<ProviderFlow session={mockSession} />)
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("will refetch the session when the browser tab becomes active again", async () => {
|
||||
render(<ProviderFlow session={mockSession} />)
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Hide the current tab
|
||||
changeTabVisibility("hidden")
|
||||
|
||||
// Given the current tab got hidden, it should not attempt to re-fetch the session
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Make the tab again visible
|
||||
changeTabVisibility("visible")
|
||||
|
||||
// Given the user made the tab visible again, now attempts to sync and re-fetch the session
|
||||
return waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/auth/session",
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("will refetch the session if told to do so programmatically from another window", async () => {
|
||||
render(<ProviderFlow session={mockSession} />)
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
|
||||
// Hide the current tab
|
||||
changeTabVisibility("hidden")
|
||||
|
||||
// Given the current tab got hidden, it should not attempt to re-fetch the session
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
|
||||
// simulate sign-out triggered by another tab
|
||||
signOut({ redirect: false })
|
||||
|
||||
// Given signed out in another tab, it attempts to sync and re-fetch the session
|
||||
return waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
"/api/auth/session",
|
||||
expect.anything()
|
||||
)
|
||||
|
||||
// We should have a call to sign-out and a call to refetch the session accordingly
|
||||
expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"GET /api/auth/csrf",
|
||||
"POST /api/auth/signout",
|
||||
"GET /api/auth/session",
|
||||
]
|
||||
`)
|
||||
})
|
||||
})
|
||||
|
||||
test("allows to customize how often the session will be re-fetched through polling", () => {
|
||||
jest.useFakeTimers()
|
||||
|
||||
render(<ProviderFlow session={mockSession} refetchInterval={1} />)
|
||||
|
||||
// we provided a mock session so it shouldn't try to fetch a new one
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(fetchSpy).toHaveBeenCalledWith("/api/auth/session", expect.anything())
|
||||
|
||||
jest.advanceTimersByTime(1000)
|
||||
|
||||
// it should have tried to refetch the session, hence counting 2 calls to the session endpoint
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2)
|
||||
expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"GET /api/auth/session",
|
||||
"GET /api/auth/session",
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
test("allows to customize the URL for session fetching", async () => {
|
||||
const myPath = "/api/v1/auth"
|
||||
|
||||
server.use(
|
||||
rest.get(`${myPath}/session`, (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockSession))
|
||||
)
|
||||
)
|
||||
|
||||
render(<ProviderFlow session={mockSession} basePath={myPath} />)
|
||||
|
||||
// there's an existing session so it should not try to fetch a new one
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
|
||||
// force a session refetch across all clients...
|
||||
getSession()
|
||||
|
||||
return waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
`${myPath}/session`,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function ProviderFlow(props) {
|
||||
function ProviderFlow({ options = {} }) {
|
||||
return (
|
||||
<SessionProvider {...props}>
|
||||
<SessionConsumer />
|
||||
<SessionConsumer testId="2" />
|
||||
</SessionProvider>
|
||||
<>
|
||||
<Provider options={options}>
|
||||
<SessionConsumer />
|
||||
<SessionConsumer testId="2" />
|
||||
</Provider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionConsumer({ testId = 1, ...rest }) {
|
||||
const { data: session, status } = useSession(rest)
|
||||
function SessionConsumer({ testId = 1 }) {
|
||||
const [session, loading] = useSession()
|
||||
|
||||
if (loading) return <span>loading</span>
|
||||
|
||||
return (
|
||||
<div data-testid={`session-${testId}`}>
|
||||
{status === "loading" ? "loading" : JSON.stringify(session)}
|
||||
<div data-testid={`session-consumer-${testId}`}>
|
||||
{JSON.stringify(session)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function changeTabVisibility(status) {
|
||||
const visibleStates = ["visible", "hidden"]
|
||||
|
||||
if (!visibleStates.includes(status)) return
|
||||
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
value: status,
|
||||
})
|
||||
|
||||
document.dispatchEvent(new Event("visibilitychange"))
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { server, mockCSRFToken } from "./helpers/mocks"
|
||||
import logger from "../../lib/logger"
|
||||
import { getCsrfToken } from "../../react"
|
||||
import { getCsrfToken } from ".."
|
||||
import { rest } from "msw"
|
||||
|
||||
jest.mock("../../lib/logger", () => ({
|
||||
@@ -78,10 +78,11 @@ test("when the fetch fails it'll throw a client fetch error", async () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logger.error).toHaveBeenCalledTimes(1)
|
||||
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
|
||||
path: "csrf",
|
||||
error: new SyntaxError("Unexpected token s in JSON at position 0"),
|
||||
})
|
||||
expect(logger.error).toBeCalledWith(
|
||||
"CLIENT_FETCH_ERROR",
|
||||
"csrf",
|
||||
new SyntaxError("Unexpected token s in JSON at position 0")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -65,28 +65,26 @@ export const mockSignOutResponse = {
|
||||
}
|
||||
|
||||
export const server = setupServer(
|
||||
rest.post("http://localhost/api/auth/signout", (req, res, ctx) =>
|
||||
rest.post("/api/auth/signout", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockSignOutResponse))
|
||||
),
|
||||
rest.get("http://localhost/api/auth/session", (req, res, ctx) =>
|
||||
rest.get("/api/auth/session", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockSession))
|
||||
),
|
||||
rest.get("http://localhost/api/auth/csrf", (req, res, ctx) =>
|
||||
rest.get("/api/auth/csrf", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockCSRFToken))
|
||||
),
|
||||
rest.get("http://localhost/api/auth/providers", (req, res, ctx) =>
|
||||
rest.get("/api/auth/providers", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockProviders))
|
||||
),
|
||||
rest.post("http://localhost/api/auth/signin/github", (req, res, ctx) =>
|
||||
rest.post("/api/auth/signin/github", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockGithubResponse))
|
||||
),
|
||||
rest.post("http://localhost/api/auth/callback/credentials", (req, res, ctx) =>
|
||||
rest.post("/api/auth/callback/credentials", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockCredentialsResponse))
|
||||
),
|
||||
rest.post("http://localhost/api/auth/signin/email", (req, res, ctx) =>
|
||||
rest.post("/api/auth/signin/email", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(mockEmailResponse))
|
||||
),
|
||||
rest.post("http://localhost/api/auth/_log", (req, res, ctx) =>
|
||||
res(ctx.status(200))
|
||||
)
|
||||
rest.post("/api/auth/_log", (req, res, ctx) => res(ctx.status(200)))
|
||||
)
|
||||
|
||||
@@ -6,9 +6,3 @@ export function getBroadcastEvents() {
|
||||
return { eventName, value: rest }
|
||||
})
|
||||
}
|
||||
|
||||
export function printFetchCalls(mockCalls) {
|
||||
return mockCalls.map(([path, { method = "GET" }]) => {
|
||||
return `${method.toUpperCase()} ${path}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { server, mockProviders } from "./helpers/mocks"
|
||||
import { getProviders } from "../../react"
|
||||
import { getProviders } from ".."
|
||||
import logger from "../../lib/logger"
|
||||
import { rest } from "msw"
|
||||
|
||||
@@ -56,10 +56,11 @@ test("when failing to fetch the providers, it'll log the error", async () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logger.error).toHaveBeenCalledTimes(1)
|
||||
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
|
||||
path: "providers",
|
||||
error: new SyntaxError("Unexpected token s in JSON at position 0"),
|
||||
})
|
||||
expect(logger.error).toBeCalledWith(
|
||||
"CLIENT_FETCH_ERROR",
|
||||
"providers",
|
||||
new SyntaxError("Unexpected token s in JSON at position 0")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { rest } from "msw"
|
||||
import { server, mockSession } from "./helpers/mocks"
|
||||
import logger from "../../lib/logger"
|
||||
import { useState, useEffect } from "react"
|
||||
import { getSession } from "../../react"
|
||||
import { getSession } from ".."
|
||||
import { getBroadcastEvents } from "./helpers/utils"
|
||||
|
||||
jest.mock("../../lib/logger", () => ({
|
||||
@@ -70,10 +70,11 @@ test("if there's an error fetching the session, it should log it", async () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(logger.error).toHaveBeenCalledTimes(1)
|
||||
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
|
||||
path: "session",
|
||||
error: new SyntaxError("Unexpected token S in JSON at position 0"),
|
||||
})
|
||||
expect(logger.error).toBeCalledWith(
|
||||
"CLIENT_FETCH_ERROR",
|
||||
"session",
|
||||
new SyntaxError("Unexpected token S in JSON at position 0")
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
mockEmailResponse,
|
||||
mockGithubResponse,
|
||||
} from "./helpers/mocks"
|
||||
import { signIn } from "../../react"
|
||||
import { signIn } from ".."
|
||||
import { rest } from "msw"
|
||||
|
||||
const { location } = window
|
||||
@@ -27,22 +27,12 @@ jest.mock("../../lib/logger", () => ({
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen()
|
||||
|
||||
let _href = window.location.href
|
||||
// Allows to mutate `window.location`...
|
||||
delete window.location
|
||||
|
||||
window.location = {
|
||||
...location,
|
||||
replace: jest.fn(),
|
||||
reload: jest.fn(),
|
||||
}
|
||||
Object.defineProperty(window.location, "href", {
|
||||
get: () => _href,
|
||||
// whatwg-fetch or whatwg-url does not seem to work with relative URLs
|
||||
set: (href) => {
|
||||
_href = href.startsWith("/") ? `http://localhost${href}` : href
|
||||
return _href
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -69,10 +59,9 @@ test.each`
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(
|
||||
`http://localhost/api/auth/signin?${new URLSearchParams({
|
||||
callbackUrl,
|
||||
})}`
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
`/api/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -87,14 +76,14 @@ test.each`
|
||||
async ({ provider }) => {
|
||||
render(<SignInFlow providerId={provider} />)
|
||||
|
||||
const callbackUrl = window.location.href
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(
|
||||
`http://localhost/api/auth/signin?${new URLSearchParams({
|
||||
callbackUrl,
|
||||
})}`
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
`/api/auth/signin?callbackUrl=${encodeURIComponent(
|
||||
window.location.href
|
||||
)}`
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -112,7 +101,8 @@ test.each`
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockUrl)
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(mockUrl)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -129,7 +119,8 @@ test("redirection can't be stopped using an oauth provider", async () => {
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockGithubResponse.url)
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(mockGithubResponse.url)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,7 +136,9 @@ test("redirection can be stopped using the 'credentials' provider", async () =>
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).not.toBe(mockCredentialsResponse.url)
|
||||
expect(window.location.replace).not.toHaveBeenCalledWith(
|
||||
mockCredentialsResponse.url
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("signin-result").textContent).not.toBe(
|
||||
"no response"
|
||||
@@ -172,7 +165,9 @@ test("redirection can be stopped using the 'email' provider", async () => {
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).not.toBe(mockEmailResponse.url)
|
||||
expect(window.location.replace).not.toHaveBeenCalledWith(
|
||||
mockEmailResponse.url
|
||||
)
|
||||
|
||||
expect(screen.getByTestId("signin-result").textContent).not.toBe(
|
||||
"no response"
|
||||
@@ -195,7 +190,7 @@ test("if callback URL contains a hash we force a window reload when re-directing
|
||||
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
|
||||
|
||||
server.use(
|
||||
rest.post("http://localhost/api/auth/signin/email", (req, res, ctx) => {
|
||||
rest.post("/api/auth/signin/email", (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
@@ -211,7 +206,8 @@ test("if callback URL contains a hash we force a window reload when re-directing
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockUrlWithHash)
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
|
||||
// the browser will not refresh the page if the redirect URL contains a hash, hence we force it on the client, see #1289
|
||||
expect(window.location.reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -222,7 +218,7 @@ test("params are propagated to the signin URL when supplied", async () => {
|
||||
const authParams = "foo=bar&bar=foo"
|
||||
|
||||
server.use(
|
||||
rest.post("http://localhost/api/auth/signin/github", (req, res, ctx) => {
|
||||
rest.post("/api/auth/signin/github", (req, res, ctx) => {
|
||||
matchedParams = req.url.search
|
||||
return res(ctx.status(200), ctx.json(mockGithubResponse))
|
||||
})
|
||||
@@ -241,7 +237,7 @@ test("when it fails to fetch the providers, it redirected back to signin page",
|
||||
const errorMsg = "Error when retrieving providers"
|
||||
|
||||
server.use(
|
||||
rest.get("http://localhost/api/auth/providers", (req, res, ctx) =>
|
||||
rest.get("/api/auth/providers", (req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json(errorMsg))
|
||||
)
|
||||
)
|
||||
@@ -251,13 +247,14 @@ test("when it fails to fetch the providers, it redirected back to signin page",
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(`http://localhost/api/auth/error`)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(`/api/auth/error`)
|
||||
|
||||
expect(logger.error).toHaveBeenCalledTimes(1)
|
||||
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
|
||||
error: "Error when retrieving providers",
|
||||
path: "providers",
|
||||
})
|
||||
expect(logger.error).toBeCalledWith(
|
||||
"CLIENT_FETCH_ERROR",
|
||||
"providers",
|
||||
errorMsg
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -272,7 +269,10 @@ function SignInFlow({
|
||||
async function handleSignIn() {
|
||||
const result = await signIn(
|
||||
providerId,
|
||||
{ callbackUrl, redirect },
|
||||
{
|
||||
callbackUrl,
|
||||
redirect,
|
||||
},
|
||||
authorizationParams
|
||||
)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import { server, mockSignOutResponse } from "./helpers/mocks"
|
||||
import { signOut } from "../../react"
|
||||
import { signOut } from ".."
|
||||
import { rest } from "msw"
|
||||
import { getBroadcastEvents } from "./helpers/utils"
|
||||
|
||||
@@ -10,11 +10,11 @@ const { location } = window
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen()
|
||||
// Allows to mutate `window.location`...
|
||||
delete window.location
|
||||
window.location = {
|
||||
...location,
|
||||
replace: jest.fn(),
|
||||
reload: jest.fn(),
|
||||
href: location.href,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ const callbackUrl = "https://redirects/to"
|
||||
|
||||
test("by default it redirects to the current URL if the server did not provide one", async () => {
|
||||
server.use(
|
||||
rest.post("http://localhost/api/auth/signout", (req, res, ctx) =>
|
||||
rest.post("/api/auth/signout", (req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ ...mockSignOutResponse, url: undefined }))
|
||||
)
|
||||
)
|
||||
@@ -47,7 +47,8 @@ test("by default it redirects to the current URL if the server did not provide o
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(window.location.href)
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(window.location.href)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -57,7 +58,10 @@ test("it redirects to the URL allowed by the server", async () => {
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockSignOutResponse.url)
|
||||
expect(window.location.replace).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(
|
||||
mockSignOutResponse.url
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -65,7 +69,7 @@ test("if url contains a hash during redirection a page reload happens", async ()
|
||||
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
|
||||
|
||||
server.use(
|
||||
rest.post("http://localhost/api/auth/signout", (req, res, ctx) => {
|
||||
rest.post("/api/auth/signout", (req, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
@@ -81,7 +85,8 @@ test("if url contains a hash during redirection a page reload happens", async ()
|
||||
userEvent.click(screen.getByRole("button"))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe(mockUrlWithHash)
|
||||
expect(window.location.reload).toHaveBeenCalledTimes(1)
|
||||
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { rest } from "msw"
|
||||
import { renderHook } from "@testing-library/react-hooks"
|
||||
import { render, waitFor } from "@testing-library/react"
|
||||
import { SessionProvider, useSession, signOut } from "../../react"
|
||||
import { server, mockSession } from "./helpers/mocks"
|
||||
|
||||
const origConsoleError = console.error
|
||||
const { location } = window
|
||||
|
||||
let _href = window.location.href
|
||||
beforeAll(() => {
|
||||
// Prevent noise on the terminal... `next-auth` will log to `console.error`
|
||||
// every time a request fails, which makes the tests output very noisy...
|
||||
console.error = jest.fn()
|
||||
|
||||
// Allows to mutate `window.location`...
|
||||
delete window.location
|
||||
window.location = {}
|
||||
Object.defineProperty(window.location, "href", {
|
||||
get: () => _href,
|
||||
// whatwg-fetch or whatwg-url does not seem to work with relative URLs
|
||||
set: (href) => {
|
||||
_href = href.startsWith("/") ? `http://localhost${href}` : href
|
||||
return _href
|
||||
},
|
||||
})
|
||||
|
||||
server.listen()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
_href = "http://localhost/"
|
||||
|
||||
// clear the internal session cache...
|
||||
signOut({ redirect: false })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
console.error = origConsoleError
|
||||
window.location = location
|
||||
server.close()
|
||||
})
|
||||
|
||||
test("it won't allow to fetch the session in isolation without a session context", () => {
|
||||
function App() {
|
||||
useSession()
|
||||
return null
|
||||
}
|
||||
|
||||
expect(() => render(<App />)).toThrow(
|
||||
"[next-auth]: `useSession` must be wrapped in a <SessionProvider />"
|
||||
)
|
||||
})
|
||||
|
||||
test("when fetching the session, there won't be `data` and `status` will be 'loading'", () => {
|
||||
const { result } = renderHook(() => useSession(), {
|
||||
wrapper: SessionProvider,
|
||||
})
|
||||
|
||||
expect(result.current.data).toBe(undefined)
|
||||
expect(result.current.status).toBe("loading")
|
||||
})
|
||||
|
||||
test("when session is fetched, `data` will contain the session data and `status` will be 'authenticated'", async () => {
|
||||
const { result } = renderHook(() => useSession(), {
|
||||
wrapper: SessionProvider,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(mockSession)
|
||||
expect(result.current.status).toBe("authenticated")
|
||||
})
|
||||
})
|
||||
|
||||
test("when it fails to fetch the session, `data` will be null and `status` will be 'unauthenticated'", async () => {
|
||||
server.use(
|
||||
rest.get(`http://localhost/api/auth/session`, (_, res, ctx) =>
|
||||
res(ctx.status(401), ctx.json({}))
|
||||
)
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useSession(), {
|
||||
wrapper: SessionProvider,
|
||||
})
|
||||
|
||||
return waitFor(() => {
|
||||
expect(result.current.data).toEqual(null)
|
||||
expect(result.current.status).toBe("unauthenticated")
|
||||
})
|
||||
})
|
||||
|
||||
test("it'll redirect to sign-in page if the session is required and the user is not authenticated", async () => {
|
||||
server.use(
|
||||
rest.get(`http://localhost/api/auth/session`, (req, res, ctx) =>
|
||||
res(ctx.status(401), ctx.json({}))
|
||||
)
|
||||
)
|
||||
|
||||
const callbackUrl = window.location.href
|
||||
const { result } = renderHook(() => useSession({ required: true }), {
|
||||
wrapper: SessionProvider,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(null)
|
||||
expect(result.current.status).toBe("loading")
|
||||
})
|
||||
|
||||
expect(window.location.href).toBe(
|
||||
`http://localhost/api/auth/signin?${new URLSearchParams({
|
||||
error: "SessionRequired",
|
||||
callbackUrl,
|
||||
})}`
|
||||
)
|
||||
})
|
||||
|
||||
test("will call custom redirect logic if supplied when the user could not authenticate", async () => {
|
||||
server.use(
|
||||
rest.get(`http://localhost/api/auth/session`, (_, res, ctx) =>
|
||||
res(ctx.status(401), ctx.json({}))
|
||||
)
|
||||
)
|
||||
|
||||
const customRedirect = jest.fn()
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useSession({ required: true, onUnauthenticated: customRedirect }),
|
||||
{
|
||||
wrapper: SessionProvider,
|
||||
}
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual(null)
|
||||
expect(result.current.status).toBe("loading")
|
||||
})
|
||||
|
||||
expect(customRedirect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { IncomingMessage } from "http"
|
||||
import type { LoggerInstance, Session } from ".."
|
||||
|
||||
export interface NextAuthClientConfig {
|
||||
baseUrl: string
|
||||
basePath: string
|
||||
baseUrlServer: string
|
||||
basePathServer: string
|
||||
/** Stores last session response */
|
||||
_session?: Session | null | undefined
|
||||
/** Used for timestamp since last sycned (in seconds) */
|
||||
_lastSync: number
|
||||
/**
|
||||
* Stores the `SessionProvider`'s session update method to be able to
|
||||
* trigger session updates from places like `signIn` or `signOut`
|
||||
*/
|
||||
_getSession: (...args: any[]) => any
|
||||
}
|
||||
|
||||
export interface CtxOrReq {
|
||||
req?: IncomingMessage
|
||||
ctx?: { req: IncomingMessage }
|
||||
}
|
||||
|
||||
/**
|
||||
* If passed 'appContext' via getInitialProps() in _app.js
|
||||
* then get the req object from ctx and use that for the
|
||||
* req value to allow `fetchData` to
|
||||
* work seemlessly in getInitialProps() on server side
|
||||
* pages *and* in _app.js.
|
||||
*/
|
||||
export async function fetchData<T = any>(
|
||||
path: string,
|
||||
__NEXTAUTH: NextAuthClientConfig,
|
||||
logger: LoggerInstance,
|
||||
{ ctx, req = ctx?.req }: CtxOrReq = {}
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const options = req?.headers.cookie
|
||||
? { headers: { cookie: req.headers.cookie } }
|
||||
: {}
|
||||
const res = await fetch(`${apiBaseUrl(__NEXTAUTH)}/${path}`, options)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw data
|
||||
return Object.keys(data).length > 0 ? data : null // Return null if data empty
|
||||
} catch (error) {
|
||||
logger.error("CLIENT_FETCH_ERROR", {
|
||||
error,
|
||||
path,
|
||||
...(req ? { header: req.headers } : {}),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function apiBaseUrl(__NEXTAUTH: NextAuthClientConfig) {
|
||||
if (typeof window === "undefined") {
|
||||
// Return absolute path when called server side
|
||||
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
|
||||
}
|
||||
// Return relative path when called client side
|
||||
return __NEXTAUTH.basePath
|
||||
}
|
||||
|
||||
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
|
||||
export function now() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
export interface BroadcastMessage {
|
||||
event?: "session"
|
||||
data?: { trigger?: "signout" | "getSession" }
|
||||
clientId: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspired by [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
|
||||
* Only not using it directly, because Safari does not support it.
|
||||
*
|
||||
* https://caniuse.com/?search=broadcastchannel
|
||||
*/
|
||||
export function BroadcastChannel(name = "nextauth.message") {
|
||||
return {
|
||||
/** Get notified by other tabs/windows. */
|
||||
receive(onReceive: (message: BroadcastMessage) => void) {
|
||||
const handler = (event) => {
|
||||
if (event.key !== name) return
|
||||
const message: BroadcastMessage = JSON.parse(event.newValue)
|
||||
if (message?.event !== "session" || !message?.data) return
|
||||
|
||||
onReceive(message)
|
||||
}
|
||||
window.addEventListener("storage", handler)
|
||||
return () => window.removeEventListener("storage", handler)
|
||||
},
|
||||
/** Notify other tabs/windows. */
|
||||
post(message) {
|
||||
if (typeof window === "undefined") return
|
||||
localStorage.setItem(
|
||||
name,
|
||||
JSON.stringify({ ...message, timestamp: now() })
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
418
src/client/index.js
Normal file
418
src/client/index.js
Normal file
@@ -0,0 +1,418 @@
|
||||
// Note about signIn() and signOut() methods:
|
||||
//
|
||||
// On signIn() and signOut() we pass 'json: true' to request a response in JSON
|
||||
// instead of HTTP as redirect URLs on other domains are not returned to
|
||||
// requests made using the fetch API in the browser, and we need to ask the API
|
||||
// to return the response as a JSON object (the end point still defaults to
|
||||
// returning an HTTP response with a redirect for non-JavaScript clients).
|
||||
//
|
||||
// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks.
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useContext,
|
||||
createContext,
|
||||
createElement,
|
||||
} from "react"
|
||||
import _logger, { proxyLogger } from "../lib/logger"
|
||||
import parseUrl from "../lib/parse-url"
|
||||
|
||||
// This behaviour mirrors the default behaviour for getting the site name that
|
||||
// happens server side in server/index.js
|
||||
// 1. An empty value is legitimate when the code is being invoked client side as
|
||||
// relative URLs are valid in that context and so defaults to empty.
|
||||
// 2. When invoked server side the value is picked up from an environment
|
||||
// variable and defaults to 'http://localhost:3000'.
|
||||
/** @type {import("types/internals/client").NextAuthConfig} */
|
||||
const __NEXTAUTH = {
|
||||
baseUrl: parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL).baseUrl,
|
||||
basePath: parseUrl(process.env.NEXTAUTH_URL).basePath,
|
||||
baseUrlServer: parseUrl(
|
||||
process.env.NEXTAUTH_URL_INTERNAL ||
|
||||
process.env.NEXTAUTH_URL ||
|
||||
process.env.VERCEL_URL
|
||||
).baseUrl,
|
||||
basePathServer: parseUrl(
|
||||
process.env.NEXTAUTH_URL_INTERNAL || process.env.NEXTAUTH_URL
|
||||
).basePath,
|
||||
keepAlive: 0,
|
||||
clientMaxAge: 0,
|
||||
// Properties starting with _ are used for tracking internal app state
|
||||
_clientLastSync: 0,
|
||||
_clientSyncTimer: null,
|
||||
_eventListenersAdded: false,
|
||||
_clientSession: undefined,
|
||||
_getSession: () => {},
|
||||
}
|
||||
|
||||
const logger = proxyLogger(_logger, __NEXTAUTH.basePath)
|
||||
|
||||
const broadcast = BroadcastChannel()
|
||||
|
||||
// Add event listners on load
|
||||
if (typeof window !== "undefined" && !__NEXTAUTH._eventListenersAdded) {
|
||||
__NEXTAUTH._eventListenersAdded = true
|
||||
// Listen for storage events and update session if event fired from
|
||||
// another window (but suppress firing another event to avoid a loop)
|
||||
// Fetch new session data but tell it to not to fire another event to
|
||||
// avoid an infinite loop.
|
||||
// Note: We could pass session data through and do something like
|
||||
// `setData(message.data)` but that can cause problems depending
|
||||
// on how the session object is being used in the client; it is
|
||||
// more robust to have each window/tab fetch it's own copy of the
|
||||
// session object rather than share it across instances.
|
||||
broadcast.receive(() => __NEXTAUTH._getSession({ event: "storage" }))
|
||||
|
||||
// Listen for document visibility change events and
|
||||
// if visibility of the document changes, re-fetch the session.
|
||||
document.addEventListener(
|
||||
"visibilitychange",
|
||||
() => {
|
||||
!document.hidden && __NEXTAUTH._getSession({ event: "visibilitychange" })
|
||||
},
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
// Context to store session data globally
|
||||
/** @type {import("types/internals/client").SessionContext} */
|
||||
const SessionContext = createContext()
|
||||
|
||||
export function useSession(session) {
|
||||
const context = useContext(SessionContext)
|
||||
if (context) return context
|
||||
return _useSessionHook(session)
|
||||
}
|
||||
|
||||
function _useSessionHook(session) {
|
||||
const [data, setData] = useState(session)
|
||||
const [loading, setLoading] = useState(!data)
|
||||
|
||||
useEffect(() => {
|
||||
__NEXTAUTH._getSession = async ({ event = null } = {}) => {
|
||||
try {
|
||||
const triggredByEvent = event !== null
|
||||
const triggeredByStorageEvent = event === "storage"
|
||||
|
||||
const clientMaxAge = __NEXTAUTH.clientMaxAge
|
||||
const clientLastSync = parseInt(__NEXTAUTH._clientLastSync)
|
||||
const currentTime = _now()
|
||||
const clientSession = __NEXTAUTH._clientSession
|
||||
|
||||
// Updates triggered by a storage event *always* trigger an update and we
|
||||
// always update if we don't have any value for the current session state.
|
||||
if (!triggeredByStorageEvent && clientSession !== undefined) {
|
||||
if (clientMaxAge === 0 && triggredByEvent !== true) {
|
||||
// If there is no time defined for when a session should be considered
|
||||
// stale, then it's okay to use the value we have until an event is
|
||||
// triggered which updates it.
|
||||
return
|
||||
} else if (clientMaxAge > 0 && clientSession === null) {
|
||||
// If the client doesn't have a session then we don't need to call
|
||||
// the server to check if it does (if they have signed in via another
|
||||
// tab or window that will come through as a triggeredByStorageEvent
|
||||
// event and will skip this logic)
|
||||
return
|
||||
} else if (
|
||||
clientMaxAge > 0 &&
|
||||
currentTime < clientLastSync + clientMaxAge
|
||||
) {
|
||||
// If the session freshness is within clientMaxAge then don't request
|
||||
// it again on this call (avoids too many invokations).
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (clientSession === undefined) {
|
||||
__NEXTAUTH._clientSession = null
|
||||
}
|
||||
|
||||
// Update clientLastSync before making response to avoid repeated
|
||||
// invokations that would otherwise be triggered while we are still
|
||||
// waiting for a response.
|
||||
__NEXTAUTH._clientLastSync = _now()
|
||||
|
||||
// If this call was invoked via a storage event (i.e. another window) then
|
||||
// tell getSession not to trigger an event when it calls to avoid an
|
||||
// infinate loop.
|
||||
const newClientSessionData = await getSession({
|
||||
triggerEvent: !triggeredByStorageEvent,
|
||||
})
|
||||
|
||||
// Save session state internally, just so we can track that we've checked
|
||||
// if a session exists at least once.
|
||||
__NEXTAUTH._clientSession = newClientSessionData
|
||||
|
||||
setData(newClientSessionData)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
logger.error("CLIENT_USE_SESSION_ERROR", error)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
__NEXTAUTH._getSession()
|
||||
})
|
||||
|
||||
return [data, loading]
|
||||
}
|
||||
|
||||
export async function getSession(ctx) {
|
||||
const session = await _fetchData("session", ctx)
|
||||
if (ctx?.triggerEvent ?? true) {
|
||||
broadcast.post({ event: "session", data: { trigger: "getSession" } })
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
export async function getCsrfToken(ctx) {
|
||||
return (await _fetchData("csrf", ctx))?.csrfToken
|
||||
}
|
||||
|
||||
export async function getProviders() {
|
||||
return await _fetchData("providers")
|
||||
}
|
||||
|
||||
export async function signIn(provider, options = {}, authorizationParams = {}) {
|
||||
const { callbackUrl = window.location.href, redirect = true } = options
|
||||
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const providers = await getProviders()
|
||||
|
||||
if (!providers) {
|
||||
return window.location.replace(`${baseUrl}/error`)
|
||||
}
|
||||
|
||||
if (!(provider in providers)) {
|
||||
return window.location.replace(
|
||||
`${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
)
|
||||
}
|
||||
|
||||
const isCredentials = providers[provider].type === "credentials"
|
||||
const isEmail = providers[provider].type === "email"
|
||||
const isSupportingReturn = isCredentials || isEmail
|
||||
|
||||
const signInUrl = isCredentials
|
||||
? `${baseUrl}/callback/${provider}`
|
||||
: `${baseUrl}/signin/${provider}`
|
||||
|
||||
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
|
||||
|
||||
const res = await fetch(_signInUrl, {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
...options,
|
||||
csrfToken: await getCsrfToken(),
|
||||
callbackUrl,
|
||||
json: true,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (redirect || !isSupportingReturn) {
|
||||
const url = data.url ?? callbackUrl
|
||||
window.location.replace(url)
|
||||
// If url contains a hash, the browser does not reload the page. We reload manually
|
||||
if (url.includes("#")) window.location.reload()
|
||||
return
|
||||
}
|
||||
|
||||
const error = new URL(data.url).searchParams.get("error")
|
||||
|
||||
if (res.ok) {
|
||||
await __NEXTAUTH._getSession({ event: "storage" })
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
url: error ? null : data.url,
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(options = {}) {
|
||||
const { callbackUrl = window.location.href, redirect = true } = options
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const fetchOptions = {
|
||||
method: "post",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
csrfToken: await getCsrfToken(),
|
||||
callbackUrl,
|
||||
json: true,
|
||||
}),
|
||||
}
|
||||
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
|
||||
const data = await res.json()
|
||||
broadcast.post({ event: "session", data: { trigger: "signout" } })
|
||||
|
||||
if (redirect) {
|
||||
const url = data.url ?? callbackUrl
|
||||
window.location.replace(url)
|
||||
// If url contains a hash, the browser does not reload the page. We reload manually
|
||||
if (url.includes("#")) window.location.reload()
|
||||
return
|
||||
}
|
||||
|
||||
await __NEXTAUTH._getSession({ event: "storage" })
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Method to set options. The documented way is to use the provider, but this
|
||||
// method is being left in as an alternative, that will be helpful if/when we
|
||||
// expose a vanilla JavaScript version that doesn't depend on React.
|
||||
export function setOptions({
|
||||
baseUrl,
|
||||
basePath,
|
||||
clientMaxAge,
|
||||
keepAlive,
|
||||
} = {}) {
|
||||
if (baseUrl) __NEXTAUTH.baseUrl = baseUrl
|
||||
if (basePath) __NEXTAUTH.basePath = basePath
|
||||
if (clientMaxAge) __NEXTAUTH.clientMaxAge = clientMaxAge
|
||||
if (keepAlive) {
|
||||
__NEXTAUTH.keepAlive = keepAlive
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
// Clear existing timer (if there is one)
|
||||
if (__NEXTAUTH._clientSyncTimer !== null) {
|
||||
clearTimeout(__NEXTAUTH._clientSyncTimer)
|
||||
}
|
||||
|
||||
// Set next timer to trigger in number of seconds
|
||||
__NEXTAUTH._clientSyncTimer = setTimeout(async () => {
|
||||
// Only invoke keepalive when a session exists
|
||||
if (!__NEXTAUTH._clientSession) return
|
||||
await __NEXTAUTH._getSession({ event: "timer" })
|
||||
}, keepAlive * 1000)
|
||||
}
|
||||
}
|
||||
|
||||
export function Provider({ children, session, options }) {
|
||||
setOptions(options)
|
||||
return createElement(
|
||||
SessionContext.Provider,
|
||||
{ value: useSession(session) },
|
||||
children
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* If passed 'appContext' via getInitialProps() in _app.js
|
||||
* then get the req object from ctx and use that for the
|
||||
* req value to allow _fetchData to
|
||||
* work seemlessly in getInitialProps() on server side
|
||||
* pages *and* in _app.js.
|
||||
*/
|
||||
async function _fetchData(path, { ctx, req = ctx?.req } = {}) {
|
||||
try {
|
||||
const baseUrl = await _apiBaseUrl()
|
||||
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
|
||||
const res = await fetch(`${baseUrl}/${path}`, options)
|
||||
const data = await res.json()
|
||||
if (!res.ok) throw data
|
||||
return Object.keys(data).length > 0 ? data : null // Return null if data empty
|
||||
} catch (error) {
|
||||
logger.error("CLIENT_FETCH_ERROR", path, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function _apiBaseUrl() {
|
||||
if (typeof window === "undefined") {
|
||||
// NEXTAUTH_URL should always be set explicitly to support server side calls - log warning if not set
|
||||
if (!process.env.NEXTAUTH_URL) {
|
||||
logger.warn("NEXTAUTH_URL", "NEXTAUTH_URL environment variable not set")
|
||||
}
|
||||
|
||||
// Return absolute path when called server side
|
||||
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
|
||||
}
|
||||
// Return relative path when called client side
|
||||
return __NEXTAUTH.basePath
|
||||
}
|
||||
|
||||
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
|
||||
function _now() {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspired by [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
|
||||
* Only not using it directly, because Safari does not support it.
|
||||
*
|
||||
* https://caniuse.com/?search=broadcastchannel
|
||||
*/
|
||||
function BroadcastChannel(name = "nextauth.message") {
|
||||
return {
|
||||
/**
|
||||
* Get notified by other tabs/windows.
|
||||
* @param {(message: import("types/internals/client").BroadcastMessage) => void} onReceive
|
||||
*/
|
||||
receive(onReceive) {
|
||||
if (typeof window === "undefined") return
|
||||
window.addEventListener("storage", async (event) => {
|
||||
if (event.key !== name) return
|
||||
/** @type {import("types/internals/client").BroadcastMessage} */
|
||||
const message = JSON.parse(event.newValue)
|
||||
if (message?.event !== "session" || !message?.data) return
|
||||
|
||||
onReceive(message)
|
||||
})
|
||||
},
|
||||
/** Notify other tabs/windows. */
|
||||
post(message) {
|
||||
if (typeof localStorage === "undefined") return
|
||||
localStorage.setItem(
|
||||
name,
|
||||
JSON.stringify({ ...message, timestamp: _now() })
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Some methods are exported with more than one name. This provides some
|
||||
// flexibility over how they can be invoked and backwards compatibility
|
||||
// with earlier releases. These should be removed in a newer release, as it only
|
||||
// creates problems for bundlers and adds confusion to users. TypeScript declarations
|
||||
// will provide sufficient help when importing
|
||||
export {
|
||||
setOptions as options,
|
||||
getSession as session,
|
||||
getProviders as providers,
|
||||
getCsrfToken as csrfToken,
|
||||
signIn as signin,
|
||||
signOut as signout,
|
||||
}
|
||||
|
||||
export default {
|
||||
getSession,
|
||||
getCsrfToken,
|
||||
getProviders,
|
||||
useSession,
|
||||
signIn,
|
||||
signOut,
|
||||
Provider,
|
||||
/* Deprecated / unsupported features below this line */
|
||||
// Use setOptions() set options globally in the app.
|
||||
setOptions,
|
||||
// Some methods are exported with more than one name. This provides some
|
||||
// flexibility over how they can be invoked and backwards compatibility
|
||||
// with earlier releases.
|
||||
options: setOptions,
|
||||
session: getSession,
|
||||
providers: getProviders,
|
||||
csrfToken: getCsrfToken,
|
||||
signin: signIn,
|
||||
signout: signOut,
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { EventCallbacks, LoggerInstance } from ".."
|
||||
import { Adapter } from "../adapters"
|
||||
|
||||
/**
|
||||
* Same as the default `Error`, but it is JSON serializable.
|
||||
* @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
|
||||
*/
|
||||
export class UnknownError extends Error {
|
||||
constructor(error) {
|
||||
// Support passing error or string
|
||||
super(error?.message ?? error)
|
||||
this.name = "UnknownError"
|
||||
if (error instanceof Error) {
|
||||
this.stack = error.stack
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
stack: this.stack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthCallbackError extends UnknownError {
|
||||
name = "OAuthCallbackError"
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an Email address is already associated with an account
|
||||
* but the user is trying an OAuth account that is not linked to it.
|
||||
*/
|
||||
export class AccountNotLinkedError extends UnknownError {
|
||||
name = "AccountNotLinkedError"
|
||||
}
|
||||
|
||||
type Method = (...args: any[]) => Promise<any>
|
||||
|
||||
export function upperSnake(s: string) {
|
||||
return s.replace(/([A-Z])/g, "_$1").toUpperCase()
|
||||
}
|
||||
|
||||
export function capitalize(s: string) {
|
||||
return `${s[0].toUpperCase()}${s.slice(1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an object of methods and adds error handling.
|
||||
*/
|
||||
export function eventsErrorHandler(
|
||||
methods: Partial<EventCallbacks>,
|
||||
logger: LoggerInstance
|
||||
): Partial<EventCallbacks> {
|
||||
return Object.keys(methods).reduce<any>((acc, name) => {
|
||||
acc[name] = async (...args: any[]) => {
|
||||
try {
|
||||
const method: Method = methods[name]
|
||||
return await method(...args)
|
||||
} catch (e) {
|
||||
logger.error(`${upperSnake(name)}_EVENT_ERROR`, e)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
/** Handles adapter induced errors. */
|
||||
export function adapterErrorHandler(
|
||||
adapter: Adapter | undefined,
|
||||
logger: LoggerInstance
|
||||
): Adapter | undefined {
|
||||
if (!adapter) return
|
||||
|
||||
return Object.keys(adapter).reduce<any>((acc, name) => {
|
||||
acc[name] = async (...args: any[]) => {
|
||||
try {
|
||||
logger.debug(`adapter_${name}`, { args })
|
||||
const method: Method = adapter[name as any]
|
||||
return await method(...args)
|
||||
} catch (error) {
|
||||
logger.error(`adapter_error_${name}`, error)
|
||||
const e = new UnknownError(error)
|
||||
e.name = `${capitalize(name)}Error`
|
||||
throw e
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
import logger from "../lib/logger"
|
||||
import * as routes from "./routes"
|
||||
import renderPage from "./pages"
|
||||
import type { NextAuthOptions } from "./types"
|
||||
import { init } from "./init"
|
||||
import { Cookie } from "./lib/cookie"
|
||||
|
||||
import { NextAuthAction } from "../lib/types"
|
||||
|
||||
export interface IncomingRequest {
|
||||
/** @default "http://localhost:3000" */
|
||||
host?: string
|
||||
method: string
|
||||
cookies?: Record<string, any>
|
||||
headers?: Record<string, any>
|
||||
query?: Record<string, any>
|
||||
body?: Record<string, any>
|
||||
action: NextAuthAction
|
||||
providerId?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface NextAuthHeader {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface OutgoingResponse<
|
||||
Body extends string | Record<string, any> | any[] = any
|
||||
> {
|
||||
status?: number
|
||||
headers?: NextAuthHeader[]
|
||||
body?: Body
|
||||
redirect?: string
|
||||
cookies?: Cookie[]
|
||||
}
|
||||
|
||||
interface NextAuthHandlerParams {
|
||||
req: IncomingRequest
|
||||
options: NextAuthOptions
|
||||
}
|
||||
|
||||
export async function NextAuthHandler<
|
||||
Body extends string | Record<string, any> | any[]
|
||||
>(params: NextAuthHandlerParams): Promise<OutgoingResponse<Body>> {
|
||||
const { options: userOptions, req } = params
|
||||
const { action, providerId, error } = req
|
||||
|
||||
const { options, cookies } = await init({
|
||||
userOptions,
|
||||
action,
|
||||
providerId,
|
||||
host: req.host,
|
||||
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
|
||||
csrfToken: req.body?.csrfToken,
|
||||
cookies: req.cookies,
|
||||
isPost: req.method === "POST",
|
||||
})
|
||||
|
||||
const sessionToken =
|
||||
req.cookies?.[options.cookies.sessionToken.name] ||
|
||||
req.headers?.Authorization?.replace("Bearer ", "")
|
||||
|
||||
const codeVerifier = req.cookies?.[options.cookies.pkceCodeVerifier.name]
|
||||
|
||||
if (req.method === "GET") {
|
||||
const render = renderPage({ options, query: req.query, cookies })
|
||||
const { pages } = options
|
||||
switch (action) {
|
||||
case "providers":
|
||||
return (await routes.providers(options.providers)) as any
|
||||
case "session":
|
||||
return (await routes.session({ options, sessionToken })) as any
|
||||
case "csrf":
|
||||
return {
|
||||
headers: [{ key: "Content-Type", value: "application/json" }],
|
||||
body: { csrfToken: options.csrfToken } as any,
|
||||
cookies,
|
||||
}
|
||||
case "signin":
|
||||
if (pages.signIn) {
|
||||
let signinUrl = `${pages.signIn}${
|
||||
pages.signIn.includes("?") ? "&" : "?"
|
||||
}callbackUrl=${options.callbackUrl}`
|
||||
if (error) signinUrl = `${signinUrl}&error=${error}`
|
||||
return { redirect: signinUrl, cookies }
|
||||
}
|
||||
|
||||
return render.signin()
|
||||
case "signout":
|
||||
if (pages.signOut) return { redirect: pages.signOut, cookies }
|
||||
|
||||
return render.signout()
|
||||
case "callback":
|
||||
if (options.provider) {
|
||||
const callback = await routes.callback({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
options,
|
||||
sessionToken,
|
||||
codeVerifier,
|
||||
})
|
||||
if (callback.cookies) cookies.push(...callback.cookies)
|
||||
return { ...callback, cookies }
|
||||
}
|
||||
break
|
||||
case "verify-request":
|
||||
if (pages.verifyRequest) {
|
||||
return { redirect: pages.verifyRequest, cookies }
|
||||
}
|
||||
return render.verifyRequest()
|
||||
case "error":
|
||||
if (pages.error) {
|
||||
return {
|
||||
redirect: `${pages.error}${
|
||||
pages.error.includes("?") ? "&" : "?"
|
||||
}error=${error}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// These error messages are displayed in line on the sign in page
|
||||
if (
|
||||
[
|
||||
"Signin",
|
||||
"OAuthSignin",
|
||||
"OAuthCallback",
|
||||
"OAuthCreateAccount",
|
||||
"EmailCreateAccount",
|
||||
"Callback",
|
||||
"OAuthAccountNotLinked",
|
||||
"EmailSignin",
|
||||
"CredentialsSignin",
|
||||
"SessionRequired",
|
||||
].includes(error as string)
|
||||
) {
|
||||
return { redirect: `${options.url}/signin?error=${error}`, cookies }
|
||||
}
|
||||
|
||||
return render.error({ error })
|
||||
default:
|
||||
}
|
||||
} else if (req.method === "POST") {
|
||||
switch (action) {
|
||||
case "signin":
|
||||
// Verified CSRF Token required for all sign in routes
|
||||
if (options.csrfTokenVerified && options.provider) {
|
||||
const signin = await routes.signin({
|
||||
query: req.query,
|
||||
body: req.body,
|
||||
options,
|
||||
})
|
||||
if (signin.cookies) cookies.push(...signin.cookies)
|
||||
return { ...signin, cookies }
|
||||
}
|
||||
|
||||
return { redirect: `${options.url}/signin?csrf=true`, cookies }
|
||||
case "signout":
|
||||
// Verified CSRF Token required for signout
|
||||
if (options.csrfTokenVerified) {
|
||||
const signout = await routes.signout({ options, sessionToken })
|
||||
if (signout.cookies) cookies.push(...signout.cookies)
|
||||
return { ...signout, cookies }
|
||||
}
|
||||
return { redirect: `${options.url}/signout?csrf=true`, cookies }
|
||||
case "callback":
|
||||
if (options.provider) {
|
||||
// Verified CSRF Token required for credentials providers only
|
||||
if (
|
||||
options.provider.type === "credentials" &&
|
||||
!options.csrfTokenVerified
|
||||
) {
|
||||
return { redirect: `${options.url}/signin?csrf=true`, cookies }
|
||||
}
|
||||
|
||||
const callback = await routes.callback({
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
options,
|
||||
sessionToken,
|
||||
codeVerifier,
|
||||
})
|
||||
if (callback.cookies) cookies.push(...callback.cookies)
|
||||
return { ...callback, cookies }
|
||||
}
|
||||
break
|
||||
case "_log":
|
||||
if (userOptions.logger) {
|
||||
try {
|
||||
const { code, level, ...metadata } = req.body ?? {}
|
||||
logger[level](code, metadata)
|
||||
} catch (error) {
|
||||
// If logging itself failed...
|
||||
logger.error("LOGGER_ERROR", error)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 400,
|
||||
body: `Error: Action ${action} with HTTP ${req.method} is not supported by NextAuth.js` as any,
|
||||
}
|
||||
}
|
||||
151
src/core/init.ts
151
src/core/init.ts
@@ -1,151 +0,0 @@
|
||||
import { NextAuthOptions } from ".."
|
||||
import logger from "../lib/logger"
|
||||
import parseUrl from "../lib/parse-url"
|
||||
import { InternalOptions } from "../lib/types"
|
||||
import { adapterErrorHandler, eventsErrorHandler } from "./errors"
|
||||
import parseProviders from "./lib/providers"
|
||||
import createSecret from "./lib/utils"
|
||||
import * as cookie from "./lib/cookie"
|
||||
import * as jwt from "../jwt"
|
||||
import { defaultCallbacks } from "./lib/default-callbacks"
|
||||
import { createCSRFToken } from "./lib/csrf-token"
|
||||
import { createCallbackUrl } from "./lib/callback-url"
|
||||
import { IncomingRequest } from "."
|
||||
|
||||
interface InitParams {
|
||||
host?: string
|
||||
userOptions: NextAuthOptions
|
||||
providerId?: string
|
||||
action: InternalOptions["action"]
|
||||
/** Callback URL value extracted from the incoming request. */
|
||||
callbackUrl?: string
|
||||
/** CSRF token value extracted from the incoming request. From body if POST, from query if GET */
|
||||
csrfToken?: string
|
||||
/** Is the incoming request a POST request? */
|
||||
isPost: boolean
|
||||
cookies: IncomingRequest["cookies"]
|
||||
}
|
||||
|
||||
/** Initialize all internal options and cookies. */
|
||||
export async function init({
|
||||
userOptions,
|
||||
providerId,
|
||||
action,
|
||||
host,
|
||||
cookies: reqCookies,
|
||||
callbackUrl: reqCallbackUrl,
|
||||
csrfToken: reqCsrfToken,
|
||||
isPost,
|
||||
}: InitParams): Promise<{
|
||||
options: InternalOptions
|
||||
cookies: cookie.Cookie[]
|
||||
}> {
|
||||
// If debug enabled, set ENV VAR so that logger logs debug messages
|
||||
if (userOptions.debug) {
|
||||
;(process.env._NEXTAUTH_DEBUG as any) = true
|
||||
}
|
||||
|
||||
const url = parseUrl(host)
|
||||
|
||||
const secret = createSecret({ userOptions, url })
|
||||
|
||||
const { providers, provider } = parseProviders({
|
||||
providers: userOptions.providers,
|
||||
url,
|
||||
providerId,
|
||||
})
|
||||
|
||||
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
|
||||
|
||||
// User provided options are overriden by other options,
|
||||
// except for the options with special handling above
|
||||
const options: InternalOptions = {
|
||||
debug: false,
|
||||
pages: {},
|
||||
theme: {
|
||||
colorScheme: "auto",
|
||||
logo: "",
|
||||
brandColor: "",
|
||||
},
|
||||
// Custom options override defaults
|
||||
...userOptions,
|
||||
// These computed settings can have values in userOptions but we override them
|
||||
// and are request-specific.
|
||||
url,
|
||||
action,
|
||||
provider,
|
||||
cookies: {
|
||||
...cookie.defaultCookies(
|
||||
userOptions.useSecureCookies ?? url.base.startsWith("https://")
|
||||
),
|
||||
// Allow user cookie options to override any cookie settings above
|
||||
...userOptions.cookies,
|
||||
},
|
||||
secret,
|
||||
providers,
|
||||
// Session options
|
||||
session: {
|
||||
jwt: !userOptions.adapter, // If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||
maxAge,
|
||||
updateAge: 24 * 60 * 60,
|
||||
...userOptions.session,
|
||||
},
|
||||
// JWT options
|
||||
jwt: {
|
||||
secret, // Use application secret if no keys specified
|
||||
maxAge, // same as session maxAge,
|
||||
encode: jwt.encode,
|
||||
decode: jwt.decode,
|
||||
...userOptions.jwt,
|
||||
},
|
||||
// Event messages
|
||||
events: eventsErrorHandler(userOptions.events ?? {}, logger),
|
||||
adapter: adapterErrorHandler(userOptions.adapter, logger),
|
||||
// Callback functions
|
||||
callbacks: { ...defaultCallbacks, ...userOptions.callbacks },
|
||||
logger,
|
||||
callbackUrl: process.env.NEXTAUTH_URL ?? "http://localhost:3000",
|
||||
}
|
||||
|
||||
// Init cookies
|
||||
|
||||
const cookies: cookie.Cookie[] = []
|
||||
|
||||
const {
|
||||
csrfToken,
|
||||
cookie: csrfCookie,
|
||||
csrfTokenVerified,
|
||||
} = createCSRFToken({
|
||||
options,
|
||||
cookieValue: reqCookies?.[options.cookies.csrfToken.name],
|
||||
isPost,
|
||||
bodyValue: reqCsrfToken,
|
||||
})
|
||||
|
||||
options.csrfToken = csrfToken
|
||||
options.csrfTokenVerified = csrfTokenVerified
|
||||
|
||||
if (csrfCookie) {
|
||||
cookies.push({
|
||||
name: options.cookies.csrfToken.name,
|
||||
value: csrfCookie,
|
||||
options: options.cookies.csrfToken.options,
|
||||
})
|
||||
}
|
||||
|
||||
const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({
|
||||
options,
|
||||
cookieValue: reqCookies?.[options.cookies.callbackUrl.name],
|
||||
paramValue: reqCallbackUrl,
|
||||
})
|
||||
options.callbackUrl = callbackUrl
|
||||
if (callbackUrlCookie) {
|
||||
cookies.push({
|
||||
name: options.cookies.callbackUrl.name,
|
||||
value: callbackUrlCookie,
|
||||
options: options.cookies.callbackUrl.options,
|
||||
})
|
||||
}
|
||||
|
||||
return { options, cookies }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
|
||||
interface CreateCallbackUrlParams {
|
||||
options: InternalOptions
|
||||
/** Try reading value from request body (POST) then from query param (GET) */
|
||||
paramValue?: string
|
||||
cookieValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback URL based on query param / cookie + validation,
|
||||
* and add it to `req.options.callbackUrl`.
|
||||
*/
|
||||
export async function createCallbackUrl({
|
||||
options,
|
||||
paramValue,
|
||||
cookieValue,
|
||||
}: CreateCallbackUrlParams) {
|
||||
const { url, callbacks } = options
|
||||
|
||||
let callbackUrl = url.origin
|
||||
|
||||
if (paramValue) {
|
||||
// If callbackUrl form field or query parameter is passed try to use it if allowed
|
||||
callbackUrl = await callbacks.redirect({
|
||||
url: paramValue,
|
||||
baseUrl: url.origin,
|
||||
})
|
||||
} else if (cookieValue) {
|
||||
// If no callbackUrl specified, try using the value from the cookie if allowed
|
||||
callbackUrl = await callbacks.redirect({
|
||||
url: cookieValue,
|
||||
baseUrl: url.origin,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
callbackUrl,
|
||||
// Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow
|
||||
callbackUrlCookie: callbackUrl !== cookieValue ? callbackUrl : undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createHash, randomBytes } from "crypto"
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
|
||||
interface CreateCSRFTokenParams {
|
||||
options: InternalOptions
|
||||
cookieValue?: string
|
||||
isPost: boolean
|
||||
bodyValue?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CSRF Token cookie is set for any subsequent requests.
|
||||
* Used as part of the strategy for mitigation for CSRF tokens.
|
||||
*
|
||||
* Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
|
||||
* where 'token' is the CSRF token and 'hash' is a hash made of the token and
|
||||
* the secret, and the two values are joined by a pipe '|'. By storing the
|
||||
* value and the hash of the value (with the secret used as a salt) we can
|
||||
* verify the cookie was set by the server and not by a malicous attacker.
|
||||
*
|
||||
* For more details, see the following OWASP links:
|
||||
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
||||
* https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
|
||||
*/
|
||||
export function createCSRFToken({
|
||||
options,
|
||||
cookieValue,
|
||||
isPost,
|
||||
bodyValue,
|
||||
}: CreateCSRFTokenParams) {
|
||||
if (cookieValue) {
|
||||
const [csrfToken, csrfTokenHash] = cookieValue.split("|")
|
||||
const expectedCsrfTokenHash = createHash("sha256")
|
||||
.update(`${csrfToken}${options.secret}`)
|
||||
.digest("hex")
|
||||
if (csrfTokenHash === expectedCsrfTokenHash) {
|
||||
// If hash matches then we trust the CSRF token value
|
||||
// If this is a POST request and the CSRF Token in the POST request matches
|
||||
// the cookie we have already verified is the one we have set, then the token is verified!
|
||||
const csrfTokenVerified = isPost && csrfToken === bodyValue
|
||||
|
||||
return { csrfTokenVerified, csrfToken }
|
||||
}
|
||||
}
|
||||
|
||||
// New CSRF token
|
||||
const csrfToken = randomBytes(32).toString("hex")
|
||||
const csrfTokenHash = createHash("sha256")
|
||||
.update(`${csrfToken}${options.secret}`)
|
||||
.digest("hex")
|
||||
const cookie = `${csrfToken}|${csrfTokenHash}`
|
||||
|
||||
return { cookie, csrfToken }
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { CallbacksOptions } from "../.."
|
||||
|
||||
export const defaultCallbacks: CallbacksOptions = {
|
||||
signIn() {
|
||||
return true
|
||||
},
|
||||
redirect({ url, baseUrl }) {
|
||||
if (url.startsWith(baseUrl)) return url
|
||||
return baseUrl
|
||||
},
|
||||
session({ session }) {
|
||||
return session
|
||||
},
|
||||
jwt({ token }) {
|
||||
return token
|
||||
},
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { randomBytes } from "crypto"
|
||||
import { InternalOptions } from "../../../lib/types"
|
||||
import { hashToken } from "../utils"
|
||||
|
||||
/**
|
||||
* Starts an e-mail login flow, by generating a token,
|
||||
* and sending it to the user's e-mail (with the help of a DB adapter)
|
||||
*/
|
||||
export default async function email(
|
||||
identifier: string,
|
||||
options: InternalOptions<"email">
|
||||
) {
|
||||
const { url, adapter, provider, logger, callbackUrl } = options
|
||||
|
||||
// Generate token
|
||||
const token =
|
||||
(await provider.generateVerificationToken?.()) ??
|
||||
randomBytes(32).toString("hex")
|
||||
|
||||
const ONE_DAY_IN_SECONDS = 86400
|
||||
const expires = new Date(
|
||||
Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
|
||||
)
|
||||
|
||||
// Save in database
|
||||
// @ts-expect-error
|
||||
await adapter.createVerificationToken({
|
||||
identifier,
|
||||
token: hashToken(token, options),
|
||||
expires,
|
||||
})
|
||||
|
||||
// Generate a link with email, unhashed token and callback url
|
||||
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
|
||||
const _url = `${url}/callback/${provider.id}?${params}`
|
||||
|
||||
try {
|
||||
// Send to user
|
||||
await provider.sendVerificationRequest({
|
||||
identifier,
|
||||
token,
|
||||
expires,
|
||||
url: _url,
|
||||
provider,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error("SEND_VERIFICATION_EMAIL_ERROR", {
|
||||
identifier,
|
||||
url,
|
||||
error,
|
||||
})
|
||||
throw new Error("SEND_VERIFICATION_EMAIL_ERROR")
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { openidClient } from "./client"
|
||||
import { oAuth1Client } from "./client-legacy"
|
||||
import { createState } from "./state-handler"
|
||||
import { createPKCE } from "./pkce-handler"
|
||||
import { InternalOptions } from "../../../lib/types"
|
||||
import { IncomingRequest } from "../.."
|
||||
import { Cookie } from "../cookie"
|
||||
|
||||
/**
|
||||
*
|
||||
* Generates an authorization/request token URL.
|
||||
*
|
||||
* [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/) | [OAuth 1](https://oauth.net/core/1.0a/#auth_step2)
|
||||
*/
|
||||
export default async function getAuthorizationUrl(params: {
|
||||
options: InternalOptions<"oauth">
|
||||
query: IncomingRequest["query"]
|
||||
}) {
|
||||
const { options, query } = params
|
||||
const { logger, provider } = options
|
||||
try {
|
||||
let params: any = {}
|
||||
|
||||
if (typeof provider.authorization === "string") {
|
||||
const parsedUrl = new URL(provider.authorization)
|
||||
const parsedParams = Object.fromEntries(parsedUrl.searchParams.entries())
|
||||
params = { ...params, ...parsedParams }
|
||||
} else {
|
||||
params = { ...params, ...provider.authorization?.params }
|
||||
}
|
||||
|
||||
params = { ...params, ...query }
|
||||
|
||||
// Handle OAuth v1.x
|
||||
if (provider.version?.startsWith("1.")) {
|
||||
const client = oAuth1Client(options)
|
||||
const tokens = (await client.getOAuthRequestToken(params)) as any
|
||||
const url = `${
|
||||
// @ts-expect-error
|
||||
provider.authorization?.url ?? provider.authorization
|
||||
}?${new URLSearchParams({
|
||||
oauth_token: tokens.oauth_token,
|
||||
oauth_token_secret: tokens.oauth_token_secret,
|
||||
...tokens.params,
|
||||
})}`
|
||||
|
||||
logger.debug("GET_AUTHORIZATION_URL", { url })
|
||||
return { redirect: url }
|
||||
}
|
||||
|
||||
const cookies: Cookie[] = []
|
||||
const client = await openidClient(options)
|
||||
const pkce = await createPKCE(options)
|
||||
if (pkce?.cookie) {
|
||||
cookies.push(pkce.cookie)
|
||||
}
|
||||
|
||||
const url = client.authorizationUrl({
|
||||
...params,
|
||||
...pkce,
|
||||
state: createState(options),
|
||||
})
|
||||
|
||||
logger.debug("GET_AUTHORIZATION_URL", { url })
|
||||
return {
|
||||
redirect: url,
|
||||
cookies,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("GET_AUTHORIZATION_URL_ERROR", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
import { TokenSet } from "openid-client"
|
||||
import { openidClient } from "./client"
|
||||
import { oAuth1Client } from "./client-legacy"
|
||||
import { getState } from "./state-handler"
|
||||
import { usePKCECodeVerifier } from "./pkce-handler"
|
||||
import { OAuthCallbackError } from "../../errors"
|
||||
import { Account, LoggerInstance, Profile } from "../../.."
|
||||
import { OAuthChecks, OAuthConfig } from "../../../providers"
|
||||
import { InternalOptions } from "../../../lib/types"
|
||||
import { IncomingRequest, OutgoingResponse } from "../.."
|
||||
|
||||
export default async function oAuthCallback(params: {
|
||||
options: InternalOptions<"oauth">
|
||||
query: IncomingRequest["query"]
|
||||
body: IncomingRequest["body"]
|
||||
method: IncomingRequest["method"]
|
||||
codeVerifier?: string
|
||||
}): Promise<GetProfileResult & { cookies?: OutgoingResponse["cookies"] }> {
|
||||
const { options, query, body, method, codeVerifier } = params
|
||||
const { logger, provider } = options
|
||||
|
||||
const errorMessage = body?.error ?? query?.error
|
||||
if (errorMessage) {
|
||||
const error = new Error(errorMessage)
|
||||
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", {
|
||||
error,
|
||||
error_description: query?.error_description,
|
||||
body,
|
||||
providerId: provider.id,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
if (provider.version?.startsWith("1.")) {
|
||||
try {
|
||||
const client = await oAuth1Client(options)
|
||||
// Handle OAuth v1.x
|
||||
const { oauth_token, oauth_verifier } = query ?? {}
|
||||
// @ts-expect-error
|
||||
const tokens: TokenSet = await client.getOAuthAccessToken(
|
||||
oauth_token,
|
||||
// @ts-expect-error
|
||||
null,
|
||||
oauth_verifier
|
||||
)
|
||||
// @ts-expect-error
|
||||
let profile: Profile = await client.get(
|
||||
provider.profileUrl,
|
||||
tokens.oauth_token,
|
||||
tokens.oauth_token_secret
|
||||
)
|
||||
|
||||
if (typeof profile === "string") {
|
||||
profile = JSON.parse(profile)
|
||||
}
|
||||
|
||||
return await getProfile({ profile, tokens, provider, logger })
|
||||
} catch (error) {
|
||||
logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await openidClient(options)
|
||||
|
||||
let tokens: TokenSet
|
||||
|
||||
const pkce = await usePKCECodeVerifier({
|
||||
options,
|
||||
codeVerifier,
|
||||
})
|
||||
const checks: OAuthChecks = {
|
||||
code_verifier: pkce?.codeVerifier,
|
||||
state: getState(options),
|
||||
}
|
||||
const params = {
|
||||
...client.callbackParams({
|
||||
url: `http://n?${new URLSearchParams(query)}`,
|
||||
// TODO: Ask to allow object to be passed upstream:
|
||||
// https://github.com/panva/node-openid-client/blob/3ae206dfc78c02134aa87a07f693052c637cab84/types/index.d.ts#L439
|
||||
// @ts-expect-error
|
||||
body,
|
||||
method,
|
||||
}),
|
||||
// @ts-expect-error
|
||||
...provider.token?.params,
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
if (provider.token?.request) {
|
||||
// @ts-expect-error
|
||||
const response = await provider.token.request({
|
||||
provider,
|
||||
params,
|
||||
checks,
|
||||
client,
|
||||
})
|
||||
tokens = new TokenSet(response.tokens)
|
||||
} else if (provider.idToken) {
|
||||
tokens = await client.callback(provider.callbackUrl, params, checks)
|
||||
} else {
|
||||
tokens = await client.oauthCallback(provider.callbackUrl, params, checks)
|
||||
}
|
||||
|
||||
// REVIEW: How can scope be returned as an array?
|
||||
if (Array.isArray(tokens.scope)) {
|
||||
tokens.scope = tokens.scope.join(" ")
|
||||
}
|
||||
|
||||
let profile: Profile
|
||||
// @ts-expect-error
|
||||
if (provider.userinfo?.request) {
|
||||
// @ts-expect-error
|
||||
profile = await provider.userinfo.request({
|
||||
provider,
|
||||
tokens,
|
||||
client,
|
||||
})
|
||||
} else if (provider.idToken) {
|
||||
profile = tokens.claims()
|
||||
} else {
|
||||
profile = await client.userinfo(tokens, {
|
||||
// @ts-expect-error
|
||||
params: provider.userinfo?.params,
|
||||
})
|
||||
}
|
||||
|
||||
// If a user object is supplied (e.g. Apple provider) add it to the profile object
|
||||
// TODO: Remove/extract to Apple provider?
|
||||
profile.user = JSON.parse(body?.user ?? query?.user ?? null)
|
||||
|
||||
const profileResult = await getProfile({
|
||||
profile,
|
||||
provider,
|
||||
tokens,
|
||||
logger,
|
||||
})
|
||||
return {
|
||||
...profileResult,
|
||||
cookies: pkce?.cookie ? [pkce.cookie] : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("OAUTH_CALLBACK_ERROR", { error, providerId: provider.id })
|
||||
throw new OAuthCallbackError(error)
|
||||
}
|
||||
}
|
||||
|
||||
export interface GetProfileParams {
|
||||
profile: Profile
|
||||
tokens: TokenSet
|
||||
provider: OAuthConfig<any>
|
||||
logger: LoggerInstance
|
||||
}
|
||||
|
||||
export interface GetProfileResult {
|
||||
// @ts-expect-error
|
||||
profile: ReturnType<OAuthConfig["profile"]> | null
|
||||
account: Omit<Account, "userId"> | null
|
||||
OAuthProfile: Profile
|
||||
}
|
||||
|
||||
/** Returns profile, raw profile and auth provider details */
|
||||
async function getProfile({
|
||||
profile: OAuthProfile,
|
||||
tokens,
|
||||
provider,
|
||||
logger,
|
||||
}: GetProfileParams): Promise<GetProfileResult> {
|
||||
try {
|
||||
logger.debug("PROFILE_DATA", { OAuthProfile })
|
||||
// @ts-expect-error
|
||||
const profile = await provider.profile(OAuthProfile, tokens)
|
||||
profile.email = profile.email?.toLowerCase()
|
||||
// Return profile, raw profile and auth provider details
|
||||
return {
|
||||
profile,
|
||||
account: {
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
providerAccountId: profile.id.toString(),
|
||||
...tokens,
|
||||
},
|
||||
OAuthProfile,
|
||||
}
|
||||
} catch (error) {
|
||||
// If we didn't get a response either there was a problem with the provider
|
||||
// response *or* the user cancelled the action with the provider.
|
||||
//
|
||||
// Unfortuately, we can't tell which - at least not in a way that works for
|
||||
// all providers, so we return an empty object; the user should then be
|
||||
// redirected back to the sign up page. We log the error to help developers
|
||||
// who might be trying to debug this when configuring a new provider.
|
||||
logger.error("OAUTH_PARSE_PROFILE_ERROR", { error, OAuthProfile })
|
||||
return {
|
||||
profile: null,
|
||||
account: null,
|
||||
OAuthProfile,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// This is kept around for being backwards compatible with OAuth 1.0 providers.
|
||||
// We have the intentions to provide only minor fixes for this in the future.
|
||||
|
||||
import { OAuth } from "oauth"
|
||||
import { InternalOptions } from "src/lib/types"
|
||||
|
||||
/**
|
||||
* Client supporting OAuth 1.x
|
||||
*/
|
||||
export function oAuth1Client(options: InternalOptions<"oauth">) {
|
||||
const provider = options.provider
|
||||
|
||||
const oauth1Client = new OAuth(
|
||||
provider.requestTokenUrl as string,
|
||||
provider.accessTokenUrl as string,
|
||||
provider.clientId as string,
|
||||
provider.clientSecret as string,
|
||||
provider.version ?? "1.0",
|
||||
provider.callbackUrl,
|
||||
provider.encoding ?? "HMAC-SHA1"
|
||||
)
|
||||
|
||||
// Promisify get() for OAuth1
|
||||
const originalGet = oauth1Client.get.bind(oauth1Client)
|
||||
// @ts-expect-error
|
||||
oauth1Client.get = async (...args) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
originalGet(...args, (error, result) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
// Promisify getOAuth1AccessToken() for OAuth1
|
||||
const originalGetOAuth1AccessToken =
|
||||
oauth1Client.getOAuthAccessToken.bind(oauth1Client)
|
||||
oauth1Client.getOAuthAccessToken = async (...args: any[]) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
originalGetOAuth1AccessToken(
|
||||
...args,
|
||||
(error, oauth_token, oauth_token_secret) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve({ oauth_token, oauth_token_secret })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const originalGetOAuthRequestToken =
|
||||
oauth1Client.getOAuthRequestToken.bind(oauth1Client)
|
||||
oauth1Client.getOAuthRequestToken = async (params = {}) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
originalGetOAuthRequestToken(
|
||||
params,
|
||||
(error, oauth_token, oauth_token_secret, params) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve({ oauth_token, oauth_token_secret, params })
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
return oauth1Client
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Issuer, Client } from "openid-client"
|
||||
import { InternalOptions } from "src/lib/types"
|
||||
|
||||
/**
|
||||
* NOTE: We can add auto discovery of the provider's endpoint
|
||||
* that requires only one endpoint to be specified by the user.
|
||||
* Check out `Issuer.discover`
|
||||
*
|
||||
* Client supporting OAuth 2.x and OIDC
|
||||
*/
|
||||
export async function openidClient(
|
||||
options: InternalOptions<"oauth">
|
||||
): Promise<Client> {
|
||||
const provider = options.provider
|
||||
|
||||
let issuer
|
||||
if (provider.wellKnown) {
|
||||
issuer = await Issuer.discover(provider.wellKnown)
|
||||
} else {
|
||||
issuer = new Issuer({
|
||||
issuer: provider.issuer as string,
|
||||
authorization_endpoint:
|
||||
// @ts-expect-error
|
||||
provider.authorization?.url ?? provider.authorization,
|
||||
// @ts-expect-error
|
||||
token_endpoint: provider.token?.url ?? provider.token,
|
||||
// @ts-expect-error
|
||||
userinfo_endpoint: provider.userinfo?.url ?? provider.userinfo,
|
||||
})
|
||||
}
|
||||
|
||||
const client = new issuer.Client(
|
||||
{
|
||||
client_id: provider.clientId,
|
||||
client_secret: provider.clientSecret,
|
||||
redirect_uris: [provider.callbackUrl],
|
||||
...provider.client,
|
||||
},
|
||||
provider.jwks
|
||||
)
|
||||
|
||||
return client
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import * as cookie from "../cookie"
|
||||
import * as jwt from "../../../jwt"
|
||||
import { generators } from "openid-client"
|
||||
import { InternalOptions } from "src/lib/types"
|
||||
|
||||
const PKCE_CODE_CHALLENGE_METHOD = "S256"
|
||||
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
|
||||
/**
|
||||
* Returns `code_challenge` and `code_challenge_method`
|
||||
* and saves them in a cookie.
|
||||
* @type {import("src/lib/types").InternalOptions}
|
||||
* @returns {Promise<undefined | {
|
||||
* code_challenge: string
|
||||
* code_challenge_method: "S256"
|
||||
* cookie: import("../cookie").Cookie
|
||||
* }>
|
||||
*/
|
||||
export async function createPKCE(options) {
|
||||
const { cookies, logger } = options
|
||||
/** @type {import("src/providers").OAuthConfig} */
|
||||
const provider = options.provider
|
||||
if (!provider.checks?.includes("pkce")) {
|
||||
// Provider does not support PKCE, return nothing.
|
||||
return
|
||||
}
|
||||
const codeVerifier = generators.codeVerifier()
|
||||
const codeChallenge = generators.codeChallenge(codeVerifier)
|
||||
|
||||
// Encrypt code_verifier and save it to an encrypted cookie
|
||||
const encryptedCodeVerifier = await jwt.encode({
|
||||
maxAge: PKCE_MAX_AGE,
|
||||
...options.jwt,
|
||||
token: { code_verifier: codeVerifier },
|
||||
})
|
||||
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + PKCE_MAX_AGE * 1000)
|
||||
|
||||
logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", {
|
||||
pkce: {
|
||||
code_challenge: codeChallenge,
|
||||
code_verifier: codeVerifier,
|
||||
},
|
||||
method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
})
|
||||
return {
|
||||
cookie: {
|
||||
name: cookies.pkceCodeVerifier.name,
|
||||
value: encryptedCodeVerifier,
|
||||
options: {
|
||||
expires: cookieExpires.toISOString(),
|
||||
...cookies.pkceCodeVerifier.options,
|
||||
},
|
||||
},
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns code_verifier if provider uses PKCE,
|
||||
* and clears the cookie afterwards.
|
||||
*/
|
||||
export async function usePKCECodeVerifier(params: {
|
||||
options: InternalOptions<"oauth">
|
||||
codeVerifier?: string
|
||||
}): Promise<
|
||||
| {
|
||||
codeVerifier?: string
|
||||
cookie?: cookie.Cookie
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const { options, codeVerifier } = params
|
||||
const { cookies, provider } = options
|
||||
|
||||
if (!provider?.checks?.includes("pkce") || !codeVerifier) {
|
||||
return
|
||||
}
|
||||
|
||||
const pkce = await jwt.decode({
|
||||
...options.jwt,
|
||||
token: codeVerifier,
|
||||
})
|
||||
|
||||
// remove PKCE cookie after it has been used up
|
||||
const cookie: cookie.Cookie = {
|
||||
name: cookies.pkceCodeVerifier.name,
|
||||
value: "",
|
||||
options: {
|
||||
...cookies.pkceCodeVerifier.options,
|
||||
maxAge: 0,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
codeVerifier: (pkce?.code_verifier as any) ?? undefined,
|
||||
cookie,
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { createHash } from "crypto"
|
||||
import { InternalOptions } from "src/lib/types"
|
||||
|
||||
/** Returns state if provider supports it */
|
||||
export function createState(options: InternalOptions<"oauth">) {
|
||||
const { csrfToken, logger, provider } = options
|
||||
|
||||
if (!provider.checks?.includes("state")) {
|
||||
// Provider does not support state, return nothing
|
||||
return
|
||||
}
|
||||
|
||||
if (!csrfToken) {
|
||||
logger.warn("NO_CSRF_TOKEN")
|
||||
return
|
||||
}
|
||||
|
||||
// A hash of the NextAuth.js CSRF token is used as the state
|
||||
const state = createHash("sha256").update(csrfToken).digest("hex")
|
||||
|
||||
logger.debug("OAUTH_CALLBACK_PROTECTION", { state, csrfToken })
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Consistently recreate state from the csrfToken
|
||||
* if `provider.checks` supports `"state"`.
|
||||
*/
|
||||
export function getState({ provider, csrfToken }: InternalOptions<"oauth">) {
|
||||
if (provider?.checks?.includes("state") && csrfToken) {
|
||||
return createHash("sha256").update(csrfToken).digest("hex")
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { InternalProvider } from "../../lib/types"
|
||||
import { Provider } from "../../providers"
|
||||
import { merge } from "../../lib/merge"
|
||||
import { InternalUrl } from "../../lib/parse-url"
|
||||
|
||||
/**
|
||||
* Adds `signinUrl` and `callbackUrl` to each provider
|
||||
* and deep merge user-defined options.
|
||||
*/
|
||||
export default function parseProviders(params: {
|
||||
providers: Provider[]
|
||||
url: InternalUrl
|
||||
providerId?: string
|
||||
}): {
|
||||
providers: InternalProvider[]
|
||||
provider?: InternalProvider
|
||||
} {
|
||||
const { url, providerId } = params
|
||||
|
||||
const providers = params.providers.map(({ options, ...rest }) => {
|
||||
const defaultOptions = normalizeProvider(rest as Provider)
|
||||
const userOptions = normalizeProvider(options as Provider)
|
||||
|
||||
return merge(defaultOptions, {
|
||||
...userOptions,
|
||||
signinUrl: `${url}/signin/${userOptions?.id ?? rest.id}`,
|
||||
callbackUrl: `${url}/callback/${userOptions?.id ?? rest.id}`,
|
||||
})
|
||||
})
|
||||
|
||||
const provider = providers.find(({ id }) => id === providerId)
|
||||
|
||||
return { providers, provider }
|
||||
}
|
||||
|
||||
function normalizeProvider(provider?: Provider) {
|
||||
if (!provider) return
|
||||
|
||||
const normalizedProvider: any = Object.entries(provider).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (
|
||||
["authorization", "token", "userinfo"].includes(key) &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
const url = new URL(value)
|
||||
acc[key] = {
|
||||
url: `${url.origin}${url.pathname}`,
|
||||
params: Object.fromEntries(url.searchParams ?? []),
|
||||
}
|
||||
} else {
|
||||
acc[key] = value
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
// Checks only work on OAuth 2.x + OIDC providers
|
||||
if (
|
||||
provider.type === "oauth" &&
|
||||
!provider.version?.startsWith("1.") &&
|
||||
!provider.checks
|
||||
) {
|
||||
normalizedProvider.checks = ["state"]
|
||||
}
|
||||
return normalizedProvider as InternalProvider
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { createHash } from "crypto"
|
||||
import { NextAuthOptions } from "../.."
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
import { InternalUrl } from "../../lib/parse-url"
|
||||
|
||||
/**
|
||||
* Takes a number in seconds and returns the date in the future.
|
||||
* Optionally takes a second date parameter. In that case
|
||||
* the date in the future will be calculated from that date instead of now.
|
||||
*/
|
||||
export function fromDate(time, date = Date.now()) {
|
||||
return new Date(date + time * 1000)
|
||||
}
|
||||
|
||||
export function hashToken(token: string, options: InternalOptions<"email">) {
|
||||
const { provider, secret } = options
|
||||
return (
|
||||
createHash("sha256")
|
||||
// Prefer provider specific secret, but use default secret if none specified
|
||||
.update(`${token}${provider.secret ?? secret}`)
|
||||
.digest("hex")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret used salt cookies and tokens (e.g. for CSRF protection).
|
||||
* If no secret option is specified then it creates one on the fly
|
||||
* based on options passed here. A options contains unique data, such as
|
||||
* OAuth provider secrets and database credentials it should be sufficent.
|
||||
*/
|
||||
export default function createSecret(params: {
|
||||
userOptions: NextAuthOptions
|
||||
url: InternalUrl
|
||||
}) {
|
||||
const { userOptions, url } = params
|
||||
|
||||
return (
|
||||
userOptions.secret ??
|
||||
createHash("sha256")
|
||||
.update(JSON.stringify({ ...url, ...userOptions }))
|
||||
.digest("hex")
|
||||
)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Theme } from "../.."
|
||||
import { InternalUrl } from "../../lib/parse-url"
|
||||
|
||||
export interface ErrorProps {
|
||||
url: InternalUrl
|
||||
theme: Theme
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Renders an error page. */
|
||||
export default function ErrorPage(props: ErrorProps) {
|
||||
const { url, error = "default", theme } = props
|
||||
const signinPageUrl = `${url}/signin`
|
||||
|
||||
const errors = {
|
||||
default: {
|
||||
status: 200,
|
||||
heading: "Error",
|
||||
message: (
|
||||
<p>
|
||||
<a className="site" href={url.origin}>
|
||||
{url.host}
|
||||
</a>
|
||||
</p>
|
||||
),
|
||||
},
|
||||
configuration: {
|
||||
status: 500,
|
||||
heading: "Server error",
|
||||
message: (
|
||||
<div>
|
||||
<p>There is a problem with the server configuration.</p>
|
||||
<p>Check the server logs for more information.</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
accessdenied: {
|
||||
status: 403,
|
||||
heading: "Access Denied",
|
||||
message: (
|
||||
<div>
|
||||
<p>You do not have permission to sign in.</p>
|
||||
<p>
|
||||
<a className="button" href={signinPageUrl}>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
verification: {
|
||||
status: 403,
|
||||
heading: "Unable to sign in",
|
||||
message: (
|
||||
<div>
|
||||
<p>The sign in link is no longer valid.</p>
|
||||
<p>It may have been used already or it may have expired.</p>
|
||||
</div>
|
||||
),
|
||||
signin: (
|
||||
<p>
|
||||
<a className="button" href={signinPageUrl}>
|
||||
Sign in
|
||||
</a>
|
||||
</p>
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
const { status, heading, message, signin } =
|
||||
errors[error.toLowerCase()] ?? errors.default
|
||||
|
||||
return {
|
||||
status,
|
||||
html: (
|
||||
<div className="error">
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
<h1>{heading}</h1>
|
||||
<div className="message">{message}</div>
|
||||
{signin}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import renderToString from "preact-render-to-string"
|
||||
import SigninPage from "./signin"
|
||||
import SignoutPage from "./signout"
|
||||
import VerifyRequestPage from "./verify-request"
|
||||
import ErrorPage from "./error"
|
||||
import css from "../../css"
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
import { IncomingRequest, OutgoingResponse } from ".."
|
||||
import { Cookie } from "../lib/cookie"
|
||||
|
||||
/** Takes a request and response, and gives renderable pages */
|
||||
export default function renderPage({
|
||||
options,
|
||||
query,
|
||||
cookies,
|
||||
}: {
|
||||
options: InternalOptions
|
||||
query: IncomingRequest["query"]
|
||||
cookies: Cookie[]
|
||||
}) {
|
||||
const { url, callbackUrl, csrfToken, providers, theme } = options
|
||||
|
||||
function send({ html, title, status }: any): OutgoingResponse {
|
||||
return {
|
||||
cookies,
|
||||
status,
|
||||
headers: [{ key: "Content-Type", value: "text/html" }],
|
||||
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css()}</style><title>${title}</title></head><body class="__next-auth-theme-${
|
||||
theme.colorScheme
|
||||
}"><div class="page">${renderToString(html)}</div></body></html>`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signin(props?: any) {
|
||||
return send({
|
||||
html: SigninPage({
|
||||
csrfToken,
|
||||
providers,
|
||||
callbackUrl,
|
||||
theme,
|
||||
...query,
|
||||
...props,
|
||||
}),
|
||||
title: "Sign In",
|
||||
})
|
||||
},
|
||||
signout(props?: any) {
|
||||
return send({
|
||||
html: SignoutPage({ csrfToken, url, theme, ...props }),
|
||||
title: "Sign Out",
|
||||
})
|
||||
},
|
||||
verifyRequest(props?: any) {
|
||||
return send({
|
||||
html: VerifyRequestPage({ url, theme, ...props }),
|
||||
title: "Verify Request",
|
||||
})
|
||||
},
|
||||
error(props) {
|
||||
return send({
|
||||
...ErrorPage({ url, theme, ...props }),
|
||||
title: "Error",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
export default function SigninPage(props) {
|
||||
const {
|
||||
csrfToken,
|
||||
providers,
|
||||
callbackUrl,
|
||||
theme,
|
||||
email,
|
||||
error: errorType,
|
||||
} = props
|
||||
// We only want to render providers
|
||||
const providersToRender = providers.filter((provider) => {
|
||||
if (provider.type === "oauth" || provider.type === "email") {
|
||||
// Always render oauth and email type providers
|
||||
return true
|
||||
} else if (provider.type === "credentials" && provider.credentials) {
|
||||
// Only render credentials type provider if credentials are defined
|
||||
return true
|
||||
}
|
||||
// Don't render other provider types
|
||||
return false
|
||||
})
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.style.setProperty(
|
||||
"--brand-color",
|
||||
theme.brandColor
|
||||
)
|
||||
}
|
||||
|
||||
const errors = {
|
||||
Signin: "Try signing in with a different account.",
|
||||
OAuthSignin: "Try signing in with a different account.",
|
||||
OAuthCallback: "Try signing in with a different account.",
|
||||
OAuthCreateAccount: "Try signing in with a different account.",
|
||||
EmailCreateAccount: "Try signing in with a different account.",
|
||||
Callback: "Try signing in with a different account.",
|
||||
OAuthAccountNotLinked:
|
||||
"To confirm your identity, sign in with the same account you used originally.",
|
||||
EmailSignin: "Check your email inbox.",
|
||||
CredentialsSignin:
|
||||
"Sign in failed. Check the details you provided are correct.",
|
||||
default: "Unable to sign in.",
|
||||
}
|
||||
|
||||
const error = errorType && (errors[errorType] ?? errors.default)
|
||||
|
||||
return (
|
||||
<div className="signin">
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
{error && (
|
||||
<div className="error">
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{providersToRender.map((provider, i: number) => (
|
||||
<div key={provider.id} className="provider">
|
||||
{provider.type === "oauth" && (
|
||||
<form action={provider.signinUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
{callbackUrl && (
|
||||
<input type="hidden" name="callbackUrl" value={callbackUrl} />
|
||||
)}
|
||||
<button type="submit" className="button">
|
||||
Sign in with {provider.name}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
{(provider.type === "email" || provider.type === "credentials") &&
|
||||
i > 0 &&
|
||||
providersToRender[i - 1].type !== "email" &&
|
||||
providersToRender[i - 1].type !== "credentials" && <hr />}
|
||||
{provider.type === "email" && (
|
||||
<form action={provider.signinUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<label
|
||||
className="section-header"
|
||||
htmlFor={`input-email-for-${provider.id}-provider`}
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id={`input-email-for-${provider.id}-provider`}
|
||||
autoFocus
|
||||
type="text"
|
||||
name="email"
|
||||
value={email}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<button type="submit">Sign in with {provider.name}</button>
|
||||
</form>
|
||||
)}
|
||||
{provider.type === "credentials" && (
|
||||
<form action={provider.callbackUrl} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
{Object.keys(provider.credentials).map((credential) => {
|
||||
return (
|
||||
<div key={`input-group-${provider.id}`}>
|
||||
<label
|
||||
className="section-header"
|
||||
htmlFor={`input-${credential}-for-${provider.id}-provider`}
|
||||
>
|
||||
{provider.credentials[credential].label || credential}
|
||||
</label>
|
||||
<input
|
||||
name={credential}
|
||||
id={`input-${credential}-for-${provider.id}-provider`}
|
||||
type={provider.credentials[credential].type || "text"}
|
||||
placeholder={
|
||||
provider.credentials[credential].placeholder ||
|
||||
"Password"
|
||||
}
|
||||
{...provider.credentials[credential]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button type="submit">Sign in with {provider.name}</button>
|
||||
</form>
|
||||
)}
|
||||
{(provider.type === "email" || provider.type === "credentials") &&
|
||||
i + 1 < providersToRender.length && <hr />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Theme } from "../.."
|
||||
import { InternalUrl } from "../../lib/parse-url"
|
||||
|
||||
export interface SignoutProps {
|
||||
url: InternalUrl
|
||||
csrfToken: string
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default function SignoutPage(props: SignoutProps) {
|
||||
const { url, csrfToken, theme } = props
|
||||
|
||||
return (
|
||||
<div className="signout">
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
<h1>Signout</h1>
|
||||
<p>Are you sure you want to sign out?</p>
|
||||
<form action={`${url}/signout`} method="POST">
|
||||
<input type="hidden" name="csrfToken" value={csrfToken} />
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Theme } from "../.."
|
||||
import { InternalUrl } from "../../lib/parse-url"
|
||||
|
||||
interface VerifyRequestPageProps {
|
||||
url: InternalUrl
|
||||
theme: Theme
|
||||
}
|
||||
|
||||
export default function VerifyRequestPage(props: VerifyRequestPageProps) {
|
||||
const { url, theme } = props
|
||||
|
||||
return (
|
||||
<div className="verify-request">
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
:root {
|
||||
--brand-color: ${theme.brandColor}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
|
||||
<div className="card">
|
||||
<h1>Check your email</h1>
|
||||
<p>A sign in link has been sent to your email address.</p>
|
||||
<p>
|
||||
<a className="site" href={url.origin}>
|
||||
{url.host}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
import oAuthCallback from "../lib/oauth/callback"
|
||||
import callbackHandler from "../lib/callback-handler"
|
||||
import * as cookie from "../lib/cookie"
|
||||
import { hashToken } from "../lib/utils"
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
import { IncomingRequest, OutgoingResponse } from ".."
|
||||
|
||||
/** Handle callbacks from login services */
|
||||
export default async function callback(params: {
|
||||
options: InternalOptions<"oauth" | "credentials" | "email">
|
||||
query: IncomingRequest["query"]
|
||||
method: IncomingRequest["method"]
|
||||
body: IncomingRequest["body"]
|
||||
headers: IncomingRequest["headers"]
|
||||
sessionToken?: string
|
||||
codeVerifier?: string
|
||||
}): Promise<OutgoingResponse> {
|
||||
const { options, query, body, method, headers, sessionToken, codeVerifier } =
|
||||
params
|
||||
const {
|
||||
provider,
|
||||
adapter,
|
||||
url,
|
||||
callbackUrl,
|
||||
pages,
|
||||
jwt,
|
||||
events,
|
||||
callbacks,
|
||||
session: { jwt: useJwtSession, maxAge: sessionMaxAge },
|
||||
logger,
|
||||
} = options
|
||||
|
||||
const cookies: cookie.Cookie[] = []
|
||||
|
||||
if (provider.type === "oauth") {
|
||||
try {
|
||||
const {
|
||||
profile,
|
||||
account,
|
||||
OAuthProfile,
|
||||
cookies: oauthCookies,
|
||||
} = await oAuthCallback({
|
||||
query,
|
||||
body,
|
||||
method,
|
||||
options,
|
||||
codeVerifier,
|
||||
})
|
||||
|
||||
if (oauthCookies) cookies.push(...oauthCookies)
|
||||
|
||||
try {
|
||||
// Make it easier to debug when adding a new provider
|
||||
logger.debug("OAUTH_CALLBACK_RESPONSE", {
|
||||
profile,
|
||||
account,
|
||||
OAuthProfile,
|
||||
})
|
||||
|
||||
// If we don't have a profile object then either something went wrong
|
||||
// or the user cancelled signing in. We don't know which, so we just
|
||||
// direct the user to the signin page for now. We could do something
|
||||
// else in future.
|
||||
//
|
||||
// Note: In oAuthCallback an error is logged with debug info, so it
|
||||
// should at least be visible to developers what happened if it is an
|
||||
// error with the provider.
|
||||
if (!profile) {
|
||||
return { redirect: `${url}/signin`, cookies }
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
// Attempt to get Profile from OAuth provider details before invoking
|
||||
// signIn callback - but if no user object is returned, that is fine
|
||||
// (that just means it's a new user signing in for the first time).
|
||||
let userOrProfile = profile
|
||||
if (adapter) {
|
||||
const { getUserByAccount } = adapter
|
||||
const userByAccount = await getUserByAccount({
|
||||
// @ts-expect-error
|
||||
providerAccountId: account.providerAccountId,
|
||||
provider: provider.id,
|
||||
})
|
||||
|
||||
if (userByAccount) userOrProfile = userByAccount
|
||||
}
|
||||
|
||||
try {
|
||||
const isAllowed = await callbacks.signIn({
|
||||
user: userOrProfile,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
profile: OAuthProfile,
|
||||
})
|
||||
if (!isAllowed) {
|
||||
return { redirect: `${url}/error?error=AccessDenied`, cookies }
|
||||
} else if (typeof isAllowed === "string") {
|
||||
return { redirect: isAllowed, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
// @ts-expect-error
|
||||
const { user, session, isNewUser } = await callbackHandler({
|
||||
sessionToken,
|
||||
profile,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
options,
|
||||
})
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultToken = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString(),
|
||||
}
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
profile: OAuthProfile,
|
||||
isNewUser,
|
||||
})
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
|
||||
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: newEncodedJwt,
|
||||
options: {
|
||||
expires: cookieExpires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: session.sessionToken,
|
||||
options: {
|
||||
expires: session.expires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
await events.signIn?.({ user, account, profile, isNewUser })
|
||||
|
||||
// Handle first logins on new accounts
|
||||
// e.g. option to send users to a new account landing page on initial login
|
||||
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||
if (isNewUser && pages.newUser) {
|
||||
return {
|
||||
redirect: `${pages.newUser}${
|
||||
pages.newUser.includes("?") ? "&" : "?"
|
||||
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
return { redirect: callbackUrl, cookies }
|
||||
} catch (error) {
|
||||
if (error.name === "AccountNotLinkedError") {
|
||||
// If the email on the account is already linked, but not with this OAuth account
|
||||
return {
|
||||
redirect: `${url}/error?error=OAuthAccountNotLinked`,
|
||||
cookies,
|
||||
}
|
||||
} else if (error.name === "CreateUserError") {
|
||||
return { redirect: `${url}/error?error=OAuthCreateAccount`, cookies }
|
||||
}
|
||||
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error)
|
||||
return { redirect: `${url}/error?error=Callback`, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === "OAuthCallbackError") {
|
||||
logger.error("CALLBACK_OAUTH_ERROR", error)
|
||||
return { redirect: `${url}/error?error=OAuthCallback`, cookies }
|
||||
}
|
||||
logger.error("OAUTH_CALLBACK_ERROR", error)
|
||||
return { redirect: `${url}/error?error=Callback`, cookies }
|
||||
}
|
||||
} else if (provider.type === "email") {
|
||||
try {
|
||||
if (!adapter) {
|
||||
logger.error(
|
||||
"EMAIL_REQUIRES_ADAPTER_ERROR",
|
||||
new Error("E-mail login requires an adapter but it was undefined")
|
||||
)
|
||||
return { redirect: `${url}/error?error=Configuration`, cookies }
|
||||
}
|
||||
|
||||
const { useVerificationToken, getUserByEmail } = adapter
|
||||
|
||||
const token = query?.token
|
||||
const identifier = query?.email
|
||||
|
||||
const invite = await useVerificationToken?.({
|
||||
identifier,
|
||||
token: hashToken(token, options),
|
||||
})
|
||||
|
||||
const invalidInvite = !invite || invite.expires.valueOf() < Date.now()
|
||||
if (invalidInvite) {
|
||||
return { redirect: `${url}/error?error=Verification`, cookies }
|
||||
}
|
||||
|
||||
// If it is an existing user, use that, otherwise use a placeholder
|
||||
const profile = (identifier
|
||||
? await getUserByEmail(identifier)
|
||||
: null) ?? {
|
||||
email: identifier,
|
||||
}
|
||||
|
||||
/** @type {import("src").Account} */
|
||||
const account = {
|
||||
providerAccountId: profile.email,
|
||||
type: "email",
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn({
|
||||
// @ts-expect-error
|
||||
user: profile,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
// @ts-expect-error
|
||||
email: { email: identifier },
|
||||
})
|
||||
if (!signInCallbackResponse) {
|
||||
return { redirect: `${url}/error?error=AccessDenied`, cookies }
|
||||
} else if (typeof signInCallbackResponse === "string") {
|
||||
return { redirect: signInCallbackResponse, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
// @ts-expect-error
|
||||
const { user, session, isNewUser } = await callbackHandler({
|
||||
sessionToken,
|
||||
// @ts-expect-error
|
||||
profile,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
options,
|
||||
})
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultToken = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString(),
|
||||
}
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
isNewUser,
|
||||
})
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
|
||||
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: newEncodedJwt,
|
||||
options: {
|
||||
expires: cookieExpires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: session.sessionToken,
|
||||
options: {
|
||||
expires: session.expires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
await events.signIn?.({ user, account, isNewUser })
|
||||
|
||||
// Handle first logins on new accounts
|
||||
// e.g. option to send users to a new account landing page on initial login
|
||||
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||
if (isNewUser && pages.newUser) {
|
||||
return {
|
||||
redirect: `${pages.newUser}${
|
||||
pages.newUser.includes("?") ? "&" : "?"
|
||||
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
return { redirect: callbackUrl, cookies }
|
||||
} catch (error) {
|
||||
if (error.name === "CreateUserError") {
|
||||
return { redirect: `${url}/error?error=EmailCreateAccount`, cookies }
|
||||
}
|
||||
logger.error("CALLBACK_EMAIL_ERROR", error)
|
||||
return { redirect: `${url}/error?error=Callback`, cookies }
|
||||
}
|
||||
} else if (provider.type === "credentials" && method === "POST") {
|
||||
if (!useJwtSession) {
|
||||
logger.error(
|
||||
"CALLBACK_CREDENTIALS_JWT_ERROR",
|
||||
new Error(
|
||||
"Signin in with credentials is only supported if JSON Web Tokens are enabled"
|
||||
)
|
||||
)
|
||||
return {
|
||||
status: 500,
|
||||
redirect: `${url}/error?error=Configuration`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider.authorize) {
|
||||
logger.error(
|
||||
"CALLBACK_CREDENTIALS_HANDLER_ERROR",
|
||||
new Error(
|
||||
"Must define an authorize() handler to use credentials authentication provider"
|
||||
)
|
||||
)
|
||||
return {
|
||||
status: 500,
|
||||
redirect: `${url}/error?error=Configuration`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
const credentials = body
|
||||
|
||||
let user
|
||||
try {
|
||||
user = await provider.authorize(credentials, {
|
||||
query,
|
||||
body,
|
||||
headers,
|
||||
method,
|
||||
})
|
||||
if (!user) {
|
||||
return {
|
||||
status: 401,
|
||||
redirect: `${url}/error?${new URLSearchParams({
|
||||
error: "CredentialsSignin",
|
||||
provider: provider.id,
|
||||
})}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import("src").Account} */
|
||||
const account = {
|
||||
providerAccountId: user.id,
|
||||
type: "credentials",
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
try {
|
||||
const isAllowed = await callbacks.signIn({
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
credentials,
|
||||
})
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
status: 403,
|
||||
redirect: `${url}/error?error=AccessDenied`,
|
||||
cookies,
|
||||
}
|
||||
} else if (typeof isAllowed === "string") {
|
||||
return { redirect: isAllowed, cookies }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
|
||||
const defaultToken = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString(),
|
||||
}
|
||||
|
||||
const token = await callbacks.jwt({
|
||||
token: defaultToken,
|
||||
user,
|
||||
// @ts-expect-error
|
||||
account,
|
||||
isNewUser: false,
|
||||
})
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
|
||||
|
||||
cookies.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: newEncodedJwt,
|
||||
options: {
|
||||
expires: cookieExpires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error
|
||||
await events.signIn?.({ user, account })
|
||||
|
||||
return { redirect: callbackUrl, cookies }
|
||||
}
|
||||
return {
|
||||
status: 500,
|
||||
body: `Error: Callback for provider type ${provider.type} not supported`,
|
||||
cookies,
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { OutgoingResponse } from ".."
|
||||
import { InternalProvider } from "../../lib/types"
|
||||
|
||||
export interface PublicProvider {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
signinUrl: string
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a JSON object with a list of all OAuth providers currently configured
|
||||
* and their signin and callback URLs. This makes it possible to automatically
|
||||
* generate buttons for all providers when rendering client side.
|
||||
*/
|
||||
export default function providers(
|
||||
providers: InternalProvider[]
|
||||
): OutgoingResponse<Record<string, PublicProvider>> {
|
||||
return {
|
||||
headers: [{ key: "Content-Type", value: "application/json" }],
|
||||
body: providers.reduce(
|
||||
(acc, { id, name, type, signinUrl, callbackUrl }) => {
|
||||
acc[id] = { id, name, type, signinUrl, callbackUrl }
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { Adapter } from "../../adapters"
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
import { OutgoingResponse } from ".."
|
||||
import { Session } from "../.."
|
||||
import { fromDate } from "../lib/utils"
|
||||
|
||||
interface SessionParams {
|
||||
options: InternalOptions
|
||||
sessionToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a session object (without any private fields)
|
||||
* for Single Page App clients
|
||||
*/
|
||||
|
||||
export default async function session(
|
||||
params: SessionParams
|
||||
): Promise<OutgoingResponse<Session | {}>> {
|
||||
const { options, sessionToken } = params
|
||||
const { adapter, jwt, events, callbacks, logger } = options
|
||||
const useJwtSession = options.session.jwt
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
|
||||
const response: OutgoingResponse<Session | {}> = {
|
||||
body: {},
|
||||
headers: [{ key: "Content-Type", value: "application/json" }],
|
||||
cookies: [],
|
||||
}
|
||||
|
||||
if (!sessionToken) return response
|
||||
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
// Decrypt and verify token
|
||||
const decodedToken = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
|
||||
// Generate new session expiry date
|
||||
const newExpires = fromDate(sessionMaxAge)
|
||||
|
||||
// By default, only exposes a limited subset of information to the client
|
||||
// as needed for presentation purposes (e.g. "you are logged in as...").
|
||||
const defaultSession = {
|
||||
user: {
|
||||
name: decodedToken?.name,
|
||||
email: decodedToken?.email,
|
||||
image: decodedToken?.picture,
|
||||
},
|
||||
expires: newExpires.toISOString(),
|
||||
}
|
||||
|
||||
// Pass Session and JSON Web Token through to the session callback
|
||||
// @ts-expect-error
|
||||
const token = await callbacks.jwt({ token: decodedToken })
|
||||
// @ts-expect-error
|
||||
const session = await callbacks.session({
|
||||
session: defaultSession,
|
||||
token,
|
||||
})
|
||||
|
||||
// Return session payload as response
|
||||
response.body = session
|
||||
|
||||
// Refresh JWT expiry by re-signing it, with an updated expiry date
|
||||
const newToken = await jwt.encode({ ...jwt, token })
|
||||
|
||||
// Set cookie, to also update expiry date on cookie
|
||||
response.cookies?.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: newToken,
|
||||
options: {
|
||||
expires: newExpires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
|
||||
await events.session?.({ session, token })
|
||||
} catch (error) {
|
||||
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
|
||||
logger.error("JWT_SESSION_ERROR", error)
|
||||
|
||||
response.cookies?.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: "",
|
||||
options: { ...options.cookies.sessionToken.options, maxAge: 0 },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { getSessionAndUser, deleteSession, updateSession } =
|
||||
adapter as Adapter
|
||||
let userAndSession = await getSessionAndUser(sessionToken)
|
||||
|
||||
// If session has expired, clean up the database
|
||||
if (
|
||||
userAndSession &&
|
||||
userAndSession.session.expires.valueOf() < Date.now()
|
||||
) {
|
||||
await deleteSession(sessionToken)
|
||||
userAndSession = null
|
||||
}
|
||||
|
||||
if (userAndSession) {
|
||||
const { user, session } = userAndSession
|
||||
|
||||
const sessionUpdateAge = options.session.updateAge
|
||||
// Calculate last updated date to throttle write updates to database
|
||||
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||
const sessionIsDueToBeUpdatedDate =
|
||||
session.expires.valueOf() -
|
||||
sessionMaxAge * 1000 +
|
||||
sessionUpdateAge * 1000
|
||||
|
||||
const newExpires = fromDate(sessionMaxAge)
|
||||
// Trigger update of session expiry date and write to database, only
|
||||
// if the session was last updated more than {sessionUpdateAge} ago
|
||||
if (sessionIsDueToBeUpdatedDate <= Date.now()) {
|
||||
await updateSession({ sessionToken, expires: newExpires })
|
||||
}
|
||||
|
||||
// Pass Session through to the session callback
|
||||
// @ts-expect-error
|
||||
const sessionPayload = await callbacks.session({
|
||||
// By default, only exposes a limited subset of information to the client
|
||||
// as needed for presentation purposes (e.g. "you are logged in as...").
|
||||
session: {
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
},
|
||||
expires: session.expires.toISOString(),
|
||||
},
|
||||
user,
|
||||
})
|
||||
|
||||
// Return session payload as response
|
||||
response.body = sessionPayload
|
||||
|
||||
// Set cookie again to update expiry
|
||||
response.cookies?.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: sessionToken,
|
||||
options: {
|
||||
expires: newExpires,
|
||||
...options.cookies.sessionToken.options,
|
||||
},
|
||||
})
|
||||
|
||||
// @ts-expect-error
|
||||
await events.session?.({ session: sessionPayload })
|
||||
} else if (sessionToken) {
|
||||
// If sessionToken was found set but it's not valid for a session then
|
||||
// remove the sessionToken cookie from browser.
|
||||
response.cookies?.push({
|
||||
name: options.cookies.sessionToken.name,
|
||||
value: "",
|
||||
options: { ...options.cookies.sessionToken.options, maxAge: 0 },
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("SESSION_ERROR", error)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import getAuthorizationUrl from "../lib/oauth/authorization-url"
|
||||
import emailSignin from "../lib/email/signin"
|
||||
import { IncomingRequest, OutgoingResponse } from ".."
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
import { Account, User } from "../.."
|
||||
|
||||
/** Handle requests to /api/auth/signin */
|
||||
export default async function signin(params: {
|
||||
options: InternalOptions<"oauth" | "email">
|
||||
query: IncomingRequest["query"]
|
||||
body: IncomingRequest["body"]
|
||||
}): Promise<OutgoingResponse> {
|
||||
const { options, query, body } = params
|
||||
const { url, adapter, callbacks, logger, provider } = options
|
||||
|
||||
if (!provider.type) {
|
||||
return {
|
||||
status: 500,
|
||||
// @ts-expect-error
|
||||
text: `Error: Type not specified for ${provider.name}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (provider.type === "oauth") {
|
||||
try {
|
||||
const response = await getAuthorizationUrl({ options, query })
|
||||
return response
|
||||
} catch (error) {
|
||||
logger.error("SIGNIN_OAUTH_ERROR", { error, provider })
|
||||
return { redirect: `${url}/error?error=OAuthSignin` }
|
||||
}
|
||||
} else if (provider.type === "email") {
|
||||
if (!adapter) {
|
||||
logger.error(
|
||||
"EMAIL_REQUIRES_ADAPTER_ERROR",
|
||||
new Error("E-mail login requires an adapter but it was undefined")
|
||||
)
|
||||
return { redirect: `${url}/error?error=Configuration` }
|
||||
}
|
||||
|
||||
// Note: Technically the part of the email address local mailbox element
|
||||
// (everything before the @ symbol) should be treated as 'case sensitive'
|
||||
// according to RFC 2821, but in practice this causes more problems than
|
||||
// it solves. We treat email addresses as all lower case. If anyone
|
||||
// complains about this we can make strict RFC 2821 compliance an option.
|
||||
const email = body?.email?.toLowerCase() ?? null
|
||||
|
||||
const { getUserByEmail } = adapter
|
||||
// If is an existing user return a user object (otherwise use placeholder)
|
||||
const user: User = (email ? await getUserByEmail(email) : null) ?? {
|
||||
email,
|
||||
id: email,
|
||||
}
|
||||
|
||||
const account: Account = {
|
||||
providerAccountId: email,
|
||||
userId: email,
|
||||
type: "email",
|
||||
provider: provider.id,
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
try {
|
||||
// @ts-expect-error
|
||||
const signInCallbackResponse = await callbacks.signIn({
|
||||
user,
|
||||
account,
|
||||
email: { verificationRequest: true },
|
||||
})
|
||||
if (!signInCallbackResponse) {
|
||||
return { redirect: `${url}/error?error=AccessDenied` }
|
||||
} else if (typeof signInCallbackResponse === "string") {
|
||||
return { redirect: signInCallbackResponse }
|
||||
}
|
||||
} catch (error) {
|
||||
return { redirect: `${url}/error?${new URLSearchParams({ error })}}` }
|
||||
}
|
||||
|
||||
try {
|
||||
await emailSignin(email, options)
|
||||
} catch (error) {
|
||||
logger.error("SIGNIN_EMAIL_ERROR", error)
|
||||
return { redirect: `${url}/error?error=EmailSignin` }
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
})
|
||||
|
||||
return { redirect: `${url}/verify-request?${params}` }
|
||||
}
|
||||
return { redirect: `${url}/signin` }
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Adapter } from "src/adapters"
|
||||
import { InternalOptions } from "../../lib/types"
|
||||
import { OutgoingResponse } from ".."
|
||||
import { Cookie } from "../lib/cookie"
|
||||
|
||||
/** Handle requests to /api/auth/signout */
|
||||
export default async function signout(params: {
|
||||
options: InternalOptions
|
||||
sessionToken?: string
|
||||
}): Promise<OutgoingResponse> {
|
||||
const { options, sessionToken } = params
|
||||
const { adapter, cookies, events, jwt, callbackUrl, logger } = options
|
||||
|
||||
if (!sessionToken) {
|
||||
return { redirect: callbackUrl }
|
||||
}
|
||||
|
||||
const useJwtSession = options.session.jwt
|
||||
|
||||
if (useJwtSession) {
|
||||
// Dispatch signout event
|
||||
try {
|
||||
const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
// @ts-expect-error
|
||||
await events.signOut?.({ token: decodedJwt })
|
||||
} catch (error) {
|
||||
// Do nothing if decoding the JWT fails
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const session = await (adapter as Adapter).deleteSession(sessionToken)
|
||||
// Dispatch signout event
|
||||
// @ts-expect-error
|
||||
await events.signOut?.({ session })
|
||||
} catch (error) {
|
||||
// If error, log it but continue
|
||||
logger.error("SIGNOUT_ERROR", error)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Session Token
|
||||
const sessionCookie: Cookie = {
|
||||
name: cookies.sessionToken.name,
|
||||
value: "",
|
||||
options: { ...cookies.sessionToken.options, maxAge: 0 },
|
||||
}
|
||||
|
||||
return { redirect: callbackUrl, cookies: [sessionCookie] }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
--border-width: 1px;
|
||||
--border-radius: 0.3rem;
|
||||
--border-radius: .3rem;
|
||||
--color-error: #c94b4b;
|
||||
--color-info: #157efb;
|
||||
--color-info-text: #fff;
|
||||
@@ -43,9 +43,7 @@ body {
|
||||
background-color: var(--color-background);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
|
||||
sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
|
||||
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -56,7 +54,7 @@ h1 {
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text);
|
||||
color: var(--color-text)
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -76,12 +74,12 @@ input[type] {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: .5rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
background: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: inset 0 0.1rem 0.2rem rgba(0, 0, 0, 0.2);
|
||||
box-shadow: inset 0 .1rem .2rem rgba(0, 0, 0, .2);
|
||||
color: var(--color-text);
|
||||
|
||||
&:focus {
|
||||
@@ -109,17 +107,15 @@ a.button {
|
||||
|
||||
button,
|
||||
a.button {
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 .75rem 0;
|
||||
padding: .75rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
color: var(--color-primary);
|
||||
background-color: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.1s ease-in-out;
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
|
||||
inset 0 0.1rem 0.2rem var(--color-background),
|
||||
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.05);
|
||||
transition: all .1s ease-in-out;
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .05);
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
|
||||
@@ -128,9 +124,7 @@ a.button {
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
|
||||
inset 0 0.1rem 0.2rem var(--color-background),
|
||||
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .1);
|
||||
background-color: var(--color-button-active-background);
|
||||
border-color: var(--color-button-active-border);
|
||||
cursor: pointer;
|
||||
@@ -152,12 +146,13 @@ a.site {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: table;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
> div {
|
||||
>div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
@@ -168,7 +163,7 @@ a.site {
|
||||
display: inline-block;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
@@ -177,6 +172,7 @@ a.site {
|
||||
}
|
||||
|
||||
.signin {
|
||||
|
||||
button,
|
||||
a.button,
|
||||
input[type="text"] {
|
||||
@@ -196,9 +192,9 @@ a.site {
|
||||
content: "or";
|
||||
background: var(--color-background);
|
||||
color: #888;
|
||||
padding: 0 0.4rem;
|
||||
padding: 0 .4rem;
|
||||
position: relative;
|
||||
top: -0.6rem;
|
||||
top: -.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,9 +213,10 @@ a.site {
|
||||
}
|
||||
}
|
||||
|
||||
> div,
|
||||
>div,
|
||||
form {
|
||||
display: block;
|
||||
margin: 0 auto 0.5rem auto;
|
||||
|
||||
input[type] {
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -231,32 +228,4 @@ a.site {
|
||||
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
.signout {
|
||||
.message {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline-block;
|
||||
margin-top: 100px;
|
||||
max-width: 300px;
|
||||
max-height: 150px;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: max-content;
|
||||
border: 1px solid var(--color-control-border);
|
||||
border-radius: 5px;
|
||||
padding: 20px 50px;
|
||||
margin: 50px auto;
|
||||
|
||||
.header {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
color: var(--brand-color);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// To support serverless targets (which don't work if you try to read in things
|
||||
// like CSS files at run time) this file is replaced in production builds with
|
||||
// a function that returns compiled CSS (embedded as a string in the function).
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const pathToCss = path.join(process.cwd(), "/src/css/index.css")
|
||||
const pathToCss = path.join(process.cwd(), '/dist/css/index.css')
|
||||
|
||||
export default function css() {
|
||||
return fs.readFileSync(pathToCss, "utf8")
|
||||
export default function css () {
|
||||
return fs.readFileSync(pathToCss, 'utf8')
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from "./core/types"
|
||||
|
||||
export type { IncomingRequest, OutgoingResponse } from "./core"
|
||||
|
||||
export * from "./next"
|
||||
export { default } from "./next"
|
||||
110
src/jwt/index.ts
110
src/jwt/index.ts
@@ -1,110 +0,0 @@
|
||||
import { EncryptJWT, jwtDecrypt } from "jose"
|
||||
import hkdf from '@panva/hkdf'
|
||||
import { v4 as uuid } from "uuid"
|
||||
import { NextApiRequest } from "next"
|
||||
import type { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "./types"
|
||||
|
||||
export * from "./types"
|
||||
|
||||
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
const now = () => (Date.now() / 1000) | 0
|
||||
|
||||
/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */
|
||||
export async function encode({
|
||||
token = {},
|
||||
secret,
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
}: JWTEncodeParams) {
|
||||
const encryptionSecret = await getDerivedEncryptionKey(secret)
|
||||
return await new EncryptJWT(token)
|
||||
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(now() + maxAge)
|
||||
.setJti(uuid())
|
||||
.encrypt(encryptionSecret)
|
||||
}
|
||||
|
||||
/** Decodes a NextAuth.js issued JWT. */
|
||||
export async function decode({
|
||||
token,
|
||||
secret,
|
||||
}: JWTDecodeParams): Promise<JWT | null> {
|
||||
if (!token) return null
|
||||
const encryptionSecret = await getDerivedEncryptionKey(secret)
|
||||
const { payload } = await jwtDecrypt(token, encryptionSecret, {
|
||||
clockTolerance: 15,
|
||||
})
|
||||
return payload
|
||||
}
|
||||
|
||||
export type GetTokenParams<R extends boolean = false> = {
|
||||
/** The request containing the JWT either in the cookies or in the `Authorization` header. */
|
||||
req: NextApiRequest
|
||||
/**
|
||||
* Use secure prefix for cookie name, unless URL in `NEXTAUTH_URL` is http://
|
||||
* or not set (e.g. development or test instance) case use unprefixed name
|
||||
*/
|
||||
secureCookie?: boolean
|
||||
/** If the JWT is in the cookie, what name `getToken()` should look for. */
|
||||
cookieName?: string
|
||||
/**
|
||||
* `getToken()` will return the raw JWT if this is set to `true`
|
||||
* @default false
|
||||
*/
|
||||
raw?: R
|
||||
} & Pick<JWTOptions, "decode" | "secret">
|
||||
|
||||
/**
|
||||
* Takes a NextAuth.js request (`req`) and returns either the NextAuth.js issued JWT's payload,
|
||||
* or the raw JWT string. We look for the JWT in the either the cookies, or the `Authorization` header.
|
||||
* [Documentation](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
|
||||
*/
|
||||
export async function getToken<R extends boolean = false>(
|
||||
params?: GetTokenParams<R>
|
||||
): Promise<R extends true ? string : JWT | null> {
|
||||
const {
|
||||
req,
|
||||
secureCookie = !(
|
||||
!process.env.NEXTAUTH_URL ||
|
||||
process.env.NEXTAUTH_URL.startsWith("http://")
|
||||
),
|
||||
cookieName = secureCookie
|
||||
? "__Secure-next-auth.session-token"
|
||||
: "next-auth.session-token",
|
||||
raw,
|
||||
decode: _decode = decode,
|
||||
} = params ?? {}
|
||||
|
||||
if (!req) throw new Error("Must pass `req` to JWT getToken()")
|
||||
|
||||
let token = req.cookies[cookieName]
|
||||
|
||||
if (!token && req.headers.authorization?.split(" ")[0] === "Bearer") {
|
||||
const urlEncodedToken = req.headers.authorization.split(" ")[1]
|
||||
token = decodeURIComponent(urlEncodedToken)
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
// @ts-expect-error
|
||||
return token
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
return await _decode({ token, ...params })
|
||||
} catch {
|
||||
// @ts-expect-error
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getDerivedEncryptionKey(secret) {
|
||||
return await hkdf(
|
||||
'sha256',
|
||||
secret,
|
||||
"",
|
||||
"NextAuth.js Generated Encryption Key",
|
||||
32
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { decode, encode } from "."
|
||||
|
||||
export interface DefaultJWT extends Record<string, unknown> {
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
picture?: string | null
|
||||
sub?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by the `jwt` callback and `getToken`, when using JWT sessions
|
||||
*
|
||||
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) | [`getToken`](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
|
||||
*/
|
||||
export interface JWT extends Record<string, unknown>, DefaultJWT {}
|
||||
|
||||
export interface JWTEncodeParams {
|
||||
/** The JWT payload. */
|
||||
token?: JWT
|
||||
/** The secret used to encode the NextAuth.js issued JWT. */
|
||||
secret: string | Buffer
|
||||
/**
|
||||
* The maximum age of the NextAuth.js issued JWT in seconds.
|
||||
* @default 30 * 24 * 30 * 60 // 30 days
|
||||
*/
|
||||
maxAge?: number
|
||||
}
|
||||
|
||||
export interface JWTDecodeParams {
|
||||
/** The NextAuth.js issued JWT to be decoded */
|
||||
token?: string
|
||||
/** The secret used to decode the NextAuth.js issued JWT. */
|
||||
secret: string | Buffer
|
||||
}
|
||||
|
||||
export interface JWTOptions {
|
||||
/** The secret used to encode/decode the NextAuth.js issued JWT. */
|
||||
secret: string
|
||||
/**
|
||||
* The maximum age of the NextAuth.js issued JWT in seconds.
|
||||
* @default 30 * 24 * 30 * 60 // 30 days
|
||||
*/
|
||||
maxAge: number
|
||||
/** Override this method to control the NextAuth.js issued JWT encoding. */
|
||||
encode: typeof encode
|
||||
/** Override this method to control the NextAuth.js issued JWT decoding. */
|
||||
decode: typeof decode
|
||||
}
|
||||
98
src/lib/errors.js
Normal file
98
src/lib/errors.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Same as the default `Error`, but it is JSON serializable.
|
||||
* @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
|
||||
*/
|
||||
export class UnknownError extends Error {
|
||||
constructor(error) {
|
||||
// Support passing error or string
|
||||
super(error?.message ?? error)
|
||||
this.name = "UnknownError"
|
||||
if (error instanceof Error) {
|
||||
this.stack = error.stack
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
name: this.name,
|
||||
message: this.message,
|
||||
stack: this.stack,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OAuthCallbackError extends UnknownError {
|
||||
name = "OAuthCallbackError"
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when an Email address is already associated with an account
|
||||
* but the user is trying an OAuth account that is not linked to it.
|
||||
*/
|
||||
export class AccountNotLinkedError extends UnknownError {
|
||||
name = "AccountNotLinkedError"
|
||||
}
|
||||
|
||||
export class CreateUserError extends UnknownError {
|
||||
name = "CreateUserError"
|
||||
}
|
||||
|
||||
export class GetUserError extends UnknownError {
|
||||
name = "GetUserError"
|
||||
}
|
||||
|
||||
export class GetUserByEmailError extends UnknownError {
|
||||
name = "GetUserByEmailError"
|
||||
}
|
||||
|
||||
export class GetUserByIdError extends UnknownError {
|
||||
name = "GetUserByIdError"
|
||||
}
|
||||
|
||||
export class GetUserByProviderAccountIdError extends UnknownError {
|
||||
name = "GetUserByProviderAccountIdError"
|
||||
}
|
||||
|
||||
export class UpdateUserError extends UnknownError {
|
||||
name = "UpdateUserError"
|
||||
}
|
||||
|
||||
export class DeleteUserError extends UnknownError {
|
||||
name = "DeleteUserError"
|
||||
}
|
||||
|
||||
export class LinkAccountError extends UnknownError {
|
||||
name = "LinkAccountError"
|
||||
}
|
||||
|
||||
export class UnlinkAccountError extends UnknownError {
|
||||
name = "UnlinkAccountError"
|
||||
}
|
||||
|
||||
export class CreateSessionError extends UnknownError {
|
||||
name = "CreateSessionError"
|
||||
}
|
||||
|
||||
export class GetSessionError extends UnknownError {
|
||||
name = "GetSessionError"
|
||||
}
|
||||
|
||||
export class UpdateSessionError extends UnknownError {
|
||||
name = "UpdateSessionError"
|
||||
}
|
||||
|
||||
export class DeleteSessionError extends UnknownError {
|
||||
name = "DeleteSessionError"
|
||||
}
|
||||
|
||||
export class CreateVerificationRequestError extends UnknownError {
|
||||
name = "CreateVerificationRequestError"
|
||||
}
|
||||
|
||||
export class GetVerificationRequestError extends UnknownError {
|
||||
name = "GetVerificationRequestError"
|
||||
}
|
||||
|
||||
export class DeleteVerificationRequestError extends UnknownError {
|
||||
name = "DeleteVerificationRequestError"
|
||||
}
|
||||
208
src/lib/jwt.js
Normal file
208
src/lib/jwt.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import crypto from "crypto"
|
||||
import jose from "jose"
|
||||
import logger from "./logger"
|
||||
|
||||
// Set default algorithm to use for auto-generated signing key
|
||||
const DEFAULT_SIGNATURE_ALGORITHM = "HS512"
|
||||
|
||||
// Set default algorithm for auto-generated symmetric encryption key
|
||||
const DEFAULT_ENCRYPTION_ALGORITHM = "A256GCM"
|
||||
|
||||
// Use encryption or not by default
|
||||
const DEFAULT_ENCRYPTION_ENABLED = false
|
||||
|
||||
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
export async function encode({
|
||||
token = {},
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
secret,
|
||||
signingKey,
|
||||
signingOptions = {
|
||||
expiresIn: `${maxAge}s`,
|
||||
},
|
||||
encryptionKey,
|
||||
encryptionOptions = {
|
||||
alg: "dir",
|
||||
enc: DEFAULT_ENCRYPTION_ALGORITHM,
|
||||
zip: "DEF",
|
||||
},
|
||||
encryption = DEFAULT_ENCRYPTION_ENABLED,
|
||||
} = {}) {
|
||||
// Signing Key
|
||||
const _signingKey = signingKey
|
||||
? jose.JWK.asKey(JSON.parse(signingKey))
|
||||
: getDerivedSigningKey(secret)
|
||||
|
||||
// Sign token
|
||||
const signedToken = jose.JWT.sign(token, _signingKey, signingOptions)
|
||||
|
||||
if (encryption) {
|
||||
// Encryption Key
|
||||
const _encryptionKey = encryptionKey
|
||||
? jose.JWK.asKey(JSON.parse(encryptionKey))
|
||||
: getDerivedEncryptionKey(secret)
|
||||
|
||||
// Encrypt token
|
||||
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
|
||||
}
|
||||
return signedToken
|
||||
}
|
||||
|
||||
export async function decode({
|
||||
secret,
|
||||
token,
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
signingKey,
|
||||
verificationKey = signingKey, // Optional (defaults to encryptionKey)
|
||||
verificationOptions = {
|
||||
maxTokenAge: `${maxAge}s`,
|
||||
algorithms: [DEFAULT_SIGNATURE_ALGORITHM],
|
||||
},
|
||||
encryptionKey,
|
||||
decryptionKey = encryptionKey, // Optional (defaults to encryptionKey)
|
||||
decryptionOptions = {
|
||||
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM],
|
||||
},
|
||||
encryption = DEFAULT_ENCRYPTION_ENABLED,
|
||||
} = {}) {
|
||||
if (!token) return null
|
||||
|
||||
let tokenToVerify = token
|
||||
|
||||
if (encryption) {
|
||||
// Encryption Key
|
||||
const _encryptionKey = decryptionKey
|
||||
? jose.JWK.asKey(JSON.parse(decryptionKey))
|
||||
: getDerivedEncryptionKey(secret)
|
||||
|
||||
// Decrypt token
|
||||
const decryptedToken = jose.JWE.decrypt(
|
||||
token,
|
||||
_encryptionKey,
|
||||
decryptionOptions
|
||||
)
|
||||
tokenToVerify = decryptedToken.toString("utf8")
|
||||
}
|
||||
|
||||
// Signing Key
|
||||
const _signingKey = verificationKey
|
||||
? jose.JWK.asKey(JSON.parse(verificationKey))
|
||||
: getDerivedSigningKey(secret)
|
||||
|
||||
// Verify token
|
||||
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side method to retrieve the JWT from `req`.
|
||||
* @param {{
|
||||
* req: NextApiRequest
|
||||
* secureCookie?: boolean
|
||||
* cookieName?: string
|
||||
* raw?: boolean
|
||||
* }} params
|
||||
*/
|
||||
export async function getToken(params) {
|
||||
const {
|
||||
req,
|
||||
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
|
||||
// or not set (e.g. development or test instance) case use unprefixed name
|
||||
secureCookie = !(
|
||||
!process.env.NEXTAUTH_URL ||
|
||||
process.env.NEXTAUTH_URL.startsWith("http://")
|
||||
),
|
||||
cookieName = secureCookie
|
||||
? "__Secure-next-auth.session-token"
|
||||
: "next-auth.session-token",
|
||||
raw = false,
|
||||
decode: _decode = decode,
|
||||
} = params
|
||||
if (!req) throw new Error("Must pass `req` to JWT getToken()")
|
||||
|
||||
// Try to get token from cookie
|
||||
let token = req.cookies[cookieName]
|
||||
|
||||
// If cookie not found in cookie look for bearer token in authorization header.
|
||||
// This allows clients that pass through tokens in headers rather than as
|
||||
// cookies to use this helper function.
|
||||
if (!token && req.headers.authorization?.split(" ")[0] === "Bearer") {
|
||||
const urlEncodedToken = req.headers.authorization.split(" ")[1]
|
||||
token = decodeURIComponent(urlEncodedToken)
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
return token
|
||||
}
|
||||
|
||||
try {
|
||||
return _decode({ token, ...params })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Generate warning (but only once at startup) when auto-generated keys are used
|
||||
let DERIVED_SIGNING_KEY_WARNING = false
|
||||
let DERIVED_ENCRYPTION_KEY_WARNING = false
|
||||
|
||||
// Do the better hkdf of Node.js one added in `v15.0.0` and Third Party one
|
||||
function hkdf(secret, { byteLength, encryptionInfo, digest = "sha256" }) {
|
||||
if (crypto.hkdfSync) {
|
||||
return Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
digest,
|
||||
secret,
|
||||
Buffer.alloc(0),
|
||||
encryptionInfo,
|
||||
byteLength
|
||||
)
|
||||
)
|
||||
}
|
||||
return require("futoin-hkdf")(secret, byteLength, {
|
||||
info: encryptionInfo,
|
||||
hash: digest,
|
||||
})
|
||||
}
|
||||
|
||||
function getDerivedSigningKey(secret) {
|
||||
if (!DERIVED_SIGNING_KEY_WARNING) {
|
||||
logger.warn("JWT_AUTO_GENERATED_SIGNING_KEY")
|
||||
DERIVED_SIGNING_KEY_WARNING = true
|
||||
}
|
||||
|
||||
const buffer = hkdf(secret, {
|
||||
byteLength: 64,
|
||||
encryptionInfo: "NextAuth.js Generated Signing Key",
|
||||
})
|
||||
const key = jose.JWK.asKey(buffer, {
|
||||
alg: DEFAULT_SIGNATURE_ALGORITHM,
|
||||
use: "sig",
|
||||
kid: "nextauth-auto-generated-signing-key",
|
||||
})
|
||||
return key
|
||||
}
|
||||
|
||||
function getDerivedEncryptionKey(secret) {
|
||||
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
|
||||
logger.warn("JWT_AUTO_GENERATED_ENCRYPTION_KEY")
|
||||
DERIVED_ENCRYPTION_KEY_WARNING = true
|
||||
}
|
||||
|
||||
const buffer = hkdf(secret, {
|
||||
byteLength: 32,
|
||||
encryptionInfo: "NextAuth.js Generated Encryption Key",
|
||||
})
|
||||
const key = jose.JWK.asKey(buffer, {
|
||||
alg: DEFAULT_ENCRYPTION_ALGORITHM,
|
||||
use: "enc",
|
||||
kid: "nextauth-auto-generated-encryption-key",
|
||||
})
|
||||
return key
|
||||
}
|
||||
|
||||
export default {
|
||||
encode,
|
||||
decode,
|
||||
getToken,
|
||||
}
|
||||
81
src/lib/logger.js
Normal file
81
src/lib/logger.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/** @type {import("types").LoggerInstance} */
|
||||
const _logger = {
|
||||
error(code, ...message) {
|
||||
console.error(
|
||||
`[next-auth][error][${code.toLowerCase()}]`,
|
||||
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
|
||||
...message
|
||||
)
|
||||
},
|
||||
warn(code, ...message) {
|
||||
console.warn(
|
||||
`[next-auth][warn][${code.toLowerCase()}]`,
|
||||
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`,
|
||||
...message
|
||||
)
|
||||
},
|
||||
debug(code, ...message) {
|
||||
if (!process?.env?._NEXTAUTH_DEBUG) return
|
||||
console.log(`[next-auth][debug][${code.toLowerCase()}]`, ...message)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the built-in logger.
|
||||
* Any `undefined` level will use the default logger.
|
||||
* @param {Partial<import("types").LoggerInstance>} newLogger
|
||||
*/
|
||||
export function setLogger(newLogger = {}) {
|
||||
if (newLogger.error) _logger.error = newLogger.error
|
||||
if (newLogger.warn) _logger.warn = newLogger.warn
|
||||
if (newLogger.debug) _logger.debug = newLogger.debug
|
||||
}
|
||||
|
||||
export default _logger
|
||||
|
||||
/**
|
||||
* Serializes client-side log messages and sends them to the server
|
||||
* @param {import("types").LoggerInstance} logger
|
||||
* @param {string} basePath
|
||||
* @return {import("types").LoggerInstance}
|
||||
*/
|
||||
export function proxyLogger(logger = _logger, basePath) {
|
||||
try {
|
||||
if (typeof window === "undefined") {
|
||||
return logger
|
||||
}
|
||||
|
||||
const clientLogger = {}
|
||||
for (const level in logger) {
|
||||
clientLogger[level] = (code, ...message) => {
|
||||
_logger[level](code, ...message) // Log on client as usual
|
||||
|
||||
const url = `${basePath}/_log`
|
||||
const body = new URLSearchParams({
|
||||
level,
|
||||
code,
|
||||
message: JSON.stringify(
|
||||
message.map((m) => {
|
||||
if (m instanceof Error) {
|
||||
// Serializing errors: https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
|
||||
return { name: m.name, message: m.message, stack: m.stack }
|
||||
}
|
||||
return m
|
||||
})
|
||||
),
|
||||
})
|
||||
if (navigator.sendBeacon) {
|
||||
return navigator.sendBeacon(url, body)
|
||||
}
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
})
|
||||
}
|
||||
}
|
||||
return clientLogger
|
||||
} catch {
|
||||
return _logger
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { UnknownError } from "../core/errors"
|
||||
|
||||
/** Makes sure that error is always serializable */
|
||||
function formatError(o) {
|
||||
if (o instanceof Error && !(o instanceof UnknownError)) {
|
||||
return { message: o.message, stack: o.stack, name: o.name }
|
||||
}
|
||||
if (o?.error) {
|
||||
o.error = formatError(o.error)
|
||||
o.message = o.message ?? o.error.message
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
/**
|
||||
* Override any of the methods, and the rest will use the default logger.
|
||||
*
|
||||
* [Documentation](https://next-auth.js.org/configuration/options#logger)
|
||||
*/
|
||||
export interface LoggerInstance {
|
||||
warn: (
|
||||
code:
|
||||
| "JWT_AUTO_GENERATED_SIGNING_KEY"
|
||||
| "JWT_AUTO_GENERATED_ENCRYPTION_KEY"
|
||||
| "NEXTAUTH_URL"
|
||||
| "NO_CSRF_TOKEN"
|
||||
) => void
|
||||
error: (
|
||||
code: string,
|
||||
/**
|
||||
* Either an instance of (JSON serializable) Error
|
||||
* or an object that contains some debug information.
|
||||
* (Error is still available through `metadata.error`)
|
||||
*/
|
||||
metadata: Error | { error: Error; [key: string]: unknown }
|
||||
) => void
|
||||
debug: (code: string, metadata: unknown) => void
|
||||
}
|
||||
|
||||
const _logger: LoggerInstance = {
|
||||
error(code, metadata) {
|
||||
metadata = formatError(metadata)
|
||||
console.error(
|
||||
`[next-auth][error][${code}]`,
|
||||
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
|
||||
metadata.message,
|
||||
metadata
|
||||
)
|
||||
},
|
||||
warn(code) {
|
||||
console.warn(
|
||||
`[next-auth][warn][${code}]`,
|
||||
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`
|
||||
)
|
||||
},
|
||||
debug(code, metadata) {
|
||||
if (!process?.env?._NEXTAUTH_DEBUG) return
|
||||
console.log(`[next-auth][debug][${code}]`, metadata)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the built-in logger.
|
||||
* Any `undefined` level will use the default logger.
|
||||
*/
|
||||
export function setLogger(newLogger: Partial<LoggerInstance> = {}) {
|
||||
if (newLogger.error) _logger.error = newLogger.error
|
||||
if (newLogger.warn) _logger.warn = newLogger.warn
|
||||
if (newLogger.debug) _logger.debug = newLogger.debug
|
||||
}
|
||||
|
||||
export default _logger
|
||||
|
||||
/** Serializes client-side log messages and sends them to the server */
|
||||
export function proxyLogger(
|
||||
logger: LoggerInstance = _logger,
|
||||
basePath?: string
|
||||
): LoggerInstance {
|
||||
try {
|
||||
if (typeof window === "undefined") {
|
||||
return logger
|
||||
}
|
||||
|
||||
const clientLogger = {}
|
||||
for (const level in logger) {
|
||||
clientLogger[level] = (code, metadata) => {
|
||||
_logger[level](code, metadata) // Logs to console
|
||||
|
||||
if (level === "error") {
|
||||
metadata = formatError(metadata)
|
||||
}
|
||||
metadata.client = true
|
||||
const url = `${basePath}/_log`
|
||||
const body = new URLSearchParams({ level, code, ...metadata })
|
||||
if (navigator.sendBeacon) {
|
||||
return navigator.sendBeacon(url, body)
|
||||
}
|
||||
return fetch(url, { method: "POST", body, keepalive: true })
|
||||
}
|
||||
}
|
||||
return clientLogger as LoggerInstance
|
||||
} catch {
|
||||
return _logger
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Source: https://stackoverflow.com/a/34749873/5364135
|
||||
|
||||
/** Simple object check */
|
||||
function isObject(item: any): boolean {
|
||||
return item && typeof item === "object" && !Array.isArray(item)
|
||||
}
|
||||
|
||||
/** Deep merge two objects */
|
||||
export function merge(target: any, ...sources: any[]) {
|
||||
if (!sources.length) return target
|
||||
const source = sources.shift()
|
||||
|
||||
if (isObject(target) && isObject(source)) {
|
||||
for (const key in source) {
|
||||
if (isObject(source[key])) {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} })
|
||||
merge(target[key], source[key])
|
||||
} else {
|
||||
Object.assign(target, { [key]: source[key] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return merge(target, ...sources)
|
||||
}
|
||||
27
src/lib/parse-url.js
Normal file
27
src/lib/parse-url.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Simple universal (client/server) function to split host and path
|
||||
* We use this rather than a library because we need to use the same logic both
|
||||
* client and server side and we only need to parse out the host and path, while
|
||||
* supporting a default value, so a simple split is sufficent.
|
||||
* @param {string} url
|
||||
*/
|
||||
export default function parseUrl (url) {
|
||||
// Default values
|
||||
const defaultHost = 'http://localhost:3000'
|
||||
const defaultPath = '/api/auth'
|
||||
|
||||
if (!url) { url = `${defaultHost}${defaultPath}` }
|
||||
|
||||
// Default to HTTPS if no protocol explictly specified
|
||||
const protocol = url.startsWith('http:') ? 'http' : 'https'
|
||||
|
||||
// Normalize URLs by stripping protocol and no trailing slash
|
||||
url = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
|
||||
// Simple split based on first /
|
||||
const [_host, ..._path] = url.split('/')
|
||||
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
|
||||
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
|
||||
|
||||
return { baseUrl, basePath }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
export interface InternalUrl {
|
||||
/** @default "http://localhost:3000" */
|
||||
origin: string
|
||||
/** @default "localhost:3000" */
|
||||
host: string
|
||||
/** @default "/api/auth" */
|
||||
path: string
|
||||
/** @default "http://localhost:3000/api/auth" */
|
||||
base: string
|
||||
/** @default "http://localhost:3000/api/auth" */
|
||||
toString: () => string
|
||||
}
|
||||
|
||||
export default function parseUrl(url?: string): InternalUrl {
|
||||
const defaultUrl = new URL("http://localhost:3000/api/auth")
|
||||
const _url = new URL(url ?? defaultUrl)
|
||||
const path = (_url.pathname === "/" ? defaultUrl.pathname : _url.pathname)
|
||||
// Remove trailing slash
|
||||
.replace(/\/$/, "")
|
||||
|
||||
const base = `${_url.origin}${path}`
|
||||
|
||||
return {
|
||||
origin: _url.origin,
|
||||
host: _url.host,
|
||||
path,
|
||||
base,
|
||||
toString: () => base,
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
|
||||
import type {
|
||||
CallbacksOptions,
|
||||
CookiesOptions,
|
||||
EventCallbacks,
|
||||
LoggerInstance,
|
||||
PagesOptions,
|
||||
SessionOptions,
|
||||
Theme,
|
||||
Awaitable,
|
||||
} from ".."
|
||||
|
||||
import type {
|
||||
OAuthConfig,
|
||||
EmailConfig,
|
||||
CredentialsConfig,
|
||||
ProviderType,
|
||||
} from "../providers"
|
||||
import type { JWTOptions } from "../jwt"
|
||||
import type { Adapter } from "../adapters"
|
||||
import { InternalUrl } from "./parse-url"
|
||||
|
||||
// Below are types that are only supposed be used by next-auth internally
|
||||
|
||||
/** @internal */
|
||||
export type InternalProvider<T extends ProviderType = any> = (T extends "oauth"
|
||||
? OAuthConfig<any>
|
||||
: T extends "email"
|
||||
? EmailConfig
|
||||
: T extends "credentials"
|
||||
? CredentialsConfig
|
||||
: never) & {
|
||||
signinUrl: string
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
export type NextAuthAction =
|
||||
| "providers"
|
||||
| "session"
|
||||
| "csrf"
|
||||
| "signin"
|
||||
| "signout"
|
||||
| "callback"
|
||||
| "verify-request"
|
||||
| "error"
|
||||
| "_log"
|
||||
|
||||
/** @internal */
|
||||
export interface InternalOptions<T extends ProviderType = any> {
|
||||
providers: InternalProvider[]
|
||||
/**
|
||||
* Parsed from `NEXTAUTH_URL` or `VERCEL_URL`.
|
||||
* @default "http://localhost:3000/api/auth"
|
||||
*/
|
||||
url: InternalUrl
|
||||
action: NextAuthAction
|
||||
provider: T extends string
|
||||
? InternalProvider<T>
|
||||
: InternalProvider<T> | undefined
|
||||
csrfToken?: string
|
||||
csrfTokenVerified?: boolean
|
||||
secret: string
|
||||
theme: Theme
|
||||
debug: boolean
|
||||
logger: LoggerInstance
|
||||
session: Required<SessionOptions>
|
||||
pages: Partial<PagesOptions>
|
||||
jwt: JWTOptions
|
||||
events: Partial<EventCallbacks>
|
||||
adapter?: Adapter
|
||||
callbacks: CallbacksOptions
|
||||
cookies: CookiesOptions
|
||||
callbackUrl: string
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export interface NextAuthRequest extends NextApiRequest {
|
||||
options: InternalOptions
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type NextAuthResponse<T = any> = NextApiResponse<T>
|
||||
|
||||
/** @internal */
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
export type NextAuthApiHandler<Result = void, Response = any> = (
|
||||
req: NextAuthRequest,
|
||||
res: NextAuthResponse<Response>
|
||||
) => Awaitable<Result>
|
||||
@@ -1,118 +0,0 @@
|
||||
import {
|
||||
GetServerSidePropsContext,
|
||||
NextApiRequest,
|
||||
NextApiResponse,
|
||||
} from "next"
|
||||
import { NextAuthOptions, Session } from ".."
|
||||
import { NextAuthHandler } from "../core"
|
||||
import { NextAuthAction } from "../lib/types"
|
||||
import { set as setCookie } from "../core/lib/cookie"
|
||||
import logger, { setLogger } from "../lib/logger"
|
||||
|
||||
async function NextAuthNextHandler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
options: NextAuthOptions
|
||||
) {
|
||||
setLogger(options.logger)
|
||||
|
||||
if (!req.query.nextauth) {
|
||||
const error = new Error(
|
||||
"Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly."
|
||||
)
|
||||
|
||||
logger.error("MISSING_NEXTAUTH_API_ROUTE_ERROR", error)
|
||||
return res.status(500).send(error.message)
|
||||
}
|
||||
|
||||
const host = process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL
|
||||
if (!host) logger.warn("NEXTAUTH_URL")
|
||||
|
||||
const {
|
||||
body,
|
||||
redirect,
|
||||
cookies,
|
||||
headers,
|
||||
status = 200,
|
||||
} = await NextAuthHandler({
|
||||
req: {
|
||||
host,
|
||||
body: req.body,
|
||||
query: req.query,
|
||||
cookies: req.cookies,
|
||||
headers: req.headers,
|
||||
method: req.method ?? "GET",
|
||||
action: req.query.nextauth[0] as NextAuthAction,
|
||||
providerId: req.query.nextauth[1],
|
||||
error: req.query.nextauth[1],
|
||||
},
|
||||
options,
|
||||
})
|
||||
|
||||
res.status(status)
|
||||
|
||||
cookies?.forEach((cookie) => {
|
||||
setCookie(res, cookie.name, cookie.value, cookie.options)
|
||||
})
|
||||
headers?.forEach((header) => {
|
||||
res.setHeader(header.key, header.value)
|
||||
})
|
||||
|
||||
if (redirect) {
|
||||
// If the request expects a return URL, send it as JSON
|
||||
// instead of doing an actual redirect.
|
||||
if (req.body?.json !== "true") {
|
||||
// Could chain. .end() when lowest target is Node 14
|
||||
// https://github.com/nodejs/node/issues/33148
|
||||
res.status(302).setHeader("Location", redirect)
|
||||
return res.end()
|
||||
}
|
||||
return res.json({ url: redirect })
|
||||
}
|
||||
|
||||
return res.send(body)
|
||||
}
|
||||
|
||||
function NextAuth(options: NextAuthOptions): any
|
||||
function NextAuth(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
options: NextAuthOptions
|
||||
): any
|
||||
|
||||
/** Tha main entry point to next-auth */
|
||||
function NextAuth(...args) {
|
||||
if (args.length === 1) {
|
||||
return async (req, res) => await NextAuthNextHandler(req, res, args[0])
|
||||
}
|
||||
|
||||
return NextAuthNextHandler(args[0], args[1], args[2])
|
||||
}
|
||||
|
||||
export default NextAuth
|
||||
|
||||
export async function getServerSession(
|
||||
context:
|
||||
| GetServerSidePropsContext
|
||||
| { req: NextApiRequest; res: NextApiResponse },
|
||||
options: NextAuthOptions
|
||||
): Promise<Session | null> {
|
||||
const session = await NextAuthHandler<Session | {}>({
|
||||
options,
|
||||
req: {
|
||||
action: "session",
|
||||
method: "GET",
|
||||
cookies: context.req.cookies,
|
||||
headers: context.req.headers,
|
||||
},
|
||||
})
|
||||
|
||||
const { body, cookies } = session
|
||||
|
||||
cookies?.forEach((cookie) => {
|
||||
setCookie(context.res, cookie.name, cookie.value, cookie.options)
|
||||
})
|
||||
|
||||
if (body && Object.keys(body).length) return body as Session
|
||||
return null
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function FortyTwo(options) {
|
||||
return {
|
||||
id: "42-school",
|
||||
name: "42 School",
|
||||
type: "oauth",
|
||||
authorization: "https://api.intra.42.fr/oauth/authorize",
|
||||
token: "https://api.intra.42.fr/oauth/token",
|
||||
userinfo: "https://api.intra.42.fr/v2/me",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.usual_full_name,
|
||||
email: profile.email,
|
||||
image: profile.image_url,
|
||||
}
|
||||
},
|
||||
options,
|
||||
id: '42-school',
|
||||
name: '42 School',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://api.intra.42.fr/oauth/token',
|
||||
authorizationUrl:
|
||||
'https://api.intra.42.fr/oauth/authorize?response_type=code',
|
||||
profileUrl: 'https://api.intra.42.fr/v2/me',
|
||||
profile: (profile) => ({
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
image: profile.image_url,
|
||||
name: profile.usual_full_name,
|
||||
}),
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,34 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Apple(options) {
|
||||
return {
|
||||
id: "apple",
|
||||
name: "Apple",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: "https://appleid.apple.com/auth/authorize",
|
||||
params: {
|
||||
scope: "name email",
|
||||
response_type: "code",
|
||||
id_token: "",
|
||||
response_mode: "form_post",
|
||||
},
|
||||
},
|
||||
token: {
|
||||
url: "https://appleid.apple.com/auth/token",
|
||||
idToken: true,
|
||||
},
|
||||
jwks_endpoint: "https://appleid.apple.com/auth/keys",
|
||||
version: "2.0",
|
||||
scope: "name email",
|
||||
params: { grant_type: "authorization_code" },
|
||||
accessTokenUrl: "https://appleid.apple.com/auth/token",
|
||||
authorizationUrl:
|
||||
"https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post",
|
||||
profileUrl: null,
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
// The name of the user will only be returned on first login
|
||||
const name = profile.user
|
||||
? profile.user.name.firstName + " " + profile.user.name.lastName
|
||||
: null
|
||||
|
||||
// The name of the user will only return on first login
|
||||
return {
|
||||
id: profile.sub,
|
||||
name,
|
||||
name:
|
||||
profile.user != null
|
||||
? profile.user.name.firstName + " " + profile.user.name.lastName
|
||||
: null,
|
||||
email: profile.email,
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
checks: ["none"], // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
|
||||
options,
|
||||
clientId: null,
|
||||
clientSecret: {
|
||||
teamId: null,
|
||||
privateKey: null,
|
||||
keyId: null,
|
||||
},
|
||||
protection: "none", // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/** @type {import(".").OAuthProvider} */
|
||||
export default function Atlassian(options) {
|
||||
return {
|
||||
id: "atlassian",
|
||||
name: "Atlassian",
|
||||
type: "oauth",
|
||||
authorization: {
|
||||
url: "https://auth.atlassian.com/oauth/authorize",
|
||||
params: {
|
||||
audience: "api.atlassian.com",
|
||||
prompt: "consent",
|
||||
},
|
||||
version: "2.0",
|
||||
params: {
|
||||
grant_type: "authorization_code",
|
||||
},
|
||||
token: "https://auth.atlassian.com/oauth/token",
|
||||
userinfo: "https://api.atlassian.com/me",
|
||||
accessTokenUrl: "https://auth.atlassian.com/oauth/token",
|
||||
authorizationUrl:
|
||||
"https://auth.atlassian.com/authorize?audience=api.atlassian.com&response_type=code&prompt=consent",
|
||||
profileUrl: "https://api.atlassian.com/me",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.account_id,
|
||||
@@ -21,6 +19,6 @@ export default function Atlassian(options) {
|
||||
image: profile.picture,
|
||||
}
|
||||
},
|
||||
options,
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
22
src/providers/auth0.js
Normal file
22
src/providers/auth0.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function Auth0(options) {
|
||||
return {
|
||||
id: "auth0",
|
||||
name: "Auth0",
|
||||
type: "oauth",
|
||||
version: "2.0",
|
||||
params: { grant_type: "authorization_code" },
|
||||
scope: "openid email profile",
|
||||
accessTokenUrl: `https://${options.domain}/oauth/token`,
|
||||
authorizationUrl: `https://${options.domain}/authorize?response_type=code`,
|
||||
profileUrl: `https://${options.domain}/userinfo`,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.nickname,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { OAuthConfig, OAuthUserConfig } from "./oauth"
|
||||
|
||||
export interface Auth0Profile {
|
||||
sub: string
|
||||
nicname: string
|
||||
email: string
|
||||
picture: string
|
||||
}
|
||||
|
||||
export default function Auth0<P extends Record<string, any> = Auth0Profile>(
|
||||
options: OAuthUserConfig<P>
|
||||
): OAuthConfig<P> {
|
||||
return {
|
||||
id: "auth0",
|
||||
name: "Auth0",
|
||||
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
|
||||
type: "oauth",
|
||||
authorization: { params: { scope: "openid email profile" } },
|
||||
checks: ["pkce", "state"],
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.nickname,
|
||||
email: profile.email,
|
||||
image: profile.picture,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
24
src/providers/azure-ad-b2c.js
Normal file
24
src/providers/azure-ad-b2c.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default function AzureADB2C(options) {
|
||||
const tenant = options.tenantId ? options.tenantId : "common"
|
||||
|
||||
return {
|
||||
id: "azure-ad-b2c",
|
||||
name: "Azure Active Directory B2C",
|
||||
type: "oauth",
|
||||
version: "2.0",
|
||||
params: {
|
||||
grant_type: "authorization_code",
|
||||
},
|
||||
accessTokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
||||
authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query`,
|
||||
profileUrl: "https://graph.microsoft.com/v1.0/me/",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.displayName,
|
||||
email: profile.userPrincipalName,
|
||||
}
|
||||
},
|
||||
...options,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { OAuthConfig, OAuthUserConfig } from "."
|
||||
|
||||
export interface AzureB2CProfile {
|
||||
exp: number
|
||||
nbf: number
|
||||
ver: string
|
||||
iss: string
|
||||
sub: string
|
||||
aud: string
|
||||
iat: number
|
||||
auth_time: number
|
||||
oid: string
|
||||
country: string
|
||||
name: string
|
||||
postalCode: string
|
||||
emails: string[]
|
||||
tfp: string
|
||||
}
|
||||
|
||||
export default function AzureADB2C<
|
||||
P extends Record<string, any> = AzureB2CProfile
|
||||
>(
|
||||
options: OAuthUserConfig<P> & {
|
||||
primaryUserFlow: string
|
||||
tenantId: string
|
||||
}
|
||||
): OAuthConfig<P> {
|
||||
const { tenantId, primaryUserFlow } = options
|
||||
return {
|
||||
id: "azure-ad-b2c",
|
||||
name: "Azure Active Directory B2C",
|
||||
type: "oauth",
|
||||
wellKnown: `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${primaryUserFlow}/v2.0/.well-known/openid-configuration`,
|
||||
idToken: true,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.emails[0],
|
||||
// TODO: Find out how to retrieve the profile picture
|
||||
image: null,
|
||||
}
|
||||
},
|
||||
options,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user