Compare commits

..

16 Commits

Author SHA1 Message Date
Justin Forlenza
ad6c13cdc9 fix(ts): extend server type in Email provider from nodemailer (#2259)
* Added optional secure & TLS settings for SMTP

* Replaced custom interface with nodemailers

* Fix lockfile version

* Apply suggestions from code review

* Apply suggestions from code review

* Apply suggestions from code review

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-06-27 18:51:34 +02:00
Nico Domino
591aa7cc7e docs(adapter): rm @canary from adapters' install instructions (#2260) 2021-06-27 18:28:58 +02:00
ndom91
9abb392b4e chore: fix gh action typo 2021-06-27 03:39:38 +02:00
ndom91
b89ae87fb1 docs: respect color mode 2021-06-27 03:38:04 +02:00
ndom91
3687d17724 Merge branch 'main' of ssh://github.com/nextauthjs/next-auth 2021-06-27 03:11:07 +02:00
Balázs Orbán
b04ff82fb9 chore: clarify where to run envinfo in bug report template 2021-06-24 01:46:02 +02:00
Balázs Orbán
c11915ba9c chore: update bug report template 2021-06-24 01:44:33 +02:00
Balázs Orbán
24ee459f97 chore(ci): run tests and typechecks only 2021-06-24 00:38:17 +02:00
Balázs Orbán
ac4851d238 chore(ci): run test:ci (linting+test+typecheck) 2021-06-24 00:33:32 +02:00
can-mihci
84094b0ee7 docs(client): fix code block typo (#2217) 2021-06-22 20:11:18 +02:00
Vikrant Bhat
f09ab4a04f docs(providers): fix typo (#2220) 2021-06-22 20:08:43 +02:00
Vikrant Bhat
067364381b docs(providers): fix english sentence in Email provider section (#2222) 2021-06-22 09:28:47 +02:00
ndom91
6ee36b6842 ci: test release environment approval 2021-06-18 20:03:07 +02:00
Sangwon Park
5a89ab69d3 feat(provider): add Naver provider (#2172)
* add Naver provider

* fix typo

* Update src/providers/naver.js

Co-authored-by: Balázs Orbán <info@balazsorban.com>

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-06-16 00:46:41 +02:00
Balázs Orbán
665445818e docs(config): link to next documentation instead of canary 2021-06-12 17:11:53 +02:00
ndom91
67cf2a11bb docs: fix alt client provider example 2021-06-12 16:42:48 +02:00
360 changed files with 67697 additions and 14054 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1,2 +1 @@
/types/ @balazsorban44 @lluia
/__tests__/ @lluia

1
.github/FUNDING.yml vendored
View File

@@ -1,4 +1,3 @@
# https://docs.github.com/en/github/administering-a-repository/displaying-a-sponsor-button-in-your-repository
open_collective: nextauth
github: [balazsorban44]

43
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,43 @@
---
name: Bug report
about: Report a defect with NextAuth.js
labels: bug
assignees: ""
---
## Description 🐜
Please provide a clear and concise description of the bug in NextAuth.js.
🚧 _Do not report bugs with your own project here; ask for help [by raising a question instead](https://github.com/nextauthjs/next-auth/issues/new?assignees=&labels=question&template=question.md) - this helps us a lot with administration overhead._
## How to reproduce ☕️
We encourage you to use one of the templates set up on **CodeSandbox** to reproduce your issue:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
🚧 _If you don't provide any way to reproduce the bug, the issue is at risk of being closed._
## Screenshots / Logs 📽
**Help us help you**. We can address the bug you found much faster if you provide contextual screenshots or screen recordings showcasing the issue.
See [Kap](https://getkap.co/) for a good, easy-to-use, cross-platform screen recording tool.
## Environment 🖥
Please run this command in your project's root folder:
```sh
npx envinfo --system --binaries --browsers --npmPackages "next,next-auth,react"
```
and paste the output here.
## Contributing 🙌🏽
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚
In case you're willing to help fix this bug, please let us know here, and we'll reach you 😊 . Otherwise, you can have a look at the issues labelled with [`"good first issue"`](https://github.com/nextauthjs/next-auth/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and pick any of them.

View File

@@ -1,91 +0,0 @@
name: Bug Report
description: File a bug report
labels: bug
# note: markdown sections will NOT appear as part of the issue as per documentation, rather they provide context to the user
# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema#markdown
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! Please provide the following information:
- type: textarea
id: description
attributes:
label: Description 🐜
description: Please provide a clear and concise description of the bug in NextAuth.js
validations:
required: true
- type: dropdown
id: ownproject
attributes:
label: Is this a bug in your own project?
description: 🚧 _Do not report bugs with your own project here; ask for help [by raising a question instead](https://github.com/nextauthjs/next-auth/issues/new?assignees=&labels=question&template=question.md) or use the [Discussions tab](https://github.com/nextauthjs/next-auth/discussions) - this helps us reduce the maintenance overhead._
multiple: false
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: How to reproduce ☕️
description: Please provide a link or code snippets to a minimal reproduction of the bug
validations:
required: true
- type: markdown
attributes:
value: |
We encourage you to use one of the templates set up on **CodeSandbox** to reproduce your issue:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
🚧 _If you don't provide any way to reproduce the bug, the issue is at risk of being closed._
- type: textarea
id: logs
attributes:
label: Screenshots / Logs 📽
description: We can address the bug you found much faster if you provide contextual screenshots or screen recordings showcasing the issue.
- type: markdown
attributes:
value: |
See [Kap](https://getkap.co/) for a good, easy-to-use, cross-platform screen recording tool.
validations:
required: false
- type: textarea
id: environment
attributes:
label: Environment 🖥
validations:
required: true
- type: markdown
attributes:
value: |
Please run this command in your project's root folder:
```sh
npx envinfo --system --binaries --browsers --npmPackages "next,next-auth,react"
```
- type: dropdown
id: pr
attributes:
label: Contributing 🙌🏽
multiple: false
options:
- "Yes, I am willing to help solve this bug in a PR"
- "No, I am afraid I cannot help regarding this"
validations:
required: true
- type: markdown
attributes:
value: |
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚

View File

@@ -0,0 +1,39 @@
---
name: Feature request
about: Suggest an idea for NextAuth.js
labels: enhancement
assignees: ""
---
## Summary 💭
A clear and concise summary of the feature being proposed.
## Description 📓
Please provide a more in-depth description of the feature proposed.
Make sure you provide plenty of [links]() to external documentation and inline code examples like so:
```js
function myAwesomeNextAuthFeature() {
return 💚
}
```
Take time thinking about what you want to say and help us understand your proposal making sure that this description contains:
- **purpose of the feature**
- **potential problems**
- **potential alternatives**
You can use one of the templates set up on **CodeSandbox** to better illustrate your idea:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
## Contributing 🙌🏽
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚
In case you're willing to help implement this feature, please let us know here, and we'll reach you 😊 . Otherwise, you can have a look at the issues labelled with [`"good first issue"`](https://github.com/nextauthjs/next-auth/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and pick any of them.

View File

@@ -1,68 +0,0 @@
name: Feature Request
description: Suggest an idea for NextAuth.js
labels: enhancement
# note: markdown sections will NOT appear as part of the issue as per documentation, rather they provide context to the user
# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema#markdown
body:
- type: markdown
attributes:
value: |
Thank you very much for reaching out to us regarding the awesome feature that you believe should be included in the NextAuth.js library. Please provide the following information:
- type: textarea
id: description
attributes:
label: Description 📓
description: Please provide a more in-depth description of the feature proposed.
validations:
required: true
- type: markdown
attributes:
value: |
Make sure you provide plenty of [links]() to external documentation and inline code examples like so:
```js
function myAwesomeNextAuthFeature() {
return 💚
}
```
Take time thinking about what you want to say and help us understand your proposal making sure that this description contains:
- **purpose of the feature**
- **potential problems**
- **potential alternatives**
- type: textarea
id: reproduction
attributes:
label: How to reproduce ☕️
description: If you have a CodeSandbox playground or some code snippets to help us visualize your idea better, please provide it here.
validations:
required: true
- type: markdown
attributes:
value: |
You can use one of the templates set up on **CodeSandbox** to better illustrate your idea:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
- type: dropdown
id: pr
attributes:
label: Contributing 🙌🏽
multiple: false
options:
- "Yes, I am willing to help implement this feature in a PR"
- "No, I am afraid I cannot help regarding this"
validations:
required: true
- type: markdown
attributes:
value: |
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚

32
.github/ISSUE_TEMPLATE/question.md vendored Normal file
View File

@@ -0,0 +1,32 @@
---
name: Question
about: Ask a question about NextAuth.js or for help using it
labels: question
assignees: ""
---
## Question 💬
Please provide an in-depth description of the question you have.
Make sure you [link]() to external documentation if necessary and provide inline code examples like so:
```js
function myAwesomeNextAuthFeature() {
return 💚
}
```
**NOTE:** Questions will be converted to Discussions. You can find them [here](https://github.com/nextauthjs/next-auth/discussions)!
## How to reproduce ☕️
We encourage you to use the template set-up on **CodeSandbox** as a playground to represent your question or doubt:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
## Contributing 🙌🏽
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚
In case you're willing to help answer this question, please let us know here, and we'll reach you 😊 . Otherwise, you can have a look at the issues labelled with [`"good first issue"`](https://github.com/nextauthjs/next-auth/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and pick any of them.

View File

@@ -1,62 +0,0 @@
name: Question
description: Ask a question about NextAuth.js or for help using it
labels: question
# note: markdown sections will NOT appear as part of the issue as per documentation, rather they provide context to the user
# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema#markdown
body:
- type: markdown
attributes:
value: |
We are glad that you have a question about this library. Please provide the following information:
- type: textarea
id: question
attributes:
label: Question 💬
description: Please provide an in-depth description of the question you have.
validations:
required: true
- type: markdown
attributes:
value: |
Make sure you [link]() to external documentation if necessary and provide inline code examples like so:
```js
function myAwesomeNextAuthFeature() {
return 💚
}
```
**NOTE:** Questions will be converted to Discussions. You can find them [here](https://github.com/nextauthjs/next-auth/discussions)!
- type: textarea
id: reproduction
attributes:
label: How to reproduce ☕️
description: Please provide a link to a minimal reproduction or code snippets that represents your question
validations:
required: true
- type: markdown
attributes:
value: |
We encourage you to use the template set-up on **CodeSandbox** as a playground to represent your question or doubt:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
- type: dropdown
id: pr
attributes:
label: Contributing 🙌🏽
multiple: false
options:
- "Yes, I am willing to help answer this question in a PR"
- "No, I am afraid I cannot help regarding this"
validations:
required: true
- type: markdown
attributes:
value: |
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚

36
.github/ISSUE_TEMPLATE/typescript.md vendored Normal file
View File

@@ -0,0 +1,36 @@
---
name: TypeScript
about: Ask a question about NextAuth.js TypeScript integration
labels:
- question
- TypeScript
assignees:
- lluia
- balazsorban44
---
## Question 💬
Please provide an in-depth description of the question you have when using NextAuth.js on a Typescript project or when consuming the built-in types for `next-auth`.
Make sure you [link]() to external documentation if necessary and provide inline code examples like so:
```js
function myAwesomeNextAuthFeature() {
return 💚
}
```
**NOTE:** Questions will be converted to Discussions. You can find them [here](https://github.com/nextauthjs/next-auth/discussions)!
## How to reproduce ☕️
We encourage you to use the template set-up on **CodeSandbox** as a playground to represent your question or doubt:
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
## Contributing 🙌🏽
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚
In case you're willing to help answer this TypeScript question, please let us know here, and we'll reach you 😊 . Otherwise, you can have a look at the issues labelled with [`"good first issue"`](https://github.com/nextauthjs/next-auth/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) and pick any of them.

View File

@@ -1,58 +0,0 @@
name: TypeScript
description: Ask a question about NextAuth.js TypeScript integration
labels: [question, TypeScript]
assignees: [lluia, balazsorban44]
# note: markdown sections will NOT appear as part of the issue as per documentation, rather they provide context to the user
# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-githubs-form-schema#markdown
body:
- type: textarea
id: question
attributes:
label: Question 💬
description: Please provide an in-depth description of the question you have when using NextAuth.js on a Typescript project or when consuming the built-in types for `next-auth`.
validations:
required: true
- type: markdown
attributes:
value: |
Make sure you [link]() to external documentation if necessary and provide inline code examples like so:
```js
function myAwesomeNextAuthFeature() {
return 💚
}
```
**NOTE:** Questions will be converted to Discussions. You can find them [here](https://github.com/nextauthjs/next-auth/discussions)!
- type: textarea
id: codesandbox
attributes:
label: How to reproduce ☕️
description: Please provide a link to a minimal reproduction or code snippets that represents your question
validations:
required: true
- type: markdown
attributes:
value: |
We encourage you to use the template set-up on **CodeSandbox** as a playground to represent your question or doubt:
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
- type: dropdown
id: pr
attributes:
label: Contributing 🙌🏽
multiple: false
options:
- "Yes, I am willing to help answer this question in a PR"
- "No, I am afraid I cannot help regarding this"
validations:
required: true
- type: markdown
attributes:
value: |
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚

10
.github/labeler.yml vendored
View File

@@ -2,15 +2,23 @@ test:
- test/**/*
- types/tests/**/*
documentation:
- www/**/*
- ./**/*.md
providers:
- src/providers/**/*
- www/docs/configuration/providers.md
- test/integration/**/*
adapters:
- src/adapters/**/*
- www/docs/schemas/adapters.md
databases:
- www/docs/schemas/*.md
- test/docker/databases/**/*
- www/docs/configuration/databases.md
- test/fixtures/**/*
core:
@@ -21,9 +29,11 @@ style:
client:
- src/client/**/*
- www/docs/getting-started/client.md
pages:
- src/server/pages/**/*
- www/docs/configuration/pages.md
TypeScript:
- types/**/*

View File

@@ -1,4 +1,4 @@
name: Release
name: Release Flow
on:
push:
@@ -11,76 +11,38 @@ on:
jobs:
test:
name: Test
name: Tests
runs-on: ubuntu-latest
steps:
- name: Init
uses: actions/checkout@v2
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 16
- name: Dependencies
uses: bahmutov/npm-install@v1
- name: Build
run: npm run build
- name: Run tests
run: npm test -- --coverage --verbose
run: npm test -- --coverage --verbose && npm run test:types
- name: Coverage
uses: codecov/codecov-action@v1
with:
directory: ./coverage
fail_ci_if_error: false
release-branch:
name: Publish branch
runs-on: ubuntu-latest
- name: Build
run: npm run build
release:
name: Release
environment: release
needs: test
if: ${{ github.event_name == 'push' }}
environment: Production
runs-on: ubuntu-latest
steps:
- name: Init
uses: actions/checkout@v2
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 16
- name: Dependencies
uses: bahmutov/npm-install@v1
- name: Publish to npm and GitHub
- name: Release
run: npx semantic-release@17
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
release-pr:
name: Publish PR
runs-on: ubuntu-latest
needs: test
if: ${{ github.event_name == 'pull_request' }}
environment: Preview
steps:
- name: Init
uses: actions/checkout@v2
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 16
- name: Dependencies
uses: bahmutov/npm-install@v1
- name: Determine version
uses: ./config/version-pr
id: determine-version
env:
PR_NUMBER: ${{ github.event.number }}
- name: Publish to npm
run: |
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
npm publish --access public --tag experimental
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Comment version on PR
uses: NejcZdovc/comment-pr@v1
with:
message: "🎉 Experimental release [published on npm](https://www.npmjs.com/package/next-auth/v/${{ env.VERSION }})!\n\n```sh\nnpm i next-auth@${{ env.VERSION }}\n```\n```sh\nyarn add next-auth@${{ env.VERSION }}\n```"
env:
VERSION: ${{ steps.determine-version.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
NPM_TOKEN: ${{secrets.NPM_TOKEN}}

29
.gitignore vendored
View File

@@ -20,29 +20,32 @@ node_modules
.next
/build
/dist
/www/build
# Generated files
.docusaurus
.cache-loader
/providers
/src/providers/oauth-types.ts
/client
/css
/lib
/core
/jwt
/react
www/providers.json
src/providers/index.js
/internals
/adapters.d.ts
/adapters.js
/client.d.ts
/client.js
/index.d.ts
/index.js
/next
/jwt.d.ts
/jwt.js
/providers.d.ts
/providers.js
/errors.js
/errors.d.ts
# Development app
app/src/css
app/next-auth
app/dist/css
app/package-lock.json
app/yarn.lock
app/prisma/migrations
app/prisma/dev.db*
# VS
/.vs/slnx.sqlite-journal
@@ -58,4 +61,4 @@ app/prisma/dev.db*
/prisma/migrations
# Tests
/coverage
/coverage

View File

@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# npx pretty-quick --staged
npx pretty-quick --staged

View File

@@ -14,22 +14,22 @@ appearance, race, religion, or sexual identity and orientation.
Examples of behavior that contributes to creating a positive environment
include:
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery and unwelcome sexual attention or
advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic
address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
@@ -55,11 +55,11 @@ further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting me@iaincollins.com or info@balazsorban.com and yo@ndo.dev.
All complaints will be reviewed and investigated and will result in a response
that is deemed necessary and appropriate to the circumstances. The project team
is obligated to maintain confidentiality with regard to the reporter of an
incident. Further details of specific enforcement policies may be posted separately.
reported by contacting me@iaincollins.com. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and
appropriate to the circumstances. The project team is obligated to maintain
confidentiality with regard to the reporter of an incident. Further details of
specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other

View File

@@ -11,49 +11,43 @@ Please raise any significant new functionality or breaking change an issue for d
## For contributors
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 `main`, 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
- We use ESLint/Prettier for linting/formatting, so please run `npm run lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting 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
* The latest changes are always in `main`, 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
* We use ESLint/Prettier for linting/formatting, so please run `npm run lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting 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
### Setting up local environment
A quick guide on how to setup _next-auth_ locally to work on it and test out any changes:
The developer application requires you to use `npm@7`.
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
```
2. Install packages and set up the developer application:
2. Install packages:
```sh
npm run dev:setup
npm i && npm run dev:setup
```
3. Populate `.env.local`:
Copy `app/.env.local.example` to `app/.env.local`, and add your env variables for each provider you want to test.
Copy `app/.env.local.example` to `app/.env.local`, and add your env variables for each provider you want to test.
> NOTE: You can add any environment variables to .env.local that you would like to use in your dev app.
> You can find the next-auth config under`app/pages/api/auth/[...nextauth].js`.
1. Start the developer application/server:
1. Start the dev application/server:
```sh
npm run dev
```
Your developer application will be available on `http://localhost:3000`
Your dev application will be available on ```http://localhost:3000```
That's it! 🎉
@@ -61,16 +55,15 @@ If you need an example project to link to, you can use [next-auth-example](https
#### Hot reloading
When running `npm run dev`, you start a Next.js developer server on `http://localhost:3000`, which includes hot reloading out of the box. Make changes on any of the files in `src` and see the changes immediately.
When running `npm run dev`, you start a Next.js dev server on `http://localhost:3000`, which includes hot reloading out of the box. Make changes on any of the files in `src` and see the changes immediately.
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered (using API routes). (Improving this through a PR is very welcome!)
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered. (Improving this through a PR is very welcome!)
> NOTE: The setup is as follows: The development application lives inside the `app` folder, and whenever you make a change to the `src` folder in the root (where next-auth is), it gets copied into `app` every time (gitignored), so Next.js can pick them up and apply hot reloading. This is to avoid some annoying issues with how symlinks are working with different React builds, and also to provide a super-fast feedback loop while developing core features.
#### Providers
If you think your custom provider might be useful to others, we encourage you to open a PR and add it to the built-in list so others can discover it much more easily! You only need to add two changes:
1. Add your config: [`src/providers/{provider}.js`](https://github.com/nextauthjs/next-auth/tree/main/src/providers) (Make sure you use a named default export, like `export default function YourProvider`!)
2. Add provider documentation: [`www/docs/providers/{provider}.md`](https://github.com/nextauthjs/next-auth/tree/main/www/docs/providers)
@@ -80,27 +73,40 @@ You can look at the existing built-in providers for inspiration.
#### Databases
If you would like to contribute to an existing database adapter or help create a new one, head over to the [nextauthjs/adapters](https://www.github.com/nextauthjs/adapters) repository and follow the instructions provided there.
Included is a Docker Compose file that starts up MySQL, PostgreSQL, and MongoDB 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 and Docker Compose installed to be able to start / stop the databases.
When stopping the databases, it will reset their contents.
#### Testing
Tests can be run with `npm run test`.
Automated tests are currently crude and limited in functionality, but improvements are in development.
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 maintenance 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 `main`
- 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.)
* Use "Squash and merge"
* Make sure you merge contributor PRs into `main`
* 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
@@ -115,9 +121,10 @@ Some recommended scopes are:
- **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.

161
README.md
View File

@@ -38,7 +38,7 @@ It is designed from the ground up to support Next.js and Serverless.
npm install --save next-auth
```
The easiest way to continue getting started, is to follow the [getting started](https://next-auth.js.org/getting-started/example) section in our docs.
The easiest way to continue getting started, is to follow the [getting started](https://next-auth.js.org/getting-started/example) section in our docs.
We also have a section of [tutorials](https://next-auth.js.org/tutorials) for those looking for more specific examples.
@@ -48,116 +48,96 @@ See [next-auth.js.org](https://next-auth.js.org) for more information and docume
### Flexible and easy to use
- Designed to work with any OAuth service, it supports OAuth 1.0, 1.0A and 2.0
- Built-in support for [many popular sign-in services](https://next-auth.js.org/configuration/providers)
- Supports email / passwordless authentication
- Supports stateless authentication with any backend (Active Directory, LDAP, etc)
- Supports both JSON Web Tokens and database sessions
- Designed for Serverless but runs anywhere (AWS Lambda, Docker, Heroku, etc…)
* Designed to work with any OAuth service, it supports OAuth 1.0, 1.0A and 2.0
* Built-in support for [many popular sign-in services](https://next-auth.js.org/configuration/providers)
* Supports email / passwordless authentication
* Supports stateless authentication with any backend (Active Directory, LDAP, etc)
* Supports both JSON Web Tokens and database sessions
* Designed for Serverless but runs anywhere (AWS Lambda, Docker, Heroku, etc…)
### Own your own data
NextAuth.js can be used with or without a database.
- An open source solution that allows you to keep control of your data
- Supports Bring Your Own Database (BYOD) and can be used with any database
- Built-in support for [MySQL, MariaDB, Postgres, Microsoft SQL Server, MongoDB and SQLite](https://next-auth.js.org/configuration/databases)
- Works great with databases from popular hosting providers
- Can also be used _without a database_ (e.g. OAuth + JWT)
* An open source solution that allows you to keep control of your data
* Supports Bring Your Own Database (BYOD) and can be used with any database
* Built-in support for [MySQL, MariaDB, Postgres, Microsoft SQL Server, MongoDB and SQLite](https://next-auth.js.org/configuration/databases)
* Works great with databases from popular hosting providers
* Can also be used *without a database* (e.g. OAuth + JWT)
### Secure by default
- Promotes the use of passwordless sign-in mechanisms
- Designed to be secure by default and encourage best practices for safeguarding user data
- Uses Cross-Site Request Forgery (CSRF) Tokens on POST routes (sign in, sign out)
- Default cookie policy aims for the most restrictive policy appropriate for each cookie
- When JSON Web Tokens are enabled, they are signed by default (JWS) with HS512
- Use JWT encryption (JWE) by setting the option `encryption: true` (defaults to A256GCM)
- Auto-generates symmetric signing and encryption keys for developer convenience
- Features tab/window syncing and session polling to support short lived sessions
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org)
* Promotes the use of passwordless sign in mechanisms
* Designed to be secure by default and encourage best practice for safeguarding user data
* Uses Cross Site Request Forgery Tokens on POST routes (sign in, sign out)
* Default cookie policy aims for the most restrictive policy appropriate for each cookie
* When JSON Web Tokens are enabled, they are signed by default (JWS) with HS512
* Use JWT encryption (JWE) by setting the option `encryption: true` (defaults to A256GCM)
* Auto-generates symmetric signing and encryption keys for developer convenience
* Features tab/window syncing and keepalive messages to support short lived sessions
* Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org/)
Advanced options allow you to define your own routines to handle controlling what accounts are allowed to sign in, for encoding and decoding JSON Web Tokens and to set custom cookie security policies and session properties, so you can control who is able to sign in and how often sessions have to be re-validated.
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
NextAuth.js comes with built-in types. For more information and usage, check out the [TypeScript section](https://next-auth.js.org/getting-started/typescript) in the documentation.
The package at `@types/next-auth` is now deprecated.
## Example
### Add API Route
```javascript
// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth"
import Providers from "next-auth/providers"
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
export default NextAuth({
providers: [
// OAuth authentication providers
Providers.Apple({
clientId: process.env.APPLE_ID,
clientSecret: process.env.APPLE_SECRET,
clientSecret: process.env.APPLE_SECRET
}),
Providers.Google({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
clientSecret: process.env.GOOGLE_SECRET
}),
// Sign in with passwordless email link
Providers.Email({
server: process.env.MAIL_SERVER,
from: "<no-reply@example.com>",
from: '<no-reply@example.com>'
}),
],
// SQL or MongoDB database (or leave empty)
database: process.env.DATABASE_URL
})
```
### Add React Hook
The `useSession()` React Hook in the NextAuth.js client is the easiest way to check if someone is signed in.
### Add React Component
```javascript
import { useSession, signIn, signOut } from "next-auth/react"
import {
useSession, signIn, signOut
} from 'next-auth/client'
export default function Component() {
const { data: session } = useSession()
if (session) {
return (
<>
Signed in as {session.user.email} <br />
<button onClick={() => signOut()}>Sign out</button>
</>
)
}
return (
<>
Not signed in <br />
<button onClick={() => signIn()}>Sign in</button>
const [ session, loading ] = useSession()
if(session) {
return <>
Signed in as {session.user.email} <br/>
<button onClick={() => signOut()}>Sign out</button>
</>
)
}
return <>
Not signed in <br/>
<button onClick={() => signIn()}>Sign in</button>
</>
}
```
### Share/configure session state
Use the `<SessionProvider>` to allows instances of `useSession()` to share the session object across components. It also takes care of keeping the session updated and synced between tabs/windows.
```jsx title="pages/_app.js"
import { SessionProvider } from "next-auth/react"
export default function App({
Component,
pageProps: { session, ...pageProps }
}) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
)
}
```
## Acknowledgments
## Acknowledgements
[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors)
@@ -165,50 +145,13 @@ export default function App({
<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"></a>
<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>
### Support
We're happy to announce we've recently created an [OpenCollective](https://opencollective.org/nextauth) for individuals and companies looking to contribute financially to the project!
<!--sponsors start-->
<table>
<tbody>
<tr>
<td align="center" valign="top">
<a href="https://vercel.com" target="_blank">
<img width="128px" src="https://avatars.githubusercontent.com/u/14985020?v=4" alt="Vercel Logo" />
</a><br />
<div>Vercel</div><br />
<sub>🥉 Bronze Financial Sponsor <br /> ☁️ Infrastructure Support</sub>
</td>
<td align="center" valign="top">
<a href="https://prisma.io" target="_blank">
<img width="128px" src="https://avatars.githubusercontent.com/u/17219288?v=4" alt="Prisma Logo" />
</a><br />
<div>Prisma</div><br />
<sub>🥉 Bronze Financial Sponsor</sub>
</td>
<td align="center" valign="top">
<a href="https://checklyhq.com" target="_blank">
<img width="128px" src="https://avatars.githubusercontent.com/u/25982255?v=4" alt="Checkly Logo" />
</a><br />
<div>Checkly</div><br />
<sub>☁️ Infrastructure Support</sub>
</td>
<td align="center" valign="top">
<a href="https://superblog.ai/" target="_blank">
<img width="128px" src="https://d33wubrfki0l68.cloudfront.net/cdc4a3833bd878933fcc131655878dbf226ac1c5/10cd6/images/logo_bolt_small.png" alt="superblog Logo" />
</a><br />
<div>superblog</div><br />
<sub>☁️ Infrastructure Support</sub>
</td>
</tr><tr></tr>
</tbody>
</table>
<br />
<!--sponsors end-->
## Contributing

View File

@@ -14,11 +14,11 @@ We request that you contact us directly to report serious issues that might impa
If you contact us regarding a serious issue:
- We will endeavor to get back to you within 72 hours.
- We will aim to publish a fix within 30 days.
- We will disclose the issue (and credit you, with your consent) once a fix to resolve the issue has been released.
- If 90 days has elapsed and we still don't have a fix, we will disclose the issue publicly.
* We will endeavor to get back to you within 72 hours.
* We will aim to publish a fix within 30 days.
* We will disclose the issue (and credit you, with your consent) once a fix to resolve the issue has been released.
* If 90 days has elapsed and we still don't have a fix, we will disclose the issue publicly.
Currently, the best way to report an issue is by contacting us via email at me@iaincollins.com or info@balazsorban.com and yo@ndo.dev.
Currently, the best way to report an issue is by emailing me@iaincollins.com
For less serious issues (e.g. RFC compliance for unsupported flows or potential issues that may cause a problem future or default behaviour / options) it is appropriate to submit these these publically as bug reports or feature requests or to raise a question to open a discussion around them.

View File

@@ -6,40 +6,28 @@ NEXTAUTH_URL=http://localhost:3000
# You can use `openssl rand -hex 32` or
# https://generate-secret.vercel.app/32 to generate a secret.
# Note: Changing a secret may invalidate existing sessions
# and/or verification tokens.
SECRET=secret
# and/or verificaion tokens.
SECRET=
AUTH0_ID=
AUTH0_DOMAIN=
AUTH0_SECRET=
AUTH0_ISSUER=
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER=
IDS4_ID=
IDS4_SECRET=
IDS4_ISSUER=
GITHUB_ID=
GITHUB_SECRET=
TWITCH_ID=
TWITCH_SECRET=
TWITTER_ID=
TWITTER_SECRET=
LINE_ID=
LINE_SECRET=
# Example configuration for a Gmail account (will need SMTP enabled)
EMAIL_SERVER=smtps://user@gmail.com:password@smtp.gmail.com:465
EMAIL_FROM=user@gmail.com
# You can use any of these as the "DATABASE_URL" for
# databases started with Docker using `npm run db:start`.
# Note: If using with Prisma adapter, you need to use a `.env`
# file rather than a `.env.local` file to configure env vars.
# Postgres: DATABASE_URL=postgres://nextauth:password@127.0.0.1:5432/nextauth?synchronize=true
# MySQL: DATABASE_URL=mysql://nextauth:password@127.0.0.1:3306/nextauth?synchronize=true
# MongoDB: DATABASE_URL=mongodb://nextauth:password@127.0.0.1:27017/nextauth?synchronize=true
DATABASE_URL=
DATABASE_URL=

View File

@@ -1 +0,0 @@
16

View File

@@ -1,18 +1,17 @@
import { signIn } from "next-auth/react"
import { signIn } from 'next-auth/client'
export default function AccessDenied() {
export default function AccessDenied () {
return (
<>
<h1>Access Denied</h1>
<p>
<a
href="/api/auth/signin"
href='/api/auth/signin'
onClick={(e) => {
e.preventDefault()
signIn()
}}
>
You must be signed in to view this page
>You must be signed in to view this page
</a>
</p>
</>

View File

@@ -1,29 +1,17 @@
import Link from "next/link"
import styles from "./footer.module.css"
import packageJSON from "package.json"
import Link from 'next/link'
import styles from './footer.module.css'
import { version } from 'package.json'
export default function Footer() {
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>{packageJSON.version}</em>
</li>
<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>
)

View File

@@ -1,22 +1,22 @@
import Link from "next/link"
import { signIn, signOut, useSession } from "next-auth/react"
import styles from "./header.module.css"
import Link from 'next/link'
import { signIn, signOut, useSession } from 'next-auth/client'
import styles from './header.module.css'
// The approach used in this component shows how to built a sign in and sign out
// component that works on pages which support both client and server side
// rendering, and avoids any flash incorrect content on initial page load.
export default function Header() {
const { data: session, status } = useSession()
export default function Header () {
const [session, loading] = useSession()
return (
<header>
<noscript>
<style>{".nojs-show { opacity: 1; top: 0; }"}</style>
<style>{'.nojs-show { opacity: 1; top: 0; }'}</style>
</noscript>
<div className={styles.signedInStatus}>
<p
className={`nojs-show ${
!session && status === "loading" ? styles.loading : styles.loaded
!session && loading ? styles.loading : styles.loaded
}`}
>
{!session && (
@@ -25,7 +25,7 @@ export default function Header() {
You are not signed in
</span>
<a
href="/api/auth/signin"
href='/api/auth/signin'
className={styles.buttonPrimary}
onClick={(e) => {
e.preventDefault()
@@ -39,16 +39,18 @@ export default function Header() {
{session && (
<>
{session.user.image && (
<img src={session.user.image} className={styles.avatar} />
<span
style={{ backgroundImage: `url(${session.user.image})` }}
className={styles.avatar}
/>
)}
<span className={styles.signedInText}>
<small>Signed in as</small>
<br />
<strong>{session.user.email} </strong>
{session.user.name ? `(${session.user.name})` : null}
<strong>{session.user.email || session.user.name}</strong>
</span>
<a
href="/api/auth/signout"
href='/api/auth/signout'
className={styles.button}
onClick={(e) => {
e.preventDefault()
@@ -64,42 +66,42 @@ export default function Header() {
<nav>
<ul className={styles.navItems}>
<li className={styles.navItem}>
<Link href="/">
<Link href='/'>
<a>Home</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/client">
<Link href='/client'>
<a>Client</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/server">
<Link href='/server'>
<a>Server</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/protected">
<Link href='/protected'>
<a>Protected</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/protected-ssr">
<Link href='/protected-ssr'>
<a>Protected(SSR)</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/api-example">
<Link href='/api-example'>
<a>API</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/credentials">
<Link href='/credentials'>
<a>Credentials</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/email">
<Link href='/email'>
<a>Email</a>
</Link>
</li>

5
app/jsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"baseUrl": "."
}
}

4
app/next-env.d.ts vendored
View File

@@ -1,6 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -6,19 +6,14 @@ module.exports = {
...config.resolve,
alias: {
...config.resolve.alias,
react: path.join(process.cwd(), "node_modules/react"),
nodemailer: path.join(process.cwd(), "node_modules/nodemailer"),
"react-dom": path.join(process.cwd(), "node_modules/react-dom"),
"react/jsx-dev-runtime": path.join(
process.cwd(),
"node_modules/react/jsx-dev-runtime"
),
"next-auth$": path.join(process.cwd(), "next-auth/server"),
"next-auth/client$": path.join(process.cwd(), "next-auth/client"),
"next-auth/jwt$": path.join(process.cwd(), "next-auth/lib/jwt"),
"next-auth/adapters": path.join(process.cwd(), "next-auth/adapters"),
"next-auth/providers": path.join(process.cwd(), "next-auth/providers"),
},
}
return config
},
experimental: {
externalDir: true,
},
}

View File

@@ -4,30 +4,22 @@
"description": "NextAuth.js Developer app",
"private": true,
"scripts": {
"clean": "rm -rf .next",
"dev": "npm-run-all --parallel dev:next watch:css copy:css ",
"dev": "npm-run-all --parallel copy:app dev:css dev:next",
"dev:next": "next dev",
"copy:css": "cpx \"../css/**/*\" src/css --watch",
"copy:app": "cpx \"../src/**/*\" next-auth --watch",
"copy:css": "cpx \"../dist/css/**/*\" dist/css --watch",
"watch:css": "cd .. && npm run watch:css",
"start": "next start",
"email": "npx fake-smtp-server",
"start:email": "email"
"dev:css": "npm-run-all --parallel watch:css copy:css",
"start": "next start"
},
"license": "ISC",
"dependencies": {
"@next-auth/fauna-adapter": "0.2.2-next.4",
"@next-auth/prisma-adapter": "0.5.2-next.5",
"@prisma/client": "^2.29.1",
"fake-smtp-server": "^0.8.0",
"faunadb": "^4.3.0",
"next": "^11.1.0",
"nodemailer": "^6.6.3",
"next": "^10.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"cpx": "^1.5.0",
"npm-run-all": "^4.1.5",
"prisma": "^2.29.1"
"npm-run-all": "^4.1.5"
}
}

View File

@@ -1,10 +1,31 @@
import { SessionProvider } from "next-auth/react"
import { Provider } from "next-auth/client"
import "./styles.css"
// Use the <Provider> to improve performance and allow components that call
// `useSession()` anywhere in your application to access the `session` object.
export default function App({ Component, pageProps }) {
return (
<SessionProvider session={pageProps.session}>
<Provider
// Provider options are not required but can be useful in situations where
// you have a short session maxAge time. Shown here with default values.
options={{
// Client Max Age controls how often the useSession in the client should
// contact the server to sync the session state. Value in seconds.
// e.g.
// * 0 - Disabled (always use cache value)
// * 60 - Sync session state with server if it's older than 60 seconds
clientMaxAge: 0,
// Keep Alive tells windows / tabs that are signed in to keep sending
// a keep alive request (which extends the current session expiry) to
// prevent sessions in open windows from expiring. Value in seconds.
//
// Note: If a session has expired when keep alive is triggered, all open
// windows / tabs will be updated to reflect the user is signed out.
keepAlive: 0,
}}
session={pageProps.session}
>
<Component {...pageProps} />
</SessionProvider>
</Provider>
)
}

View File

@@ -0,0 +1,91 @@
import NextAuth from "next-auth"
import EmailProvider from "next-auth/providers/email"
import GitHubProvider from "next-auth/providers/github"
import Auth0Provider from "next-auth/providers/auth0"
import TwitterProvider from "next-auth/providers/twitter"
import CredentialsProvider from "next-auth/providers/credentials"
// import Adapters from 'next-auth/adapters'
// import { PrismaClient } from '@prisma/client'
// const prisma = new PrismaClient()
export default NextAuth({
// Used to debug https://github.com/nextauthjs/next-auth/issues/1664
// cookies: {
// csrfToken: {
// name: 'next-auth.csrf-token',
// options: {
// httpOnly: true,
// sameSite: 'none',
// path: '/',
// secure: true
// }
// },
// pkceCodeVerifier: {
// name: 'next-auth.pkce.code_verifier',
// options: {
// httpOnly: true,
// sameSite: 'none',
// path: '/',
// secure: true
// }
// }
// },
providers: [
EmailProvider({
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Auth0Provider({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
domain: process.env.AUTH0_DOMAIN,
// Used to debug https://github.com/nextauthjs/next-auth/issues/1664
// protection: ["pkce", "state"],
// authorizationParams: {
// response_mode: 'form_post'
// }
protection: "pkce",
}),
TwitterProvider({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
}),
CredentialsProvider({
name: "Credentials",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if (credentials.password === "password") {
return {
id: 1,
name: "Fill Murray",
email: "bill@fillmurray.com",
image: "https://www.fillmurray.com/64/64",
}
}
return null
},
}),
],
jwt: {
encryption: true,
secret: process.env.SECRET,
},
debug: false,
theme: "auto",
// Default Database Adapter (TypeORM)
// database: process.env.DATABASE_URL
// Prisma Database Adapter
// To configure this app to use the schema in `prisma/schema.prisma` run:
// npx prisma generate
// npx prisma migrate dev
// adapter: Adapters.Prisma.Adapter({ prisma })
})

View File

@@ -1,183 +0,0 @@
import NextAuth, { NextAuthOptions } from "next-auth"
import EmailProvider from "next-auth/providers/email"
import GitHubProvider from "next-auth/providers/github"
import Auth0Provider from "next-auth/providers/auth0"
import KeycloakProvider from "next-auth/providers/keycloak"
import TwitterProvider from "next-auth/providers/twitter"
import CredentialsProvider from "next-auth/providers/credentials"
import IDS4Provider from "next-auth/providers/identity-server4"
import Twitch from "next-auth/providers/twitch"
import GoogleProvider from "next-auth/providers/google"
import FacebookProvider from "next-auth/providers/facebook"
import FoursquareProvider from "next-auth/providers/foursquare"
// import FreshbooksProvider from "next-auth/providers/freshbooks"
import GitlabProvider from "next-auth/providers/gitlab"
import InstagramProvider from "next-auth/providers/instagram"
import LineProvider from "next-auth/providers/line"
import LinkedInProvider from "next-auth/providers/linkedin"
import MailchimpProvider from "next-auth/providers/mailchimp"
import DiscordProvider from "next-auth/providers/discord"
import AzureADProvider from "next-auth/providers/azure-ad"
import SpotifyProvider from "next-auth/providers/spotify"
import CognitoProvider from "next-auth/providers/cognito"
import SlackProvider from "next-auth/providers/slack"
import Okta from "next-auth/providers/okta"
import AzureB2C from "next-auth/providers/azure-ad-b2c"
// import { PrismaAdapter } from "@next-auth/prisma-adapter"
// import { PrismaClient } from "@prisma/client"
// const prisma = new PrismaClient()
// const adapter = PrismaAdapter(prisma)
// import { Client as FaunaClient } from "faunadb"
// import { FaunaAdapter } from "@next-auth/fauna-adapter"
// const client = new FaunaClient({
// secret: process.env.FAUNA_SECRET,
// domain: process.env.FAUNA_DOMAIN,
// })
// const adapter = FaunaAdapter(client)
export const authOptions: NextAuthOptions = {
// adapter,
providers: [
// E-mail
// Start fake e-mail server with `npm run start:email`
EmailProvider({
server: {
host: "127.0.0.1",
auth: null,
secure: false,
port: 1025,
tls: { rejectUnauthorized: false },
},
}),
// Credentials
CredentialsProvider({
name: "Credentials",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (credentials.password === "pw") {
return {
name: "Fill Murray",
email: "bill@fillmurray.com",
image: "https://www.fillmurray.com/64/64",
}
}
return null
},
}),
// OAuth 1
TwitterProvider({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
}),
// OAuth 2 / OIDC
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Auth0Provider({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
issuer: process.env.AUTH0_ISSUER,
}),
KeycloakProvider({
clientId: process.env.KEYCLOAK_ID,
clientSecret: process.env.KEYCLOAK_SECRET,
issuer: process.env.KEYCLOAK_ISSUER,
}),
Twitch({
clientId: process.env.TWITCH_ID,
clientSecret: process.env.TWITCH_SECRET,
}),
GoogleProvider({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
FacebookProvider({
clientId: process.env.FACEBOOK_ID,
clientSecret: process.env.FACEBOOK_SECRET,
}),
FoursquareProvider({
clientId: process.env.FOURSQUARE_ID,
clientSecret: process.env.FOURSQUARE_SECRET,
}),
// FreshbooksProvider({
// clientId: process.env.FRESHBOOKS_ID,
// clientSecret: process.env.FRESHBOOKS_SECRET,
// }),
GitlabProvider({
clientId: process.env.GITLAB_ID,
clientSecret: process.env.GITLAB_SECRET,
}),
InstagramProvider({
clientId: process.env.INSTAGRAM_ID,
clientSecret: process.env.INSTAGRAM_SECRET,
}),
LineProvider({
clientId: process.env.LINE_ID,
clientSecret: process.env.LINE_SECRET,
}),
LinkedInProvider({
clientId: process.env.LINKEDIN_ID,
clientSecret: process.env.LINKEDIN_SECRET,
}),
MailchimpProvider({
clientId: process.env.MAILCHIMP_ID,
clientSecret: process.env.MAILCHIMP_SECRET,
}),
IDS4Provider({
clientId: process.env.IDS4_ID,
clientSecret: process.env.IDS4_SECRET,
issuer: process.env.IDS4_ISSUER,
}),
DiscordProvider({
clientId: process.env.DISCORD_ID,
clientSecret: process.env.DISCORD_SECRET,
}),
AzureADProvider({
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
tenantId: process.env.AZURE_AD_TENANT_ID,
profilePhotoSize: 48,
}),
SpotifyProvider({
clientId: process.env.SPOTIFY_ID,
clientSecret: process.env.SPOTIFY_SECRET,
}),
CognitoProvider({
clientId: process.env.COGNITO_ID,
clientSecret: process.env.COGNITO_SECRET,
issuer: process.env.COGNITO_ISSUER,
}),
Okta({
clientId: process.env.OKTA_ID,
clientSecret: process.env.OKTA_SECRET,
issuer: process.env.OKTA_ISSUER,
}),
SlackProvider({
clientId: process.env.SLACK_ID,
clientSecret: process.env.SLACK_SECRET,
}),
AzureB2C({
clientId: process.env.AZURE_B2C_ID,
clientSecret: process.env.AZURE_B2C_SECRET,
tenantId: process.env.AZURE_B2C_TENANT_ID,
primaryUserFlow: process.env.AZURE_B2C_PRIMARY_USER_FLOW,
}),
],
jwt: {
encryption: true,
secret: process.env.SECRET,
},
debug: true,
theme: {
colorScheme: "auto",
logo: "https://next-auth.js.org/img/logo/logo-sm.png",
brandColor: "#1786fb",
},
}
export default NextAuth(authOptions)

View File

@@ -1,5 +1,5 @@
// This is an example of how to read a JSON Web Token from an API route
import jwt from "next-auth/jwt"
import jwt from 'next-auth/jwt'
const secret = process.env.SECRET

View File

@@ -1,17 +1,12 @@
// This is an example of to protect an API route
import { getSession } from "next-auth/react"
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })
if (session) {
res.send({
content:
"This is protected content. You can access this content because you are signed in.",
})
res.send({ content: 'This is protected content. You can access this content because you are signed in.' })
} else {
res.send({
error: "You must be sign in to view the protected content on this page.",
})
res.send({ error: 'You must be sign in to view the protected content on this page.' })
}
}

View File

@@ -1,5 +1,5 @@
// This is an example of how to access a session from an API route
import { getSession } from "next-auth/react"
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })

View File

@@ -1,15 +1,15 @@
// eslint-disable-next-line no-use-before-define
import * as React from "react"
import { signIn, signOut, useSession } from "next-auth/react"
import Layout from "components/layout"
import * as React from 'react'
import { signIn, signOut, useSession } from 'next-auth/client'
import Layout from 'components/layout'
export default function Page() {
export default function Page () {
const [response, setResponse] = React.useState(null)
const handleLogin = (options) => async () => {
if (options.redirect) {
return signIn("credentials", options)
return signIn('credentials', options)
}
const response = await signIn("credentials", options)
const response = await signIn('credentials', options)
setResponse(response)
}
@@ -21,22 +21,18 @@ export default function Page() {
setResponse(response)
}
const { data: session } = useSession()
const [session] = useSession()
if (session) {
return (
<Layout>
<h1>Test different flows for Credentials logout</h1>
<span className="spacing">Default:</span>
<button onClick={handleLogout({ redirect: true })}>Logout</button>
<br />
<span className="spacing">No redirect:</span>
<button onClick={handleLogout({ redirect: false })}>Logout</button>
<br />
<span className='spacing'>Default:</span>
<button onClick={handleLogout({ redirect: true })}>Logout</button><br />
<span className='spacing'>No redirect:</span>
<button onClick={handleLogout({ redirect: false })}>Logout</button><br />
<p>Response:</p>
<pre style={{ background: "#eee", padding: 16 }}>
{JSON.stringify(response, null, 2)}
</pre>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
@@ -44,24 +40,14 @@ export default function Page() {
return (
<Layout>
<h1>Test different flows for Credentials login</h1>
<span className="spacing">Default:</span>
<button onClick={handleLogin({ redirect: true, password: "password" })}>
Login
</button>
<br />
<span className="spacing">No redirect:</span>
<button onClick={handleLogin({ redirect: false, password: "password" })}>
Login
</button>
<br />
<span className="spacing">No redirect, wrong password:</span>
<button onClick={handleLogin({ redirect: false, password: "" })}>
Login
</button>
<span className='spacing'>Default:</span>
<button onClick={handleLogin({ redirect: true, password: 'password' })}>Login</button><br />
<span className='spacing'>No redirect:</span>
<button onClick={handleLogin({ redirect: false, password: 'password' })}>Login</button><br />
<span className='spacing'>No redirect, wrong password:</span>
<button onClick={handleLogin({ redirect: false, password: '' })}>Login</button>
<p>Response:</p>
<pre style={{ background: "#eee", padding: 16 }}>
{JSON.stringify(response, null, 2)}
</pre>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

View File

@@ -1,11 +1,11 @@
// eslint-disable-next-line no-use-before-define
import * as React from "react"
import { signIn, signOut, useSession } from "next-auth/react"
import Layout from "components/layout"
import * as React from 'react'
import { signIn, signOut, useSession } from 'next-auth/client'
import Layout from 'components/layout'
export default function Page() {
export default function Page () {
const [response, setResponse] = React.useState(null)
const [email, setEmail] = React.useState("")
const [email, setEmail] = React.useState('')
const handleChange = (event) => {
setEmail(event.target.value)
@@ -15,9 +15,9 @@ export default function Page() {
event.preventDefault()
if (options.redirect) {
return signIn("email", options)
return signIn('email', options)
}
const response = await signIn("email", options)
const response = await signIn('email', options)
setResponse(response)
}
@@ -29,22 +29,18 @@ export default function Page() {
setResponse(response)
}
const { data: session } = useSession()
const [session] = useSession()
if (session) {
return (
<Layout>
<h1>Test different flows for Email logout</h1>
<span className="spacing">Default:</span>
<button onClick={handleLogout({ redirect: true })}>Logout</button>
<br />
<span className="spacing">No redirect:</span>
<button onClick={handleLogout({ redirect: false })}>Logout</button>
<br />
<span className='spacing'>Default:</span>
<button onClick={handleLogout({ redirect: true })}>Logout</button><br />
<span className='spacing'>No redirect:</span>
<button onClick={handleLogout({ redirect: false })}>Logout</button><br />
<p>Response:</p>
<pre style={{ background: "#eee", padding: 16 }}>
{JSON.stringify(response, null, 2)}
</pre>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
@@ -52,29 +48,20 @@ export default function Page() {
return (
<Layout>
<h1>Test different flows for Email login</h1>
<label className="spacing">
Email address:{" "}
<input
type="text"
id="email"
name="email"
value={email}
onChange={handleChange}
/>
</label>
<br />
<label className='spacing'>
Email address:{' '}
<input type='text' id='email' name='email' value={email} onChange={handleChange} />
</label><br />
<form onSubmit={handleLogin({ redirect: true, email })}>
<span className="spacing">Default:</span>
<button type="submit">Sign in with Email</button>
<span className='spacing'>Default:</span>
<button type='submit'>Sign in with Email</button>
</form>
<form onSubmit={handleLogin({ redirect: false, email })}>
<span className="spacing">No redirect:</span>
<button type="submit">Sign in with Email</button>
<span className='spacing'>No redirect:</span>
<button type='submit'>Sign in with Email</button>
</form>
<p>Response:</p>
<pre style={{ background: "#eee", padding: 16 }}>
{JSON.stringify(response, null, 2)}
</pre>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

View File

@@ -1,48 +1,37 @@
// This is an example of how to protect content using server rendering
import { getServerSession } from "next-auth/next"
import { authOptions } from "./api/auth/[...nextauth]"
import Layout from "../components/layout"
import AccessDenied from "../components/access-denied"
import { getSession } from 'next-auth/client'
import Layout from '../components/layout'
import AccessDenied from '../components/access-denied'
export default function Page({ content, session }) {
export default function Page ({ content, session }) {
// If no session exists, display access denied message
if (!session) {
return (
<Layout>
<AccessDenied />
</Layout>
)
}
if (!session) { return <Layout><AccessDenied /></Layout> }
// If session exists, display content
return (
<Layout>
<h1>Protected Page</h1>
<p>
<strong>{content}</strong>
</p>
<p><strong>{content}</strong></p>
</Layout>
)
}
export async function getServerSideProps(context) {
const session = await getServerSession(context, authOptions)
export async function getServerSideProps (context) {
const session = await getSession(context)
let content = null
if (session) {
const hostname = process.env.NEXTAUTH_URL || "http://localhost:3000"
const hostname = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const options = { headers: { cookie: context.req.headers.cookie } }
const res = await fetch(`${hostname}/api/examples/protected`, options)
const json = await res.json()
if (json.content) {
content = json.content
}
if (json.content) { content = json.content }
}
return {
props: {
session,
content,
},
content
}
}
}

View File

@@ -1,35 +1,33 @@
import { useState, useEffect } from "react"
import { useSession } from "next-auth/react"
import Layout from "../components/layout"
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/client'
import Layout from '../components/layout'
import AccessDenied from '../components/access-denied'
export default function Page() {
const { status } = useSession({
required: true,
})
export default function Page () {
const [session, loading] = useSession()
const [content, setContent] = useState()
// Fetch content from protected route
useEffect(() => {
if (status === "loading") return
const fetchData = async () => {
const res = await fetch("/api/examples/protected")
const res = await fetch('/api/examples/protected')
const json = await res.json()
if (json.content) {
setContent(json.content)
}
if (json.content) { setContent(json.content) }
}
fetchData()
}, [status])
}, [session])
if (status === "loading") return <Layout>Loading...</Layout>
// When rendering client side don't display anything until loading is complete
if (typeof window !== 'undefined' && loading) return null
// If no session exists, display access denied message
if (!session) { return <Layout><AccessDenied /></Layout> }
// If session exists, display content
return (
<Layout>
<h1>Protected Page</h1>
<p>
<strong>{content}</strong>
</p>
<p><strong>{content}</strong></p>
</Layout>
)
}

View File

@@ -1,7 +1,7 @@
import { getSession } from "next-auth/react"
import Layout from "../components/layout"
import { getSession } from 'next-auth/client'
import Layout from '../components/layout'
export default function Page() {
export default function Page () {
// As this page uses Server Side Rendering, the `session` will be already
// populated on render without needing to go through a loading stage.
// This is possible because of the shared context configured in `_app.js` that
@@ -11,31 +11,27 @@ export default function Page() {
<Layout>
<h1>Server Side Rendering</h1>
<p>
This page uses the universal <strong>getSession()</strong> method in{" "}
<strong>getServerSideProps()</strong>.
This page uses the universal <strong>getSession()</strong> method in <strong>getServerSideProps()</strong>.
</p>
<p>
Using <strong>getSession()</strong> in{" "}
<strong>getServerSideProps()</strong> is the recommended approach if you
need to support Server Side Rendering with authentication.
Using <strong>getSession()</strong> in <strong>getServerSideProps()</strong> is the recommended approach if you need to
support Server Side Rendering with authentication.
</p>
<p>
The advantage of Server Side Rendering is this page does not require
client side JavaScript.
The advantage of Server Side Rendering is this page does not require client side JavaScript.
</p>
<p>
The disadvantage of Server Side Rendering is that this page is slower to
render.
The disadvantage of Server Side Rendering is that this page is slower to render.
</p>
</Layout>
)
}
// Export the `session` prop to use sessions with Server Side Rendering
export async function getServerSideProps(context) {
export async function getServerSideProps (context) {
return {
props: {
session: await getSession(context),
},
session: await getSession(context)
}
}
}

View File

@@ -1,57 +1,63 @@
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
oauth_token_secret String?
oauth_token String?
id Int @default(autoincrement()) @id
compoundId String @unique @map(name: "compound_id")
userId Int @map(name: "user_id")
providerType String @map(name: "provider_type")
providerId String @map(name: "provider_id")
providerAccountId String @map(name: "provider_account_id")
refreshToken String? @map(name: "refresh_token")
accessToken String? @map(name: "access_token")
accessTokenExpires DateTime? @map(name: "access_token_expires")
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([providerAccountId], name: "providerAccountId")
@@index([providerId], name: "providerId")
@@index([userId], name: "userId")
@@unique([provider, providerAccountId])
@@map(name: "accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
id Int @default(autoincrement()) @id
userId Int @map(name: "user_id")
expires DateTime
user User @relation(fields: [userId], references: [id])
sessionToken String @unique @map(name: "session_token")
accessToken String @unique @map(name: "access_token")
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@map(name: "sessions")
}
model User {
id String @id @default(cuid())
id Int @default(autoincrement()) @id
name String?
email String? @unique
emailVerified DateTime?
emailVerified DateTime? @map(name: "email_verified")
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@map(name: "users")
}
model VerificationToken {
model VerificationRequest {
id Int @default(autoincrement()) @id
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@unique([identifier, token])
}
@@map(name: "verification_requests")
}

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"next-auth": [ "../src" ],
"next-auth/*": [ "../src/*" ]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@@ -2,55 +2,32 @@
// https://nextjs.org/docs/getting-started#system-requirements
// https://nextjs.org/docs/basic-features/supported-browsers-features
module.exports = (api) => {
const isTest = api.env("test")
if (isTest) {
return {
module.exports = {
presets: [["@babel/preset-env", { targets: { node: "10.13" } }]],
plugins: [
"@babel/plugin-proposal-optional-catch-binding",
"@babel/plugin-transform-runtime",
],
comments: false,
overrides: [
{
test: ["../src/client/**"],
presets: [["@babel/preset-env", { targets: { ie: "11" } }]],
},
{
test: ["../src/server/pages/**"],
presets: ["preact"],
},
{
test: ["../src/**/*.test.js"],
presets: [
"@babel/preset-env",
["@babel/preset-react", { runtime: "automatic" }],
["@babel/preset-typescript", { isTSX: true, allExtensions: true }],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
],
}
}
return {
presets: [
["@babel/preset-env", { targets: { node: 12 } }],
"@babel/preset-typescript",
],
plugins: [
"@babel/plugin-proposal-optional-catch-binding",
"@babel/plugin-transform-runtime",
],
ignore: [
"../src/**/__tests__/**",
"../src/adapters.ts",
"../src/lib/types.ts",
"../src/providers/oauth-types.ts",
],
comments: false,
overrides: [
{
test: ["../src/react/index.tsx"],
presets: [
["@babel/preset-env", { targets: { ie: 11 } }],
["@babel/preset-react", { runtime: "automatic" }],
],
},
{
test: ["../src/core/pages/*.tsx"],
presets: ["preact"],
plugins: [
[
"jsx-pragmatic",
{
module: "preact",
export: "h",
import: "h",
},
],
],
},
],
}
},
],
}

91
config/build.js Normal file
View File

@@ -0,0 +1,91 @@
const fs = require("fs-extra")
const path = require("path")
const MODULE_ENTRIES = {
SERVER: "index",
CLIENT: "client",
PROVIDERS: "providers",
ADAPTERS: "adapters",
JWT: "jwt",
ERRORS: "errors",
}
// Building submodule entries
const BUILD_TARGETS = {
[`${MODULE_ENTRIES.SERVER}.js`]: "module.exports = require('./dist/server').default\n",
[`${MODULE_ENTRIES.CLIENT}.js`]: "module.exports = require('./dist/client').default\n",
[`${MODULE_ENTRIES.ADAPTERS}.js`]: "module.exports = require('./dist/adapters').default\n",
[`${MODULE_ENTRIES.PROVIDERS}.js`]: "module.exports = require('./dist/providers').default\n",
[`${MODULE_ENTRIES.JWT}.js`]: "module.exports = require('./dist/lib/jwt').default\n",
[`${MODULE_ENTRIES.ERRORS}.js`]: "module.exports = require('./dist/lib/errors').default\n",
}
Object.entries(BUILD_TARGETS).forEach(([target, content]) => {
fs.writeFile(path.join(process.cwd(), target), content, (err) => {
if (err) throw err
console.log(`[build] created "${target}" in root folder`)
})
})
// Building types
const TYPES_TARGETS = [
`${MODULE_ENTRIES.SERVER}.d.ts`,
`${MODULE_ENTRIES.CLIENT}.d.ts`,
`${MODULE_ENTRIES.ADAPTERS}.d.ts`,
`${MODULE_ENTRIES.PROVIDERS}.d.ts`,
`${MODULE_ENTRIES.JWT}.d.ts`,
`${MODULE_ENTRIES.ERRORS}.d.ts`,
"internals",
]
TYPES_TARGETS.forEach((target) => {
fs.copy(
path.resolve("types", target),
path.join(process.cwd(), target),
(err) => {
if (err) throw err
console.log(`[build-types] copying "${target}" to root folder`)
}
)
})
// Building providers
const providersDir = path.join(process.cwd(), "/src/providers")
const files = fs
.readdirSync(providersDir, "utf8")
.filter((file) => file !== "index.js")
let importLines = ""
let exportLines = `export default {\n`
files.forEach((file) => {
const provider = fs.readFileSync(path.join(providersDir, file), "utf8")
try {
// NOTE: If this fails, the default export probably wasn't a named function.
// Always use a named function as default export.
// Eg.: export default function YourProvider ...
const { functionName } = provider.match(
/export default function (?<functionName>.+)\s?\(/
).groups
importLines += `import ${functionName} from "./${file}"\n`
exportLines += ` ${functionName},\n`
} catch (error) {
console.error(
[
`\nThe provider file '${file}' should have a single named default export`,
"Example: 'export default function YourProvider'\n\n",
].join("\n")
)
process.exit(1)
}
})
exportLines += `}\n`
fs.writeFile(
path.join(process.cwd(), "src/providers/index.js"),
[importLines, exportLines].join("\n")
)

View File

@@ -1,18 +0,0 @@
const path = require("path")
const fs = require("fs")
const providersPath = path.join(process.cwd(), "/src/providers")
const files = fs.readdirSync(providersPath, "utf8")
const providers = files.map((file) => {
const strippedProviderName = file.substring(0, file.indexOf("."))
return `"${strippedProviderName}"`
})
const result = `
// THIS FILE IS AUTOGENERATED. DO NOT EDIT.
export type OAuthProviderType =
| ${providers.join("\n | ")}`
fs.writeFileSync(path.join(providersPath, "oauth-types.ts"), result)

View File

@@ -1,3 +1,2 @@
import "regenerator-runtime/runtime"
import "@testing-library/jest-dom"
import "whatwg-fetch"

View File

@@ -1,19 +1,11 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
transform: {
"\\.(js|jsx|ts|tsx)$": [
"babel-jest",
{ configFile: "./config/babel.config.js" },
],
"\\.js$": ["babel-jest", { configFile: "./config/babel.config.js" }],
},
rootDir: "../src",
setupFilesAfterEnv: ["../config/jest-setup.js"],
collectCoverageFrom: ["!client/__tests__/**"],
testMatch: ["**/*.test.js"],
coverageDirectory: "../coverage",
testEnvironment: "jsdom",
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
}

View File

@@ -1,17 +0,0 @@
const fs = require("fs-extra")
const path = require("path")
try {
const packageJSONPath = path.join(process.cwd(), "package.json")
const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, "utf8"))
const sha8 = process.env.GITHUB_SHA.substr(0, 8)
const prNumber = process.env.PR_NUMBER
packageJSON.version = `0.0.0-pr.${prNumber}.${sha8}`
fs.writeFileSync(packageJSONPath, JSON.stringify(packageJSON))
} catch (error) {
console.error("Could not set PR version", error)
process.exit(1)
}

View File

@@ -1,8 +0,0 @@
name: "Determine version"
description: "Determines npm package version based on PR number and commit SHA"
outputs:
version:
description: "npm package version"
runs:
using: "node12"
main: "index.js"

View File

@@ -1,18 +0,0 @@
const fs = require("fs-extra")
const path = require("path")
const core = require("@actions/core")
try {
const packageJSONPath = path.join(process.cwd(), "package.json")
const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, "utf8"))
const sha8 = process.env.GITHUB_SHA.substr(0, 8)
const prNumber = process.env.PR_NUMBER
const packageVersion = `0.0.0-pr.${prNumber}.${sha8}`
packageJSON.version = packageVersion
core.setOutput("version", packageVersion)
fs.writeFileSync(packageJSONPath, JSON.stringify(packageJSON))
} catch (error) {
core.setFailed(error.message)
}

View File

@@ -5,13 +5,14 @@
// To work around this issue, this script is a manual step that wraps CSS in a
// JavaScript file that has the compiled CSS embedded in it, and exports only
// a function that returns the CSS as a string.
const fs = require("fs")
const path = require("path")
const fs = require('fs')
const path = require('path')
const pathToCss = path.join(__dirname, "../css/index.css")
const css = fs.readFileSync(pathToCss, "utf8")
const pathToCssJs = path.join(__dirname, '../dist/css/index.js')
const pathToCss = path.join(__dirname, '../dist/css/index.css')
const css = fs.readFileSync(pathToCss, 'utf8')
const cssWithEscapedQuotes = css.replace(/"/gm, '\\"')
const js = `module.exports = function() { return "${cssWithEscapedQuotes}" }`
const pathToCssJs = path.join(__dirname, "../css/index.js")
fs.writeFileSync(pathToCssJs, js)

32065
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@
"repository": "https://github.com/nextauthjs/next-auth.git",
"author": "Iain Collins <me@iaincollins.com>",
"main": "index.js",
"module": "index.js",
"types": "index.d.ts",
"types": "./index.d.ts",
"keywords": [
"react",
"nodejs",
@@ -21,115 +20,118 @@
"nextauth"
],
"exports": {
".": "./index.js",
"./jwt": "./jwt/index.js",
"./react": "./react/index.js",
"./core": "./core/index.js",
"./next": "./next/index.js",
"./client/_utils": "./client/_utils.js",
"./providers/*": "./providers/*.js"
".": "./dist/server/index.js",
"./jwt": "./dist/lib/jwt.js",
"./adapters": "./dist/adapters/index.js",
"./client": "./dist/client/index.js",
"./providers": "./dist/providers/index.js",
"./providers/*": "./dist/providers/*.js",
"./errors": "./dist/lib/errors.js"
},
"scripts": {
"build": "npm run build:js && npm run build:css",
"clean": "rm -rf client css lib providers core jwt react next index.d.ts index.js adapters.d.ts",
"build:js": "npm run clean && npm run generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"",
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js",
"dev:setup": "npm i && npm run generate-providers && npm run build:css && cd app && npm i",
"build:js": "node ./config/build.js && babel --config-file ./config/babel.config.js src --out-dir dist",
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
"dev:setup": "npm run build:css && cd app && npm i",
"dev": "cd app && npm run dev",
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .",
"watch": "npm run watch:js | npm run watch:css",
"watch:js": "babel --config-file ./config/babel.config.js --watch src --out-dir dist",
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
"test": "jest --config ./config/jest.config.js",
"test:ci": "npm run lint && npm run test -- --ci",
"test:ci": "npm run lint && npm run test:types && npm run test -- --ci",
"test:types": "dtslint types --onlyTestTsNext",
"prepublishOnly": "npm run build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"version:pr": "node ./config/version-pr",
"generate-providers": "node ./config/generate-providers.js"
"lint:fix": "eslint . --fix"
},
"files": [
"lib",
"css",
"jwt",
"react",
"next",
"client",
"providers",
"core",
"index.d.ts",
"dist",
"index.js",
"adapters.d.ts"
"index.d.ts",
"providers.js",
"providers.d.ts",
"adapters.js",
"adapters.d.ts",
"client.js",
"client.d.ts",
"errors.js",
"errors.d.ts",
"jwt.js",
"jwt.d.ts",
"internals"
],
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.15.4",
"@panva/hkdf": "^1.0.0",
"jose": "^4.1.2",
"@babel/runtime": "^7.14.0",
"@next-auth/prisma-legacy-adapter": "0.0.1-canary.127",
"@next-auth/typeorm-legacy-adapter": "0.0.2-canary.129",
"futoin-hkdf": "^1.3.2",
"jose": "^1.27.2",
"jsonwebtoken": "^8.5.1",
"nodemailer": "^6.4.16",
"oauth": "^0.9.15",
"openid-client": "^5.0.1",
"preact": "^10.5.14",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
"pkce-challenge": "^2.1.0",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.14",
"querystring": "^0.2.0"
},
"peerDependencies": {
"nodemailer": "^6.6.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react": "^16.13.1 || ^17",
"react-dom": "^16.13.1 || ^17"
},
"peerDependenciesMeta": {
"nodemailer": {
"optional": true
}
"peerOptionalDependencies": {
"mongodb": "^3.5.9",
"mysql": "^2.18.1",
"mssql": "^6.2.1",
"pg": "^8.2.1",
"@prisma/client": "^2.16.1"
},
"devDependencies": {
"@actions/core": "^1.6.0",
"@babel/cli": "^7.15.7",
"@babel/core": "^7.15.5",
"@babel/plugin-proposal-optional-catch-binding": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.15.0",
"@babel/preset-env": "^7.15.6",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.15.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.1.2",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^13.2.1",
"@types/node": "^16.11.6",
"@types/nodemailer": "^6.4.4",
"@types/oauth": "^0.9.1",
"@types/react": "^17.0.27",
"@types/react-dom": "^17.0.9",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"autoprefixer": "^10.3.7",
"babel-jest": "^27.3.0",
"babel-plugin-jsx-pragmatic": "^1.0.2",
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.6",
"@babel/plugin-proposal-optional-catch-binding": "^7.14.2",
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.13.13",
"@semantic-release/commit-analyzer": "^8.0.1",
"@semantic-release/github": "^7.2.0",
"@semantic-release/npm": "7.0.8",
"@semantic-release/release-notes-generator": "^9.0.1",
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.1.9",
"@types/nodemailer": "^6.4.2",
"@types/react": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"autoprefixer": "^9.7.6",
"babel-jest": "^26.6.3",
"babel-preset-preact": "^2.0.0",
"conventional-changelog-conventionalcommits": "4.6.1",
"cssnano": "^5.0.8",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard-with-typescript": "^21.0.1",
"eslint-plugin-import": "^2.24.2",
"eslint-plugin-jest": "^25.2.2",
"conventional-changelog-conventionalcommits": "4.4.0",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"dtslint": "^4.0.8",
"eslint": "^7.19.0",
"eslint-config-prettier": "^8.2.0",
"eslint-config-standard-with-typescript": "^19.0.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.3.6",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0",
"fs-extra": "^10.0.0",
"husky": "^7.0.2",
"jest": "^27.3.0",
"jest-watch-typeahead": "^1.0.0",
"msw": "^0.35.0",
"next": "v11.1.3-canary.0",
"postcss-cli": "^9.0.1",
"postcss-nested": "^5.0.6",
"prettier": "^2.4.1",
"pretty-quick": "^3.1.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.4.3",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-standard": "^5.0.0",
"husky": "^6.0.0",
"jest": "^26.6.3",
"msw": "^0.28.2",
"next": "^10.0.5",
"postcss-cli": "^7.1.1",
"postcss-nested": "^4.2.1",
"prettier": "^2.2.1",
"pretty-quick": "^3.1.0",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"typescript": "^4.1.3",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": "^12.19.0 || ^14.15.0 || ^16.13.0"
},
"prettier": {
"semi": false
},
@@ -144,25 +146,18 @@
],
"ignorePatterns": [
"node_modules",
"test",
"next-env.d.ts",
"types",
"www",
".next",
"dist",
"/core",
"/react.js"
"dist"
],
"globals": {
"localStorage": "readonly",
"location": "readonly",
"fetch": "readonly"
},
"rules": {
"camelcase": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/restrict-template-expressions": "off"
},
"overrides": [
{
"files": [

View File

@@ -1,109 +0,0 @@
import { Account, User, Awaitable } from "."
export interface AdapterUser extends User {
id: string
emailVerified: Date | null
}
export interface AdapterSession {
id: string
/** A randomly generated value that is used to get hold of the session. */
sessionToken: string
/** Used to connect the session to a particular user */
userId: string
expires: Date
}
export interface VerificationToken {
identifier: string
expires: Date
token: string
}
/**
* Using a custom adapter you can connect to any database backend or even several different databases.
* Custom adapters created and maintained by our community can be found in the adapters repository.
* Feel free to add a custom adapter from your project to the repository,
* or even become a maintainer of a certain adapter.
* Custom adapters can still be created and used in a project without being added to the repository.
*
* **Required methods**
*
* _(These methods are required for all sign in flows)_
* - `createUser`
* - `getUser`
* - `getUserByEmail`
* - `getUserByAccount`
* - `linkAccount`
* - `createSession`
* - `getSessionAndUser`
* - `updateSession`
* - `deleteSession`
* - `updateUser`
*
* _(Required to support email / passwordless sign in)_
*
* - `createVerificationToken`
* - `useVerificationToken`
*
* **Unimplemented methods**
*
* _(These methods will be required in a future release, but are not yet invoked)_
* - `deleteUser`
* - `unlinkAccount`
*
* [Community adapters](https://github.com/nextauthjs/adapters) |
* [Create a custom adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter)
*/
export interface Adapter {
createUser: (user: Omit<AdapterUser, "id">) => Awaitable<AdapterUser>
getUser: (id: string) => Awaitable<AdapterUser | null>
getUserByEmail: (email: string) => Awaitable<AdapterUser | null>
/** Using the provider id and the id of the user for a specific account, get the user. */
getUserByAccount: (
providerAccountId: Pick<Account, "provider" | "providerAccountId">
) => Awaitable<AdapterUser | null>
updateUser: (user: Partial<AdapterUser>) => Awaitable<AdapterUser>
/** @todo Implement */
deleteUser?: (
userId: string
) => Promise<void> | Awaitable<AdapterUser | null | undefined>
linkAccount: (
account: Account
) => Promise<void> | Awaitable<Account | null | undefined>
/** @todo Implement */
unlinkAccount?: (
providerAccountId: Pick<Account, "provider" | "providerAccountId">
) => Promise<void> | Awaitable<Account | undefined>
/** Creates a session for the user and returns it. */
createSession: (session: {
sessionToken: string
userId: string
expires: Date
}) => Awaitable<AdapterSession>
getSessionAndUser: (
sessionToken: string
) => Awaitable<{ session: AdapterSession; user: AdapterUser } | null>
updateSession: (
session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">
) => Awaitable<AdapterSession | null | undefined>
/**
* Deletes a session from the database.
* It is preferred that this method also returns the session
* that is being deleted for logging purposes.
*/
deleteSession: (
sessionToken: string
) => Promise<void> | Awaitable<AdapterSession | null | undefined>
createVerificationToken?: (
verificationToken: VerificationToken
) => Awaitable<VerificationToken | null | undefined>
/**
* Return verification token from the database
* and delete it so it cannot be used again.
*/
useVerificationToken?: (params: {
identifier: string
token: string
}) => Awaitable<VerificationToken | null>
}

View File

@@ -0,0 +1,36 @@
import { UnknownError } from "../lib/errors"
/**
* Handles adapter induced errors.
* @param {import("types/adapters").AdapterInstance} adapter
* @param {import("types").LoggerInstance} logger
* @return {import("types/adapters").AdapterInstance}
*/
export default function adapterErrorHandler(adapter, logger) {
return Object.keys(adapter).reduce((acc, method) => {
const name = capitalize(method)
const code = upperSnake(name, adapter.displayName)
const adapterMethod = adapter[method]
acc[method] = async (...args) => {
try {
logger.debug(code, ...args)
return await adapterMethod(...args)
} catch (error) {
logger.error(`${code}_ERROR`, error)
const e = new UnknownError(error)
e.name = `${name}Error`
throw e
}
}
return acc
}, {})
}
function capitalize(s) {
return `${s[0].toUpperCase()}${s.slice(1)}`
}
function upperSnake(s, prefix = "ADAPTER") {
return `${prefix}_${s.replace(/([A-Z])/g, "_$1")}`.toUpperCase()
}

10
src/adapters/index.js Normal file
View File

@@ -0,0 +1,10 @@
import * as TypeORM from "./typeorm"
import * as Prisma from "./prisma"
export { TypeORM, Prisma }
export default {
Default: TypeORM.Adapter,
TypeORM,
Prisma,
}

6
src/adapters/prisma.js Normal file
View File

@@ -0,0 +1,6 @@
/*
* Source code can be found at:
* https://github.com/nextauthjs/adapters/tree/canary/packages/prisma-legacy
*/
export { PrismaLegacyAdapter as Adapter } from "@next-auth/prisma-legacy-adapter"

9
src/adapters/typeorm.js Normal file
View File

@@ -0,0 +1,9 @@
/*
* Source code can be found at:
* https://github.com/nextauthjs/adapters/tree/canary/packages/typeorm-legacy
*/
export {
TypeORMLegacyAdapter as Adapter,
Models,
} from "@next-auth/typeorm-legacy-adapter"

View File

@@ -1,20 +1,17 @@
import { useState } from "react"
import { rest } from "msw"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockSession } from "./helpers/mocks"
import { printFetchCalls } from "./helpers/utils"
import { SessionProvider, useSession, signOut, getSession } from "../../react"
const origDocumentVisibility = document.visibilityState
const fetchSpy = jest.spyOn(global, "fetch")
import { Provider, useSession } from ".."
import userEvent from "@testing-library/user-event"
beforeAll(() => {
server.listen()
})
afterEach(() => {
jest.clearAllMocks()
server.resetHandlers()
changeTabVisibility(origDocumentVisibility)
fetchSpy.mockClear()
})
afterAll(() => {
@@ -22,167 +19,46 @@ afterAll(() => {
})
test("fetches the session once and re-uses it for different consumers", async () => {
const sessionRouteCall = jest.fn()
server.use(
rest.get("/api/auth/session", (req, res, ctx) => {
sessionRouteCall()
res(ctx.status(200), ctx.json(mockSession))
})
)
render(<ProviderFlow />)
expect(screen.getByTestId("session-1")).toHaveTextContent("loading")
expect(screen.getByTestId("session-2")).toHaveTextContent("loading")
await waitFor(() => {
expect(sessionRouteCall).toHaveBeenCalledTimes(1)
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/session",
expect.anything()
)
const session1 = screen.getByTestId("session-1").textContent
const session2 = screen.getByTestId("session-2").textContent
const session1 = screen.getByTestId("session-consumer-1").textContent
const session2 = screen.getByTestId("session-consumer-2").textContent
expect(session1).toEqual(session2)
})
})
test("when there's an existing session, it won't try to fetch a new one straightaway", async () => {
render(<ProviderFlow session={mockSession} />)
expect(fetchSpy).not.toHaveBeenCalled()
})
test("will refetch the session when the browser tab becomes active again", async () => {
render(<ProviderFlow session={mockSession} />)
expect(fetchSpy).not.toHaveBeenCalled()
// Hide the current tab
changeTabVisibility("hidden")
// Given the current tab got hidden, it should not attempt to re-fetch the session
expect(fetchSpy).not.toHaveBeenCalled()
// Make the tab again visible
changeTabVisibility("visible")
// Given the user made the tab visible again, now attempts to sync and re-fetch the session
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/session",
expect.anything()
)
})
})
test("will refetch the session if told to do so programmatically from another window", async () => {
render(<ProviderFlow session={mockSession} />)
expect(fetchSpy).not.toHaveBeenCalled()
// Hide the current tab
changeTabVisibility("hidden")
// Given the current tab got hidden, it should not attempt to re-fetch the session
expect(fetchSpy).not.toHaveBeenCalled()
// simulate sign-out triggered by another tab
signOut({ redirect: false })
// Given signed out in another tab, it attempts to sync and re-fetch the session
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/session",
expect.anything()
)
// We should have a call to sign-out and a call to refetch the session accordingly
expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(`
Array [
"GET /api/auth/csrf",
"POST /api/auth/signout",
"GET /api/auth/session",
]
`)
})
})
test("allows to customize how often the session will be re-fetched through polling", () => {
jest.useFakeTimers()
render(<ProviderFlow session={mockSession} refetchInterval={1} />)
// we provided a mock session so it shouldn't try to fetch a new one
expect(fetchSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith("/api/auth/session", expect.anything())
jest.advanceTimersByTime(1000)
// it should have tried to refetch the session, hence counting 2 calls to the session endpoint
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(`
Array [
"GET /api/auth/session",
"GET /api/auth/session",
]
`)
})
test("allows to customize the URL for session fetching", async () => {
const myPath = "/api/v1/auth"
server.use(
rest.get(`${myPath}/session`, (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSession))
)
)
render(<ProviderFlow session={mockSession} basePath={myPath} />)
// there's an existing session so it should not try to fetch a new one
expect(fetchSpy).not.toHaveBeenCalled()
// force a session refetch across all clients...
getSession()
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
`${myPath}/session`,
expect.anything()
)
})
})
function ProviderFlow(props) {
function ProviderFlow({ options = {} }) {
return (
<SessionProvider {...props}>
<SessionConsumer />
<SessionConsumer testId="2" />
</SessionProvider>
<>
<Provider options={options}>
<SessionConsumer />
<SessionConsumer testId="2" />
</Provider>
</>
)
}
function SessionConsumer({ testId = 1, ...rest }) {
const { data: session, status } = useSession(rest)
function SessionConsumer({ testId = 1 }) {
const [session, loading] = useSession()
if (loading) return <span>loading</span>
return (
<div data-testid={`session-${testId}`}>
{status === "loading" ? "loading" : JSON.stringify(session)}
<div data-testid={`session-consumer-${testId}`}>
{JSON.stringify(session)}
</div>
)
}
function changeTabVisibility(status) {
const visibleStates = ["visible", "hidden"]
if (!visibleStates.includes(status)) return
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: status,
})
document.dispatchEvent(new Event("visibilitychange"))
}

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockCSRFToken } from "./helpers/mocks"
import logger from "../../lib/logger"
import { getCsrfToken } from "../../react"
import { getCsrfToken } from ".."
import { rest } from "msw"
jest.mock("../../lib/logger", () => ({
@@ -78,10 +78,11 @@ test("when the fetch fails it'll throw a client fetch error", async () => {
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
path: "csrf",
error: new SyntaxError("Unexpected token s in JSON at position 0"),
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"csrf",
new SyntaxError("Unexpected token s in JSON at position 0")
)
})
})

View File

@@ -65,28 +65,26 @@ export const mockSignOutResponse = {
}
export const server = setupServer(
rest.post("http://localhost/api/auth/signout", (req, res, ctx) =>
rest.post("/api/auth/signout", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSignOutResponse))
),
rest.get("http://localhost/api/auth/session", (req, res, ctx) =>
rest.get("/api/auth/session", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSession))
),
rest.get("http://localhost/api/auth/csrf", (req, res, ctx) =>
rest.get("/api/auth/csrf", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockCSRFToken))
),
rest.get("http://localhost/api/auth/providers", (req, res, ctx) =>
rest.get("/api/auth/providers", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockProviders))
),
rest.post("http://localhost/api/auth/signin/github", (req, res, ctx) =>
rest.post("/api/auth/signin/github", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockGithubResponse))
),
rest.post("http://localhost/api/auth/callback/credentials", (req, res, ctx) =>
rest.post("/api/auth/callback/credentials", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockCredentialsResponse))
),
rest.post("http://localhost/api/auth/signin/email", (req, res, ctx) =>
rest.post("/api/auth/signin/email", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmailResponse))
),
rest.post("http://localhost/api/auth/_log", (req, res, ctx) =>
res(ctx.status(200))
)
rest.post("/api/auth/_log", (req, res, ctx) => res(ctx.status(200)))
)

View File

@@ -6,9 +6,3 @@ export function getBroadcastEvents() {
return { eventName, value: rest }
})
}
export function printFetchCalls(mockCalls) {
return mockCalls.map(([path, { method = "GET" }]) => {
return `${method.toUpperCase()} ${path}`
})
}

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockProviders } from "./helpers/mocks"
import { getProviders } from "../../react"
import { getProviders } from ".."
import logger from "../../lib/logger"
import { rest } from "msw"
@@ -56,10 +56,11 @@ test("when failing to fetch the providers, it'll log the error", async () => {
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
path: "providers",
error: new SyntaxError("Unexpected token s in JSON at position 0"),
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"providers",
new SyntaxError("Unexpected token s in JSON at position 0")
)
})
})

View File

@@ -3,7 +3,7 @@ import { rest } from "msw"
import { server, mockSession } from "./helpers/mocks"
import logger from "../../lib/logger"
import { useState, useEffect } from "react"
import { getSession } from "../../react"
import { getSession } from ".."
import { getBroadcastEvents } from "./helpers/utils"
jest.mock("../../lib/logger", () => ({
@@ -70,10 +70,11 @@ test("if there's an error fetching the session, it should log it", async () => {
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
path: "session",
error: new SyntaxError("Unexpected token S in JSON at position 0"),
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"session",
new SyntaxError("Unexpected token S in JSON at position 0")
)
})
})

View File

@@ -8,7 +8,7 @@ import {
mockEmailResponse,
mockGithubResponse,
} from "./helpers/mocks"
import { signIn } from "../../react"
import { signIn } from ".."
import { rest } from "msw"
const { location } = window
@@ -27,22 +27,12 @@ jest.mock("../../lib/logger", () => ({
beforeAll(() => {
server.listen()
let _href = window.location.href
// Allows to mutate `window.location`...
delete window.location
window.location = {
...location,
replace: jest.fn(),
reload: jest.fn(),
}
Object.defineProperty(window.location, "href", {
get: () => _href,
// whatwg-fetch or whatwg-url does not seem to work with relative URLs
set: (href) => {
_href = href.startsWith("/") ? `http://localhost${href}` : href
return _href
},
})
})
beforeEach(() => {
@@ -69,10 +59,9 @@ test.each`
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(
`http://localhost/api/auth/signin?${new URLSearchParams({
callbackUrl,
})}`
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
`/api/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
)
})
}
@@ -87,14 +76,14 @@ test.each`
async ({ provider }) => {
render(<SignInFlow providerId={provider} />)
const callbackUrl = window.location.href
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(
`http://localhost/api/auth/signin?${new URLSearchParams({
callbackUrl,
})}`
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
`/api/auth/signin?callbackUrl=${encodeURIComponent(
window.location.href
)}`
)
})
}
@@ -112,7 +101,8 @@ test.each`
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(mockUrl)
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrl)
})
}
)
@@ -129,7 +119,8 @@ test("redirection can't be stopped using an oauth provider", async () => {
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(mockGithubResponse.url)
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockGithubResponse.url)
})
})
@@ -145,7 +136,9 @@ test("redirection can be stopped using the 'credentials' provider", async () =>
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).not.toBe(mockCredentialsResponse.url)
expect(window.location.replace).not.toHaveBeenCalledWith(
mockCredentialsResponse.url
)
expect(screen.getByTestId("signin-result").textContent).not.toBe(
"no response"
@@ -172,7 +165,9 @@ test("redirection can be stopped using the 'email' provider", async () => {
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).not.toBe(mockEmailResponse.url)
expect(window.location.replace).not.toHaveBeenCalledWith(
mockEmailResponse.url
)
expect(screen.getByTestId("signin-result").textContent).not.toBe(
"no response"
@@ -195,7 +190,7 @@ test("if callback URL contains a hash we force a window reload when re-directing
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
server.use(
rest.post("http://localhost/api/auth/signin/email", (req, res, ctx) => {
rest.post("/api/auth/signin/email", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -211,7 +206,8 @@ test("if callback URL contains a hash we force a window reload when re-directing
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(mockUrlWithHash)
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
// the browser will not refresh the page if the redirect URL contains a hash, hence we force it on the client, see #1289
expect(window.location.reload).toHaveBeenCalledTimes(1)
})
@@ -222,7 +218,7 @@ test("params are propagated to the signin URL when supplied", async () => {
const authParams = "foo=bar&bar=foo"
server.use(
rest.post("http://localhost/api/auth/signin/github", (req, res, ctx) => {
rest.post("/api/auth/signin/github", (req, res, ctx) => {
matchedParams = req.url.search
return res(ctx.status(200), ctx.json(mockGithubResponse))
})
@@ -241,7 +237,7 @@ test("when it fails to fetch the providers, it redirected back to signin page",
const errorMsg = "Error when retrieving providers"
server.use(
rest.get("http://localhost/api/auth/providers", (req, res, ctx) =>
rest.get("/api/auth/providers", (req, res, ctx) =>
res(ctx.status(500), ctx.json(errorMsg))
)
)
@@ -251,13 +247,14 @@ test("when it fails to fetch the providers, it redirected back to signin page",
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(`http://localhost/api/auth/error`)
expect(window.location.replace).toHaveBeenCalledWith(`/api/auth/error`)
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
error: "Error when retrieving providers",
path: "providers",
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"providers",
errorMsg
)
})
})
@@ -272,7 +269,10 @@ function SignInFlow({
async function handleSignIn() {
const result = await signIn(
providerId,
{ callbackUrl, redirect },
{
callbackUrl,
redirect,
},
authorizationParams
)

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockSignOutResponse } from "./helpers/mocks"
import { signOut } from "../../react"
import { signOut } from ".."
import { rest } from "msw"
import { getBroadcastEvents } from "./helpers/utils"
@@ -10,11 +10,11 @@ const { location } = window
beforeAll(() => {
server.listen()
// Allows to mutate `window.location`...
delete window.location
window.location = {
...location,
replace: jest.fn(),
reload: jest.fn(),
href: location.href,
}
})
@@ -37,7 +37,7 @@ const callbackUrl = "https://redirects/to"
test("by default it redirects to the current URL if the server did not provide one", async () => {
server.use(
rest.post("http://localhost/api/auth/signout", (req, res, ctx) =>
rest.post("/api/auth/signout", (req, res, ctx) =>
res(ctx.status(200), ctx.json({ ...mockSignOutResponse, url: undefined }))
)
)
@@ -47,7 +47,8 @@ test("by default it redirects to the current URL if the server did not provide o
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(window.location.href)
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(window.location.href)
})
})
@@ -57,7 +58,10 @@ test("it redirects to the URL allowed by the server", async () => {
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(mockSignOutResponse.url)
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
mockSignOutResponse.url
)
})
})
@@ -65,7 +69,7 @@ test("if url contains a hash during redirection a page reload happens", async ()
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
server.use(
rest.post("http://localhost/api/auth/signout", (req, res, ctx) => {
rest.post("/api/auth/signout", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -81,7 +85,8 @@ test("if url contains a hash during redirection a page reload happens", async ()
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.href).toBe(mockUrlWithHash)
expect(window.location.reload).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
})
})

View File

@@ -1,140 +0,0 @@
import { rest } from "msw"
import { renderHook } from "@testing-library/react-hooks"
import { render, waitFor } from "@testing-library/react"
import { SessionProvider, useSession, signOut } from "../../react"
import { server, mockSession } from "./helpers/mocks"
const origConsoleError = console.error
const { location } = window
let _href = window.location.href
beforeAll(() => {
// Prevent noise on the terminal... `next-auth` will log to `console.error`
// every time a request fails, which makes the tests output very noisy...
console.error = jest.fn()
// Allows to mutate `window.location`...
delete window.location
window.location = {}
Object.defineProperty(window.location, "href", {
get: () => _href,
// whatwg-fetch or whatwg-url does not seem to work with relative URLs
set: (href) => {
_href = href.startsWith("/") ? `http://localhost${href}` : href
return _href
},
})
server.listen()
})
afterEach(() => {
server.resetHandlers()
_href = "http://localhost/"
// clear the internal session cache...
signOut({ redirect: false })
})
afterAll(() => {
console.error = origConsoleError
window.location = location
server.close()
})
test("it won't allow to fetch the session in isolation without a session context", () => {
function App() {
useSession()
return null
}
expect(() => render(<App />)).toThrow(
"[next-auth]: `useSession` must be wrapped in a <SessionProvider />"
)
})
test("when fetching the session, there won't be `data` and `status` will be 'loading'", () => {
const { result } = renderHook(() => useSession(), {
wrapper: SessionProvider,
})
expect(result.current.data).toBe(undefined)
expect(result.current.status).toBe("loading")
})
test("when session is fetched, `data` will contain the session data and `status` will be 'authenticated'", async () => {
const { result } = renderHook(() => useSession(), {
wrapper: SessionProvider,
})
await waitFor(() => {
expect(result.current.data).toEqual(mockSession)
expect(result.current.status).toBe("authenticated")
})
})
test("when it fails to fetch the session, `data` will be null and `status` will be 'unauthenticated'", async () => {
server.use(
rest.get(`http://localhost/api/auth/session`, (_, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const { result } = renderHook(() => useSession(), {
wrapper: SessionProvider,
})
return waitFor(() => {
expect(result.current.data).toEqual(null)
expect(result.current.status).toBe("unauthenticated")
})
})
test("it'll redirect to sign-in page if the session is required and the user is not authenticated", async () => {
server.use(
rest.get(`http://localhost/api/auth/session`, (req, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const callbackUrl = window.location.href
const { result } = renderHook(() => useSession({ required: true }), {
wrapper: SessionProvider,
})
await waitFor(() => {
expect(result.current.data).toEqual(null)
expect(result.current.status).toBe("loading")
})
expect(window.location.href).toBe(
`http://localhost/api/auth/signin?${new URLSearchParams({
error: "SessionRequired",
callbackUrl,
})}`
)
})
test("will call custom redirect logic if supplied when the user could not authenticate", async () => {
server.use(
rest.get(`http://localhost/api/auth/session`, (_, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const customRedirect = jest.fn()
const { result } = renderHook(
() => useSession({ required: true, onUnauthenticated: customRedirect }),
{
wrapper: SessionProvider,
}
)
await waitFor(() => {
expect(result.current.data).toEqual(null)
expect(result.current.status).toBe("loading")
})
expect(customRedirect).toHaveBeenCalledTimes(1)
})

View File

@@ -1,106 +0,0 @@
import type { IncomingMessage } from "http"
import type { LoggerInstance, Session } from ".."
export interface NextAuthClientConfig {
baseUrl: string
basePath: string
baseUrlServer: string
basePathServer: string
/** Stores last session response */
_session?: Session | null | undefined
/** Used for timestamp since last sycned (in seconds) */
_lastSync: number
/**
* Stores the `SessionProvider`'s session update method to be able to
* trigger session updates from places like `signIn` or `signOut`
*/
_getSession: (...args: any[]) => any
}
export interface CtxOrReq {
req?: IncomingMessage
ctx?: { req: IncomingMessage }
}
/**
* If passed 'appContext' via getInitialProps() in _app.js
* then get the req object from ctx and use that for the
* req value to allow `fetchData` to
* work seemlessly in getInitialProps() on server side
* pages *and* in _app.js.
*/
export async function fetchData<T = any>(
path: string,
__NEXTAUTH: NextAuthClientConfig,
logger: LoggerInstance,
{ ctx, req = ctx?.req }: CtxOrReq = {}
): Promise<T | null> {
try {
const options = req?.headers.cookie
? { headers: { cookie: req.headers.cookie } }
: {}
const res = await fetch(`${apiBaseUrl(__NEXTAUTH)}/${path}`, options)
const data = await res.json()
if (!res.ok) throw data
return Object.keys(data).length > 0 ? data : null // Return null if data empty
} catch (error) {
logger.error("CLIENT_FETCH_ERROR", {
error,
path,
...(req ? { header: req.headers } : {}),
})
return null
}
}
export function apiBaseUrl(__NEXTAUTH: NextAuthClientConfig) {
if (typeof window === "undefined") {
// Return absolute path when called server side
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
}
// Return relative path when called client side
return __NEXTAUTH.basePath
}
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
export function now() {
return Math.floor(Date.now() / 1000)
}
export interface BroadcastMessage {
event?: "session"
data?: { trigger?: "signout" | "getSession" }
clientId: string
timestamp: number
}
/**
* Inspired by [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
* Only not using it directly, because Safari does not support it.
*
* https://caniuse.com/?search=broadcastchannel
*/
export function BroadcastChannel(name = "nextauth.message") {
return {
/** Get notified by other tabs/windows. */
receive(onReceive: (message: BroadcastMessage) => void) {
const handler = (event) => {
if (event.key !== name) return
const message: BroadcastMessage = JSON.parse(event.newValue)
if (message?.event !== "session" || !message?.data) return
onReceive(message)
}
window.addEventListener("storage", handler)
return () => window.removeEventListener("storage", handler)
},
/** Notify other tabs/windows. */
post(message) {
if (typeof window === "undefined") return
localStorage.setItem(
name,
JSON.stringify({ ...message, timestamp: now() })
)
},
}
}

418
src/client/index.js Normal file
View File

@@ -0,0 +1,418 @@
// Note about signIn() and signOut() methods:
//
// On signIn() and signOut() we pass 'json: true' to request a response in JSON
// instead of HTTP as redirect URLs on other domains are not returned to
// requests made using the fetch API in the browser, and we need to ask the API
// to return the response as a JSON object (the end point still defaults to
// returning an HTTP response with a redirect for non-JavaScript clients).
//
// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks.
import {
useState,
useEffect,
useContext,
createContext,
createElement,
} from "react"
import _logger, { proxyLogger } from "../lib/logger"
import parseUrl from "../lib/parse-url"
// This behaviour mirrors the default behaviour for getting the site name that
// happens server side in server/index.js
// 1. An empty value is legitimate when the code is being invoked client side as
// relative URLs are valid in that context and so defaults to empty.
// 2. When invoked server side the value is picked up from an environment
// variable and defaults to 'http://localhost:3000'.
/** @type {import("types/internals/client").NextAuthConfig} */
const __NEXTAUTH = {
baseUrl: parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL).baseUrl,
basePath: parseUrl(process.env.NEXTAUTH_URL).basePath,
baseUrlServer: parseUrl(
process.env.NEXTAUTH_URL_INTERNAL ||
process.env.NEXTAUTH_URL ||
process.env.VERCEL_URL
).baseUrl,
basePathServer: parseUrl(
process.env.NEXTAUTH_URL_INTERNAL || process.env.NEXTAUTH_URL
).basePath,
keepAlive: 0,
clientMaxAge: 0,
// Properties starting with _ are used for tracking internal app state
_clientLastSync: 0,
_clientSyncTimer: null,
_eventListenersAdded: false,
_clientSession: undefined,
_getSession: () => {},
}
const logger = proxyLogger(_logger, __NEXTAUTH.basePath)
const broadcast = BroadcastChannel()
// Add event listners on load
if (typeof window !== "undefined" && !__NEXTAUTH._eventListenersAdded) {
__NEXTAUTH._eventListenersAdded = true
// Listen for storage events and update session if event fired from
// another window (but suppress firing another event to avoid a loop)
// Fetch new session data but tell it to not to fire another event to
// avoid an infinite loop.
// Note: We could pass session data through and do something like
// `setData(message.data)` but that can cause problems depending
// on how the session object is being used in the client; it is
// more robust to have each window/tab fetch it's own copy of the
// session object rather than share it across instances.
broadcast.receive(() => __NEXTAUTH._getSession({ event: "storage" }))
// Listen for document visibility change events and
// if visibility of the document changes, re-fetch the session.
document.addEventListener(
"visibilitychange",
() => {
!document.hidden && __NEXTAUTH._getSession({ event: "visibilitychange" })
},
false
)
}
// Context to store session data globally
/** @type {import("types/internals/client").SessionContext} */
const SessionContext = createContext()
export function useSession(session) {
const context = useContext(SessionContext)
if (context) return context
return _useSessionHook(session)
}
function _useSessionHook(session) {
const [data, setData] = useState(session)
const [loading, setLoading] = useState(!data)
useEffect(() => {
__NEXTAUTH._getSession = async ({ event = null } = {}) => {
try {
const triggredByEvent = event !== null
const triggeredByStorageEvent = event === "storage"
const clientMaxAge = __NEXTAUTH.clientMaxAge
const clientLastSync = parseInt(__NEXTAUTH._clientLastSync)
const currentTime = _now()
const clientSession = __NEXTAUTH._clientSession
// Updates triggered by a storage event *always* trigger an update and we
// always update if we don't have any value for the current session state.
if (!triggeredByStorageEvent && clientSession !== undefined) {
if (clientMaxAge === 0 && triggredByEvent !== true) {
// If there is no time defined for when a session should be considered
// stale, then it's okay to use the value we have until an event is
// triggered which updates it.
return
} else if (clientMaxAge > 0 && clientSession === null) {
// If the client doesn't have a session then we don't need to call
// the server to check if it does (if they have signed in via another
// tab or window that will come through as a triggeredByStorageEvent
// event and will skip this logic)
return
} else if (
clientMaxAge > 0 &&
currentTime < clientLastSync + clientMaxAge
) {
// If the session freshness is within clientMaxAge then don't request
// it again on this call (avoids too many invokations).
return
}
}
if (clientSession === undefined) {
__NEXTAUTH._clientSession = null
}
// Update clientLastSync before making response to avoid repeated
// invokations that would otherwise be triggered while we are still
// waiting for a response.
__NEXTAUTH._clientLastSync = _now()
// If this call was invoked via a storage event (i.e. another window) then
// tell getSession not to trigger an event when it calls to avoid an
// infinate loop.
const newClientSessionData = await getSession({
triggerEvent: !triggeredByStorageEvent,
})
// Save session state internally, just so we can track that we've checked
// if a session exists at least once.
__NEXTAUTH._clientSession = newClientSessionData
setData(newClientSessionData)
setLoading(false)
} catch (error) {
logger.error("CLIENT_USE_SESSION_ERROR", error)
setLoading(false)
}
}
__NEXTAUTH._getSession()
})
return [data, loading]
}
export async function getSession(ctx) {
const session = await _fetchData("session", ctx)
if (ctx?.triggerEvent ?? true) {
broadcast.post({ event: "session", data: { trigger: "getSession" } })
}
return session
}
export async function getCsrfToken(ctx) {
return (await _fetchData("csrf", ctx))?.csrfToken
}
export async function getProviders() {
return await _fetchData("providers")
}
export async function signIn(provider, options = {}, authorizationParams = {}) {
const { callbackUrl = window.location.href, redirect = true } = options
const baseUrl = _apiBaseUrl()
const providers = await getProviders()
if (!providers) {
return window.location.replace(`${baseUrl}/error`)
}
if (!(provider in providers)) {
return window.location.replace(
`${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
)
}
const isCredentials = providers[provider].type === "credentials"
const isEmail = providers[provider].type === "email"
const isSupportingReturn = isCredentials || isEmail
const signInUrl = isCredentials
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
const res = await fetch(_signInUrl, {
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
})
const data = await res.json()
if (redirect || !isSupportingReturn) {
const url = data.url ?? callbackUrl
window.location.replace(url)
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes("#")) window.location.reload()
return
}
const error = new URL(data.url).searchParams.get("error")
if (res.ok) {
await __NEXTAUTH._getSession({ event: "storage" })
}
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : data.url,
}
}
export async function signOut(options = {}) {
const { callbackUrl = window.location.href, redirect = true } = options
const baseUrl = _apiBaseUrl()
const fetchOptions = {
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
}
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
const data = await res.json()
broadcast.post({ event: "session", data: { trigger: "signout" } })
if (redirect) {
const url = data.url ?? callbackUrl
window.location.replace(url)
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes("#")) window.location.reload()
return
}
await __NEXTAUTH._getSession({ event: "storage" })
return data
}
// Method to set options. The documented way is to use the provider, but this
// method is being left in as an alternative, that will be helpful if/when we
// expose a vanilla JavaScript version that doesn't depend on React.
export function setOptions({
baseUrl,
basePath,
clientMaxAge,
keepAlive,
} = {}) {
if (baseUrl) __NEXTAUTH.baseUrl = baseUrl
if (basePath) __NEXTAUTH.basePath = basePath
if (clientMaxAge) __NEXTAUTH.clientMaxAge = clientMaxAge
if (keepAlive) {
__NEXTAUTH.keepAlive = keepAlive
if (typeof window === "undefined") return
// Clear existing timer (if there is one)
if (__NEXTAUTH._clientSyncTimer !== null) {
clearTimeout(__NEXTAUTH._clientSyncTimer)
}
// Set next timer to trigger in number of seconds
__NEXTAUTH._clientSyncTimer = setTimeout(async () => {
// Only invoke keepalive when a session exists
if (!__NEXTAUTH._clientSession) return
await __NEXTAUTH._getSession({ event: "timer" })
}, keepAlive * 1000)
}
}
export function Provider({ children, session, options }) {
setOptions(options)
return createElement(
SessionContext.Provider,
{ value: useSession(session) },
children
)
}
/**
* If passed 'appContext' via getInitialProps() in _app.js
* then get the req object from ctx and use that for the
* req value to allow _fetchData to
* work seemlessly in getInitialProps() on server side
* pages *and* in _app.js.
*/
async function _fetchData(path, { ctx, req = ctx?.req } = {}) {
try {
const baseUrl = await _apiBaseUrl()
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
const res = await fetch(`${baseUrl}/${path}`, options)
const data = await res.json()
if (!res.ok) throw data
return Object.keys(data).length > 0 ? data : null // Return null if data empty
} catch (error) {
logger.error("CLIENT_FETCH_ERROR", path, error)
return null
}
}
function _apiBaseUrl() {
if (typeof window === "undefined") {
// NEXTAUTH_URL should always be set explicitly to support server side calls - log warning if not set
if (!process.env.NEXTAUTH_URL) {
logger.warn("NEXTAUTH_URL", "NEXTAUTH_URL environment variable not set")
}
// Return absolute path when called server side
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
}
// Return relative path when called client side
return __NEXTAUTH.basePath
}
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
function _now() {
return Math.floor(Date.now() / 1000)
}
/**
* Inspired by [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
* Only not using it directly, because Safari does not support it.
*
* https://caniuse.com/?search=broadcastchannel
*/
function BroadcastChannel(name = "nextauth.message") {
return {
/**
* Get notified by other tabs/windows.
* @param {(message: import("types/internals/client").BroadcastMessage) => void} onReceive
*/
receive(onReceive) {
if (typeof window === "undefined") return
window.addEventListener("storage", async (event) => {
if (event.key !== name) return
/** @type {import("types/internals/client").BroadcastMessage} */
const message = JSON.parse(event.newValue)
if (message?.event !== "session" || !message?.data) return
onReceive(message)
})
},
/** Notify other tabs/windows. */
post(message) {
if (typeof localStorage === "undefined") return
localStorage.setItem(
name,
JSON.stringify({ ...message, timestamp: _now() })
)
},
}
}
// Some methods are exported with more than one name. This provides some
// flexibility over how they can be invoked and backwards compatibility
// with earlier releases. These should be removed in a newer release, as it only
// creates problems for bundlers and adds confusion to users. TypeScript declarations
// will provide sufficient help when importing
export {
setOptions as options,
getSession as session,
getProviders as providers,
getCsrfToken as csrfToken,
signIn as signin,
signOut as signout,
}
export default {
getSession,
getCsrfToken,
getProviders,
useSession,
signIn,
signOut,
Provider,
/* Deprecated / unsupported features below this line */
// Use setOptions() set options globally in the app.
setOptions,
// Some methods are exported with more than one name. This provides some
// flexibility over how they can be invoked and backwards compatibility
// with earlier releases.
options: setOptions,
session: getSession,
providers: getProviders,
csrfToken: getCsrfToken,
signin: signIn,
signout: signOut,
}

View File

@@ -1,91 +0,0 @@
import { EventCallbacks, LoggerInstance } from ".."
import { Adapter } from "../adapters"
/**
* Same as the default `Error`, but it is JSON serializable.
* @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
*/
export class UnknownError extends Error {
constructor(error) {
// Support passing error or string
super(error?.message ?? error)
this.name = "UnknownError"
if (error instanceof Error) {
this.stack = error.stack
}
}
toJSON() {
return {
name: this.name,
message: this.message,
stack: this.stack,
}
}
}
export class OAuthCallbackError extends UnknownError {
name = "OAuthCallbackError"
}
/**
* Thrown when an Email address is already associated with an account
* but the user is trying an OAuth account that is not linked to it.
*/
export class AccountNotLinkedError extends UnknownError {
name = "AccountNotLinkedError"
}
type Method = (...args: any[]) => Promise<any>
export function upperSnake(s: string) {
return s.replace(/([A-Z])/g, "_$1").toUpperCase()
}
export function capitalize(s: string) {
return `${s[0].toUpperCase()}${s.slice(1)}`
}
/**
* Wraps an object of methods and adds error handling.
*/
export function eventsErrorHandler(
methods: Partial<EventCallbacks>,
logger: LoggerInstance
): Partial<EventCallbacks> {
return Object.keys(methods).reduce<any>((acc, name) => {
acc[name] = async (...args: any[]) => {
try {
const method: Method = methods[name]
return await method(...args)
} catch (e) {
logger.error(`${upperSnake(name)}_EVENT_ERROR`, e)
}
}
return acc
}, {})
}
/** Handles adapter induced errors. */
export function adapterErrorHandler(
adapter: Adapter | undefined,
logger: LoggerInstance
): Adapter | undefined {
if (!adapter) return
return Object.keys(adapter).reduce<any>((acc, name) => {
acc[name] = async (...args: any[]) => {
try {
logger.debug(`adapter_${name}`, { args })
const method: Method = adapter[name as any]
return await method(...args)
} catch (error) {
logger.error(`adapter_error_${name}`, error)
const e = new UnknownError(error)
e.name = `${capitalize(name)}Error`
throw e
}
}
return acc
}, {})
}

View File

@@ -1,210 +0,0 @@
import logger from "../lib/logger"
import * as routes from "./routes"
import renderPage from "./pages"
import type { NextAuthOptions } from "./types"
import { init } from "./init"
import { Cookie } from "./lib/cookie"
import { NextAuthAction } from "../lib/types"
export interface IncomingRequest {
/** @default "http://localhost:3000" */
host?: string
method: string
cookies?: Record<string, any>
headers?: Record<string, any>
query?: Record<string, any>
body?: Record<string, any>
action: NextAuthAction
providerId?: string
error?: string
}
export interface NextAuthHeader {
key: string
value: string
}
export interface OutgoingResponse<
Body extends string | Record<string, any> | any[] = any
> {
status?: number
headers?: NextAuthHeader[]
body?: Body
redirect?: string
cookies?: Cookie[]
}
interface NextAuthHandlerParams {
req: IncomingRequest
options: NextAuthOptions
}
export async function NextAuthHandler<
Body extends string | Record<string, any> | any[]
>(params: NextAuthHandlerParams): Promise<OutgoingResponse<Body>> {
const { options: userOptions, req } = params
const { action, providerId, error } = req
const { options, cookies } = await init({
userOptions,
action,
providerId,
host: req.host,
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
csrfToken: req.body?.csrfToken,
cookies: req.cookies,
isPost: req.method === "POST",
})
const sessionToken =
req.cookies?.[options.cookies.sessionToken.name] ||
req.headers?.Authorization?.replace("Bearer ", "")
const codeVerifier = req.cookies?.[options.cookies.pkceCodeVerifier.name]
if (req.method === "GET") {
const render = renderPage({ options, query: req.query, cookies })
const { pages } = options
switch (action) {
case "providers":
return (await routes.providers(options.providers)) as any
case "session":
return (await routes.session({ options, sessionToken })) as any
case "csrf":
return {
headers: [{ key: "Content-Type", value: "application/json" }],
body: { csrfToken: options.csrfToken } as any,
cookies,
}
case "signin":
if (pages.signIn) {
let signinUrl = `${pages.signIn}${
pages.signIn.includes("?") ? "&" : "?"
}callbackUrl=${options.callbackUrl}`
if (error) signinUrl = `${signinUrl}&error=${error}`
return { redirect: signinUrl, cookies }
}
return render.signin()
case "signout":
if (pages.signOut) return { redirect: pages.signOut, cookies }
return render.signout()
case "callback":
if (options.provider) {
const callback = await routes.callback({
body: req.body,
query: req.query,
method: req.method,
headers: req.headers,
options,
sessionToken,
codeVerifier,
})
if (callback.cookies) cookies.push(...callback.cookies)
return { ...callback, cookies }
}
break
case "verify-request":
if (pages.verifyRequest) {
return { redirect: pages.verifyRequest, cookies }
}
return render.verifyRequest()
case "error":
if (pages.error) {
return {
redirect: `${pages.error}${
pages.error.includes("?") ? "&" : "?"
}error=${error}`,
cookies,
}
}
// These error messages are displayed in line on the sign in page
if (
[
"Signin",
"OAuthSignin",
"OAuthCallback",
"OAuthCreateAccount",
"EmailCreateAccount",
"Callback",
"OAuthAccountNotLinked",
"EmailSignin",
"CredentialsSignin",
"SessionRequired",
].includes(error as string)
) {
return { redirect: `${options.url}/signin?error=${error}`, cookies }
}
return render.error({ error })
default:
}
} else if (req.method === "POST") {
switch (action) {
case "signin":
// Verified CSRF Token required for all sign in routes
if (options.csrfTokenVerified && options.provider) {
const signin = await routes.signin({
query: req.query,
body: req.body,
options,
})
if (signin.cookies) cookies.push(...signin.cookies)
return { ...signin, cookies }
}
return { redirect: `${options.url}/signin?csrf=true`, cookies }
case "signout":
// Verified CSRF Token required for signout
if (options.csrfTokenVerified) {
const signout = await routes.signout({ options, sessionToken })
if (signout.cookies) cookies.push(...signout.cookies)
return { ...signout, cookies }
}
return { redirect: `${options.url}/signout?csrf=true`, cookies }
case "callback":
if (options.provider) {
// Verified CSRF Token required for credentials providers only
if (
options.provider.type === "credentials" &&
!options.csrfTokenVerified
) {
return { redirect: `${options.url}/signin?csrf=true`, cookies }
}
const callback = await routes.callback({
body: req.body,
query: req.query,
method: req.method,
headers: req.headers,
options,
sessionToken,
codeVerifier,
})
if (callback.cookies) cookies.push(...callback.cookies)
return { ...callback, cookies }
}
break
case "_log":
if (userOptions.logger) {
try {
const { code, level, ...metadata } = req.body ?? {}
logger[level](code, metadata)
} catch (error) {
// If logging itself failed...
logger.error("LOGGER_ERROR", error)
}
}
return {}
default:
}
}
return {
status: 400,
body: `Error: Action ${action} with HTTP ${req.method} is not supported by NextAuth.js` as any,
}
}

View File

@@ -1,151 +0,0 @@
import { NextAuthOptions } from ".."
import logger from "../lib/logger"
import parseUrl from "../lib/parse-url"
import { InternalOptions } from "../lib/types"
import { adapterErrorHandler, eventsErrorHandler } from "./errors"
import parseProviders from "./lib/providers"
import createSecret from "./lib/utils"
import * as cookie from "./lib/cookie"
import * as jwt from "../jwt"
import { defaultCallbacks } from "./lib/default-callbacks"
import { createCSRFToken } from "./lib/csrf-token"
import { createCallbackUrl } from "./lib/callback-url"
import { IncomingRequest } from "."
interface InitParams {
host?: string
userOptions: NextAuthOptions
providerId?: string
action: InternalOptions["action"]
/** Callback URL value extracted from the incoming request. */
callbackUrl?: string
/** CSRF token value extracted from the incoming request. From body if POST, from query if GET */
csrfToken?: string
/** Is the incoming request a POST request? */
isPost: boolean
cookies: IncomingRequest["cookies"]
}
/** Initialize all internal options and cookies. */
export async function init({
userOptions,
providerId,
action,
host,
cookies: reqCookies,
callbackUrl: reqCallbackUrl,
csrfToken: reqCsrfToken,
isPost,
}: InitParams): Promise<{
options: InternalOptions
cookies: cookie.Cookie[]
}> {
// If debug enabled, set ENV VAR so that logger logs debug messages
if (userOptions.debug) {
;(process.env._NEXTAUTH_DEBUG as any) = true
}
const url = parseUrl(host)
const secret = createSecret({ userOptions, url })
const { providers, provider } = parseProviders({
providers: userOptions.providers,
url,
providerId,
})
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
// User provided options are overriden by other options,
// except for the options with special handling above
const options: InternalOptions = {
debug: false,
pages: {},
theme: {
colorScheme: "auto",
logo: "",
brandColor: "",
},
// Custom options override defaults
...userOptions,
// These computed settings can have values in userOptions but we override them
// and are request-specific.
url,
action,
provider,
cookies: {
...cookie.defaultCookies(
userOptions.useSecureCookies ?? url.base.startsWith("https://")
),
// Allow user cookie options to override any cookie settings above
...userOptions.cookies,
},
secret,
providers,
// Session options
session: {
jwt: !userOptions.adapter, // If no adapter specified, force use of JSON Web Tokens (stateless)
maxAge,
updateAge: 24 * 60 * 60,
...userOptions.session,
},
// JWT options
jwt: {
secret, // Use application secret if no keys specified
maxAge, // same as session maxAge,
encode: jwt.encode,
decode: jwt.decode,
...userOptions.jwt,
},
// Event messages
events: eventsErrorHandler(userOptions.events ?? {}, logger),
adapter: adapterErrorHandler(userOptions.adapter, logger),
// Callback functions
callbacks: { ...defaultCallbacks, ...userOptions.callbacks },
logger,
callbackUrl: process.env.NEXTAUTH_URL ?? "http://localhost:3000",
}
// Init cookies
const cookies: cookie.Cookie[] = []
const {
csrfToken,
cookie: csrfCookie,
csrfTokenVerified,
} = createCSRFToken({
options,
cookieValue: reqCookies?.[options.cookies.csrfToken.name],
isPost,
bodyValue: reqCsrfToken,
})
options.csrfToken = csrfToken
options.csrfTokenVerified = csrfTokenVerified
if (csrfCookie) {
cookies.push({
name: options.cookies.csrfToken.name,
value: csrfCookie,
options: options.cookies.csrfToken.options,
})
}
const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({
options,
cookieValue: reqCookies?.[options.cookies.callbackUrl.name],
paramValue: reqCallbackUrl,
})
options.callbackUrl = callbackUrl
if (callbackUrlCookie) {
cookies.push({
name: options.cookies.callbackUrl.name,
value: callbackUrlCookie,
options: options.cookies.callbackUrl.options,
})
}
return { options, cookies }
}

View File

@@ -1,42 +0,0 @@
import { InternalOptions } from "../../lib/types"
interface CreateCallbackUrlParams {
options: InternalOptions
/** Try reading value from request body (POST) then from query param (GET) */
paramValue?: string
cookieValue?: string
}
/**
* Get callback URL based on query param / cookie + validation,
* and add it to `req.options.callbackUrl`.
*/
export async function createCallbackUrl({
options,
paramValue,
cookieValue,
}: CreateCallbackUrlParams) {
const { url, callbacks } = options
let callbackUrl = url.origin
if (paramValue) {
// If callbackUrl form field or query parameter is passed try to use it if allowed
callbackUrl = await callbacks.redirect({
url: paramValue,
baseUrl: url.origin,
})
} else if (cookieValue) {
// If no callbackUrl specified, try using the value from the cookie if allowed
callbackUrl = await callbacks.redirect({
url: cookieValue,
baseUrl: url.origin,
})
}
return {
callbackUrl,
// Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow
callbackUrlCookie: callbackUrl !== cookieValue ? callbackUrl : undefined,
}
}

View File

@@ -1,54 +0,0 @@
import { createHash, randomBytes } from "crypto"
import { InternalOptions } from "../../lib/types"
interface CreateCSRFTokenParams {
options: InternalOptions
cookieValue?: string
isPost: boolean
bodyValue?: string
}
/**
* Ensure CSRF Token cookie is set for any subsequent requests.
* Used as part of the strategy for mitigation for CSRF tokens.
*
* Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
* where 'token' is the CSRF token and 'hash' is a hash made of the token and
* the secret, and the two values are joined by a pipe '|'. By storing the
* value and the hash of the value (with the secret used as a salt) we can
* verify the cookie was set by the server and not by a malicous attacker.
*
* For more details, see the following OWASP links:
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
* https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
*/
export function createCSRFToken({
options,
cookieValue,
isPost,
bodyValue,
}: CreateCSRFTokenParams) {
if (cookieValue) {
const [csrfToken, csrfTokenHash] = cookieValue.split("|")
const expectedCsrfTokenHash = createHash("sha256")
.update(`${csrfToken}${options.secret}`)
.digest("hex")
if (csrfTokenHash === expectedCsrfTokenHash) {
// If hash matches then we trust the CSRF token value
// If this is a POST request and the CSRF Token in the POST request matches
// the cookie we have already verified is the one we have set, then the token is verified!
const csrfTokenVerified = isPost && csrfToken === bodyValue
return { csrfTokenVerified, csrfToken }
}
}
// New CSRF token
const csrfToken = randomBytes(32).toString("hex")
const csrfTokenHash = createHash("sha256")
.update(`${csrfToken}${options.secret}`)
.digest("hex")
const cookie = `${csrfToken}|${csrfTokenHash}`
return { cookie, csrfToken }
}

View File

@@ -1,17 +0,0 @@
import { CallbacksOptions } from "../.."
export const defaultCallbacks: CallbacksOptions = {
signIn() {
return true
},
redirect({ url, baseUrl }) {
if (url.startsWith(baseUrl)) return url
return baseUrl
},
session({ session }) {
return session
},
jwt({ token }) {
return token
},
}

View File

@@ -1,54 +0,0 @@
import { randomBytes } from "crypto"
import { InternalOptions } from "../../../lib/types"
import { hashToken } from "../utils"
/**
* Starts an e-mail login flow, by generating a token,
* and sending it to the user's e-mail (with the help of a DB adapter)
*/
export default async function email(
identifier: string,
options: InternalOptions<"email">
) {
const { url, adapter, provider, logger, callbackUrl } = options
// Generate token
const token =
(await provider.generateVerificationToken?.()) ??
randomBytes(32).toString("hex")
const ONE_DAY_IN_SECONDS = 86400
const expires = new Date(
Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
)
// Save in database
// @ts-expect-error
await adapter.createVerificationToken({
identifier,
token: hashToken(token, options),
expires,
})
// Generate a link with email, unhashed token and callback url
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
const _url = `${url}/callback/${provider.id}?${params}`
try {
// Send to user
await provider.sendVerificationRequest({
identifier,
token,
expires,
url: _url,
provider,
})
} catch (error) {
logger.error("SEND_VERIFICATION_EMAIL_ERROR", {
identifier,
url,
error,
})
throw new Error("SEND_VERIFICATION_EMAIL_ERROR")
}
}

View File

@@ -1,73 +0,0 @@
import { openidClient } from "./client"
import { oAuth1Client } from "./client-legacy"
import { createState } from "./state-handler"
import { createPKCE } from "./pkce-handler"
import { InternalOptions } from "../../../lib/types"
import { IncomingRequest } from "../.."
import { Cookie } from "../cookie"
/**
*
* Generates an authorization/request token URL.
*
* [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/) | [OAuth 1](https://oauth.net/core/1.0a/#auth_step2)
*/
export default async function getAuthorizationUrl(params: {
options: InternalOptions<"oauth">
query: IncomingRequest["query"]
}) {
const { options, query } = params
const { logger, provider } = options
try {
let params: any = {}
if (typeof provider.authorization === "string") {
const parsedUrl = new URL(provider.authorization)
const parsedParams = Object.fromEntries(parsedUrl.searchParams.entries())
params = { ...params, ...parsedParams }
} else {
params = { ...params, ...provider.authorization?.params }
}
params = { ...params, ...query }
// Handle OAuth v1.x
if (provider.version?.startsWith("1.")) {
const client = oAuth1Client(options)
const tokens = (await client.getOAuthRequestToken(params)) as any
const url = `${
// @ts-expect-error
provider.authorization?.url ?? provider.authorization
}?${new URLSearchParams({
oauth_token: tokens.oauth_token,
oauth_token_secret: tokens.oauth_token_secret,
...tokens.params,
})}`
logger.debug("GET_AUTHORIZATION_URL", { url })
return { redirect: url }
}
const cookies: Cookie[] = []
const client = await openidClient(options)
const pkce = await createPKCE(options)
if (pkce?.cookie) {
cookies.push(pkce.cookie)
}
const url = client.authorizationUrl({
...params,
...pkce,
state: createState(options),
})
logger.debug("GET_AUTHORIZATION_URL", { url })
return {
redirect: url,
cookies,
}
} catch (error) {
logger.error("GET_AUTHORIZATION_URL_ERROR", error)
throw error
}
}

View File

@@ -1,201 +0,0 @@
import { TokenSet } from "openid-client"
import { openidClient } from "./client"
import { oAuth1Client } from "./client-legacy"
import { getState } from "./state-handler"
import { usePKCECodeVerifier } from "./pkce-handler"
import { OAuthCallbackError } from "../../errors"
import { Account, LoggerInstance, Profile } from "../../.."
import { OAuthChecks, OAuthConfig } from "../../../providers"
import { InternalOptions } from "../../../lib/types"
import { IncomingRequest, OutgoingResponse } from "../.."
export default async function oAuthCallback(params: {
options: InternalOptions<"oauth">
query: IncomingRequest["query"]
body: IncomingRequest["body"]
method: IncomingRequest["method"]
codeVerifier?: string
}): Promise<GetProfileResult & { cookies?: OutgoingResponse["cookies"] }> {
const { options, query, body, method, codeVerifier } = params
const { logger, provider } = options
const errorMessage = body?.error ?? query?.error
if (errorMessage) {
const error = new Error(errorMessage)
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", {
error,
error_description: query?.error_description,
body,
providerId: provider.id,
})
throw error
}
if (provider.version?.startsWith("1.")) {
try {
const client = await oAuth1Client(options)
// Handle OAuth v1.x
const { oauth_token, oauth_verifier } = query ?? {}
// @ts-expect-error
const tokens: TokenSet = await client.getOAuthAccessToken(
oauth_token,
// @ts-expect-error
null,
oauth_verifier
)
// @ts-expect-error
let profile: Profile = await client.get(
provider.profileUrl,
tokens.oauth_token,
tokens.oauth_token_secret
)
if (typeof profile === "string") {
profile = JSON.parse(profile)
}
return await getProfile({ profile, tokens, provider, logger })
} catch (error) {
logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error)
throw error
}
}
try {
const client = await openidClient(options)
let tokens: TokenSet
const pkce = await usePKCECodeVerifier({
options,
codeVerifier,
})
const checks: OAuthChecks = {
code_verifier: pkce?.codeVerifier,
state: getState(options),
}
const params = {
...client.callbackParams({
url: `http://n?${new URLSearchParams(query)}`,
// TODO: Ask to allow object to be passed upstream:
// https://github.com/panva/node-openid-client/blob/3ae206dfc78c02134aa87a07f693052c637cab84/types/index.d.ts#L439
// @ts-expect-error
body,
method,
}),
// @ts-expect-error
...provider.token?.params,
}
// @ts-expect-error
if (provider.token?.request) {
// @ts-expect-error
const response = await provider.token.request({
provider,
params,
checks,
client,
})
tokens = new TokenSet(response.tokens)
} else if (provider.idToken) {
tokens = await client.callback(provider.callbackUrl, params, checks)
} else {
tokens = await client.oauthCallback(provider.callbackUrl, params, checks)
}
// REVIEW: How can scope be returned as an array?
if (Array.isArray(tokens.scope)) {
tokens.scope = tokens.scope.join(" ")
}
let profile: Profile
// @ts-expect-error
if (provider.userinfo?.request) {
// @ts-expect-error
profile = await provider.userinfo.request({
provider,
tokens,
client,
})
} else if (provider.idToken) {
profile = tokens.claims()
} else {
profile = await client.userinfo(tokens, {
// @ts-expect-error
params: provider.userinfo?.params,
})
}
// If a user object is supplied (e.g. Apple provider) add it to the profile object
// TODO: Remove/extract to Apple provider?
profile.user = JSON.parse(body?.user ?? query?.user ?? null)
const profileResult = await getProfile({
profile,
provider,
tokens,
logger,
})
return {
...profileResult,
cookies: pkce?.cookie ? [pkce.cookie] : undefined,
}
} catch (error) {
logger.error("OAUTH_CALLBACK_ERROR", { error, providerId: provider.id })
throw new OAuthCallbackError(error)
}
}
export interface GetProfileParams {
profile: Profile
tokens: TokenSet
provider: OAuthConfig<any>
logger: LoggerInstance
}
export interface GetProfileResult {
// @ts-expect-error
profile: ReturnType<OAuthConfig["profile"]> | null
account: Omit<Account, "userId"> | null
OAuthProfile: Profile
}
/** Returns profile, raw profile and auth provider details */
async function getProfile({
profile: OAuthProfile,
tokens,
provider,
logger,
}: GetProfileParams): Promise<GetProfileResult> {
try {
logger.debug("PROFILE_DATA", { OAuthProfile })
// @ts-expect-error
const profile = await provider.profile(OAuthProfile, tokens)
profile.email = profile.email?.toLowerCase()
// Return profile, raw profile and auth provider details
return {
profile,
account: {
provider: provider.id,
type: provider.type,
providerAccountId: profile.id.toString(),
...tokens,
},
OAuthProfile,
}
} catch (error) {
// If we didn't get a response either there was a problem with the provider
// response *or* the user cancelled the action with the provider.
//
// Unfortuately, we can't tell which - at least not in a way that works for
// all providers, so we return an empty object; the user should then be
// redirected back to the sign up page. We log the error to help developers
// who might be trying to debug this when configuring a new provider.
logger.error("OAUTH_PARSE_PROFILE_ERROR", { error, OAuthProfile })
return {
profile: null,
account: null,
OAuthProfile,
}
}
}

View File

@@ -1,69 +0,0 @@
// This is kept around for being backwards compatible with OAuth 1.0 providers.
// We have the intentions to provide only minor fixes for this in the future.
import { OAuth } from "oauth"
import { InternalOptions } from "src/lib/types"
/**
* Client supporting OAuth 1.x
*/
export function oAuth1Client(options: InternalOptions<"oauth">) {
const provider = options.provider
const oauth1Client = new OAuth(
provider.requestTokenUrl as string,
provider.accessTokenUrl as string,
provider.clientId as string,
provider.clientSecret as string,
provider.version ?? "1.0",
provider.callbackUrl,
provider.encoding ?? "HMAC-SHA1"
)
// Promisify get() for OAuth1
const originalGet = oauth1Client.get.bind(oauth1Client)
// @ts-expect-error
oauth1Client.get = async (...args) => {
return await new Promise((resolve, reject) => {
originalGet(...args, (error, result) => {
if (error) {
return reject(error)
}
resolve(result)
})
})
}
// Promisify getOAuth1AccessToken() for OAuth1
const originalGetOAuth1AccessToken =
oauth1Client.getOAuthAccessToken.bind(oauth1Client)
oauth1Client.getOAuthAccessToken = async (...args: any[]) => {
return await new Promise((resolve, reject) => {
originalGetOAuth1AccessToken(
...args,
(error, oauth_token, oauth_token_secret) => {
if (error) {
return reject(error)
}
resolve({ oauth_token, oauth_token_secret })
}
)
})
}
const originalGetOAuthRequestToken =
oauth1Client.getOAuthRequestToken.bind(oauth1Client)
oauth1Client.getOAuthRequestToken = async (params = {}) => {
return await new Promise((resolve, reject) => {
originalGetOAuthRequestToken(
params,
(error, oauth_token, oauth_token_secret, params) => {
if (error) {
return reject(error)
}
resolve({ oauth_token, oauth_token_secret, params })
}
)
})
}
return oauth1Client
}

View File

@@ -1,43 +0,0 @@
import { Issuer, Client } from "openid-client"
import { InternalOptions } from "src/lib/types"
/**
* NOTE: We can add auto discovery of the provider's endpoint
* that requires only one endpoint to be specified by the user.
* Check out `Issuer.discover`
*
* Client supporting OAuth 2.x and OIDC
*/
export async function openidClient(
options: InternalOptions<"oauth">
): Promise<Client> {
const provider = options.provider
let issuer
if (provider.wellKnown) {
issuer = await Issuer.discover(provider.wellKnown)
} else {
issuer = new Issuer({
issuer: provider.issuer as string,
authorization_endpoint:
// @ts-expect-error
provider.authorization?.url ?? provider.authorization,
// @ts-expect-error
token_endpoint: provider.token?.url ?? provider.token,
// @ts-expect-error
userinfo_endpoint: provider.userinfo?.url ?? provider.userinfo,
})
}
const client = new issuer.Client(
{
client_id: provider.clientId,
client_secret: provider.clientSecret,
redirect_uris: [provider.callbackUrl],
...provider.client,
},
provider.jwks
)
return client
}

View File

@@ -1,101 +0,0 @@
import * as cookie from "../cookie"
import * as jwt from "../../../jwt"
import { generators } from "openid-client"
import { InternalOptions } from "src/lib/types"
const PKCE_CODE_CHALLENGE_METHOD = "S256"
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
/**
* Returns `code_challenge` and `code_challenge_method`
* and saves them in a cookie.
* @type {import("src/lib/types").InternalOptions}
* @returns {Promise<undefined | {
* code_challenge: string
* code_challenge_method: "S256"
* cookie: import("../cookie").Cookie
* }>
*/
export async function createPKCE(options) {
const { cookies, logger } = options
/** @type {import("src/providers").OAuthConfig} */
const provider = options.provider
if (!provider.checks?.includes("pkce")) {
// Provider does not support PKCE, return nothing.
return
}
const codeVerifier = generators.codeVerifier()
const codeChallenge = generators.codeChallenge(codeVerifier)
// Encrypt code_verifier and save it to an encrypted cookie
const encryptedCodeVerifier = await jwt.encode({
maxAge: PKCE_MAX_AGE,
...options.jwt,
token: { code_verifier: codeVerifier },
})
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + PKCE_MAX_AGE * 1000)
logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", {
pkce: {
code_challenge: codeChallenge,
code_verifier: codeVerifier,
},
method: PKCE_CODE_CHALLENGE_METHOD,
})
return {
cookie: {
name: cookies.pkceCodeVerifier.name,
value: encryptedCodeVerifier,
options: {
expires: cookieExpires.toISOString(),
...cookies.pkceCodeVerifier.options,
},
},
code_challenge: codeChallenge,
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
}
}
/**
* Returns code_verifier if provider uses PKCE,
* and clears the cookie afterwards.
*/
export async function usePKCECodeVerifier(params: {
options: InternalOptions<"oauth">
codeVerifier?: string
}): Promise<
| {
codeVerifier?: string
cookie?: cookie.Cookie
}
| undefined
> {
const { options, codeVerifier } = params
const { cookies, provider } = options
if (!provider?.checks?.includes("pkce") || !codeVerifier) {
return
}
const pkce = await jwt.decode({
...options.jwt,
token: codeVerifier,
})
// remove PKCE cookie after it has been used up
const cookie: cookie.Cookie = {
name: cookies.pkceCodeVerifier.name,
value: "",
options: {
...cookies.pkceCodeVerifier.options,
maxAge: 0,
},
}
return {
codeVerifier: (pkce?.code_verifier as any) ?? undefined,
cookie,
}
}

View File

@@ -1,33 +0,0 @@
import { createHash } from "crypto"
import { InternalOptions } from "src/lib/types"
/** Returns state if provider supports it */
export function createState(options: InternalOptions<"oauth">) {
const { csrfToken, logger, provider } = options
if (!provider.checks?.includes("state")) {
// Provider does not support state, return nothing
return
}
if (!csrfToken) {
logger.warn("NO_CSRF_TOKEN")
return
}
// A hash of the NextAuth.js CSRF token is used as the state
const state = createHash("sha256").update(csrfToken).digest("hex")
logger.debug("OAUTH_CALLBACK_PROTECTION", { state, csrfToken })
return state
}
/**
* Consistently recreate state from the csrfToken
* if `provider.checks` supports `"state"`.
*/
export function getState({ provider, csrfToken }: InternalOptions<"oauth">) {
if (provider?.checks?.includes("state") && csrfToken) {
return createHash("sha256").update(csrfToken).digest("hex")
}
}

View File

@@ -1,68 +0,0 @@
import { InternalProvider } from "../../lib/types"
import { Provider } from "../../providers"
import { merge } from "../../lib/merge"
import { InternalUrl } from "../../lib/parse-url"
/**
* Adds `signinUrl` and `callbackUrl` to each provider
* and deep merge user-defined options.
*/
export default function parseProviders(params: {
providers: Provider[]
url: InternalUrl
providerId?: string
}): {
providers: InternalProvider[]
provider?: InternalProvider
} {
const { url, providerId } = params
const providers = params.providers.map(({ options, ...rest }) => {
const defaultOptions = normalizeProvider(rest as Provider)
const userOptions = normalizeProvider(options as Provider)
return merge(defaultOptions, {
...userOptions,
signinUrl: `${url}/signin/${userOptions?.id ?? rest.id}`,
callbackUrl: `${url}/callback/${userOptions?.id ?? rest.id}`,
})
})
const provider = providers.find(({ id }) => id === providerId)
return { providers, provider }
}
function normalizeProvider(provider?: Provider) {
if (!provider) return
const normalizedProvider: any = Object.entries(provider).reduce(
(acc, [key, value]) => {
if (
["authorization", "token", "userinfo"].includes(key) &&
typeof value === "string"
) {
const url = new URL(value)
acc[key] = {
url: `${url.origin}${url.pathname}`,
params: Object.fromEntries(url.searchParams ?? []),
}
} else {
acc[key] = value
}
return acc
},
{}
)
// Checks only work on OAuth 2.x + OIDC providers
if (
provider.type === "oauth" &&
!provider.version?.startsWith("1.") &&
!provider.checks
) {
normalizedProvider.checks = ["state"]
}
return normalizedProvider as InternalProvider
}

View File

@@ -1,43 +0,0 @@
import { createHash } from "crypto"
import { NextAuthOptions } from "../.."
import { InternalOptions } from "../../lib/types"
import { InternalUrl } from "../../lib/parse-url"
/**
* Takes a number in seconds and returns the date in the future.
* Optionally takes a second date parameter. In that case
* the date in the future will be calculated from that date instead of now.
*/
export function fromDate(time, date = Date.now()) {
return new Date(date + time * 1000)
}
export function hashToken(token: string, options: InternalOptions<"email">) {
const { provider, secret } = options
return (
createHash("sha256")
// Prefer provider specific secret, but use default secret if none specified
.update(`${token}${provider.secret ?? secret}`)
.digest("hex")
)
}
/**
* Secret used salt cookies and tokens (e.g. for CSRF protection).
* If no secret option is specified then it creates one on the fly
* based on options passed here. A options contains unique data, such as
* OAuth provider secrets and database credentials it should be sufficent.
*/
export default function createSecret(params: {
userOptions: NextAuthOptions
url: InternalUrl
}) {
const { userOptions, url } = params
return (
userOptions.secret ??
createHash("sha256")
.update(JSON.stringify({ ...url, ...userOptions }))
.digest("hex")
)
}

View File

@@ -1,95 +0,0 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
export interface ErrorProps {
url: InternalUrl
theme: Theme
error?: string
}
/** Renders an error page. */
export default function ErrorPage(props: ErrorProps) {
const { url, error = "default", theme } = props
const signinPageUrl = `${url}/signin`
const errors = {
default: {
status: 200,
heading: "Error",
message: (
<p>
<a className="site" href={url.origin}>
{url.host}
</a>
</p>
),
},
configuration: {
status: 500,
heading: "Server error",
message: (
<div>
<p>There is a problem with the server configuration.</p>
<p>Check the server logs for more information.</p>
</div>
),
},
accessdenied: {
status: 403,
heading: "Access Denied",
message: (
<div>
<p>You do not have permission to sign in.</p>
<p>
<a className="button" href={signinPageUrl}>
Sign in
</a>
</p>
</div>
),
},
verification: {
status: 403,
heading: "Unable to sign in",
message: (
<div>
<p>The sign in link is no longer valid.</p>
<p>It may have been used already or it may have expired.</p>
</div>
),
signin: (
<p>
<a className="button" href={signinPageUrl}>
Sign in
</a>
</p>
),
},
}
const { status, heading, message, signin } =
errors[error.toLowerCase()] ?? errors.default
return {
status,
html: (
<div className="error">
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/>
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
<h1>{heading}</h1>
<div className="message">{message}</div>
{signin}
</div>
</div>
),
}
}

View File

@@ -1,67 +0,0 @@
import renderToString from "preact-render-to-string"
import SigninPage from "./signin"
import SignoutPage from "./signout"
import VerifyRequestPage from "./verify-request"
import ErrorPage from "./error"
import css from "../../css"
import { InternalOptions } from "../../lib/types"
import { IncomingRequest, OutgoingResponse } from ".."
import { Cookie } from "../lib/cookie"
/** Takes a request and response, and gives renderable pages */
export default function renderPage({
options,
query,
cookies,
}: {
options: InternalOptions
query: IncomingRequest["query"]
cookies: Cookie[]
}) {
const { url, callbackUrl, csrfToken, providers, theme } = options
function send({ html, title, status }: any): OutgoingResponse {
return {
cookies,
status,
headers: [{ key: "Content-Type", value: "text/html" }],
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css()}</style><title>${title}</title></head><body class="__next-auth-theme-${
theme.colorScheme
}"><div class="page">${renderToString(html)}</div></body></html>`,
}
}
return {
signin(props?: any) {
return send({
html: SigninPage({
csrfToken,
providers,
callbackUrl,
theme,
...query,
...props,
}),
title: "Sign In",
})
},
signout(props?: any) {
return send({
html: SignoutPage({ csrfToken, url, theme, ...props }),
title: "Sign Out",
})
},
verifyRequest(props?: any) {
return send({
html: VerifyRequestPage({ url, theme, ...props }),
title: "Verify Request",
})
},
error(props) {
return send({
...ErrorPage({ url, theme, ...props }),
title: "Error",
})
},
}
}

View File

@@ -1,137 +0,0 @@
export default function SigninPage(props) {
const {
csrfToken,
providers,
callbackUrl,
theme,
email,
error: errorType,
} = props
// We only want to render providers
const providersToRender = providers.filter((provider) => {
if (provider.type === "oauth" || provider.type === "email") {
// Always render oauth and email type providers
return true
} else if (provider.type === "credentials" && provider.credentials) {
// Only render credentials type provider if credentials are defined
return true
}
// Don't render other provider types
return false
})
if (typeof document !== "undefined") {
document.documentElement.style.setProperty(
"--brand-color",
theme.brandColor
)
}
const errors = {
Signin: "Try signing in with a different account.",
OAuthSignin: "Try signing in with a different account.",
OAuthCallback: "Try signing in with a different account.",
OAuthCreateAccount: "Try signing in with a different account.",
EmailCreateAccount: "Try signing in with a different account.",
Callback: "Try signing in with a different account.",
OAuthAccountNotLinked:
"To confirm your identity, sign in with the same account you used originally.",
EmailSignin: "Check your email inbox.",
CredentialsSignin:
"Sign in failed. Check the details you provided are correct.",
default: "Unable to sign in.",
}
const error = errorType && (errors[errorType] ?? errors.default)
return (
<div className="signin">
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/>
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
{error && (
<div className="error">
<p>{error}</p>
</div>
)}
{providersToRender.map((provider, i: number) => (
<div key={provider.id} className="provider">
{provider.type === "oauth" && (
<form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
{callbackUrl && (
<input type="hidden" name="callbackUrl" value={callbackUrl} />
)}
<button type="submit" className="button">
Sign in with {provider.name}
</button>
</form>
)}
{(provider.type === "email" || provider.type === "credentials") &&
i > 0 &&
providersToRender[i - 1].type !== "email" &&
providersToRender[i - 1].type !== "credentials" && <hr />}
{provider.type === "email" && (
<form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<label
className="section-header"
htmlFor={`input-email-for-${provider.id}-provider`}
>
Email
</label>
<input
id={`input-email-for-${provider.id}-provider`}
autoFocus
type="text"
name="email"
value={email}
placeholder="email@example.com"
/>
<button type="submit">Sign in with {provider.name}</button>
</form>
)}
{provider.type === "credentials" && (
<form action={provider.callbackUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
{Object.keys(provider.credentials).map((credential) => {
return (
<div key={`input-group-${provider.id}`}>
<label
className="section-header"
htmlFor={`input-${credential}-for-${provider.id}-provider`}
>
{provider.credentials[credential].label || credential}
</label>
<input
name={credential}
id={`input-${credential}-for-${provider.id}-provider`}
type={provider.credentials[credential].type || "text"}
placeholder={
provider.credentials[credential].placeholder ||
"Password"
}
{...provider.credentials[credential]}
/>
</div>
)
})}
<button type="submit">Sign in with {provider.name}</button>
</form>
)}
{(provider.type === "email" || provider.type === "credentials") &&
i + 1 < providersToRender.length && <hr />}
</div>
))}
</div>
</div>
)
}

View File

@@ -1,35 +0,0 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
export interface SignoutProps {
url: InternalUrl
csrfToken: string
theme: Theme
}
export default function SignoutPage(props: SignoutProps) {
const { url, csrfToken, theme } = props
return (
<div className="signout">
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/>
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
<h1>Signout</h1>
<p>Are you sure you want to sign out?</p>
<form action={`${url}/signout`} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<button type="submit">Sign out</button>
</form>
</div>
</div>
)
}

View File

@@ -1,35 +0,0 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
interface VerifyRequestPageProps {
url: InternalUrl
theme: Theme
}
export default function VerifyRequestPage(props: VerifyRequestPageProps) {
const { url, theme } = props
return (
<div className="verify-request">
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/>
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
<h1>Check your email</h1>
<p>A sign in link has been sent to your email address.</p>
<p>
<a className="site" href={url.origin}>
{url.host}
</a>
</p>
</div>
</div>
)
}

View File

@@ -1,463 +0,0 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
import * as cookie from "../lib/cookie"
import { hashToken } from "../lib/utils"
import { InternalOptions } from "../../lib/types"
import { IncomingRequest, OutgoingResponse } from ".."
/** Handle callbacks from login services */
export default async function callback(params: {
options: InternalOptions<"oauth" | "credentials" | "email">
query: IncomingRequest["query"]
method: IncomingRequest["method"]
body: IncomingRequest["body"]
headers: IncomingRequest["headers"]
sessionToken?: string
codeVerifier?: string
}): Promise<OutgoingResponse> {
const { options, query, body, method, headers, sessionToken, codeVerifier } =
params
const {
provider,
adapter,
url,
callbackUrl,
pages,
jwt,
events,
callbacks,
session: { jwt: useJwtSession, maxAge: sessionMaxAge },
logger,
} = options
const cookies: cookie.Cookie[] = []
if (provider.type === "oauth") {
try {
const {
profile,
account,
OAuthProfile,
cookies: oauthCookies,
} = await oAuthCallback({
query,
body,
method,
options,
codeVerifier,
})
if (oauthCookies) cookies.push(...oauthCookies)
try {
// Make it easier to debug when adding a new provider
logger.debug("OAUTH_CALLBACK_RESPONSE", {
profile,
account,
OAuthProfile,
})
// If we don't have a profile object then either something went wrong
// or the user cancelled signing in. We don't know which, so we just
// direct the user to the signin page for now. We could do something
// else in future.
//
// Note: In oAuthCallback an error is logged with debug info, so it
// should at least be visible to developers what happened if it is an
// error with the provider.
if (!profile) {
return { redirect: `${url}/signin`, cookies }
}
// Check if user is allowed to sign in
// Attempt to get Profile from OAuth provider details before invoking
// signIn callback - but if no user object is returned, that is fine
// (that just means it's a new user signing in for the first time).
let userOrProfile = profile
if (adapter) {
const { getUserByAccount } = adapter
const userByAccount = await getUserByAccount({
// @ts-expect-error
providerAccountId: account.providerAccountId,
provider: provider.id,
})
if (userByAccount) userOrProfile = userByAccount
}
try {
const isAllowed = await callbacks.signIn({
user: userOrProfile,
// @ts-expect-error
account,
profile: OAuthProfile,
})
if (!isAllowed) {
return { redirect: `${url}/error?error=AccessDenied`, cookies }
} else if (typeof isAllowed === "string") {
return { redirect: isAllowed, cookies }
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
cookies,
}
}
// Sign user in
// @ts-expect-error
const { user, session, isNewUser } = await callbackHandler({
sessionToken,
profile,
// @ts-expect-error
account,
options,
})
if (useJwtSession) {
const defaultToken = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
profile: OAuthProfile,
isNewUser,
})
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookies.push({
name: options.cookies.sessionToken.name,
value: newEncodedJwt,
options: {
expires: cookieExpires,
...options.cookies.sessionToken.options,
},
})
} else {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: session.sessionToken,
options: {
expires: session.expires,
...options.cookies.sessionToken.options,
},
})
}
// @ts-expect-error
await events.signIn?.({ user, account, profile, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
cookies,
}
}
// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} catch (error) {
if (error.name === "AccountNotLinkedError") {
// If the email on the account is already linked, but not with this OAuth account
return {
redirect: `${url}/error?error=OAuthAccountNotLinked`,
cookies,
}
} else if (error.name === "CreateUserError") {
return { redirect: `${url}/error?error=OAuthCreateAccount`, cookies }
}
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} catch (error) {
if (error.name === "OAuthCallbackError") {
logger.error("CALLBACK_OAUTH_ERROR", error)
return { redirect: `${url}/error?error=OAuthCallback`, cookies }
}
logger.error("OAUTH_CALLBACK_ERROR", error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} else if (provider.type === "email") {
try {
if (!adapter) {
logger.error(
"EMAIL_REQUIRES_ADAPTER_ERROR",
new Error("E-mail login requires an adapter but it was undefined")
)
return { redirect: `${url}/error?error=Configuration`, cookies }
}
const { useVerificationToken, getUserByEmail } = adapter
const token = query?.token
const identifier = query?.email
const invite = await useVerificationToken?.({
identifier,
token: hashToken(token, options),
})
const invalidInvite = !invite || invite.expires.valueOf() < Date.now()
if (invalidInvite) {
return { redirect: `${url}/error?error=Verification`, cookies }
}
// If it is an existing user, use that, otherwise use a placeholder
const profile = (identifier
? await getUserByEmail(identifier)
: null) ?? {
email: identifier,
}
/** @type {import("src").Account} */
const account = {
providerAccountId: profile.email,
type: "email",
provider: provider.id,
}
// Check if user is allowed to sign in
try {
const signInCallbackResponse = await callbacks.signIn({
// @ts-expect-error
user: profile,
// @ts-expect-error
account,
// @ts-expect-error
email: { email: identifier },
})
if (!signInCallbackResponse) {
return { redirect: `${url}/error?error=AccessDenied`, cookies }
} else if (typeof signInCallbackResponse === "string") {
return { redirect: signInCallbackResponse, cookies }
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
cookies,
}
}
// Sign user in
// @ts-expect-error
const { user, session, isNewUser } = await callbackHandler({
sessionToken,
// @ts-expect-error
profile,
// @ts-expect-error
account,
options,
})
if (useJwtSession) {
const defaultToken = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
isNewUser,
})
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookies.push({
name: options.cookies.sessionToken.name,
value: newEncodedJwt,
options: {
expires: cookieExpires,
...options.cookies.sessionToken.options,
},
})
} else {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: session.sessionToken,
options: {
expires: session.expires,
...options.cookies.sessionToken.options,
},
})
}
// @ts-expect-error
await events.signIn?.({ user, account, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
cookies,
}
}
// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} catch (error) {
if (error.name === "CreateUserError") {
return { redirect: `${url}/error?error=EmailCreateAccount`, cookies }
}
logger.error("CALLBACK_EMAIL_ERROR", error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} else if (provider.type === "credentials" && method === "POST") {
if (!useJwtSession) {
logger.error(
"CALLBACK_CREDENTIALS_JWT_ERROR",
new Error(
"Signin in with credentials is only supported if JSON Web Tokens are enabled"
)
)
return {
status: 500,
redirect: `${url}/error?error=Configuration`,
cookies,
}
}
if (!provider.authorize) {
logger.error(
"CALLBACK_CREDENTIALS_HANDLER_ERROR",
new Error(
"Must define an authorize() handler to use credentials authentication provider"
)
)
return {
status: 500,
redirect: `${url}/error?error=Configuration`,
cookies,
}
}
const credentials = body
let user
try {
user = await provider.authorize(credentials, {
query,
body,
headers,
method,
})
if (!user) {
return {
status: 401,
redirect: `${url}/error?${new URLSearchParams({
error: "CredentialsSignin",
provider: provider.id,
})}`,
cookies,
}
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
cookies,
}
}
/** @type {import("src").Account} */
const account = {
providerAccountId: user.id,
type: "credentials",
provider: provider.id,
}
try {
const isAllowed = await callbacks.signIn({
user,
// @ts-expect-error
account,
credentials,
})
if (!isAllowed) {
return {
status: 403,
redirect: `${url}/error?error=AccessDenied`,
cookies,
}
} else if (typeof isAllowed === "string") {
return { redirect: isAllowed, cookies }
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(error.message)}`,
cookies,
}
}
const defaultToken = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
isNewUser: false,
})
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookies.push({
name: options.cookies.sessionToken.name,
value: newEncodedJwt,
options: {
expires: cookieExpires,
...options.cookies.sessionToken.options,
},
})
// @ts-expect-error
await events.signIn?.({ user, account })
return { redirect: callbackUrl, cookies }
}
return {
status: 500,
body: `Error: Callback for provider type ${provider.type} not supported`,
cookies,
}
}

View File

@@ -1,30 +0,0 @@
import { OutgoingResponse } from ".."
import { InternalProvider } from "../../lib/types"
export interface PublicProvider {
id: string
name: string
type: string
signinUrl: string
callbackUrl: string
}
/**
* Return a JSON object with a list of all OAuth providers currently configured
* and their signin and callback URLs. This makes it possible to automatically
* generate buttons for all providers when rendering client side.
*/
export default function providers(
providers: InternalProvider[]
): OutgoingResponse<Record<string, PublicProvider>> {
return {
headers: [{ key: "Content-Type", value: "application/json" }],
body: providers.reduce(
(acc, { id, name, type, signinUrl, callbackUrl }) => {
acc[id] = { id, name, type, signinUrl, callbackUrl }
return acc
},
{}
),
}
}

View File

@@ -1,168 +0,0 @@
import { Adapter } from "../../adapters"
import { InternalOptions } from "../../lib/types"
import { OutgoingResponse } from ".."
import { Session } from "../.."
import { fromDate } from "../lib/utils"
interface SessionParams {
options: InternalOptions
sessionToken?: string
}
/**
* Return a session object (without any private fields)
* for Single Page App clients
*/
export default async function session(
params: SessionParams
): Promise<OutgoingResponse<Session | {}>> {
const { options, sessionToken } = params
const { adapter, jwt, events, callbacks, logger } = options
const useJwtSession = options.session.jwt
const sessionMaxAge = options.session.maxAge
const response: OutgoingResponse<Session | {}> = {
body: {},
headers: [{ key: "Content-Type", value: "application/json" }],
cookies: [],
}
if (!sessionToken) return response
if (useJwtSession) {
try {
// Decrypt and verify token
const decodedToken = await jwt.decode({ ...jwt, token: sessionToken })
// Generate new session expiry date
const newExpires = fromDate(sessionMaxAge)
// By default, only exposes a limited subset of information to the client
// as needed for presentation purposes (e.g. "you are logged in as...").
const defaultSession = {
user: {
name: decodedToken?.name,
email: decodedToken?.email,
image: decodedToken?.picture,
},
expires: newExpires.toISOString(),
}
// Pass Session and JSON Web Token through to the session callback
// @ts-expect-error
const token = await callbacks.jwt({ token: decodedToken })
// @ts-expect-error
const session = await callbacks.session({
session: defaultSession,
token,
})
// Return session payload as response
response.body = session
// Refresh JWT expiry by re-signing it, with an updated expiry date
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie, to also update expiry date on cookie
response.cookies?.push({
name: options.cookies.sessionToken.name,
value: newToken,
options: {
expires: newExpires,
...options.cookies.sessionToken.options,
},
})
await events.session?.({ session, token })
} catch (error) {
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
logger.error("JWT_SESSION_ERROR", error)
response.cookies?.push({
name: options.cookies.sessionToken.name,
value: "",
options: { ...options.cookies.sessionToken.options, maxAge: 0 },
})
}
} else {
try {
const { getSessionAndUser, deleteSession, updateSession } =
adapter as Adapter
let userAndSession = await getSessionAndUser(sessionToken)
// If session has expired, clean up the database
if (
userAndSession &&
userAndSession.session.expires.valueOf() < Date.now()
) {
await deleteSession(sessionToken)
userAndSession = null
}
if (userAndSession) {
const { user, session } = userAndSession
const sessionUpdateAge = options.session.updateAge
// Calculate last updated date to throttle write updates to database
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
// e.g. ({expiry date} - 30 days) + 1 hour
const sessionIsDueToBeUpdatedDate =
session.expires.valueOf() -
sessionMaxAge * 1000 +
sessionUpdateAge * 1000
const newExpires = fromDate(sessionMaxAge)
// Trigger update of session expiry date and write to database, only
// if the session was last updated more than {sessionUpdateAge} ago
if (sessionIsDueToBeUpdatedDate <= Date.now()) {
await updateSession({ sessionToken, expires: newExpires })
}
// Pass Session through to the session callback
// @ts-expect-error
const sessionPayload = await callbacks.session({
// By default, only exposes a limited subset of information to the client
// as needed for presentation purposes (e.g. "you are logged in as...").
session: {
user: {
name: user.name,
email: user.email,
image: user.image,
},
expires: session.expires.toISOString(),
},
user,
})
// Return session payload as response
response.body = sessionPayload
// Set cookie again to update expiry
response.cookies?.push({
name: options.cookies.sessionToken.name,
value: sessionToken,
options: {
expires: newExpires,
...options.cookies.sessionToken.options,
},
})
// @ts-expect-error
await events.session?.({ session: sessionPayload })
} else if (sessionToken) {
// If sessionToken was found set but it's not valid for a session then
// remove the sessionToken cookie from browser.
response.cookies?.push({
name: options.cookies.sessionToken.name,
value: "",
options: { ...options.cookies.sessionToken.options, maxAge: 0 },
})
}
} catch (error) {
logger.error("SESSION_ERROR", error)
}
}
return response
}

View File

@@ -1,94 +0,0 @@
import getAuthorizationUrl from "../lib/oauth/authorization-url"
import emailSignin from "../lib/email/signin"
import { IncomingRequest, OutgoingResponse } from ".."
import { InternalOptions } from "../../lib/types"
import { Account, User } from "../.."
/** Handle requests to /api/auth/signin */
export default async function signin(params: {
options: InternalOptions<"oauth" | "email">
query: IncomingRequest["query"]
body: IncomingRequest["body"]
}): Promise<OutgoingResponse> {
const { options, query, body } = params
const { url, adapter, callbacks, logger, provider } = options
if (!provider.type) {
return {
status: 500,
// @ts-expect-error
text: `Error: Type not specified for ${provider.name}`,
}
}
if (provider.type === "oauth") {
try {
const response = await getAuthorizationUrl({ options, query })
return response
} catch (error) {
logger.error("SIGNIN_OAUTH_ERROR", { error, provider })
return { redirect: `${url}/error?error=OAuthSignin` }
}
} else if (provider.type === "email") {
if (!adapter) {
logger.error(
"EMAIL_REQUIRES_ADAPTER_ERROR",
new Error("E-mail login requires an adapter but it was undefined")
)
return { redirect: `${url}/error?error=Configuration` }
}
// Note: Technically the part of the email address local mailbox element
// (everything before the @ symbol) should be treated as 'case sensitive'
// according to RFC 2821, but in practice this causes more problems than
// it solves. We treat email addresses as all lower case. If anyone
// complains about this we can make strict RFC 2821 compliance an option.
const email = body?.email?.toLowerCase() ?? null
const { getUserByEmail } = adapter
// If is an existing user return a user object (otherwise use placeholder)
const user: User = (email ? await getUserByEmail(email) : null) ?? {
email,
id: email,
}
const account: Account = {
providerAccountId: email,
userId: email,
type: "email",
provider: provider.id,
}
// Check if user is allowed to sign in
try {
// @ts-expect-error
const signInCallbackResponse = await callbacks.signIn({
user,
account,
email: { verificationRequest: true },
})
if (!signInCallbackResponse) {
return { redirect: `${url}/error?error=AccessDenied` }
} else if (typeof signInCallbackResponse === "string") {
return { redirect: signInCallbackResponse }
}
} catch (error) {
return { redirect: `${url}/error?${new URLSearchParams({ error })}}` }
}
try {
await emailSignin(email, options)
} catch (error) {
logger.error("SIGNIN_EMAIL_ERROR", error)
return { redirect: `${url}/error?error=EmailSignin` }
}
const params = new URLSearchParams({
provider: provider.id,
type: provider.type,
})
return { redirect: `${url}/verify-request?${params}` }
}
return { redirect: `${url}/signin` }
}

View File

@@ -1,49 +0,0 @@
import { Adapter } from "src/adapters"
import { InternalOptions } from "../../lib/types"
import { OutgoingResponse } from ".."
import { Cookie } from "../lib/cookie"
/** Handle requests to /api/auth/signout */
export default async function signout(params: {
options: InternalOptions
sessionToken?: string
}): Promise<OutgoingResponse> {
const { options, sessionToken } = params
const { adapter, cookies, events, jwt, callbackUrl, logger } = options
if (!sessionToken) {
return { redirect: callbackUrl }
}
const useJwtSession = options.session.jwt
if (useJwtSession) {
// Dispatch signout event
try {
const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken })
// @ts-expect-error
await events.signOut?.({ token: decodedJwt })
} catch (error) {
// Do nothing if decoding the JWT fails
}
} else {
try {
const session = await (adapter as Adapter).deleteSession(sessionToken)
// Dispatch signout event
// @ts-expect-error
await events.signOut?.({ session })
} catch (error) {
// If error, log it but continue
logger.error("SIGNOUT_ERROR", error)
}
}
// Remove Session Token
const sessionCookie: Cookie = {
name: cookies.sessionToken.name,
value: "",
options: { ...cookies.sessionToken.options, maxAge: 0 },
}
return { redirect: callbackUrl, cookies: [sessionCookie] }
}

View File

@@ -1,6 +1,6 @@
:root {
--border-width: 1px;
--border-radius: 0.3rem;
--border-radius: .3rem;
--color-error: #c94b4b;
--color-info: #157efb;
--color-info-text: #fff;
@@ -43,9 +43,7 @@ body {
background-color: var(--color-background);
margin: 0;
padding: 0;
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
}
h1 {
@@ -56,7 +54,7 @@ h1 {
}
p {
color: var(--color-text);
color: var(--color-text)
}
form {
@@ -76,12 +74,12 @@ input[type] {
box-sizing: border-box;
display: block;
width: 100%;
padding: 0.5rem 1rem;
padding: .5rem 1rem;
border: var(--border-width) solid var(--color-control-border);
background: var(--color-background);
font-size: 1rem;
border-radius: var(--border-radius);
box-shadow: inset 0 0.1rem 0.2rem rgba(0, 0, 0, 0.2);
box-shadow: inset 0 .1rem .2rem rgba(0, 0, 0, .2);
color: var(--color-text);
&:focus {
@@ -109,17 +107,15 @@ a.button {
button,
a.button {
margin: 0 0 0.75rem 0;
padding: 0.75rem 1rem;
margin: 0 0 .75rem 0;
padding: .75rem 1rem;
border: var(--border-width) solid var(--color-control-border);
color: var(--color-primary);
background-color: var(--color-background);
font-size: 1rem;
border-radius: var(--border-radius);
transition: all 0.1s ease-in-out;
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
inset 0 0.1rem 0.2rem var(--color-background),
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.05);
transition: all .1s ease-in-out;
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .05);
font-weight: 500;
position: relative;
@@ -128,9 +124,7 @@ a.button {
}
&:active {
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
inset 0 0.1rem 0.2rem var(--color-background),
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.1);
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .1);
background-color: var(--color-button-active-background);
border-color: var(--color-button-active-border);
cursor: pointer;
@@ -152,12 +146,13 @@ a.site {
position: absolute;
width: 100%;
height: 100%;
display: grid;
place-items: center;
display: table;
margin: 0;
padding: 0;
> div {
>div {
display: table-cell;
vertical-align: middle;
text-align: center;
padding: 0.5rem;
}
@@ -168,7 +163,7 @@ a.site {
display: inline-block;
padding-left: 2rem;
padding-right: 2rem;
margin-top: 0.5rem;
margin-top: .5rem;
}
.message {
@@ -177,6 +172,7 @@ a.site {
}
.signin {
button,
a.button,
input[type="text"] {
@@ -196,9 +192,9 @@ a.site {
content: "or";
background: var(--color-background);
color: #888;
padding: 0 0.4rem;
padding: 0 .4rem;
position: relative;
top: -0.6rem;
top: -.6rem;
}
}
@@ -217,9 +213,10 @@ a.site {
}
}
> div,
>div,
form {
display: block;
margin: 0 auto 0.5rem auto;
input[type] {
margin-bottom: 0.5rem;
@@ -231,32 +228,4 @@ a.site {
max-width: 300px;
}
}
.signout {
.message {
margin-bottom: 1.5rem;
}
}
.logo {
display: inline-block;
margin-top: 100px;
max-width: 300px;
max-height: 150px;
}
.card {
max-width: max-content;
border: 1px solid var(--color-control-border);
border-radius: 5px;
padding: 20px 50px;
margin: 50px auto;
.header {
color: var(--color-primary);
}
}
.section-header {
color: var(--brand-color);
}
}

View File

@@ -1,11 +1,11 @@
// To support serverless targets (which don't work if you try to read in things
// like CSS files at run time) this file is replaced in production builds with
// a function that returns compiled CSS (embedded as a string in the function).
import fs from "fs"
import path from "path"
import fs from 'fs'
import path from 'path'
const pathToCss = path.join(process.cwd(), "/src/css/index.css")
const pathToCss = path.join(process.cwd(), '/dist/css/index.css')
export default function css() {
return fs.readFileSync(pathToCss, "utf8")
export default function css () {
return fs.readFileSync(pathToCss, 'utf8')
}

View File

@@ -1,6 +0,0 @@
export * from "./core/types"
export type { IncomingRequest, OutgoingResponse } from "./core"
export * from "./next"
export { default } from "./next"

View File

@@ -1,110 +0,0 @@
import { EncryptJWT, jwtDecrypt } from "jose"
import hkdf from '@panva/hkdf'
import { v4 as uuid } from "uuid"
import { NextApiRequest } from "next"
import type { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "./types"
export * from "./types"
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
const now = () => (Date.now() / 1000) | 0
/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */
export async function encode({
token = {},
secret,
maxAge = DEFAULT_MAX_AGE,
}: JWTEncodeParams) {
const encryptionSecret = await getDerivedEncryptionKey(secret)
return await new EncryptJWT(token)
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuedAt()
.setExpirationTime(now() + maxAge)
.setJti(uuid())
.encrypt(encryptionSecret)
}
/** Decodes a NextAuth.js issued JWT. */
export async function decode({
token,
secret,
}: JWTDecodeParams): Promise<JWT | null> {
if (!token) return null
const encryptionSecret = await getDerivedEncryptionKey(secret)
const { payload } = await jwtDecrypt(token, encryptionSecret, {
clockTolerance: 15,
})
return payload
}
export type GetTokenParams<R extends boolean = false> = {
/** The request containing the JWT either in the cookies or in the `Authorization` header. */
req: NextApiRequest
/**
* Use secure prefix for cookie name, unless URL in `NEXTAUTH_URL` is http://
* or not set (e.g. development or test instance) case use unprefixed name
*/
secureCookie?: boolean
/** If the JWT is in the cookie, what name `getToken()` should look for. */
cookieName?: string
/**
* `getToken()` will return the raw JWT if this is set to `true`
* @default false
*/
raw?: R
} & Pick<JWTOptions, "decode" | "secret">
/**
* Takes a NextAuth.js request (`req`) and returns either the NextAuth.js issued JWT's payload,
* or the raw JWT string. We look for the JWT in the either the cookies, or the `Authorization` header.
* [Documentation](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
*/
export async function getToken<R extends boolean = false>(
params?: GetTokenParams<R>
): Promise<R extends true ? string : JWT | null> {
const {
req,
secureCookie = !(
!process.env.NEXTAUTH_URL ||
process.env.NEXTAUTH_URL.startsWith("http://")
),
cookieName = secureCookie
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
raw,
decode: _decode = decode,
} = params ?? {}
if (!req) throw new Error("Must pass `req` to JWT getToken()")
let token = req.cookies[cookieName]
if (!token && req.headers.authorization?.split(" ")[0] === "Bearer") {
const urlEncodedToken = req.headers.authorization.split(" ")[1]
token = decodeURIComponent(urlEncodedToken)
}
if (raw) {
// @ts-expect-error
return token
}
try {
// @ts-expect-error
return await _decode({ token, ...params })
} catch {
// @ts-expect-error
return null
}
}
async function getDerivedEncryptionKey(secret) {
return await hkdf(
'sha256',
secret,
"",
"NextAuth.js Generated Encryption Key",
32
)
}

View File

@@ -1,48 +0,0 @@
import { decode, encode } from "."
export interface DefaultJWT extends Record<string, unknown> {
name?: string | null
email?: string | null
picture?: string | null
sub?: string
}
/**
* Returned by the `jwt` callback and `getToken`, when using JWT sessions
*
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) | [`getToken`](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
*/
export interface JWT extends Record<string, unknown>, DefaultJWT {}
export interface JWTEncodeParams {
/** The JWT payload. */
token?: JWT
/** The secret used to encode the NextAuth.js issued JWT. */
secret: string | Buffer
/**
* The maximum age of the NextAuth.js issued JWT in seconds.
* @default 30 * 24 * 30 * 60 // 30 days
*/
maxAge?: number
}
export interface JWTDecodeParams {
/** The NextAuth.js issued JWT to be decoded */
token?: string
/** The secret used to decode the NextAuth.js issued JWT. */
secret: string | Buffer
}
export interface JWTOptions {
/** The secret used to encode/decode the NextAuth.js issued JWT. */
secret: string
/**
* The maximum age of the NextAuth.js issued JWT in seconds.
* @default 30 * 24 * 30 * 60 // 30 days
*/
maxAge: number
/** Override this method to control the NextAuth.js issued JWT encoding. */
encode: typeof encode
/** Override this method to control the NextAuth.js issued JWT decoding. */
decode: typeof decode
}

98
src/lib/errors.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* Same as the default `Error`, but it is JSON serializable.
* @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
*/
export class UnknownError extends Error {
constructor(error) {
// Support passing error or string
super(error?.message ?? error)
this.name = "UnknownError"
if (error instanceof Error) {
this.stack = error.stack
}
}
toJSON() {
return {
name: this.name,
message: this.message,
stack: this.stack,
}
}
}
export class OAuthCallbackError extends UnknownError {
name = "OAuthCallbackError"
}
/**
* Thrown when an Email address is already associated with an account
* but the user is trying an OAuth account that is not linked to it.
*/
export class AccountNotLinkedError extends UnknownError {
name = "AccountNotLinkedError"
}
export class CreateUserError extends UnknownError {
name = "CreateUserError"
}
export class GetUserError extends UnknownError {
name = "GetUserError"
}
export class GetUserByEmailError extends UnknownError {
name = "GetUserByEmailError"
}
export class GetUserByIdError extends UnknownError {
name = "GetUserByIdError"
}
export class GetUserByProviderAccountIdError extends UnknownError {
name = "GetUserByProviderAccountIdError"
}
export class UpdateUserError extends UnknownError {
name = "UpdateUserError"
}
export class DeleteUserError extends UnknownError {
name = "DeleteUserError"
}
export class LinkAccountError extends UnknownError {
name = "LinkAccountError"
}
export class UnlinkAccountError extends UnknownError {
name = "UnlinkAccountError"
}
export class CreateSessionError extends UnknownError {
name = "CreateSessionError"
}
export class GetSessionError extends UnknownError {
name = "GetSessionError"
}
export class UpdateSessionError extends UnknownError {
name = "UpdateSessionError"
}
export class DeleteSessionError extends UnknownError {
name = "DeleteSessionError"
}
export class CreateVerificationRequestError extends UnknownError {
name = "CreateVerificationRequestError"
}
export class GetVerificationRequestError extends UnknownError {
name = "GetVerificationRequestError"
}
export class DeleteVerificationRequestError extends UnknownError {
name = "DeleteVerificationRequestError"
}

208
src/lib/jwt.js Normal file
View File

@@ -0,0 +1,208 @@
import crypto from "crypto"
import jose from "jose"
import logger from "./logger"
// Set default algorithm to use for auto-generated signing key
const DEFAULT_SIGNATURE_ALGORITHM = "HS512"
// Set default algorithm for auto-generated symmetric encryption key
const DEFAULT_ENCRYPTION_ALGORITHM = "A256GCM"
// Use encryption or not by default
const DEFAULT_ENCRYPTION_ENABLED = false
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
export async function encode({
token = {},
maxAge = DEFAULT_MAX_AGE,
secret,
signingKey,
signingOptions = {
expiresIn: `${maxAge}s`,
},
encryptionKey,
encryptionOptions = {
alg: "dir",
enc: DEFAULT_ENCRYPTION_ALGORITHM,
zip: "DEF",
},
encryption = DEFAULT_ENCRYPTION_ENABLED,
} = {}) {
// Signing Key
const _signingKey = signingKey
? jose.JWK.asKey(JSON.parse(signingKey))
: getDerivedSigningKey(secret)
// Sign token
const signedToken = jose.JWT.sign(token, _signingKey, signingOptions)
if (encryption) {
// Encryption Key
const _encryptionKey = encryptionKey
? jose.JWK.asKey(JSON.parse(encryptionKey))
: getDerivedEncryptionKey(secret)
// Encrypt token
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
}
return signedToken
}
export async function decode({
secret,
token,
maxAge = DEFAULT_MAX_AGE,
signingKey,
verificationKey = signingKey, // Optional (defaults to encryptionKey)
verificationOptions = {
maxTokenAge: `${maxAge}s`,
algorithms: [DEFAULT_SIGNATURE_ALGORITHM],
},
encryptionKey,
decryptionKey = encryptionKey, // Optional (defaults to encryptionKey)
decryptionOptions = {
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM],
},
encryption = DEFAULT_ENCRYPTION_ENABLED,
} = {}) {
if (!token) return null
let tokenToVerify = token
if (encryption) {
// Encryption Key
const _encryptionKey = decryptionKey
? jose.JWK.asKey(JSON.parse(decryptionKey))
: getDerivedEncryptionKey(secret)
// Decrypt token
const decryptedToken = jose.JWE.decrypt(
token,
_encryptionKey,
decryptionOptions
)
tokenToVerify = decryptedToken.toString("utf8")
}
// Signing Key
const _signingKey = verificationKey
? jose.JWK.asKey(JSON.parse(verificationKey))
: getDerivedSigningKey(secret)
// Verify token
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
}
/**
* Server-side method to retrieve the JWT from `req`.
* @param {{
* req: NextApiRequest
* secureCookie?: boolean
* cookieName?: string
* raw?: boolean
* }} params
*/
export async function getToken(params) {
const {
req,
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
// or not set (e.g. development or test instance) case use unprefixed name
secureCookie = !(
!process.env.NEXTAUTH_URL ||
process.env.NEXTAUTH_URL.startsWith("http://")
),
cookieName = secureCookie
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
raw = false,
decode: _decode = decode,
} = params
if (!req) throw new Error("Must pass `req` to JWT getToken()")
// Try to get token from cookie
let token = req.cookies[cookieName]
// If cookie not found in cookie look for bearer token in authorization header.
// This allows clients that pass through tokens in headers rather than as
// cookies to use this helper function.
if (!token && req.headers.authorization?.split(" ")[0] === "Bearer") {
const urlEncodedToken = req.headers.authorization.split(" ")[1]
token = decodeURIComponent(urlEncodedToken)
}
if (raw) {
return token
}
try {
return _decode({ token, ...params })
} catch {
return null
}
}
// Generate warning (but only once at startup) when auto-generated keys are used
let DERIVED_SIGNING_KEY_WARNING = false
let DERIVED_ENCRYPTION_KEY_WARNING = false
// Do the better hkdf of Node.js one added in `v15.0.0` and Third Party one
function hkdf(secret, { byteLength, encryptionInfo, digest = "sha256" }) {
if (crypto.hkdfSync) {
return Buffer.from(
crypto.hkdfSync(
digest,
secret,
Buffer.alloc(0),
encryptionInfo,
byteLength
)
)
}
return require("futoin-hkdf")(secret, byteLength, {
info: encryptionInfo,
hash: digest,
})
}
function getDerivedSigningKey(secret) {
if (!DERIVED_SIGNING_KEY_WARNING) {
logger.warn("JWT_AUTO_GENERATED_SIGNING_KEY")
DERIVED_SIGNING_KEY_WARNING = true
}
const buffer = hkdf(secret, {
byteLength: 64,
encryptionInfo: "NextAuth.js Generated Signing Key",
})
const key = jose.JWK.asKey(buffer, {
alg: DEFAULT_SIGNATURE_ALGORITHM,
use: "sig",
kid: "nextauth-auto-generated-signing-key",
})
return key
}
function getDerivedEncryptionKey(secret) {
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
logger.warn("JWT_AUTO_GENERATED_ENCRYPTION_KEY")
DERIVED_ENCRYPTION_KEY_WARNING = true
}
const buffer = hkdf(secret, {
byteLength: 32,
encryptionInfo: "NextAuth.js Generated Encryption Key",
})
const key = jose.JWK.asKey(buffer, {
alg: DEFAULT_ENCRYPTION_ALGORITHM,
use: "enc",
kid: "nextauth-auto-generated-encryption-key",
})
return key
}
export default {
encode,
decode,
getToken,
}

81
src/lib/logger.js Normal file
View File

@@ -0,0 +1,81 @@
/** @type {import("types").LoggerInstance} */
const _logger = {
error(code, ...message) {
console.error(
`[next-auth][error][${code.toLowerCase()}]`,
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
...message
)
},
warn(code, ...message) {
console.warn(
`[next-auth][warn][${code.toLowerCase()}]`,
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`,
...message
)
},
debug(code, ...message) {
if (!process?.env?._NEXTAUTH_DEBUG) return
console.log(`[next-auth][debug][${code.toLowerCase()}]`, ...message)
},
}
/**
* Override the built-in logger.
* Any `undefined` level will use the default logger.
* @param {Partial<import("types").LoggerInstance>} newLogger
*/
export function setLogger(newLogger = {}) {
if (newLogger.error) _logger.error = newLogger.error
if (newLogger.warn) _logger.warn = newLogger.warn
if (newLogger.debug) _logger.debug = newLogger.debug
}
export default _logger
/**
* Serializes client-side log messages and sends them to the server
* @param {import("types").LoggerInstance} logger
* @param {string} basePath
* @return {import("types").LoggerInstance}
*/
export function proxyLogger(logger = _logger, basePath) {
try {
if (typeof window === "undefined") {
return logger
}
const clientLogger = {}
for (const level in logger) {
clientLogger[level] = (code, ...message) => {
_logger[level](code, ...message) // Log on client as usual
const url = `${basePath}/_log`
const body = new URLSearchParams({
level,
code,
message: JSON.stringify(
message.map((m) => {
if (m instanceof Error) {
// Serializing errors: https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
return { name: m.name, message: m.message, stack: m.stack }
}
return m
})
),
})
if (navigator.sendBeacon) {
return navigator.sendBeacon(url, body)
}
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
})
}
}
return clientLogger
} catch {
return _logger
}
}

Some files were not shown because too many files have changed in this diff Show More