mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
130 Commits
v3.2.0-can
...
v3.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6af40e3fe2 | ||
|
|
cdc1ac52b2 | ||
|
|
f2a7ee0b34 | ||
|
|
d67f1b7718 | ||
|
|
396f5d8bbc | ||
|
|
d7e78d5996 | ||
|
|
ad3b0b6a7d | ||
|
|
f4a954ccbb | ||
|
|
93f051ce08 | ||
|
|
645b53ee49 | ||
|
|
9c9744f30a | ||
|
|
9860ad8c8c | ||
|
|
23ada52f97 | ||
|
|
65e4910618 | ||
|
|
30c6d6360f | ||
|
|
214b22ecbb | ||
|
|
f50ac194aa | ||
|
|
b40e1441ec | ||
|
|
90a8f7c890 | ||
|
|
a7bae0395b | ||
|
|
71b3122fd1 | ||
|
|
1caa9bb813 | ||
|
|
daed68aee2 | ||
|
|
2f3ed7507b | ||
|
|
a15bdc191b | ||
|
|
ea71a1fb2f | ||
|
|
0069095bce | ||
|
|
42a822c407 | ||
|
|
2cfe5ad879 | ||
|
|
cba149f4b6 | ||
|
|
31bb2c342c | ||
|
|
af30be1fa4 | ||
|
|
ebaa28f04e | ||
|
|
e26297b901 | ||
|
|
2562b3c5d8 | ||
|
|
f8e5a79ce1 | ||
|
|
be53ef0f71 | ||
|
|
fbb5a12cbd | ||
|
|
70a186c183 | ||
|
|
cff9d3e294 | ||
|
|
2865b8cc2d | ||
|
|
b84f1b681c | ||
|
|
ea1d09bf83 | ||
|
|
a18ec09307 | ||
|
|
37cb81094f | ||
|
|
040d7c5017 | ||
|
|
f53ea6c9a9 | ||
|
|
7f670c5222 | ||
|
|
b1a99ec32f | ||
|
|
ca065604a3 | ||
|
|
77de2abd14 | ||
|
|
f6d6c4344c | ||
|
|
e8b1513899 | ||
|
|
505efc8a5d | ||
|
|
76b983229a | ||
|
|
ecddaf696b | ||
|
|
b43e7dca43 | ||
|
|
e7c34fd74b | ||
|
|
e0dd8e400b | ||
|
|
21d22a7e08 | ||
|
|
cb2fe0cae6 | ||
|
|
9ed75c7788 | ||
|
|
8e8713755a | ||
|
|
7fdde6268e | ||
|
|
5e949a3b97 | ||
|
|
a979e040cd | ||
|
|
2205cfa754 | ||
|
|
0989ef6171 | ||
|
|
7979b1069e | ||
|
|
71b50082f8 | ||
|
|
f3cc4d1018 | ||
|
|
15570b7479 | ||
|
|
a5187b69e8 | ||
|
|
751fd7bb0e | ||
|
|
94054db3f3 | ||
|
|
f93dbbbfee | ||
|
|
e3fd0ad450 | ||
|
|
e06816a374 | ||
|
|
284118e708 | ||
|
|
84bcecbec1 | ||
|
|
5060bd7f9b | ||
|
|
16a8720b1d | ||
|
|
3c056d7ff5 | ||
|
|
49dd7a807d | ||
|
|
42596fbca5 | ||
|
|
68d0f9465a | ||
|
|
71b4af0894 | ||
|
|
8bbb0ec344 | ||
|
|
b2770d5a1f | ||
|
|
192e5bf07e | ||
|
|
9abdbb57eb | ||
|
|
989d23e827 | ||
|
|
025f33a91f | ||
|
|
6e2fc11d64 | ||
|
|
6b1b8613d0 | ||
|
|
6d023aa533 | ||
|
|
080dd5f569 | ||
|
|
a6867b3564 | ||
|
|
6750accc0a | ||
|
|
9aae7bbc54 | ||
|
|
a84fe596af | ||
|
|
18840ead40 | ||
|
|
f72ee5ec06 | ||
|
|
958c31a4ee | ||
|
|
f47f5c6c62 | ||
|
|
d5f5157366 | ||
|
|
9fbfd90bb2 | ||
|
|
97d6f19fab | ||
|
|
fdcc62bd26 | ||
|
|
4764d60268 | ||
|
|
5f51c975c9 | ||
|
|
0c9104c13a | ||
|
|
8a6f0944ef | ||
|
|
672cedc712 | ||
|
|
05b275956b | ||
|
|
219b01724b | ||
|
|
94b0c68c8f | ||
|
|
81ebff897f | ||
|
|
8ac14ed298 | ||
|
|
51cfec92ba | ||
|
|
78fd783bac | ||
|
|
cec46b0d67 | ||
|
|
7bedd4afa9 | ||
|
|
6e6a24a7af | ||
|
|
6f067be7b0 | ||
|
|
e4cc3a92e5 | ||
|
|
610ab39b40 | ||
|
|
7d1168637d | ||
|
|
b2c1f32057 | ||
|
|
9247495fee |
@@ -1,3 +1,5 @@
|
||||
# Rename file to .env and populate values
|
||||
# to be able to run tests
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_TWITTER_ID=
|
||||
NEXTAUTH_TWITTER_SECRET=
|
||||
|
||||
15
.env.local.example
Normal file
15
.env.local.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Rename file to .env.local and populate values
|
||||
# to be able to run the dev app
|
||||
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
SECRET= Linux: `openssl rand -hex 32` or https://generate-secret.now.sh/32
|
||||
|
||||
AUTH0_ID=
|
||||
AUTH0_DOMAIN=
|
||||
AUTH0_SECRET=
|
||||
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
|
||||
TWITTER_ID=
|
||||
TWITTER_SECRET=
|
||||
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -7,10 +7,8 @@ this project (found in the CODE_OF_CONDUCT.md file).
|
||||
Also, please make sure you're familiar with and follow the instructions in the
|
||||
contributing guidelines (found in the CONTRIBUTING.md file).
|
||||
|
||||
Note before creating the Pull Request. Even though the CONTRIBUTONG.md tells otherwise, we ask you to use the `canary` branch as base for your PR. We are tranistioning to a new structure, and the CONTRIBUTONG.md file has not been updated yet. Thank you!
|
||||
|
||||
If you're new to contributing to open source projects, you might find this free
|
||||
video course helpful: http://kcd.im/pull-request
|
||||
video course helpful: https://kcd.im/pull-request
|
||||
|
||||
Please fill out the information below to expedite the review and (hopefully)
|
||||
merge of your pull request!
|
||||
|
||||
21
.github/labeler.yml
vendored
Normal file
21
.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
test:
|
||||
- test/**/*
|
||||
|
||||
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/**/*
|
||||
4
.github/workflows/integration.yml
vendored
4
.github/workflows/integration.yml
vendored
@@ -2,9 +2,9 @@ name: Integration Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches: [ main, canary ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, canary ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
12
.github/workflows/labeler.yml
vendored
Normal file
12
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
name: "Pull Request Labeler"
|
||||
on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@main
|
||||
with:
|
||||
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
sync-labels: true
|
||||
156
CONTRIBUTING.md
156
CONTRIBUTING.md
@@ -8,115 +8,71 @@ Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates co
|
||||
|
||||
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
|
||||
|
||||
## Pull Requests
|
||||
## For contributors
|
||||
|
||||
* The latest changes are always in `main`
|
||||
* Pull Requests should be raised for larger changes
|
||||
* Pull Requests do not need approval before merging for those with contributor access (it's just helpful to have them to track changes)
|
||||
Anyone can be a contributor. Either you found a typo, or you have an awesome feature request you could implement, we encourage you to create a Pull Request.
|
||||
### Pull Requests
|
||||
|
||||
* The latest changes are always in `canary`, so please make your Pull Request against that branch.
|
||||
* Pull Requests should be raised for any change
|
||||
* Pull Requests need approval of a [core contributor](https://next-auth.js.org/contributors#core-team) before merging
|
||||
* Rebasing in Pull Requests is preferred to keep a clean commit history (see below)
|
||||
* Running `npm run lint:fix` before committing can make resolving conflicts easier, but is not required
|
||||
* Merge commits (and pushing merge commits to `main`) are disabled in this repo, but commits in PR can be squashed so this is not a blocker
|
||||
* Pushing directly to main should ideally be reserved for minor updates (e.g. correcting typos) or small single-commit fixes
|
||||
* Run `npm run lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this extension](https://marketplace.visualstudio.com/items?itemName=chenxsan.vscode-standardjs) to fix lint issues in development)
|
||||
* We encourage you to test your changes, and if you have the opportunity, please make those tests part of the Pull Request
|
||||
* If you add new functionality, please provide the corresponding documentation as well and make it part of the Pull Request
|
||||
|
||||
## Rebasing
|
||||
### Setting up local environment
|
||||
|
||||
*If you don't rebase and end up with merge commits in a PR then it's not a blocker, we can always squash the commits when merging!*
|
||||
|
||||
If you create a branch and there are conflicting updates in the `main` branch, you can resolve them by rebasing from a check out of your branch:
|
||||
|
||||
git fetch
|
||||
git rebase origin/main
|
||||
|
||||
If there are any conflicts, you can resolve them and stage the files, then run:
|
||||
|
||||
git rebase --continue
|
||||
|
||||
*If there are a lot of changes you may be prompted to step more than once.*
|
||||
|
||||
When the rebase is complete (i.e. there are no more conflicts) you should push your changes to your branch before doing anything else:
|
||||
|
||||
git push --force-with-lease
|
||||
|
||||
You should see that any conflicts in your PR are now resolved. You can review changes to make sure it contains changes you intended to make.
|
||||
|
||||
*If you accidentally sync before pushing, it will trigger a merge. You can use `git merge --abort` to undo the merge.*
|
||||
|
||||
You can use `npm run lint:fix` to automatically apply Standard JS rules to resolve formatting differences (tabs vs spaces, line endings, etc).
|
||||
|
||||
## Setting up local environment
|
||||
|
||||
A quick and dirty guide on how to setup *next-auth* locally to work on it and test out any changes:
|
||||
A quick guide on how to setup *next-auth* locally to work on it and test out any changes:
|
||||
|
||||
1. Clone the repo:
|
||||
```sh
|
||||
git clone git@github.com:nextauthjs/next-auth.git
|
||||
cd next-auth
|
||||
```
|
||||
|
||||
git clone git@github.com:iaincollins/next-auth.git
|
||||
cd next-auth/
|
||||
2. Install packages:
|
||||
```sh
|
||||
npm i
|
||||
```
|
||||
|
||||
2. Install packages and run the build command:
|
||||
3. Populate `.env.local`:
|
||||
|
||||
Copy `.env.local.example` to `.env.local`, and add your env variables for each provider you want to test.
|
||||
|
||||
npm i
|
||||
npm run build
|
||||
> 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`pages/api/auth/[...nextauth].js`.
|
||||
|
||||
3. Link your project back to your local copy of next auth:
|
||||
1. Start the dev application/server and CSS watching:
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
cd ../your-application
|
||||
npm link ../next-auth
|
||||
Your dev application will be available on ```http://localhost:3000```
|
||||
|
||||
4. Finally link React between the repo and the version installed in your project:
|
||||
|
||||
cd ../next-auth
|
||||
npm link ../your-application/node_modules/react
|
||||
|
||||
*This is an annoying step and not obvious, but is needed because of how React has been written (otherwise React crashes when you try to use the `useSession()` hook in your project).*
|
||||
|
||||
That's it!
|
||||
|
||||
Notes: You may need to repeat both `npm link` steps if you install / update additional dependencies with `npm i`.
|
||||
That's it! 🎉
|
||||
|
||||
If you need an example project to link to, you can use [next-auth-example](https://github.com/iaincollins/next-auth-example).
|
||||
|
||||
### Hot reloading
|
||||
#### Hot reloading
|
||||
|
||||
You might find it helpful to use the `npm run watch` command in the next-auth project, which will automatically (and silently) rebuild JS and CSS files as you edit them.
|
||||
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.
|
||||
|
||||
cd next-auth/
|
||||
npm run watch
|
||||
>NOTE: When working on CSS, you will need to manually refresh the page after changes. (Improving this through a PR is very welcome!)
|
||||
|
||||
If you are working on `next-auth/src/client/index.js` hot reloading will work as normal in your Next.js app.
|
||||
|
||||
However, if you are working on anything else (e.g. `next-auth/src/server/*` etc) then you will need to *stop and start* your app for changes to apply as **Next.js will not hot reload those changes by default**. To facilitate this, you can try [this webpack plugin](https://www.npmjs.com/package/webpack-clear-require-cache-plugin). Note that the `next.config.js` syntax in the plugin README may be out of date. It should look like this:
|
||||
|
||||
```
|
||||
const clearRequireCachePlugin = require('webpack-clear-require-cache-plugin')
|
||||
|
||||
module.exports = {
|
||||
webpack: (config, {
|
||||
buildId, dev, isServer, defaultLoaders, webpack,
|
||||
}) => {
|
||||
config.plugins.push(clearRequireCachePlugin([
|
||||
/\.next\/server\/static\/development\/pages/,
|
||||
/\.next\/server\/ssr-module-cache.js/,
|
||||
/next-auth/,
|
||||
]))
|
||||
|
||||
return config
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Databases
|
||||
#### Databases
|
||||
|
||||
Included is a Docker Compose file that starts up MySQL, Postgres, and MongoDB databases on localhost.
|
||||
|
||||
It will use port 3306, 5432, and 27017 on localhost respectively; it will not work if are running existing databases on localhost.
|
||||
It will use port `3306`, `5432`, and `27017` on localhost respectively; please make sure those ports are not used by other services on localhost.
|
||||
|
||||
You can start them with `npm run db:start` and stop them with `npm run db:stop`.
|
||||
|
||||
You will need Docker installed to be able to start / stop the databases.
|
||||
You will need Docker and Docker Compose installed to be able to start / stop the databases.
|
||||
|
||||
When stopping the databases, it will reset their contents.
|
||||
|
||||
### Testing
|
||||
#### Testing
|
||||
|
||||
Tests can be run with `npm run test`.
|
||||
|
||||
@@ -125,3 +81,39 @@ Automated tests are currently crude and limited in functionality, but improvemen
|
||||
Currently, to run tests you need to first have started local test databases (e.g. using `npm run db:start`).
|
||||
|
||||
The databases can take a few seconds to start up, so you might need to give it a minute before running the tests.
|
||||
|
||||
## For maintainers
|
||||
|
||||
We use [semantic-release](https://github.com/semantic-release/semantic-release) together with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to automate releases. This makes the maintainenance process easier and less error-prone to human error. Please study the "Conventional Commits" site to understand how to write a good commit message.
|
||||
|
||||
When accepting Pull Requests, make sure the following:
|
||||
|
||||
* Use "Squash and merge"
|
||||
* Make sure you merge contributor PRs into `canary`
|
||||
* Rewrite the commit message to conform to the `Conventional Commits` style. Check the "Recommended Scopes" section for further advice.
|
||||
* Optionally link issues the PR will resolve (You can add "close" in front of the issue numbers to close the issues automatically, when the PR is merged. `semantic-release` will also comment back to connected issues and PRs, notifying the users that a feature is added/bug fixed, etc.)
|
||||
|
||||
### Recommended Scopes
|
||||
|
||||
A typical conventional commit looks like this:
|
||||
```
|
||||
type(scope): title
|
||||
|
||||
body
|
||||
```
|
||||
|
||||
Scope is the part that will help groupping the different commit types in the release notes.
|
||||
|
||||
Some recommened scopes are:
|
||||
|
||||
- **provider** - Provider related changes. (eg.: "feat(provider): add X provider", "docs(provider): fix typo in X documentation"
|
||||
- **adapter** - Adapter related changes. (eg.: "feat(adapter): add X provider", "docs(provider): fix typo in X documentation"
|
||||
- **db** - Database related changes. (eg.: "feat(db): add X database", "docs(db): fix typo in X documentation"
|
||||
- **deps** - Adding/removing/updating a dependency (eg.: "chore(deps): add X")
|
||||
|
||||
> NOTE: If you are not sure which scope to use, you can simply ignore it. (eg.: "feat: add something"). Adding the correct type already helps a lot when analyzing the commit messages.
|
||||
|
||||
|
||||
### Skipping a release
|
||||
|
||||
Every commit that contains [skip release] or [release skip] in their message will be excluded from the commit analysis and won't participate in the release type determination. This is useful, if the PR being merged should not trigger a new `npm` release.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2018-2020, Iain Collins
|
||||
Copyright (c) 2018-2021, Iain Collins
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
44
README.md
44
README.md
@@ -69,7 +69,7 @@ NextAuth.js can be used with or without a database.
|
||||
|
||||
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.
|
||||
|
||||
### Typescript
|
||||
### TypeScript
|
||||
|
||||
You can install the appropriate types via the following command:
|
||||
|
||||
@@ -77,9 +77,7 @@ You can install the appropriate types via the following command:
|
||||
npm install --save-dev @types/next-auth
|
||||
```
|
||||
|
||||
If you encounter any problems with the types package, please create an issue and add the `typescript` label to it.
|
||||
|
||||
Alternatively, you can open a pull request directly with your fixes on the [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth) repository, where you'll find a `next-auth` subfolder.
|
||||
As of now, TypeScript is a community effort. If you encounter any problems with the types package, please create an issue at [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth). Alternatively, you can open a pull request directly with your fixes there. We welcome anyone to start a discussion on migrating this package to TypeScript, or how to improve the TypeScript experience in general.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -114,26 +112,22 @@ export default NextAuth({
|
||||
### Add React Component
|
||||
|
||||
```javascript
|
||||
import React from 'react'
|
||||
import {
|
||||
useSession,
|
||||
signin,
|
||||
signout
|
||||
import {
|
||||
useSession, signIn, signOut
|
||||
} from 'next-auth/client'
|
||||
|
||||
export default function myComponent() {
|
||||
export default function Component() {
|
||||
const [ session, loading ] = useSession()
|
||||
|
||||
return <p>
|
||||
{!session && <>
|
||||
Not signed in <br/>
|
||||
<button onClick={signin}>Sign in</button>
|
||||
</>}
|
||||
{session && <>
|
||||
if(session) {
|
||||
return <>
|
||||
Signed in as {session.user.email} <br/>
|
||||
<button onClick={signout}>Sign out</button>
|
||||
</>}
|
||||
</p>
|
||||
<button onClick={() => signOut()}>Sign out</button>
|
||||
</>
|
||||
}
|
||||
return <>
|
||||
Not signed in <br/>
|
||||
<button onClick={() => signIn()}>Sign in</button>
|
||||
</>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -144,10 +138,18 @@ export default function myComponent() {
|
||||
<a href="https://github.com/nextauthjs/next-auth/graphs/contributors">
|
||||
<img width="500px" src="https://contrib.rocks/image?repo=nextauthjs/next-auth" />
|
||||
</a>
|
||||
<div>
|
||||
<a href="https://vercel.com?utm_source=nextauthjs&utm_campaign=oss">
|
||||
<img width="170px" src="https://raw.githubusercontent.com/nextauthjs/next-auth/canary/www/static/img/powered-by-vercel.svg" alt="Powered By Vercel" />
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p align="left">Thanks to Vercel sponsoring this project by allowing it to be deployed for free for the entire NextAuth.js Team</p>
|
||||
</div>
|
||||
|
||||
## Contributing
|
||||
|
||||
We're open to all community contributions! If you'd like to contribute in any way, please first read our [Contributing Guide](https://github.com/iaincollins/next-auth/blob/main/CONTRIBUTING.md).
|
||||
We're open to all community contributions! If you'd like to contribute in any way, please first read our [Contributing Guide](https://github.com/nextauthjs/next-auth/blob/canary/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "targets": { "esmodules": true } } ]
|
||||
],
|
||||
"comments": false,
|
||||
"overrides": [
|
||||
{
|
||||
"test": [ "./src/server/pages/**" ],
|
||||
"presets": [ "preact" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
16
components/access-denied.js
Normal file
16
components/access-denied.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { signIn } from 'next-auth/client'
|
||||
|
||||
export default function AccessDenied () {
|
||||
return (
|
||||
<>
|
||||
<h1>Access Denied</h1>
|
||||
<p>
|
||||
<a href="/api/auth/signin"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signIn()
|
||||
}}>You must be signed in to view this page</a>
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
18
components/footer.js
Normal file
18
components/footer.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Link from 'next/link'
|
||||
import styles from './footer.module.css'
|
||||
import { version } from 'package.json'
|
||||
|
||||
export default function Footer () {
|
||||
return (
|
||||
<footer className={styles.footer}>
|
||||
<hr />
|
||||
<ul className={styles.navItems}>
|
||||
<li className={styles.navItem}><a href='https://next-auth.js.org'>Documentation</a></li>
|
||||
<li className={styles.navItem}><a href='https://www.npmjs.com/package/next-auth'>NPM</a></li>
|
||||
<li className={styles.navItem}><a href='https://github.com/nextauthjs/next-auth-example'>GitHub</a></li>
|
||||
<li className={styles.navItem}><Link href='/policy'><a>Policy</a></Link></li>
|
||||
<li className={styles.navItem}><em>{version}</em></li>
|
||||
</ul>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
14
components/footer.module.css
Normal file
14
components/footer.module.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.footer {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.navItems {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
103
components/header.js
Normal file
103
components/header.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import Link from 'next/link'
|
||||
import { signIn, signOut, useSession } from 'next-auth/client'
|
||||
import * as client 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 [session, loading] = useSession()
|
||||
|
||||
return (
|
||||
<header>
|
||||
<noscript>
|
||||
<style>{'.nojs-show { opacity: 1; top: 0; }'}</style>
|
||||
</noscript>
|
||||
<div className={styles.signedInStatus}>
|
||||
<p
|
||||
className={`nojs-show ${
|
||||
!session && loading ? styles.loading : styles.loaded
|
||||
}`}
|
||||
>
|
||||
{!session && (
|
||||
<>
|
||||
<span className={styles.notSignedInText}>
|
||||
You are not signed in
|
||||
</span>
|
||||
<a
|
||||
href="/api/auth/signin"
|
||||
className={styles.buttonPrimary}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signIn()
|
||||
}}
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
{session.user.image && (
|
||||
<span
|
||||
style={{ backgroundImage: `url(${session.user.image})` }}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.signedInText}>
|
||||
<small>Signed in as</small>
|
||||
<br />
|
||||
<strong>{session.user.email || session.user.name}</strong>
|
||||
</span>
|
||||
<a
|
||||
href="/api/auth/signout"
|
||||
className={styles.button}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
signOut()
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<ul className={styles.navItems}>
|
||||
<li className={styles.navItem}>
|
||||
<Link href='/'>
|
||||
<a>Home</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href='/client'>
|
||||
<a>Client</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href='/server'>
|
||||
<a>Server</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href='/protected'>
|
||||
<a>Protected</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href='/protected-ssr'>
|
||||
<a>Protected(SSR)</a>
|
||||
</Link>
|
||||
</li>
|
||||
<li className={styles.navItem}>
|
||||
<Link href='/api-example'>
|
||||
<a>API</a>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
92
components/header.module.css
Normal file
92
components/header.module.css
Normal file
@@ -0,0 +1,92 @@
|
||||
/* Set min-height to avoid page reflow while session loading */
|
||||
.signedInStatus {
|
||||
display: block;
|
||||
min-height: 4rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.loaded {
|
||||
position: relative;
|
||||
top: 0;
|
||||
opacity: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 0 0 .6rem .6rem;
|
||||
padding: .6rem 1rem;
|
||||
margin: 0;
|
||||
background-color: rgba(0,0,0,.05);
|
||||
transition: all 0.2s ease-in;
|
||||
}
|
||||
|
||||
.loading {
|
||||
top: -2rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.signedInText,
|
||||
.notSignedInText {
|
||||
position: absolute;
|
||||
padding-top: .8rem;
|
||||
left: 1rem;
|
||||
right: 6.5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: inherit;
|
||||
z-index: 1;
|
||||
line-height: 1.3rem;
|
||||
}
|
||||
|
||||
.signedInText {
|
||||
padding-top: 0rem;
|
||||
left: 4.6rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
border-radius: 2rem;
|
||||
float: left;
|
||||
height: 2.8rem;
|
||||
width: 2.8rem;
|
||||
background-color: white;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.button,
|
||||
.buttonPrimary {
|
||||
float: right;
|
||||
margin-right: -.4rem;
|
||||
font-weight: 500;
|
||||
border-radius: .3rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4rem;
|
||||
padding: .7rem .8rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background-color: transparent;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.buttonPrimary {
|
||||
background-color: #346df1;
|
||||
border-color: #346df1;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
padding: .7rem 1.4rem;
|
||||
}
|
||||
|
||||
.buttonPrimary:hover {
|
||||
box-shadow: inset 0 0 5rem rgba(0,0,0,0.2)
|
||||
}
|
||||
|
||||
.navItems {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.navItem {
|
||||
display: inline-block;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
14
components/layout.js
Normal file
14
components/layout.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import Header from 'components/header'
|
||||
import Footer from 'components/footer'
|
||||
|
||||
export default function Layout ({ children }) {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
12
config/babel.config.json
Normal file
12
config/babel.config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "targets": { "esmodules": true } }]
|
||||
],
|
||||
"comments": false,
|
||||
"overrides": [
|
||||
{
|
||||
"test": ["../src/server/pages/**"],
|
||||
"presets": ["preact"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Serverless target in Next.js does not work if you try to read in files at runtime
|
||||
// that are not JavaScript or JSON (e.g. CSS files).
|
||||
// https://github.com/iaincollins/next-auth/issues/281
|
||||
// https://github.com/nextauthjs/next-auth/issues/281
|
||||
//
|
||||
// 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
|
||||
12
jsconfig.json
Normal file
12
jsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"next-auth": ["./src/server"],
|
||||
"next-auth/adapters": ["./src/adapters"],
|
||||
"next-auth/client": ["./src/client"],
|
||||
"next-auth/jwt": ["./src/lib/jwt"],
|
||||
"next-auth/providers": ["./src/providers"]
|
||||
}
|
||||
}
|
||||
}
|
||||
6604
package-lock.json
generated
6604
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -8,21 +8,21 @@
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "npm run build:js && npm run build:css",
|
||||
"build:js": "babel src --out-dir dist",
|
||||
"build:css": "postcss src/**/*.css --base src --dir dist && node scripts/wrap-css.js",
|
||||
"build:js": "babel --config-file ./config/babel.config.json src --out-dir dist",
|
||||
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
|
||||
"dev": "next | npm run watch:css",
|
||||
"watch": "npm run watch:js | npm run watch:css",
|
||||
"watch:js": "babel --watch src --out-dir dist",
|
||||
"watch:css": "postcss --watch src/**/*.css --base src --dir dist",
|
||||
"watch:js": "babel --config-file ./config/babel.config.json --watch src --out-dir dist",
|
||||
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
|
||||
"test:app:start": "docker-compose -f test/docker/app.yml up -d",
|
||||
"test:app:rebuild": "npm run build && docker-compose -f test/docker/app.yml up -d --build",
|
||||
"test:app:stop": "docker-compose -f test/docker/app.yml down",
|
||||
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop",
|
||||
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql && npm run test:db:fauna",
|
||||
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql",
|
||||
"test:db:mysql": "node test/mysql.js",
|
||||
"test:db:postgres": "node test/postgres.js",
|
||||
"test:db:mongodb": "node test/mongodb.js",
|
||||
"test:db:mssql": "node test/mssql.js",
|
||||
"test:db:fauna": "node test/fauna.js",
|
||||
"test:integration": "mocha test/integration",
|
||||
"db:start": "docker-compose -f test/docker/databases.yml up -d",
|
||||
"db:stop": "docker-compose -f test/docker/databases.yml down",
|
||||
@@ -43,18 +43,17 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.0.0",
|
||||
"faunadb": "^3.0.1",
|
||||
"futoin-hkdf": "^1.3.2",
|
||||
"jose": "^1.27.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"nodemailer": "^6.4.16",
|
||||
"oauth": "^0.9.15",
|
||||
"pkce-challenge": "^2.1.0",
|
||||
"preact": "^10.4.1",
|
||||
"preact-render-to-string": "^5.1.7",
|
||||
"querystring": "^0.2.0",
|
||||
"require_optional": "^1.0.1",
|
||||
"typeorm": "^0.2.24"
|
||||
"typeorm": "^0.2.30"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1 || ^17",
|
||||
@@ -84,17 +83,22 @@
|
||||
"mongodb": "^3.5.9",
|
||||
"mssql": "^6.2.1",
|
||||
"mysql": "^2.18.1",
|
||||
"next": "^10.0.5",
|
||||
"pg": "^8.2.1",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"postcss-nested": "^4.2.1",
|
||||
"puppeteer": "^5.2.1",
|
||||
"puppeteer-extra": "^3.1.15",
|
||||
"puppeteer-extra-plugin-stealth": "^2.6.1",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"standard": "^16.0.3"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"test/"
|
||||
"test/",
|
||||
"pages/",
|
||||
"components/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
31
pages/_app.js
Normal file
31
pages/_app.js
Normal file
@@ -0,0 +1,31 @@
|
||||
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 (
|
||||
<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} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
17
pages/api-example.js
Normal file
17
pages/api-example.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import Layout from '../components/layout'
|
||||
|
||||
export default function Page () {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>API Example</h1>
|
||||
<p>The examples below show responses from the example API endpoints.</p>
|
||||
<p><em>You must be signed in to see responses.</em></p>
|
||||
<h2>Session</h2>
|
||||
<p>/api/examples/session</p>
|
||||
<iframe src="/api/examples/session"/>
|
||||
<h2>JSON Web Token</h2>
|
||||
<p>/api/examples/jwt</p>
|
||||
<iframe src="/api/examples/jwt"/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
92
pages/api/auth/[...nextauth].js
Normal file
92
pages/api/auth/[...nextauth].js
Normal file
@@ -0,0 +1,92 @@
|
||||
import NextAuth from "next-auth"
|
||||
import Providers from "next-auth/providers"
|
||||
|
||||
export default NextAuth({
|
||||
// https://next-auth.js.org/configuration/providers
|
||||
providers: [
|
||||
Providers.GitHub({
|
||||
clientId: process.env.GITHUB_ID,
|
||||
clientSecret: process.env.GITHUB_SECRET,
|
||||
}),
|
||||
Providers.Auth0({
|
||||
clientId: process.env.AUTH0_ID,
|
||||
clientSecret: process.env.AUTH0_SECRET,
|
||||
domain: process.env.AUTH0_DOMAIN,
|
||||
protection: "pkce"
|
||||
}),
|
||||
Providers.Twitter({
|
||||
clientId: process.env.TWITTER_ID,
|
||||
clientSecret: process.env.TWITTER_SECRET,
|
||||
}),
|
||||
],
|
||||
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
|
||||
// https://next-auth.js.org/configuration/databases
|
||||
//
|
||||
// Notes:
|
||||
// * You must to install an appropriate node_module for your database
|
||||
// * The Email provider requires a database (OAuth providers do not)
|
||||
|
||||
// The secret should be set to a reasonably long random string.
|
||||
// It is used to sign cookies and to sign and encrypt JSON Web Tokens, unless
|
||||
// a separate secret is defined explicitly for encrypting the JWT.
|
||||
|
||||
session: {
|
||||
// Use JSON Web Tokens for session instead of database sessions.
|
||||
// This option can be used with or without a database for users/accounts.
|
||||
// Note: `jwt` is automatically set to `true` if no database is specified.
|
||||
jwt: true,
|
||||
|
||||
// Seconds - How long until an idle session expires and is no longer valid.
|
||||
// maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
|
||||
// Seconds - Throttle how frequently to write to database to extend a session.
|
||||
// Use it to limit write operations. Set to 0 to always update the database.
|
||||
// Note: This option is ignored if using JSON Web Tokens
|
||||
// updateAge: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
|
||||
// JSON Web tokens are only used for sessions if the `jwt: true` session
|
||||
// option is set - or by default if no database is specified.
|
||||
// https://next-auth.js.org/configuration/options#jwt
|
||||
jwt: {
|
||||
encryption: true,
|
||||
secret: process.env.SECRET,
|
||||
// A secret to use for key generation (you should set this explicitly)
|
||||
// secret: 'INp8IvdIyeMcoGAgFGoA61DdBglwwSqnXJZkgz8PSnw',
|
||||
// Set to true to use encryption (default: false)
|
||||
// encryption: true,
|
||||
// You can define your own encode/decode functions for signing and encryption
|
||||
// if you want to override the default behaviour.
|
||||
// encode: async ({ secret, token, maxAge }) => {},
|
||||
// decode: async ({ secret, token, maxAge }) => {},
|
||||
},
|
||||
|
||||
// You can define custom pages to override the built-in pages.
|
||||
// The routes shown here are the default URLs that will be used when a custom
|
||||
// pages is not specified for that route.
|
||||
// https://next-auth.js.org/configuration/pages
|
||||
pages: {
|
||||
// signIn: '/api/auth/signin', // Displays signin buttons
|
||||
// signOut: '/api/auth/signout', // Displays form with sign out button
|
||||
// error: '/api/auth/error', // Error code passed in query string as ?error=
|
||||
// verifyRequest: '/api/auth/verify-request', // Used for check email page
|
||||
// newUser: null // If set, new users will be directed here on first sign in
|
||||
},
|
||||
|
||||
// Callbacks are asynchronous functions you can use to control what happens
|
||||
// when an action is performed.
|
||||
// https://next-auth.js.org/configuration/callbacks
|
||||
callbacks: {
|
||||
// signIn: async (user, account, profile) => { return Promise.resolve(true) },
|
||||
// redirect: async (url, baseUrl) => { return Promise.resolve(baseUrl) },
|
||||
// session: async (session, user) => { return Promise.resolve(session) },
|
||||
// jwt: async (token, user, account, profile, isNewUser) => { return Promise.resolve(token) }
|
||||
},
|
||||
|
||||
// Events are useful for logging
|
||||
// https://next-auth.js.org/configuration/events
|
||||
events: {},
|
||||
|
||||
// Enable debug messages in the console if you are having problems
|
||||
debug: false,
|
||||
})
|
||||
9
pages/api/examples/jwt.js
Normal file
9
pages/api/examples/jwt.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// This is an example of how to read a JSON Web Token from an API route
|
||||
import jwt from "next-auth/jwt"
|
||||
|
||||
const secret = process.env.SECRET
|
||||
|
||||
export default async (req, res) => {
|
||||
const token = await jwt.getToken({ req, secret, encryption: true })
|
||||
res.send(JSON.stringify(token, null, 2))
|
||||
}
|
||||
12
pages/api/examples/protected.js
Normal file
12
pages/api/examples/protected.js
Normal file
@@ -0,0 +1,12 @@
|
||||
// This is an example of to protect an API route
|
||||
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.' })
|
||||
} else {
|
||||
res.send({ error: 'You must be sign in to view the protected content on this page.' })
|
||||
}
|
||||
}
|
||||
7
pages/api/examples/session.js
Normal file
7
pages/api/examples/session.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// This is an example of how to access a session from an API route
|
||||
import { getSession } from 'next-auth/client'
|
||||
|
||||
export default async (req, res) => {
|
||||
const session = await getSession({ req })
|
||||
res.send(JSON.stringify(session, null, 2))
|
||||
}
|
||||
22
pages/client.js
Normal file
22
pages/client.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import Layout from '../components/layout'
|
||||
|
||||
export default function Page () {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Client Side Rendering</h1>
|
||||
<p>
|
||||
This page uses the <strong>useSession()</strong> React Hook in the <strong></Header></strong> component.
|
||||
</p>
|
||||
<p>
|
||||
The <strong>useSession()</strong> React Hook easy to use and allows pages to render very quickly.
|
||||
</p>
|
||||
<p>
|
||||
The advantage of this approach is that session state is shared between pages by using the <strong>Provider</strong> in <strong>_app.js</strong> so
|
||||
that navigation between pages using <strong>useSession()</strong> is very fast.
|
||||
</p>
|
||||
<p>
|
||||
The disadvantage of <strong>useSession()</strong> is that it requires client side JavaScript.
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
12
pages/index.js
Normal file
12
pages/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import Layout from 'components/layout'
|
||||
|
||||
export default function Page () {
|
||||
return (
|
||||
<Layout>
|
||||
<h1>NextAuth.js Example</h1>
|
||||
<p>
|
||||
This is an example site to demonstrate how to use <a href='https://next-auth.js.org'>NextAuth.js</a> for authentication.
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
30
pages/policy.js
Normal file
30
pages/policy.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import Layout from '../components/layout'
|
||||
|
||||
export default function Page () {
|
||||
return (
|
||||
<Layout>
|
||||
<p>
|
||||
This is an example site to demonstrate how to use <a href={`https://next-auth.js.org`}>NextAuth.js</a> for authentication.
|
||||
</p>
|
||||
<h2>Terms of Service</h2>
|
||||
<p>
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
</p>
|
||||
<h2>Privacy Policy</h2>
|
||||
<p>
|
||||
This site uses JSON Web Tokens and an in-memory database which resets every ~2 hours.
|
||||
</p>
|
||||
<p>
|
||||
Data provided to this site is exclusively used to support signing in
|
||||
and is not passed to any third party services, other than via SMTP or OAuth for the
|
||||
purposes of authentication.
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
37
pages/protected-ssr.js
Normal file
37
pages/protected-ssr.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// This is an example of how to protect content using server rendering
|
||||
import { getSession } from 'next-auth/client'
|
||||
import Layout from '../components/layout'
|
||||
import AccessDenied from '../components/access-denied'
|
||||
|
||||
export default function Page ({ content, session }) {
|
||||
// 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>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
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 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 }
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
session,
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
33
pages/protected.js
Normal file
33
pages/protected.js
Normal file
@@ -0,0 +1,33 @@
|
||||
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 [ session, loading ] = useSession()
|
||||
const [ content , setContent ] = useState()
|
||||
|
||||
// Fetch content from protected route
|
||||
useEffect(()=>{
|
||||
const fetchData = async () => {
|
||||
const res = await fetch('/api/examples/protected')
|
||||
const json = await res.json()
|
||||
if (json.content) { setContent(json.content) }
|
||||
}
|
||||
fetchData()
|
||||
},[session])
|
||||
|
||||
// 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>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
38
pages/server.js
Normal file
38
pages/server.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useSession, getSession } from 'next-auth/client'
|
||||
import Layout from '../components/layout'
|
||||
|
||||
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
|
||||
// is used by `useSession()`.
|
||||
const [ session, loading ] = useSession()
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<h1>Server Side Rendering</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the `session` prop to use sessions with Server Side Rendering
|
||||
export async function getServerSideProps(context) {
|
||||
return {
|
||||
props: {
|
||||
session: await getSession(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
pages/styles.css
Normal file
30
pages/styles.css
Normal file
@@ -0,0 +1,30 @@
|
||||
body {
|
||||
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';
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
li,
|
||||
p {
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
iframe {
|
||||
background: #ccc;
|
||||
border: 1px solid #ccc;
|
||||
height: 10rem;
|
||||
width: 100%;
|
||||
border-radius: .5rem;
|
||||
filter: invert(1);
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
import { query as q } from 'faunadb'
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
const Adapter = (config, options = {}) => {
|
||||
const { faunaClient } = config
|
||||
|
||||
async function getAdapter (appOptions) {
|
||||
function _debug (debugCode, ...args) {
|
||||
logger.debug(`fauna_${debugCode}`, ...args)
|
||||
}
|
||||
|
||||
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
|
||||
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
|
||||
? appOptions.session.maxAge * 1000
|
||||
: defaultSessionMaxAge
|
||||
const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge)
|
||||
? appOptions.session.updateAge * 1000
|
||||
: 0
|
||||
|
||||
async function createUser (profile) {
|
||||
_debug('createUser', profile)
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const FQL = q.Create(
|
||||
q.Collection('user'), {
|
||||
data: {
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.image,
|
||||
emailVerified: profile.emailVerified
|
||||
? profile.emailVerified
|
||||
: false,
|
||||
createdAt: q.Time(timestamp),
|
||||
updatedAt: q.Time(timestamp)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const newUser = await faunaClient.query(FQL)
|
||||
newUser.data.id = newUser.ref.id
|
||||
|
||||
return newUser.data
|
||||
} catch (error) {
|
||||
console.error('CREATE_USER', error)
|
||||
return Promise.reject(new Error('CREATE_USER'))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser (id) {
|
||||
_debug('getUser', id)
|
||||
|
||||
const FQL = q.Get(
|
||||
q.Ref(q.Collection('user'), id)
|
||||
)
|
||||
|
||||
try {
|
||||
const user = await faunaClient.query(FQL)
|
||||
user.data.id = user.ref.id
|
||||
|
||||
return user.data
|
||||
} catch (error) {
|
||||
console.error('GET_USER', error)
|
||||
return Promise.reject(new Error('GET_USER'))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail (email) {
|
||||
_debug('getUserByEmail', email)
|
||||
|
||||
if (!email) {
|
||||
return null
|
||||
}
|
||||
|
||||
const FQL = q.Let(
|
||||
{
|
||||
ref: q.Match(q.Index('user_by_email'), email)
|
||||
},
|
||||
q.If(
|
||||
q.Exists(q.Var('ref')),
|
||||
q.Get(q.Var('ref')),
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
const user = await faunaClient.query(FQL)
|
||||
|
||||
if (user == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
user.data.id = user.ref.id
|
||||
return user.data
|
||||
} catch (error) {
|
||||
console.error('GET_USER_BY_EMAIL', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_EMAIL'))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||
_debug('getUserByProviderAccountId', providerId, providerAccountId)
|
||||
|
||||
const FQL = q.Let(
|
||||
{
|
||||
ref: q.Match(
|
||||
q.Index('account_by_provider_account_id'),
|
||||
[providerId, providerAccountId]
|
||||
)
|
||||
},
|
||||
q.If(
|
||||
q.Exists(q.Var('ref')),
|
||||
q.Get(
|
||||
q.Ref(
|
||||
q.Collection('user'),
|
||||
q.Select(['data', 'userId'],
|
||||
q.Get(q.Var('ref'))
|
||||
)
|
||||
)
|
||||
),
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
const user = await faunaClient.query(FQL)
|
||||
|
||||
if (user == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
user.data.id = user.ref.id
|
||||
|
||||
return user.data
|
||||
} catch (error) {
|
||||
console.error('GET_USER_BY_PROVIDER_ACCOUNT_ID', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID'))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser (user) {
|
||||
_debug('updateUser', user)
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const FQL = q.Update(
|
||||
q.Ref(q.Collection('user'), user.id),
|
||||
{
|
||||
data: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
emailVerified: user.emailVerified ? user.emailVerified : false,
|
||||
updatedAt: q.Time(timestamp)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const user = await faunaClient.query(FQL)
|
||||
user.data.id = user.ref.id
|
||||
|
||||
return user.data
|
||||
} catch (error) {
|
||||
console.error('UPDATE_USER_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_USER_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser (userId) {
|
||||
_debug('deleteUser', userId)
|
||||
|
||||
const FQL = q.Delete(
|
||||
q.Ref(q.Collection('user'), userId)
|
||||
)
|
||||
|
||||
try {
|
||||
await faunaClient.query(FQL)
|
||||
} catch (error) {
|
||||
console.error('DELETE_USER_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_USER_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
|
||||
_debug('linkAccount', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const account = await faunaClient.query(
|
||||
q.Create(q.Collection('account'), {
|
||||
data: {
|
||||
userId: userId,
|
||||
providerId: providerId,
|
||||
providerType: providerType,
|
||||
providerAccountId: providerAccountId,
|
||||
refreshToken: refreshToken,
|
||||
accessToken: accessToken,
|
||||
accessTokenExpires: accessTokenExpires,
|
||||
createdAt: q.Time(timestamp),
|
||||
updatedAt: q.Time(timestamp)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return account.data
|
||||
} catch (error) {
|
||||
console.error('LINK_ACCOUNT_ERROR', error)
|
||||
return Promise.reject(new Error('LINK_ACCOUNT_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount (userId, providerId, providerAccountId) {
|
||||
_debug('unlinkAccount', userId, providerId, providerAccountId)
|
||||
|
||||
const FQL = q.Delete(
|
||||
q.Select('ref',
|
||||
q.Get(
|
||||
q.Match(
|
||||
q.Index('account_by_provider_account_id'),
|
||||
[providerId, providerAccountId]
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await faunaClient.query(FQL)
|
||||
} catch (error) {
|
||||
console.error('UNLINK_ACCOUNT_ERROR', error)
|
||||
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession (user) {
|
||||
_debug('createSession', user)
|
||||
|
||||
let expires = null
|
||||
if (sessionMaxAge) {
|
||||
const dateExpires = new Date()
|
||||
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
|
||||
expires = dateExpires.toISOString()
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const FQL =
|
||||
q.Create(q.Collection('session'), {
|
||||
data: {
|
||||
userId: user.id,
|
||||
expires: q.Time(expires),
|
||||
sessionToken: randomBytes(32).toString('hex'),
|
||||
accessToken: randomBytes(32).toString('hex'),
|
||||
createdAt: q.Time(timestamp),
|
||||
updatedAt: q.Time(timestamp)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const session = await faunaClient.query(FQL)
|
||||
|
||||
session.data.id = session.ref.id
|
||||
|
||||
return session.data
|
||||
} catch (error) {
|
||||
console.error('CREATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('CREATE_SESSION_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession (sessionToken) {
|
||||
_debug('getSession', sessionToken)
|
||||
|
||||
try {
|
||||
var session = await faunaClient.query(
|
||||
q.Get(
|
||||
q.Match(
|
||||
q.Index('session_by_token'),
|
||||
sessionToken
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Check session has not expired (do not return it if it has)
|
||||
if (session && session.expires && new Date() > session.expires) {
|
||||
await _deleteSession(sessionToken)
|
||||
return null
|
||||
}
|
||||
|
||||
session.data.id = session.ref.id
|
||||
|
||||
return session.data
|
||||
} catch (error) {
|
||||
console.error('GET_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('GET_SESSION_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSession (session, force) {
|
||||
_debug('updateSession', session)
|
||||
|
||||
try {
|
||||
const shouldUpdate = sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires
|
||||
if (!shouldUpdate && !force) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Calculate last updated date, to throttle write updates to database
|
||||
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||
//
|
||||
// Default for sessionMaxAge is 30 days.
|
||||
// Default for sessionUpdateAge is 1 hour.
|
||||
const dateSessionIsDueToBeUpdated = new Date(session.expires)
|
||||
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
|
||||
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
|
||||
|
||||
// Trigger update of session expiry date and write to database, only
|
||||
// if the session was last updated more than {sessionUpdateAge} ago
|
||||
const currentDate = new Date()
|
||||
if (currentDate < dateSessionIsDueToBeUpdated && !force) {
|
||||
return null
|
||||
}
|
||||
|
||||
const newExpiryDate = new Date()
|
||||
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
|
||||
|
||||
const updatedSession = await faunaClient.query(
|
||||
q.Update(
|
||||
q.Ref(q.Collection('session'), session.id),
|
||||
{
|
||||
data: {
|
||||
expires: q.Time(newExpiryDate.toISOString()),
|
||||
updatedAt: q.Time(new Date().toISOString())
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
updatedSession.data.id = updatedSession.ref.id
|
||||
|
||||
return updatedSession.data
|
||||
} catch (error) {
|
||||
console.error('UPDATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_SESSION_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteSession (sessionToken) {
|
||||
const FQL = q.Delete(
|
||||
q.Select('ref',
|
||||
q.Get(
|
||||
q.Match(
|
||||
q.Index('session_by_token'),
|
||||
sessionToken
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return faunaClient.query(FQL)
|
||||
}
|
||||
|
||||
async function deleteSession (sessionToken) {
|
||||
_debug('deleteSession', sessionToken)
|
||||
|
||||
try {
|
||||
return await _deleteSession(sessionToken)
|
||||
} catch (error) {
|
||||
console.error('DELETE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_SESSION_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function createVerificationRequest (identifier, url, token, secret, provider) {
|
||||
_debug('createVerificationRequest', identifier)
|
||||
|
||||
const { baseUrl } = appOptions
|
||||
const { sendVerificationRequest, maxAge } = provider
|
||||
|
||||
// Store hashed token (using secret as salt) so that tokens cannot be exploited
|
||||
// even if the contents of the database is compromised
|
||||
// @TODO Use bcrypt function here instead of simple salted hash
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
|
||||
let expires = null
|
||||
if (maxAge) {
|
||||
const dateExpires = new Date()
|
||||
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
|
||||
|
||||
expires = dateExpires.toISOString()
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const FQL = q.Create(
|
||||
q.Collection('verification_request'), {
|
||||
data: {
|
||||
identifier: identifier,
|
||||
token: hashedToken,
|
||||
expires: expires === null ? null : q.Time(expires),
|
||||
createdAt: q.Time(timestamp),
|
||||
updatedAt: q.Time(timestamp)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
try {
|
||||
const verificationRequest = await faunaClient.query(FQL)
|
||||
|
||||
// With the verificationCallback on a provider, you can send an email, or queue
|
||||
// an email to be sent, or perform some other action (e.g. send a text message)
|
||||
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
|
||||
|
||||
return verificationRequest.data
|
||||
} catch (error) {
|
||||
console.error('CREATE_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function getVerificationRequest (identifier, token, secret, provider) {
|
||||
_debug('getVerificationRequest', identifier, token)
|
||||
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
const FQL = q.Let(
|
||||
{
|
||||
ref: q.Match(q.Index('vertification_request_by_token'), hashedToken)
|
||||
},
|
||||
q.If(
|
||||
q.Exists(q.Var('ref')),
|
||||
{
|
||||
ref: q.Var('ref'),
|
||||
request: q.Select('data', q.Get(q.Var('ref')))
|
||||
},
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
const { ref, request: verificationRequest } = await faunaClient.query(FQL)
|
||||
const nowDate = Date.now()
|
||||
|
||||
if (verificationRequest && verificationRequest.expires && verificationRequest.expires < nowDate) {
|
||||
// Delete the expired request so it cannot be used
|
||||
await faunaClient.query(
|
||||
q.Delete(ref)
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return verificationRequest
|
||||
} catch (error) {
|
||||
console.error('GET_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVerificationRequest (identifier, token, secret, provider) {
|
||||
_debug('deleteVerification', identifier, token)
|
||||
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
const FQL = q.Delete(
|
||||
q.Select('ref',
|
||||
q.Get(
|
||||
q.Match(
|
||||
q.Index('vertification_request_by_token'), hashedToken
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
await faunaClient.query(FQL)
|
||||
} catch (error) {
|
||||
console.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR'))
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByProviderAccountId,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
linkAccount,
|
||||
unlinkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createVerificationRequest,
|
||||
getVerificationRequest,
|
||||
deleteVerificationRequest
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getAdapter
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Adapter
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import TypeORM from './typeorm'
|
||||
import Prisma from './prisma'
|
||||
import Fauna from './fauna'
|
||||
|
||||
export default {
|
||||
Default: TypeORM.Adapter,
|
||||
TypeORM,
|
||||
Prisma,
|
||||
Fauna
|
||||
Prisma
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ const Adapter = (config) => {
|
||||
}
|
||||
|
||||
const { id, expires } = session
|
||||
return prisma[Session].update({ where: { id }, data: { expires } })
|
||||
return prisma[Session].update({ where: { id }, data: { expires: expires.toISOString() } })
|
||||
} catch (error) {
|
||||
logger.error('UPDATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
|
||||
|
||||
@@ -144,7 +144,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
try {
|
||||
return manager.findUnique(User, { [idKey]: id })
|
||||
return manager.findOne(User, { [idKey]: id })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
|
||||
@@ -155,7 +155,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
debug('GET_USER_BY_EMAIL', email)
|
||||
try {
|
||||
if (!email) { return Promise.resolve(null) }
|
||||
return manager.findUnique(User, { email })
|
||||
return manager.findOne(User, { email })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_EMAIL_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
|
||||
@@ -165,9 +165,9 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
||||
try {
|
||||
const account = await manager.findUnique(Account, { providerId, providerAccountId })
|
||||
const account = await manager.findOne(Account, { providerId, providerAccountId })
|
||||
if (!account) { return null }
|
||||
return manager.findUnique(User, { [idKey]: account.userId })
|
||||
return manager.findOne(User, { [idKey]: account.userId })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
|
||||
@@ -227,7 +227,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
async function getSession (sessionToken) {
|
||||
debug('GET_SESSION', sessionToken)
|
||||
try {
|
||||
const session = await manager.findUnique(Session, { sessionToken })
|
||||
const session = await manager.findOne(Session, { sessionToken })
|
||||
|
||||
// Check session has not expired (do not return it if it has)
|
||||
if (session && session.expires && new Date() > new Date(session.expires)) {
|
||||
@@ -327,7 +327,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
// Hash token provided with secret before trying to match it with database
|
||||
// @TODO Use bcrypt instead of salted SHA-256 hash for token
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
const verificationRequest = await manager.findUnique(VerificationRequest, { identifier, token: hashedToken })
|
||||
const verificationRequest = await manager.findOne(VerificationRequest, { identifier, token: hashedToken })
|
||||
|
||||
if (verificationRequest && verificationRequest.expires && new Date() > new Date(verificationRequest.expires)) {
|
||||
// Delete verification entry so it cannot be used again
|
||||
|
||||
@@ -48,7 +48,7 @@ if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', async (event) => {
|
||||
if (event.key === 'nextauth.message') {
|
||||
const message = JSON.parse(event.newValue)
|
||||
if (message.event && message.event === 'session' && message.data) {
|
||||
if (message?.event === 'session' && message.data) {
|
||||
// Ignore storage events fired from the same window that created them
|
||||
if (__NEXTAUTH._clientId === message.clientId) {
|
||||
return
|
||||
@@ -67,9 +67,20 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for window focus/blur events
|
||||
window.addEventListener('focus', async (event) => __NEXTAUTH._getSession({ event: 'focus' }))
|
||||
window.addEventListener('blur', async (event) => __NEXTAUTH._getSession({ event: 'blur' }))
|
||||
// Listen for document visibilitychange events
|
||||
let hidden, visibilityChange
|
||||
if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
|
||||
hidden = 'hidden'
|
||||
visibilityChange = 'visibilitychange'
|
||||
} else if (typeof document.msHidden !== 'undefined') {
|
||||
hidden = 'msHidden'
|
||||
visibilityChange = 'msvisibilitychange'
|
||||
} else if (typeof document.webkitHidden !== 'undefined') {
|
||||
hidden = 'webkitHidden'
|
||||
visibilityChange = 'webkitvisibilitychange'
|
||||
}
|
||||
const handleVisibilityChange = () => !document[hidden] && __NEXTAUTH._getSession({ event: visibilityChange })
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +115,7 @@ const setOptions = ({
|
||||
}
|
||||
|
||||
// Universal method (client + server)
|
||||
const getSession = async ({ req, ctx, triggerEvent = true } = {}) => {
|
||||
export const getSession = async ({ req, ctx, triggerEvent = true } = {}) => {
|
||||
// If passed 'appContext' via getInitialProps() in _app.js then get the req
|
||||
// object from ctx and use that for the req value to allow getSession() to
|
||||
// work seemlessly in getInitialProps() on server side pages *and* in _app.js.
|
||||
@@ -142,7 +153,7 @@ const getProviders = async () => {
|
||||
const SessionContext = createContext()
|
||||
|
||||
// Client side method
|
||||
const useSession = (session) => {
|
||||
export const useSession = (session) => {
|
||||
// Try to use context if we can
|
||||
const value = useContext(SessionContext)
|
||||
|
||||
@@ -223,24 +234,20 @@ const _useSessionHook = (session) => {
|
||||
}
|
||||
|
||||
// Client side method
|
||||
const signIn = async (provider, args = {}, authParams = {}) => {
|
||||
export const signIn = async (provider, args = {}, authorizationParams = {}) => {
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
||||
const callbackUrl = args?.callbackUrl ?? window.location
|
||||
const providers = await getProviders()
|
||||
|
||||
// Redirect to sign in page if no valid provider specified
|
||||
if (!provider || !providers[provider]) {
|
||||
if (!(provider in providers)) {
|
||||
// If Provider not recognized, redirect to sign in page
|
||||
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
} else {
|
||||
let signInUrl = (providers[provider].type === 'credentials')
|
||||
const signInUrl = (providers[provider].type === 'credentials')
|
||||
? `${baseUrl}/callback/${provider}`
|
||||
: `${baseUrl}/signin/${provider}`
|
||||
|
||||
if (authParams) {
|
||||
signInUrl += `?${new URLSearchParams(authParams).toString()}`
|
||||
}
|
||||
|
||||
// If is any other provider type, POST to provider URL with CSRF Token,
|
||||
// callback URL and any other parameters supplied.
|
||||
const fetchOptions = {
|
||||
@@ -255,15 +262,16 @@ const signIn = async (provider, args = {}, authParams = {}) => {
|
||||
json: true
|
||||
})
|
||||
}
|
||||
const res = await fetch(signInUrl, fetchOptions)
|
||||
const _signInUrl = `${signInUrl}?${_encodedForm(authorizationParams)}`
|
||||
const res = await fetch(_signInUrl, fetchOptions)
|
||||
const data = await res.json()
|
||||
window.location = data.url ? data.url : callbackUrl
|
||||
window.location = data.url ?? callbackUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Client side method
|
||||
const signOut = async (args = {}) => {
|
||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
||||
export const signOut = async (args = {}) => {
|
||||
const callbackUrl = args.callbackUrl ?? window.location
|
||||
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const fetchOptions = {
|
||||
@@ -280,11 +288,11 @@ const signOut = async (args = {}) => {
|
||||
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
|
||||
const data = await res.json()
|
||||
_sendMessage({ event: 'session', data: { trigger: 'signout' } })
|
||||
window.location = data.url ? data.url : callbackUrl
|
||||
window.location = data.url ?? callbackUrl
|
||||
}
|
||||
|
||||
// Provider to wrap the app in to make session data available globally
|
||||
const Provider = ({ children, session, options }) => {
|
||||
export const Provider = ({ children, session, options }) => {
|
||||
setOptions(options)
|
||||
return createElement(SessionContext.Provider, { value: useSession(session) }, children)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
:root {
|
||||
--color-background: #fff;
|
||||
--color-primary: #444;
|
||||
--color-control-border: #bbb;
|
||||
--color-button-active-background: #f9f9f9;
|
||||
--color-button-active-border: #aaa;
|
||||
--border-width: 1px;
|
||||
--border-radius: .3rem;
|
||||
--color-error: #c94b4b;
|
||||
--color-info: #157efb;
|
||||
}
|
||||
|
||||
.__next-auth-theme-auto,
|
||||
.__next-auth-theme-light {
|
||||
--color-background: #fff;
|
||||
--color-text: #000;
|
||||
--color-primary: #444;
|
||||
--color-control-border: #bbb;
|
||||
--color-button-active-background: #f9f9f9;
|
||||
--color-button-active-border: #aaa;
|
||||
--color-seperator: #ccc;
|
||||
}
|
||||
|
||||
.__next-auth-theme-dark {
|
||||
--color-background: #000;
|
||||
--color-text: #fff;
|
||||
--color-primary: #ccc;
|
||||
--color-control-border: #555;
|
||||
--color-button-active-background: #060606;
|
||||
--color-button-active-border: #666;
|
||||
|
||||
--color-seperator: #444;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.__next-auth-theme-auto {
|
||||
--color-background: #000;
|
||||
--color-text: #fff;
|
||||
--color-primary: #ccc;
|
||||
--color-control-border: #555;
|
||||
--color-button-active-background: #060606;
|
||||
--color-button-active-border: #666;
|
||||
--color-seperator: #444;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
margin: 0;
|
||||
@@ -22,6 +50,11 @@ h1 {
|
||||
font-weight: 400;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text)
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -46,7 +79,7 @@ input[type] {
|
||||
background: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: inset 0 .1rem .2rem rgba(0,0,0,.2);
|
||||
box-shadow: inset 0 .1rem .2rem rgba(0, 0, 0, .2);
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
@@ -63,6 +96,7 @@ p {
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
line-height: 1rem;
|
||||
|
||||
&:link,
|
||||
&:visited {
|
||||
background-color: var(--color-background);
|
||||
@@ -79,17 +113,17 @@ a.button {
|
||||
background-color: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
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);
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
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);
|
||||
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;
|
||||
@@ -101,20 +135,21 @@ a.site {
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
line-height: 2rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: table;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
> div {
|
||||
>div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
@@ -129,12 +164,14 @@ a.site {
|
||||
padding-right: 2rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.signin {
|
||||
|
||||
button,
|
||||
a.button,
|
||||
input[type="text"] {
|
||||
@@ -165,7 +202,8 @@ a.site {
|
||||
font-weight: 500;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-info);
|
||||
color: #fff;
|
||||
color: var(--color-text);
|
||||
|
||||
p {
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
@@ -174,16 +212,19 @@ a.site {
|
||||
}
|
||||
}
|
||||
|
||||
> div,
|
||||
>div,
|
||||
form {
|
||||
display: block;
|
||||
margin: 0 auto 0.5rem auto;
|
||||
|
||||
input[type] {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const pathToCss = path.join(__dirname, '/index.css')
|
||||
const css = fs.readFileSync(pathToCss, 'utf8')
|
||||
const pathToCss = path.join(process.cwd(), '/dist/css/index.css')
|
||||
|
||||
export default () => css
|
||||
export default function css () {
|
||||
return fs.readFileSync(pathToCss, 'utf8')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
class UnknownError extends Error {
|
||||
export class UnknownError extends Error {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'UnknownError'
|
||||
this.message = message
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
@@ -16,26 +15,25 @@ class UnknownError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class CreateUserError extends UnknownError {
|
||||
export class CreateUserError extends UnknownError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'CreateUserError'
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
class AccountNotLinkedError extends UnknownError {
|
||||
// but the user is trying an OAuth account that is not linked to it.
|
||||
export class AccountNotLinkedError extends UnknownError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'AccountNotLinkedError'
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UnknownError,
|
||||
CreateUserError,
|
||||
AccountNotLinkedError
|
||||
export class OAuthCallbackError extends UnknownError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'OAuthCallbackError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from 'crypto'
|
||||
import jose from 'jose'
|
||||
import hkdf from 'futoin-hkdf'
|
||||
import logger from './logger'
|
||||
|
||||
// Set default algorithm to use for auto-generated signing key
|
||||
@@ -13,7 +13,7 @@ const DEFAULT_ENCRYPTION_ENABLED = false
|
||||
|
||||
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
const encode = async ({
|
||||
async function encode ({
|
||||
token = {},
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
secret,
|
||||
@@ -28,9 +28,9 @@ const encode = async ({
|
||||
zip: 'DEF'
|
||||
},
|
||||
encryption = DEFAULT_ENCRYPTION_ENABLED
|
||||
} = {}) => {
|
||||
} = {}) {
|
||||
// Signing Key
|
||||
const _signingKey = (signingKey)
|
||||
const _signingKey = signingKey
|
||||
? jose.JWK.asKey(JSON.parse(signingKey))
|
||||
: getDerivedSigningKey(secret)
|
||||
|
||||
@@ -39,18 +39,17 @@ const encode = async ({
|
||||
|
||||
if (encryption) {
|
||||
// Encryption Key
|
||||
const _encryptionKey = (encryptionKey)
|
||||
const _encryptionKey = encryptionKey
|
||||
? jose.JWK.asKey(JSON.parse(encryptionKey))
|
||||
: getDerivedEncryptionKey(secret)
|
||||
|
||||
// Encrypt token
|
||||
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
|
||||
} else {
|
||||
return signedToken
|
||||
}
|
||||
return signedToken
|
||||
}
|
||||
|
||||
const decode = async ({
|
||||
async function decode ({
|
||||
secret,
|
||||
token,
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
@@ -66,14 +65,14 @@ const decode = async ({
|
||||
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM]
|
||||
},
|
||||
encryption = DEFAULT_ENCRYPTION_ENABLED
|
||||
} = {}) => {
|
||||
} = {}) {
|
||||
if (!token) return null
|
||||
|
||||
let tokenToVerify = token
|
||||
|
||||
if (encryption) {
|
||||
// Encryption Key
|
||||
const _encryptionKey = (decryptionKey)
|
||||
const _encryptionKey = decryptionKey
|
||||
? jose.JWK.asKey(JSON.parse(decryptionKey))
|
||||
: getDerivedEncryptionKey(secret)
|
||||
|
||||
@@ -83,7 +82,7 @@ const decode = async ({
|
||||
}
|
||||
|
||||
// Signing Key
|
||||
const _signingKey = (verificationKey)
|
||||
const _signingKey = verificationKey
|
||||
? jose.JWK.asKey(JSON.parse(verificationKey))
|
||||
: getDerivedSigningKey(secret)
|
||||
|
||||
@@ -91,7 +90,16 @@ const decode = async ({
|
||||
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
|
||||
}
|
||||
|
||||
const getToken = async (args) => {
|
||||
/**
|
||||
* Server-side method to retrieve the JWT from `req`.
|
||||
* @param {{
|
||||
* req: NextApiRequest
|
||||
* secureCookie?: boolean
|
||||
* cookieName?: string
|
||||
* raw?: boolean
|
||||
* }} params
|
||||
*/
|
||||
async function getToken (params) {
|
||||
const {
|
||||
req,
|
||||
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
|
||||
@@ -99,7 +107,7 @@ const getToken = async (args) => {
|
||||
secureCookie = !(!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://')),
|
||||
cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token',
|
||||
raw = false
|
||||
} = args
|
||||
} = params
|
||||
if (!req) throw new Error('Must pass `req` to JWT getToken()')
|
||||
|
||||
// Try to get token from cookie
|
||||
@@ -108,7 +116,7 @@ const getToken = async (args) => {
|
||||
// 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 && req.headers.authorization.split(' ')[0] === 'Bearer') {
|
||||
if (!token && req.headers.authorization?.split(' ')[0] === 'Bearer') {
|
||||
const urlEncodedToken = req.headers.authorization.split(' ')[1]
|
||||
token = decodeURIComponent(urlEncodedToken)
|
||||
}
|
||||
@@ -118,8 +126,8 @@ const getToken = async (args) => {
|
||||
}
|
||||
|
||||
try {
|
||||
return await decode({ token, ...args })
|
||||
} catch (error) {
|
||||
return decode({ token, ...params })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -128,24 +136,46 @@ const getToken = async (args) => {
|
||||
let DERIVED_SIGNING_KEY_WARNING = false
|
||||
let DERIVED_ENCRYPTION_KEY_WARNING = false
|
||||
|
||||
const getDerivedSigningKey = (secret) => {
|
||||
// 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, 64, { info: 'NextAuth.js Generated Signing Key', hash: 'SHA-256' })
|
||||
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
|
||||
}
|
||||
|
||||
const getDerivedEncryptionKey = (secret) => {
|
||||
function getDerivedEncryptionKey (secret) {
|
||||
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
|
||||
logger.warn('JWT_AUTO_GENERATED_ENCRYPTION_KEY')
|
||||
DERIVED_ENCRYPTION_KEY_WARNING = true
|
||||
}
|
||||
|
||||
const buffer = hkdf(secret, 32, { info: 'NextAuth.js Generated Encryption Key', hash: 'SHA-256' })
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
const logger = {
|
||||
error: (errorCode, ...text) => {
|
||||
if (!console) { return }
|
||||
if (text && text.length <= 1) { text = text[0] || '' }
|
||||
error (code, ...message) {
|
||||
console.error(
|
||||
`[next-auth][error][${errorCode.toLowerCase()}]`,
|
||||
text,
|
||||
`\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}`
|
||||
`[next-auth][error][${code.toLowerCase()}]`,
|
||||
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
|
||||
...message
|
||||
)
|
||||
},
|
||||
warn: (warnCode, ...text) => {
|
||||
if (!console) { return }
|
||||
if (text && text.length <= 1) { text = text[0] || '' }
|
||||
warn (code, ...message) {
|
||||
console.warn(
|
||||
`[next-auth][warn][${warnCode.toLowerCase()}]`,
|
||||
text,
|
||||
`\nhttps://next-auth.js.org/warnings#${warnCode.toLowerCase()}`
|
||||
`[next-auth][warn][${code.toLowerCase()}]`,
|
||||
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`,
|
||||
...message
|
||||
)
|
||||
},
|
||||
debug: (debugCode, ...text) => {
|
||||
if (!console) { return }
|
||||
if (text && text.length <= 1) { text = text[0] || '' }
|
||||
if (process && process.env && process.env._NEXTAUTH_DEBUG) {
|
||||
console.log(
|
||||
`[next-auth][debug][${debugCode.toLowerCase()}]`,
|
||||
text
|
||||
)
|
||||
}
|
||||
debug (code, ...message) {
|
||||
if (!process?.env?._NEXTAUTH_DEBUG) return
|
||||
console.log(
|
||||
`[next-auth][debug][${code.toLowerCase()}]`,
|
||||
...message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// 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.
|
||||
export default (url) => {
|
||||
/**
|
||||
* 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'
|
||||
@@ -10,7 +13,7 @@ export default (url) => {
|
||||
if (!url) { url = `${defaultHost}${defaultPath}` }
|
||||
|
||||
// Default to HTTPS if no protocol explictly specified
|
||||
const protocol = url.match(/^http?:\/\//) ? 'http' : 'https'
|
||||
const protocol = url.startsWith('http:') ? 'http' : 'https'
|
||||
|
||||
// Normalize URLs by stripping protocol and no trailing slash
|
||||
url = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
@@ -20,8 +23,5 @@ export default (url) => {
|
||||
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
|
||||
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
basePath
|
||||
}
|
||||
return { baseUrl, basePath }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'apple',
|
||||
@@ -12,7 +10,6 @@ export default (options) => {
|
||||
authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post',
|
||||
profileUrl: null,
|
||||
idToken: true,
|
||||
state: false, // Apple doesn't support state verfication
|
||||
profile: (profile) => {
|
||||
// The name of the user will only return on first login
|
||||
return {
|
||||
@@ -23,30 +20,11 @@ export default (options) => {
|
||||
},
|
||||
clientId: null,
|
||||
clientSecret: {
|
||||
appleId: null,
|
||||
teamId: null,
|
||||
privateKey: null,
|
||||
keyId: null
|
||||
},
|
||||
clientSecretCallback: async ({ appleId, keyId, teamId, privateKey }) => {
|
||||
const response = jwt.sign(
|
||||
{
|
||||
iss: teamId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: appleId
|
||||
},
|
||||
// Automatically convert \\n into \n if found in private key. If the key
|
||||
// is passed in an environment variable \n can get escaped as \\n
|
||||
privateKey.replace(/\\n/g, '\n'),
|
||||
{
|
||||
algorithm: 'ES256',
|
||||
keyid: keyId
|
||||
}
|
||||
)
|
||||
return Promise.resolve(response)
|
||||
},
|
||||
protection: 'none', // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,6 @@ export default (options) => {
|
||||
requestTokenUrl: 'https://www.bungie.net/platform/app/oauth/token/',
|
||||
authorizationUrl: 'https://www.bungie.net/en/OAuth/Authorize?response_type=code',
|
||||
profileUrl: 'https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/',
|
||||
prepareProfileRequest: ({ provider, url, headers, results }) => {
|
||||
if (!results.membership_id) {
|
||||
// internal error
|
||||
// @TODO: handle better
|
||||
throw new Error('Expected membership_id to be passed.')
|
||||
}
|
||||
|
||||
if (!provider.apiKey) {
|
||||
throw new Error('The Bungie provider requires the apiKey option to be present.')
|
||||
}
|
||||
|
||||
headers['X-API-Key'] = provider.apiKey
|
||||
url = url.replace('{membershipId}', results.membership_id)
|
||||
|
||||
return url
|
||||
},
|
||||
profile: (profile) => {
|
||||
const { bungieNetUser: user } = profile.Response
|
||||
|
||||
@@ -36,7 +20,9 @@ export default (options) => {
|
||||
email: null
|
||||
}
|
||||
},
|
||||
apiKey: null,
|
||||
headers: {
|
||||
'X-API-Key': null
|
||||
},
|
||||
clientId: null,
|
||||
clientSecret: null,
|
||||
...options
|
||||
|
||||
@@ -12,7 +12,6 @@ export default (options) => {
|
||||
profile: (profile) => {
|
||||
return { ...profile, id: profile.sub }
|
||||
},
|
||||
setGetAccessTokenAuthHeader: false,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import Basecamp from './basecamp'
|
||||
import BattleNet from './battlenet'
|
||||
import Box from './box'
|
||||
import Bungie from './bungie'
|
||||
import Credentials from './credentials'
|
||||
import Cognito from './cognito'
|
||||
import Credentials from './credentials'
|
||||
import Discord from './discord'
|
||||
import Email from './email'
|
||||
import Facebook from './facebook'
|
||||
@@ -17,28 +17,33 @@ import GitHub from './github'
|
||||
import GitLab from './gitlab'
|
||||
import Google from './google'
|
||||
import IdentityServer4 from './identity-server4'
|
||||
import LINE from './line'
|
||||
import LinkedIn from './linkedin'
|
||||
import Mixer from './mixer'
|
||||
import MailRu from './mailru'
|
||||
import Medium from './medium'
|
||||
import Netlify from './netlify'
|
||||
import Okta from './okta'
|
||||
import Reddit from './reddit'
|
||||
import Salesforce from './salesforce'
|
||||
import Slack from './slack'
|
||||
import Spotify from './spotify'
|
||||
import Strava from './strava'
|
||||
import Twitch from './twitch'
|
||||
import Twitter from './twitter'
|
||||
import VK from './vk'
|
||||
import Yandex from './yandex'
|
||||
|
||||
export default {
|
||||
Apple,
|
||||
Atlassian,
|
||||
Auth0,
|
||||
Apple,
|
||||
AzureADB2C,
|
||||
Basecamp,
|
||||
BattleNet,
|
||||
Box,
|
||||
Bungie,
|
||||
Credentials,
|
||||
Cognito,
|
||||
Credentials,
|
||||
Discord,
|
||||
Email,
|
||||
Facebook,
|
||||
@@ -48,14 +53,19 @@ export default {
|
||||
GitLab,
|
||||
Google,
|
||||
IdentityServer4,
|
||||
LINE,
|
||||
LinkedIn,
|
||||
Mixer,
|
||||
MailRu,
|
||||
Medium,
|
||||
Netlify,
|
||||
Okta,
|
||||
Reddit,
|
||||
Salesforce,
|
||||
Slack,
|
||||
Spotify,
|
||||
Strava,
|
||||
Twitter,
|
||||
Twitch,
|
||||
Twitter,
|
||||
VK,
|
||||
Yandex
|
||||
}
|
||||
|
||||
22
src/providers/line.js
Normal file
22
src/providers/line.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'line',
|
||||
name: 'LINE',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'profile openid',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://api.line.me/oauth2/v2.1/token',
|
||||
authorizationUrl: 'https://access.line.me/oauth2/v2.1/authorize?response_type=code',
|
||||
profileUrl: 'https://api.line.me/v2/profile',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.userId,
|
||||
name: profile.displayName,
|
||||
email: null,
|
||||
image: profile.pictureUrl
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
25
src/providers/mailru.js
Normal file
25
src/providers/mailru.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'mailru',
|
||||
name: 'Mail.ru',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'userinfo',
|
||||
params: {
|
||||
grant_type: 'authorization_code'
|
||||
},
|
||||
accessTokenUrl: 'https://oauth.mail.ru/token',
|
||||
requestTokenUrl: 'https://oauth.mail.ru/token',
|
||||
authorizationUrl: 'https://oauth.mail.ru/login?response_type=code',
|
||||
profileUrl: 'https://oauth.mail.ru/userinfo',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.image
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
22
src/providers/medium.js
Normal file
22
src/providers/medium.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'basicProfile',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://api.medium.com/v1/tokens',
|
||||
authorizationUrl: 'https://medium.com/m/oauth/authorize?response_type=code',
|
||||
profileUrl: 'https://api.medium.com/v1/me',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.data.id,
|
||||
name: profile.data.name,
|
||||
email: null,
|
||||
image: profile.data.imageUrl
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'mixer',
|
||||
name: 'Mixer',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'user:details:self',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://mixer.com/api/v1/oauth/token',
|
||||
authorizationUrl: 'https://mixer.com/oauth/authorize?response_type=code',
|
||||
profileUrl: 'https://mixer.com/api/v1/users/current',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.username,
|
||||
image: profile.avatarUrl,
|
||||
email: profile.email
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,6 @@ export default (options) => {
|
||||
profile: (profile) => {
|
||||
return { ...profile, id: profile.sub }
|
||||
},
|
||||
setGetAccessTokenAuthHeader: false,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Logging in works but trying to retrieve the profile results in 401 unauthorized
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'reddit',
|
||||
@@ -12,12 +11,12 @@ export default (options) => {
|
||||
'https://www.reddit.com/api/v1/authorize?response_type=code',
|
||||
profileUrl: 'https://oauth.reddit.com/api/v1/me',
|
||||
profile: (profile) => {
|
||||
// return {
|
||||
// id: profile.id,
|
||||
// name: profile.name,
|
||||
// image: null,
|
||||
// email: null,
|
||||
// };
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
image: null,
|
||||
email: null
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
|
||||
21
src/providers/salesforce.js
Normal file
21
src/providers/salesforce.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'salesforce',
|
||||
name: 'Salesforce',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
params: { display: 'page', grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://login.salesforce.com/services/oauth2/token',
|
||||
authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize?response_type=code',
|
||||
profileUrl: 'https://login.salesforce.com/services/oauth2/userinfo',
|
||||
protection: 'none', // REVIEW: Can we use "pkce" ?
|
||||
profile: (profile) => {
|
||||
return {
|
||||
...profile,
|
||||
id: profile.user_id,
|
||||
image: profile.picture
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,8 @@ export default (options) => {
|
||||
scope: [],
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://slack.com/api/oauth.v2.access',
|
||||
accessTokenGetter: (json) => json.authed_user.access_token,
|
||||
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
||||
additionalAuthorizeParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
|
||||
authorizationParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
|
||||
profileUrl: 'https://slack.com/api/users.identity',
|
||||
profile: (profile) => {
|
||||
const { user } = profile
|
||||
|
||||
30
src/providers/vk.js
Normal file
30
src/providers/vk.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export default (options) => {
|
||||
const apiVersion = '5.126' // https://vk.com/dev/versions
|
||||
|
||||
return {
|
||||
id: 'vk',
|
||||
name: 'vk.com',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'email',
|
||||
params: {
|
||||
grant_type: 'authorization_code'
|
||||
},
|
||||
accessTokenUrl: `https://oauth.vk.com/access_token?v=${apiVersion}`,
|
||||
requestTokenUrl: `https://oauth.vk.com/access_token?v=${apiVersion}`,
|
||||
authorizationUrl:
|
||||
`https://oauth.vk.com/authorize?response_type=code&v=${apiVersion}`,
|
||||
profileUrl: `https://api.vk.com/method/users.get?fields=photo_100&v=${apiVersion}`,
|
||||
profile: (result) => {
|
||||
const profile = result.response?.[0] ?? {}
|
||||
|
||||
return {
|
||||
id: profile.id,
|
||||
name: [profile.first_name, profile.last_name].filter(Boolean).join(' '),
|
||||
email: profile.email,
|
||||
image: profile.photo_100
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import adapters from '../adapters'
|
||||
import jwt from '../lib/jwt'
|
||||
import parseUrl from '../lib/parse-url'
|
||||
import cookie from './lib/cookie'
|
||||
import callbackUrlHandler from './lib/callback-url-handler'
|
||||
import parseProviders from './lib/providers'
|
||||
import events from './lib/events'
|
||||
import callbacks from './lib/callbacks'
|
||||
import providers from './routes/providers'
|
||||
import signin from './routes/signin'
|
||||
import signout from './routes/signout'
|
||||
import callback from './routes/callback'
|
||||
import session from './routes/session'
|
||||
import pages from './pages'
|
||||
import adapters from '../adapters'
|
||||
import logger from '../lib/logger'
|
||||
import * as cookie from './lib/cookie'
|
||||
import * as defaultEvents from './lib/default-events'
|
||||
import * as defaultCallbacks from './lib/default-callbacks'
|
||||
import parseProviders from './lib/providers'
|
||||
import callbackUrlHandler from './lib/callback-url-handler'
|
||||
import extendRes from './lib/extend-req'
|
||||
import * as routes from './routes'
|
||||
import renderPage from './pages'
|
||||
import csrfTokenHandler from './lib/csrf-token-handler'
|
||||
import createSecret from './lib/create-secret'
|
||||
import * as pkce from './lib/oauth/pkce-handler'
|
||||
import * as state from './lib/oauth/state-handler'
|
||||
|
||||
// To work properly in production with OAuth providers the NEXTAUTH_URL
|
||||
// environment variable must be set.
|
||||
@@ -21,196 +21,72 @@ if (!process.env.NEXTAUTH_URL) {
|
||||
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
|
||||
}
|
||||
|
||||
async function NextAuth (req, res, userSuppliedOptions) {
|
||||
async function NextAuthHandler (req, res, userOptions) {
|
||||
// If debug enabled, set ENV VAR so that logger logs debug messages
|
||||
if (userOptions.debug) {
|
||||
process.env._NEXTAUTH_DEBUG = true
|
||||
}
|
||||
|
||||
// To the best of my knowledge, we need to return a promise here
|
||||
// to avoid early termination of calls to the serverless function
|
||||
// (and then return that promise when we are done) - eslint
|
||||
// complains but I'm not sure there is another way to do this.
|
||||
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
|
||||
// This is passed to all methods that handle responses, and must be called
|
||||
// when they are complete so that the serverless function knows when it is
|
||||
// safe to return and that no more data will be sent.
|
||||
const done = resolve
|
||||
extendRes(req, res, resolve)
|
||||
|
||||
if (!req.query.nextauth) {
|
||||
const error = 'Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly.'
|
||||
|
||||
logger.error('MISSING_NEXTAUTH_API_ROUTE_ERROR', error)
|
||||
res
|
||||
.status(500)
|
||||
.end(
|
||||
`Error: ${error}`
|
||||
)
|
||||
return done()
|
||||
return res.status(500).end(`Error: ${error}`)
|
||||
}
|
||||
|
||||
const { url, query, body } = req
|
||||
const {
|
||||
nextauth,
|
||||
action = nextauth[0],
|
||||
provider = nextauth[1],
|
||||
providerId = nextauth[1],
|
||||
error = nextauth[1]
|
||||
} = query
|
||||
} = req.query
|
||||
|
||||
const {
|
||||
csrfToken: csrfTokenFromPost
|
||||
} = body
|
||||
// @todo refactor all existing references to baseUrl and basePath
|
||||
const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
|
||||
|
||||
// @todo refactor all existing references to site, baseUrl and basePath
|
||||
const parsedUrl = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
|
||||
const baseUrl = parsedUrl.baseUrl
|
||||
const basePath = parsedUrl.basePath
|
||||
const cookies = {
|
||||
...cookie.defaultCookies(userOptions.useSecureCookies || baseUrl.startsWith('https://')),
|
||||
// Allow user cookie options to override any cookie settings above
|
||||
...userOptions.cookies
|
||||
}
|
||||
|
||||
const secret = createSecret({ userOptions, basePath, baseUrl })
|
||||
|
||||
const { csrfToken, csrfTokenVerified } = csrfTokenHandler(req, res, cookies, secret)
|
||||
|
||||
const providers = parseProviders({ providers: userOptions.providers, baseUrl, basePath })
|
||||
const provider = providers.find(({ id }) => id === providerId)
|
||||
|
||||
if (provider &&
|
||||
provider.type === 'oauth' && provider.version?.startsWith('2') &&
|
||||
(!provider.protection && provider.state !== false)
|
||||
) {
|
||||
provider.protection = 'state' // Default to state, as we did in 3.1 REVIEW: should we use "pkce" or "none" as default?
|
||||
}
|
||||
|
||||
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle
|
||||
|
||||
// Parse database / adapter
|
||||
let adapter
|
||||
if (userSuppliedOptions.adapter) {
|
||||
// If adapter is provided, use it (advanced usage, overrides database)
|
||||
adapter = userSuppliedOptions.adapter
|
||||
} else if (userSuppliedOptions.database) {
|
||||
// If database URI or config object is provided, use it (simple usage)
|
||||
adapter = adapters.Default(userSuppliedOptions.database)
|
||||
}
|
||||
|
||||
// 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.
|
||||
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify({ baseUrl, basePath, ...userSuppliedOptions })).digest('hex')
|
||||
|
||||
// Use secure cookies if the site uses HTTPS
|
||||
// This being conditional allows cookies to work non-HTTPS development URLs
|
||||
// Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
|
||||
// prefix, but enable them by default if the site URL is HTTPS; but not for
|
||||
// non-HTTPS URLs like http://localhost which are used in development).
|
||||
// For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
|
||||
const useSecureCookies = userSuppliedOptions.useSecureCookies || baseUrl.startsWith('https://')
|
||||
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
|
||||
|
||||
// @TODO Review cookie settings (names, options)
|
||||
const cookies = {
|
||||
// default cookie options
|
||||
sessionToken: {
|
||||
name: `${cookiePrefix}next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `${cookiePrefix}next-auth.callback-url`,
|
||||
options: {
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
csrfToken: {
|
||||
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
|
||||
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
|
||||
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
// Allow user cookie options to override any cookie settings above
|
||||
...userSuppliedOptions.cookies
|
||||
}
|
||||
|
||||
// Session options
|
||||
const sessionOptions = {
|
||||
jwt: false,
|
||||
maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle
|
||||
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
|
||||
...userSuppliedOptions.session
|
||||
}
|
||||
|
||||
// JWT options
|
||||
const jwtOptions = {
|
||||
secret, // Use application secret if no keys specified
|
||||
maxAge: sessionOptions.maxAge, // maxAge is dereived from session maxAge,
|
||||
encode: jwt.encode,
|
||||
decode: jwt.decode,
|
||||
...userSuppliedOptions.jwt
|
||||
}
|
||||
|
||||
// If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||
if (!adapter) {
|
||||
sessionOptions.jwt = true
|
||||
}
|
||||
|
||||
// Event messages
|
||||
const eventsOptions = {
|
||||
...events,
|
||||
...userSuppliedOptions.events
|
||||
}
|
||||
|
||||
// Callback functions
|
||||
const callbacksOptions = {
|
||||
...callbacks,
|
||||
...userSuppliedOptions.callbacks
|
||||
}
|
||||
|
||||
// Ensure CSRF Token cookie is set for any subsequent requests.
|
||||
// Used as part of the strateigy 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
|
||||
let csrfToken
|
||||
let csrfTokenVerified = false
|
||||
if (req.cookies[cookies.csrfToken.name]) {
|
||||
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
|
||||
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
|
||||
// If hash matches then we trust the CSRF token value
|
||||
csrfToken = csrfTokenValue
|
||||
|
||||
// If this is a POST request and the CSRF Token in the Post request matches
|
||||
// the cookie we have already verified is one we have set, then token is verified!
|
||||
if (req.method === 'POST' && csrfToken === csrfTokenFromPost) { csrfTokenVerified = true }
|
||||
}
|
||||
}
|
||||
if (!csrfToken) {
|
||||
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
|
||||
// (e.g. because it's been modifed or because the secret has changed) create a new token.
|
||||
csrfToken = randomBytes(32).toString('hex')
|
||||
const newCsrfTokenCookie = `${csrfToken}|${createHash('sha256').update(`${csrfToken}${secret}`).digest('hex')}`
|
||||
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
|
||||
}
|
||||
|
||||
// Helper method for handling redirects, this is passed to all routes
|
||||
// @TODO Refactor into a lib instead of passing as an option
|
||||
// e.g. and call as redirect(req, res, url)
|
||||
const redirect = (redirectUrl) => {
|
||||
const reponseAsJson = !!((req.body && req.body.json === 'true'))
|
||||
if (reponseAsJson) {
|
||||
res.json({ url: redirectUrl })
|
||||
} else {
|
||||
res.status(302).setHeader('Location', redirectUrl)
|
||||
res.end()
|
||||
}
|
||||
return done()
|
||||
}
|
||||
// If adapter is provided, use it (advanced usage, overrides database)
|
||||
// If database URI or config object is provided, use it (simple usage)
|
||||
const adapter = userOptions.adapter ?? (userOptions.database && adapters.Default(userOptions.database))
|
||||
|
||||
// User provided options are overriden by other options,
|
||||
// except for the options with special handling above
|
||||
const options = {
|
||||
// Defaults options can be overidden
|
||||
debug: false, // Enable debug messages to be displayed
|
||||
pages: {}, // Custom pages (e.g. sign in, sign out, errors)
|
||||
req.options = {
|
||||
debug: false,
|
||||
pages: {},
|
||||
theme: 'auto',
|
||||
// Custom options override defaults
|
||||
...userSuppliedOptions,
|
||||
// These computed settings can values in userSuppliedOptions but override them
|
||||
...userOptions,
|
||||
// These computed settings can have values in userOptions but we override them
|
||||
// and are request-specific.
|
||||
adapter,
|
||||
baseUrl,
|
||||
@@ -220,116 +96,137 @@ async function NextAuth (req, res, userSuppliedOptions) {
|
||||
cookies,
|
||||
secret,
|
||||
csrfToken,
|
||||
providers: parseProviders(userSuppliedOptions.providers, baseUrl, basePath),
|
||||
session: sessionOptions,
|
||||
jwt: jwtOptions,
|
||||
events: eventsOptions,
|
||||
callbacks: callbacksOptions,
|
||||
callbackUrl: baseUrl,
|
||||
redirect
|
||||
providers,
|
||||
// Session options
|
||||
session: {
|
||||
jwt: !adapter, // If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||
maxAge,
|
||||
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
|
||||
...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: {
|
||||
...defaultEvents,
|
||||
...userOptions.events
|
||||
},
|
||||
// Callback functions
|
||||
callbacks: {
|
||||
...defaultCallbacks,
|
||||
...userOptions.callbacks
|
||||
},
|
||||
pkce: {}
|
||||
}
|
||||
|
||||
// If debug enabled, set ENV VAR so that logger logs debug messages
|
||||
if (options.debug === true) { process.env._NEXTAUTH_DEBUG = true }
|
||||
await callbackUrlHandler(req, res)
|
||||
|
||||
// Get / Set callback URL based on query param / cookie + validation
|
||||
options.callbackUrl = await callbackUrlHandler(req, res, options)
|
||||
const render = renderPage(req, res)
|
||||
const { pages } = req.options
|
||||
|
||||
if (req.method === 'GET') {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
providers(req, res, options, done)
|
||||
break
|
||||
return routes.providers(req, res)
|
||||
case 'session':
|
||||
session(req, res, options, done)
|
||||
break
|
||||
return routes.session(req, res)
|
||||
case 'csrf':
|
||||
res.json({ csrfToken })
|
||||
return done()
|
||||
return res.json({ csrfToken })
|
||||
case 'signin':
|
||||
if (options.pages.signIn) {
|
||||
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`
|
||||
if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` }
|
||||
return redirect(redirectUrl)
|
||||
if (pages.signIn) {
|
||||
let signinUrl = `${pages.signIn}${pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${req.options.callbackUrl}`
|
||||
if (error) { signinUrl = `${signinUrl}&error=${error}` }
|
||||
return res.redirect(signinUrl)
|
||||
}
|
||||
|
||||
pages.render(req, res, 'signin', { baseUrl, basePath, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done)
|
||||
break
|
||||
return render.signin()
|
||||
case 'signout':
|
||||
if (options.pages.signOut) { return redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`) }
|
||||
|
||||
pages.render(req, res, 'signout', { baseUrl, basePath, csrfToken, callbackUrl: options.callbackUrl }, done)
|
||||
break
|
||||
if (pages.signOut) {
|
||||
return res.redirect(`${pages.signOut}${pages.signOut.includes('?') ? '&' : '?'}error=${error}`)
|
||||
}
|
||||
return render.signout()
|
||||
case 'callback':
|
||||
if (provider && options.providers[provider]) {
|
||||
callback(req, res, options, done)
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
|
||||
return done()
|
||||
if (provider) {
|
||||
if (await pkce.handleCallback(req, res)) return
|
||||
if (await state.handleCallback(req, res)) return
|
||||
return routes.callback(req, res)
|
||||
}
|
||||
break
|
||||
case 'verify-request':
|
||||
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
|
||||
|
||||
pages.render(req, res, 'verify-request', { baseUrl }, done)
|
||||
break
|
||||
if (pages.verifyRequest) {
|
||||
return res.redirect(pages.verifyRequest)
|
||||
}
|
||||
return render.verifyRequest()
|
||||
case 'error':
|
||||
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
|
||||
if (pages.error) {
|
||||
return res.redirect(`${pages.error}${pages.error.includes('?') ? '&' : '?'}error=${error}`)
|
||||
}
|
||||
|
||||
pages.render(req, res, 'error', { baseUrl, basePath, error }, done)
|
||||
break
|
||||
// These error messages are displayed in line on the sign in page
|
||||
if ([
|
||||
'Signin',
|
||||
'OAuthSignin',
|
||||
'OAuthCallback',
|
||||
'OAuthCreateAccount',
|
||||
'EmailCreateAccount',
|
||||
'Callback',
|
||||
'OAuthAccountNotLinked',
|
||||
'EmailSignin',
|
||||
'CredentialsSignin'
|
||||
].includes(error)) {
|
||||
return res.redirect(`${baseUrl}${basePath}/signin?error=${error}`)
|
||||
}
|
||||
|
||||
return render.error({ error })
|
||||
default:
|
||||
res.status(404).end()
|
||||
return done()
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
switch (action) {
|
||||
case 'signin':
|
||||
// Verified CSRF Token required for all sign in routes
|
||||
if (!csrfTokenVerified) {
|
||||
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||
if (csrfTokenVerified && provider) {
|
||||
if (await pkce.handleSignin(req, res)) return
|
||||
if (await state.handleSignin(req, res)) return
|
||||
return routes.signin(req, res)
|
||||
}
|
||||
|
||||
if (provider && options.providers[provider]) {
|
||||
signin(req, res, options, done)
|
||||
}
|
||||
break
|
||||
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||
case 'signout':
|
||||
// Verified CSRF Token required for signout
|
||||
if (!csrfTokenVerified) {
|
||||
return redirect(`${baseUrl}${basePath}/signout?csrf=true`)
|
||||
if (csrfTokenVerified) {
|
||||
return routes.signout(req, res)
|
||||
}
|
||||
|
||||
signout(req, res, options, done)
|
||||
break
|
||||
return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`)
|
||||
case 'callback':
|
||||
if (provider && options.providers[provider]) {
|
||||
if (provider) {
|
||||
// Verified CSRF Token required for credentials providers only
|
||||
if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) {
|
||||
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||
if (provider.type === 'credentials' && !csrfTokenVerified) {
|
||||
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||
}
|
||||
|
||||
callback(req, res, options, done)
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
||||
return done()
|
||||
if (await pkce.handleCallback(req, res)) return
|
||||
if (await state.handleCallback(req, res)) return
|
||||
return routes.callback(req, res)
|
||||
}
|
||||
break
|
||||
default:
|
||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
||||
return done()
|
||||
}
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
|
||||
return done()
|
||||
}
|
||||
return res.status(400).end(`Error: HTTP ${req.method} is not supported for ${req.url}`)
|
||||
})
|
||||
}
|
||||
|
||||
export default async (...args) => {
|
||||
/** Tha main entry point to next-auth */
|
||||
export default function NextAuth (...args) {
|
||||
if (args.length === 1) {
|
||||
return (req, res) => NextAuth(req, res, args[0])
|
||||
return (req, res) => NextAuthHandler(req, res, args[0])
|
||||
}
|
||||
|
||||
return NextAuth(...args)
|
||||
return NextAuthHandler(...args)
|
||||
}
|
||||
|
||||
@@ -1,223 +1,221 @@
|
||||
// This function handles the complex flow of signing users in, and either creating,
|
||||
// linking (or not linking) accounts depending on if the user is currently logged
|
||||
// in, if they have account already and the authentication mechanism they are using.
|
||||
//
|
||||
// It prevents insecure behaviour, such as linking oAuth accounts unless a user is
|
||||
// signed in and authenticated with an existing valid account.
|
||||
//
|
||||
// All verification (e.g. oAuth flows or email address verificaiton flows) are
|
||||
// done prior to this handler being called to avoid additonal complexity in this
|
||||
// handler.
|
||||
import { AccountNotLinkedError } from '../../lib/errors'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (sessionToken, profile, providerAccount, options) => {
|
||||
try {
|
||||
// Input validation
|
||||
if (!profile) { throw new Error('Missing profile') }
|
||||
if (!providerAccount || !providerAccount.id || !providerAccount.type) { throw new Error('Missing or invalid provider account') }
|
||||
/**
|
||||
* This function handles the complex flow of signing users in, and either creating,
|
||||
* linking (or not linking) accounts depending on if the user is currently logged
|
||||
* in, if they have account already and the authentication mechanism they are using.
|
||||
*
|
||||
* It prevents insecure behaviour, such as linking OAuth accounts unless a user is
|
||||
* signed in and authenticated with an existing valid account.
|
||||
*
|
||||
* All verification (e.g. OAuth flows or email address verificaiton flows) are
|
||||
* done prior to this handler being called to avoid additonal complexity in this
|
||||
* handler.
|
||||
*/
|
||||
export default async function callbackHandler (sessionToken, profile, providerAccount, options) {
|
||||
// Input validation
|
||||
if (!profile) throw new Error('Missing profile')
|
||||
if (!providerAccount?.id || !providerAccount.type) throw new Error('Missing or invalid provider account')
|
||||
if (!['email', 'oauth'].includes(providerAccount.type)) throw new Error('Provider not supported')
|
||||
|
||||
const { adapter, jwt, events } = options
|
||||
|
||||
const useJwtSession = options.session.jwt
|
||||
|
||||
// If no adapter is configured then we don't have a database and cannot
|
||||
// persist data; in this mode we just return a dummy session object.
|
||||
if (!adapter) {
|
||||
return {
|
||||
user: profile,
|
||||
account: providerAccount,
|
||||
session: {}
|
||||
}
|
||||
const {
|
||||
adapter,
|
||||
jwt,
|
||||
events,
|
||||
session: {
|
||||
jwt: useJwtSession
|
||||
}
|
||||
} = options
|
||||
|
||||
const {
|
||||
createUser,
|
||||
updateUser,
|
||||
getUser,
|
||||
getUserByProviderAccountId,
|
||||
getUserByEmail,
|
||||
linkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession
|
||||
} = await adapter.getAdapter(options)
|
||||
// If no adapter is configured then we don't have a database and cannot
|
||||
// persist data; in this mode we just return a dummy session object.
|
||||
if (!adapter) {
|
||||
return {
|
||||
user: profile,
|
||||
account: providerAccount,
|
||||
session: {}
|
||||
}
|
||||
}
|
||||
|
||||
let session = null
|
||||
let user = null
|
||||
let isSignedIn = null
|
||||
let isNewUser = false
|
||||
const {
|
||||
createUser,
|
||||
updateUser,
|
||||
getUser,
|
||||
getUserByProviderAccountId,
|
||||
getUserByEmail,
|
||||
linkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession
|
||||
} = await adapter.getAdapter(options)
|
||||
|
||||
if (sessionToken) {
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
session = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
if (session && session.sub) {
|
||||
user = await getUser(session.sub)
|
||||
isSignedIn = !!user
|
||||
}
|
||||
} catch (e) {
|
||||
// If session can't be verified, treat as no session
|
||||
}
|
||||
} else {
|
||||
session = await getSession(sessionToken)
|
||||
if (session && session.userId) {
|
||||
user = await getUser(session.userId)
|
||||
let session = null
|
||||
let user = null
|
||||
let isSignedIn = null
|
||||
let isNewUser = false
|
||||
|
||||
if (sessionToken) {
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
session = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
if (session?.sub) {
|
||||
user = await getUser(session.sub)
|
||||
isSignedIn = !!user
|
||||
}
|
||||
} catch {
|
||||
// If session can't be verified, treat as no session
|
||||
}
|
||||
}
|
||||
session = await getSession(sessionToken)
|
||||
if (session?.userId) {
|
||||
user = await getUser(session.userId)
|
||||
isSignedIn = !!user
|
||||
}
|
||||
}
|
||||
|
||||
if (providerAccount.type === 'email') {
|
||||
// If signing in with an email, check if an account with the same email address exists already
|
||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||
if (userByEmail) {
|
||||
// If they are not already signed in as the same user, this flow will
|
||||
// sign them out of the current session and sign them in as the new user
|
||||
if (isSignedIn) {
|
||||
if (user.id !== userByEmail.id && !useJwtSession) {
|
||||
// Delete existing session if they are currently signed in as another user.
|
||||
// This will switch user accounts for the session in cases where the user was
|
||||
// already logged in with a different account.
|
||||
await deleteSession(sessionToken)
|
||||
}
|
||||
if (providerAccount.type === 'email') {
|
||||
// If signing in with an email, check if an account with the same email address exists already
|
||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||
if (userByEmail) {
|
||||
// If they are not already signed in as the same user, this flow will
|
||||
// sign them out of the current session and sign them in as the new user
|
||||
if (isSignedIn) {
|
||||
if (user.id !== userByEmail.id && !useJwtSession) {
|
||||
// Delete existing session if they are currently signed in as another user.
|
||||
// This will switch user accounts for the session in cases where the user was
|
||||
// already logged in with a different account.
|
||||
await deleteSession(sessionToken)
|
||||
}
|
||||
|
||||
// Update emailVerified property on the user object
|
||||
const currentDate = new Date()
|
||||
user = await updateUser({ ...userByEmail, emailVerified: currentDate })
|
||||
await dispatchEvent(events.updateUser, user)
|
||||
} else {
|
||||
// Create user account if there isn't one for the email address already
|
||||
const currentDate = new Date()
|
||||
user = await createUser({ ...profile, emailVerified: currentDate })
|
||||
await dispatchEvent(events.createUser, user)
|
||||
isNewUser = true
|
||||
}
|
||||
|
||||
// Create new session
|
||||
session = useJwtSession ? {} : await createSession(user)
|
||||
// Update emailVerified property on the user object
|
||||
const currentDate = new Date()
|
||||
user = await updateUser({ ...userByEmail, emailVerified: currentDate })
|
||||
await dispatchEvent(events.updateUser, user)
|
||||
} else {
|
||||
// Create user account if there isn't one for the email address already
|
||||
const currentDate = new Date()
|
||||
user = await createUser({ ...profile, emailVerified: currentDate })
|
||||
await dispatchEvent(events.createUser, user)
|
||||
isNewUser = true
|
||||
}
|
||||
|
||||
// Create new session
|
||||
session = useJwtSession ? {} : await createSession(user)
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
} else if (providerAccount.type === 'oauth') {
|
||||
// If signing in with oauth account, check to see if the account exists already
|
||||
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
|
||||
if (userByProviderAccountId) {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in with this account, we don't need to do anything
|
||||
// Note: These are cast as strings here to ensure they match as in
|
||||
// some flows (e.g. JWT with a database) one of the values might be a
|
||||
// string and the other might be an ObjectID and would otherwise fail.
|
||||
if (`${userByProviderAccountId.id}` === `${user.id}`) {
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
// If the user is currently signed in, but the new account they are signing in
|
||||
// with is already associated with another account, then we cannot link them
|
||||
// and need to return an error.
|
||||
throw new AccountNotLinkedError()
|
||||
}
|
||||
// If there is no active session, but the account being signed in with is already
|
||||
// associated with a valid user then create session to sign the user in.
|
||||
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
|
||||
return {
|
||||
session,
|
||||
user: userByProviderAccountId,
|
||||
isNewUser
|
||||
}
|
||||
} else {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in and the OAuth account isn't already associated
|
||||
// with another user account then we can go ahead and link the accounts safely.
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerAccount.provider,
|
||||
providerAccount.type,
|
||||
providerAccount.id,
|
||||
providerAccount.refreshToken,
|
||||
providerAccount.accessToken,
|
||||
providerAccount.accessTokenExpires
|
||||
)
|
||||
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
|
||||
|
||||
// As they are already signed in, we don't need to do anything after linking them
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is not signed in and it looks like a new OAuth account then we
|
||||
// check there also isn't an user account already associated with the same
|
||||
// email address as the one in the OAuth profile.
|
||||
//
|
||||
// This step is often overlooked in OAuth implementations, but covers the following cases:
|
||||
//
|
||||
// 1. It makes it harder for someone to accidentally create two accounts.
|
||||
// e.g. by signin in with email, then again with an oauth account connected to the same email.
|
||||
// 2. It makes it harder to hijack a user account using a 3rd party OAuth account.
|
||||
// e.g. by creating an oauth account then changing the email address associated with it.
|
||||
//
|
||||
// It's quite common for services to automatically link accounts in this case, but it's
|
||||
// better practice to require the user to sign in *then* link accounts to be sure
|
||||
// someone is not exploiting a problem with a third party OAuth service.
|
||||
//
|
||||
// OAuth providers should require email address verification to prevent this, but in
|
||||
// practice that is not always the case; this helps protect against that.
|
||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||
if (userByEmail) {
|
||||
// We end up here when we don't have an account with the same [provider].id *BUT*
|
||||
// we do already have an account with the same email address as the one in the
|
||||
// OAuth profile the user has just tried to sign in with.
|
||||
//
|
||||
// We don't want to have two accounts with the same email address, and we don't
|
||||
// want to link them in case it's not safe to do so, so instead we prompt the user
|
||||
// to sign in via email to verify their identity and then link the accounts.
|
||||
throw new AccountNotLinkedError()
|
||||
}
|
||||
// If the current user is not logged in and the profile isn't linked to any user
|
||||
// accounts (by email or provider account id)...
|
||||
//
|
||||
// If no account matching the same [provider].id or .email exists, we can
|
||||
// create a new account for the user, link it to the OAuth acccount and
|
||||
// create a new session for them so they are signed in with it.
|
||||
user = await createUser(profile)
|
||||
await dispatchEvent(events.createUser, user)
|
||||
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerAccount.provider,
|
||||
providerAccount.type,
|
||||
providerAccount.id,
|
||||
providerAccount.refreshToken,
|
||||
providerAccount.accessToken,
|
||||
providerAccount.accessTokenExpires
|
||||
)
|
||||
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
|
||||
|
||||
session = useJwtSession ? {} : await createSession(user)
|
||||
isNewUser = true
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
} else if (providerAccount.type === 'oauth') {
|
||||
// If signing in with oauth account, check to see if the account exists already
|
||||
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
|
||||
if (userByProviderAccountId) {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in with this account, we don't need to do anything
|
||||
// Note: These are cast as strings here to ensure they match as in
|
||||
// some flows (e.g. JWT with a database) one of the values might be a
|
||||
// string and the other might be an ObjectID and would otherwise fail.
|
||||
if (`${userByProviderAccountId.id}` === `${user.id}`) {
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
} else {
|
||||
// If the user is currently signed in, but the new account they are signing in
|
||||
// with is already associated with another account, then we cannot link them
|
||||
// and need to return an error.
|
||||
throw new AccountNotLinkedError()
|
||||
}
|
||||
} else {
|
||||
// If there is no active session, but the account being signed in with is already
|
||||
// associated with a valid user then create session to sign the user in.
|
||||
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
|
||||
return {
|
||||
session,
|
||||
user: userByProviderAccountId,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in and the oAuth account isn't already associated
|
||||
// with another user account then we can go ahead and link the accounts safely.
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerAccount.provider,
|
||||
providerAccount.type,
|
||||
providerAccount.id,
|
||||
providerAccount.refreshToken,
|
||||
providerAccount.accessToken,
|
||||
providerAccount.accessTokenExpires
|
||||
)
|
||||
await dispatchEvent(events.linkAccount, { user, providerAccount })
|
||||
|
||||
// As they are already signed in, we don't need to do anything after linking them
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is not signed in and it looks like a new oAuth account then we
|
||||
// check there also isn't an user account already associated with the same
|
||||
// email address as the one in the oAuth profile.
|
||||
//
|
||||
// This step is often overlooked in oAuth implementations, but covers the following cases:
|
||||
//
|
||||
// 1. It makes it harder for someone to accidentally create two accounts.
|
||||
// e.g. by signin in with email, then again with an oauth account connected to the same email.
|
||||
// 2. It makes it harder to hijack a user account using a 3rd party oAuth account.
|
||||
// e.g. by creating an oauth account then changing the email address associated with it.
|
||||
//
|
||||
// It's quite common for services to automatically link accounts in this case, but it's
|
||||
// better practice to require the user to sign in *then* link accounts to be sure
|
||||
// someone is not exploiting a problem with a third party oAuth service.
|
||||
//
|
||||
// oAuth providers should require email address verification to prevent this, but in
|
||||
// practice that is not always the case; this helps protect against that.
|
||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||
if (userByEmail) {
|
||||
// We end up here when we don't have an account with the same [provider].id *BUT*
|
||||
// we do already have an account with the same email address as the one in the
|
||||
// oAuth profile the user has just tried to sign in with.
|
||||
//
|
||||
// We don't want to have two accounts with the same email address, and we don't
|
||||
// want to link them in case it's not safe to do so, so instead we prompt the user
|
||||
// to sign in via email to verify their identity and then link the accounts.
|
||||
throw new AccountNotLinkedError()
|
||||
} else {
|
||||
// If the current user is not logged in and the profile isn't linked to any user
|
||||
// accounts (by email or provider account id)...
|
||||
//
|
||||
// If no account matching the same [provider].id or .email exists, we can
|
||||
// create a new account for the user, link it to the oAuth acccount and
|
||||
// create a new session for them so they are signed in with it.
|
||||
user = await createUser(profile)
|
||||
await dispatchEvent(events.createUser, user)
|
||||
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerAccount.provider,
|
||||
providerAccount.type,
|
||||
providerAccount.id,
|
||||
providerAccount.refreshToken,
|
||||
providerAccount.accessToken,
|
||||
providerAccount.accessTokenExpires
|
||||
)
|
||||
await dispatchEvent(events.linkAccount, { user, providerAccount })
|
||||
|
||||
session = useJwtSession ? {} : await createSession(user)
|
||||
isNewUser = true
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Promise.reject(new Error('Provider not supported'))
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
|
||||
export default async (req, res, options) => {
|
||||
/**
|
||||
* Get callback URL based on query param / cookie + validation,
|
||||
* and add it to `req.options.callbackUrl`.
|
||||
* @note: `req.options` must already be defined when called.
|
||||
*/
|
||||
export default async function callbackUrlHandler (req, res) {
|
||||
const { query } = req
|
||||
const { body } = req
|
||||
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = options
|
||||
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = req.options
|
||||
|
||||
// Handle preserving and validating callback URLs
|
||||
// If no defaultCallbackUrl option specified, default to the homepage for the site
|
||||
let callbackUrl = defaultCallbackUrl || baseUrl
|
||||
|
||||
// Try reading callbackUrlParamValue from request body (form submission) then from query param (get request)
|
||||
const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null
|
||||
const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null
|
||||
@@ -21,7 +25,9 @@ export default async (req, res, options) => {
|
||||
}
|
||||
|
||||
// Save callback URL in a cookie so that can be used for subsequent requests in signin/signout/callback flow
|
||||
if (callbackUrl && (callbackUrl !== callbackUrlCookieValue)) { cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options) }
|
||||
if (callbackUrl && (callbackUrl !== callbackUrlCookieValue)) {
|
||||
cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options)
|
||||
}
|
||||
|
||||
return Promise.resolve(callbackUrl)
|
||||
req.options.callbackUrl = callbackUrl
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Function to set cookies server side
|
||||
//
|
||||
// Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
|
||||
// * https://github.com/jshttp/cookie/blob/master/index.js
|
||||
// * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
|
||||
//
|
||||
// As only partial functionlity is required, only the code we need has been incorporated here
|
||||
// (with fixes for specific issues) to keep dependancy size down.
|
||||
const set = (res, name, value, options = {}) => {
|
||||
/**
|
||||
* Function to set cookies server side
|
||||
*
|
||||
* Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
|
||||
* * https://github.com/jshttp/cookie/blob/master/index.js
|
||||
* * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
|
||||
*
|
||||
* As only partial functionlity is required, only the code we need has been incorporated here
|
||||
* (with fixes for specific issues) to keep dependancy size down.
|
||||
*/
|
||||
export function set (res, name, value, options = {}) {
|
||||
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
|
||||
|
||||
if ('maxAge' in options) {
|
||||
@@ -99,6 +101,56 @@ function _serialize (name, val, options) {
|
||||
return str
|
||||
}
|
||||
|
||||
export default {
|
||||
set
|
||||
/**
|
||||
* Use secure cookies if the site uses HTTPS
|
||||
* This being conditional allows cookies to work non-HTTPS development URLs
|
||||
* Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
|
||||
* prefix, but enable them by default if the site URL is HTTPS; but not for
|
||||
* non-HTTPS URLs like http://localhost which are used in development).
|
||||
* For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
|
||||
*
|
||||
* @TODO Review cookie settings (names, options)
|
||||
*/
|
||||
export function defaultCookies (useSecureCookies) {
|
||||
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
|
||||
return {
|
||||
// default cookie options
|
||||
sessionToken: {
|
||||
name: `${cookiePrefix}next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `${cookiePrefix}next-auth.callback-url`,
|
||||
options: {
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
csrfToken: {
|
||||
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
|
||||
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
|
||||
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
pkceCodeVerifier: {
|
||||
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
src/server/lib/create-secret.js
Normal file
13
src/server/lib/create-secret.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
/**
|
||||
* 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 ({ userOptions, basePath, baseUrl }) {
|
||||
return userOptions.secret || createHash('sha256').update(JSON.stringify({
|
||||
baseUrl, basePath, ...userOptions
|
||||
})).digest('hex')
|
||||
}
|
||||
42
src/server/lib/csrf-token-handler.js
Normal file
42
src/server/lib/csrf-token-handler.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import * as cookie from './cookie'
|
||||
|
||||
/**
|
||||
* Ensure CSRF Token cookie is set for any subsequent requests.
|
||||
* Used as part of the strateigy 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 default function csrfTokenHandler (req, res, cookies, secret) {
|
||||
const { csrfToken: csrfTokenFromRequest } = req.body
|
||||
|
||||
let csrfTokenFromCookie
|
||||
let csrfTokenVerified = false
|
||||
if (req.cookies[cookies.csrfToken.name]) {
|
||||
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
|
||||
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
|
||||
// If hash matches then we trust the CSRF token value
|
||||
csrfTokenFromCookie = csrfTokenValue
|
||||
|
||||
// If this is a POST request and the CSRF Token in the Post request matches
|
||||
// the cookie we have already verified is one we have set, then token is verified!
|
||||
if (req.method === 'POST' && csrfTokenFromCookie === csrfTokenFromRequest) { csrfTokenVerified = true }
|
||||
}
|
||||
}
|
||||
if (!csrfTokenFromCookie) {
|
||||
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
|
||||
// (e.g. because it's been modifed or because the secret has changed) create a new token.
|
||||
csrfTokenFromCookie = randomBytes(32).toString('hex')
|
||||
const newCsrfTokenCookie = `${csrfTokenFromCookie}|${createHash('sha256').update(`${csrfTokenFromCookie}${secret}`).digest('hex')}`
|
||||
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
|
||||
}
|
||||
return { csrfToken: csrfTokenFromCookie, csrfTokenVerified }
|
||||
}
|
||||
@@ -9,19 +9,14 @@
|
||||
* requests to sign in and again when they activate the link in the sign in
|
||||
* email.
|
||||
*
|
||||
* @param {object} profile User profile (e.g. user id, name, email)
|
||||
* @param {object} account Account used to sign in (e.g. OAuth account)
|
||||
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
|
||||
* @return {boolean|object} Return `true` (or a modified JWT) to allow sign in
|
||||
* Return `false` to deny access
|
||||
* @param {object} profile User profile (e.g. user id, name, email)
|
||||
* @param {object} account Account used to sign in (e.g. OAuth account)
|
||||
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
|
||||
* @return {Promise<boolean|never>} Return `true` (or a modified JWT) to allow sign in
|
||||
* Return `false` to deny access
|
||||
*/
|
||||
const signIn = async (profile, account, metadata) => {
|
||||
const isAllowedToSignIn = true
|
||||
if (isAllowedToSignIn) {
|
||||
return Promise.resolve(true)
|
||||
} else {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
export async function signIn () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,12 +26,13 @@ const signIn = async (profile, account, metadata) => {
|
||||
*
|
||||
* @param {string} url URL provided as callback URL by the client
|
||||
* @param {string} baseUrl Default base URL of site (can be used as fallback)
|
||||
* @return {string} URL the client will be redirect to
|
||||
* @return {Promise<string>} URL the client will be redirect to
|
||||
*/
|
||||
const redirect = async (url, baseUrl) => {
|
||||
return url.startsWith(baseUrl)
|
||||
? Promise.resolve(url)
|
||||
: Promise.resolve(baseUrl)
|
||||
export async function redirect (url, baseUrl) {
|
||||
if (url.startsWith(baseUrl)) {
|
||||
return url
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,31 +41,24 @@ const redirect = async (url, baseUrl) => {
|
||||
*
|
||||
* @param {object} session Session object
|
||||
* @param {object} token JSON Web Token (if enabled)
|
||||
* @return {object} Session that will be returned to the client
|
||||
* @return {Promise<object>} Session that will be returned to the client
|
||||
*/
|
||||
const session = async (session, token) => {
|
||||
return Promise.resolve(session)
|
||||
export async function session (session) {
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* This callback is called whenever a JSON Web Token is created / updated.
|
||||
* e.g. On sign in, `getSession()`, `useSession()`, `/api/auth/session` (etc)
|
||||
*
|
||||
* On initial sign in, the raw oAuthProfile is passed if the user is signing in
|
||||
* On initial sign in, the raw OAuthProfile is passed if the user is signing in
|
||||
* with an OAuth provider. It is not avalible on subsequent calls. You can
|
||||
* take advantage of this to persist additional data you need to in the JWT.
|
||||
*
|
||||
* @param {object} token Decrypted JSON Web Token
|
||||
* @param {object} oAuthProfile OAuth profile - only available on sign in
|
||||
* @return {object} JSON Web Token that will be saved
|
||||
* @return {Promise<object>} JSON Web Token that will be saved
|
||||
*/
|
||||
const jwt = async (token, oAuthProfile) => {
|
||||
return Promise.resolve(token)
|
||||
}
|
||||
|
||||
export default {
|
||||
signIn,
|
||||
redirect,
|
||||
session,
|
||||
jwt
|
||||
export async function jwt (token) {
|
||||
return token
|
||||
}
|
||||
23
src/server/lib/default-events.js
Normal file
23
src/server/lib/default-events.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Event triggered on successful sign in */
|
||||
export async function signIn (message) {}
|
||||
|
||||
/** Event triggered on sign out */
|
||||
export async function signOut (message) {}
|
||||
|
||||
/** Event triggered on user creation */
|
||||
export async function createUser (message) {}
|
||||
|
||||
/** Event triggered when a user object is updated */
|
||||
export async function updateUser (message) {}
|
||||
|
||||
/** Event triggered when an account is linked to a user */
|
||||
export async function linkAccount (message) {}
|
||||
|
||||
/** Event triggered when a session is active */
|
||||
export async function session (message) {}
|
||||
|
||||
/**
|
||||
* @TODO Event triggered when something goes wrong in an authentication flow
|
||||
* This event may be fired multiple times when an error occurs
|
||||
*/
|
||||
export async function error (message) {}
|
||||
@@ -1,6 +1,6 @@
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
export default async (event, message) => {
|
||||
export default async function dispatchEvent (event, message) {
|
||||
try {
|
||||
await event(message)
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
const signIn = async (message) => {
|
||||
// Event triggered on successful sign in
|
||||
}
|
||||
|
||||
const signOut = async (message) => {
|
||||
// Event triggered on sign out
|
||||
}
|
||||
|
||||
const createUser = async (message) => {
|
||||
// Event triggered on user creation
|
||||
}
|
||||
|
||||
const updateUser = async (message) => {
|
||||
// Event triggered when a user object is updated
|
||||
}
|
||||
|
||||
const linkAccount = async (message) => {
|
||||
// Event triggered when an account is linked to a user
|
||||
}
|
||||
|
||||
const session = async (message) => {
|
||||
// Event triggered when a session is active
|
||||
}
|
||||
|
||||
const error = async (message) => {
|
||||
// @TODO Event triggered when something goes wrong in an authentication flow
|
||||
// This event may be fired multiple times when an error occurs
|
||||
}
|
||||
|
||||
export default {
|
||||
signIn,
|
||||
signOut,
|
||||
createUser,
|
||||
updateUser,
|
||||
linkAccount,
|
||||
session,
|
||||
error
|
||||
}
|
||||
35
src/server/lib/extend-req.js
Normal file
35
src/server/lib/extend-req.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Extends res.{end,json,send} with `done()`,
|
||||
* and redirect to support sending url as json.
|
||||
*
|
||||
* When a response is complete, it will call the `done` method,
|
||||
* so that the serverless function knows when it is
|
||||
* safe to return and that no more data will be sent.
|
||||
*/
|
||||
export default function extendRes (req, res, done) {
|
||||
const originalResEnd = res.end.bind(res)
|
||||
res.end = (...args) => {
|
||||
done()
|
||||
return originalResEnd(...args)
|
||||
}
|
||||
|
||||
const originalResJson = res.json.bind(res)
|
||||
res.json = (...args) => {
|
||||
done()
|
||||
return originalResJson(...args)
|
||||
}
|
||||
|
||||
const originalResSend = res.send.bind(res)
|
||||
res.send = (...args) => {
|
||||
done()
|
||||
return originalResSend(...args)
|
||||
}
|
||||
|
||||
res.redirect = (url) => {
|
||||
if (req.body?.json === 'true') {
|
||||
return res.json({ url })
|
||||
}
|
||||
res.status(302).setHeader('Location', url)
|
||||
return res.end()
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,33 @@
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import querystring from 'querystring'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import { decode as jwtDecode } from 'jsonwebtoken'
|
||||
import oAuthClient from './client'
|
||||
import logger from '../../../lib/logger'
|
||||
import { OAuthCallbackError } from '../../../lib/errors'
|
||||
|
||||
// @TODO Refactor monkey patching in _getOAuthAccessToken() and _get()
|
||||
// These methods have been forked from `node-oauth` to fix bugs; it may make
|
||||
// sense to migrate all the methods we need from node-oauth to nexth-auth (with
|
||||
// appropriate credit) to make it easier to maintain and address issues as they
|
||||
// come up, as the node-oauth package does not seem to be actively maintained.
|
||||
|
||||
// @TODO Refactor to use promises and not callbacks
|
||||
// @TODO Refactor to use jsonwebtoken instead of jwt-decode & remove dependancy
|
||||
export default async (req, provider, csrfToken, callback) => {
|
||||
// The "user" object is specific to apple provider and is provided on first sign in
|
||||
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
|
||||
let { oauth_token, oauth_verifier, code, user, state } = req.query // eslint-disable-line camelcase
|
||||
export default async function oAuthCallback (req) {
|
||||
const { provider, pkce } = req.options
|
||||
const client = oAuthClient(provider)
|
||||
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
// For OAuth 2.0 flows, check state returned and matches expected value
|
||||
// (a hash of the NextAuth.js CSRF token).
|
||||
//
|
||||
// This check can be disabled for providers that do not support it by
|
||||
// setting `state: false` as a option on the provider (defaults to true).
|
||||
if (!Object.prototype.hasOwnProperty.call(provider, 'state') || provider.state === true) {
|
||||
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
|
||||
if (state !== expectedState) {
|
||||
return callback(new Error('Invalid state returned from oAuth provider'))
|
||||
}
|
||||
}
|
||||
if (provider.version?.startsWith('2.')) {
|
||||
// The "user" object is specific to the Apple provider and is provided on first sign in
|
||||
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
|
||||
let { code, user } = req.query // eslint-disable-line camelcase
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = JSON.parse(JSON.stringify(req.body))
|
||||
if (body.error) { throw new Error(body.error) }
|
||||
if (body.error) {
|
||||
throw new Error(body.error)
|
||||
}
|
||||
|
||||
code = body.code
|
||||
user = body.user != null ? JSON.parse(body.user) : null
|
||||
} catch (e) {
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', e, req.body, provider.id, code)
|
||||
return callback()
|
||||
} catch (error) {
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error, req.body, provider.id, code)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// REVIEW: Is this used by any of the providers?
|
||||
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
|
||||
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
|
||||
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
|
||||
@@ -52,105 +35,93 @@ export default async (req, provider, csrfToken, callback) => {
|
||||
client.useAuthorizationHeaderforGET(true)
|
||||
}
|
||||
|
||||
// Use custom getOAuthAccessToken() method for oAuth2 flows
|
||||
client.getOAuthAccessToken = _getOAuthAccessToken
|
||||
|
||||
await client.getOAuthAccessToken(
|
||||
code,
|
||||
provider,
|
||||
(error, accessToken, refreshToken, results) => {
|
||||
if (error || results.error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, results, provider.id, code)
|
||||
return callback(error || results.error)
|
||||
try {
|
||||
const tokens = await client.getOAuthAccessToken(code, provider, pkce.code_verifier)
|
||||
let profileData
|
||||
if (provider.idToken) {
|
||||
if (!tokens?.id_token) {
|
||||
throw new OAuthCallbackError('Missing JWT ID Token')
|
||||
}
|
||||
|
||||
if (provider.idToken) {
|
||||
// If we don't have an ID Token most likely the user hit a cancel
|
||||
// button when signing in (or the provider is misconfigured).
|
||||
//
|
||||
// Unfortunately, we can't tell which, so we can't treat it as an
|
||||
// error, so instead we just returning nothing, which will cause the
|
||||
// user to be redirected back to the sign in page.
|
||||
if (!results || !results.id_token) {
|
||||
return callback()
|
||||
}
|
||||
|
||||
// Support services that use OpenID ID Tokens to encode profile data
|
||||
_decodeToken(
|
||||
provider,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
results.id_token,
|
||||
async (error, profileData) => {
|
||||
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider, user)
|
||||
callback(error, profile, account, OAuthProfile)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Use custom get() method for oAuth2 flows
|
||||
client.get = _get
|
||||
|
||||
client.get(
|
||||
provider,
|
||||
accessToken,
|
||||
results,
|
||||
async (error, profileData) => {
|
||||
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
|
||||
callback(error, profile, account, OAuthProfile)
|
||||
}
|
||||
)
|
||||
}
|
||||
// Support services that use OpenID ID Tokens to encode profile data
|
||||
profileData = jwtDecode(tokens.id_token, { json: true })
|
||||
} else {
|
||||
profileData = await client.get(provider, tokens.accessToken, tokens)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Handle oAuth v1.x
|
||||
await client.getOAuthAccessToken(
|
||||
oauth_token,
|
||||
null,
|
||||
oauth_verifier,
|
||||
(error, accessToken, refreshToken, results) => {
|
||||
// @TODO Handle error
|
||||
if (error || results.error) {
|
||||
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error, results)
|
||||
}
|
||||
|
||||
client.get(
|
||||
provider.profileUrl,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
async (error, profileData) => {
|
||||
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
|
||||
callback(error, profile, account, OAuthProfile)
|
||||
}
|
||||
)
|
||||
}
|
||||
return getProfile({ profileData, provider, tokens, user })
|
||||
} catch (error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, provider.id, code)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle OAuth v1.x
|
||||
const {
|
||||
oauth_token: oauthToken, oauth_verifier: oauthVerifier
|
||||
} = req.query
|
||||
const tokens = await client.getOAuthAccessToken(oauthToken, null, oauthVerifier)
|
||||
const profileData = await client.get(
|
||||
provider.profileUrl,
|
||||
tokens.accessToken,
|
||||
tokens.refreshToken
|
||||
)
|
||||
|
||||
return getProfile({ profileData, tokens, provider })
|
||||
} catch (error) {
|
||||
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
|
||||
* Returns profile, raw profile and auth provider details
|
||||
* @param {{
|
||||
* profileData: object | string
|
||||
* tokens: {
|
||||
* accessToken: string
|
||||
* idToken?: string
|
||||
* refreshToken?: string
|
||||
* access_token: string
|
||||
* expires_in?: string | Date | null
|
||||
* refresh_token?: string
|
||||
* id_token?: string
|
||||
* }
|
||||
* provider: object
|
||||
* user?: object
|
||||
* }} profileParams
|
||||
*/
|
||||
async function _getProfile (error, profileData, accessToken, refreshToken, provider, userData) {
|
||||
// @TODO Handle error
|
||||
if (error) {
|
||||
logger.error('OAUTH_GET_PROFILE_ERROR', error)
|
||||
}
|
||||
|
||||
let profile = {}
|
||||
async function getProfile ({ profileData, tokens, provider, user }) {
|
||||
try {
|
||||
// Convert profileData into an object if it's a string
|
||||
if (typeof profileData === 'string' || profileData instanceof String) { profileData = JSON.parse(profileData) }
|
||||
if (typeof profileData === 'string' || profileData instanceof String) {
|
||||
profileData = JSON.parse(profileData)
|
||||
}
|
||||
|
||||
// If a user object is supplied (e.g. Apple provider) add it to the profile object
|
||||
if (userData != null) {
|
||||
profileData.user = userData
|
||||
if (user != null) {
|
||||
profileData.user = user
|
||||
}
|
||||
|
||||
logger.debug('PROFILE_DATA', profileData)
|
||||
|
||||
profile = await provider.profile(profileData)
|
||||
const profile = await provider.profile(profileData)
|
||||
// Return profile, raw profile and auth provider details
|
||||
return {
|
||||
profile: {
|
||||
...profile,
|
||||
email: profile.email?.toLowerCase() ?? null
|
||||
},
|
||||
account: {
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
id: profile.id,
|
||||
...tokens
|
||||
},
|
||||
OAuthProfile: profileData
|
||||
}
|
||||
} catch (exception) {
|
||||
// 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.
|
||||
@@ -166,123 +137,4 @@ async function _getProfile (error, profileData, accessToken, refreshToken, provi
|
||||
OAuthProfile: profileData
|
||||
}
|
||||
}
|
||||
|
||||
// Return profile, raw profile and auth provider details
|
||||
return {
|
||||
profile: {
|
||||
name: profile.name,
|
||||
email: profile.email ? profile.email.toLowerCase() : null,
|
||||
image: profile.image
|
||||
},
|
||||
account: {
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
id: profile.id,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
accessTokenExpires: null
|
||||
},
|
||||
OAuthProfile: profileData
|
||||
}
|
||||
}
|
||||
|
||||
// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||
async function _getOAuthAccessToken (code, provider, callback) {
|
||||
const url = provider.accessTokenUrl
|
||||
const setGetAccessTokenAuthHeader = (provider.setGetAccessTokenAuthHeader !== null) ? provider.setGetAccessTokenAuthHeader : true
|
||||
const params = { ...provider.params } || {}
|
||||
const headers = { ...provider.headers } || {}
|
||||
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
|
||||
|
||||
if (!params[codeParam]) { params[codeParam] = code }
|
||||
|
||||
if (!params.client_id) { params.client_id = provider.clientId }
|
||||
|
||||
if (!params.client_secret) {
|
||||
// For some providers it useful to be able to generate the secret on the fly
|
||||
// e.g. For Sign in With Apple a JWT token using the properties in clientSecret
|
||||
if (provider.clientSecretCallback) {
|
||||
params.client_secret = await provider.clientSecretCallback(provider.clientSecret)
|
||||
} else {
|
||||
params.client_secret = provider.clientSecret
|
||||
}
|
||||
}
|
||||
|
||||
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
|
||||
|
||||
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
|
||||
// Added as a fix to accomodate change in Twitch oAuth API
|
||||
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
|
||||
// Added as a fix for Reddit Authentication
|
||||
if (provider.id === 'reddit') {
|
||||
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
|
||||
}
|
||||
// Okta errors when this is set. Maybe there are other Providers that also wont like this.
|
||||
if (setGetAccessTokenAuthHeader) {
|
||||
if (!headers.Authorization) { headers.Authorization = `Bearer ${code}` }
|
||||
}
|
||||
|
||||
const postData = querystring.stringify(params)
|
||||
|
||||
this._request(
|
||||
'POST',
|
||||
url,
|
||||
headers,
|
||||
postData,
|
||||
null,
|
||||
(error, data, response) => {
|
||||
if (error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
|
||||
return callback(error)
|
||||
}
|
||||
|
||||
let results
|
||||
try {
|
||||
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
|
||||
// responses should be in JSON
|
||||
results = JSON.parse(data)
|
||||
} catch (e) {
|
||||
// However both Facebook + Github currently use rev05 of the spec and neither
|
||||
// seem to specify a content-type correctly in their response headers. :(
|
||||
// Clients of these services suffer a minor performance cost.
|
||||
results = querystring.parse(data)
|
||||
}
|
||||
const accessToken = provider.accessTokenGetter ? provider.accessTokenGetter(results) : results.access_token
|
||||
const refreshToken = results.refresh_token
|
||||
callback(null, accessToken, refreshToken, results)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||
*
|
||||
* 18/08/2020 @robertcraigie added results parameter to pass data to an optional request preparer.
|
||||
* e.g. see providers/bungie
|
||||
*/
|
||||
function _get (provider, accessToken, results, callback) {
|
||||
let url = provider.profileUrl
|
||||
const headers = provider.headers || {}
|
||||
|
||||
if (this._useAuthorizationHeaderForGET) {
|
||||
headers.Authorization = this.buildAuthHeader(accessToken)
|
||||
|
||||
// This line is required for Twitch
|
||||
headers['Client-ID'] = provider.clientId
|
||||
accessToken = null
|
||||
}
|
||||
|
||||
const prepareRequest = provider.prepareProfileRequest
|
||||
if (prepareRequest) {
|
||||
url = prepareRequest({ provider, url, headers, results }) || url
|
||||
}
|
||||
|
||||
this._request('GET', url, headers, null, accessToken, callback)
|
||||
}
|
||||
|
||||
function _decodeToken (provider, accessToken, refreshToken, idToken, callback) {
|
||||
if (!idToken) { throw new Error('Missing JWT ID Token', provider, idToken) }
|
||||
const decodedToken = jwtDecode(idToken)
|
||||
const profileData = JSON.stringify(decodedToken)
|
||||
callback(null, profileData, accessToken, refreshToken, provider)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,236 @@
|
||||
// @TODO Refactor to remove dependancy on 'oauth' package
|
||||
// It is already quite monkey patched, we don't use all the features and and it
|
||||
// would be easier to maintain if all the code was native to next-auth.
|
||||
import { OAuth, OAuth2 } from 'oauth'
|
||||
import querystring from 'querystring'
|
||||
import logger from '../../../lib/logger'
|
||||
import { sign as jwtSign } from 'jsonwebtoken'
|
||||
|
||||
export default (provider) => {
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
// Handle oAuth v2.x
|
||||
const basePath = new URL(provider.authorizationUrl).origin
|
||||
const authorizePath = new URL(provider.authorizationUrl).pathname
|
||||
/**
|
||||
* @TODO Refactor to remove dependancy on 'oauth' package
|
||||
* It is already quite monkey patched, we don't use all the features and and it
|
||||
* would be easier to maintain if all the code was native to next-auth.
|
||||
*/
|
||||
export default function oAuthClient (provider) {
|
||||
if (provider.version?.startsWith('2.')) {
|
||||
// Handle OAuth v2.x
|
||||
const authorizationUrl = new URL(provider.authorizationUrl)
|
||||
const basePath = authorizationUrl.origin
|
||||
const authorizePath = authorizationUrl.pathname
|
||||
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
|
||||
return new OAuth2(
|
||||
const oauth2Client = new OAuth2(
|
||||
provider.clientId,
|
||||
provider.clientSecret,
|
||||
basePath,
|
||||
authorizePath,
|
||||
accessTokenPath,
|
||||
provider.headers)
|
||||
} else {
|
||||
// Handle oAuth v1.x
|
||||
return new OAuth(
|
||||
provider.requestTokenUrl,
|
||||
provider.accessTokenUrl,
|
||||
provider.clientId,
|
||||
provider.clientSecret,
|
||||
(provider.version || '1.0'),
|
||||
provider.callbackUrl,
|
||||
(provider.encoding || 'HMAC-SHA1')
|
||||
provider.headers
|
||||
)
|
||||
oauth2Client.getOAuthAccessToken = getOAuth2AccessToken
|
||||
oauth2Client.get = getOAuth2
|
||||
return oauth2Client
|
||||
}
|
||||
// Handle OAuth v1.x
|
||||
const oauth1Client = new OAuth(
|
||||
provider.requestTokenUrl,
|
||||
provider.accessTokenUrl,
|
||||
provider.clientId,
|
||||
provider.clientSecret,
|
||||
provider.version || '1.0',
|
||||
provider.callbackUrl,
|
||||
provider.encoding || 'HMAC-SHA1'
|
||||
)
|
||||
|
||||
// Promisify get() and getOAuth2AccessToken() for OAuth1
|
||||
const originalGet = oauth1Client.get.bind(oauth1Client)
|
||||
oauth1Client.get = (...args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
originalGet(...args, (error, result) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve(result)
|
||||
})
|
||||
})
|
||||
}
|
||||
const originalGetOAuth1AccessToken = oauth1Client.getOAuthAccessToken.bind(oauth1Client)
|
||||
oauth1Client.getOAuthAccessToken = (...args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
originalGetOAuth1AccessToken(...args, (error, accessToken, refreshToken, results) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve({ accessToken, refreshToken, results })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const originalGetOAuthRequestToken = oauth1Client.getOAuthRequestToken.bind(oauth1Client)
|
||||
oauth1Client.getOAuthRequestToken = (...args) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
originalGetOAuthRequestToken(...args, (error, oauthToken) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve(oauthToken)
|
||||
})
|
||||
})
|
||||
}
|
||||
return oauth1Client
|
||||
}
|
||||
|
||||
/**
|
||||
* @TODO Refactor monkey patching in OAuth2.getOAuthAccessToken() and OAuth2.get()
|
||||
* These methods have been forked from `node-oauth` to fix bugs; it may make
|
||||
* sense to migrate all the methods we need from node-oauth to nexth-auth (with
|
||||
* appropriate credit) to make it easier to maintain and address issues as they
|
||||
* come up, as the node-oauth package does not seem to be actively maintained.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||
*/
|
||||
async function getOAuth2AccessToken (code, provider, codeVerifier) {
|
||||
const url = provider.accessTokenUrl
|
||||
const params = { ...provider.params }
|
||||
const headers = { ...provider.headers }
|
||||
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
|
||||
|
||||
if (!params[codeParam]) { params[codeParam] = code }
|
||||
|
||||
if (!params.client_id) { params.client_id = provider.clientId }
|
||||
|
||||
// For Apple the client secret must be generated on-the-fly.
|
||||
// Using the properties in clientSecret to create a JWT.
|
||||
if (provider.id === 'apple' && typeof provider.clientSecret === 'object') {
|
||||
const { keyId, teamId, privateKey } = provider.clientSecret
|
||||
const clientSecret = jwtSign({
|
||||
iss: teamId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: provider.clientId
|
||||
},
|
||||
// Automatically convert \\n into \n if found in private key. If the key
|
||||
// is passed in an environment variable \n can get escaped as \\n
|
||||
privateKey.replace(/\\n/g, '\n'),
|
||||
{ algorithm: 'ES256', keyid: keyId }
|
||||
)
|
||||
params.client_secret = clientSecret
|
||||
} else {
|
||||
params.client_secret = provider.clientSecret
|
||||
}
|
||||
|
||||
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
|
||||
|
||||
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
|
||||
// Added as a fix to accomodate change in Twitch OAuth API
|
||||
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
|
||||
// Added as a fix for Reddit Authentication
|
||||
if (provider.id === 'reddit') {
|
||||
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
|
||||
}
|
||||
|
||||
if ((provider.id === 'okta' || provider.id === 'identity-server4') && !headers.Authorization) {
|
||||
headers.Authorization = `Bearer ${code}`
|
||||
}
|
||||
|
||||
if (provider.protection === 'pkce') {
|
||||
params.code_verifier = codeVerifier
|
||||
}
|
||||
|
||||
const postData = querystring.stringify(params)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._request(
|
||||
'POST',
|
||||
url,
|
||||
headers,
|
||||
postData,
|
||||
null,
|
||||
(error, data, response) => {
|
||||
if (error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
|
||||
return reject(error)
|
||||
}
|
||||
|
||||
let raw
|
||||
try {
|
||||
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
|
||||
// responses should be in JSON
|
||||
raw = JSON.parse(data)
|
||||
} catch {
|
||||
// However both Facebook + Github currently use rev05 of the spec and neither
|
||||
// seem to specify a content-type correctly in their response headers. :(
|
||||
// Clients of these services suffer a minor performance cost.
|
||||
raw = querystring.parse(data)
|
||||
}
|
||||
|
||||
const accessToken = provider.id === 'slack'
|
||||
? raw.authed_user.access_token
|
||||
: raw.access_token
|
||||
|
||||
resolve({
|
||||
accessToken,
|
||||
accessTokenExpires: null,
|
||||
refreshToken: raw.refresh_token,
|
||||
idToken: raw.id_token,
|
||||
...raw
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||
*
|
||||
* 18/08/2020 @robertcraigie added results parameter to pass data to an optional request preparer.
|
||||
* e.g. see providers/bungie
|
||||
*/
|
||||
async function getOAuth2 (provider, accessToken, results) {
|
||||
let url = provider.profileUrl
|
||||
const headers = { ...provider.headers }
|
||||
|
||||
if (this._useAuthorizationHeaderForGET) {
|
||||
headers.Authorization = this.buildAuthHeader(accessToken)
|
||||
|
||||
// Mail.ru & vk.com require 'access_token' as URL request parameter
|
||||
if (['mailru', 'vk'].includes(provider.id)) {
|
||||
const safeAccessTokenURL = new URL(url)
|
||||
safeAccessTokenURL.searchParams.append('access_token', accessToken)
|
||||
url = safeAccessTokenURL.href
|
||||
}
|
||||
|
||||
// This line is required for Twitch
|
||||
if (provider.id === 'twitch') {
|
||||
headers['Client-ID'] = provider.clientId
|
||||
}
|
||||
accessToken = null
|
||||
}
|
||||
|
||||
if (provider.id === 'bungie') {
|
||||
url = prepareProfileUrl({ provider, url, results })
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this._request('GET', url, headers, null, accessToken, (error, profileData) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
resolve(profileData)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Bungie needs special handling */
|
||||
function prepareProfileUrl ({ provider, url, results }) {
|
||||
if (!results.membership_id) {
|
||||
// internal error
|
||||
// @TODO: handle better
|
||||
throw new Error('Expected membership_id to be passed.')
|
||||
}
|
||||
|
||||
if (!provider.headers?.['X-API-Key']) {
|
||||
throw new Error('The Bungie provider requires the X-API-Key option to be present in "headers".')
|
||||
}
|
||||
|
||||
return url.replace('{membershipId}', results.membership_id)
|
||||
}
|
||||
|
||||
82
src/server/lib/oauth/pkce-handler.js
Normal file
82
src/server/lib/oauth/pkce-handler.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import pkceChallenge from 'pkce-challenge'
|
||||
import * as cookie from '../cookie'
|
||||
import jwt from '../../../lib/jwt'
|
||||
import logger from '../../../lib/logger'
|
||||
import { OAuthCallbackError } from '../../../lib/errors'
|
||||
|
||||
const PKCE_LENGTH = 64
|
||||
const PKCE_CODE_CHALLENGE_METHOD = 'S256' // can be 'plain', not recommended https://tools.ietf.org/html/rfc7636#section-4.2
|
||||
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
|
||||
|
||||
/** Adds `code_verifier` to `req.options.pkce`, and removes the corresponding cookie */
|
||||
export async function handleCallback (req, res) {
|
||||
const { cookies, provider, baseUrl, basePath } = req.options
|
||||
try {
|
||||
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
if (!(cookies.pkceCodeVerifier.name in req.cookies)) {
|
||||
throw new OAuthCallbackError('The code_verifier cookie was not found.')
|
||||
}
|
||||
const pkce = await jwt.decode({
|
||||
...req.options.jwt,
|
||||
token: req.cookies[cookies.pkceCodeVerifier.name],
|
||||
maxAge: PKCE_MAX_AGE,
|
||||
encryption: true
|
||||
})
|
||||
req.options.pkce = pkce
|
||||
logger.debug('OAUTH_CALLBACK_PROTECTION', 'Read PKCE verifier from cookie', {
|
||||
code_verifier: pkce.code_verifier,
|
||||
pkceLength: PKCE_LENGTH,
|
||||
method: PKCE_CODE_CHALLENGE_METHOD
|
||||
})
|
||||
cookie.set(res, cookies.pkceCodeVerifier.name, null, { maxAge: 0 }) // remove PKCE after it has been used
|
||||
} catch (error) {
|
||||
logger.error('CALLBACK_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds `code_challenge` and `code_challenge_method` to `req.options.pkce`. */
|
||||
export async function handleSignin (req, res) {
|
||||
const { cookies, provider, baseUrl, basePath } = req.options
|
||||
try {
|
||||
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
|
||||
return
|
||||
}
|
||||
// Started login flow, add generated pkce to req.options and (encrypted) code_verifier to a cookie
|
||||
const pkce = pkceChallenge(PKCE_LENGTH)
|
||||
logger.debug('OAUTH_SIGNIN_PROTECTION', 'Created PKCE challenge/verifier', {
|
||||
...pkce,
|
||||
pkceLength: PKCE_LENGTH,
|
||||
method: PKCE_CODE_CHALLENGE_METHOD
|
||||
})
|
||||
|
||||
provider.authorizationParams = {
|
||||
...provider.authorizationParams,
|
||||
code_challenge: pkce.code_challenge,
|
||||
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD
|
||||
}
|
||||
|
||||
const encryptedCodeVerifier = await jwt.encode({
|
||||
...req.options.jwt,
|
||||
maxAge: PKCE_MAX_AGE,
|
||||
token: { code_verifier: pkce.code_verifier },
|
||||
encryption: true
|
||||
})
|
||||
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + (PKCE_MAX_AGE * 1000))
|
||||
cookie.set(res, cookies.pkceCodeVerifier.name, encryptedCodeVerifier, {
|
||||
expires: cookieExpires.toISOString(),
|
||||
...cookies.pkceCodeVerifier.options
|
||||
})
|
||||
logger.debug('OAUTH_SIGNIN_PROTECTION', 'Created PKCE code_verifier saved in cookie')
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
||||
}
|
||||
}
|
||||
|
||||
export default { handleSignin, handleCallback }
|
||||
64
src/server/lib/oauth/state-handler.js
Normal file
64
src/server/lib/oauth/state-handler.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '../../../lib/logger'
|
||||
import { OAuthCallbackError } from '../../../lib/errors'
|
||||
|
||||
/**
|
||||
* For OAuth 2.0 flows, if the provider supports state,
|
||||
* check if state matches the one sent on signin
|
||||
* (a hash of the NextAuth.js CSRF token).
|
||||
*/
|
||||
export async function handleCallback (req, res) {
|
||||
const { csrfToken, provider, baseUrl, basePath } = req.options
|
||||
try {
|
||||
if (provider.protection !== 'state') { // Provider does not support state, nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
const { state } = req.query
|
||||
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
|
||||
|
||||
logger.debug(
|
||||
'OAUTH_CALLBACK_PROTECTION',
|
||||
'Comparing received and expected state',
|
||||
{ state, expectedState }
|
||||
)
|
||||
if (state !== expectedState) {
|
||||
throw new OAuthCallbackError('Invalid state returned from OAuth provider')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('STATE_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adds CSRF token to the authorizationParams. */
|
||||
export async function handleSignin (req, res) {
|
||||
const { provider, baseUrl, basePath, csrfToken } = req.options
|
||||
try {
|
||||
if (provider.protection !== 'state') { // Provider does not support state, nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
if ('state' in provider) {
|
||||
logger.warn(
|
||||
'STATE_OPTION_DEPRECATION',
|
||||
'The `state` provider option is being replaced with `protection`. See the docs.'
|
||||
)
|
||||
}
|
||||
|
||||
// A hash of the NextAuth.js CSRF token is used as the state
|
||||
const state = createHash('sha256').update(csrfToken).digest('hex')
|
||||
|
||||
provider.authorizationParams = { ...provider.authorizationParams, state }
|
||||
logger.debug(
|
||||
'OAUTH_CALLBACK_PROTECTION',
|
||||
'Added state to authorization params',
|
||||
{ state }
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
||||
}
|
||||
}
|
||||
|
||||
export default { handleSignin, handleCallback }
|
||||
@@ -1,14 +1,8 @@
|
||||
export default (_providers, baseUrl, basePath) => {
|
||||
const providers = {}
|
||||
|
||||
_providers.forEach(provider => {
|
||||
const providerId = provider.id
|
||||
providers[providerId] = {
|
||||
...provider,
|
||||
signinUrl: `${baseUrl}${basePath}/signin/${providerId}`,
|
||||
callbackUrl: `${baseUrl}${basePath}/callback/${providerId}`
|
||||
}
|
||||
})
|
||||
|
||||
return providers
|
||||
/** Adds `signinUrl` and `callbackUrl` to each provider. */
|
||||
export default function parseProviders ({ providers = [], baseUrl, basePath }) {
|
||||
return providers.map((provider) => ({
|
||||
...provider,
|
||||
signinUrl: `${baseUrl}${basePath}/signin/${provider.id}`,
|
||||
callbackUrl: `${baseUrl}${basePath}/callback/${provider.id}`
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export default async (email, provider, options) => {
|
||||
export default async function email (email, provider, options) {
|
||||
try {
|
||||
const { baseUrl, basePath, adapter } = options
|
||||
|
||||
@@ -10,7 +10,7 @@ export default async (email, provider, options) => {
|
||||
const secret = provider.secret || options.secret
|
||||
|
||||
// Generate token
|
||||
const token = randomBytes(32).toString('hex')
|
||||
const token = provider.generateVerificationToken?.() ?? randomBytes(32).toString('hex')
|
||||
|
||||
// Send email with link containing token (the unhashed version)
|
||||
const url = `${baseUrl}${basePath}/callback/${encodeURIComponent(provider.id)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
import oAuthClient from '../oauth/client'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '../../../lib/logger'
|
||||
|
||||
export default (provider, csrfToken, callback, authParams) => {
|
||||
const { callbackUrl } = provider
|
||||
export default async function getAuthorizationUrl (req) {
|
||||
const { provider } = req.options
|
||||
|
||||
const client = oAuthClient(provider)
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
// Handle oAuth v2.x
|
||||
if (provider.version?.startsWith('2.')) {
|
||||
delete req.query?.nextauth
|
||||
// Handle OAuth v2.x
|
||||
let url = client.getAuthorizeUrl({
|
||||
...authParams,
|
||||
...provider.authorizationParams,
|
||||
...req.query,
|
||||
redirect_uri: provider.callbackUrl,
|
||||
scope: provider.scope,
|
||||
// A hash of the NextAuth.js CSRF token is used as the state
|
||||
state: createHash('sha256').update(csrfToken).digest('hex'),
|
||||
...provider.additionalAuthorizeParams
|
||||
scope: provider.scope
|
||||
})
|
||||
|
||||
// If the authorizationUrl specified in the config has query parameters on it
|
||||
// make sure they are included in the URL we return.
|
||||
//
|
||||
// This is a fix for an open issue with the oAuthClient library we are using
|
||||
// This is a fix for an open issue with the OAuthClient library we are using
|
||||
// which inadvertantly strips them.
|
||||
//
|
||||
// https://github.com/ciaranj/node-oauth/pull/193
|
||||
@@ -29,15 +28,17 @@ export default (provider, csrfToken, callback, authParams) => {
|
||||
url = url.replace(baseUrl, provider.authorizationUrl + '&')
|
||||
}
|
||||
|
||||
callback(null, url)
|
||||
} else {
|
||||
// Handle oAuth v1.x
|
||||
client.getOAuthRequestToken((error, oAuthToken) => {
|
||||
if (error) {
|
||||
logger.error('GET_AUTHORISATION_URL_ERROR', error)
|
||||
}
|
||||
const url = `${provider.authorizationUrl}?oauth_token=${oAuthToken}`
|
||||
callback(error, url)
|
||||
}, callbackUrl)
|
||||
logger.debug('GET_AUTHORIZATION_URL', url)
|
||||
return url
|
||||
}
|
||||
|
||||
try {
|
||||
const oAuthToken = await client.getOAuthRequestToken()
|
||||
const url = `${provider.authorizationUrl}?oauth_token=${oAuthToken}`
|
||||
logger.debug('GET_AUTHORIZATION_URL', url)
|
||||
return url
|
||||
} catch (error) {
|
||||
logger.error('GET_AUTHORIZATION_URL_ERROR', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +1,58 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ baseUrl, basePath, error, res }) => {
|
||||
/** Renders an error page. */
|
||||
export default function error ({ baseUrl, basePath, error, res }) {
|
||||
const signinPageUrl = `${baseUrl}${basePath}/signin`
|
||||
|
||||
let statusCode = 200
|
||||
let heading = <h1>Error</h1>
|
||||
let message = <p><a className='site' href={baseUrl}>{baseUrl.replace(/^https?:\/\//, '')}</a></p>
|
||||
|
||||
switch (error) {
|
||||
case 'Signin':
|
||||
case 'OAuthSignin':
|
||||
case 'OAuthCallback':
|
||||
case 'OAuthCreateAccount':
|
||||
case 'EmailCreateAccount':
|
||||
case 'Callback':
|
||||
case 'OAuthAccountNotLinked':
|
||||
case 'EmailSignin':
|
||||
case 'CredentialsSignin':
|
||||
// These messages are displayed in line on the sign in page
|
||||
res.status(302).setHeader('Location', `${signinPageUrl}?error=${error}`)
|
||||
res.end()
|
||||
return false
|
||||
case 'Configuration':
|
||||
statusCode = 500
|
||||
heading = <h1>Server error</h1>
|
||||
message = (
|
||||
const errors = {
|
||||
default: {
|
||||
statusCode: 200,
|
||||
heading: 'Error',
|
||||
message: <p><a className='site' href={baseUrl}>{baseUrl.replace(/^https?:\/\//, '')}</a></p>
|
||||
},
|
||||
configuration: {
|
||||
statusCode: 500,
|
||||
heading: 'Server error',
|
||||
message: (
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>There is a problem with the server configuration.</p>
|
||||
<p>Check the server logs for more information.</p>
|
||||
</div>
|
||||
<p>There is a problem with the server configuration.</p>
|
||||
<p>Check the server logs for more information.</p>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'AccessDenied':
|
||||
statusCode = 403
|
||||
heading = <h1>Access Denied</h1>
|
||||
message = (
|
||||
},
|
||||
accessdenied: {
|
||||
statusCode: 403,
|
||||
heading: 'Access Denied',
|
||||
message: (
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>You do not have permission to sign in.</p>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'Verification':
|
||||
// @TODO Check if user is signed in already with the same email address.
|
||||
// If they are, no need to display this message, can just direct to callbackUrl
|
||||
statusCode = 403
|
||||
heading = <h1>Unable to sign in</h1>
|
||||
message = (
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>The sign in link is no longer valid.</p>
|
||||
<p>It may have be used already or it may have expired.</p>
|
||||
</div>
|
||||
<p>You do not have permission to sign in.</p>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
default:
|
||||
},
|
||||
verification: {
|
||||
statusCode: 403,
|
||||
heading: 'Unable to sign in',
|
||||
message: (
|
||||
<div>
|
||||
<p>The sign in link is no longer valid.</p>
|
||||
<p>It may have be used already or it may have expired.</p>
|
||||
</div>
|
||||
),
|
||||
signin: <p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
}
|
||||
}
|
||||
|
||||
const { statusCode, heading, message, signin } = errors[error.toLowerCase()] || errors.default
|
||||
|
||||
res.status(statusCode)
|
||||
|
||||
return render(
|
||||
<div className='error'>
|
||||
{heading}
|
||||
{message}
|
||||
<h1>{heading}</h1>
|
||||
<div className='message'>{message}</div>
|
||||
{signin}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,32 +4,19 @@ import verifyRequest from './verify-request'
|
||||
import error from './error'
|
||||
import css from '../../css'
|
||||
|
||||
function render (req, res, page, props, done) {
|
||||
let html = ''
|
||||
switch (page) {
|
||||
case 'signin':
|
||||
html = signin({ ...props, req })
|
||||
break
|
||||
case 'signout':
|
||||
html = signout(props)
|
||||
break
|
||||
case 'verify-request':
|
||||
html = verifyRequest(props)
|
||||
break
|
||||
case 'error':
|
||||
html = error({ ...props, res })
|
||||
if (html === false) return done()
|
||||
break
|
||||
default:
|
||||
html = error(props)
|
||||
return
|
||||
}
|
||||
/** Takes a request and response, and gives renderable pages */
|
||||
export default function renderPage (req, res) {
|
||||
const { baseUrl, basePath, callbackUrl, csrfToken, providers, theme } = req.options
|
||||
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.send(`<!DOCTYPE html><head><style type="text/css">${css()}</style><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div class="page">${html}</div></body></html>`)
|
||||
done()
|
||||
}
|
||||
function send (html) {
|
||||
res.send(`<!DOCTYPE html><head><style type="text/css">${css()}</style><meta name="viewport" content="width=device-width, initial-scale=1"></head><body class="__next-auth-theme-${theme}"><div class="page">${html}</div></body></html>`)
|
||||
}
|
||||
|
||||
export default {
|
||||
render
|
||||
return {
|
||||
signin (props) { send(signin({ csrfToken, providers, callbackUrl, ...req.query, ...props })) },
|
||||
signout (props) { send(signout({ csrfToken, baseUrl, basePath, ...props })) },
|
||||
verifyRequest (props) { send(verifyRequest({ baseUrl, ...props })) },
|
||||
error (props) { send(error({ basePath, baseUrl, res, ...props })) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ req, csrfToken, providers, callbackUrl }) => {
|
||||
const { email, error } = req.query
|
||||
|
||||
export default function signin ({ csrfToken, providers, callbackUrl, email, error: errorType }) {
|
||||
// We only want to render providers
|
||||
const providersToRender = providers.filter(provider => {
|
||||
if (provider.type === 'oauth' || provider.type === 'email') {
|
||||
@@ -12,43 +10,31 @@ export default ({ req, csrfToken, providers, callbackUrl }) => {
|
||||
} else if (provider.type === 'credentials' && provider.credentials) {
|
||||
// Only render credentials type provider if credentials are defined
|
||||
return true
|
||||
} else {
|
||||
// Don't render other provider types
|
||||
return false
|
||||
}
|
||||
// Don't render other provider types
|
||||
return false
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
if (error) {
|
||||
switch (error) {
|
||||
case 'Signin':
|
||||
case 'OAuthSignin':
|
||||
case 'OAuthCallback':
|
||||
case 'OAuthCreateAccount':
|
||||
case 'EmailCreateAccount':
|
||||
case 'Callback':
|
||||
errorMessage = <p>Try signing with a different account.</p>
|
||||
break
|
||||
case 'OAuthAccountNotLinked':
|
||||
errorMessage = <p>To confirm your identity, sign in with the same account you used originally.</p>
|
||||
break
|
||||
case 'EmailSignin':
|
||||
errorMessage = <p>Check your email address.</p>
|
||||
break
|
||||
case 'CredentialsSignin':
|
||||
errorMessage = <p>Sign in failed. Check the details you provided are correct.</p>
|
||||
break
|
||||
default:
|
||||
errorMessage = <p>Unable to sign in.</p>
|
||||
break
|
||||
}
|
||||
const errors = {
|
||||
Signin: 'Try signing with a different account.',
|
||||
OAuthSignin: 'Try signing with a different account.',
|
||||
OAuthCallback: 'Try signing with a different account.',
|
||||
OAuthCreateAccount: 'Try signing with a different account.',
|
||||
EmailCreateAccount: 'Try signing with a different account.',
|
||||
Callback: 'Try signing with a different account.',
|
||||
OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.',
|
||||
EmailSignin: 'Check your email address.',
|
||||
CredentialsSignin: 'Sign in failed. Check the details you provided are correct.',
|
||||
default: 'Unable to sign in.'
|
||||
}
|
||||
|
||||
const error = errorType && (errors[errorType] ?? errors.default)
|
||||
|
||||
return render(
|
||||
<div className='signin'>
|
||||
{errorMessage &&
|
||||
{error &&
|
||||
<div className='error'>
|
||||
{errorMessage}
|
||||
<p>{error}</p>
|
||||
</div>}
|
||||
{providersToRender.map((provider, i) =>
|
||||
<div key={provider.id} className='provider'>
|
||||
@@ -59,8 +45,8 @@ export default ({ req, csrfToken, providers, 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 />}
|
||||
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} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ baseUrl, basePath, csrfToken }) => {
|
||||
export default function signout ({ baseUrl, basePath, csrfToken }) {
|
||||
return render(
|
||||
<div className='signout'>
|
||||
<h1>Are you sure you want to sign out?</h1>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ baseUrl }) => {
|
||||
export default function verifyRequest ({ baseUrl }) {
|
||||
return render(
|
||||
<div className='verify-request'>
|
||||
<h1>Check your email</h1>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// Handle callbacks from login services
|
||||
import oAuthCallback from '../lib/oauth/callback'
|
||||
import callbackHandler from '../lib/callback-handler'
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
/** Handle callbacks from login services */
|
||||
export default async function callback (req, res) {
|
||||
const {
|
||||
provider: providerName,
|
||||
providers,
|
||||
provider,
|
||||
adapter,
|
||||
baseUrl,
|
||||
basePath,
|
||||
@@ -19,134 +18,132 @@ export default async (req, res, options, done) => {
|
||||
jwt,
|
||||
events,
|
||||
callbacks,
|
||||
csrfToken,
|
||||
redirect
|
||||
} = options
|
||||
const provider = providers[providerName]
|
||||
const { type } = provider
|
||||
const useJwtSession = options.session.jwt
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
session: {
|
||||
jwt: useJwtSession,
|
||||
maxAge: sessionMaxAge
|
||||
}
|
||||
} = req.options
|
||||
|
||||
// Get session ID (if set)
|
||||
const sessionToken = req.cookies ? req.cookies[cookies.sessionToken.name] : null
|
||||
const sessionToken = req.cookies?.[cookies.sessionToken.name] ?? null
|
||||
|
||||
if (type === 'oauth') {
|
||||
if (provider.type === 'oauth') {
|
||||
try {
|
||||
oAuthCallback(req, provider, csrfToken, async (error, profile, account, OAuthProfile) => {
|
||||
try {
|
||||
if (error) {
|
||||
logger.error('CALLBACK_OAUTH_ERROR', error)
|
||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
||||
}
|
||||
const { profile, account, OAuthProfile } = await oAuthCallback(req)
|
||||
try {
|
||||
// Make it easier to debug when adding a new provider
|
||||
logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile })
|
||||
|
||||
// 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 res.redirect(`${baseUrl}${basePath}/signin`)
|
||||
}
|
||||
|
||||
// If we don't have a profile object then either something went wrong
|
||||
// or the user cancelled signin in. We don't know which, so we just
|
||||
// direct the user to the signup 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(`${baseUrl}${basePath}/signin`)
|
||||
}
|
||||
|
||||
// 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 { getUserByProviderAccountId } = await adapter.getAdapter(options)
|
||||
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
|
||||
if (userFromProviderAccountId) {
|
||||
userOrProfile = userFromProviderAccountId
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
|
||||
if (signInCallbackResponse === false) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return redirect(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id.toString()
|
||||
}
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
|
||||
}
|
||||
|
||||
await dispatchEvent(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)}`)
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
return redirect(callbackUrl || baseUrl)
|
||||
} catch (error) {
|
||||
if (error.name === 'AccountNotLinkedError') {
|
||||
// If the email on the account is already linked, but nto with this oAuth account
|
||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
|
||||
} else if (error.name === 'CreateUserError') {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
|
||||
} else {
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
// 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 { getUserByProviderAccountId } = await adapter.getAdapter(req.options)
|
||||
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
|
||||
if (userFromProviderAccountId) {
|
||||
userOrProfile = userFromProviderAccountId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
|
||||
if (signInCallbackResponse === false) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
} else if (typeof signInCallbackResponse === 'string') {
|
||||
return res.redirect(signInCallbackResponse)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
// TODO: Remove in a future major release
|
||||
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString()
|
||||
}
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
|
||||
}
|
||||
|
||||
await dispatchEvent(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 res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
return res.redirect(callbackUrl || baseUrl)
|
||||
} catch (error) {
|
||||
if (error.name === 'AccountNotLinkedError') {
|
||||
// If the email on the account is already linked, but not with this OAuth account
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
|
||||
} else if (error.name === 'CreateUserError') {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
|
||||
}
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name === 'OAuthCallbackError') {
|
||||
logger.error('CALLBACK_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
||||
}
|
||||
logger.error('OAUTH_CALLBACK_ERROR', error)
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
}
|
||||
} else if (type === 'email') {
|
||||
} else if (provider.type === 'email') {
|
||||
try {
|
||||
if (!adapter) {
|
||||
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
|
||||
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(options)
|
||||
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(req.options)
|
||||
const verificationToken = req.query.token
|
||||
const email = req.query.email
|
||||
|
||||
// Verify email and verification token exist in database
|
||||
const invite = await getVerificationRequest(email, verificationToken, secret, provider)
|
||||
if (!invite) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Verification`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Verification`)
|
||||
}
|
||||
|
||||
// If verification token is valid, delete verification request token from
|
||||
@@ -161,25 +158,28 @@ export default async (req, res, options, done) => {
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
||||
if (signInCallbackResponse === false) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
} else if (typeof signInCallbackResponse === 'string') {
|
||||
return res.redirect(signInCallbackResponse)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return redirect(error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
// TODO: Remove in a future major release
|
||||
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id.toString()
|
||||
sub: user.id?.toString()
|
||||
}
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, profile, isNewUser)
|
||||
|
||||
@@ -202,32 +202,27 @@ export default async (req, res, options, done) => {
|
||||
// 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)}`)
|
||||
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
if (callbackUrl) {
|
||||
return redirect(callbackUrl)
|
||||
} else {
|
||||
return redirect(baseUrl)
|
||||
}
|
||||
return res.redirect(callbackUrl || baseUrl)
|
||||
} catch (error) {
|
||||
if (error.name === 'CreateUserError') {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
|
||||
} else {
|
||||
logger.error('CALLBACK_EMAIL_ERROR', error)
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
|
||||
}
|
||||
logger.error('CALLBACK_EMAIL_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
}
|
||||
} else if (type === 'credentials' && req.method === 'POST') {
|
||||
} else if (provider.type === 'credentials' && req.method === 'POST') {
|
||||
if (!useJwtSession) {
|
||||
logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled')
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
|
||||
if (!provider.authorize) {
|
||||
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
|
||||
const credentials = req.body
|
||||
@@ -236,14 +231,13 @@ export default async (req, res, options, done) => {
|
||||
try {
|
||||
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
|
||||
if (!userObjectReturnedFromAuthorizeHandler) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return redirect(error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
const user = userObjectReturnedFromAuthorizeHandler
|
||||
@@ -252,14 +246,13 @@ export default async (req, res, options, done) => {
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
|
||||
if (signInCallbackResponse === false) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return redirect(error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
const defaultJwtPayload = {
|
||||
@@ -280,9 +273,7 @@ export default async (req, res, options, done) => {
|
||||
|
||||
await dispatchEvent(events.signIn, { user, account })
|
||||
|
||||
return redirect(callbackUrl || baseUrl)
|
||||
} else {
|
||||
res.status(500).end(`Error: Callback for provider type ${type} not supported`)
|
||||
return done()
|
||||
return res.redirect(callbackUrl || baseUrl)
|
||||
}
|
||||
return res.status(500).end(`Error: Callback for provider type ${provider.type} not supported`)
|
||||
}
|
||||
|
||||
5
src/server/routes/index.js
Normal file
5
src/server/routes/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as callback } from './callback'
|
||||
export { default as signin } from './signin'
|
||||
export { default as signout } from './signout'
|
||||
export { default as session } from './session'
|
||||
export { default as providers } from './providers'
|
||||
@@ -1,21 +1,15 @@
|
||||
// Return a JSON object with a list of all outh 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 (req, res, options, done) => {
|
||||
const { providers } = options
|
||||
/**
|
||||
* 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 (req, res) {
|
||||
const { providers } = req.options
|
||||
|
||||
const result = {}
|
||||
Object.entries(providers).forEach(([provider, providerConfig]) => {
|
||||
result[provider] = {
|
||||
id: provider,
|
||||
name: providerConfig.name,
|
||||
type: providerConfig.type,
|
||||
signinUrl: providerConfig.signinUrl,
|
||||
callbackUrl: providerConfig.callbackUrl
|
||||
}
|
||||
})
|
||||
const result = providers.reduce((acc, { id, name, type, signinUrl, callbackUrl }) => {
|
||||
acc[id] = { id, name, type, signinUrl, callbackUrl }
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json(result)
|
||||
return done()
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// Return a session object (without any private fields) for Single Page App clients
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
const { cookies, adapter, jwt, events, callbacks } = options
|
||||
const useJwtSession = options.session.jwt
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
/**
|
||||
* Return a session object (without any private fields)
|
||||
* for Single Page App clients
|
||||
*/
|
||||
export default async function session (req, res) {
|
||||
const { cookies, adapter, jwt, events, callbacks } = req.options
|
||||
const useJwtSession = req.options.session.jwt
|
||||
const sessionMaxAge = req.options.session.maxAge
|
||||
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||
|
||||
if (!sessionToken) {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json({})
|
||||
return done()
|
||||
return res.json({})
|
||||
}
|
||||
|
||||
let response = {}
|
||||
@@ -58,7 +59,7 @@ export default async (req, res, options, done) => {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { getUser, getSession, updateSession } = await adapter.getAdapter(options)
|
||||
const { getUser, getSession, updateSession } = await adapter.getAdapter(req.options)
|
||||
const session = await getSession(sessionToken)
|
||||
if (session) {
|
||||
// Trigger update to session object to update session expiry
|
||||
@@ -98,7 +99,5 @@ export default async (req, res, options, done) => {
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json(response)
|
||||
return done()
|
||||
}
|
||||
|
||||
@@ -1,52 +1,42 @@
|
||||
// Handle requests to /api/auth/signin
|
||||
import oAuthSignin from '../lib/signin/oauth'
|
||||
import getAuthorizationUrl from '../lib/signin/oauth'
|
||||
import emailSignin from '../lib/signin/email'
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
/** Handle requests to /api/auth/signin */
|
||||
export default async function signin (req, res) {
|
||||
const {
|
||||
provider: providerName,
|
||||
providers,
|
||||
provider,
|
||||
baseUrl,
|
||||
basePath,
|
||||
adapter,
|
||||
callbacks,
|
||||
csrfToken,
|
||||
redirect
|
||||
} = options
|
||||
const provider = providers[providerName]
|
||||
const { type } = provider
|
||||
callbacks
|
||||
} = req.options
|
||||
|
||||
if (!type) {
|
||||
res.status(500).end(`Error: Type not specified for ${provider}`)
|
||||
return done()
|
||||
if (!provider.type) {
|
||||
return res.status(500).end(`Error: Type not specified for ${provider.name}`)
|
||||
}
|
||||
|
||||
if (type === 'oauth' && req.method === 'POST') {
|
||||
const authParams = { ...req.query }
|
||||
delete authParams.nextauth // This is probably not intended to be sent to the provider, remove
|
||||
|
||||
oAuthSignin(provider, csrfToken, (error, oAuthSigninUrl) => {
|
||||
if (error) {
|
||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
||||
}
|
||||
|
||||
return redirect(oAuthSigninUrl)
|
||||
}, authParams)
|
||||
} else if (type === 'email' && req.method === 'POST') {
|
||||
if (provider.type === 'oauth' && req.method === 'POST') {
|
||||
try {
|
||||
const authorizationUrl = await getAuthorizationUrl(req)
|
||||
return res.redirect(authorizationUrl)
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
||||
}
|
||||
} else if (provider.type === 'email' && req.method === 'POST') {
|
||||
if (!adapter) {
|
||||
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
const { getUserByEmail } = await adapter.getAdapter(options)
|
||||
const { getUserByEmail } = await adapter.getAdapter(req.options)
|
||||
|
||||
// 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 = req.body.email ? req.body.email.toLowerCase() : null
|
||||
const email = req.body.email?.toLowerCase() ?? null
|
||||
|
||||
// If is an existing user return a user object (otherwise use placeholder)
|
||||
const profile = await getUserByEmail(email) || { email }
|
||||
@@ -54,29 +44,31 @@ export default async (req, res, options, done) => {
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
try {
|
||||
const signinCallbackResponse = await callbacks.signIn(profile, account, { email, verificationRequest: true })
|
||||
if (signinCallbackResponse === false) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
||||
if (signInCallbackResponse === false) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
} else if (typeof signInCallbackResponse === 'string') {
|
||||
return res.redirect(signInCallbackResponse)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return redirect(error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
// TODO: Remove in a future major release
|
||||
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
try {
|
||||
await emailSignin(email, provider, options)
|
||||
await emailSignin(email, provider, req.options)
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_EMAIL_ERROR', error)
|
||||
return redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
|
||||
}
|
||||
|
||||
return redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
|
||||
return res.redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
|
||||
provider.id
|
||||
)}&type=${encodeURIComponent(provider.type)}`)
|
||||
} else {
|
||||
return redirect(`${baseUrl}${basePath}/signin`)
|
||||
}
|
||||
return res.redirect(`${baseUrl}${basePath}/signin`)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Handle requests to /api/auth/signout
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
const { adapter, cookies, events, jwt, callbackUrl, redirect } = options
|
||||
const useJwtSession = options.session.jwt
|
||||
/** Handle requests to /api/auth/signout */
|
||||
export default async function signout (req, res) {
|
||||
const { adapter, cookies, events, jwt, callbackUrl } = req.options
|
||||
const useJwtSession = req.options.session.jwt
|
||||
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||
|
||||
if (useJwtSession) {
|
||||
@@ -18,7 +18,7 @@ export default async (req, res, options, done) => {
|
||||
}
|
||||
} else {
|
||||
// Get session from database
|
||||
const { getSession, deleteSession } = await adapter.getAdapter(options)
|
||||
const { getSession, deleteSession } = await adapter.getAdapter(req.options)
|
||||
|
||||
try {
|
||||
// Dispatch signout event
|
||||
@@ -43,5 +43,5 @@ export default async (req, res, options, done) => {
|
||||
maxAge: 0
|
||||
})
|
||||
|
||||
return redirect(callbackUrl)
|
||||
return res.redirect(callbackUrl)
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ const options = {
|
||||
|
||||
// You can define your own encode/decode functions for signing and encryption
|
||||
// if you want to override the default behaviour.
|
||||
// encode: async ({ secret, token, maxAge }) => {},
|
||||
// decode: async ({ secret, token, maxAge }) => {},
|
||||
// async encode({ secret, token, maxAge }) {},
|
||||
// async decode({ secret, token, maxAge }) {},
|
||||
},
|
||||
|
||||
// You can define custom pages to override the built-in pages.
|
||||
@@ -101,10 +101,10 @@ const options = {
|
||||
// when an action is performed.
|
||||
// https://next-auth.js.org/configuration/callbacks
|
||||
callbacks: {
|
||||
// signIn: async (user, account, profile) => { return Promise.resolve(true) },
|
||||
// redirect: async (url, baseUrl) => { return Promise.resolve(baseUrl) },
|
||||
// session: async (session, user) => { return Promise.resolve(session) },
|
||||
// jwt: async (token, user, account, profile, isNewUser) => { return Promise.resolve(token) }
|
||||
// async signIn(user, account, profile) { return Promise.resolve(true) },
|
||||
// async redirect(url, baseUrl) { return Promise.resolve(baseUrl) },
|
||||
// async session(session, user) { return Promise.resolve(session) },
|
||||
// async jwt(token, user, account, profile, isNewUser) { return Promise.resolve(token) }
|
||||
},
|
||||
|
||||
// Events are useful for logging
|
||||
|
||||
@@ -33,11 +33,4 @@ services:
|
||||
file: databases/postgres.yml
|
||||
service: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
fauna:
|
||||
extends:
|
||||
file: databases/fauna.yml
|
||||
service: fauna
|
||||
ports:
|
||||
- 8443:8443
|
||||
- "5432:5432"
|
||||
@@ -1,7 +0,0 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
fauna:
|
||||
image: fauna/faunadb
|
||||
restart: always
|
||||
200
test/fauna.js
200
test/fauna.js
@@ -1,200 +0,0 @@
|
||||
/* eslint-disable */
|
||||
const Adapters = require('../adapters');
|
||||
const assert = require('assert');
|
||||
const fauna = require('faunadb');
|
||||
const q = fauna.query;
|
||||
|
||||
const adminClient = new fauna.Client({
|
||||
secret: 'secret',
|
||||
domain: 'localhost',
|
||||
port: '8443',
|
||||
scheme: 'http'
|
||||
});
|
||||
|
||||
// Authenticated client against the new DB used for tests
|
||||
let client = null;
|
||||
|
||||
const InitialiseDb = async () => {
|
||||
await adminClient.query(
|
||||
q.CreateDatabase({name: 'nextauth'})
|
||||
);
|
||||
|
||||
const key = await adminClient.query(
|
||||
q.CreateKey({
|
||||
database: q.Database('nextauth'),
|
||||
role: 'server'
|
||||
})
|
||||
);
|
||||
|
||||
client = new fauna.Client({
|
||||
secret: key.secret,
|
||||
domain: 'localhost',
|
||||
port: '8443',
|
||||
scheme: 'http'
|
||||
});
|
||||
|
||||
await client.query(q.CreateCollection({name: 'account'}));
|
||||
await client.query(q.CreateCollection({name: 'session'}));
|
||||
await client.query(q.CreateCollection({name: 'user'}));
|
||||
await client.query(q.CreateCollection({name: 'verification_request'}));
|
||||
|
||||
await client.query(q.CreateIndex({
|
||||
name: 'account_by_provider_account_id',
|
||||
source: q.Collection('account'),
|
||||
unique: true,
|
||||
terms: [
|
||||
{ field: ['data', 'providerId'] },
|
||||
{ field: ['data', 'providerAccountId'] }
|
||||
]
|
||||
}));
|
||||
|
||||
await client.query(q.CreateIndex({
|
||||
name: 'session_by_token',
|
||||
source: q.Collection('session'),
|
||||
unique: true,
|
||||
terms: [
|
||||
{ field: ['data', 'sessionToken'] }
|
||||
]
|
||||
}));
|
||||
|
||||
await client.query(q.CreateIndex({
|
||||
name: 'user_by_email',
|
||||
source: q.Collection('user'),
|
||||
unique: true,
|
||||
terms: [
|
||||
{ field: ['data', 'email'] }
|
||||
]
|
||||
}));
|
||||
|
||||
await client.query(q.CreateIndex({
|
||||
name: 'vertification_request_by_token',
|
||||
source: q.Collection('verification_request'),
|
||||
unique: true,
|
||||
terms: [
|
||||
{ field: ['data', 'token'] }
|
||||
]
|
||||
}));
|
||||
}
|
||||
|
||||
const RunTests = async (adapter) => {
|
||||
// createUser
|
||||
const newUserResult = await adapter.createUser({
|
||||
name: 'test user',
|
||||
email: 'user@name.test',
|
||||
image: 'https://www.gravatar.com/avatar/0'
|
||||
});
|
||||
|
||||
assert.strictEqual(newUserResult.name, 'test user');
|
||||
assert(newUserResult.createdAt !== null);
|
||||
|
||||
const userId = newUserResult.id;
|
||||
|
||||
// getUser
|
||||
const user = await adapter.getUser(newUserResult.id);
|
||||
assert.strictEqual(user.id, userId);
|
||||
|
||||
// getUserByEmail
|
||||
const userByEmaiil = await adapter.getUserByEmail('user@name.test');
|
||||
assert.strictEqual(userByEmaiil.id, userId);
|
||||
|
||||
// updateUser
|
||||
const update = {
|
||||
...user,
|
||||
name: 'updated name'
|
||||
};
|
||||
const updatedUser = await adapter.updateUser(update);
|
||||
assert.strictEqual(updatedUser.name, 'updated name');
|
||||
assert.strictEqual(updatedUser.id, userId);
|
||||
|
||||
// linkAccount
|
||||
const account = await adapter.linkAccount(
|
||||
userId,
|
||||
'github',
|
||||
'oauth',
|
||||
756832,
|
||||
undefined,
|
||||
'b7e3b00f2c596abc445f11abc445f1104c1b2b',
|
||||
null
|
||||
);
|
||||
assert.strictEqual(account.userId, userId);
|
||||
assert.strictEqual(account.providerId, 'github');
|
||||
assert(account.createdAt !== null);
|
||||
|
||||
// getUserByProviderAccountId
|
||||
const userByProviderAccountId = await adapter.getUserByProviderAccountId('github', 756832);
|
||||
assert.strictEqual(userByProviderAccountId.email, user.email);
|
||||
|
||||
// createSession
|
||||
const newSession = await adapter.createSession(user);
|
||||
assert(newSession.sessionToken !== null);
|
||||
assert(newSession.createdAt !== null);
|
||||
assert(newSession.expires !== null);
|
||||
|
||||
// getSession
|
||||
const session = await adapter.getSession(newSession.sessionToken);
|
||||
assert.strictEqual(session.sessionToken, newSession.sessionToken);
|
||||
|
||||
// updateSession
|
||||
const updatedSession = await adapter.updateSession(session);
|
||||
assert(updatedSession.expires !== session.expires);
|
||||
|
||||
// deleteSession
|
||||
await adapter.deleteSession(session.sessionToken);
|
||||
|
||||
// unlinkAccount
|
||||
await adapter.unlinkAccount(userId, 'github', 756832);
|
||||
|
||||
// deleteUser
|
||||
await adapter.deleteUser(userId);
|
||||
|
||||
// createVerificationRequest
|
||||
let requestSent = false;
|
||||
const newVerificationRequest = await adapter.createVerificationRequest(
|
||||
'user@test.test',
|
||||
'http://localhost/callback/email?email=test@test.test&token=123',
|
||||
'123',
|
||||
'abc',
|
||||
{
|
||||
sendVerificationRequest: ({}) => {
|
||||
requestSent = true;
|
||||
}
|
||||
}
|
||||
);
|
||||
assert.strictEqual(newVerificationRequest.identifier, 'user@test.test');
|
||||
assert(newVerificationRequest.token !== null && newVerificationRequest.token !== '');
|
||||
assert(requestSent === true);
|
||||
|
||||
// getVerificationRequest
|
||||
const verificationRequest = await adapter.getVerificationRequest('user@test.test', '123', 'abc');
|
||||
assert.strictEqual(verificationRequest.identifier, 'user@test.test');
|
||||
assert.strictEqual(verificationRequest.token, newVerificationRequest.token);
|
||||
|
||||
// deleteVerificationRequest
|
||||
await adapter.deleteVerificationRequest('user@test.test', '123', 'abc');
|
||||
}
|
||||
|
||||
;(async () => {
|
||||
let error = false;
|
||||
|
||||
try {
|
||||
// Initialise collections and create indexes
|
||||
await InitialiseDb();
|
||||
|
||||
const adapterFactory = Adapters.Fauna.Adapter({faunaClient: client});
|
||||
const adapter = await adapterFactory.getAdapter({baseUrl: 'http://localhost'});
|
||||
|
||||
await RunTests(adapter);
|
||||
console.log('FaunaDB loaded ok');
|
||||
} catch (error) {
|
||||
console.error('FaunaDB error', error);
|
||||
error = true;
|
||||
} finally {
|
||||
// Clean up the DB
|
||||
await adminClient.query(
|
||||
q.Delete(q.Database('nextauth'))
|
||||
);
|
||||
}
|
||||
|
||||
const retCode = error ? 1 : 0;
|
||||
process.exit(retCode);
|
||||
})();
|
||||
@@ -3,7 +3,7 @@ id: callbacks
|
||||
title: Callbacks
|
||||
---
|
||||
|
||||
Callbacks are asynchronous functions you can use to control what happens when an action is performed.
|
||||
Callbacks are **asynchronous** functions you can use to control what happens when an action is performed.
|
||||
|
||||
Callbacks are extremely powerful, especially in scenarios involving JSON Web Tokens as they allow you to implement access controls without a database and to integrate with external databases or APIs.
|
||||
|
||||
@@ -16,17 +16,17 @@ You can specify a handler for any of the callbacks below.
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
...
|
||||
callbacks: {
|
||||
signIn: async (user, account, profile) => {
|
||||
return Promise.resolve(true)
|
||||
async signIn(user, account, profile) {
|
||||
return true
|
||||
},
|
||||
redirect: async (url, baseUrl) => {
|
||||
return Promise.resolve(baseUrl)
|
||||
async redirect(url, baseUrl) {
|
||||
return baseUrl
|
||||
},
|
||||
session: async (session, user) => {
|
||||
return Promise.resolve(session)
|
||||
async session(session, user) {
|
||||
return session
|
||||
},
|
||||
jwt: async (token, user, account, profile, isNewUser) => {
|
||||
return Promise.resolve(token)
|
||||
async jwt(token, user, account, profile, isNewUser) {
|
||||
return token
|
||||
}
|
||||
...
|
||||
}
|
||||
@@ -39,27 +39,29 @@ The documentation below shows how to implement each callback, their default beha
|
||||
Use the `signIn()` callback to control if a user is allowed to sign in.
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
...
|
||||
callbacks: {
|
||||
/**
|
||||
* @param {object} user User object
|
||||
* @param {object} account Provider account
|
||||
* @param {object} profile Provider profile
|
||||
* @return {boolean} Return `true` (or a modified JWT) to allow sign in
|
||||
* @return {boolean|string} Return `true` to allow sign in
|
||||
* Return `false` to deny access
|
||||
* Return `string` to redirect to (eg.: "/unauthorized")
|
||||
*/
|
||||
signIn: async (user, account, profile) => {
|
||||
async signIn(user, account, profile) {
|
||||
const isAllowedToSignIn = true
|
||||
if (isAllowedToSignIn) {
|
||||
return Promise.resolve(true)
|
||||
return true
|
||||
} else {
|
||||
// Return false to display a default error message
|
||||
return Promise.resolve(false)
|
||||
// You can also Reject this callback with an Error or with a URL:
|
||||
// return Promise.reject(new Error('error message')) // Redirect to error page
|
||||
// return Promise.reject('/path/to/redirect') // Redirect to a URL
|
||||
return false
|
||||
// Or you can return a URL to redirect to:
|
||||
// return '/unauthorized'
|
||||
}
|
||||
}
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
* When using the **Email Provider** the `signIn()` callback is triggered both when the user makes a **Verification Request** (before they are sent email with a link that will allow them to sign in) and again *after* they activate the link in the sign in email.
|
||||
@@ -89,18 +91,20 @@ The redirect callback is called anytime the user is redirected to a callback URL
|
||||
By default only URLs on the same URL as the site are allowed, you can use the redirect callback to customise that behaviour.
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
...
|
||||
callbacks: {
|
||||
/**
|
||||
* @param {string} url URL provided as callback URL by the client
|
||||
* @param {string} baseUrl Default base URL of site (can be used as fallback)
|
||||
* @return {string} URL the client will be redirect to
|
||||
*/
|
||||
redirect: async (url, baseUrl) => {
|
||||
async redirect(url, baseUrl) {
|
||||
return url.startsWith(baseUrl)
|
||||
? Promise.resolve(url)
|
||||
: Promise.resolve(baseUrl)
|
||||
? url
|
||||
: baseUrl
|
||||
}
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
|
||||
@@ -118,23 +122,32 @@ e.g. `getSession()`, `useSession()`, `/api/auth/session`
|
||||
* When using JSON Web Tokens for sessions, the JWT payload is provided instead.
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
...
|
||||
callbacks: {
|
||||
/**
|
||||
* @param {object} session Session object
|
||||
* @param {object} user User object (if using database sessions)
|
||||
* @param {object} token User object (if using database sessions)
|
||||
* JSON Web Token (if not using database sessions)
|
||||
* @return {object} Session that will be returned to the client
|
||||
*/
|
||||
session: async (session, user) => {
|
||||
session.foo = 'bar' // Add property to session
|
||||
return Promise.resolve(session)
|
||||
async session(session, token) {
|
||||
if(token?.accessToken) {
|
||||
// Add property to session, like an access_token from a provider
|
||||
session.accessToken = token.accessToken
|
||||
}
|
||||
return session
|
||||
}
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
:::tip
|
||||
When using JSON Web Tokens the `jwt()` callback is invoked before the `session()` callback, so anything you add to the
|
||||
JSON Web Token will be immediately available in the session callback.
|
||||
JSON Web Token will be immediately available in the session callback, like for example an `access_token` from a provider.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
To better represent its value, when using a JWT session, the second parameter should be called `token` (This is the same thing you return from the `jwt` callback). If you use a database, call it `user`.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
@@ -158,6 +171,7 @@ e.g. `/api/auth/signin`, `getSession()`, `useSession()`, `/api/auth/session`
|
||||
The contents *user*, *account*, *profile* and *isNewUser* will vary depending on the provider and on if you are using a database or not. If you want to pass data such as User ID, OAuth Access Token, etc. to the browser, you can persist it in the token and use the `session()` callback to return it.
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
...
|
||||
callbacks: {
|
||||
/**
|
||||
* @param {object} token Decrypted JSON Web Token
|
||||
@@ -167,15 +181,26 @@ callbacks: {
|
||||
* @param {boolean} isNewUser True if new user (only available on sign in)
|
||||
* @return {object} JSON Web Token that will be saved
|
||||
*/
|
||||
jwt: async (token, user, account, profile, isNewUser) => {
|
||||
const isSignIn = (user) ? true : false
|
||||
// Add auth_time to token on signin in
|
||||
if (isSignIn) { token.auth_time = Math.floor(Date.now() / 1000) }
|
||||
return Promise.resolve(token)
|
||||
async jwt(token, user, account, profile, isNewUser) {
|
||||
// Add access_token to the token right after signin
|
||||
if (account?.accessToken) {
|
||||
token.accessToken = account.accessToken
|
||||
}
|
||||
return token
|
||||
}
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
:::tip
|
||||
Use an if branch in jwt with checking for existence of any other params than token. If any of those exist, you call jwt for the first time.
|
||||
This is a good place to add for example an `access_token` to your jwt, if you want to.
|
||||
:::
|
||||
|
||||
:::tip
|
||||
Check out the content of all the params in addition `token`, to see what info you have available on signin.
|
||||
:::
|
||||
|
||||
:::warning
|
||||
NextAuth.js does not limit how much data you can store in a JSON Web Token, however a ~**4096 byte limit** for all cookies on a domain is commonly imposed by browsers.
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ You can specify a handler for any of these events below, for debugging or for an
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
...
|
||||
events: {
|
||||
signIn: async (message) => { /* on successful sign in */ },
|
||||
signOut: async (message) => { /* on signout */ },
|
||||
createUser: async (message) => { /* user created */ },
|
||||
linkAccount: async (message) => { /* account linked to a user */ },
|
||||
session: async (message) => { /* session is active */ },
|
||||
error: async (message) => { /* error in authentication flow */ }
|
||||
async signIn(message) { /* on successful sign in */ },
|
||||
async signOut(message) { /* on signout */ },
|
||||
async createUser(message) { /* user created */ },
|
||||
async linkAccount(message) { /* account linked to a user */ },
|
||||
async session(message) { /* session is active */ },
|
||||
async error(message) { /* error in authentication flow */ }
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
@@ -120,8 +120,8 @@ jwt: {
|
||||
|
||||
// You can define your own encode/decode functions for signing and encryption
|
||||
// if you want to override the default behaviour.
|
||||
// encode: async ({ secret, token, maxAge }) => {},
|
||||
// decode: async ({ secret, token, maxAge }) => {},
|
||||
// async encode({ secret, token, maxAge }) {},
|
||||
// async decode({ secret, token, maxAge }) {},
|
||||
}
|
||||
```
|
||||
|
||||
@@ -132,8 +132,8 @@ An example JSON Web Token contains a payload like this:
|
||||
name: 'Iain Collins',
|
||||
email: 'me@iaincollins.com',
|
||||
picture: 'https://example.com/image.jpg',
|
||||
"iat": 1594601838,
|
||||
"exp": 1597193838
|
||||
iat: 1594601838,
|
||||
exp: 1597193838
|
||||
}
|
||||
```
|
||||
|
||||
@@ -230,17 +230,17 @@ You can specify a handler for any of the callbacks below.
|
||||
|
||||
```js
|
||||
callbacks: {
|
||||
signIn: async (user, account, profile) => {
|
||||
return Promise.resolve(true)
|
||||
async signIn(user, account, profile) {
|
||||
return true
|
||||
},
|
||||
redirect: async (url, baseUrl) => {
|
||||
return Promise.resolve(baseUrl)
|
||||
async redirect(url, baseUrl) {
|
||||
return baseUrl
|
||||
},
|
||||
session: async (session, user) => {
|
||||
return Promise.resolve(session)
|
||||
async session(session, user) {
|
||||
return session
|
||||
},
|
||||
jwt: async (token, user, account, profile, isNewUser) => {
|
||||
return Promise.resolve(token)
|
||||
async jwt(token, user, account, profile, isNewUser) {
|
||||
return token
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -264,15 +264,17 @@ The content of the message object varies depending on the flow (e.g. OAuth or Em
|
||||
|
||||
```js
|
||||
events: {
|
||||
signIn: async (message) => { /* on successful sign in */ },
|
||||
signOut: async (message) => { /* on signout */ },
|
||||
createUser: async (message) => { /* user created */ },
|
||||
linkAccount: async (message) => { /* account linked to a user */ },
|
||||
session: async (message) => { /* session is active */ },
|
||||
error: async (message) => { /* error in authentication flow */ }
|
||||
async signIn(message) { /* on successful sign in */ },
|
||||
async signOut(message) { /* on signout */ },
|
||||
async createUser(message) { /* user created */ },
|
||||
async linkAccount(message) { /* account linked to a user */ },
|
||||
async session(message) { /* session is active */ },
|
||||
async error(message) { /* error in authentication flow */ }
|
||||
}
|
||||
```
|
||||
|
||||
See the [events documentation](/configuration/events) for more information on how to use the events functions.
|
||||
|
||||
---
|
||||
|
||||
### adapter
|
||||
@@ -305,6 +307,17 @@ Set debug to `true` to enable debug messages for authentication and database ope
|
||||
|
||||
---
|
||||
|
||||
### theme
|
||||
|
||||
* **Default value**: `"auto"`
|
||||
* **Required**: *No*
|
||||
|
||||
#### Description
|
||||
|
||||
Changes the theme of [pages](/configuration/pages). Set to `"light"`, if you want to force pages to always be light. Set to `"dark"`, if you want to force pages to always be dark. Set to `"auto"`, (or leave this option out) if you want the pages to follow the preferred system theme. (Uses the [prefers-color-scheme](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) media query.)
|
||||
|
||||
---
|
||||
|
||||
## Advanced Options
|
||||
|
||||
Advanced options are passed the same way as basic options, but may have complex implications or side effects. You should try to avoid using advanced options unless you are very comfortable using them.
|
||||
@@ -382,6 +395,15 @@ cookies: {
|
||||
path: '/',
|
||||
secure: true
|
||||
}
|
||||
},
|
||||
pkceCodeVerifier: {
|
||||
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -28,7 +28,6 @@ To add a custom login page, for example. You can use the `pages` option:
|
||||
In order to get the available authentication providers and the URLs to use for them, you can make a request to the API endpoint `/api/auth/providers`:
|
||||
|
||||
```jsx title="pages/auth/signin.js"
|
||||
import React from 'react'
|
||||
import { providers, signIn } from 'next-auth/client'
|
||||
|
||||
export default function SignIn({ providers }) {
|
||||
@@ -55,7 +54,6 @@ SignIn.getInitialProps = async (context) => {
|
||||
If you create a custom sign in form for email sign in, you will need to submit both fields for the **email** address and **csrfToken** from **/api/auth/csrf** in a POST request to **/api/auth/signin/email**.
|
||||
|
||||
```jsx title="pages/auth/email-signin.js"
|
||||
import React from 'react'
|
||||
import { csrfToken } from 'next-auth/client'
|
||||
|
||||
export default function SignIn({ csrfToken }) {
|
||||
@@ -89,7 +87,6 @@ signIn('email', { email: 'jsmith@example.com' })
|
||||
If you create a sign in form for credentials based authentication, you will need to pass a **csrfToken** from **/api/auth/csrf** in a POST request to **/api/auth/callback/credentials**.
|
||||
|
||||
```jsx title="pages/auth/credentials-signin.js"
|
||||
import React from 'react'
|
||||
import { csrfToken } from 'next-auth/client'
|
||||
|
||||
export default function SignIn({ csrfToken }) {
|
||||
|
||||
@@ -11,32 +11,14 @@ NextAuth.js is designed to work with any OAuth service, it supports OAuth 1.0, 1
|
||||
|
||||
### Built-in OAuth providers
|
||||
|
||||
* [Apple](/providers/apple)
|
||||
* [Atlassian](/providers/atlassian)
|
||||
* [Auth0](/providers/auth0)
|
||||
* [Azure Active Directory B2C](/providers/azure-ad-b2c)
|
||||
* [Basecamp](/providers/basecamp)
|
||||
* [Battle.net](/providers/battle.net)
|
||||
* [Box](/providers/box)
|
||||
* [Amazon Cognito](/providers/cognito)
|
||||
* [Discord](/providers/discord)
|
||||
* [Facebook](/providers/facebook)
|
||||
* [Foursquare](/providers/foursquare)
|
||||
* [FusionAuth](/providers/fusionauth)
|
||||
* [GitHub](/providers/github)
|
||||
* [GitLab](/providers/gitlab)
|
||||
* [Google](/providers/google)
|
||||
* [IdentityServer4](/providers/identity-server4)
|
||||
* [LinkedIn](/providers/LinkedIn)
|
||||
* [Mixer](/providers/Mixer)
|
||||
* [Netlify](/providers/Netlify)
|
||||
* [Okta](/providers/Okta)
|
||||
* [Slack](/providers/slack)
|
||||
* [Spotify](/providers/spotify)
|
||||
* [Strava](/providers/strava)
|
||||
* [Twitch](/providers/Twitch)
|
||||
* [Twitter](/providers/twitter)
|
||||
* [Yandex](/providers/yandex)
|
||||
<ul>
|
||||
{Object.entries(require("../../providers.json"))
|
||||
.filter(([key]) => !["email", "credentials"].includes(key))
|
||||
.sort(([, a], [, b]) => a.localeCompare(b))
|
||||
.map(([key, name]) =>
|
||||
<li key={key}><a href={`/providers/${key}`}>{name}</a></li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
### Using a built-in OAuth provider
|
||||
|
||||
@@ -60,6 +42,7 @@ NextAuth.js is designed to work with any OAuth service, it supports OAuth 1.0, 1
|
||||
4. Now you can add the provider settings to the NextAuth options object. You can add as many OAuth providers as you like, as you can see `providers` is an array.
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
import Providers from `next-auth/providers`
|
||||
...
|
||||
providers: [
|
||||
Providers.Twitter({
|
||||
@@ -83,19 +66,19 @@ You can use an OAuth provider that isn't built-in by using a custom object.
|
||||
|
||||
As an example of what this looks like, this is the the provider object returned for the Google provider:
|
||||
|
||||
```json
|
||||
```js
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://accounts.google.com/o/oauth2/token',
|
||||
requestTokenUrl: 'https://accounts.google.com/o/oauth2/auth',
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/auth?response_type=code',
|
||||
profileUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
|
||||
profile: (profile) => {
|
||||
id: "google",
|
||||
name: "Google",
|
||||
type: "oauth",
|
||||
version: "2.0",
|
||||
scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
|
||||
params: { grant_type: "authorization_code" },
|
||||
accessTokenUrl: "https://accounts.google.com/o/oauth2/token",
|
||||
requestTokenUrl: "https://accounts.google.com/o/oauth2/auth",
|
||||
authorizationUrl: "https://accounts.google.com/o/oauth2/auth?response_type=code",
|
||||
profileUrl: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
|
||||
async profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
@@ -103,13 +86,14 @@ As an example of what this looks like, this is the the provider object returned
|
||||
image: profile.picture
|
||||
}
|
||||
},
|
||||
clientId: '',
|
||||
clientSecret: ''
|
||||
clientId: "",
|
||||
clientSecret: ""
|
||||
}
|
||||
```
|
||||
You can replace all the options in this JSON object with the ones from your custom provider – be sure to give it a unique ID and specify the correct OAuth version - and add it to the providers option:
|
||||
You can replace all the options in this JSON object with the ones from your custom provider - be sure to give it a unique ID and specify the correct OAuth version - and add it to the providers option:
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
import Providers from `next-auth/providers`
|
||||
...
|
||||
providers: [
|
||||
Providers.Twitter({
|
||||
@@ -130,28 +114,26 @@ providers: [
|
||||
|
||||
### OAuth provider options
|
||||
|
||||
| Name | Description | Required |
|
||||
| :--------------: | :-------------------------------------------------: | :------: |
|
||||
| id | Unique ID for the provider | Yes |
|
||||
| name | Descriptive name for the provider | Yes |
|
||||
| type | Type of provider, in this case it should be `oauth` | Yes |
|
||||
| version | OAuth version (e.g. '1.0', '1.0a', '2.0') | Yes |
|
||||
| scope | OAuth access scopes (expects array or string) | No |
|
||||
| params | Additional authorization URL parameters | No |
|
||||
| accessTokenUrl | Endpoint to retrieve an access token | Yes |
|
||||
| accessTokenGetter | Default `(json) => json.access_token` | No |
|
||||
| requestTokenUrl | Endpoint to retrieve a request token | No |
|
||||
| authorizationUrl | Endpoint to request authorization from the user | Yes |
|
||||
| profileUrl | Endpoint to retrieve the user's profile | No |
|
||||
| profile | An object with the user's info | No |
|
||||
| clientId | Client ID of the OAuth provider | Yes |
|
||||
| clientSecret | Client Secret of the OAuth provider | No |
|
||||
| idToken | Set to `true` for services that use ID Tokens (e.g. OpenID) | No |
|
||||
| state | Set to `false` for services that do not support `state` verfication | No |
|
||||
|
||||
:::note
|
||||
Feel free to open a PR for your custom configuration if you've created one for a provider that others may be interested in so we can add it to the list of built-in OAuth providers!
|
||||
:::
|
||||
| Name | Description | Type | Required |
|
||||
| :-----------------: | :--------------------------------------------------------------: | :-----------------------------: | :------: |
|
||||
| id | Unique ID for the provider | `string` | Yes |
|
||||
| name | Descriptive name for the provider | `string` | Yes |
|
||||
| type | Type of provider, in this case it should be `oauth` | `oauth`, `email`, `credentials` | Yes |
|
||||
| version | OAuth version (e.g. '1.0', '1.0a', '2.0') | `string` | Yes |
|
||||
| accessTokenUrl | Endpoint to retrieve an access token | `string` | Yes |
|
||||
| authorizationUrl | Endpoint to request authorization from the user | `string` | Yes |
|
||||
| clientId | Client ID of the OAuth provider | `string` | Yes |
|
||||
| clientSecret | Client Secret of the OAuth provider | `string` | No |
|
||||
| scope | OAuth access scopes (expects array or string) | `string` or `string[]` | No |
|
||||
| params | Additional authorization URL parameters | `object` | No |
|
||||
| requestTokenUrl | Endpoint to retrieve a request token | `string` | No |
|
||||
| authorizationParams | Additional params to be sent to the authorization endpoint | `object` | No |
|
||||
| profileUrl | Endpoint to retrieve the user's profile | `string` | No |
|
||||
| profile | An callback returning an object with the user's info | `object` | No |
|
||||
| idToken | Set to `true` for services that use ID Tokens (e.g. OpenID) | `boolean` | No |
|
||||
| headers | Any headers that should be sent to the OAuth provider | `object` | No |
|
||||
| protection | Additional security for OAuth login flows (defaults to `state`) | `pkce`, `state`, `none` | No |
|
||||
| state | Same as `protection: "state"`. Being deprecated, use protection. | `boolean` | No |
|
||||
|
||||
## Sign in with Email
|
||||
|
||||
@@ -162,6 +144,8 @@ Adding support for signing in via email in addition to one or more OAuth service
|
||||
Configuration is similar to other providers, but the options are different:
|
||||
|
||||
```js title="pages/api/auth/[...nextauth].js"
|
||||
import Providers from `next-auth/providers`
|
||||
...
|
||||
providers: [
|
||||
Providers.Email({
|
||||
server: process.env.EMAIL_SERVER,
|
||||
@@ -169,6 +153,7 @@ providers: [
|
||||
// maxAge: 24 * 60 * 60, // How long email links are valid for (default 24h)
|
||||
}),
|
||||
],
|
||||
...
|
||||
```
|
||||
|
||||
See the [Email provider documentation](/providers/email) for more information on how to configure email sign in.
|
||||
@@ -198,7 +183,7 @@ providers: [
|
||||
username: { label: "Username", type: "text", placeholder: "jsmith" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
authorize: async (credentials) => {
|
||||
async authorize(credentials) {
|
||||
const user = (credentials) => {
|
||||
// You need to provide your own logic here that takes the credentials
|
||||
// submitted and returns either a object representing a user or value
|
||||
@@ -208,13 +193,13 @@ providers: [
|
||||
}
|
||||
if (user) {
|
||||
// Any user object returned here will be saved in the JSON Web Token
|
||||
return Promise.resolve(user)
|
||||
return user
|
||||
} else {
|
||||
return Promise.resolve(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
...
|
||||
```
|
||||
|
||||
@@ -225,7 +210,7 @@ The Credentials provider can only be used if JSON Web Tokens are enabled for ses
|
||||
:::
|
||||
|
||||
<!-- React Image Component -->
|
||||
export const Image = ({ children, src, alt = '' }) => (
|
||||
export const Image = ({ children, src, alt = '' }) => (
|
||||
<div
|
||||
style={{
|
||||
padding: '0.2rem',
|
||||
|
||||
@@ -20,7 +20,7 @@ _Special thanks to Lori Karikari for creating most of the providers, to Nico Dom
|
||||
|
||||
NextAuth.js as it exists today has been possible thanks to the work of many individual contributors.
|
||||
|
||||
Thank you to the [dozens of individual contributors](https://github.com/iaincollins/next-auth/graphs/contributors) who have help shaped NextAuth.js.
|
||||
Thank you to the [dozens of individual contributors](https://github.com/nextauthjs/next-auth/graphs/contributors) who have help shaped NextAuth.js.
|
||||
|
||||
## History
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ These errors are displayed on the terminal.
|
||||
|
||||
### Signin / Callback
|
||||
|
||||
#### GET_AUTHORISATION_URL_ERROR
|
||||
#### GET_AUTHORIZATION_URL_ERROR
|
||||
|
||||
#### SIGNIN_OAUTH_ERROR
|
||||
|
||||
@@ -71,6 +71,12 @@ In _most cases_ it does not make sense to specify a database in NextAuth.js opti
|
||||
|
||||
#### CALLBACK_CREDENTIALS_HANDLER_ERROR
|
||||
|
||||
#### PKCE_ERROR
|
||||
|
||||
The provider you tried to use failed when setting [PKCE or Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636#section-4.2).
|
||||
The `code_verifier` is saved in a cookie called (by default) `__Secure-next-auth.pkce.code_verifier` which expires after 15 minutes.
|
||||
Check if `cookies.pkceCodeVerifier` is configured correctly. The default `code_challenge_method` is `"S256"`. This is currently not configurable to `"plain"`, as it is not recommended, and in most cases it is only supported for backward compatibility.
|
||||
|
||||
---
|
||||
|
||||
### Session Handling
|
||||
@@ -133,4 +139,4 @@ Check your mail server configuration.
|
||||
|
||||
This error happens when `[...nextauth].js` file is not found inside `pages/api/auth`.
|
||||
|
||||
Make sure the file is there and the filename is written correctly.
|
||||
Make sure the file is there and the filename is written correctly.
|
||||
@@ -17,13 +17,18 @@ It is not commercial software and is not associated with a commercial organizati
|
||||
|
||||
### What databases does NextAuth.js support?
|
||||
|
||||
You can use NextAuth.js with MySQL, MariaDB, Postgres, MongoDB and SQLite or without a database.
|
||||
You can use NextAuth.js with MySQL, MariaDB, Postgres, MongoDB and SQLite or without a database. (See also: [Databases](/configuration/databases))
|
||||
|
||||
You can use also NextAuth.js with any database using a custom database adapter, or by using a custom credentials authentication provider - e.g. to support signing in with a username and password stored in an existing database.
|
||||
|
||||
### What authentication services does NextAuth.js support?
|
||||
|
||||
NextAuth.js includes built-in support for signing in with Apple, Atlassian, Auth0, Azure Active Directory B2C, Google, Battle.net, Box, AWS Cognito, Discord, Facebook, Foursquare, FusionAuth, GitHub, GitLab, Google, Open ID Identity Server, Mixer, Netlify, Okta, Slack, Spotify, Strava, Twitch, Twitter and Yandex.
|
||||
|
||||
<p>NextAuth.js includes built-in support for signing in with
|
||||
{Object.values(require("../providers.json")).sort().join(", ")}.
|
||||
(See also: <a href="/configuration/providers">Providers</a>)
|
||||
</p>
|
||||
|
||||
|
||||
NextAuth.js also supports email for passwordless sign in, which is useful for account recovery or for people who are not able to use an account with the configured OAuth services (e.g. due to service outage, account suspension or otherwise becoming locked out of an account).
|
||||
|
||||
@@ -43,7 +48,7 @@ NextAuth.js is designed for use with Next.js and Serverless.
|
||||
|
||||
If you are using a different framework for you website, you can create a website that handles sign in with Next.js and then access those sessions on a website that does not use Next.js as long as the websites are on the same domain.
|
||||
|
||||
If use NextAuth.js on a website with a different subdomain the rest of your website (e.g. `auth.example.com` vs `www.example.com`) you will need to set a custom cookie domain policy for the Session Token cookie.
|
||||
If you use NextAuth.js on a website with a different subdomain then the rest of your website (e.g. `auth.example.com` vs `www.example.com`) you will need to set a custom cookie domain policy for the Session Token cookie. (See also: [Cookies](/configuration/options#cookies))
|
||||
|
||||
NextAuth.js does not currently support automatically signing into sites on different top level domains (e.g. `www.example.com` vs `www.example.org`) using a single session.
|
||||
|
||||
@@ -111,7 +116,7 @@ NextAuth.js records Refresh Tokens and Access Tokens on sign in (if supplied by
|
||||
|
||||
You can then look them up from the database or persist them to the JSON Web Token.
|
||||
|
||||
Note: NextAuth.js does not current handle Access Token rotation for OAuth providers for you, if this is something you need, currently you will need to write the logic to handle that yourself.
|
||||
Note: NextAuth.js does not currently handle Access Token rotation for OAuth providers for you, if this is something you need, currently you will need to write the logic to handle that yourself.
|
||||
|
||||
### When I sign in with another account with the same email address, why are accounts not linked automatically?
|
||||
|
||||
@@ -123,7 +128,7 @@ With automatic account linking on sign in, this can be exploited by bad actors t
|
||||
|
||||
For this reason it is not secure to automatically link accounts between abitrary providers on sign in, which is why this feature is generally not provided by authentication service and is not provided by NextAuth.js.
|
||||
|
||||
Automatic acccount linking is seen on some sites, sometimes insecurely. It can be technically possible to do automatic account linking securely if you trust all the providers involved to ensure they have securely verified the email address associated with the account, but requires placing trust (and transferring the risk) to those providers to handle the process securely.
|
||||
Automatic account linking is seen on some sites, sometimes insecurely. It can be technically possible to do automatic account linking securely if you trust all the providers involved to ensure they have securely verified the email address associated with the account, but requires placing trust (and transferring the risk) to those providers to handle the process securely.
|
||||
|
||||
Examples of scenarios where this is secure include with an OAuth provider you control (e.g. that only authorizes users internal to your organization) or with a provider you explicitly trust to have verified the users email address.
|
||||
|
||||
@@ -137,11 +142,11 @@ Providing support for secure account linking and unlinking of additional provide
|
||||
|
||||
### Why doesn't NextAuth.js support [a particular feature]?
|
||||
|
||||
NextAuth.js is an open source project built by individual contributors who are volunteers who writing code and providing support to help folks out in their spare time.
|
||||
NextAuth.js is an open source project built by individual contributors who are volunteers writing code and providing support in their spare time.
|
||||
|
||||
If you would like NextAuth.js to support a particular feature the best way to help make that happen is to raise a feature request describing the feature and to offer to work with other contributors to develop and test it.
|
||||
If you would like NextAuth.js to support a particular feature, the best way to help make it happen is to raise a feature request describing the feature and offer to work with other contributors to develop and test it.
|
||||
|
||||
If you are not able to develop a feature yourself, you can offer sponsor someone to work on it.
|
||||
If you are not able to develop a feature yourself, you can offer to sponsor someone to work on it.
|
||||
|
||||
### I disagree with a design decision, how can I change your mind?
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user