Compare commits

..

13 Commits

Author SHA1 Message Date
Balázs Orbán
b6a3a72db4 Merge branch 'main' into next 2021-04-24 23:20:41 +02:00
Balázs Orbán
edcb10a823 Merge branch 'main' into next 2021-04-23 15:43:20 +02:00
Balázs Orbán
2acabe19e0 Merge main into next 2021-04-23 15:28:26 +02:00
Balázs Orbán
a6f5f4c184 fix: use upgraded require optional (#1743)
* chore(deps): switch back to (updated) require_optional

* fix: use @balazsorban/require-optional
2021-04-16 16:05:44 +02:00
Balázs Orbán
9fa93e3b5e fix(build): use optional-require dependency (#1736)
* chore(deps): add optional-require

* refactor: use optional-require
2021-04-16 00:23:29 +02:00
Balázs Orbán
cb4342fdda feat(build): modernize how we bundle next-auth (#1682)
* feat(build): optionally include TypeORM

If the user doesn't use databases,
it shouldn't be necessary to iclude it in the bundle.
This can more than half the package size!

* feat(build): clean up in dependencies

Remove unused dependencies, move optional ones to be optional

* feat(build): add exports field

* fix: use peerDependenciesMeta instead of non-standard peerOptionalDependecns field

* fix: ts-standard string quotes

* fix: ts-standard string quotes

* refactor: use asnyc/await for sendVerificationRequest

* chore(deps): upgrade mongodb, remove require_optional

Co-authored-by: ndom91 <yo@ndo.dev>

BREAKING CHANGE:
`typeorm`, and `nodemailer` are no longer dependencies added by default.
If you need any of them, you will have to install them yourself in your project directory.
TypeOrm is the default adapter, so if you only provide an `adapter` configuration or a `database`, you will need `typeorm`. You could also check out `@next-auth/typeorm-adapter`. In case you are using the Email provider, you will have to install `nodemailer` (or you can use the choice of your library in the `sendVerificationRequest` callback to send out the e-mail.)
2021-04-15 23:40:33 +02:00
Balázs Orbán
5f717b3914 chore: merge main into next 2021-04-12 00:46:27 +02:00
Balázs Orbán
d09a45ec7c chore: merge main into next 2021-03-26 16:23:35 +01:00
Balázs Orbán
930f58eba3 chore: merge main into next 2021-03-08 01:05:54 +01:00
Balázs Orbán
c20b7f2930 feat: use IE11 as client code bundle target (#1402) 2021-03-03 20:25:42 +01:00
Balázs Orbán
e418cddd96 chore: merge main into next 2021-03-03 20:25:42 +01:00
Balázs Orbán
111e7aabdf feat(provider): remove state property
BREAKING CHANGE: adding `state: true` is already redundant
as `protection: "state` is the default value. `state: false`
can be substituted with `protection: "state"`
2021-02-15 21:47:47 +01:00
Balázs Orbán
a113ef6fab feat: encourage returning strings instead of throwing
BREAKING CHANGE: We have supported throwing strings
for redirections, while we were showing a waring.
From now on, it is not possible. The user MUST return a string,
rather than throw it.
2021-02-15 21:47:35 +01:00
231 changed files with 29397 additions and 48787 deletions

4
.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
# Exclude directories we don't need from Docker context to improve build time
node_modules
www
src

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]

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

@@ -0,0 +1,34 @@
---
name: Bug report
about: Report a defect with NextAuth.js
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of the bug in NextAuth.js.
Do not report bugs with your own project here, ask from help by raising a question instead - this helps us a lot with administration overhead.
**Steps to reproduce**
Steps to reproduce the behavior.
Include a link to public repository which can be used to reproduce the behaviour.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots or error logs**
If applicable add screenshots or error logs to help explain the problem.
**Additional context**
Add any other context about the problem here.
**Feedback**
*Documentation refers to searching through [online documentation](https://next-auth.js.org), code comments and issue history. The example project refers to [next-auth-example](https://github.com/iaincollins/next-auth-example).*
* [ ] Found the documentation helpful
* [ ] Found documentation but was incomplete
* [ ] Could not find relevant documentation
* [ ] Found the example project helpful
* [ ] Did not find the example project helpful

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,26 @@
---
name: Feature request
about: Suggest an idea for NextAuth.js
labels: enhancement
assignees: ''
---
**Summary of proposed feature**
A clear and concise description of the feature being proposed.
**Purpose of proposed feature**
A clear and concise description of why this feature is necessary and what problems it solves.
**Detail about proposed feature**
A detailed description of how the proposal might work (if you have one).
**Potential problems**
Describe any potential problems or potential limitations or caveats that might apply to the proposed solution.
**Describe any alternatives you've considered**
A clear and concise description of any alternative options you've considered.
**Additional context**
Any other context, screenshots, etc.
*Please indicate if you are willing and able to help implement the proposed feature.*

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 💚

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

@@ -0,0 +1,25 @@
---
name: Question
about: Ask a question about NextAuth.js or for help using it
labels: question
assignees: ''
---
<!-- NOTE: Questions will be converted to Discussions. You can find them at https://github.com/nextauthjs/next-auth/discussions! -->
**Your question**
<!-- A clear and concise question. -->
**What are you trying to do**
<!-- A description of what you are trying to do, for context. -->
**Reproduction**
<!-- If your question is code related, adding a reproduction to your use case can greatly reduce the time it takes us to figure out how to better help you. -->
**Feedback**
*Documentation refers to searching through [online documentation](https://next-auth.js.org), code comments and issue history. The example project refers to [next-auth-example](https://github.com/iaincollins/next-auth-example).*
* [ ] Found the documentation helpful
* [ ] Found documentation but was incomplete
* [ ] Could not find relevant documentation
* [ ] Found the example project helpful
* [ ] Did not find the example project helpful

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 💚

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 💚

View File

@@ -16,33 +16,26 @@ merge of your pull request!
<!-- What changes are being made? (What feature/bug is being fixed here?) -->
## Reasoning 💡
**What**:
<!-- What changes are being made? What feature/bug is being fixed here? -->
<!-- Why are these changes necessary? -->
## Checklist 🧢
**Why**:
<!-- Feel free cross items ( like this `~[] item~` ) if they're irrelevant to your changes.
<!-- How were these changes implemented? -->
To check an item, place an `x` in the box like so: `- [x] Documentation`. -->
**How**:
<!-- Have you done all of these things? -->
**Checklist**:
<!-- add "N/A" to the end of each line that's irrelevant to your changes -->
<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->
- [ ] Documentation
- [ ] Tests
- [ ] Ready to be merged
<!-- In your opinion, is this ready to be merged as soon as it's reviewed? -->
<!-- In your opinion, is this ready to be merged as soon as it's reviewed? -->
## Affected issues 🎟
<!--
Please [scout and link issues](https://github.com/nextauthjs/next-auth/issues) that might be solved by this PR.
If you write `"Fixes"` or `"Closes"` before the issue link like so:
```
Fixes #359
```
the connected issue will be automatically closed once the PR is merged and hence help with maintenance of the library 😊
-->
<!-- feel free to add additional comments -->

32
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
# Simple check that the build is valid and no linting errors.
# Currently is run as a seperate workflow as it's fast to fail.
name: Lint/Build
on:
push:
branches:
- main
- beta
- next
pull_request:
branches:
- main
- beta
- next
jobs:
lint-and-build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
uses: bahmutov/npm-install@v1
- run: npm run lint
- run: npm run build

View File

@@ -1,27 +1,67 @@
name: Code Analysis
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [main, beta, next]
branches: [ main, beta, next ]
pull_request:
branches: [main]
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: "43 17 * * 2"
- cron: '43 17 * * 2'
jobs:
analyze:
name: Verify
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: ["javascript"]
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

57
.github/workflows/integration.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Integration Test
on:
push:
branches:
- main
- beta
- next
pull_request:
jobs:
test:
# Only run tests integration against Pull Requests from branches in
# this repository. We do this as integration tests require access to
# secrets in GitHub and they are not exposed to tests run against
# forks (for security reasons), so integration test against
# Pull Requests from external repos just fail and generate noise.
if: github.event.pull_request.head.repo.full_name == github.repository
# We use self-hosted runners as cloud based runnners (e.g. AWS, GPC)
# fail due to IP Address checks done by providers, which enforce
# CAPTCHA checks on login request from cloud compute IP addresses to
# prevent abuse.
runs-on: self-hosted
# Target time is under 5 minutes to run all tests. If it takes longer than
# 10 minutes should look at running tests in parallel. No individual flow
# should take longer than 5 minutes to build and run.
timeout-minutes: 10
strategy:
matrix:
node-version: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
uses: bahmutov/npm-install@v1
# Run tests (build library, build + start test app in Docker, run tests)
- run: npm test
# TODO Tests should exit out if env vars not set (currently hangs)
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
NEXTAUTH_TWITTER_ID: ${{secrets.NEXTAUTH_TWITTER_ID}}
NEXTAUTH_TWITTER_SECRET: ${{secrets.NEXTAUTH_TWITTER_SECRET}}
NEXTAUTH_TWITTER_USERNAME: ${{secrets.NEXTAUTH_TWITTER_USERNAME}}
NEXTAUTH_TWITTER_PASSWORD: ${{secrets.NEXTAUTH_TWITTER_PASSWORD}}
NEXTAUTH_GITHUB_ID: ${{secrets.NEXTAUTH_GITHUB_ID}}
NEXTAUTH_GITHUB_SECRET: ${{secrets.NEXTAUTH_GITHUB_SECRET}}
NEXTAUTH_GITHUB_USERNAME: ${{secrets.NEXTAUTH_GITHUB_USERNAME}}
NEXTAUTH_GITHUB_PASSWORD: ${{secrets.NEXTAUTH_GITHUB_PASSWORD}}

View File

@@ -1,13 +1,11 @@
name: PR Labeler
name: "Pull Request Labeler"
on:
- pull_request_target
- pull_request_target
jobs:
triage:
name: Triage
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@main
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
- uses: actions/labeler@main
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -1,5 +1,4 @@
name: Release
on:
push:
branches:
@@ -8,69 +7,20 @@ on:
- "next"
- "3.x"
pull_request:
jobs:
test:
name: Test
release:
name: "Release"
runs-on: ubuntu-latest
steps:
- name: Init
- name: Checkout
uses: actions/checkout@v2
- name: Setup Node
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 16
- name: Dependencies
node-version: 14
- name: Install dependencies
uses: bahmutov/npm-install@v1
- name: Build
run: npm run build
- name: Run tests
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
needs: test
if: ${{ github.event_name == 'push' }}
environment: Production
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
run: npx semantic-release@17
- 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: Publish to npm
run: |
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
npm run version:pr
npm publish --access public --tag experimental
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
NPM_TOKEN: ${{secrets.NPM_TOKEN}}

27
.github/workflows/types.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Types
on:
push:
branches:
- main
- beta
- next
pull_request:
branches:
- main
- beta
- next
jobs:
lint-and-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js
uses: actions/setup-node@v1
with:
node-version: 14
- name: Install dependencies
uses: bahmutov/npm-install@v1
- name: Check types
run: npm run test:types

8
.gitignore vendored
View File

@@ -59,11 +59,3 @@ app/yarn.lock
# Prisma migrations
/prisma/migrations
# Tests
/coverage
# v4
packages
apps
docs/providers.json

1
.husky/.gitignore vendored
View File

@@ -1 +0,0 @@
_

View File

@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
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 dev 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, set up the dev application:
2. Install packages:
```sh
npm run dev:setup
npm i && npm 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 dev application/server:
```sh
npm run dev
```
Your dev application will be available on `http://localhost:3000`
Your dev application will be available on ```http://localhost:3000```
That's it! 🎉
@@ -70,7 +64,6 @@ When running `npm run dev`, you start a Next.js dev server on `http://localhost:
#### 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,44 +73,58 @@ 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.
We use [semantic-release](https://github.com/semantic-release/semantic-release) together with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to automate releases. This makes the maintainenance process easier and less error-prone to human error. Please study the "Conventional Commits" site to understand how to write a good commit message.
When accepting Pull Requests, make sure the following:
- Use "Squash and merge"
- Make sure you merge contributor PRs into `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
body
```
Scope is the part that will help grouping the different commit types in the release notes.
Scope is the part that will help groupping the different commit types in the release notes.
Some recommended scopes are:
Some recommened scopes are:
- **provider** - Provider related changes. (eg.: "feat(provider): add X provider", "docs(provider): fix typo in X documentation"
- **adapter** - Adapter related changes. (eg.: "feat(adapter): add X provider", "docs(provider): fix typo in X documentation"
- **db** - Database related changes. (eg.: "feat(db): add X database", "docs(db): fix typo in X documentation"
- **deps** - Adding/removing/updating a dependency (eg.: "chore(deps): add X")
> NOTE: If you are not sure which scope to use, you can simply ignore it. (eg.: "feat: add something"). Adding the correct type already helps a lot when analyzing the commit messages.
### Skipping a release
Every commit that contains [skip release] or [release skip] in their message will be excluded from the commit analysis and won't participate in the release type determination. This is useful, if the PR being merged should not trigger a new `npm` release.

135
README.md
View File

@@ -7,8 +7,11 @@
Open Source. Full Stack. Own Your Data.
</p>
<p align="center" style="align: center;">
<a href="https://github.com/nextauthjs/next-auth/actions/workflows/release.yml?query=workflow%3ARelease">
<img src="https://github.com/nextauthjs/next-auth/actions/workflows/release.yml/badge.svg" alt="Release" />
<a href="https://github.com/nextauthjs/next-auth/actions?query=workflow%3ARelease">
<img src="https://github.com/nextauthjs/next-auth/workflows/Release/badge.svg" alt="Release" />
</a>
<a href="https://github.com/nextauthjs/next-auth/actions?query=workflow%3A%22Integration+Test%22">
<img src="https://github.com/nextauthjs/next-auth/workflows/Integration%20Test/badge.svg" alt="Integration Test" />
</a>
<a href="https://bundlephobia.com/result?p=next-auth">
<img src="https://img.shields.io/bundlephobia/minzip/next-auth" alt="Bundle Size"/>
@@ -38,7 +41,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,40 +51,40 @@ 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 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/)
* 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.
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 documentaion.
The package at `@types/next-auth` is now deprecated.
@@ -90,52 +93,50 @@ The package at `@types/next-auth` is now deprecated.
### Add API Route
```javascript
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,
database: process.env.DATABASE_URL
})
```
### Add React Component
```javascript
import { useSession, signIn, signOut } from "next-auth/client"
import {
useSession, signIn, signOut
} from 'next-auth/client'
export default function Component() {
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>
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>
</>
}
```
@@ -147,43 +148,13 @@ export default function Component() {
<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>
</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 publically.
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

@@ -4,7 +4,7 @@
NEXTAUTH_URL=http://localhost:3000
# You can use `openssl rand -hex 32` or
# https://generate-secret.vercel.app/32 to generate a secret.
# https://generate-secret.now.sh/32 to generate a secret.
# Note: Changing a secret may invalidate existing sessions
# and/or verificaion tokens.
SECRET=
@@ -23,6 +23,8 @@ TWITTER_SECRET=
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

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

@@ -14,7 +14,7 @@
},
"license": "ISC",
"dependencies": {
"next": "^11.0.1",
"next": "^10.1.3",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},

View File

@@ -1,9 +1,5 @@
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 NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
// import Adapters from 'next-auth/adapters'
// import { PrismaClient } from '@prisma/client'
@@ -32,15 +28,15 @@ export default NextAuth({
// }
// },
providers: [
EmailProvider({
Providers.Email({
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
from: process.env.EMAIL_FROM
}),
GitHubProvider({
Providers.GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
clientSecret: process.env.GITHUB_SECRET
}),
Auth0Provider({
Providers.Auth0({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
domain: process.env.AUTH0_DOMAIN,
@@ -49,36 +45,36 @@ export default NextAuth({
// authorizationParams: {
// response_mode: 'form_post'
// }
protection: "pkce",
protection: 'pkce'
}),
TwitterProvider({
Providers.Twitter({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
clientSecret: process.env.TWITTER_SECRET
}),
CredentialsProvider({
name: "Credentials",
Providers.Credentials({
name: 'Credentials',
credentials: {
password: { label: "Password", type: "password" },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials, req) {
if (credentials.password === "password") {
async authorize (credentials) {
if (credentials.password === 'password') {
return {
id: 1,
name: "Fill Murray",
email: "bill@fillmurray.com",
image: "https://www.fillmurray.com/64/64",
name: 'Fill Murray',
email: 'bill@fillmurray.com',
image: 'https://www.fillmurray.com/64/64'
}
}
return null
},
}),
}
})
],
jwt: {
encryption: true,
secret: process.env.SECRET,
secret: process.env.SECRET
},
debug: false,
theme: "auto",
theme: 'auto'
// Default Database Adapter (TypeORM)
// database: process.env.DATABASE_URL

View File

@@ -4,6 +4,6 @@ import jwt from 'next-auth/jwt'
const secret = process.env.SECRET
export default async (req, res) => {
const token = await jwt.getToken({ req, secret, encryption: true })
const token = await jwt.getToken({ req, secret })
res.send(JSON.stringify(token, null, 2))
}

View File

@@ -1,33 +0,0 @@
// We aim to have the same support as Next.js
// https://nextjs.org/docs/getting-started#system-requirements
// https://nextjs.org/docs/basic-features/supported-browsers-features
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-react",
{
runtime: "automatic",
},
],
],
},
],
}

25
config/babel.config.json Normal file
View File

@@ -0,0 +1,25 @@
{
"presets": [
["@babel/preset-env", { "targets": { "node": "10" } }]
],
"plugins": [
"@babel/plugin-proposal-class-properties"
],
"plugins": [
"@babel/plugin-proposal-class-properties"
],
"comments": false,
"overrides": [
{
"test": ["../src/client/**"],
"comments": true,
"presets": [
["@babel/preset-env", { "targets": { "ie": "11" } }]
]
},
{
"test": ["../src/server/pages/**"],
"presets": ["preact"]
}
]
}

View File

@@ -1,2 +0,0 @@
import "@testing-library/jest-dom"
import "whatwg-fetch"

View File

@@ -1,11 +0,0 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
transform: {
"\\.js$": ["babel-jest", { configFile: "./config/babel.config.js" }],
},
rootDir: "../src",
setupFilesAfterEnv: ["../config/jest-setup.js"],
collectCoverageFrom: ["!client/__tests__/**"],
testMatch: ["**/*.test.js"],
coverageDirectory: "../coverage",
}

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)
}

38437
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth",
"version": "3.29.9",
"version": "0.0.0-semantically-released",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",
@@ -30,20 +30,31 @@
},
"scripts": {
"build": "npm run build:js && npm run build:css",
"build:js": "node ./config/build.js && babel --config-file ./config/babel.config.js src --out-dir dist",
"build:js": "node ./config/build.js && babel --config-file ./config/babel.config.json src --out-dir dist",
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
"dev:setup": "npm i && npm run build:css && cd app && npm i",
"dev:setup": "npm run build:css && cd app && npm i",
"dev": "cd app && npm run dev",
"watch": "npm run watch:js | npm run watch:css",
"watch:js": "babel --config-file ./config/babel.config.js --watch src --out-dir dist",
"watch:js": "babel --config-file ./config/babel.config.json --watch src --out-dir dist",
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
"test": "jest --config ./config/jest.config.js",
"test:ci": "npm run lint && npm run test:types && npm run test -- --ci",
"test:types": "dtslint types --onlyTestTsNext",
"test:app:start": "docker-compose -f test/docker/app.yml up -d",
"test:app:rebuild": "npm run build && docker-compose -f test/docker/app.yml up -d --build",
"test:app:stop": "docker-compose -f test/docker/app.yml down",
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop && npm run test:types",
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql",
"test:db:mysql": "node test/mysql.js",
"test:db:postgres": "node test/postgres.js",
"test:db:mongodb": "node test/mongodb.js",
"test:db:mssql": "node test/mssql.js",
"test:integration": "mocha test/integration",
"test:types": "dtslint types",
"db:start": "docker-compose -f test/docker/databases.yml up -d",
"db:stop": "docker-compose -f test/docker/databases.yml down",
"prepublishOnly": "npm run build",
"publish:beta": "npm publish --tag beta",
"publish:canary": "npm publish --tag canary",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"version:pr": "node ./config/version-pr"
"lint:fix": "eslint . --fix"
},
"files": [
"dist",
@@ -63,46 +74,63 @@
],
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.14.0",
"@next-auth/prisma-legacy-adapter": "0.1.2",
"@next-auth/typeorm-legacy-adapter": "0.1.4",
"@balazsorban/require-optional": "^1.0.0",
"futoin-hkdf": "^1.3.2",
"jose": "^1.27.2",
"jsonwebtoken": "^8.5.1",
"nodemailer": "^6.4.16",
"oauth": "^0.9.15",
"pkce-challenge": "^2.1.0",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.14",
"querystring": "^0.2.0"
"preact-render-to-string": "^5.1.14"
},
"peerDependencies": {
"react": "^16.13.1 || ^17",
"react-dom": "^16.13.1 || ^17"
},
"peerOptionalDependencies": {
"mongodb": "^3.5.9",
"react-dom": "^16.13.1 || ^17",
"mongodb": "^3.6.6",
"mysql": "^2.18.1",
"mssql": "^6.2.1",
"pg": "^8.2.1",
"@prisma/client": "^2.16.1"
"@prisma/client": "^2.16.1",
"nodemailer": "^6.4.16",
"typeorm": "^0.2.30"
},
"peerDependenciesMeta": {
"mongodb": {
"optional": true
},
"mysql": {
"optional": true
},
"mssql": {
"optional": true
},
"pg": {
"optional": true
},
"@prisma/client": {
"optional": true
},
"nodemailer": {
"optional": true
},
"typeorm": {
"optional": true
}
},
"devDependencies": {
"@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/plugin-proposal-class-properties": "^7.13.0",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.13.13",
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.1.9",
"@types/nodemailer": "^6.4.2",
"@prisma/client": "^2.16.1",
"@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",
"@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.4.0",
"cssnano": "^4.1.10",
@@ -112,22 +140,25 @@
"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": "^4.3.1",
"eslint-plugin-standard": "^5.0.0",
"husky": "^6.0.0",
"jest": "^26.6.3",
"msw": "^0.28.2",
"next": "^11.0.1",
"mocha": "^8.1.3",
"mongodb": "^3.6.6",
"mssql": "^6.2.1",
"mysql": "^2.18.1",
"next": "^10.0.5",
"pg": "^8.2.1",
"postcss-cli": "^7.1.1",
"postcss-nested": "^4.2.1",
"prettier": "^2.2.1",
"pretty-quick": "^3.1.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.1.3",
"whatwg-fetch": "^3.6.2"
"prisma": "^2.16.1",
"puppeteer": "^5.2.1",
"puppeteer-extra": "^3.1.15",
"puppeteer-extra-plugin-stealth": "^2.6.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"typescript": "^4.1.3"
},
"prettier": {
"semi": false
@@ -154,23 +185,7 @@
"localStorage": "readonly",
"location": "readonly",
"fetch": "readonly"
},
"overrides": [
{
"files": [
"./**/*test.js"
],
"env": {
"jest/globals": true
},
"extends": [
"plugin:jest/recommended"
],
"plugins": [
"jest"
]
}
]
}
},
"release": {
"branches": [

View File

@@ -1,36 +0,0 @@
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()
}

View File

@@ -0,0 +1,110 @@
const Adapter = (config, options = {}) => {
async function getAdapter (appOptions) {
const { logger } = appOptions
// Display debug output if debug option enabled
function debug (debugCode, ...args) {
logger.debug(`ADAPTER_${debugCode}`, ...args)
}
async function createUser (profile) {
debug('createUser', profile)
return null
}
async function getUser (id) {
debug('getUser', id)
return null
}
async function getUserByEmail (email) {
debug('getUserByEmail', email)
return null
}
async function getUserByProviderAccountId (providerId, providerAccountId) {
debug('getUserByProviderAccountId', providerId, providerAccountId)
return null
}
async function updateUser (user) {
debug('updateUser', user)
return null
}
async function deleteUser (userId) {
debug('deleteUser', userId)
return null
}
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
debug('linkAccount', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
return null
}
async function unlinkAccount (userId, providerId, providerAccountId) {
debug('unlinkAccount', userId, providerId, providerAccountId)
return null
}
async function createSession (user) {
debug('createSession', user)
return null
}
async function getSession (sessionToken) {
debug('getSession', sessionToken)
return null
}
async function updateSession (session, force) {
debug('updateSession', session)
return null
}
async function deleteSession (sessionToken) {
debug('deleteSession', sessionToken)
return null
}
async function createVerificationRequest (identifier, url, token, secret, provider) {
debug('createVerificationRequest', identifier)
return null
}
async function getVerificationRequest (identifier, token, secret, provider) {
debug('getVerificationRequest', identifier, token)
return null
}
async function deleteVerificationRequest (identifier, token, secret, provider) {
debug('deleteVerification', identifier, token)
return null
}
return Promise.resolve({
createUser,
getUser,
getUserByEmail,
getUserByProviderAccountId,
updateUser,
deleteUser,
linkAccount,
unlinkAccount,
createSession,
getSession,
updateSession,
deleteSession,
createVerificationRequest,
getVerificationRequest,
deleteVerificationRequest
})
}
return {
getAdapter
}
}
export default {
Adapter
}

View File

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

View File

@@ -1,6 +0,0 @@
/*
* 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"

View File

@@ -0,0 +1,340 @@
import { createHash, randomBytes } from 'crypto'
import { CreateUserError } from '../../lib/errors'
const Adapter = (config) => {
const {
prisma,
modelMapping = {
User: 'user',
Account: 'account',
Session: 'session',
VerificationRequest: 'verificationRequest'
}
} = config
const { User, Account, Session, VerificationRequest } = modelMapping
function getCompoundId (providerId, providerAccountId) {
return createHash('sha256').update(`${providerId}:${providerAccountId}`).digest('hex')
}
async function getAdapter (appOptions) {
const { logger } = appOptions
function debug (debugCode, ...args) {
logger.debug(`PRISMA_${debugCode}`, ...args)
}
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) {
debug('GET_ADAPTER', 'Session expiry not configured (defaulting to 30 days')
}
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
? appOptions.session.maxAge * 1000
: defaultSessionMaxAge
const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge)
? appOptions.session.updateAge * 1000
: 0
async function createUser (profile) {
debug('CREATE_USER', profile)
try {
return prisma[User].create({
data: {
name: profile.name,
email: profile.email,
image: profile.image,
emailVerified: profile.emailVerified ? profile.emailVerified.toISOString() : null
}
})
} catch (error) {
logger.error('CREATE_USER_ERROR', error)
return Promise.reject(new CreateUserError(error))
}
}
async function getUser (id) {
debug('GET_USER', id)
try {
return prisma[User].findUnique({ where: { id } })
} catch (error) {
logger.error('GET_USER_BY_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
}
}
async function getUserByEmail (email) {
debug('GET_USER_BY_EMAIL', email)
try {
if (!email) { return Promise.resolve(null) }
return prisma[User].findUnique({ where: { email } })
} catch (error) {
logger.error('GET_USER_BY_EMAIL_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
}
}
async function getUserByProviderAccountId (providerId, providerAccountId) {
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
try {
const account = await prisma[Account].findUnique({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
if (!account) { return null }
return prisma[User].findUnique({ where: { id: account.userId } })
} catch (error) {
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
}
}
async function updateUser (user) {
debug('UPDATE_USER', user)
try {
const { id, name, email, image, emailVerified } = user
return prisma[User].update({
where: { id },
data: {
name,
email,
image,
emailVerified: emailVerified ? emailVerified.toISOString() : null
}
})
} catch (error) {
logger.error('UPDATE_USER_ERROR', error)
return Promise.reject(new Error('UPDATE_USER_ERROR', error))
}
}
async function deleteUser (userId) {
debug('DELETE_USER', userId)
try {
return prisma[User].delete({ where: { id: userId } })
} catch (error) {
logger.error('DELETE_USER_ERROR', error)
return Promise.reject(new Error('DELETE_USER_ERROR', error))
}
}
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
debug('LINK_ACCOUNT', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
try {
return prisma[Account].create({
data: {
accessToken,
refreshToken,
compoundId: getCompoundId(providerId, providerAccountId),
providerAccountId: `${providerAccountId}`,
providerId,
providerType,
accessTokenExpires,
userId
}
})
} catch (error) {
logger.error('LINK_ACCOUNT_ERROR', error)
return Promise.reject(new Error('LINK_ACCOUNT_ERROR', error))
}
}
async function unlinkAccount (userId, providerId, providerAccountId) {
debug('UNLINK_ACCOUNT', userId, providerId, providerAccountId)
try {
return prisma[Account].delete({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
} catch (error) {
logger.error('UNLINK_ACCOUNT_ERROR', error)
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR', error))
}
}
async function createSession (user) {
debug('CREATE_SESSION', user)
try {
let expires = null
if (sessionMaxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
expires = dateExpires.toISOString()
}
return prisma[Session].create({
data: {
expires,
userId: user.id,
sessionToken: randomBytes(32).toString('hex'),
accessToken: randomBytes(32).toString('hex')
}
})
} catch (error) {
logger.error('CREATE_SESSION_ERROR', error)
return Promise.reject(new Error('CREATE_SESSION_ERROR', error))
}
}
async function getSession (sessionToken) {
debug('GET_SESSION', sessionToken)
try {
const session = await prisma[Session].findUnique({ where: { sessionToken } })
// Check session has not expired (do not return it if it has)
if (session && session.expires && new Date() > session.expires) {
await prisma[Session].delete({ where: { sessionToken } })
return null
}
return session
} catch (error) {
logger.error('GET_SESSION_ERROR', error)
return Promise.reject(new Error('GET_SESSION_ERROR', error))
}
}
async function updateSession (session, force) {
debug('UPDATE_SESSION', session)
try {
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
// Calculate last updated date, to throttle write updates to database
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
// e.g. ({expiry date} - 30 days) + 1 hour
//
// Default for sessionMaxAge is 30 days.
// Default for sessionUpdateAge is 1 hour.
const dateSessionIsDueToBeUpdated = new Date(session.expires)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
// Trigger update of session expiry date and write to database, only
// if the session was last updated more than {sessionUpdateAge} ago
if (new Date() > dateSessionIsDueToBeUpdated) {
const newExpiryDate = new Date()
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
session.expires = newExpiryDate
} else if (!force) {
return null
}
} else {
// If session MaxAge, session UpdateAge or session.expires are
// missing then don't even try to save changes, unless force is set.
if (!force) { return null }
}
const { id, expires } = session
return prisma[Session].update({ where: { id }, data: { expires: expires.toISOString() } })
} catch (error) {
logger.error('UPDATE_SESSION_ERROR', error)
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
}
}
async function deleteSession (sessionToken) {
debug('DELETE_SESSION', sessionToken)
try {
return prisma[Session].delete({ where: { sessionToken } })
} catch (error) {
logger.error('DELETE_SESSION_ERROR', error)
return Promise.reject(new Error('DELETE_SESSION_ERROR', error))
}
}
async function createVerificationRequest (identifier, url, token, secret, provider) {
debug('CREATE_VERIFICATION_REQUEST', identifier)
try {
const { baseUrl } = appOptions
const { sendVerificationRequest, maxAge } = provider
// Store hashed token (using secret as salt) so that tokens cannot be exploited
// even if the contents of the database is compromised.
// @TODO Use bcrypt function here instead of simple salted hash
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
let expires = null
if (maxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
expires = dateExpires.toISOString()
}
// Save to database
const verificationRequest = await prisma[VerificationRequest].create({
data: {
identifier,
token: hashedToken,
expires
}
})
// With the verificationCallback on a provider, you can send an email, or queue
// an email to be sent, or perform some other action (e.g. send a text message)
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
return verificationRequest
} catch (error) {
logger.error('CREATE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR', error))
}
}
async function getVerificationRequest (identifier, token, secret, provider) {
debug('GET_VERIFICATION_REQUEST', identifier, token)
try {
// Hash token provided with secret before trying to match it with database
// @TODO Use bcrypt instead of salted SHA-256 hash for token
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
const verificationRequest = await prisma[VerificationRequest].findFirst({
where: {
identifier,
token: hashedToken
}
})
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
// Delete verification entry so it cannot be used again
await prisma[VerificationRequest].deleteMany({ where: { identifier, token: hashedToken } })
return null
}
return verificationRequest
} catch (error) {
logger.error('GET_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR', error))
}
}
async function deleteVerificationRequest (identifier, token, secret, provider) {
debug('DELETE_VERIFICATION', identifier, token)
try {
// Delete verification entry so it cannot be used again
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
await prisma[VerificationRequest].deleteMany({ where: { identifier, token: hashedToken } })
} catch (error) {
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))
}
}
return Promise.resolve({
createUser,
getUser,
getUserByEmail,
getUserByProviderAccountId,
updateUser,
deleteUser,
linkAccount,
unlinkAccount,
createSession,
getSession,
updateSession,
deleteSession,
createVerificationRequest,
getVerificationRequest,
deleteVerificationRequest
})
}
return {
getAdapter
}
}
export default {
Adapter
}

View File

@@ -1,9 +0,0 @@
/*
* 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

@@ -0,0 +1,385 @@
import { createConnection, getConnection } from 'typeorm'
import { createHash } from 'crypto'
import { CreateUserError } from '../../lib/errors'
import adapterConfig from './lib/config'
import adapterTransform from './lib/transform'
import Models from './models'
import { updateConnectionEntities } from './lib/utils'
import requireOptional from '@balazsorban/require-optional'
const Adapter = (typeOrmConfig, options = {}) => {
// Ensure typeOrmConfigObject is normalized to an object
const typeOrmConfigObject = (typeof typeOrmConfig === 'string')
? adapterConfig.parseConnectionString(typeOrmConfig)
: typeOrmConfig
// Load any custom models passed as an option, default to built in models
const { models: customModels = {} } = options
const models = {
User: customModels.User ? customModels.User : Models.User,
Account: customModels.Account ? customModels.Account : Models.Account,
Session: customModels.Session ? customModels.Session : Models.Session,
VerificationRequest: customModels.VerificationRequest ? customModels.VerificationRequest : Models.VerificationRequest
}
// The models are designed for ANSI SQL databases first (as a baseline).
// For databases that use a different pragma, we transform the models at run
// time *unless* the models are user supplied (in which case we don't do
// anything to do them). This function updates arguments by reference.
adapterTransform(typeOrmConfigObject, models, options)
const config = adapterConfig.loadConfig(typeOrmConfigObject, { ...options, models })
// Create objects from models that can be consumed by functions in the adapter
const User = models.User.model
const Account = models.Account.model
const Session = models.Session.model
const VerificationRequest = models.VerificationRequest.model
let connection = null
async function getAdapter (appOptions) {
const { logger } = appOptions
// Display debug output if debug option enabled
function debug (debugCode, ...args) {
logger.debug(`TYPEORM_${debugCode}`, ...args)
}
// Helper function to reuse / restablish connections
// (useful if they drop when after being idle)
async function _connect () {
// Get current connection by name
connection = getConnection(config.name)
// If connection is no longer established, reconnect
if (!connection.isConnected) { connection = await connection.connect() }
}
if (!connection) {
// If no connection, create new connection
try {
connection = await createConnection(config)
} catch (error) {
if (error.name === 'AlreadyHasActiveConnectionError') {
// If creating connection fails because it's already
// been re-established, check it's really up
await _connect()
} else {
logger.error('ADAPTER_CONNECTION_ERROR', error)
}
}
} else {
// If the connection object already exists, ensure it's valid
await _connect()
}
if (process.env.NODE_ENV !== 'production') {
await updateConnectionEntities(connection, config.entities)
}
// Get manager from connection object
// https://github.com/typeorm/typeorm/blob/master/docs/entity-manager-api.md
const { manager } = connection
// The models are primarily designed for ANSI SQL database, but some
// flexiblity is required in the adapter to support non-SQL databases such
// as MongoDB which have different pragmas.
//
// TypeORM does some abstraction, but doesn't handle everything (e.g. it
// handles translating `id` and `_id` in models, but not queries) so we
// need to handle somethings in the adapter to make it compatible.
let idKey = 'id'
let ObjectId
if (config.type === 'mongodb') {
idKey = '_id'
// We should/could use dynamic import here, but
// bundlers like webpack will try to import the module,
// even if this conditional branch is never entered.
// We work around this with requireOptional.
const mongodb = requireOptional('mongodb')
ObjectId = mongodb.ObjectID
}
// These values are stored as seconds, but to use them with dates in
// JavaScript we convert them to milliseconds.
//
// Use a conditional to default to 30 day session age if not set - it should
// always be set but a meaningful fallback is helpful to facilitate testing.
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) {
debug('GET_ADAPTER', 'Session expiry not configured (defaulting to 30 days')
}
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
? appOptions.session.maxAge * 1000
: defaultSessionMaxAge
const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge)
? appOptions.session.updateAge * 1000
: 0
async function createUser (profile) {
debug('CREATE_USER', profile)
try {
// Create user account
const user = new User(profile.name, profile.email, profile.image, profile.emailVerified)
return await manager.save(user)
} catch (error) {
logger.error('CREATE_USER_ERROR', error)
return Promise.reject(new CreateUserError(error))
}
}
async function getUser (id) {
debug('GET_USER', id)
// In the very specific case of both using JWT for storing session data
// and using MongoDB to store user data, the ID is a string rather than
// an ObjectId and we need to turn it into an ObjectId.
//
// In all other scenarios it is already an ObjectId, because it will have
// come from another MongoDB query.
if (ObjectId && !(id instanceof ObjectId)) {
id = ObjectId(id)
}
try {
return manager.findOne(User, { [idKey]: id })
} catch (error) {
logger.error('GET_USER_BY_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
}
}
async function getUserByEmail (email) {
debug('GET_USER_BY_EMAIL', email)
try {
if (!email) { return Promise.resolve(null) }
return manager.findOne(User, { email })
} catch (error) {
logger.error('GET_USER_BY_EMAIL_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
}
}
async function getUserByProviderAccountId (providerId, providerAccountId) {
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
try {
const account = await manager.findOne(Account, { providerId, providerAccountId })
if (!account) { return null }
return manager.findOne(User, { [idKey]: account.userId })
} catch (error) {
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
}
}
async function updateUser (user) {
debug('UPDATE_USER', user)
return manager.save(User, user)
}
async function deleteUser (userId) {
debug('DELETE_USER', userId)
// @TODO Delete user from DB
return false
}
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
debug('LINK_ACCOUNT', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
try {
// Create provider account linked to user
const account = new Account(userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
return manager.save(account)
} catch (error) {
logger.error('LINK_ACCOUNT_ERROR', error)
return Promise.reject(new Error('LINK_ACCOUNT_ERROR', error))
}
}
async function unlinkAccount (userId, providerId, providerAccountId) {
debug('UNLINK_ACCOUNT', userId, providerId, providerAccountId)
// @TODO Get current user from DB
// @TODO Delete [provider] object from user object
// @TODO Save changes to user object in DB
return false
}
async function createSession (user) {
debug('CREATE_SESSION', user)
try {
let expires = null
if (sessionMaxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
expires = dateExpires
}
const session = new Session(user.id, expires)
return manager.save(session)
} catch (error) {
logger.error('CREATE_SESSION_ERROR', error)
return Promise.reject(new Error('CREATE_SESSION_ERROR', error))
}
}
async function getSession (sessionToken) {
debug('GET_SESSION', sessionToken)
try {
const session = await manager.findOne(Session, { sessionToken })
// Check session has not expired (do not return it if it has)
if (session && session.expires && new Date() > new Date(session.expires)) {
// @TODO Delete old sessions from database
return null
}
return session
} catch (error) {
logger.error('GET_SESSION_ERROR', error)
return Promise.reject(new Error('GET_SESSION_ERROR', error))
}
}
async function updateSession (session, force) {
debug('UPDATE_SESSION', session)
try {
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
// Calculate last updated date, to throttle write updates to database
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
// e.g. ({expiry date} - 30 days) + 1 hour
//
// Default for sessionMaxAge is 30 days.
// Default for sessionUpdateAge is 1 hour.
const dateSessionIsDueToBeUpdated = new Date(session.expires)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
// Trigger update of session expiry date and write to database, only
// if the session was last updated more than {sessionUpdateAge} ago
if (new Date() > dateSessionIsDueToBeUpdated) {
const newExpiryDate = new Date()
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
session.expires = newExpiryDate
} else if (!force) {
return null
}
} else {
// If session MaxAge, session UpdateAge or session.expires are
// missing then don't even try to save changes, unless force is set.
if (!force) { return null }
}
return manager.save(Session, session)
} catch (error) {
logger.error('UPDATE_SESSION_ERROR', error)
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
}
}
async function deleteSession (sessionToken) {
debug('DELETE_SESSION', sessionToken)
try {
return await manager.delete(Session, { sessionToken })
} catch (error) {
logger.error('DELETE_SESSION_ERROR', error)
return Promise.reject(new Error('DELETE_SESSION_ERROR', error))
}
}
async function createVerificationRequest (identifier, url, token, secret, provider) {
debug('CREATE_VERIFICATION_REQUEST', identifier)
try {
const { baseUrl } = appOptions
const { sendVerificationRequest, maxAge } = provider
// Store hashed token (using secret as salt) so that tokens cannot be exploited
// even if the contents of the database is compromised.
// @TODO Use bcrypt function here instead of simple salted hash
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
let expires = null
if (maxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
expires = dateExpires
}
// Save to database
const newVerificationRequest = new VerificationRequest(identifier, hashedToken, expires)
const verificationRequest = await manager.save(newVerificationRequest)
// With the verificationCallback on a provider, you can send an email, or queue
// an email to be sent, or perform some other action (e.g. send a text message)
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
return verificationRequest
} catch (error) {
logger.error('CREATE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR', error))
}
}
async function getVerificationRequest (identifier, token, secret, provider) {
debug('GET_VERIFICATION_REQUEST', identifier, token)
try {
// Hash token provided with secret before trying to match it with database
// @TODO Use bcrypt instead of salted SHA-256 hash for token
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
const verificationRequest = await manager.findOne(VerificationRequest, { identifier, token: hashedToken })
if (verificationRequest && verificationRequest.expires && new Date() > new Date(verificationRequest.expires)) {
// Delete verification entry so it cannot be used again
await manager.delete(VerificationRequest, { identifier, token: hashedToken })
return null
}
return verificationRequest
} catch (error) {
logger.error('GET_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR', error))
}
}
async function deleteVerificationRequest (identifier, token, secret, provider) {
debug('DELETE_VERIFICATION', identifier, token)
try {
// Delete verification entry so it cannot be used again
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
await manager.delete(VerificationRequest, { identifier, token: hashedToken })
} catch (error) {
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))
}
}
return Promise.resolve({
createUser,
getUser,
getUserByEmail,
getUserByProviderAccountId,
updateUser,
deleteUser,
linkAccount,
unlinkAccount,
createSession,
getSession,
updateSession,
deleteSession,
createVerificationRequest,
getVerificationRequest,
deleteVerificationRequest
})
}
return {
getAdapter
}
}
export default {
Adapter,
Models
}

View File

@@ -0,0 +1,84 @@
import { EntitySchema } from 'typeorm'
const parseConnectionString = (configString) => {
if (typeof configString !== 'string') { return configString }
// If the input is URL string, automatically convert the string to an object
// to make configuration easier (in most use cases).
//
// TypeORM accepts connection string as a 'url' option, but unfortunately
// not for all databases (e.g. SQLite) or for all options, so we handle
// parsing it in this function.
try {
const parsedUrl = new URL(configString)
const config = {}
if (parsedUrl.protocol.startsWith('mongodb+srv')) {
// Special case handling is required for mongodb+srv with TypeORM
config.type = 'mongodb'
config.url = configString.replace(/\?(.*)$/, '')
config.useNewUrlParser = true
} else {
config.type = parsedUrl.protocol.replace(/:$/, '')
config.host = parsedUrl.hostname
config.port = Number(parsedUrl.port)
config.username = parsedUrl.username
config.password = parsedUrl.password
config.database = parsedUrl.pathname.replace(/^\//, '').replace(/\?(.*)$/, '')
config.options = {}
}
// This option is recommended by mongodb
if (config.type === 'mongodb') {
config.useUnifiedTopology = true
}
// Prevents warning about deprecated option (sets default value)
if (config.type === 'mssql') {
config.options.enableArithAbort = true
}
if (parsedUrl.search) {
parsedUrl.search.replace(/^\?/, '').split('&').forEach(keyValuePair => {
let [key, value] = keyValuePair.split('=')
// Converts true/false strings to actual boolean values
if (value === 'true') { value = true }
if (value === 'false') { value = false }
config[key] = value
})
}
return config
} catch (error) {
// If URL parsing fails for any reason, try letting TypeORM handle it
return {
url: configString
}
}
}
const loadConfig = (config, { models, namingStrategy }) => {
const defaultConfig = {
name: 'nextauth',
autoLoadEntities: true,
entities: [
new EntitySchema(models.User.schema),
new EntitySchema(models.Account.schema),
new EntitySchema(models.Session.schema),
new EntitySchema(models.VerificationRequest.schema)
],
timezone: 'Z', // Required for timestamps to be treated as UTC in MySQL
logging: false,
namingStrategy
}
return {
...defaultConfig,
...config
}
}
export default {
parseConnectionString,
loadConfig
}

View File

@@ -0,0 +1,45 @@
// Inspired by https://github.com/tonivj5/typeorm-naming-strategies
import { DefaultNamingStrategy } from 'typeorm'
import { snakeCase, camelCase } from 'typeorm/util/StringUtils'
export class SnakeCaseNamingStrategy extends DefaultNamingStrategy {
// Pluralise table names (set customName to override)
tableName (className, customName) {
return customName || snakeCase(`${className}s`)
}
columnName (propertyName, customName, embeddedPrefixes) {
return `${snakeCase(embeddedPrefixes.join('_'))}${customName || snakeCase(propertyName)}`
}
relationName (propertyName) {
return snakeCase(propertyName)
}
joinColumnName (relationName, referencedColumnName) {
return snakeCase(`${relationName}_${referencedColumnName}`)
}
joinTableName (firstTableName, secondTableName, firstPropertyName, secondPropertyName) {
return snakeCase(`${firstTableName}_${firstPropertyName.replace(/\./gi, '_')}_${secondTableName}`)
}
joinTableColumnName (tableName, propertyName, columnName) {
return snakeCase(`${tableName}_${(columnName || propertyName)}`)
}
classTableInheritanceParentColumnName (parentTableName, parentTableIdPropertyName) {
return snakeCase(`${parentTableName}_${parentTableIdPropertyName}`)
}
eagerJoinRelationAlias (alias, propertyPath) {
return `${alias}__${propertyPath.replace('.', '_')}`
}
}
export class CamelCaseNamingStrategy extends DefaultNamingStrategy {
// Pluralise collection names, uses (set customName to override)
tableName (className, customName) {
return customName || camelCase(`${className}s`)
}
}

View File

@@ -0,0 +1,166 @@
// Perform transforms on SQL models so they can be used with other databases
import { SnakeCaseNamingStrategy, CamelCaseNamingStrategy } from './naming-strategies'
const postgresTransform = (models, options) => {
// Apply snake case naming strategy for Postgres databases
if (!options.namingStrategy) {
options.namingStrategy = new SnakeCaseNamingStrategy()
}
// For Postgres we need to use the `timestamp with time zone` type
// aka `timestamptz` to store timestamps correctly in UTC.
for (const model in models) {
for (const column in models[model].schema.columns) {
if (models[model].schema.columns[column].type === 'timestamp') {
models[model].schema.columns[column].type = 'timestamptz'
}
}
}
}
const mysqlTransform = (models, options) => {
// Apply snake case naming strategy for MySQL databases
if (!options.namingStrategy) {
options.namingStrategy = new SnakeCaseNamingStrategy()
}
// For MySQL we default milisecond precision of all timestamps to 6 digits.
// This ensures all timestamp fields use the same precision (unless explictly
// configured otherwise) and that values in MySQL match those Postgress.
for (const model in models) {
for (const column in models[model].schema.columns) {
if (models[model].schema.columns[column].type === 'timestamp') {
// If precision explictly set (including to null) don't change it
if (typeof models[model].schema.columns[column].precision === 'undefined') {
models[model].schema.columns[column].precision = 6
}
}
}
}
}
const mongodbTransform = (models, options) => {
// A CamelCase naming strategy is used for all document databases
if (!options.namingStrategy) {
options.namingStrategy = new CamelCaseNamingStrategy()
}
// Important!
//
// 1. You must set 'objectId: true' on one property on a model in MongoDB.
//
// 'objectId' MUST be set on the primary ID field. This overrides other
// values on that object in TypeORM (e.g. type: 'int' or 'primary').
//
// 2. Other properties that are Object IDs in the same model MUST be set to
// type: 'objectId' (and should not be set to `objectId: true`).
//
// If you set 'objectId: true' on multiple properties on a model you will
// see the result of queries like find() is wrong. You will see the same
// Object ID in every property of type Object ID in the result (but the
// database will look fine); so use `type: 'objectId'` for them instead.
for (const model in models) {
delete models[model].schema.columns.id.type
models[model].schema.columns.id.objectId = true
}
// Ensure reference to User ID in other models are Object IDs
// This needs to done for any properties that reference another entity by ID
models.Account.schema.columns.userId.type = 'objectId'
models.Session.schema.columns.userId.type = 'objectId'
// The options `unique: true` and `nullable: true` don't work the same
// with MongoDB as they do with SQL databases like MySQL and Postgres,
// we need to create a sparse index to only allow unique values, while
// still allowing multiple entires to omit the email address.
delete models.User.schema.columns.email.unique
if (!models.User.schema.indices) { models.User.schema.indices = [] }
models.User.schema.indices.push({
name: 'email',
unique: true,
sparse: true,
columns: ['email']
})
}
const sqliteTransform = (models, options) => {
// Apply snake case naming strategy for SQLite databases
if (!options.namingStrategy) {
options.namingStrategy = new SnakeCaseNamingStrategy()
}
// SQLite does not support `timestamp` fields so we remap them to `datetime`
// in all models.
//
// `timestamp` is an ANSI SQL specification and widely supported by other
// databases so this transform is a specific workaround required for SQLite.
//
// NB: SQLite adds 'create' and 'update' fields to allow rows, but that is
// specific to SQLite and so we ignore that behaviour.
for (const model in models) {
for (const column in models[model].schema.columns) {
if (models[model].schema.columns[column].type === 'timestamp') {
models[model].schema.columns[column].type = 'datetime'
}
}
}
}
const mssqlTransform = (models, options) => {
// Apply snake case naming strategy for SQL Server databases
if (!options.namingStrategy) {
// @TODO Add TitleCase instead as more common MSSQL convention?
options.namingStrategy = new SnakeCaseNamingStrategy()
}
// SQL Server deprecated TIMESTAMP in favor of ROWVERSION.
// But ROWVERSION is not what it was intended in the other adapters.
for (const model in models) {
for (const column in models[model].schema.columns) {
if (models[model].schema.columns[column].type === 'timestamp') {
models[model].schema.columns[column].type = 'datetime'
}
}
}
// Support UNIQUE on on User.email that allows duplicate NULL values
// Note: This is ANSI SQL behaviour for UNIQUE not default in SQL Server
delete models.User.schema.columns.email.unique
if (!models.User.schema.indices) { models.User.schema.indices = [] }
models.User.schema.indices.push({
name: 'email',
columns: ['email'],
unique: true,
where: 'email IS NOT NULL'
})
}
export default (config, models, options) => {
// @TODO Refactor into switch statement
if ((config.type && config.type.startsWith('mongodb')) ||
(config.url && config.url.startsWith('mongodb'))) {
mongodbTransform(models, options)
} else if ((config.type && config.type.startsWith('postgres')) ||
(config.url && config.url.startsWith('postgres'))) {
postgresTransform(models, options)
} else if ((config.type && config.type.startsWith('mysql')) ||
(config.url && config.url.startsWith('mysql'))) {
mysqlTransform(models, options)
} else if ((config.type && config.type.startsWith('sqlite')) ||
(config.url && config.url.startsWith('sqlite'))) {
sqliteTransform(models, options)
} else if ((config.type && config.type.startsWith('mssql')) ||
(config.url && config.url.startsWith('mssql'))) {
mssqlTransform(models, options)
} else {
// For all other SQL databases (e.g. MySQL) apply snake case naming
// strategy, but otherwise use the models and schemas as they are.
if (!options.namingStrategy) {
options.namingStrategy = new SnakeCaseNamingStrategy()
}
}
}

View File

@@ -0,0 +1,18 @@
const entitiesChanged = (prevEntities, newEntities) => {
if (prevEntities.length !== newEntities.length) return true
for (let i = 0; i < prevEntities.length; i++) {
if (prevEntities[i] !== newEntities[i]) return true
}
return false
}
export const updateConnectionEntities = async (connection, entities) => {
// Check if the entities passed have changed and if so replace them
// and re-sync the typeorm connection.
if (!connection || !entitiesChanged(connection.options.entities, entities)) return
connection.options.entities = entities
connection.buildMetadatas()
if (connection.options.synchronize) {
await connection.synchronize()
}
}

View File

@@ -0,0 +1,94 @@
import { createHash } from 'crypto'
export class Account {
constructor (
userId,
providerId,
providerType,
providerAccountId,
refreshToken,
accessToken,
accessTokenExpires
) {
// The compound ID ensures there is only one entry for a given provider and account
this.compoundId = createHash('sha256').update(`${providerId}:${providerAccountId}`).digest('hex')
this.userId = userId
this.providerType = providerType
this.providerId = providerId
this.providerAccountId = providerAccountId
this.refreshToken = refreshToken
this.accessToken = accessToken
this.accessTokenExpires = accessTokenExpires
}
}
export const AccountSchema = {
name: 'Account',
target: Account,
columns: {
id: {
// This property has `objectId: true` instead of `type: int` in MongoDB
primary: true,
type: 'int',
generated: true
},
compoundId: {
// The compound ID ensures that there there is only one instance of an
// OAuth account in a way that works across different databases.
// It is not used for anything else.
type: 'varchar',
unique: true
},
userId: {
// This property is set to `type: objectId` on MongoDB databases
type: 'int'
},
providerType: {
type: 'varchar'
},
providerId: {
type: 'varchar'
},
providerAccountId: {
type: 'varchar'
},
refreshToken: {
type: 'text',
nullable: true
},
accessToken: {
// AccessTokens are not (yet) automatically rotated by NextAuth.js
// You can update it using the refreshToken and the accessTokenUrl endpoint for the provider
type: 'text',
nullable: true
},
accessTokenExpires: {
// AccessTokens expiry times are not (yet) updated by NextAuth.js
// You can update it using the refreshToken and the accessTokenUrl endpoint for the provider
type: 'timestamp',
nullable: true
},
createdAt: {
type: 'timestamp',
createDate: true
},
updatedAt: {
type: 'timestamp',
updateDate: true
}
},
indices: [
{
name: 'userId',
columns: ['userId']
},
{
name: 'providerId',
columns: ['providerId']
},
{
name: 'providerAccountId',
columns: ['providerAccountId']
}
]
}

View File

@@ -0,0 +1,23 @@
import { Account, AccountSchema } from './account'
import { User, UserSchema } from './user'
import { Session, SessionSchema } from './session'
import { VerificationRequest, VerificationRequestSchema } from './verification-request'
export default {
Account: {
model: Account,
schema: AccountSchema
},
User: {
model: User,
schema: UserSchema
},
Session: {
model: Session,
schema: SessionSchema
},
VerificationRequest: {
model: VerificationRequest,
schema: VerificationRequestSchema
}
}

View File

@@ -0,0 +1,50 @@
import { randomBytes } from 'crypto'
export class Session {
constructor (userId, expires, sessionToken, accessToken) {
this.userId = userId
this.expires = expires
this.sessionToken = sessionToken || randomBytes(32).toString('hex')
this.accessToken = accessToken || randomBytes(32).toString('hex')
}
}
export const SessionSchema = {
name: 'Session',
target: Session,
columns: {
id: {
// This property has `objectId: true` instead of `type: int` in MongoDB
primary: true,
type: 'int',
generated: true
},
userId: {
// This property is set to `type: objectId` on MongoDB databases
type: 'int'
},
expires: {
// The date the session expires (is updated when a session is active)
type: 'timestamp'
},
sessionToken: {
// The sessionToken should never be exposed to client side JavaScript
type: 'varchar',
unique: true
},
accessToken: {
// The accessToken can be safely exposed to client side JavaScript to
// to identify the owner of a session without exposing the sessionToken
type: 'varchar',
unique: true
},
createdAt: {
type: 'timestamp',
createDate: true
},
updatedAt: {
type: 'timestamp',
updateDate: true
}
}
}

View File

@@ -0,0 +1,58 @@
export class User {
constructor (name, email, image, emailVerified) {
if (name) { this.name = name }
if (email) { this.email = email }
if (image) { this.image = image }
if (emailVerified) {
const currentDate = new Date()
this.emailVerified = currentDate
}
}
}
export const UserSchema = {
name: 'User',
target: User,
columns: {
id: {
// This property has `objectId: true` instead of `type: int` in MongoDB
primary: true,
type: 'int',
generated: true
},
name: {
type: 'varchar',
nullable: true
},
email: {
// This is inherited from the one in the OAuth provider profile on
// initial sign in, if one is specified in that profile.
type: 'varchar',
unique: true,
nullable: true
},
emailVerified: {
// Contains a timestamp of the last time an action was performed that
// confirmed this email address was active and used by the user (e.g.
// when an email sign in link is clicked on and verified). Is null
// if the email address specified has never been verified.
type: 'timestamp',
nullable: true
},
image: {
// A URL that points to an avatar to use for the user.
// This is inherited from the one in the OAuth provider profile on
// initial sign in, if one is specified in that profile.
type: 'varchar',
nullable: true
},
createdAt: {
type: 'timestamp',
createDate: true
},
updatedAt: {
type: 'timestamp',
updateDate: true
}
}
}

View File

@@ -0,0 +1,44 @@
// This model is used for sign in emails, but is designed to support other
// mechanisms in future (e.g. 2FA via text message or short codes)
export class VerificationRequest {
constructor (identifier, token, expires) {
if (identifier) { this.identifier = identifier }
if (token) { this.token = token }
if (expires) { this.expires = expires }
}
}
export const VerificationRequestSchema = {
name: 'VerificationRequest',
target: VerificationRequest,
columns: {
id: {
// This property has `objectId: true` instead of `type: int` in MongoDB
primary: true,
type: 'int',
generated: true
},
identifier: {
// An email address, phone number, username or other unique identifier
// associated with the request (used to track who it was on behalf of)
type: 'varchar'
},
token: {
// The token used verify the request (maybe hashed or encrypted)
type: 'varchar',
unique: true
},
expires: {
// After this time, the request will no longer ve valid
type: 'timestamp'
},
createdAt: {
type: 'timestamp',
createDate: true
},
updatedAt: {
type: 'timestamp',
updateDate: true
}
}
}

View File

@@ -1,64 +0,0 @@
import { useState } from "react"
import { rest } from "msw"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockSession } from "./helpers/mocks"
import { Provider, useSession } from ".."
import userEvent from "@testing-library/user-event"
beforeAll(() => {
server.listen()
})
afterEach(() => {
jest.clearAllMocks()
server.resetHandlers()
})
afterAll(() => {
server.close()
})
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 />)
await waitFor(() => {
expect(sessionRouteCall).toHaveBeenCalledTimes(1)
const session1 = screen.getByTestId("session-consumer-1").textContent
const session2 = screen.getByTestId("session-consumer-2").textContent
expect(session1).toEqual(session2)
})
})
function ProviderFlow({ options = {} }) {
return (
<>
<Provider options={options}>
<SessionConsumer />
<SessionConsumer testId="2" />
</Provider>
</>
)
}
function SessionConsumer({ testId = 1 }) {
const [session, loading] = useSession()
if (loading) return <span>loading</span>
return (
<div data-testid={`session-consumer-${testId}`}>
{JSON.stringify(session)}
</div>
)
}

View File

@@ -1,105 +0,0 @@
import { useState } from "react"
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 ".."
import { rest } from "msw"
jest.mock("../../lib/logger", () => ({
__esModule: true,
default: {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
proxyLogger(logger) {
return logger
},
}))
beforeAll(() => {
server.listen()
})
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})
afterAll(() => {
server.close()
})
test("returns the Cross Site Request Forgery Token (CSRF Token) required to make POST requests", async () => {
render(<CSRFFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(screen.getByTestId("csrf-result").textContent).toEqual(
mockCSRFToken.csrfToken
)
})
})
test("when there's no CSRF token returned, it'll reflect that", async () => {
server.use(
rest.get("/api/auth/csrf", (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
...mockCSRFToken,
csrfToken: null,
})
)
)
)
render(<CSRFFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(screen.getByTestId("csrf-result").textContent).toBe("null-response")
})
})
test("when the fetch fails it'll throw a client fetch error", async () => {
server.use(
rest.get("/api/auth/csrf", (req, res, ctx) =>
res(ctx.status(500), ctx.text("some error happened"))
)
)
render(<CSRFFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"csrf",
new SyntaxError("Unexpected token s in JSON at position 0")
)
})
})
function CSRFFlow() {
const [response, setResponse] = useState()
async function handleCSRF() {
const result = await getCsrfToken()
setResponse(result)
}
return (
<>
<p data-testid="csrf-result">
{response === null ? "null-response" : response || "no response"}
</p>
<button onClick={handleCSRF}>Get CSRF</button>
</>
)
}

View File

@@ -1,90 +0,0 @@
import { setupServer } from "msw/node"
import { rest } from "msw"
import { randomBytes } from "crypto"
export const mockSession = {
ok: true,
user: {
image: null,
name: "John",
email: "john@email.com",
},
expires: 123213139,
}
export const mockProviders = {
ok: true,
github: {
id: "github",
name: "Github",
type: "oauth",
signinUrl: "path/to/signin",
callbackUrl: "path/to/callback",
},
credentials: {
id: "credentials",
name: "Credentials",
type: "credentials",
authorize: null,
credentials: null,
},
email: {
id: "email",
type: "email",
name: "Email",
},
}
export const mockCSRFToken = {
ok: true,
csrfToken: randomBytes(32).toString("hex"),
}
export const mockGithubResponse = {
ok: true,
status: 200,
url: "https://path/to/github/url",
}
export const mockCredentialsResponse = {
ok: true,
status: 200,
url: "https://path/to/credentials/url",
}
export const mockEmailResponse = {
ok: true,
status: 200,
url: "https://path/to/email/url",
}
export const mockSignOutResponse = {
ok: true,
status: 200,
url: "https://path/to/signout/url",
}
export const server = setupServer(
rest.post("/api/auth/signout", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSignOutResponse))
),
rest.get("/api/auth/session", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSession))
),
rest.get("/api/auth/csrf", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockCSRFToken))
),
rest.get("/api/auth/providers", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockProviders))
),
rest.post("/api/auth/signin/github", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockGithubResponse))
),
rest.post("/api/auth/callback/credentials", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockCredentialsResponse))
),
rest.post("/api/auth/signin/email", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmailResponse))
),
rest.post("/api/auth/_log", (req, res, ctx) => res(ctx.status(200)))
)

View File

@@ -1,8 +0,0 @@
export function getBroadcastEvents() {
return window.localStorage.setItem.mock.calls
.filter((call) => call[0] === "nextauth.message")
.map(([eventName, value]) => {
const { timestamp, ...rest } = JSON.parse(value)
return { eventName, value: rest }
})
}

View File

@@ -1,85 +0,0 @@
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 ".."
import logger from "../../lib/logger"
import { rest } from "msw"
jest.mock("../../lib/logger", () => ({
__esModule: true,
default: {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
proxyLogger(logger) {
return logger
},
}))
beforeAll(() => {
server.listen()
})
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})
afterAll(() => {
server.close()
})
test("when called it'll return the currently configured providers for sign in", async () => {
render(<ProvidersFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(screen.getByTestId("providers-result").textContent).toEqual(
JSON.stringify(mockProviders)
)
})
})
test("when failing to fetch the providers, it'll log the error", async () => {
server.use(
rest.get("/api/auth/providers", (req, res, ctx) =>
res(ctx.status(500), ctx.text("some error happened"))
)
)
render(<ProvidersFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"providers",
new SyntaxError("Unexpected token s in JSON at position 0")
)
})
})
function ProvidersFlow() {
const [response, setResponse] = useState()
async function handleGerProviders() {
const result = await getProviders()
setResponse(result)
}
return (
<>
<p data-testid="providers-result">
{response === null
? "null-response"
: JSON.stringify(response) || "no response"}
</p>
<button onClick={handleGerProviders}>Get Providers</button>
</>
)
}

View File

@@ -1,98 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react"
import { rest } from "msw"
import { server, mockSession } from "./helpers/mocks"
import logger from "../../lib/logger"
import { useState, useEffect } from "react"
import { getSession } from ".."
import { getBroadcastEvents } from "./helpers/utils"
jest.mock("../../lib/logger", () => ({
__esModule: true,
default: {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
proxyLogger(logger) {
return logger
},
}))
beforeAll(() => server.listen())
beforeEach(() => {
// eslint-disable-next-line no-proto
jest.spyOn(window.localStorage.__proto__, "setItem")
})
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})
afterAll(() => {
server.close()
})
test("if it can fetch the session, it should store it in `localStorage`", async () => {
render(<SessionFlow />)
// In the start, there is no session
const noSession = await screen.findByText("No session")
expect(noSession).toBeInTheDocument()
// After we fetched the session, it should have been rendered by `<SessionFlow />`
const session = await screen.findByText(new RegExp(mockSession.user.name))
expect(session).toBeInTheDocument()
const broadcastCalls = getBroadcastEvents()
const [broadcastedEvent] = broadcastCalls
expect(broadcastCalls).toHaveLength(1)
expect(broadcastCalls).toHaveLength(1)
expect(broadcastedEvent.eventName).toBe("nextauth.message")
expect(broadcastedEvent.value).toStrictEqual({
data: {
trigger: "getSession",
},
event: "session",
})
})
test("if there's an error fetching the session, it should log it", async () => {
server.use(
rest.get("/api/auth/session", (req, res, ctx) => {
return res(ctx.status(500), ctx.body("Server error"))
})
)
render(<SessionFlow />)
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"session",
new SyntaxError("Unexpected token S in JSON at position 0")
)
})
})
function SessionFlow() {
const [session, setSession] = useState(null)
useEffect(() => {
async function fetchUserSession() {
try {
const result = await getSession()
setSession(result)
} catch (e) {
console.error(e)
}
}
fetchUserSession()
}, [])
if (session) return <pre>{JSON.stringify(session, null, 2)}</pre>
return <p>No session</p>
}

View File

@@ -1,290 +0,0 @@
import { useState } from "react"
import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import logger from "../../lib/logger"
import {
server,
mockCredentialsResponse,
mockEmailResponse,
mockGithubResponse,
} from "./helpers/mocks"
import { signIn } from ".."
import { rest } from "msw"
const { location } = window
jest.mock("../../lib/logger", () => ({
__esModule: true,
default: {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
},
proxyLogger(logger) {
return logger
},
}))
beforeAll(() => {
server.listen()
delete window.location
window.location = {
...location,
replace: jest.fn(),
reload: jest.fn(),
}
})
beforeEach(() => {
jest.clearAllMocks()
server.resetHandlers()
})
afterAll(() => {
window.location = location
server.close()
})
const callbackUrl = "https://redirects/to"
test.each`
provider | type
${""} | ${"no"}
${"foo"} | ${"unknown"}
`(
"if $type provider, it redirects to the default sign-in page",
async ({ provider }) => {
render(<SignInFlow providerId={provider} callbackUrl={callbackUrl} />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
`/api/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
)
})
}
)
test.each`
provider | type
${""} | ${"no"}
${"foo"} | ${"unknown"}
`(
"if $type provider supplied and no callback URL, redirects using the current location",
async ({ provider }) => {
render(<SignInFlow providerId={provider} />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
`/api/auth/signin?callbackUrl=${encodeURIComponent(
window.location.href
)}`
)
})
}
)
test.each`
provider | mockUrl
${`email`} | ${mockEmailResponse.url}
${`credentials`} | ${mockCredentialsResponse.url}
`(
"$provider provider redirects if `redirect` is `true`",
async ({ provider, mockUrl }) => {
render(<SignInFlow providerId={provider} redirect={true} />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrl)
})
}
)
test("redirection can't be stopped using an oauth provider", async () => {
render(
<SignInFlow
providerId="github"
callbackUrl={callbackUrl}
redirect={false}
/>
)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockGithubResponse.url)
})
})
test("redirection can be stopped using the 'credentials' provider", async () => {
render(
<SignInFlow
providerId="credentials"
callbackUrl={callbackUrl}
redirect={false}
/>
)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).not.toHaveBeenCalledWith(
mockCredentialsResponse.url
)
expect(screen.getByTestId("signin-result").textContent).not.toBe(
"no response"
)
})
// snapshot the expected return shape from `signIn`
expect(JSON.parse(screen.getByTestId("signin-result").textContent))
.toMatchInlineSnapshot(`
Object {
"error": null,
"ok": true,
"status": 200,
"url": "https://path/to/credentials/url",
}
`)
})
test("redirection can be stopped using the 'email' provider", async () => {
render(
<SignInFlow providerId="email" callbackUrl={callbackUrl} redirect={false} />
)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).not.toHaveBeenCalledWith(
mockEmailResponse.url
)
expect(screen.getByTestId("signin-result").textContent).not.toBe(
"no response"
)
})
// snapshot the expected return shape from `signIn` oauth
expect(JSON.parse(screen.getByTestId("signin-result").textContent))
.toMatchInlineSnapshot(`
Object {
"error": null,
"ok": true,
"status": 200,
"url": "https://path/to/email/url",
}
`)
})
test("if callback URL contains a hash we force a window reload when re-directing", async () => {
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
server.use(
rest.post("/api/auth/signin/email", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...mockEmailResponse,
url: mockUrlWithHash,
})
)
})
)
render(<SignInFlow providerId="email" callbackUrl={mockUrlWithHash} />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
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)
})
})
test("params are propagated to the signin URL when supplied", async () => {
let matchedParams = ""
const authParams = "foo=bar&bar=foo"
server.use(
rest.post("/api/auth/signin/github", (req, res, ctx) => {
matchedParams = req.url.search
return res(ctx.status(200), ctx.json(mockGithubResponse))
})
)
render(<SignInFlow providerId="github" authorizationParams={authParams} />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(matchedParams).toEqual(`?${authParams}`)
})
})
test("when it fails to fetch the providers, it redirected back to signin page", async () => {
const errorMsg = "Error when retrieving providers"
server.use(
rest.get("/api/auth/providers", (req, res, ctx) =>
res(ctx.status(500), ctx.json(errorMsg))
)
)
render(<SignInFlow providerId="github" />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(`/api/auth/error`)
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"providers",
errorMsg
)
})
})
function SignInFlow({
providerId,
callbackUrl,
redirect = true,
authorizationParams = {},
}) {
const [response, setResponse] = useState(null)
async function handleSignIn() {
const result = await signIn(
providerId,
{
callbackUrl,
redirect,
},
authorizationParams
)
setResponse(result)
}
return (
<>
<p data-testid="signin-result">
{response ? JSON.stringify(response) : "no response"}
</p>
<button onClick={handleSignIn}>Sign in</button>
</>
)
}

View File

@@ -1,129 +0,0 @@
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 ".."
import { rest } from "msw"
import { getBroadcastEvents } from "./helpers/utils"
const { location } = window
beforeAll(() => {
server.listen()
delete window.location
window.location = {
...location,
replace: jest.fn(),
reload: jest.fn(),
}
})
beforeEach(() => {
// eslint-disable-next-line no-proto
jest.spyOn(window.localStorage.__proto__, "setItem")
})
afterEach(() => {
jest.clearAllMocks()
server.resetHandlers()
})
afterAll(() => {
window.location = location
server.close()
})
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("/api/auth/signout", (req, res, ctx) =>
res(ctx.status(200), ctx.json({ ...mockSignOutResponse, url: undefined }))
)
)
render(<SignOutFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(window.location.href)
})
})
test("it redirects to the URL allowed by the server", async () => {
render(<SignOutFlow callbackUrl={callbackUrl} />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
mockSignOutResponse.url
)
})
})
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("/api/auth/signout", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...mockSignOutResponse,
url: mockUrlWithHash,
})
)
})
)
render(<SignOutFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.reload).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
})
})
test("will broadcast the signout event to other tabs", async () => {
render(<SignOutFlow />)
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
const broadcastCalls = getBroadcastEvents()
const [broadcastedEvent] = broadcastCalls
expect(broadcastCalls).toHaveLength(1)
expect(broadcastedEvent.eventName).toBe("nextauth.message")
expect(broadcastedEvent.value).toStrictEqual({
data: {
trigger: "signout",
},
event: "session",
})
})
})
function SignOutFlow({ callbackUrl, redirect = true }) {
const [response, setResponse] = useState(null)
async function handleSignOut() {
const result = await signOut({ callbackUrl, redirect })
setResponse(result)
}
return (
<>
<p data-testid="signout-result">
{response ? JSON.stringify(response) : "no response"}
</p>
<button onClick={handleSignOut}>Sign out</button>
</>
)
}

103
src/client/index.d.ts vendored Normal file
View File

@@ -0,0 +1,103 @@
import * as React from 'react'
import { GetServerSidePropsContext } from 'next'
interface DefaultSession {
user: {
name: string | null
email: string | null
image: string | null
}
expires: Date | string
}
interface BroadcastMessage {
event?: 'session'
data?: {
trigger?: 'signout' | 'getSession'
}
clientId: string
timestamp: number
}
type GetSession<S extends Record<string, unknown> = DefaultSession> = (options: {
ctx?: GetServerSidePropsContext
req?: GetServerSidePropsContext['req']
event?: 'storage' | 'timer' | 'hidden' | string
triggerEvent?: boolean
}) => Promise<S>
export interface NextAuthConfig {
baseUrl: string
basePath: string
baseUrlServer: string
basePathServer: string
/** 0 means disabled (don't send); 60 means send every 60 seconds */
keepAlive: number
/** 0 means disabled (only use cache); 60 means sync if last checked > 60 seconds ago */
clientMaxAge: number
/** Used for timestamp since last sycned (in seconds) */
_clientLastSync: number
/** Stores timer for poll interval */
_clientSyncTimer: ReturnType<typeof setTimeout>
/** Tracks if event listeners have been added */
_eventListenersAdded: boolean
/** Stores last session response from hook */
_clientSession: DefaultSession | null | undefined
/** Used to store to function export by getSession() hook */
_getSession: any
}
export type GetCsrfToken = (
ctxOrReq: GetServerSidePropsContext & GetServerSidePropsContext['req']
) => Promise<string | null>
export interface SessionOptions {
baseUrl?: string
basePath?: string
clientMaxAge?: number
keepAlive?: number
}
export type Provider<S extends Record<string, unknown> = DefaultSession > = (options: {
children: React.ReactNode
session: S
options: SessionOptions
}) => React.ReactNode
export type SetOptions = (options: SessionOptions) => void
export type SessionContext = React.createContext<[DefaultSession | null, boolean]>
export type UseSession = () => [any, boolean]
export type GetProviders = () => Promise<any[]>
// Sign in types
export interface SignInOptions {
/** Defaults to the current URL. */
callbackUrl?: string
redirect?: boolean
}
export interface SignInResponse {
error: string | null
status: number
ok: boolean
url: string | null
}
export type SignIn<AuthorizationParams = Record<string, string>> = (
provider?: string,
options?: SignInOptions,
authorizationParams?: AuthorizationParams
) => SignInResponse
// Sign out types
interface SignOutResponse<RedirectType extends boolean=true> {
/** Defaults to the current URL. */
callbackUrl?: string
redirect?: RedirectType
}
export type SignOut<RedirectType extends boolean = true> = (params: SignOutResponse<RedirectType>) => RedirectType extends true ? Promise<{url?: string} | undefined> : undefined

View File

@@ -8,15 +8,9 @@
//
// 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"
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
@@ -28,14 +22,8 @@ import parseUrl from "../lib/parse-url"
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,
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
@@ -43,7 +31,7 @@ const __NEXTAUTH = {
_clientSyncTimer: null,
_eventListenersAdded: false,
_clientSession: undefined,
_getSession: () => {},
_getSession: () => {}
}
const logger = proxyLogger(_logger, __NEXTAUTH.basePath)
@@ -51,7 +39,7 @@ const logger = proxyLogger(_logger, __NEXTAUTH.basePath)
const broadcast = BroadcastChannel()
// Add event listners on load
if (typeof window !== "undefined" && !__NEXTAUTH._eventListenersAdded) {
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)
@@ -62,30 +50,26 @@ if (typeof window !== "undefined" && !__NEXTAUTH._eventListenersAdded) {
// 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" }))
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
)
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) {
export function useSession (session) {
const context = useContext(SessionContext)
if (context) return context
return _useSessionHook(session)
}
function _useSessionHook(session) {
function _useSessionHook (session) {
const [data, setData] = useState(session)
const [loading, setLoading] = useState(!data)
@@ -93,7 +77,7 @@ function _useSessionHook(session) {
__NEXTAUTH._getSession = async ({ event = null } = {}) => {
try {
const triggredByEvent = event !== null
const triggeredByStorageEvent = event === "storage"
const triggeredByStorageEvent = event === 'storage'
const clientMaxAge = __NEXTAUTH.clientMaxAge
const clientLastSync = parseInt(__NEXTAUTH._clientLastSync)
@@ -114,19 +98,14 @@ function _useSessionHook(session) {
// tab or window that will come through as a triggeredByStorageEvent
// event and will skip this logic)
return
} else if (
clientMaxAge > 0 &&
currentTime < clientLastSync + clientMaxAge
) {
} 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
}
if (clientSession === undefined) { __NEXTAUTH._clientSession = null }
// Update clientLastSync before making response to avoid repeated
// invokations that would otherwise be triggered while we are still
@@ -137,7 +116,7 @@ function _useSessionHook(session) {
// tell getSession not to trigger an event when it calls to avoid an
// infinate loop.
const newClientSessionData = await getSession({
triggerEvent: !triggeredByStorageEvent,
triggerEvent: !triggeredByStorageEvent
})
// Save session state internally, just so we can track that we've checked
@@ -147,7 +126,7 @@ function _useSessionHook(session) {
setData(newClientSessionData)
setLoading(false)
} catch (error) {
logger.error("CLIENT_USE_SESSION_ERROR", error)
logger.error('CLIENT_USE_SESSION_ERROR', error)
setLoading(false)
}
}
@@ -158,112 +137,114 @@ function _useSessionHook(session) {
return [data, loading]
}
export async function getSession(ctx) {
const session = await _fetchData("session", ctx)
export async function getSession (ctx) {
const session = await _fetchData('session', ctx)
if (ctx?.triggerEvent ?? true) {
broadcast.post({ event: "session", data: { trigger: "getSession" } })
broadcast.post({ event: 'session', data: { trigger: 'getSession' } })
}
return session
}
export async function getCsrfToken(ctx) {
return (await _fetchData("csrf", ctx))?.csrfToken
export async function getCsrfToken (ctx) {
return (await _fetchData('csrf', ctx))?.csrfToken
}
export async function getProviders() {
return await _fetchData("providers")
export async function getProviders () {
return _fetchData('providers')
}
export async function signIn(provider, options = {}, authorizationParams = {}) {
const { callbackUrl = window.location.href, redirect = true } = options
export async function signIn (provider, options = {}, authorizationParams = {}) {
const {
callbackUrl = window.location,
redirect = true
} = options
const baseUrl = _apiBaseUrl()
const providers = await getProviders()
if (!providers) {
return window.location.replace(`${baseUrl}/error`)
}
// Redirect to sign in page if no valid provider specified
if (!(provider in providers)) {
return window.location.replace(
`${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
)
// If Provider not recognized, redirect to sign in page
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
return
}
const isCredentials = providers[provider].type === "credentials"
const isEmail = providers[provider].type === "email"
const isSupportingReturn = isCredentials || isEmail
const isCredentials = providers[provider].type === 'credentials'
const isEmail = providers[provider].type === 'email'
const canRedirectBeDisabled = isCredentials || isEmail
const signInUrl = isCredentials
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
const res = await fetch(_signInUrl, {
method: "post",
// If is any other provider type, POST to provider URL with CSRF Token,
// callback URL and any other parameters supplied.
const fetchOptions = {
method: 'post',
headers: {
"Content-Type": "application/x-www-form-urlencoded",
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
})
json: true
})
}
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
const res = await fetch(_signInUrl, fetchOptions)
const data = await res.json()
if (redirect || !isSupportingReturn) {
if (redirect || !canRedirectBeDisabled) {
const url = data.url ?? callbackUrl
window.location.replace(url)
window.location = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes("#")) window.location.reload()
if (url.includes('#')) window.location.reload()
return
}
const error = new URL(data.url).searchParams.get("error")
const error = new URL(data.url).searchParams.get('error')
if (res.ok) {
await __NEXTAUTH._getSession({ event: "storage" })
await __NEXTAUTH._getSession({ event: 'storage' })
}
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : data.url,
url: error ? null : data.url
}
}
export async function signOut(options = {}) {
const { callbackUrl = window.location.href, redirect = true } = options
export async function signOut (options = {}) {
const {
callbackUrl = window.location,
redirect = true
} = options
const baseUrl = _apiBaseUrl()
const fetchOptions = {
method: "post",
method: 'post',
headers: {
"Content-Type": "application/x-www-form-urlencoded",
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
json: true
})
}
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
const data = await res.json()
broadcast.post({ event: "session", data: { trigger: "signout" } })
broadcast.post({ event: 'session', data: { trigger: 'signout' } })
if (redirect) {
const url = data.url ?? callbackUrl
window.location.replace(url)
window.location = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes("#")) window.location.reload()
if (url.includes('#')) window.location.reload()
return
}
await __NEXTAUTH._getSession({ event: "storage" })
await __NEXTAUTH._getSession({ event: 'storage' })
return data
}
@@ -271,18 +252,13 @@ export async function signOut(options = {}) {
// 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,
} = {}) {
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
if (typeof window === 'undefined') return
// Clear existing timer (if there is one)
if (__NEXTAUTH._clientSyncTimer !== null) {
@@ -293,12 +269,12 @@ export function setOptions({
__NEXTAUTH._clientSyncTimer = setTimeout(async () => {
// Only invoke keepalive when a session exists
if (!__NEXTAUTH._clientSession) return
await __NEXTAUTH._getSession({ event: "timer" })
await __NEXTAUTH._getSession({ event: 'timer' })
}, keepAlive * 1000)
}
}
export function Provider({ children, session, options }) {
export function Provider ({ children, session, options }) {
setOptions(options)
return createElement(
SessionContext.Provider,
@@ -314,25 +290,24 @@ export function Provider({ children, session, options }) {
* work seemlessly in getInitialProps() on server side
* pages *and* in _app.js.
*/
async function _fetchData(path, { ctx, req = ctx?.req } = {}) {
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)
logger.error('CLIENT_FETCH_ERROR', path, error)
return null
}
}
function _apiBaseUrl() {
if (typeof window === "undefined") {
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")
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
}
// Return absolute path when called server side
@@ -343,7 +318,7 @@ function _apiBaseUrl() {
}
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
function _now() {
function _now () {
return Math.floor(Date.now() / 1000)
}
@@ -353,48 +328,33 @@ function _now() {
*
* https://caniuse.com/?search=broadcastchannel
*/
function BroadcastChannel(name = "nextauth.message") {
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) => {
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
if (message?.event !== 'session' || !message?.data) return
onReceive(message)
})
},
/** Notify other tabs/windows. */
post(message) {
if (typeof localStorage === "undefined") return
localStorage.setItem(
name,
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,
@@ -414,5 +374,5 @@ export default {
providers: getProviders,
csrfToken: getCsrfToken,
signin: signIn,
signout: signOut,
signout: signOut
}

View File

@@ -1,20 +0,0 @@
export default function FortyTwo(options) {
return {
id: '42-school',
name: '42 School',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.intra.42.fr/oauth/token',
authorizationUrl:
'https://api.intra.42.fr/oauth/authorize?response_type=code',
profileUrl: 'https://api.intra.42.fr/v2/me',
profile: (profile) => ({
id: profile.id,
email: profile.email,
image: profile.image_url,
name: profile.usual_full_name,
}),
...options,
}
}

View File

@@ -1,24 +0,0 @@
export default function Coinbase(options) {
return {
id: "coinbase",
name: "Coinbase",
type: "oauth",
version: "2.0",
scope: "wallet:user:email wallet:user:read",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://api.coinbase.com/oauth/token",
requestTokenUrl: "https://api.coinbase.com/oauth/token",
authorizationUrl:
"https://www.coinbase.com/oauth/authorize?response_type=code",
profileUrl: "https://api.coinbase.com/v2/user",
profile(profile) {
return {
id: profile.data.id,
name: profile.data.name,
email: profile.data.email,
image: profile.data.avatar_url,
}
},
...options,
}
}

View File

@@ -1,54 +0,0 @@
/**
* @param {import("../server").Provider} options
* @example
*
* ```js
* // pages/api/auth/[...nextauth].js
* import Providers from `next-auth/providers`
* ...
* providers: [
* Providers.Dropbox({
* clientId: process.env.DROPBOX_CLIENT_ID,
* clientSecret: process.env.DROPBOX_CLIENT_SECRET
* })
* ]
* ...
*
* // pages/index
* import { signIn } from "next-auth/client"
* ...
* <button onClick={() => signIn("dropbox")}>
* Sign in
* </button>
* ...
* ```
* *Resources:*
* - [NextAuth.js Documentation](https://next-auth.js.org/providers/dropbox)
* - [Dropbox Documentation](https://developers.dropbox.com/oauth-guide)
* - [Configuration](https://www.dropbox.com/developers/apps)
*/
export default function Dropbox(options) {
return {
id: 'dropbox',
name: 'Dropbox',
type: 'oauth',
version: '2.0',
scope: 'account_info.read',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.dropboxapi.com/oauth2/token',
authorizationUrl:
'https://www.dropbox.com/oauth2/authorize?token_access_type=offline&response_type=code',
profileUrl: 'https://api.dropboxapi.com/2/users/get_current_account',
profile: (profile) => {
return {
id: profile.account_id,
name: profile.name.display_name,
email: profile.email,
image: profile.profile_photo_url,
email_verified: profile.email_verified
}
},
protection: ["state", "pkce"],
...options
}
}

View File

@@ -1,5 +1,5 @@
import nodemailer from "nodemailer"
import logger from "../lib/logger"
import logger from '../lib/logger'
import requireOptional from '@balazsorban/require-optional'
export default function Email(options) {
return {
@@ -22,42 +22,34 @@ export default function Email(options) {
}
}
const sendVerificationRequest = ({
identifier: email,
url,
baseUrl,
provider,
}) => {
return new Promise((resolve, reject) => {
const { server, from } = provider
// Strip protocol from URL and use domain as site name
const site = baseUrl.replace(/^https?:\/\//, "")
nodemailer.createTransport(server).sendMail(
{
async function sendVerificationRequest ({ identifier: email, url, baseUrl, provider }) {
const { server, from } = provider
// Strip protocol from URL and use domain as site name
const site = baseUrl.replace(/^https?:\/\//, '')
try {
const nodemailer = requireOptional('nodemailer')
await nodemailer
.createTransport(server)
.sendMail({
to: email,
from,
subject: `Sign in to ${site}`,
text: text({ url, site, email }),
html: html({ url, site, email }),
},
(error) => {
if (error) {
logger.error("SEND_VERIFICATION_EMAIL_ERROR", error)
return reject(new Error("SEND_VERIFICATION_EMAIL_ERROR", error))
}
return resolve()
}
)
})
html: html({ url, site, email })
})
} catch (error) {
logger.error('SEND_VERIFICATION_EMAIL_ERROR', email, error)
throw new Error('SEND_VERIFICATION_EMAIL_ERROR')
}
}
// Email HTML body
const html = ({ url, site }) => {
// Insert invisible space into domains to prevent the
// the domain from being turned into a hyperlink by email
const html = ({ url, site, email }) => {
// Insert invisible space into domains and email address to prevent both the
// email address and the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
// like they are supposed to click it to sign in.
// like they are supposed to click on their email address to sign in.
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
const escapedSite = `${site.replace(/\./g, "&#8203;.")}`
// Some simple styling options
@@ -72,12 +64,17 @@ const html = ({ url, site }) => {
<body style="background: ${backgroundColor};">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in to <strong>${escapedSite}</strong>
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<strong>${escapedSite}</strong>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in as <strong>${escapedEmail}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">

View File

@@ -1,20 +0,0 @@
export default function Freshbooks(options) {
return {
id: 'freshbooks',
name: 'Freshbooks',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.freshbooks.com/auth/oauth/token',
authorizationUrl: 'https://auth.freshbooks.com/service/auth/oauth/authorize?response_type=code',
profileUrl: 'https://api.freshbooks.com/auth/api/v1/users/me',
async profile(profile) {
return {
id: profile.response.id,
name: `${profile.response.first_name} ${profile.response.last_name}`,
email: profile.response.email,
};
},
...options
};
}

View File

@@ -1,18 +0,0 @@
export default function Naver(options) {
return {
id: "naver",
name: "Naver",
type: "oauth",
version: "2.0",
params: { grant_type: "authorization_code" },
protection: ["state"],
accessTokenUrl: "https://nid.naver.com/oauth2.0/token",
authorizationUrl:
"https://nid.naver.com/oauth2.0/authorize?response_type=code",
profileUrl: "https://openapi.naver.com/v1/nid/me",
profile(profile) {
return profile.response
},
...options,
}
}

View File

@@ -1,19 +0,0 @@
export default function OneLogin(options) {
return {
id: "onelogin",
name: "OneLogin",
type: "oauth",
version: "2.0",
scope: "openid profile name email",
params: { grant_type: "authorization_code" },
// These will be different depending on the Org.
accessTokenUrl: `https://${options.domain}/oidc/2/token`,
requestTokenUrl: `https://${options.domain}/oidc/2/auth`,
authorizationUrl: `https://${options.domain}/oidc/2/auth?response_type=code`,
profileUrl: `https://${options.domain}/oidc/2/me`,
profile(profile) {
return { ...profile, id: profile.sub }
},
...options,
}
}

View File

@@ -15,7 +15,7 @@ export default function Twitter(options) {
id: profile.id_str,
name: profile.name,
email: profile.email,
image: profile.profile_image_url_https.replace(/_normal\.(jpg|png|gif)$/, ".$1"),
image: profile.profile_image_url_https.replace(/_normal\.jpg$/, ".jpg"),
}
},
...options,

View File

@@ -1,26 +0,0 @@
export default function WorkOS(options) {
const domain = options.domain || 'api.workos.com';
return {
id: 'workos',
name: 'WorkOS',
type: 'oauth',
version: '2.0',
scope: '',
params: {
grant_type: 'authorization_code',
client_id: options.clientId,
client_secret: options.clientSecret
},
accessTokenUrl: `https://${domain}/sso/token`,
authorizationUrl: `https://${domain}/sso/authorize?response_type=code`,
profileUrl: `https://${domain}/sso/profile`,
profile: (profile) => {
return {
...profile,
name: `${profile.first_name} ${profile.last_name}`
}
},
...options
}
}

View File

@@ -4,7 +4,7 @@ export default function Yandex(options) {
name: "Yandex",
type: "oauth",
version: "2.0",
scope: "login:email login:info login:avatar",
scope: "login:email login:info",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://oauth.yandex.ru/token",
requestTokenUrl: "https://oauth.yandex.ru/token",
@@ -15,7 +15,7 @@ export default function Yandex(options) {
id: profile.id,
name: profile.real_name,
email: profile.default_email,
image: profile.is_avatar_empty ? null : `https://avatars.yandex.net/get-yapic/${profile.default_avatar_id}/islands-200`,
image: null,
}
},
...options,

View File

@@ -1,20 +0,0 @@
export default function Zoom(options) {
return {
id: "zoom",
name: "Zoom",
type: "oauth",
version: "2.0",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://zoom.us/oauth/token",
authorizationUrl: "https://zoom.us/oauth/authorize?response_type=code",
profileUrl: "https://api.zoom.us/v2/users/me",
profile(profile) {
return {
id: profile.id,
name: `${profile.first_name} ${profile.last_name}`,
email: profile.email,
}
},
...options,
}
}

View File

@@ -1,34 +1,25 @@
import adapters from "../adapters"
import jwt from "../lib/jwt"
import parseUrl from "../lib/parse-url"
import logger, { setLogger } from "../lib/logger"
import * as cookie from "./lib/cookie"
import * as defaultEvents from "./lib/default-events"
import * as defaultCallbacks from "./lib/default-callbacks"
import parseProviders from "./lib/providers"
import * as routes from "./routes"
import renderPage from "./pages"
import createSecret from "./lib/create-secret"
import callbackUrlHandler from "./lib/callback-url-handler"
import extendRes from "./lib/extend-res"
import csrfTokenHandler from "./lib/csrf-token-handler"
import * as pkce from "./lib/oauth/pkce-handler"
import * as state from "./lib/oauth/state-handler"
import jwt from '../lib/jwt'
import parseUrl from '../lib/parse-url'
import logger, { setLogger } from '../lib/logger'
import * as cookie from './lib/cookie'
import * as defaultEvents from './lib/default-events'
import * as defaultCallbacks from './lib/default-callbacks'
import parseProviders from './lib/providers'
import * as routes from './routes'
import renderPage from './pages'
import createSecret from './lib/create-secret'
import callbackUrlHandler from './lib/callback-url-handler'
import extendRes from './lib/extend-res'
import csrfTokenHandler from './lib/csrf-token-handler'
import * as pkce from './lib/oauth/pkce-handler'
import * as state from './lib/oauth/state-handler'
import optionalRequire from '@balazsorban/require-optional'
// To work properly in production with OAuth providers the NEXTAUTH_URL
// environment variable must be set.
if (!process.env.NEXTAUTH_URL) {
logger.warn("NEXTAUTH_URL", "NEXTAUTH_URL environment variable not set")
}
function isValidHttpUrl(url, baseUrl) {
try {
return /^https?:/.test(
new URL(url, url.startsWith("/") ? baseUrl : undefined).protocol
)
} catch {
return false
}
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
}
/**
@@ -36,7 +27,7 @@ function isValidHttpUrl(url, baseUrl) {
* @param {import("next").NextApiResponse} res
* @param {import("types").NextAuthOptions} userOptions
*/
async function NextAuthHandler(req, res, userOptions) {
async function NextAuthHandler (req, res, userOptions) {
if (userOptions.logger) {
setLogger(userOptions.logger)
}
@@ -49,15 +40,13 @@ async function NextAuthHandler(req, res, userOptions) {
// to avoid early termination of calls to the serverless function
// (and then return that promise when we are done) - eslint
// complains but I'm not sure there is another way to do this.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
extendRes(req, res, resolve)
if (!req.query.nextauth) {
const error =
"Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly."
const error = 'Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly.'
logger.error("MISSING_NEXTAUTH_API_ROUTE_ERROR", error)
logger.error('MISSING_NEXTAUTH_API_ROUTE_ERROR', error)
return res.status(500).end(`Error: ${error}`)
}
@@ -65,65 +54,31 @@ async function NextAuthHandler(req, res, userOptions) {
nextauth,
action = nextauth[0],
providerId = nextauth[1],
error = nextauth[1],
error = nextauth[1]
} = req.query
// @todo refactor all existing references to baseUrl and basePath
const { basePath, baseUrl } = parseUrl(
process.env.NEXTAUTH_URL || process.env.VERCEL_URL
)
const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
const cookies = {
...cookie.defaultCookies(
userOptions.useSecureCookies || baseUrl.startsWith("https://")
),
...cookie.defaultCookies(userOptions.useSecureCookies || baseUrl.startsWith('https://')),
// Allow user cookie options to override any cookie settings above
...userOptions.cookies,
}
const errorPage = userOptions.pages?.error ?? `${baseUrl}${basePath}/error`
const callbackUrlParam = req.query?.callbackUrl
if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, baseUrl)) {
return res.redirect(`${errorPage}?error=Configuration`)
}
const { callbackUrl: defaultCallbackUrl } = cookie.defaultCookies(
userOptions.useSecureCookies ?? baseUrl.startsWith("https://")
)
const callbackUrlCookie =
req.cookies?.[cookies?.callbackUrl?.name ?? defaultCallbackUrl.name]
if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, baseUrl)) {
return res.redirect(`${errorPage}?error=Configuration`)
...userOptions.cookies
}
const secret = createSecret({ userOptions, basePath, baseUrl })
const providers = parseProviders({
providers: userOptions.providers,
baseUrl,
basePath,
})
const providers = parseProviders({ providers: userOptions.providers, baseUrl, basePath })
const provider = providers.find(({ id }) => id === providerId)
// Protection only works on OAuth 2.x providers
// TODO:
// - rename to `checks` in 4.x, so it is similar to `openid-client`
// - stop supporting `protection` as string
// - remove `state` property
if (provider?.type === "oauth" && provider.version?.startsWith("2")) {
// Priority: (protection array > protection string) > state > default
if (provider.protection) {
provider.protection = Array.isArray(provider.protection)
? provider.protection
: [provider.protection]
} else if (provider.state !== undefined) {
provider.protection = [provider.state ? "state" : "none"]
} else {
// Default to state, as we did in 3.1
// REVIEW: should we use "pkce" or "none" as default?
provider.protection = ["state"]
if (provider?.type === 'oauth' && provider.version?.startsWith('2')) {
// When provider.state is undefined, we still want this to pass
if (!provider.protection) {
// Default to state, as we did in 3.1 REVIEW: should we use "pkce" or "none" as default?
provider.protection = ['state']
} else if (typeof provider.protection === 'string') {
provider.protection = [provider.protection]
}
}
@@ -132,16 +87,18 @@ async function NextAuthHandler(req, res, userOptions) {
// Parse database / adapter
// If adapter is provided, use it (advanced usage, overrides database)
// If database URI or config object is provided, use it (simple usage)
const adapter =
userOptions.adapter ??
(userOptions.database && adapters.Default(userOptions.database))
let adapter = userOptions.adapter
if ((!adapter && !!userOptions.database)) {
const TypeOrm = optionalRequire('../adapters/typeorm')
adapter = TypeOrm.Adapter(userOptions.database)
}
// User provided options are overriden by other options,
// except for the options with special handling above
req.options = {
debug: false,
pages: {},
theme: "auto",
theme: 'auto',
// Custom options override defaults
...userOptions,
// These computed settings can have values in userOptions but we override them
@@ -159,7 +116,7 @@ async function NextAuthHandler(req, res, userOptions) {
jwt: !adapter, // If no adapter specified, force use of JSON Web Tokens (stateless)
maxAge,
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
...userOptions.session,
...userOptions.session
},
// JWT options
jwt: {
@@ -167,20 +124,20 @@ async function NextAuthHandler(req, res, userOptions) {
maxAge, // same as session maxAge,
encode: jwt.encode,
decode: jwt.decode,
...userOptions.jwt,
...userOptions.jwt
},
// Event messages
events: {
...defaultEvents,
...userOptions.events,
...userOptions.events
},
// Callback functions
callbacks: {
...defaultCallbacks,
...userOptions.callbacks,
...userOptions.callbacks
},
pkce: {},
logger,
logger
}
csrfTokenHandler(req, res)
@@ -189,74 +146,65 @@ async function NextAuthHandler(req, res, userOptions) {
const render = renderPage(req, res)
const { pages } = req.options
if (req.method === "GET") {
if (req.method === 'GET') {
switch (action) {
case "providers":
case 'providers':
return routes.providers(req, res)
case "session":
case 'session':
return routes.session(req, res)
case "csrf":
case 'csrf':
return res.json({ csrfToken: req.options.csrfToken })
case "signin":
case 'signin':
if (pages.signIn) {
let signinUrl = `${pages.signIn}${
pages.signIn.includes("?") ? "&" : "?"
}callbackUrl=${req.options.callbackUrl}`
if (error) {
signinUrl = `${signinUrl}&error=${error}`
}
let signinUrl = `${pages.signIn}${pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${req.options.callbackUrl}`
if (error) { signinUrl = `${signinUrl}&error=${error}` }
return res.redirect(signinUrl)
}
return render.signin()
case "signout":
if (pages.signOut) return res.redirect(pages.signOut)
case 'signout':
if (pages.signOut) {
return res.redirect(`${pages.signOut}${pages.signOut.includes('?') ? '&' : '?'}error=${error}`)
}
return render.signout()
case "callback":
case 'callback':
if (provider) {
if (await pkce.handleCallback(req, res)) return
if (await state.handleCallback(req, res)) return
return routes.callback(req, res)
}
break
case "verify-request":
case 'verify-request':
if (pages.verifyRequest) {
return res.redirect(pages.verifyRequest)
}
return render.verifyRequest()
case "error":
case 'error':
if (pages.error) {
return res.redirect(
`${pages.error}${
pages.error.includes("?") ? "&" : "?"
}error=${error}`
)
return res.redirect(`${pages.error}${pages.error.includes('?') ? '&' : '?'}error=${error}`)
}
// These error messages are displayed in line on the sign in page
if (
[
"Signin",
"OAuthSignin",
"OAuthCallback",
"OAuthCreateAccount",
"EmailCreateAccount",
"Callback",
"OAuthAccountNotLinked",
"EmailSignin",
"CredentialsSignin",
].includes(error)
) {
if ([
'Signin',
'OAuthSignin',
'OAuthCallback',
'OAuthCreateAccount',
'EmailCreateAccount',
'Callback',
'OAuthAccountNotLinked',
'EmailSignin',
'CredentialsSignin'
].includes(error)) {
return res.redirect(`${baseUrl}${basePath}/signin?error=${error}`)
}
return render.error({ error })
default:
}
} else if (req.method === "POST") {
} else if (req.method === 'POST') {
switch (action) {
case "signin":
case 'signin':
// Verified CSRF Token required for all sign in routes
if (req.options.csrfTokenVerified && provider) {
if (await pkce.handleSignin(req, res)) return
@@ -265,19 +213,16 @@ async function NextAuthHandler(req, res, userOptions) {
}
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
case "signout":
case 'signout':
// Verified CSRF Token required for signout
if (req.options.csrfTokenVerified) {
return routes.signout(req, res)
}
return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`)
case "callback":
case 'callback':
if (provider) {
// Verified CSRF Token required for credentials providers only
if (
provider.type === "credentials" &&
!req.options.csrfTokenVerified
) {
if (provider.type === 'credentials' && !req.options.csrfTokenVerified) {
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
@@ -286,35 +231,31 @@ async function NextAuthHandler(req, res, userOptions) {
return routes.callback(req, res)
}
break
case "_log":
case '_log':
if (userOptions.logger) {
try {
const {
code = "CLIENT_ERROR",
level = "error",
message = "[]",
code = 'CLIENT_ERROR',
level = 'error',
message = '[]'
} = req.body
logger[level](code, ...JSON.parse(message))
} catch (error) {
// If logging itself failed...
logger.error("LOGGER_ERROR", error)
logger.error('LOGGER_ERROR', error)
}
}
return res.end()
default:
}
}
return res
.status(400)
.end(
`Error: This action with HTTP ${req.method} is not supported by NextAuth.js`
)
return res.status(400).end(`Error: HTTP ${req.method} is not supported for ${req.url}`)
})
}
/** Tha main entry point to next-auth */
export default function NextAuth(...args) {
export default function NextAuth (...args) {
if (args.length === 1) {
return (req, res) => NextAuthHandler(req, res, args[0])
}

View File

@@ -1,6 +1,5 @@
import { AccountNotLinkedError } from "../../lib/errors"
import dispatchEvent from "../lib/dispatch-event"
import adapterErrorHandler from "../../adapters/error-handler"
import { AccountNotLinkedError } from '../../lib/errors'
import dispatchEvent from '../lib/dispatch-event'
/**
* This function handles the complex flow of signing users in, and either creating,
@@ -13,29 +12,20 @@ import adapterErrorHandler from "../../adapters/error-handler"
* All verification (e.g. OAuth flows or email address verificaiton flows) are
* done prior to this handler being called to avoid additonal complexity in this
* handler.
* @param {import("types").Session} sessionToken
* @param {import("types").Profile} profile
* @param {import("types").Account} account
* @param {import("types/internals").AppOptions} options
*/
export default async function callbackHandler(
sessionToken,
profile,
providerAccount,
options
) {
export default async function callbackHandler (sessionToken, profile, providerAccount, options) {
// Input validation
if (!profile) throw new Error("Missing profile")
if (!providerAccount?.id || !providerAccount.type)
throw new Error("Missing or invalid provider account")
if (!["email", "oauth"].includes(providerAccount.type))
throw new Error("Provider not supported")
if (!profile) throw new Error('Missing profile')
if (!providerAccount?.id || !providerAccount.type) throw new Error('Missing or invalid provider account')
if (!['email', 'oauth'].includes(providerAccount.type)) throw new Error('Provider not supported')
const {
adapter,
jwt,
events,
session: { jwt: useJwtSession },
session: {
jwt: useJwtSession
}
} = options
// If no adapter is configured then we don't have a database and cannot
@@ -44,7 +34,7 @@ export default async function callbackHandler(
return {
user: profile,
account: providerAccount,
session: {},
session: {}
}
}
@@ -57,8 +47,8 @@ export default async function callbackHandler(
linkAccount,
createSession,
getSession,
deleteSession,
} = adapterErrorHandler(await adapter.getAdapter(options), options.logger)
deleteSession
} = await adapter.getAdapter(options)
let session = null
let user = null
@@ -84,11 +74,9 @@ export default async function callbackHandler(
}
}
if (providerAccount.type === "email") {
if (providerAccount.type === 'email') {
// If signing in with an email, check if an account with the same email address exists already
const userByEmail = profile.email
? await getUserByEmail(profile.email)
: null
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// If they are not already signed in as the same user, this flow will
// sign them out of the current session and sign them in as the new user
@@ -119,14 +107,11 @@ export default async function callbackHandler(
return {
session,
user,
isNewUser,
isNewUser
}
} else if (providerAccount.type === "oauth") {
} else if (providerAccount.type === 'oauth') {
// If signing in with oauth account, check to see if the account exists already
const userByProviderAccountId = await getUserByProviderAccountId(
providerAccount.provider,
providerAccount.id
)
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
if (userByProviderAccountId) {
if (isSignedIn) {
// If the user is already signed in with this account, we don't need to do anything
@@ -137,7 +122,7 @@ export default async function callbackHandler(
return {
session,
user,
isNewUser,
isNewUser
}
}
// If the user is currently signed in, but the new account they are signing in
@@ -147,13 +132,11 @@ export default async function callbackHandler(
}
// If there is no active session, but the account being signed in with is already
// associated with a valid user then create session to sign the user in.
session = useJwtSession
? {}
: await createSession(userByProviderAccountId)
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
return {
session,
user: userByProviderAccountId,
isNewUser,
isNewUser
}
} else {
if (isSignedIn) {
@@ -168,16 +151,13 @@ export default async function callbackHandler(
providerAccount.accessToken,
providerAccount.accessTokenExpires
)
await dispatchEvent(events.linkAccount, {
user,
providerAccount: providerAccount,
})
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
// As they are already signed in, we don't need to do anything after linking them
return {
session,
user,
isNewUser,
isNewUser
}
}
@@ -198,9 +178,7 @@ export default async function callbackHandler(
//
// OAuth providers should require email address verification to prevent this, but in
// practice that is not always the case; this helps protect against that.
const userByEmail = profile.email
? await getUserByEmail(profile.email)
: null
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// We end up here when we don't have an account with the same [provider].id *BUT*
// we do already have an account with the same email address as the one in the
@@ -229,17 +207,14 @@ export default async function callbackHandler(
providerAccount.accessToken,
providerAccount.accessTokenExpires
)
await dispatchEvent(events.linkAccount, {
user,
providerAccount: providerAccount,
})
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
session = useJwtSession ? {} : await createSession(user)
isNewUser = true
return {
session,
user,
isNewUser,
isNewUser
}
}
}

View File

@@ -8,115 +8,115 @@
* As only partial functionlity is required, only the code we need has been incorporated here
* (with fixes for specific issues) to keep dependancy size down.
*/
export function set(res, name, value, options = {}) {
export function set (res, name, value, options = {}) {
const stringValue =
typeof value === "object" ? "j:" + JSON.stringify(value) : String(value)
typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
if ("maxAge" in options) {
if ('maxAge' in options) {
options.expires = new Date(Date.now() + options.maxAge)
options.maxAge /= 1000
}
// Preserve any existing cookies that have already been set in the same session
let setCookieHeader = res.getHeader("Set-Cookie") || []
let setCookieHeader = res.getHeader('Set-Cookie') || []
// If not an array (i.e. a string with a single cookie) convert it into an array
if (!Array.isArray(setCookieHeader)) {
setCookieHeader = [setCookieHeader]
}
setCookieHeader.push(_serialize(name, String(stringValue), options))
res.setHeader("Set-Cookie", setCookieHeader)
res.setHeader('Set-Cookie', setCookieHeader)
}
function _serialize(name, val, options) {
function _serialize (name, val, options) {
const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/ // eslint-disable-line no-control-regex
const opt = options || {}
const enc = opt.encode || encodeURIComponent
if (typeof enc !== "function") {
throw new TypeError("option encode is invalid")
if (typeof enc !== 'function') {
throw new TypeError('option encode is invalid')
}
if (!fieldContentRegExp.test(name)) {
throw new TypeError("argument name is invalid")
throw new TypeError('argument name is invalid')
}
const value = enc(val)
if (value && !fieldContentRegExp.test(value)) {
throw new TypeError("argument val is invalid")
throw new TypeError('argument val is invalid')
}
let str = name + "=" + value
let str = name + '=' + value
if (opt.maxAge != null) {
const maxAge = opt.maxAge - 0
if (isNaN(maxAge) || !isFinite(maxAge)) {
throw new TypeError("option maxAge is invalid")
throw new TypeError('option maxAge is invalid')
}
str += "; Max-Age=" + Math.floor(maxAge)
str += '; Max-Age=' + Math.floor(maxAge)
}
if (opt.domain) {
if (!fieldContentRegExp.test(opt.domain)) {
throw new TypeError("option domain is invalid")
throw new TypeError('option domain is invalid')
}
str += "; Domain=" + opt.domain
str += '; Domain=' + opt.domain
}
if (opt.path) {
if (!fieldContentRegExp.test(opt.path)) {
throw new TypeError("option path is invalid")
throw new TypeError('option path is invalid')
}
str += "; Path=" + opt.path
str += '; Path=' + opt.path
} else {
str += "; Path=/"
str += '; Path=/'
}
if (opt.expires) {
let expires = opt.expires
if (typeof opt.expires.toUTCString === "function") {
if (typeof opt.expires.toUTCString === 'function') {
expires = opt.expires.toUTCString()
} else {
const dateExpires = new Date(opt.expires)
expires = dateExpires.toUTCString()
}
str += "; Expires=" + expires
str += '; Expires=' + expires
}
if (opt.httpOnly) {
str += "; HttpOnly"
str += '; HttpOnly'
}
if (opt.secure) {
str += "; Secure"
str += '; Secure'
}
if (opt.sameSite) {
const sameSite =
typeof opt.sameSite === "string"
typeof opt.sameSite === 'string'
? opt.sameSite.toLowerCase()
: opt.sameSite
switch (sameSite) {
case true:
str += "; SameSite=Strict"
str += '; SameSite=Strict'
break
case "lax":
str += "; SameSite=Lax"
case 'lax':
str += '; SameSite=Lax'
break
case "strict":
str += "; SameSite=Strict"
case 'strict':
str += '; SameSite=Strict'
break
case "none":
str += "; SameSite=None"
case 'none':
str += '; SameSite=None'
break
default:
throw new TypeError("option sameSite is invalid")
throw new TypeError('option sameSite is invalid')
}
}
@@ -134,47 +134,46 @@ function _serialize(name, val, options) {
* @TODO Review cookie settings (names, options)
* @return {import("types").CookiesOptions}
*/
export function defaultCookies(useSecureCookies) {
const cookiePrefix = useSecureCookies ? "__Secure-" : ""
export function defaultCookies (useSecureCookies) {
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
return {
// default cookie options
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${useSecureCookies ? "__Host-" : ""}next-auth.csrf-token`,
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
},
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
}
}
}

View File

@@ -3,7 +3,7 @@ import * as cookie from './cookie'
/**
* Ensure CSRF Token cookie is set for any subsequent requests.
* Used as part of the strategy for mitigation for CSRF tokens.
* Used as part of the strateigy for mitigation for CSRF tokens.
*
* Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
* where 'token' is the CSRF token and 'hash' is a hash made of the token and

View File

@@ -15,7 +15,7 @@
* @return {Promise<boolean|never>} Return `true` (or a modified JWT) to allow sign in
* Return `false` to deny access
*/
export async function signIn() {
export async function signIn () {
return true
}
@@ -28,9 +28,10 @@ export async function signIn() {
* @param {string} baseUrl Default base URL of site (can be used as fallback)
* @return {Promise<string>} URL the client will be redirect to
*/
export async function redirect(url, baseUrl) {
if (url.startsWith("/")) return `${baseUrl}${url}`
else if (new URL(url).origin === baseUrl) return url
export async function redirect (url, baseUrl) {
if (url.startsWith(baseUrl)) {
return url
}
return baseUrl
}
@@ -42,7 +43,7 @@ export async function redirect(url, baseUrl) {
* @param {object} token JSON Web Token (if enabled)
* @return {Promise<object>} Session that will be returned to the client
*/
export async function session(session) {
export async function session (session) {
return session
}
@@ -58,6 +59,6 @@ export async function session(session) {
* @param {object} oAuthProfile OAuth profile - only available on sign in
* @return {Promise<object>} JSON Web Token that will be saved
*/
export async function jwt(token) {
export async function jwt (token) {
return token
}

View File

@@ -30,7 +30,6 @@ export default async function oAuthCallback(req) {
provider.id,
code
)
logger.debug("OAUTH_CALLBACK_HANDLER_ERROR", req.body)
throw error
}
}
@@ -63,7 +62,7 @@ export default async function oAuthCallback(req) {
return getProfile({ profileData, provider, tokens, user })
} catch (error) {
logger.error("OAUTH_GET_ACCESS_TOKEN_ERROR", error, provider.id)
logger.error("OAUTH_GET_ACCESS_TOKEN_ERROR", error, provider.id, code)
throw error
}
}
@@ -75,11 +74,7 @@ export default async function oAuthCallback(req) {
// eslint-disable-next-line camelcase
const { token_secret } = await client.getOAuthRequestToken(provider.params)
const tokens = await client.getOAuthAccessToken(
oauth_token,
token_secret,
oauth_verifier
)
const tokens = await client.getOAuthAccessToken(oauth_token, token_secret, oauth_verifier)
const profileData = await client.get(
provider.profileUrl,
tokens.oauth_token,
@@ -148,11 +143,11 @@ async function getProfile({ profileData, tokens, provider, user }) {
// 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.
//
// Unfortunately, we can't tell which - at least not in a way that works for
// 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", exception)
logger.error("OAUTH_PARSE_PROFILE_ERROR", exception, profileData)
return {
profile: null,
account: null,

View File

@@ -1,7 +1,7 @@
import { OAuth, OAuth2 } from "oauth"
import querystring from "querystring"
import logger from "../../../lib/logger"
import { sign as jwtSign } from "jsonwebtoken"
import { OAuth, OAuth2 } from 'oauth'
import querystring from 'querystring'
import logger from '../../../lib/logger'
import { sign as jwtSign } from 'jsonwebtoken'
/**
* @TODO Refactor to remove dependancy on 'oauth' package
@@ -9,8 +9,8 @@ import { sign as jwtSign } from "jsonwebtoken"
* would be easier to maintain if all the code was native to next-auth.
* @param {import("types/providers").OAuthConfig} provider
*/
export default function oAuthClient(provider) {
if (provider.version?.startsWith("2.")) {
export default function oAuthClient (provider) {
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
const authorizationUrl = new URL(provider.authorizationUrl)
const basePath = authorizationUrl.origin
@@ -34,9 +34,9 @@ export default function oAuthClient(provider) {
provider.accessTokenUrl,
provider.clientId,
provider.clientSecret,
provider.version || "1.0",
provider.version || '1.0',
provider.callbackUrl,
provider.encoding || "HMAC-SHA1"
provider.encoding || 'HMAC-SHA1'
)
// Promisify get() and getOAuth2AccessToken() for OAuth1
@@ -51,48 +51,40 @@ export default function oAuthClient(provider) {
})
})
}
const originalGetOAuth1AccessToken =
oauth1Client.getOAuthAccessToken.bind(oauth1Client)
const originalGetOAuth1AccessToken = oauth1Client.getOAuthAccessToken.bind(oauth1Client)
oauth1Client.getOAuthAccessToken = (...args) => {
return new Promise((resolve, reject) => {
// eslint-disable-next-line camelcase
originalGetOAuth1AccessToken(
...args,
(error, oauth_token, oauth_token_secret, params) => {
if (error) {
return reject(error)
}
resolve({
// TODO: Remove, this is only kept for backward compativility
// These are not in the OAuth 1.x spec
accessToken: oauth_token,
refreshToken: oauth_token_secret,
results: params,
oauth_token,
oauth_token_secret,
params,
})
originalGetOAuth1AccessToken(...args, (error, oauth_token, oauth_token_secret, params) => {
if (error) {
return reject(error)
}
)
resolve({
// TODO: Remove, this is only kept for backward compativility
// These are not in the OAuth 1.x spec
accessToken: oauth_token,
refreshToken: oauth_token_secret,
results: params,
oauth_token,
oauth_token_secret,
params
})
})
})
}
const originalGetOAuthRequestToken =
oauth1Client.getOAuthRequestToken.bind(oauth1Client)
const originalGetOAuthRequestToken = oauth1Client.getOAuthRequestToken.bind(oauth1Client)
oauth1Client.getOAuthRequestToken = (params = {}) => {
return new Promise((resolve, reject) => {
// eslint-disable-next-line camelcase
originalGetOAuthRequestToken(
params,
(error, oauth_token, oauth_token_secret, params) => {
if (error) {
return reject(error)
}
resolve({ oauth_token, oauth_token_secret, params })
originalGetOAuthRequestToken(params, (error, oauth_token, oauth_token_secret, params) => {
if (error) {
return reject(error)
}
)
resolve({ oauth_token, oauth_token_secret, params })
})
})
}
return oauth1Client
@@ -112,112 +104,103 @@ export default function oAuthClient(provider) {
* @param {import("types/providers").OAuthConfig} provider
* @param {string | undefined} codeVerifier
*/
async function getOAuth2AccessToken(code, provider, codeVerifier) {
async function getOAuth2AccessToken (code, provider, codeVerifier) {
const url = provider.accessTokenUrl
const params = { ...provider.params }
const headers = { ...provider.headers }
const codeParam =
params.grant_type === "refresh_token" ? "refresh_token" : "code"
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
if (!params[codeParam]) {
params[codeParam] = code
}
if (!params[codeParam]) { params[codeParam] = code }
if (!params.client_id) {
params.client_id = provider.clientId
}
if (!params.client_id) { params.client_id = provider.clientId }
// For Apple the client secret must be generated on-the-fly.
// Using the properties in clientSecret to create a JWT.
if (provider.id === "apple" && typeof provider.clientSecret === "object") {
if (provider.id === 'apple' && typeof provider.clientSecret === 'object') {
const { keyId, teamId, privateKey } = provider.clientSecret
const clientSecret = jwtSign(
{
iss: teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400 * 180, // 6 months
aud: "https://appleid.apple.com",
sub: provider.clientId,
},
// Automatically convert \\n into \n if found in private key. If the key
// is passed in an environment variable \n can get escaped as \\n
privateKey.replace(/\\n/g, "\n"),
{ algorithm: "ES256", keyid: keyId }
const clientSecret = jwtSign({
iss: teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
aud: 'https://appleid.apple.com',
sub: provider.clientId
},
// Automatically convert \\n into \n if found in private key. If the key
// is passed in an environment variable \n can get escaped as \\n
privateKey.replace(/\\n/g, '\n'),
{ algorithm: 'ES256', keyid: keyId }
)
params.client_secret = clientSecret
} else {
params.client_secret = provider.clientSecret
}
if (!params.redirect_uri) {
params.redirect_uri = provider.callbackUrl
}
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
if (!headers["Content-Type"]) {
headers["Content-Type"] = "application/x-www-form-urlencoded"
}
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
// Added as a fix to accomodate change in Twitch OAuth API
if (!headers["Client-ID"]) {
headers["Client-ID"] = provider.clientId
}
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
// Added as a fix for Reddit Authentication
if (provider.id === "reddit") {
headers.Authorization =
"Basic " +
Buffer.from(provider.clientId + ":" + provider.clientSecret).toString(
"base64"
)
if (provider.id === 'reddit') {
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
}
if (provider.id === "identity-server4" && !headers.Authorization) {
if (provider.id === 'identity-server4' && !headers.Authorization) {
headers.Authorization = `Bearer ${code}`
}
if (provider.protection.includes("pkce")) {
if (provider.protection.includes('pkce')) {
params.code_verifier = codeVerifier
}
const postData = querystring.stringify(params)
return new Promise((resolve, reject) => {
this._request("POST", url, headers, postData, null, (error, data) => {
if (error) {
logger.error("OAUTH_GET_ACCESS_TOKEN_ERROR", error)
return reject(error)
}
let raw
try {
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
// responses should be in JSON
raw = JSON.parse(data)
} catch {
// However both Facebook + Github currently use rev05 of the spec and neither
// seem to specify a content-type correctly in their response headers. :(
// Clients of these services suffer a minor performance cost.
raw = querystring.parse(data)
}
let accessToken
if (provider.id === "slack") {
const { ok, error } = raw
if (!ok) {
this._request(
'POST',
url,
headers,
postData,
null,
(error, data, response) => {
if (error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
return reject(error)
}
accessToken = raw.authed_user.access_token
} else {
accessToken = raw.access_token
}
let raw
try {
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
// responses should be in JSON
raw = JSON.parse(data)
} catch {
// However both Facebook + Github currently use rev05 of the spec and neither
// seem to specify a content-type correctly in their response headers. :(
// Clients of these services suffer a minor performance cost.
raw = querystring.parse(data)
}
resolve({
accessToken,
accessTokenExpires: null,
refreshToken: raw.refresh_token,
idToken: raw.id_token,
...raw,
})
})
let accessToken
if (provider.id === 'slack') {
const { ok, error } = raw
if (!ok) {
return reject(error)
}
accessToken = raw.authed_user.access_token
} else {
accessToken = raw.access_token
}
resolve({
accessToken,
accessTokenExpires: null,
refreshToken: raw.refresh_token,
idToken: raw.id_token,
...raw
})
}
)
})
}
@@ -230,69 +213,52 @@ async function getOAuth2AccessToken(code, provider, codeVerifier) {
* @param {string} accessToken
* @param {any} results
*/
async function getOAuth2(provider, accessToken, results) {
async function getOAuth2 (provider, accessToken, results) {
let url = provider.profileUrl
let httpMethod = "GET"
const headers = { ...provider.headers }
if (this._useAuthorizationHeaderForGET) {
headers.Authorization = this.buildAuthHeader(accessToken)
// Mail.ru & vk.com require 'access_token' as URL request parameter
if (["mailru", "vk"].includes(provider.id)) {
if (['mailru', 'vk'].includes(provider.id)) {
const safeAccessTokenURL = new URL(url)
safeAccessTokenURL.searchParams.append("access_token", accessToken)
safeAccessTokenURL.searchParams.append('access_token', accessToken)
url = safeAccessTokenURL.href
}
// This line is required for Twitch
if (provider.id === "twitch") {
headers["Client-ID"] = provider.clientId
if (provider.id === 'twitch') {
headers['Client-ID'] = provider.clientId
}
accessToken = null
}
if (provider.id === "bungie") {
if (provider.id === 'bungie') {
url = prepareProfileUrl({ provider, url, results })
}
/** Dropbox requires POST instead of GET
* Read more: https://www.dropbox.com/developers/reference/auth-types#user
*/
if (provider.id === "dropbox") {
httpMethod = "POST"
}
return new Promise((resolve, reject) => {
this._request(
httpMethod,
url,
headers,
null,
accessToken,
(error, profileData) => {
if (error) {
return reject(error)
}
resolve(profileData)
this._request('GET', url, headers, null, accessToken, (error, profileData) => {
if (error) {
return reject(error)
}
)
resolve(profileData)
})
})
}
/** Bungie needs special handling */
function prepareProfileUrl({ provider, url, results }) {
function prepareProfileUrl ({ provider, url, results }) {
if (!results.membership_id) {
// internal error
// @TODO: handle better
throw new Error("Expected membership_id to be passed.")
throw new Error('Expected membership_id to be passed.')
}
if (!provider.headers?.["X-API-Key"]) {
throw new Error(
'The Bungie provider requires the X-API-Key option to be present in "headers".'
)
if (!provider.headers?.['X-API-Key']) {
throw new Error('The Bungie provider requires the X-API-Key option to be present in "headers".')
}
return url.replace("{membershipId}", results.membership_id)
return url.replace('{membershipId}', results.membership_id)
}

View File

@@ -36,11 +36,7 @@ export async function handleCallback (req, res) {
pkceLength: PKCE_LENGTH,
method: PKCE_CODE_CHALLENGE_METHOD
})
// remove PKCE after it has been used
cookie.set(res, cookies.pkceCodeVerifier.name, "", {
...cookies.pkceCodeVerifier.options,
maxAge: 0
})
cookie.set(res, cookies.pkceCodeVerifier.name, null, { maxAge: 0 }) // remove PKCE after it has been used
} catch (error) {
logger.error('CALLBACK_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)

View File

@@ -1,44 +1,22 @@
import { randomBytes } from "crypto"
import adapterErrorHandler from "../../../adapters/error-handler"
import { randomBytes } from 'crypto'
/**
*
* @param {string} email
* @param {import("types/providers").EmailConfig} provider
* @param {import("types/internals").AppOptions} options
* @returns
*/
export default async function email(email, provider, options) {
export default async function email (email, provider, options) {
try {
const { baseUrl, basePath, adapter, logger } = options
const { baseUrl, basePath, adapter } = options
const { createVerificationRequest } = adapterErrorHandler(
await adapter.getAdapter(options),
logger
)
const { createVerificationRequest } = await adapter.getAdapter(options)
// Prefer provider specific secret, but use default secret if none specified
const secret = provider.secret || options.secret
// Generate token
const token =
(await provider.generateVerificationToken?.()) ??
randomBytes(32).toString("hex")
const token = await provider.generateVerificationToken?.() ?? randomBytes(32).toString('hex')
// Send email with link containing token (the unhashed version)
const url = `${baseUrl}${basePath}/callback/${encodeURIComponent(
provider.id
)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
const url = `${baseUrl}${basePath}/callback/${encodeURIComponent(provider.id)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
// @TODO Create invite (send secret so can be hashed)
await createVerificationRequest(
email,
url,
token,
secret,
provider,
options
)
await createVerificationRequest(email, url, token, secret, provider, options)
// Return promise
return Promise.resolve()

View File

@@ -15,9 +15,9 @@ export default async function getAuthorizationUrl (req) {
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
let url = client.getAuthorizeUrl({
scope: provider.scope,
...params,
redirect_uri: provider.callbackUrl
redirect_uri: provider.callbackUrl,
scope: provider.scope
})
// If the authorizationUrl specified in the config has query parameters on it

View File

@@ -1,18 +1,12 @@
import { h } from "preact" // eslint-disable-line no-unused-vars
import { h } from 'preact' // eslint-disable-line no-unused-vars
export default function signin({
csrfToken,
providers,
callbackUrl,
email,
error: errorType,
}) {
export default function signin ({ csrfToken, providers, callbackUrl, email, error: errorType }) {
// We only want to render providers
const providersToRender = providers.filter((provider) => {
if (provider.type === "oauth" || provider.type === "email") {
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) {
} else if (provider.type === 'credentials' && provider.credentials) {
// Only render credentials type provider if credentials are defined
return true
}
@@ -21,93 +15,70 @@ export default function signin({
})
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.",
Signin: 'Try signing with a different account.',
OAuthSignin: 'Try signing with a different account.',
OAuthCallback: 'Try signing with a different account.',
OAuthCreateAccount: 'Try signing with a different account.',
EmailCreateAccount: 'Try signing with a different account.',
Callback: 'Try signing with a different account.',
OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.',
EmailSignin: 'Check your email address.',
CredentialsSignin: 'Sign in failed. Check the details you provided are correct.',
default: 'Unable to sign in.'
}
const error = errorType && (errors[errorType] ?? errors.default)
return (
<div className="signin">
{error && (
<div className="error">
<div className='signin'>
{error &&
<div className='error'>
<p>{error}</p>
</div>
)}
{providersToRender.map((provider, i) => (
<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 for={`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) => {
</div>}
{providersToRender.map((provider, i) =>
<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 for={`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
for={`input-${credential}-for-${provider.id}-provider`}
>
{provider.credentials[credential].label || credential}
>{provider.credentials[credential].label || credential}
</label>
<input
name={credential}
id={`input-${credential}-for-${provider.id}-provider`}
type={provider.credentials[credential].type || "text"}
value={provider.credentials[credential].value || ""}
placeholder={
provider.credentials[credential].placeholder || ""
}
type={provider.credentials[credential].type || 'text'}
value={provider.credentials[credential].value || ''}
placeholder={provider.credentials[credential].placeholder || ''}
/>
</div>
)
})}
<button type="submit">Sign in with {provider.name}</button>
</form>
)}
{(provider.type === "email" || provider.type === "credentials") &&
i + 1 < providersToRender.length && <hr />}
<button type='submit'>Sign in with {provider.name}</button>
</form>}
{(provider.type === 'email' || provider.type === 'credentials') && ((i + 1) < providersToRender.length) &&
<hr />}
</div>
))}
)}
</div>
)
}

View File

@@ -1,15 +1,15 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
import * as cookie from "../lib/cookie"
import dispatchEvent from "../lib/dispatch-event"
import adapterErrorHandler from "../../adapters/error-handler"
import oAuthCallback from '../lib/oauth/callback'
import callbackHandler from '../lib/callback-handler'
import * as cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'
/**
* Handle callbacks from login services
* @param {import("types/internals").NextAuthRequest} req
* @param {import("types/internals").NextAuthResponse} res
*/
export default async function callback(req, res) {
export default async function callback (req, res) {
const {
provider,
adapter,
@@ -22,23 +22,21 @@ export default async function callback(req, res) {
jwt,
events,
callbacks,
session: { jwt: useJwtSession, maxAge: sessionMaxAge },
logger,
session: {
jwt: useJwtSession,
maxAge: sessionMaxAge
}
} = req.options
// Get session ID (if set)
const sessionToken = req.cookies?.[cookies.sessionToken.name] ?? null
if (provider.type === "oauth") {
if (provider.type === 'oauth') {
try {
const { profile, account, OAuthProfile } = await oAuthCallback(req)
try {
// Make it easier to debug when adding a new provider
logger.debug("OAUTH_CALLBACK_RESPONSE", {
profile,
account,
OAuthProfile,
})
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
@@ -58,85 +56,47 @@ export default async function callback(req, res) {
// (that just means it's a new user signing in for the first time).
let userOrProfile = profile
if (adapter) {
const { getUserByProviderAccountId } = adapterErrorHandler(
await adapter.getAdapter(req.options),
logger
)
const userFromProviderAccountId = await getUserByProviderAccountId(
account.provider,
account.id
)
const { getUserByProviderAccountId } = await adapter.getAdapter(req.options)
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
if (userFromProviderAccountId) {
userOrProfile = userFromProviderAccountId
}
}
try {
const signInCallbackResponse = await callbacks.signIn(
userOrProfile,
account,
OAuthProfile
)
if (signInCallbackResponse === false) {
return res.redirect(
`${baseUrl}${basePath}/error?error=AccessDenied`
)
} else if (typeof signInCallbackResponse === "string") {
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
if (!signInCallbackResponse) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(
error.message
)}`
)
}
// TODO: Remove in a future major release
logger.warn("SIGNIN_CALLBACK_REJECT_REDIRECT")
return res.redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error.message)}`)
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(
sessionToken,
profile,
account,
req.options
)
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
if (useJwtSession) {
const defaultJwtPayload = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
sub: user.id?.toString()
}
const jwtPayload = await callbacks.jwt(
defaultJwtPayload,
user,
account,
OAuthProfile,
isNewUser
)
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: cookieExpires.toISOString(),
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
} else {
// Save Session Token in cookie
cookie.set(res, cookies.sessionToken.name, session.sessionToken, {
expires: session.expires || null,
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
}
await dispatchEvent(events.signIn, { user, account, isNewUser })
@@ -145,145 +105,89 @@ export default async function callback(req, res) {
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return res.redirect(
`${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`
)
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
}
// Callback URL is already verified at this point, so safe to use if specified
return res.redirect(callbackUrl || baseUrl)
} catch (error) {
if (error.name === "AccountNotLinkedError") {
if (error.name === 'AccountNotLinkedError') {
// If the email on the account is already linked, but not with this OAuth account
return res.redirect(
`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`
)
} else if (error.name === "CreateUserError") {
return res.redirect(
`${baseUrl}${basePath}/error?error=OAuthCreateAccount`
)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
} else if (error.name === 'CreateUserError') {
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
}
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error)
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
} catch (error) {
if (error.name === "OAuthCallbackError") {
logger.error("CALLBACK_OAUTH_ERROR", error)
if (error.name === 'OAuthCallbackError') {
logger.error('CALLBACK_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
}
logger.error("OAUTH_CALLBACK_ERROR", error)
logger.error('OAUTH_CALLBACK_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
} else if (provider.type === "email") {
} else if (provider.type === 'email') {
try {
if (!adapter) {
logger.error("EMAIL_REQUIRES_ADAPTER_ERROR")
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const {
getVerificationRequest,
deleteVerificationRequest,
getUserByEmail,
} = adapterErrorHandler(await adapter.getAdapter(req.options), logger)
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(req.options)
const verificationToken = req.query.token
const email = req.query.email
// Verify email and verification token exist in database
const invite = await getVerificationRequest(
email,
verificationToken,
secret,
provider
)
const invite = await getVerificationRequest(email, verificationToken, secret, provider)
if (!invite) {
return res.redirect(`${baseUrl}${basePath}/error?error=Verification`)
}
// If verification token is valid, delete verification request token from
// the database so it cannot be used again
await deleteVerificationRequest(
email,
verificationToken,
secret,
provider
)
await deleteVerificationRequest(email, verificationToken, secret, provider)
// If is an existing user return a user object (otherwise use placeholder)
const profile = (await getUserByEmail(email)) || { email }
const account = {
id: provider.id,
type: "email",
providerAccountId: email,
}
const profile = await getUserByEmail(email) || { email }
const account = { id: provider.id, type: 'email', providerAccountId: email }
// Check if user is allowed to sign in
try {
const signInCallbackResponse = await callbacks.signIn(
profile,
account,
{ email }
)
if (signInCallbackResponse === false) {
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
if (!signInCallbackResponse) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === "string") {
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(
error.message
)}`
)
}
// TODO: Remove in a future major release
logger.warn("SIGNIN_CALLBACK_REJECT_REDIRECT")
return res.redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error.message)}`)
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(
sessionToken,
profile,
account,
req.options
)
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
if (useJwtSession) {
const defaultJwtPayload = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
sub: user.id?.toString()
}
const jwtPayload = await callbacks.jwt(
defaultJwtPayload,
user,
account,
profile,
isNewUser
)
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, profile, isNewUser)
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: cookieExpires.toISOString(),
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
} else {
// Save Session Token in cookie
cookie.set(res, cookies.sessionToken.name, session.sessionToken, {
expires: session.expires || null,
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
}
await dispatchEvent(events.signIn, { user, account, isNewUser })
@@ -292,128 +196,77 @@ export default async function callback(req, res) {
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return res.redirect(
`${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`
)
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
}
// Callback URL is already verified at this point, so safe to use if specified
return res.redirect(callbackUrl || baseUrl)
} catch (error) {
if (error.name === "CreateUserError") {
return res.redirect(
`${baseUrl}${basePath}/error?error=EmailCreateAccount`
)
if (error.name === 'CreateUserError') {
return res.redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
}
logger.error("CALLBACK_EMAIL_ERROR", error)
logger.error('CALLBACK_EMAIL_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
} else if (provider.type === "credentials" && req.method === "POST") {
} else if (provider.type === 'credentials' && req.method === 'POST') {
if (!useJwtSession) {
logger.error(
"CALLBACK_CREDENTIALS_JWT_ERROR",
"Signin in with credentials is only supported if JSON Web Tokens are enabled"
)
return res
.status(500)
.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled')
return res.status(500).redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
if (!provider.authorize) {
logger.error(
"CALLBACK_CREDENTIALS_HANDLER_ERROR",
"Must define an authorize() handler to use credentials authentication provider"
)
return res
.status(500)
.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
return res.status(500).redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const credentials = req.body
let userObjectReturnedFromAuthorizeHandler
try {
userObjectReturnedFromAuthorizeHandler = await provider.authorize(
credentials, {...req, options: {}, cookies: {}}
)
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
if (!userObjectReturnedFromAuthorizeHandler) {
return res
.status(401)
.redirect(
`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(
provider.id
)}`
)
return res.status(401).redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
} else if (typeof userObjectReturnedFromAuthorizeHandler === 'string') {
return res.redirect(userObjectReturnedFromAuthorizeHandler)
}
} catch (error) {
if (error instanceof Error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(
error.message
)}`
)
}
return res.redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error.message)}`)
}
const user = userObjectReturnedFromAuthorizeHandler
const account = { id: provider.id, type: "credentials" }
const account = { id: provider.id, type: 'credentials' }
try {
const signInCallbackResponse = await callbacks.signIn(
user,
account,
credentials
)
if (signInCallbackResponse === false) {
return res
.status(403)
.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
if (!signInCallbackResponse) {
return res.status(403).redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(
error.message
)}`
)
}
return res.redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error.message)}`)
}
const defaultJwtPayload = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
sub: user.id?.toString()
}
const jwtPayload = await callbacks.jwt(
defaultJwtPayload,
user,
account,
userObjectReturnedFromAuthorizeHandler,
false
)
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, userObjectReturnedFromAuthorizeHandler, false)
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: cookieExpires.toISOString(),
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
await dispatchEvent(events.signIn, { user, account })
return res.redirect(callbackUrl || baseUrl)
}
return res
.status(500)
.end(`Error: Callback for provider type ${provider.type} not supported`)
return res.status(500).end(`Error: Callback for provider type ${provider.type} not supported`)
}

View File

@@ -5,16 +5,13 @@
* @param {import("types/internals").NextAuthRequest} req
* @param {import("types/internals").NextAuthResponse} res
*/
export default function providers(req, res) {
export default function providers (req, res) {
const { providers } = req.options
const result = providers.reduce(
(acc, { id, name, type, signinUrl, callbackUrl }) => {
acc[id] = { id, name, type, signinUrl, callbackUrl }
return acc
},
{}
)
const result = providers.reduce((acc, { id, name, type, signinUrl, callbackUrl }) => {
acc[id] = { id, name, type, signinUrl, callbackUrl }
return acc
}, {})
res.json(result)
}

View File

@@ -1,15 +1,13 @@
import * as cookie from "../lib/cookie"
import dispatchEvent from "../lib/dispatch-event"
import adapterErrorHandler from "../../adapters/error-handler"
import * as cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'
/**
* Return a session object (without any private fields)
* for Single Page App clients
* @param {import("types/internals").NextAuthRequest} req
* @param {import("types/internals").NextAuthResponse} res
*/
export default async function session(req, res) {
const { cookies, adapter, jwt, events, callbacks, logger } = req.options
export default async function session (req, res) {
const { cookies, adapter, jwt, events, callbacks } = req.options
const useJwtSession = req.options.session.jwt
const sessionMaxAge = req.options.session.maxAge
const sessionToken = req.cookies[cookies.sessionToken.name]
@@ -26,9 +24,7 @@ export default async function session(req, res) {
// Generate new session expiry date
const sessionExpiresDate = new Date()
sessionExpiresDate.setTime(
sessionExpiresDate.getTime() + sessionMaxAge * 1000
)
sessionExpiresDate.setTime(sessionExpiresDate.getTime() + (sessionMaxAge * 1000))
const sessionExpires = sessionExpiresDate.toISOString()
// By default, only exposes a limited subset of information to the client
@@ -37,17 +33,14 @@ export default async function session(req, res) {
user: {
name: decodedJwt.name || null,
email: decodedJwt.email || null,
image: decodedJwt.picture || null,
image: decodedJwt.picture || null
},
expires: sessionExpires,
expires: sessionExpires
}
// Pass Session and JSON Web Token through to the session callback
const jwtPayload = await callbacks.jwt(decodedJwt)
const sessionPayload = await callbacks.session(
defaultSessionPayload,
jwtPayload
)
const sessionPayload = await callbacks.session(defaultSessionPayload, jwtPayload)
// Return session payload as response
response = sessionPayload
@@ -56,29 +49,17 @@ export default async function session(req, res) {
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
// Set cookie, to also update expiry date on cookie
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: sessionExpires,
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: sessionExpires, ...cookies.sessionToken.options })
await dispatchEvent(events.session, {
session: sessionPayload,
jwt: jwtPayload,
})
await dispatchEvent(events.session, { session: sessionPayload, jwt: jwtPayload })
} catch (error) {
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
logger.error("JWT_SESSION_ERROR", error)
cookie.set(res, cookies.sessionToken.name, "", {
...cookies.sessionToken.options,
maxAge: 0,
})
logger.error('JWT_SESSION_ERROR', error)
cookie.set(res, cookies.sessionToken.name, '', { ...cookies.sessionToken.options, maxAge: 0 })
}
} else {
try {
const { getUser, getSession, updateSession } = adapterErrorHandler(
await adapter.getAdapter(req.options),
logger
)
const { getUser, getSession, updateSession } = await adapter.getAdapter(req.options)
const session = await getSession(sessionToken)
if (session) {
// Trigger update to session object to update session expiry
@@ -92,38 +73,29 @@ export default async function session(req, res) {
user: {
name: user.name,
email: user.email,
image: user.image,
image: user.image
},
accessToken: session.accessToken,
expires: session.expires,
expires: session.expires
}
// Pass Session through to the session callback
const sessionPayload = await callbacks.session(
defaultSessionPayload,
user
)
const sessionPayload = await callbacks.session(defaultSessionPayload, user)
// Return session payload as response
response = sessionPayload
// Set cookie again to update expiry
cookie.set(res, cookies.sessionToken.name, sessionToken, {
expires: session.expires,
...cookies.sessionToken.options,
})
cookie.set(res, cookies.sessionToken.name, sessionToken, { expires: session.expires, ...cookies.sessionToken.options })
await dispatchEvent(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.
cookie.set(res, cookies.sessionToken.name, "", {
...cookies.sessionToken.options,
maxAge: 0,
})
cookie.set(res, cookies.sessionToken.name, '', { ...cookies.sessionToken.options, maxAge: 0 })
}
} catch (error) {
logger.error("SESSION_ERROR", error)
logger.error('SESSION_ERROR', error)
}
}

View File

@@ -1,37 +1,35 @@
import getAuthorizationUrl from "../lib/signin/oauth"
import emailSignin from "../lib/signin/email"
import adapterErrorHandler from "../../adapters/error-handler"
import getAuthorizationUrl from '../lib/signin/oauth'
import emailSignin from '../lib/signin/email'
import logger from '../../lib/logger'
/**
* Handle requests to /api/auth/signin
* @param {import("types/internals").NextAuthRequest} req
* @param {import("types/internals").NextAuthResponse} res
*/
export default async function signin(req, res) {
const { provider, baseUrl, basePath, adapter, callbacks, logger } =
req.options
/** Handle requests to /api/auth/signin */
export default async function signin (req, res) {
const {
provider,
baseUrl,
basePath,
adapter,
callbacks
} = req.options
if (!provider.type) {
return res.status(500).end(`Error: Type not specified for ${provider.name}`)
}
if (provider.type === "oauth" && req.method === "POST") {
if (provider.type === 'oauth' && req.method === 'POST') {
try {
const authorizationUrl = await getAuthorizationUrl(req)
return res.redirect(authorizationUrl)
} catch (error) {
logger.error("SIGNIN_OAUTH_ERROR", error)
logger.error('SIGNIN_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
}
} else if (provider.type === "email" && req.method === "POST") {
} else if (provider.type === 'email' && req.method === 'POST') {
if (!adapter) {
logger.error("EMAIL_REQUIRES_ADAPTER_ERROR")
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const { getUserByEmail } = adapterErrorHandler(
await adapter.getAdapter(req.options),
logger
)
const { getUserByEmail } = await adapter.getAdapter(req.options)
// Note: Technically the part of the email address local mailbox element
// (everything before the @ symbol) should be treated as 'case sensitive'
@@ -40,48 +38,32 @@ export default async function signin(req, res) {
// complains about this we can make strict RFC 2821 compliance an option.
const email = req.body.email?.toLowerCase() ?? null
if (!email) {
return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
}
// If is an existing user return a user object (otherwise use placeholder)
const profile = (await getUserByEmail(email)) || { email }
const account = { id: provider.id, type: "email", providerAccountId: email }
const profile = await getUserByEmail(email) || { email }
const account = { id: provider.id, type: 'email', providerAccountId: email }
// Check if user is allowed to sign in
try {
const signInCallbackResponse = await callbacks.signIn(profile, account, {
email,
verificationRequest: true,
})
if (signInCallbackResponse === false) {
const signInCallbackResponse = await callbacks.signIn(profile, account, { email, verificationRequest: true })
if (!signInCallbackResponse) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === "string") {
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`
)
}
// TODO: Remove in a future major release
logger.warn("SIGNIN_CALLBACK_REJECT_REDIRECT")
return res.redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
}
try {
await emailSignin(email, provider, req.options)
} catch (error) {
logger.error("SIGNIN_EMAIL_ERROR", error)
logger.error('SIGNIN_EMAIL_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
}
return res.redirect(
`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
provider.id
)}&type=${encodeURIComponent(provider.type)}`
)
return res.redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
provider.id
)}&type=${encodeURIComponent(provider.type)}`)
}
return res.redirect(`${baseUrl}${basePath}/signin`)
}

View File

@@ -1,14 +1,10 @@
import * as cookie from "../lib/cookie"
import dispatchEvent from "../lib/dispatch-event"
import adapterErrorHandler from "../../adapters/error-handler"
import * as cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'
/**
* Handle requests to /api/auth/signout
* @param {import("types/internals").NextAuthRequest} req
* @param {import("types/internals").NextAuthResponse} res
*/
export default async function signout(req, res) {
const { adapter, cookies, events, jwt, callbackUrl, logger } = req.options
/** Handle requests to /api/auth/signout */
export default async function signout (req, res) {
const { adapter, cookies, events, jwt, callbackUrl } = req.options
const useJwtSession = req.options.session.jwt
const sessionToken = req.cookies[cookies.sessionToken.name]
@@ -22,10 +18,7 @@ export default async function signout(req, res) {
}
} else {
// Get session from database
const { getSession, deleteSession } = adapterErrorHandler(
await adapter.getAdapter(req.options),
logger
)
const { getSession, deleteSession } = await adapter.getAdapter(req.options)
try {
// Dispatch signout event
@@ -40,14 +33,14 @@ export default async function signout(req, res) {
await deleteSession(sessionToken)
} catch (error) {
// If error, log it but continue
logger.error("SIGNOUT_ERROR", error)
logger.error('SIGNOUT_ERROR', error)
}
}
// Remove Session Token
cookie.set(res, cookies.sessionToken.name, "", {
cookie.set(res, cookies.sessionToken.name, '', {
...cookies.sessionToken.options,
maxAge: 0,
maxAge: 0
})
return res.redirect(callbackUrl)

16
test/.env.example Normal file
View File

@@ -0,0 +1,16 @@
# To be able to run tests:
# 1. copy to the root folder and rename to .env
# 2. Populate with values
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_TWITTER_ID=
NEXTAUTH_TWITTER_SECRET=
NEXTAUTH_TWITTER_USERNAME=
NEXTAUTH_TWITTER_PASSWORD=
NEXTAUTH_GITHUB_ID=
NEXTAUTH_GITHUB_SECRET=
NEXTAUTH_GITHUB_USERNAME=
NEXTAUTH_GITHUB_PASSWORD=
NEXTAUTH_GOOGLE_ID=
NEXTAUTH_GOOGLE_SECRET=
NEXTAUTH_GOOGLE_USERNAME=
NEXTAUTH_GOOGLE_PASSWORD=

30
test/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Multi stage build to allow us to improve performance
FROM node:10-alpine as base
WORKDIR /usr/src/app
# Install basic dependancies (Next.js, React)
COPY test/docker/app/package*.json ./
RUN npm ci --only=production
FROM node:10-alpine as app
COPY --from=base /usr/src/app ./
# Copy last build of library into the image and install dependences for it.
# This ensures the build is valid and package.json contains everything needed
# to actually run the library.
# Note: You must run `npm run build` first to build a release of the library
RUN mkdir -p node_modules/next-auth
# Copy all entrypoints for the library (if creating a new one, add it here)
COPY index.js providers.js adapters.js client.js jwt.js node_modules/next-auth/
# Copy the dist dir
COPY dist node_modules/next-auth/dist
# Copy the package.json for the library and install it's dependences
COPY package*.json node_modules/next-auth/
RUN cd node_modules/next-auth/ && npm ci --only=production
# Copy test pages across
COPY test/docker/app/pages ./pages
RUN npm run build
CMD [ "npm", "start" ]

52
test/docker/app.yml Normal file
View File

@@ -0,0 +1,52 @@
# Start test app with local databases inside the container.
#
# Note: Uses Docker Compose v2 as v3 doesn't currently support extends.
# https://docs.docker.com/compose/compose-file/compose-file-v2/
version: "2.3"
services:
app:
build:
context: ../../
dockerfile: ./test/Dockerfile
environment:
# Set env vars in your current terminal or in .env in the root directory
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_DATABASE_URL=${NEXTAUTH_DATABASE_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_JWT_SESSIONS=${NEXTAUTH_JWT_SESSIONS}
- NEXTAUTH_AUTH0_ID=${NEXTAUTH_AUTH0_ID}
- NEXTAUTH_AUTH0_SECRET=${NEXTAUTH_AUTH0_SECRET}
- NEXTAUTH_AUTH0_DOMAIN=${NEXTAUTH_AUTH0_DOMAIN}
- NEXTAUTH_FACEBOOK_ID=${NEXTAUTH_FACEBOOK_ID}
- NEXTAUTH_FACEBOOK_SECRET=${NEXTAUTH_FACEBOOK_SECRET}
- NEXTAUTH_GITHUB_ID=${NEXTAUTH_GITHUB_ID}
- NEXTAUTH_GITHUB_SECRET=${NEXTAUTH_GITHUB_SECRET}
- NEXTAUTH_GOOGLE_ID=${NEXTAUTH_GOOGLE_ID}
- NEXTAUTH_GOOGLE_SECRET=${NEXTAUTH_GOOGLE_SECRET}
- NEXTAUTH_TWITTER_ID=${NEXTAUTH_TWITTER_ID}
- NEXTAUTH_TWITTER_SECRET=${NEXTAUTH_TWITTER_SECRET}
- NEXTAUTH_EMAIL_SERVER=${NEXTAUTH_EMAIL_SERVER}
- NEXTAUTH_EMAIL_FROM=${NEXTAUTH_EMAIL_FROM}
ports:
- "3000:3000"
# mongo:
# extends:
# file: databases/mongo.yml
# service: mongo
# mssql:
# extends:
# file: databases/mssql.yml
# service: mssql
# mysql:
# extends:
# file: databases/mysql.yml
# service: mysql
# postgres:
# extends:
# file: databases/postgres.yml
# service: postgres

2521
test/docker/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,18 @@
{
"name": "next-auth-test",
"version": "0.0.1",
"description": "Test application for NextAuth.js",
"main": "",
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
},
"author": "Iain Collins <me@iaincollins.com>",
"license": "ISC",
"dependencies": {
"next": "^10.0.6",
"react": "^17.0.1",
"react-dom": "^17.0.1"
}
}

View File

@@ -0,0 +1,26 @@
import { Provider } from 'next-auth/client'
export default function App ({ Component, pageProps }) {
return (
<Provider
options={{
// Client Max Age controls how often the useSession in the client should
// contact the server to sync the session state. Value in seconds.
// e.g.
// * 0 - Disabled (always use cache value)
// * 60 - Sync session state with server if it's older than 60 seconds
clientMaxAge: 0,
// Keep Alive tells windows / tabs that are signed in to keep sending
// a keep alive request (which extends the current session expiry) to
// prevent sessions in open windows from expiring. Value in seconds.
//
// Note: If a session has expired when keep alive is triggered, all open
// windows / tabs will be updated to reflect the user is signed out.
keepAlive: 0
}}
session={pageProps.session}
>
<Component {...pageProps} />
</Provider>
)
}

View File

@@ -0,0 +1,118 @@
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
const options = {
// https://next-auth.js.org/configuration/providers
providers: [
Providers.Email({
server: process.env.NEXTAUTH_EMAIL_SERVER,
from: process.env.NEXTAUTH_EMAIL_FROM
}),
Providers.Apple({
clientId: process.env.NEXTAUTH_APPLE_ID,
clientSecret: {
appleId: process.env.NEXTAUTH_APPLE_ID,
teamId: process.env.NEXTAUTH_APPLE_TEAM_ID,
privateKey: process.env.NEXTAUTH_APPLE_PRIVATE_KEY,
keyId: process.env.NEXTAUTH_APPLE_KEY_ID
}
}),
Providers.Auth0({
clientId: process.env.NEXTAUTH_AUTH0_ID,
clientSecret: process.env.NEXTAUTH_AUTH0_SECRET,
domain: process.env.NEXTAUTH_AUTH0_DOMAIN
}),
Providers.Facebook({
clientId: process.env.NEXTAUTH_FACEBOOK_ID,
clientSecret: process.env.NEXTAUTH_FACEBOOK_SECRET
}),
Providers.GitHub({
clientId: process.env.NEXTAUTH_GITHUB_ID,
clientSecret: process.env.NEXTAUTH_GITHUB_SECRET
}),
Providers.Google({
clientId: process.env.NEXTAUTH_GOOGLE_ID,
clientSecret: process.env.NEXTAUTH_GOOGLE_SECRET
}),
Providers.Twitter({
clientId: process.env.NEXTAUTH_TWITTER_ID,
clientSecret: process.env.NEXTAUTH_TWITTER_SECRET
})
],
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
// https://next-auth.js.org/configuration/database
//
// Notes:
// * You must to install an appropriate node_module for your database
// * The Email provider requires a database (OAuth providers do not)
database: process.env.NEXTAUTH_DATABASE_URL,
// The secret should be set to a reasonably long random string.
// It is used to sign cookies and to sign and encrypt JSON Web Tokens, unless
// a seperate secret is defined explicitly for encrypting the JWT.
secret: process.env.NEXTAUTH_SECRET,
session: {
// Use JSON Web Tokens for session instead of database sessions.
// This option can be used with or without a database for users/accounts.
// Note: `jwt` is automatically set to `true` if no database is specified.
jwt: true
// Seconds - How long until an idle session expires and is no longer valid.
// maxAge: 30 * 24 * 60 * 60, // 30 days
// Seconds - Throttle how frequently to write to database to extend a session.
// Use it to limit write operations. Set to 0 to always update the database.
// Note: This option is ignored if using JSON Web Tokens
// updateAge: 24 * 60 * 60, // 24 hours
},
// JSON Web tokens are only used for sessions if the `jwt: true` session
// option is set - or by default if no database is specified.
// https://next-auth.js.org/configuration/options#jwt
jwt: {
// A secret to use for key generation (you should set this explicitly)
// secret: 'INp8IvdIyeMcoGAgFGoA61DdBglwwSqnXJZkgz8PSnw',
// Set to true to use encryption (default: false)
// encryption: true,
// You can define your own encode/decode functions for signing and encryption
// if you want to override the default behaviour.
// async encode({ secret, token, maxAge }) {},
// async decode({ secret, token, maxAge }) {},
},
// You can define custom pages to override the built-in pages.
// The routes shown here are the default URLs that will be used when a custom
// pages is not specified for that route.
// https://next-auth.js.org/configuration/pages
pages: {
// signIn: '/api/auth/signin', // Displays signin buttons
// signOut: '/api/auth/signout', // Displays form with sign out button
// error: '/api/auth/error', // Error code passed in query string as ?error=
// verifyRequest: '/api/auth/verify-request', // Used for check email page
// newUser: null // If set, new users will be directed here on first sign in
},
// Callbacks are asynchronous functions you can use to control what happens
// when an action is performed.
// https://next-auth.js.org/configuration/callbacks
callbacks: {
// async signIn(user, account, profile) { return Promise.resolve(true) },
// async redirect(url, baseUrl) { return Promise.resolve(baseUrl) },
// async session(session, user) { return Promise.resolve(session) },
// async jwt(token, user, account, profile, isNewUser) { return Promise.resolve(token) }
},
// Events are useful for logging
// https://next-auth.js.org/configuration/events
events: { },
// Enable debug messages in the console if you are having problems
debug: false
}
export default (req, res) => NextAuth(req, res, options)

View File

@@ -0,0 +1,3 @@
export default (req, res) => {
res.send(JSON.stringify(process.env, null, 2))
}

View File

@@ -0,0 +1,8 @@
import jwt from 'next-auth/jwt'
const secret = process.env.SECRET
export default async (req, res) => {
const token = await jwt.getToken({ req, secret })
res.send(JSON.stringify(token, null, 2))
}

View File

@@ -0,0 +1,11 @@
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })
if (session) {
res.send({ content: 'Protected content.' })
} else {
res.send({ content: 'Unprotected content.' })
}
}

View File

@@ -0,0 +1,6 @@
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })
res.send(JSON.stringify(session, null, 2))
}

View File

@@ -0,0 +1,5 @@
import Package from 'next-auth/package.json'
export default (req, res) => {
res.send(Package.version)
}

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