mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
79 Commits
@auth/soli
...
v3.2.0-can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad791ea45c | ||
|
|
1838e43b27 | ||
|
|
354d6c35c3 | ||
|
|
2e4832caf8 | ||
|
|
f05644dafa | ||
|
|
e7e8e0f393 | ||
|
|
416d92c33f | ||
|
|
e504044489 | ||
|
|
173df76c0f | ||
|
|
44ffd55fe2 | ||
|
|
fb8ec8a469 | ||
|
|
65504d6917 | ||
|
|
3fcdd22656 | ||
|
|
7a1d712096 | ||
|
|
f7ff4c9219 | ||
|
|
20f40d027a | ||
|
|
b5384e7403 | ||
|
|
b5c4e91f17 | ||
|
|
f1f144951a | ||
|
|
0380edfae9 | ||
|
|
4d89b27784 | ||
|
|
e17acb6762 | ||
|
|
91e26ca475 | ||
|
|
c8e76b4b5d | ||
|
|
a8362ec380 | ||
|
|
f2ad69358f | ||
|
|
ca06976422 | ||
|
|
7fa4275340 | ||
|
|
c684336b32 | ||
|
|
82d16e6ac4 | ||
|
|
bf7efbc252 | ||
|
|
b9862b86b5 | ||
|
|
9b579b5fcb | ||
|
|
abcf845ebf | ||
|
|
ee398d1acd | ||
|
|
c31cbbcd30 | ||
|
|
1728f50952 | ||
|
|
2eb17cba1a | ||
|
|
15196ee3d1 | ||
|
|
aa4439e182 | ||
|
|
66ec439b4d | ||
|
|
a49068643c | ||
|
|
1a315fe5ac | ||
|
|
652ac7de35 | ||
|
|
28ce71d99e | ||
|
|
28e2afbd3a | ||
|
|
eb828d42f8 | ||
|
|
d03504c6ef | ||
|
|
8827950f12 | ||
|
|
4f89d74d78 | ||
|
|
be159b1b18 | ||
|
|
19f2664a78 | ||
|
|
bd86e7c7c7 | ||
|
|
7ce37c71d7 | ||
|
|
3c3a4d2c4f | ||
|
|
5fcf80ce81 | ||
|
|
7a4534a6b1 | ||
|
|
ddaa830e10 | ||
|
|
9dbd372f08 | ||
|
|
dde908b54a | ||
|
|
831c59dd5c | ||
|
|
3abb0c8223 | ||
|
|
8c56e13577 | ||
|
|
12d7856640 | ||
|
|
4635113133 | ||
|
|
1aea187d5e | ||
|
|
47b8788249 | ||
|
|
06a160aa0c | ||
|
|
93f4dc0622 | ||
|
|
6088a05204 | ||
|
|
d242d72106 | ||
|
|
766874dbd8 | ||
|
|
0b7343702f | ||
|
|
0327b9049a | ||
|
|
2ee460de00 | ||
|
|
c8de34d003 | ||
|
|
d15572074f | ||
|
|
7b6fd818a5 | ||
|
|
e031591468 |
@@ -1,3 +1,5 @@
|
|||||||
|
# Rename file to .env and populate values
|
||||||
|
# to be able to run tests
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
NEXTAUTH_TWITTER_ID=
|
NEXTAUTH_TWITTER_ID=
|
||||||
NEXTAUTH_TWITTER_SECRET=
|
NEXTAUTH_TWITTER_SECRET=
|
||||||
|
|||||||
6
.env.local.example
Normal file
6
.env.local.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Rename file to .env.local and populate values
|
||||||
|
# to be able to run the dev app
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
SECRET=
|
||||||
|
GITHUB_ID=
|
||||||
|
GITHUB_SECRET=
|
||||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
8
.github/ISSUE_TEMPLATE/question.md
vendored
8
.github/ISSUE_TEMPLATE/question.md
vendored
@@ -4,12 +4,16 @@ about: Ask a question about NextAuth.js or for help using it
|
|||||||
labels: question
|
labels: question
|
||||||
assignees: ''
|
assignees: ''
|
||||||
---
|
---
|
||||||
|
<!-- NOTE: Questions will be converted to Discussions. You can find them at https://github.com/nextauthjs/next-auth/discussions! -->
|
||||||
|
|
||||||
**Your question**
|
**Your question**
|
||||||
A clear and concise question.
|
<!-- A clear and concise question. -->
|
||||||
|
|
||||||
**What are you trying to do**
|
**What are you trying to do**
|
||||||
A description of what you are trying to do, for context.
|
<!-- 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**
|
**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).*
|
*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).*
|
||||||
|
|||||||
41
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
41
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for your interest in the project. Bugs filed and PRs submitted are appreciated!
|
||||||
|
|
||||||
|
Please make sure that you are familiar with and follow the Code of Conduct for
|
||||||
|
this project (found in the CODE_OF_CONDUCT.md file).
|
||||||
|
|
||||||
|
Also, please make sure you're familiar with and follow the instructions in the
|
||||||
|
contributing guidelines (found in the CONTRIBUTING.md file).
|
||||||
|
|
||||||
|
If you're new to contributing to open source projects, you might find this free
|
||||||
|
video course helpful: https://kcd.im/pull-request
|
||||||
|
|
||||||
|
Please fill out the information below to expedite the review and (hopefully)
|
||||||
|
merge of your pull request!
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!-- What changes are being made? (What feature/bug is being fixed here?) -->
|
||||||
|
|
||||||
|
**What**:
|
||||||
|
|
||||||
|
<!-- Why are these changes necessary? -->
|
||||||
|
|
||||||
|
**Why**:
|
||||||
|
|
||||||
|
<!-- How were these changes implemented? -->
|
||||||
|
|
||||||
|
**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? -->
|
||||||
|
|
||||||
|
<!-- feel free to add additional comments -->
|
||||||
21
.github/labeler.yml
vendored
Normal file
21
.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
test:
|
||||||
|
- test/**/*
|
||||||
|
|
||||||
|
documentation:
|
||||||
|
- www/**/*
|
||||||
|
- ./**/*.md
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- src/providers/**/*
|
||||||
|
- www/docs/configuration/providers.md
|
||||||
|
- test/integration/**/*
|
||||||
|
|
||||||
|
adapters:
|
||||||
|
- src/adapters/**/*
|
||||||
|
- www/docs/schemas/adapters.md
|
||||||
|
|
||||||
|
databases:
|
||||||
|
- www/docs/schemas/*.md
|
||||||
|
- test/docker/databases/**/*
|
||||||
|
- www/docs/configuration/databases.md
|
||||||
|
- test/fixtures/**/*
|
||||||
8
.github/stale.yml
vendored
8
.github/stale.yml
vendored
@@ -8,15 +8,17 @@ exemptLabels:
|
|||||||
- security
|
- security
|
||||||
- priority
|
- priority
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: stale
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
Hi there! It looks like this issue hasn't had any activity for a while.
|
Hi there! It looks like this issue hasn't had any activity for a while.
|
||||||
It will be closed if no further activity occurs. If you think your issue
|
It will be closed if no further activity occurs. If you think your issue
|
||||||
is still relevant, feel free to comment on it to keep ot open. Thanks!
|
is still relevant, feel free to comment on it to keep it open. (Read more at #912)
|
||||||
|
Thanks!
|
||||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
closeComment: >
|
closeComment: >
|
||||||
Hi there! It looks like this issue hasn't had any activity for a while.
|
Hi there! It looks like this issue hasn't had any activity for a while.
|
||||||
To keep things tidy, I am going to close this issue for now.
|
To keep things tidy, I am going to close this issue for now.
|
||||||
If you think your issue is still relevant, just leave a comment
|
If you think your issue is still relevant, just leave a comment
|
||||||
and I will reopen it. Thanks!
|
and I will reopen it. (Read more at #912)
|
||||||
|
Thanks!
|
||||||
|
|||||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
@@ -4,9 +4,13 @@ name: Build Test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- canary
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
- canary
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
4
.github/workflows/integration.yml
vendored
4
.github/workflows/integration.yml
vendored
@@ -2,9 +2,9 @@ name: Integration Test
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, canary ]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main, canary ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
12
.github/workflows/labeler.yml
vendored
Normal file
12
.github/workflows/labeler.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
name: "Pull Request Labeler"
|
||||||
|
on:
|
||||||
|
- pull_request_target
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
triage:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/labeler@main
|
||||||
|
with:
|
||||||
|
repo-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
sync-labels: true
|
||||||
34
.github/workflows/npm-publish.yml
vendored
34
.github/workflows/npm-publish.yml
vendored
@@ -1,34 +0,0 @@
|
|||||||
# Publishes module to registry when a new release is created.
|
|
||||||
# The following secrets need to be configured for this workflow:
|
|
||||||
# * NPM_TOKEN - Auth token from npmjs.com
|
|
||||||
name: Publish to NPM
|
|
||||||
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [created]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 12
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run build
|
|
||||||
- run: npm run lint
|
|
||||||
|
|
||||||
publish-npm:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 12
|
|
||||||
registry-url: https://registry.npmjs.org/
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm publish
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
||||||
30
.github/workflows/release.yml
vendored
Normal file
30
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- canary
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v1
|
||||||
|
with:
|
||||||
|
node-version: 12
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
- name: Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
|
run: npx semantic-release
|
||||||
39
.releaserc.json
Normal file
39
.releaserc.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"branches": [
|
||||||
|
"main",
|
||||||
|
{ "name": "canary", "prerelease": true }
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
["@semantic-release/commit-analyzer", {
|
||||||
|
"preset": "conventionalcommits",
|
||||||
|
"releaseRules": [
|
||||||
|
{ "breaking": true, "release": "major" },
|
||||||
|
{ "revert": true, "release": "patch" },
|
||||||
|
{ "type": "feat", "release": "minor" },
|
||||||
|
{ "type": "fix", "release": "patch" },
|
||||||
|
{ "type": "perf", "release": "patch" },
|
||||||
|
{ "type": "docs", "release": "patch" }
|
||||||
|
]
|
||||||
|
}],
|
||||||
|
["@semantic-release/release-notes-generator", {
|
||||||
|
"preset": "conventionalcommits",
|
||||||
|
"presetConfig": {
|
||||||
|
"types": [
|
||||||
|
{ "type": "feat", "section": "Features", "hidden": false },
|
||||||
|
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
|
||||||
|
{ "type": "perf", "section": "Performance Improvements", "hidden": false },
|
||||||
|
{ "type": "revert", "section": "Reverts", "hidden": false },
|
||||||
|
{ "type": "docs", "section": "Documentation", "hidden": false },
|
||||||
|
{ "type": "style", "section": "Styles", "hidden": false },
|
||||||
|
{ "type": "chore", "section": "Miscellaneous Chores", "hidden": false },
|
||||||
|
{ "type": "refactor", "section": "Code Refactoring", "hidden": false },
|
||||||
|
{ "type": "test", "section": "Tests", "hidden": false },
|
||||||
|
{ "type": "build", "section": "Build System", "hidden": false },
|
||||||
|
{ "type": "ci", "section": "Continuous Integration", "hidden": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"@semantic-release/github",
|
||||||
|
"@semantic-release/npm"
|
||||||
|
]
|
||||||
|
}
|
||||||
5
CHANGELOG.md
Normal file
5
CHANGELOG.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# CHANGELOG
|
||||||
|
|
||||||
|
The changelog is automatically updated using
|
||||||
|
[semantic-release](https://github.com/semantic-release/semantic-release). You
|
||||||
|
can see it on the [releases page](../../releases).
|
||||||
156
CONTRIBUTING.md
156
CONTRIBUTING.md
@@ -8,115 +8,71 @@ Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates co
|
|||||||
|
|
||||||
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
|
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
|
||||||
|
|
||||||
## Pull Requests
|
## For contributors
|
||||||
|
|
||||||
* The latest changes are always in `main`
|
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 should be raised for larger changes
|
### Pull Requests
|
||||||
* Pull Requests do not need approval before merging for those with contributor access (it's just helpful to have them to track changes)
|
|
||||||
|
* The latest changes are always in `canary`, so please make your Pull Request against that branch.
|
||||||
|
* Pull Requests should be raised for any change
|
||||||
|
* Pull Requests need approval of a [core contributor](https://next-auth.js.org/contributors#core-team) before merging
|
||||||
* Rebasing in Pull Requests is preferred to keep a clean commit history (see below)
|
* Rebasing in Pull Requests is preferred to keep a clean commit history (see below)
|
||||||
* Running `npm run lint:fix` before committing can make resolving conflicts easier, but is not required
|
* Run `npm run lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this extension](https://marketplace.visualstudio.com/items?itemName=chenxsan.vscode-standardjs) to fix lint issues in development)
|
||||||
* Merge commits (and pushing merge commits to `main`) are disabled in this repo, but commits in PR can be squashed so this is not a blocker
|
* We encourage you to test your changes, and if you have the opportunity, please make those tests part of the Pull Request
|
||||||
* Pushing directly to main should ideally be reserved for minor updates (e.g. correcting typos) or small single-commit fixes
|
* If you add new functionality, please provide the corresponding documentation as well and make it part of the Pull Request
|
||||||
|
|
||||||
## Rebasing
|
### Setting up local environment
|
||||||
|
|
||||||
*If you don't rebase and end up with merge commits in a PR then it's not a blocker, we can always squash the commits when merging!*
|
A quick guide on how to setup *next-auth* locally to work on it and test out any changes:
|
||||||
|
|
||||||
If you create a branch and there are conflicting updates in the `main` branch, you can resolve them by rebasing from a check out of your branch:
|
|
||||||
|
|
||||||
git fetch
|
|
||||||
git rebase origin/main
|
|
||||||
|
|
||||||
If there are any conflicts, you can resolve them and stage the files, then run:
|
|
||||||
|
|
||||||
git rebase --continue
|
|
||||||
|
|
||||||
*If there are a lot of changes you may be prompted to step more than once.*
|
|
||||||
|
|
||||||
When the rebase is complete (i.e. there are no more conflicts) you should push your changes to your branch before doing anything else:
|
|
||||||
|
|
||||||
git push --force-with-lease
|
|
||||||
|
|
||||||
You should see that any conflicts in your PR are now resolved. You can review changes to make sure it contains changes you intended to make.
|
|
||||||
|
|
||||||
*If you accidentally sync before pushing, it will trigger a merge. You can use `git merge --abort` to undo the merge.*
|
|
||||||
|
|
||||||
You can use `npm run lint:fix` to automatically apply Standard JS rules to resolve formatting differences (tabs vs spaces, line endings, etc).
|
|
||||||
|
|
||||||
## Setting up local environment
|
|
||||||
|
|
||||||
A quick and dirty guide on how to setup *next-auth* locally to work on it and test out any changes:
|
|
||||||
|
|
||||||
1. Clone the repo:
|
1. Clone the repo:
|
||||||
|
```sh
|
||||||
|
git clone git@github.com:nextauthjs/next-auth.git
|
||||||
|
cd next-auth
|
||||||
|
```
|
||||||
|
|
||||||
git clone git@github.com:iaincollins/next-auth.git
|
2. Install packages:
|
||||||
cd next-auth/
|
```sh
|
||||||
|
npm i
|
||||||
|
```
|
||||||
|
|
||||||
2. Install packages and run the build command:
|
3. Populate `.env.local`:
|
||||||
|
|
||||||
|
Copy `.env.local.example` to `.env.local`, and add your env variables for each provider you want to test.
|
||||||
|
|
||||||
npm i
|
> NOTE: You can add any environment variables to .env.local that you would like to use in your dev app.
|
||||||
npm run build
|
> You can find the next-auth config under`pages/api/auth/[...nextauth].js`.
|
||||||
|
|
||||||
3. Link your project back to your local copy of next auth:
|
1. Start the dev application/server and CSS watching:
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
cd ../your-application
|
Your dev application will be available on ```http://localhost:3000```
|
||||||
npm link ../next-auth
|
|
||||||
|
|
||||||
4. Finally link React between the repo and the version installed in your project:
|
That's it! 🎉
|
||||||
|
|
||||||
cd ../next-auth
|
|
||||||
npm link ../your-application/node_modules/react
|
|
||||||
|
|
||||||
*This is an annoying step and not obvious, but is needed because of how React has been written (otherwise React crashes when you try to use the `useSession()` hook in your project).*
|
|
||||||
|
|
||||||
That's it!
|
|
||||||
|
|
||||||
Notes: You may need to repeat both `npm link` steps if you install / update additional dependencies with `npm i`.
|
|
||||||
|
|
||||||
If you need an example project to link to, you can use [next-auth-example](https://github.com/iaincollins/next-auth-example).
|
If you need an example project to link to, you can use [next-auth-example](https://github.com/iaincollins/next-auth-example).
|
||||||
|
|
||||||
### Hot reloading
|
#### Hot reloading
|
||||||
|
|
||||||
You might find it helpful to use the `npm run watch` command in the next-auth project, which will automatically (and silently) rebuild JS and CSS files as you edit them.
|
When running `npm run dev`, you start a Next.js dev server on `http://localhost:3000`, which includes hot reloading out of the box. Make changes on any of the files in `src` and see the changes immediately.
|
||||||
|
|
||||||
cd next-auth/
|
>NOTE: When working on CSS, you will need to manually refresh the page after changes. (Improving this through a PR is very welcome!)
|
||||||
npm run watch
|
|
||||||
|
|
||||||
If you are working on `next-auth/src/client/index.js` hot reloading will work as normal in your Next.js app.
|
#### Databases
|
||||||
|
|
||||||
However, if you are working on anything else (e.g. `next-auth/src/server/*` etc) then you will need to *stop and start* your app for changes to apply as **Next.js will not hot reload those changes by default**. To facilitate this, you can try [this webpack plugin](https://www.npmjs.com/package/webpack-clear-require-cache-plugin). Note that the `next.config.js` syntax in the plugin README may be out of date. It should look like this:
|
|
||||||
|
|
||||||
```
|
|
||||||
const clearRequireCachePlugin = require('webpack-clear-require-cache-plugin')
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
webpack: (config, {
|
|
||||||
buildId, dev, isServer, defaultLoaders, webpack,
|
|
||||||
}) => {
|
|
||||||
config.plugins.push(clearRequireCachePlugin([
|
|
||||||
/\.next\/server\/static\/development\/pages/,
|
|
||||||
/\.next\/server\/ssr-module-cache.js/,
|
|
||||||
/next-auth/,
|
|
||||||
]))
|
|
||||||
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Databases
|
|
||||||
|
|
||||||
Included is a Docker Compose file that starts up MySQL, Postgres, and MongoDB databases on localhost.
|
Included is a Docker Compose file that starts up MySQL, Postgres, and MongoDB databases on localhost.
|
||||||
|
|
||||||
It will use port 3306, 5432, and 27017 on localhost respectively; it will not work if are running existing databases on localhost.
|
It will use port `3306`, `5432`, and `27017` on localhost respectively; please make sure those ports are not used by other services on localhost.
|
||||||
|
|
||||||
You can start them with `npm run db:start` and stop them with `npm run db:stop`.
|
You can start them with `npm run db:start` and stop them with `npm run db:stop`.
|
||||||
|
|
||||||
You will need Docker installed to be able to start / stop the databases.
|
You will need Docker and Docker Compose installed to be able to start / stop the databases.
|
||||||
|
|
||||||
When stopping the databases, it will reset their contents.
|
When stopping the databases, it will reset their contents.
|
||||||
|
|
||||||
### Testing
|
#### Testing
|
||||||
|
|
||||||
Tests can be run with `npm run test`.
|
Tests can be run with `npm run test`.
|
||||||
|
|
||||||
@@ -125,3 +81,39 @@ Automated tests are currently crude and limited in functionality, but improvemen
|
|||||||
Currently, to run tests you need to first have started local test databases (e.g. using `npm run db:start`).
|
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.
|
The databases can take a few seconds to start up, so you might need to give it a minute before running the tests.
|
||||||
|
|
||||||
|
## For maintainers
|
||||||
|
|
||||||
|
We use [semantic-release](https://github.com/semantic-release/semantic-release) together with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to automate releases. This makes the maintainenance process easier and less error-prone to human error. Please study the "Conventional Commits" site to understand how to write a good commit message.
|
||||||
|
|
||||||
|
When accepting Pull Requests, make sure the following:
|
||||||
|
|
||||||
|
* Use "Squash and merge"
|
||||||
|
* Make sure you merge contributor PRs into `canary`
|
||||||
|
* Rewrite the commit message to conform to the `Conventional Commits` style. Check the "Recommended Scopes" section for further advice.
|
||||||
|
* Optionally link issues the PR will resolve (You can add "close" in front of the issue numbers to close the issues automatically, when the PR is merged. `semantic-release` will also comment back to connected issues and PRs, notifying the users that a feature is added/bug fixed, etc.)
|
||||||
|
|
||||||
|
### Recommended Scopes
|
||||||
|
|
||||||
|
A typical conventional commit looks like this:
|
||||||
|
```
|
||||||
|
type(scope): title
|
||||||
|
|
||||||
|
body
|
||||||
|
```
|
||||||
|
|
||||||
|
Scope is the part that will help groupping the different commit types in the release notes.
|
||||||
|
|
||||||
|
Some recommened scopes are:
|
||||||
|
|
||||||
|
- **provider** - Provider related changes. (eg.: "feat(provider): add X provider", "docs(provider): fix typo in X documentation"
|
||||||
|
- **adapter** - Adapter related changes. (eg.: "feat(adapter): add X provider", "docs(provider): fix typo in X documentation"
|
||||||
|
- **db** - Database related changes. (eg.: "feat(db): add X database", "docs(db): fix typo in X documentation"
|
||||||
|
- **deps** - Adding/removing/updating a dependency (eg.: "chore(deps): add X")
|
||||||
|
|
||||||
|
> NOTE: If you are not sure which scope to use, you can simply ignore it. (eg.: "feat: add something"). Adding the correct type already helps a lot when analyzing the commit messages.
|
||||||
|
|
||||||
|
|
||||||
|
### Skipping a release
|
||||||
|
|
||||||
|
Every commit that contains [skip release] or [release skip] in their message will be excluded from the commit analysis and won't participate in the release type determination. This is useful, if the PR being merged should not trigger a new `npm` release.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
ISC License
|
ISC License
|
||||||
|
|
||||||
Copyright (c) 2018-2020, Iain Collins
|
Copyright (c) 2018-2021, Iain Collins
|
||||||
|
|
||||||
Permission to use, copy, modify, and/or distribute this software for any
|
Permission to use, copy, modify, and/or distribute this software for any
|
||||||
purpose with or without fee is hereby granted, provided that the above
|
purpose with or without fee is hereby granted, provided that the above
|
||||||
95
README.md
95
README.md
@@ -1,7 +1,20 @@
|
|||||||
# NextAuth.js
|
<p align="center">
|
||||||
|
<br/>
|
||||||

|
<a href="https://next-auth.js.org" target="_blank"><img width="150px" src="https://next-auth.js.org/img/logo/logo-sm.png" /></a>
|
||||||

|
<h3 align="center">NextAuth.js</h3>
|
||||||
|
<p align="center">Authentication for Next.js</p>
|
||||||
|
<p align="center">
|
||||||
|
Open Source. Full Stack. Own Your Data.
|
||||||
|
</p>
|
||||||
|
<p align="center" style="align: center;">
|
||||||
|
<img src="https://github.com/nextauthjs/next-auth/workflows/Build%20Test/badge.svg" alt="Build Test" />
|
||||||
|
<img src="https://github.com/nextauthjs/next-auth/workflows/Integration%20Test/badge.svg" alt="Integration Test" />
|
||||||
|
<img src="https://img.shields.io/bundlephobia/minzip/next-auth" alt="Bundle Size"/>
|
||||||
|
<img src="https://img.shields.io/npm/dm/next-auth" alt="Downloads" />
|
||||||
|
<img src="https://img.shields.io/github/stars/nextauthjs/next-auth" alt="Github Stars" />
|
||||||
|
<img src="https://img.shields.io/github/v/release/nextauthjs/next-auth?include_prereleases" alt="Github Release" />
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -9,9 +22,15 @@ NextAuth.js is a complete open source authentication solution for [Next.js](http
|
|||||||
|
|
||||||
It is designed from the ground up to support Next.js and Serverless.
|
It is designed from the ground up to support Next.js and Serverless.
|
||||||
|
|
||||||
[Follow the examples](https://next-auth.js.org/getting-started/example) to see how easy it is to use NextAuth.js for authentication.
|
## Getting Started
|
||||||
|
|
||||||
Install: `npm i next-auth`
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
We also have a section of [tutorials](https://next-auth.js.org/tutorials) for those looking for more specific examples.
|
||||||
|
|
||||||
See [next-auth.js.org](https://next-auth.js.org) for more information and documentation.
|
See [next-auth.js.org](https://next-auth.js.org) for more information and documentation.
|
||||||
|
|
||||||
@@ -52,13 +71,15 @@ Advanced options allow you to define your own routines to handle controlling wha
|
|||||||
|
|
||||||
### Typescript
|
### Typescript
|
||||||
|
|
||||||
This library gained Typescript support recently. You can install the types in the following way:
|
You can install the appropriate types via the following command:
|
||||||
```
|
|
||||||
$ npm i -D @types/next-auth
|
|
||||||
```
|
|
||||||
In you encounter any issue with them, please raise an issue and add the "typescript" label to it, we'll try to help you with it as soon as possible.
|
|
||||||
|
|
||||||
Alternatively you can raise a PR directly with your fixes on [**DefinitelyTyped**](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth).
|
```
|
||||||
|
npm install --save-dev @types/next-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
If you encounter any problems with the types package, please create an issue and add the `typescript` label to it.
|
||||||
|
|
||||||
|
Alternatively, you can open a pull request directly with your fixes on the [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth) repository, where you'll find a `next-auth` subfolder.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
@@ -68,7 +89,7 @@ Alternatively you can raise a PR directly with your fixes on [**DefinitelyTyped*
|
|||||||
import NextAuth from 'next-auth'
|
import NextAuth from 'next-auth'
|
||||||
import Providers from 'next-auth/providers'
|
import Providers from 'next-auth/providers'
|
||||||
|
|
||||||
const options = {
|
export default NextAuth({
|
||||||
providers: [
|
providers: [
|
||||||
// OAuth authentication providers
|
// OAuth authentication providers
|
||||||
Providers.Apple({
|
Providers.Apple({
|
||||||
@@ -87,45 +108,43 @@ const options = {
|
|||||||
],
|
],
|
||||||
// SQL or MongoDB database (or leave empty)
|
// SQL or MongoDB database (or leave empty)
|
||||||
database: process.env.DATABASE_URL
|
database: process.env.DATABASE_URL
|
||||||
}
|
})
|
||||||
|
|
||||||
export default (req, res) => NextAuth(req, res, options)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Add React Component
|
### Add React Component
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
import React from 'react'
|
import {
|
||||||
import {
|
useSession, signIn, signOut
|
||||||
useSession,
|
|
||||||
signin,
|
|
||||||
signout
|
|
||||||
} from 'next-auth/client'
|
} from 'next-auth/client'
|
||||||
|
|
||||||
export default function myComponent() {
|
export default function Component() {
|
||||||
const [ session, loading ] = useSession()
|
const [ session, loading ] = useSession()
|
||||||
|
if(session) {
|
||||||
return <p>
|
return <>
|
||||||
{!session && <>
|
|
||||||
Not signed in <br/>
|
|
||||||
<button onClick={signin}>Sign in</button>
|
|
||||||
</>}
|
|
||||||
{session && <>
|
|
||||||
Signed in as {session.user.email} <br/>
|
Signed in as {session.user.email} <br/>
|
||||||
<button onClick={signout}>Sign out</button>
|
<button onClick={() => signOut()}>Sign out</button>
|
||||||
</>}
|
</>
|
||||||
</p>
|
}
|
||||||
|
return <>
|
||||||
|
Not signed in <br/>
|
||||||
|
<button onClick={() => signIn()}>Sign in</button>
|
||||||
|
</>
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Acknowledgement
|
## Acknowledgements
|
||||||
|
|
||||||
[NextAuth.js is possible thanks to its contributors.](https://next-auth.js.org/contributors)
|
[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors)
|
||||||
|
|
||||||
## Getting started
|
<a href="https://github.com/nextauthjs/next-auth/graphs/contributors">
|
||||||
|
<img width="500px" src="https://contrib.rocks/image?repo=nextauthjs/next-auth" />
|
||||||
[Follow the examples to get started.](https://next-auth.js.org/getting-started/example)
|
</a>
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
If you'd like to contribute to you can find useful information in our [Contributing Guide](https://github.com/iaincollins/next-auth/blob/main/CONTRIBUTING.md).
|
We're open to all community contributions! If you'd like to contribute in any way, please first read our [Contributing Guide](https://github.com/nextauthjs/next-auth/blob/canary/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
ISC
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"presets": [
|
|
||||||
["@babel/preset-env", { "targets": { "esmodules": true } } ]
|
|
||||||
],
|
|
||||||
"comments": false,
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"test": [ "./src/server/pages/**" ],
|
|
||||||
"presets": [ "preact" ]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
16
components/access-denied.js
Normal file
16
components/access-denied.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { signIn } from 'next-auth/client'
|
||||||
|
|
||||||
|
export default function AccessDenied () {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Access Denied</h1>
|
||||||
|
<p>
|
||||||
|
<a href="/api/auth/signin"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
signIn()
|
||||||
|
}}>You must be signed in to view this page</a>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
18
components/footer.js
Normal file
18
components/footer.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import styles from './footer.module.css'
|
||||||
|
import { version } from 'package.json'
|
||||||
|
|
||||||
|
export default function Footer () {
|
||||||
|
return (
|
||||||
|
<footer className={styles.footer}>
|
||||||
|
<hr />
|
||||||
|
<ul className={styles.navItems}>
|
||||||
|
<li className={styles.navItem}><a href='https://next-auth.js.org'>Documentation</a></li>
|
||||||
|
<li className={styles.navItem}><a href='https://www.npmjs.com/package/next-auth'>NPM</a></li>
|
||||||
|
<li className={styles.navItem}><a href='https://github.com/nextauthjs/next-auth-example'>GitHub</a></li>
|
||||||
|
<li className={styles.navItem}><Link href='/policy'><a>Policy</a></Link></li>
|
||||||
|
<li className={styles.navItem}><em>{version}</em></li>
|
||||||
|
</ul>
|
||||||
|
</footer>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
components/footer.module.css
Normal file
14
components/footer.module.css
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.footer {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItems {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
103
components/header.js
Normal file
103
components/header.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import { signIn, signOut, useSession } from 'next-auth/client'
|
||||||
|
import * as client from 'next-auth/client'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
|
||||||
|
// The approach used in this component shows how to built a sign in and sign out
|
||||||
|
// component that works on pages which support both client and server side
|
||||||
|
// rendering, and avoids any flash incorrect content on initial page load.
|
||||||
|
export default function Header () {
|
||||||
|
const [session, loading] = useSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<noscript>
|
||||||
|
<style>{'.nojs-show { opacity: 1; top: 0; }'}</style>
|
||||||
|
</noscript>
|
||||||
|
<div className={styles.signedInStatus}>
|
||||||
|
<p
|
||||||
|
className={`nojs-show ${
|
||||||
|
!session && loading ? styles.loading : styles.loaded
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!session && (
|
||||||
|
<>
|
||||||
|
<span className={styles.notSignedInText}>
|
||||||
|
You are not signed in
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/api/auth/signin"
|
||||||
|
className={styles.buttonPrimary}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
signIn('github')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{session && (
|
||||||
|
<>
|
||||||
|
{session.user.image && (
|
||||||
|
<span
|
||||||
|
style={{ backgroundImage: `url(${session.user.image})` }}
|
||||||
|
className={styles.avatar}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className={styles.signedInText}>
|
||||||
|
<small>Signed in as</small>
|
||||||
|
<br />
|
||||||
|
<strong>{session.user.email || session.user.name}</strong>
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="/api/auth/signout"
|
||||||
|
className={styles.button}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
signOut()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<ul className={styles.navItems}>
|
||||||
|
<li className={styles.navItem}>
|
||||||
|
<Link href='/'>
|
||||||
|
<a>Home</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className={styles.navItem}>
|
||||||
|
<Link href='/client'>
|
||||||
|
<a>Client</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className={styles.navItem}>
|
||||||
|
<Link href='/server'>
|
||||||
|
<a>Server</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className={styles.navItem}>
|
||||||
|
<Link href='/protected'>
|
||||||
|
<a>Protected</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className={styles.navItem}>
|
||||||
|
<Link href='/protected-ssr'>
|
||||||
|
<a>Protected(SSR)</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li className={styles.navItem}>
|
||||||
|
<Link href='/api-example'>
|
||||||
|
<a>API</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
components/header.module.css
Normal file
92
components/header.module.css
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/* Set min-height to avoid page reflow while session loading */
|
||||||
|
.signedInStatus {
|
||||||
|
display: block;
|
||||||
|
min-height: 4rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.loaded {
|
||||||
|
position: relative;
|
||||||
|
top: 0;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 0 0 .6rem .6rem;
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
margin: 0;
|
||||||
|
background-color: rgba(0,0,0,.05);
|
||||||
|
transition: all 0.2s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
top: -2rem;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signedInText,
|
||||||
|
.notSignedInText {
|
||||||
|
position: absolute;
|
||||||
|
padding-top: .8rem;
|
||||||
|
left: 1rem;
|
||||||
|
right: 6.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inherit;
|
||||||
|
z-index: 1;
|
||||||
|
line-height: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signedInText {
|
||||||
|
padding-top: 0rem;
|
||||||
|
left: 4.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
border-radius: 2rem;
|
||||||
|
float: left;
|
||||||
|
height: 2.8rem;
|
||||||
|
width: 2.8rem;
|
||||||
|
background-color: white;
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button,
|
||||||
|
.buttonPrimary {
|
||||||
|
float: right;
|
||||||
|
margin-right: -.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: .3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4rem;
|
||||||
|
padding: .7rem .8rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary {
|
||||||
|
background-color: #346df1;
|
||||||
|
border-color: #346df1;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: .7rem 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary:hover {
|
||||||
|
box-shadow: inset 0 0 5rem rgba(0,0,0,0.2)
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItems {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
14
components/layout.js
Normal file
14
components/layout.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Header from 'components/header'
|
||||||
|
import Footer from 'components/footer'
|
||||||
|
|
||||||
|
export default function Layout ({ children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
config/babel.config.json
Normal file
12
config/babel.config.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
["@babel/preset-env", { "targets": { "esmodules": true } }]
|
||||||
|
],
|
||||||
|
"comments": false,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"test": ["../src/server/pages/**"],
|
||||||
|
"presets": ["preact"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Serverless target in Next.js does not work if you try to read in files at runtime
|
// Serverless target in Next.js does not work if you try to read in files at runtime
|
||||||
// that are not JavaScript or JSON (e.g. CSS files).
|
// that are not JavaScript or JSON (e.g. CSS files).
|
||||||
// https://github.com/iaincollins/next-auth/issues/281
|
// https://github.com/nextauthjs/next-auth/issues/281
|
||||||
//
|
//
|
||||||
// To work around this issue, this script is a manual step that wraps CSS in a
|
// To work around this issue, this script is a manual step that wraps CSS in a
|
||||||
// JavaScript file that has the compiled CSS embedded in it, and exports only
|
// JavaScript file that has the compiled CSS embedded in it, and exports only
|
||||||
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"next-auth/*": ["./src/*"],
|
||||||
|
"next-auth": ["./src"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10015
package-lock.json
generated
10015
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "next-auth",
|
"name": "next-auth",
|
||||||
"version": "3.1.0",
|
"version": "0.0.0-semantically-released",
|
||||||
"description": "Authentication for Next.js",
|
"description": "Authentication for Next.js",
|
||||||
"homepage": "https://next-auth.js.org",
|
"homepage": "https://next-auth.js.org",
|
||||||
"repository": "https://github.com/nextauthjs/next-auth.git",
|
"repository": "https://github.com/nextauthjs/next-auth.git",
|
||||||
@@ -8,20 +8,22 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build:js && npm run build:css",
|
"build": "npm run build:js && npm run build:css",
|
||||||
"build:js": "babel src --out-dir dist",
|
"build:js": "babel --config-file ./config/babel.config.json src --out-dir dist",
|
||||||
"build:css": "postcss src/**/*.css --base src --dir dist && node scripts/wrap-css.js",
|
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
|
||||||
|
"dev": "next | npm run watch:css",
|
||||||
"watch": "npm run watch:js | npm run watch:css",
|
"watch": "npm run watch:js | npm run watch:css",
|
||||||
"watch:js": "babel --watch src --out-dir dist",
|
"watch:js": "babel --config-file ./config/babel.config.json --watch src --out-dir dist",
|
||||||
"watch:css": "postcss --watch src/**/*.css --base src --dir dist",
|
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
|
||||||
"test:app:start": "docker-compose -f test/docker/app.yml up -d",
|
"test:app: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: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:app:stop": "docker-compose -f test/docker/app.yml down",
|
||||||
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop",
|
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop",
|
||||||
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql",
|
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql && npm run test:db:fauna",
|
||||||
"test:db:mysql": "node test/mysql.js",
|
"test:db:mysql": "node test/mysql.js",
|
||||||
"test:db:postgres": "node test/postgres.js",
|
"test:db:postgres": "node test/postgres.js",
|
||||||
"test:db:mongodb": "node test/mongodb.js",
|
"test:db:mongodb": "node test/mongodb.js",
|
||||||
"test:db:mssql": "node test/mssql.js",
|
"test:db:mssql": "node test/mssql.js",
|
||||||
|
"test:db:fauna": "node test/fauna.js",
|
||||||
"test:integration": "mocha test/integration",
|
"test:integration": "mocha test/integration",
|
||||||
"db:start": "docker-compose -f test/docker/databases.yml up -d",
|
"db:start": "docker-compose -f test/docker/databases.yml up -d",
|
||||||
"db:stop": "docker-compose -f test/docker/databases.yml down",
|
"db:stop": "docker-compose -f test/docker/databases.yml down",
|
||||||
@@ -42,11 +44,11 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"crypto-js": "^4.0.0",
|
"crypto-js": "^4.0.0",
|
||||||
|
"faunadb": "^3.0.1",
|
||||||
"futoin-hkdf": "^1.3.2",
|
"futoin-hkdf": "^1.3.2",
|
||||||
"jose": "^1.27.2",
|
"jose": "^1.27.2",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"jwt-decode": "^2.2.0",
|
"nodemailer": "^6.4.16",
|
||||||
"nodemailer": "^6.4.6",
|
|
||||||
"oauth": "^0.9.15",
|
"oauth": "^0.9.15",
|
||||||
"preact": "^10.4.1",
|
"preact": "^10.4.1",
|
||||||
"preact-render-to-string": "^5.1.7",
|
"preact-render-to-string": "^5.1.7",
|
||||||
@@ -55,39 +57,49 @@
|
|||||||
"typeorm": "^0.2.24"
|
"typeorm": "^0.2.24"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1 || ^17",
|
||||||
"react-dom": "^16.13.1"
|
"react-dom": "^16.13.1 || ^17"
|
||||||
},
|
},
|
||||||
"peerOptionalDependencies": {
|
"peerOptionalDependencies": {
|
||||||
"mongodb": "^3.5.9",
|
"mongodb": "^3.5.9",
|
||||||
"mysql": "^2.18.1",
|
"mysql": "^2.18.1",
|
||||||
"mssql": "^6.2.1",
|
"mssql": "^6.2.1",
|
||||||
"pg": "^8.2.1",
|
"pg": "^8.2.1",
|
||||||
"@prisma/client": "^2.3.0"
|
"@prisma/client": "^2.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.8.4",
|
"@babel/cli": "^7.8.4",
|
||||||
"@babel/core": "^7.9.6",
|
"@babel/core": "^7.9.6",
|
||||||
"@babel/preset-env": "^7.9.6",
|
"@babel/preset-env": "^7.9.6",
|
||||||
|
"@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",
|
||||||
"autoprefixer": "^9.7.6",
|
"autoprefixer": "^9.7.6",
|
||||||
"babel-preset-preact": "^2.0.0",
|
"babel-preset-preact": "^2.0.0",
|
||||||
|
"conventional-changelog-conventionalcommits": "4.4.0",
|
||||||
"cssnano": "^4.1.10",
|
"cssnano": "^4.1.10",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
"mocha": "^8.1.3",
|
"mocha": "^8.1.3",
|
||||||
"mongodb": "^3.5.9",
|
"mongodb": "^3.5.9",
|
||||||
"mssql": "^6.2.1",
|
"mssql": "^6.2.1",
|
||||||
"mysql": "^2.18.1",
|
"mysql": "^2.18.1",
|
||||||
|
"next": "^10.0.5",
|
||||||
"pg": "^8.2.1",
|
"pg": "^8.2.1",
|
||||||
"postcss-cli": "^7.1.1",
|
"postcss-cli": "^7.1.1",
|
||||||
"postcss-nested": "^4.2.1",
|
"postcss-nested": "^4.2.1",
|
||||||
"puppeteer": "^5.2.1",
|
"puppeteer": "^5.2.1",
|
||||||
"puppeteer-extra": "^3.1.15",
|
"puppeteer-extra": "^3.1.15",
|
||||||
"puppeteer-extra-plugin-stealth": "^2.6.1",
|
"puppeteer-extra-plugin-stealth": "^2.6.1",
|
||||||
"standard": "^14.3.3"
|
"react": "^17.0.1",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"standard": "^16.0.3"
|
||||||
},
|
},
|
||||||
"standard": {
|
"standard": {
|
||||||
"ignore": [
|
"ignore": [
|
||||||
"test/"
|
"test/",
|
||||||
|
"pages/",
|
||||||
|
"components/"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
pages/_app.js
Normal file
31
pages/_app.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Provider } from 'next-auth/client'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
// Use the <Provider> to improve performance and allow components that call
|
||||||
|
// `useSession()` anywhere in your application to access the `session` object.
|
||||||
|
export default function App ({ Component, pageProps }) {
|
||||||
|
return (
|
||||||
|
<Provider
|
||||||
|
// Provider options are not required but can be useful in situations where
|
||||||
|
// you have a short session maxAge time. Shown here with default values.
|
||||||
|
options={{
|
||||||
|
// Client Max Age controls how often the useSession in the client should
|
||||||
|
// contact the server to sync the session state. Value in seconds.
|
||||||
|
// e.g.
|
||||||
|
// * 0 - Disabled (always use cache value)
|
||||||
|
// * 60 - Sync session state with server if it's older than 60 seconds
|
||||||
|
clientMaxAge: 0,
|
||||||
|
// Keep Alive tells windows / tabs that are signed in to keep sending
|
||||||
|
// a keep alive request (which extends the current session expiry) to
|
||||||
|
// prevent sessions in open windows from expiring. Value in seconds.
|
||||||
|
//
|
||||||
|
// Note: If a session has expired when keep alive is triggered, all open
|
||||||
|
// windows / tabs will be updated to reflect the user is signed out.
|
||||||
|
keepAlive: 0
|
||||||
|
}}
|
||||||
|
session={pageProps.session}
|
||||||
|
>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
pages/api-example.js
Normal file
17
pages/api-example.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Layout from '../components/layout'
|
||||||
|
|
||||||
|
export default function Page () {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<h1>API Example</h1>
|
||||||
|
<p>The examples below show responses from the example API endpoints.</p>
|
||||||
|
<p><em>You must be signed in to see responses.</em></p>
|
||||||
|
<h2>Session</h2>
|
||||||
|
<p>/api/examples/session</p>
|
||||||
|
<iframe src="/api/examples/session"/>
|
||||||
|
<h2>JSON Web Token</h2>
|
||||||
|
<p>/api/examples/jwt</p>
|
||||||
|
<iframe src="/api/examples/jwt"/>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
82
pages/api/auth/[...nextauth].js
Normal file
82
pages/api/auth/[...nextauth].js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import NextAuth from "next-auth"
|
||||||
|
import Providers from "next-auth/providers"
|
||||||
|
|
||||||
|
export default NextAuth({
|
||||||
|
// https://next-auth.js.org/configuration/providers
|
||||||
|
providers: [
|
||||||
|
Providers.GitHub({
|
||||||
|
clientId: process.env.GITHUB_ID,
|
||||||
|
clientSecret: process.env.GITHUB_SECRET,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
|
||||||
|
// https://next-auth.js.org/configuration/databases
|
||||||
|
//
|
||||||
|
// Notes:
|
||||||
|
// * You must to install an appropriate node_module for your database
|
||||||
|
// * The Email provider requires a database (OAuth providers do not)
|
||||||
|
|
||||||
|
// The secret should be set to a reasonably long random string.
|
||||||
|
// It is used to sign cookies and to sign and encrypt JSON Web Tokens, unless
|
||||||
|
// a separate secret is defined explicitly for encrypting the JWT.
|
||||||
|
|
||||||
|
session: {
|
||||||
|
// Use JSON Web Tokens for session instead of database sessions.
|
||||||
|
// This option can be used with or without a database for users/accounts.
|
||||||
|
// Note: `jwt` is automatically set to `true` if no database is specified.
|
||||||
|
jwt: true,
|
||||||
|
|
||||||
|
// Seconds - How long until an idle session expires and is no longer valid.
|
||||||
|
// maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||||
|
|
||||||
|
// Seconds - Throttle how frequently to write to database to extend a session.
|
||||||
|
// Use it to limit write operations. Set to 0 to always update the database.
|
||||||
|
// Note: This option is ignored if using JSON Web Tokens
|
||||||
|
// updateAge: 24 * 60 * 60, // 24 hours
|
||||||
|
},
|
||||||
|
|
||||||
|
// JSON Web tokens are only used for sessions if the `jwt: true` session
|
||||||
|
// option is set - or by default if no database is specified.
|
||||||
|
// https://next-auth.js.org/configuration/options#jwt
|
||||||
|
jwt: {
|
||||||
|
encryption: true,
|
||||||
|
secret: process.env.SECRET,
|
||||||
|
// A secret to use for key generation (you should set this explicitly)
|
||||||
|
// secret: 'INp8IvdIyeMcoGAgFGoA61DdBglwwSqnXJZkgz8PSnw',
|
||||||
|
// Set to true to use encryption (default: false)
|
||||||
|
// encryption: true,
|
||||||
|
// You can define your own encode/decode functions for signing and encryption
|
||||||
|
// if you want to override the default behaviour.
|
||||||
|
// encode: async ({ secret, token, maxAge }) => {},
|
||||||
|
// decode: async ({ secret, token, maxAge }) => {},
|
||||||
|
},
|
||||||
|
|
||||||
|
// You can define custom pages to override the built-in pages.
|
||||||
|
// The routes shown here are the default URLs that will be used when a custom
|
||||||
|
// pages is not specified for that route.
|
||||||
|
// https://next-auth.js.org/configuration/pages
|
||||||
|
pages: {
|
||||||
|
// signIn: '/api/auth/signin', // Displays signin buttons
|
||||||
|
// signOut: '/api/auth/signout', // Displays form with sign out button
|
||||||
|
// error: '/api/auth/error', // Error code passed in query string as ?error=
|
||||||
|
// verifyRequest: '/api/auth/verify-request', // Used for check email page
|
||||||
|
// newUser: null // If set, new users will be directed here on first sign in
|
||||||
|
},
|
||||||
|
|
||||||
|
// Callbacks are asynchronous functions you can use to control what happens
|
||||||
|
// when an action is performed.
|
||||||
|
// https://next-auth.js.org/configuration/callbacks
|
||||||
|
callbacks: {
|
||||||
|
// signIn: async (user, account, profile) => { return Promise.resolve(true) },
|
||||||
|
// redirect: async (url, baseUrl) => { return Promise.resolve(baseUrl) },
|
||||||
|
// session: async (session, user) => { return Promise.resolve(session) },
|
||||||
|
// jwt: async (token, user, account, profile, isNewUser) => { return Promise.resolve(token) }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Events are useful for logging
|
||||||
|
// https://next-auth.js.org/configuration/events
|
||||||
|
events: {},
|
||||||
|
|
||||||
|
// Enable debug messages in the console if you are having problems
|
||||||
|
debug: false,
|
||||||
|
})
|
||||||
9
pages/api/examples/jwt.js
Normal file
9
pages/api/examples/jwt.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// This is an example of how to read a JSON Web Token from an API route
|
||||||
|
import jwt from "next-auth/jwt"
|
||||||
|
|
||||||
|
const secret = process.env.SECRET
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
const token = await jwt.getToken({ req, secret, encryption: true })
|
||||||
|
res.send(JSON.stringify(token, null, 2))
|
||||||
|
}
|
||||||
12
pages/api/examples/protected.js
Normal file
12
pages/api/examples/protected.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// This is an example of to protect an API route
|
||||||
|
import { getSession } from 'next-auth/client'
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
const session = await getSession({ req })
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
res.send({ content: 'This is protected content. You can access this content because you are signed in.' })
|
||||||
|
} else {
|
||||||
|
res.send({ error: 'You must be sign in to view the protected content on this page.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
7
pages/api/examples/session.js
Normal file
7
pages/api/examples/session.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// This is an example of how to access a session from an API route
|
||||||
|
import { getSession } from 'next-auth/client'
|
||||||
|
|
||||||
|
export default async (req, res) => {
|
||||||
|
const session = await getSession({ req })
|
||||||
|
res.send(JSON.stringify(session, null, 2))
|
||||||
|
}
|
||||||
22
pages/client.js
Normal file
22
pages/client.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Layout from '../components/layout'
|
||||||
|
|
||||||
|
export default function Page () {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<h1>Client Side Rendering</h1>
|
||||||
|
<p>
|
||||||
|
This page uses the <strong>useSession()</strong> React Hook in the <strong></Header></strong> component.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The <strong>useSession()</strong> React Hook easy to use and allows pages to render very quickly.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The advantage of this approach is that session state is shared between pages by using the <strong>Provider</strong> in <strong>_app.js</strong> so
|
||||||
|
that navigation between pages using <strong>useSession()</strong> is very fast.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The disadvantage of <strong>useSession()</strong> is that it requires client side JavaScript.
|
||||||
|
</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
pages/index.js
Normal file
12
pages/index.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import Layout from 'components/layout'
|
||||||
|
|
||||||
|
export default function Page () {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<h1>NextAuth.js Example</h1>
|
||||||
|
<p>
|
||||||
|
This is an example site to demonstrate how to use <a href='https://next-auth.js.org'>NextAuth.js</a> for authentication.
|
||||||
|
</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
30
pages/policy.js
Normal file
30
pages/policy.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import Layout from '../components/layout'
|
||||||
|
|
||||||
|
export default function Page () {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<p>
|
||||||
|
This is an example site to demonstrate how to use <a href={`https://next-auth.js.org`}>NextAuth.js</a> for authentication.
|
||||||
|
</p>
|
||||||
|
<h2>Terms of Service</h2>
|
||||||
|
<p>
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
</p>
|
||||||
|
<h2>Privacy Policy</h2>
|
||||||
|
<p>
|
||||||
|
This site uses JSON Web Tokens and an in-memory database which resets every ~2 hours.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Data provided to this site is exclusively used to support signing in
|
||||||
|
and is not passed to any third party services, other than via SMTP or OAuth for the
|
||||||
|
purposes of authentication.
|
||||||
|
</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
pages/protected-ssr.js
Normal file
37
pages/protected-ssr.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// This is an example of how to protect content using server rendering
|
||||||
|
import { getSession } from 'next-auth/client'
|
||||||
|
import Layout from '../components/layout'
|
||||||
|
import AccessDenied from '../components/access-denied'
|
||||||
|
|
||||||
|
export default function Page ({ content, session }) {
|
||||||
|
// If no session exists, display access denied message
|
||||||
|
if (!session) { return <Layout><AccessDenied/></Layout> }
|
||||||
|
|
||||||
|
// If session exists, display content
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<h1>Protected Page</h1>
|
||||||
|
<p><strong>{content}</strong></p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServerSideProps(context) {
|
||||||
|
const session = await getSession(context)
|
||||||
|
let content = null
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
const hostname = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||||
|
const options = { headers: { cookie: context.req.headers.cookie } }
|
||||||
|
const res = await fetch(`${hostname}/api/examples/protected`, options)
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.content) { content = json.content }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
session,
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
pages/protected.js
Normal file
33
pages/protected.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSession } from 'next-auth/client'
|
||||||
|
import Layout from '../components/layout'
|
||||||
|
import AccessDenied from '../components/access-denied'
|
||||||
|
|
||||||
|
export default function Page () {
|
||||||
|
const [ session, loading ] = useSession()
|
||||||
|
const [ content , setContent ] = useState()
|
||||||
|
|
||||||
|
// Fetch content from protected route
|
||||||
|
useEffect(()=>{
|
||||||
|
const fetchData = async () => {
|
||||||
|
const res = await fetch('/api/examples/protected')
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.content) { setContent(json.content) }
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
},[session])
|
||||||
|
|
||||||
|
// When rendering client side don't display anything until loading is complete
|
||||||
|
if (typeof window !== 'undefined' && loading) return null
|
||||||
|
|
||||||
|
// If no session exists, display access denied message
|
||||||
|
if (!session) { return <Layout><AccessDenied/></Layout> }
|
||||||
|
|
||||||
|
// If session exists, display content
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<h1>Protected Page</h1>
|
||||||
|
<p><strong>{content}</strong></p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
pages/server.js
Normal file
38
pages/server.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { useSession, getSession } from 'next-auth/client'
|
||||||
|
import Layout from '../components/layout'
|
||||||
|
|
||||||
|
export default function Page () {
|
||||||
|
// As this page uses Server Side Rendering, the `session` will be already
|
||||||
|
// populated on render without needing to go through a loading stage.
|
||||||
|
// This is possible because of the shared context configured in `_app.js` that
|
||||||
|
// is used by `useSession()`.
|
||||||
|
const [ session, loading ] = useSession()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<h1>Server Side Rendering</h1>
|
||||||
|
<p>
|
||||||
|
This page uses the universal <strong>getSession()</strong> method in <strong>getServerSideProps()</strong>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Using <strong>getSession()</strong> in <strong>getServerSideProps()</strong> is the recommended approach if you need to
|
||||||
|
support Server Side Rendering with authentication.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The advantage of Server Side Rendering is this page does not require client side JavaScript.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The disadvantage of Server Side Rendering is that this page is slower to render.
|
||||||
|
</p>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the `session` prop to use sessions with Server Side Rendering
|
||||||
|
export async function getServerSideProps(context) {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
session: await getSession(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
pages/styles.css
Normal file
30
pages/styles.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
body {
|
||||||
|
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
li,
|
||||||
|
p {
|
||||||
|
line-height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
background: #ccc;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
height: 10rem;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: .5rem;
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
519
src/adapters/fauna/index.js
Normal file
519
src/adapters/fauna/index.js
Normal file
@@ -0,0 +1,519 @@
|
|||||||
|
import { query as q } from 'faunadb'
|
||||||
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
import logger from '../../lib/logger'
|
||||||
|
|
||||||
|
const Adapter = (config, options = {}) => {
|
||||||
|
const {
|
||||||
|
faunaClient,
|
||||||
|
collections = {
|
||||||
|
User: 'user',
|
||||||
|
Account: 'account',
|
||||||
|
Session: 'session',
|
||||||
|
VerificationRequest: 'verification_request'
|
||||||
|
},
|
||||||
|
indexes = {
|
||||||
|
Account: 'account_by_provider_account_id',
|
||||||
|
User: 'user_by_email',
|
||||||
|
Session: 'session_by_token',
|
||||||
|
VerificationRequest: 'verification_request_by_token'
|
||||||
|
}
|
||||||
|
} = config
|
||||||
|
|
||||||
|
async function getAdapter (appOptions) {
|
||||||
|
function _debug (debugCode, ...args) {
|
||||||
|
logger.debug(`fauna_${debugCode}`, ...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
|
||||||
|
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
|
||||||
|
? appOptions.session.maxAge * 1000
|
||||||
|
: defaultSessionMaxAge
|
||||||
|
const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge)
|
||||||
|
? appOptions.session.updateAge * 1000
|
||||||
|
: 0
|
||||||
|
|
||||||
|
async function createUser (profile) {
|
||||||
|
_debug('createUser', profile)
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const FQL = q.Create(
|
||||||
|
q.Collection(collections.User), {
|
||||||
|
data: {
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
image: profile.image,
|
||||||
|
emailVerified: profile.emailVerified
|
||||||
|
? profile.emailVerified
|
||||||
|
: false,
|
||||||
|
createdAt: q.Time(timestamp),
|
||||||
|
updatedAt: q.Time(timestamp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newUser = await faunaClient.query(FQL)
|
||||||
|
newUser.data.id = newUser.ref.id
|
||||||
|
|
||||||
|
return newUser.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CREATE_USER', error)
|
||||||
|
return Promise.reject(new Error('CREATE_USER'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser (id) {
|
||||||
|
_debug('getUser', id)
|
||||||
|
|
||||||
|
const FQL = q.Get(
|
||||||
|
q.Ref(q.Collection(collections.User), id)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await faunaClient.query(FQL)
|
||||||
|
user.data.id = user.ref.id
|
||||||
|
|
||||||
|
return user.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET_USER', error)
|
||||||
|
return Promise.reject(new Error('GET_USER'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserByEmail (email) {
|
||||||
|
_debug('getUserByEmail', email)
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FQL = q.Let(
|
||||||
|
{
|
||||||
|
ref: q.Match(q.Index(indexes.User), email)
|
||||||
|
},
|
||||||
|
q.If(
|
||||||
|
q.Exists(q.Var('ref')),
|
||||||
|
q.Get(q.Var('ref')),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await faunaClient.query(FQL)
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
user.data.id = user.ref.id
|
||||||
|
return user.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET_USER_BY_EMAIL', error)
|
||||||
|
return Promise.reject(new Error('GET_USER_BY_EMAIL'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||||
|
_debug('getUserByProviderAccountId', providerId, providerAccountId)
|
||||||
|
|
||||||
|
const FQL = q.Let(
|
||||||
|
{
|
||||||
|
ref: q.Match(
|
||||||
|
q.Index(indexes.Account),
|
||||||
|
[providerId, providerAccountId]
|
||||||
|
)
|
||||||
|
},
|
||||||
|
q.If(
|
||||||
|
q.Exists(q.Var('ref')),
|
||||||
|
q.Get(
|
||||||
|
q.Ref(
|
||||||
|
q.Collection(collections.User),
|
||||||
|
q.Select(['data', 'userId'],
|
||||||
|
q.Get(q.Var('ref'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await faunaClient.query(FQL)
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
user.data.id = user.ref.id
|
||||||
|
|
||||||
|
return user.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET_USER_BY_PROVIDER_ACCOUNT_ID', error)
|
||||||
|
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser (user) {
|
||||||
|
_debug('updateUser', user)
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const FQL = q.Update(
|
||||||
|
q.Ref(q.Collection(collections.User), user.id),
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
image: user.image,
|
||||||
|
emailVerified: user.emailVerified ? user.emailVerified : false,
|
||||||
|
updatedAt: q.Time(timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await faunaClient.query(FQL)
|
||||||
|
user.data.id = user.ref.id
|
||||||
|
|
||||||
|
return user.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('UPDATE_USER_ERROR', error)
|
||||||
|
return Promise.reject(new Error('UPDATE_USER_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser (userId) {
|
||||||
|
_debug('deleteUser', userId)
|
||||||
|
|
||||||
|
const FQL = q.Delete(
|
||||||
|
q.Ref(q.Collection(collections.User), userId)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await faunaClient.query(FQL)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE_USER_ERROR', error)
|
||||||
|
return Promise.reject(new Error('DELETE_USER_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
|
||||||
|
_debug('linkAccount', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const account = await faunaClient.query(
|
||||||
|
q.Create(q.Collection(collections.Account), {
|
||||||
|
data: {
|
||||||
|
userId: userId,
|
||||||
|
providerId: providerId,
|
||||||
|
providerType: providerType,
|
||||||
|
providerAccountId: providerAccountId,
|
||||||
|
refreshToken: refreshToken,
|
||||||
|
accessToken: accessToken,
|
||||||
|
accessTokenExpires: accessTokenExpires,
|
||||||
|
createdAt: q.Time(timestamp),
|
||||||
|
updatedAt: q.Time(timestamp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return account.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('LINK_ACCOUNT_ERROR', error)
|
||||||
|
return Promise.reject(new Error('LINK_ACCOUNT_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlinkAccount (userId, providerId, providerAccountId) {
|
||||||
|
_debug('unlinkAccount', userId, providerId, providerAccountId)
|
||||||
|
|
||||||
|
const FQL = q.Delete(
|
||||||
|
q.Select('ref',
|
||||||
|
q.Get(
|
||||||
|
q.Match(
|
||||||
|
q.Index(indexes.Account),
|
||||||
|
[providerId, providerAccountId]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await faunaClient.query(FQL)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('UNLINK_ACCOUNT_ERROR', error)
|
||||||
|
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession (user) {
|
||||||
|
_debug('createSession', user)
|
||||||
|
|
||||||
|
let expires = null
|
||||||
|
if (sessionMaxAge) {
|
||||||
|
const dateExpires = new Date()
|
||||||
|
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
|
||||||
|
expires = dateExpires.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const FQL =
|
||||||
|
q.Create(q.Collection(collections.Session), {
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
expires: q.Time(expires),
|
||||||
|
sessionToken: randomBytes(32).toString('hex'),
|
||||||
|
accessToken: randomBytes(32).toString('hex'),
|
||||||
|
createdAt: q.Time(timestamp),
|
||||||
|
updatedAt: q.Time(timestamp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await faunaClient.query(FQL)
|
||||||
|
|
||||||
|
session.data.id = session.ref.id
|
||||||
|
|
||||||
|
return session.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CREATE_SESSION_ERROR', error)
|
||||||
|
return Promise.reject(new Error('CREATE_SESSION_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSession (sessionToken) {
|
||||||
|
_debug('getSession', sessionToken)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await faunaClient.query(
|
||||||
|
q.Get(
|
||||||
|
q.Match(
|
||||||
|
q.Index(indexes.Session),
|
||||||
|
sessionToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Check session has not expired (do not return it if it has)
|
||||||
|
if (session && session.expires && new Date() > session.expires) {
|
||||||
|
await _deleteSession(sessionToken)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
session.data.id = session.ref.id
|
||||||
|
|
||||||
|
return session.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET_SESSION_ERROR', error)
|
||||||
|
return Promise.reject(new Error('GET_SESSION_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSession (session, force) {
|
||||||
|
_debug('updateSession', session)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const shouldUpdate = sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires
|
||||||
|
if (!shouldUpdate && !force) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate last updated date, to throttle write updates to database
|
||||||
|
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||||
|
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||||
|
//
|
||||||
|
// Default for sessionMaxAge is 30 days.
|
||||||
|
// Default for sessionUpdateAge is 1 hour.
|
||||||
|
const dateSessionIsDueToBeUpdated = new Date(session.expires)
|
||||||
|
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
|
||||||
|
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
|
||||||
|
|
||||||
|
// Trigger update of session expiry date and write to database, only
|
||||||
|
// if the session was last updated more than {sessionUpdateAge} ago
|
||||||
|
const currentDate = new Date()
|
||||||
|
if (currentDate < dateSessionIsDueToBeUpdated && !force) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExpiryDate = new Date()
|
||||||
|
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
|
||||||
|
|
||||||
|
const updatedSession = await faunaClient.query(
|
||||||
|
q.Update(
|
||||||
|
q.Ref(q.Collection(collections.Session), session.id),
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
expires: q.Time(newExpiryDate.toISOString()),
|
||||||
|
updatedAt: q.Time(new Date().toISOString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
updatedSession.data.id = updatedSession.ref.id
|
||||||
|
|
||||||
|
return updatedSession.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('UPDATE_SESSION_ERROR', error)
|
||||||
|
return Promise.reject(new Error('UPDATE_SESSION_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _deleteSession (sessionToken) {
|
||||||
|
const FQL = q.Delete(
|
||||||
|
q.Select('ref',
|
||||||
|
q.Get(
|
||||||
|
q.Match(
|
||||||
|
q.Index(indexes.Session),
|
||||||
|
sessionToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return faunaClient.query(FQL)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSession (sessionToken) {
|
||||||
|
_debug('deleteSession', sessionToken)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await _deleteSession(sessionToken)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE_SESSION_ERROR', error)
|
||||||
|
return Promise.reject(new Error('DELETE_SESSION_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVerificationRequest (identifier, url, token, secret, provider) {
|
||||||
|
_debug('createVerificationRequest', identifier)
|
||||||
|
|
||||||
|
const { baseUrl } = appOptions
|
||||||
|
const { sendVerificationRequest, maxAge } = provider
|
||||||
|
|
||||||
|
// Store hashed token (using secret as salt) so that tokens cannot be exploited
|
||||||
|
// even if the contents of the database is compromised
|
||||||
|
// @TODO Use bcrypt function here instead of simple salted hash
|
||||||
|
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||||
|
|
||||||
|
let expires = null
|
||||||
|
if (maxAge) {
|
||||||
|
const dateExpires = new Date()
|
||||||
|
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
|
||||||
|
|
||||||
|
expires = dateExpires.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const FQL = q.Create(
|
||||||
|
q.Collection(collections.VerificationRequest), {
|
||||||
|
data: {
|
||||||
|
identifier: identifier,
|
||||||
|
token: hashedToken,
|
||||||
|
expires: expires === null ? null : q.Time(expires),
|
||||||
|
createdAt: q.Time(timestamp),
|
||||||
|
updatedAt: q.Time(timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const verificationRequest = await faunaClient.query(FQL)
|
||||||
|
|
||||||
|
// With the verificationCallback on a provider, you can send an email, or queue
|
||||||
|
// an email to be sent, or perform some other action (e.g. send a text message)
|
||||||
|
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
|
||||||
|
|
||||||
|
return verificationRequest.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('CREATE_VERIFICATION_REQUEST_ERROR', error)
|
||||||
|
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVerificationRequest (identifier, token, secret, provider) {
|
||||||
|
_debug('getVerificationRequest', identifier, token)
|
||||||
|
|
||||||
|
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||||
|
const FQL = q.Let(
|
||||||
|
{
|
||||||
|
ref: q.Match(q.Index(indexes.VerificationRequest), hashedToken)
|
||||||
|
},
|
||||||
|
q.If(
|
||||||
|
q.Exists(q.Var('ref')),
|
||||||
|
{
|
||||||
|
ref: q.Var('ref'),
|
||||||
|
request: q.Select('data', q.Get(q.Var('ref')))
|
||||||
|
},
|
||||||
|
null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { ref, request: verificationRequest } = await faunaClient.query(FQL)
|
||||||
|
const nowDate = Date.now()
|
||||||
|
|
||||||
|
if (verificationRequest && verificationRequest.expires && verificationRequest.expires < nowDate) {
|
||||||
|
// Delete the expired request so it cannot be used
|
||||||
|
await faunaClient.query(
|
||||||
|
q.Delete(ref)
|
||||||
|
)
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return verificationRequest
|
||||||
|
} catch (error) {
|
||||||
|
console.error('GET_VERIFICATION_REQUEST_ERROR', error)
|
||||||
|
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVerificationRequest (identifier, token, secret, provider) {
|
||||||
|
_debug('deleteVerification', identifier, token)
|
||||||
|
|
||||||
|
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||||
|
const FQL = q.Delete(
|
||||||
|
q.Select('ref',
|
||||||
|
q.Get(
|
||||||
|
q.Match(
|
||||||
|
q.Index(indexes.VerificationRequest), hashedToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await faunaClient.query(FQL)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
|
||||||
|
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
createUser,
|
||||||
|
getUser,
|
||||||
|
getUserByEmail,
|
||||||
|
getUserByProviderAccountId,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
linkAccount,
|
||||||
|
unlinkAccount,
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
updateSession,
|
||||||
|
deleteSession,
|
||||||
|
createVerificationRequest,
|
||||||
|
getVerificationRequest,
|
||||||
|
deleteVerificationRequest
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getAdapter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
Adapter
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import TypeORM from './typeorm'
|
import TypeORM from './typeorm'
|
||||||
import Prisma from './prisma'
|
import Prisma from './prisma'
|
||||||
|
import Fauna from './fauna'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Default: TypeORM.Adapter,
|
Default: TypeORM.Adapter,
|
||||||
TypeORM,
|
TypeORM,
|
||||||
Prisma
|
Prisma,
|
||||||
|
Fauna
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const Adapter = (config) => {
|
|||||||
async function getUser (id) {
|
async function getUser (id) {
|
||||||
debug('GET_USER', id)
|
debug('GET_USER', id)
|
||||||
try {
|
try {
|
||||||
return prisma[User].findOne({ where: { id } })
|
return prisma[User].findUnique({ where: { id } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('GET_USER_BY_ID_ERROR', error)
|
logger.error('GET_USER_BY_ID_ERROR', error)
|
||||||
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
|
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
|
||||||
@@ -68,7 +68,7 @@ const Adapter = (config) => {
|
|||||||
debug('GET_USER_BY_EMAIL', email)
|
debug('GET_USER_BY_EMAIL', email)
|
||||||
try {
|
try {
|
||||||
if (!email) { return Promise.resolve(null) }
|
if (!email) { return Promise.resolve(null) }
|
||||||
return prisma[User].findOne({ where: { email } })
|
return prisma[User].findUnique({ where: { email } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('GET_USER_BY_EMAIL_ERROR', error)
|
logger.error('GET_USER_BY_EMAIL_ERROR', error)
|
||||||
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
|
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
|
||||||
@@ -78,9 +78,9 @@ const Adapter = (config) => {
|
|||||||
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||||
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
||||||
try {
|
try {
|
||||||
const account = await prisma[Account].findOne({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
|
const account = await prisma[Account].findUnique({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
|
||||||
if (!account) { return null }
|
if (!account) { return null }
|
||||||
return prisma[User].findOne({ where: { id: account.userId } })
|
return prisma[User].findUnique({ where: { id: account.userId } })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
|
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
|
||||||
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
|
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
|
||||||
@@ -174,7 +174,7 @@ const Adapter = (config) => {
|
|||||||
async function getSession (sessionToken) {
|
async function getSession (sessionToken) {
|
||||||
debug('GET_SESSION', sessionToken)
|
debug('GET_SESSION', sessionToken)
|
||||||
try {
|
try {
|
||||||
const session = await prisma[Session].findOne({ where: { sessionToken } })
|
const session = await prisma[Session].findUnique({ where: { sessionToken } })
|
||||||
|
|
||||||
// Check session has not expired (do not return it if it has)
|
// Check session has not expired (do not return it if it has)
|
||||||
if (session && session.expires && new Date() > session.expires) {
|
if (session && session.expires && new Date() > session.expires) {
|
||||||
@@ -280,7 +280,7 @@ const Adapter = (config) => {
|
|||||||
// Hash token provided with secret before trying to match it with database
|
// Hash token provided with secret before trying to match it with database
|
||||||
// @TODO Use bcrypt instead of salted SHA-256 hash for token
|
// @TODO Use bcrypt instead of salted SHA-256 hash for token
|
||||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||||
const verificationRequest = await prisma[VerificationRequest].findOne({ where: { token: hashedToken } })
|
const verificationRequest = await prisma[VerificationRequest].findUnique({ where: { token: hashedToken } })
|
||||||
|
|
||||||
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
|
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
|
||||||
// Delete verification entry so it cannot be used again
|
// Delete verification entry so it cannot be used again
|
||||||
|
|||||||
@@ -67,9 +67,20 @@ if (typeof window !== 'undefined') {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Listen for window focus/blur events
|
// Listen for document visibilitychange events
|
||||||
window.addEventListener('focus', async (event) => __NEXTAUTH._getSession({ event: 'focus' }))
|
let hidden, visibilityChange
|
||||||
window.addEventListener('blur', async (event) => __NEXTAUTH._getSession({ event: 'blur' }))
|
if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
|
||||||
|
hidden = 'hidden'
|
||||||
|
visibilityChange = 'visibilitychange'
|
||||||
|
} else if (typeof document.msHidden !== 'undefined') {
|
||||||
|
hidden = 'msHidden'
|
||||||
|
visibilityChange = 'msvisibilitychange'
|
||||||
|
} else if (typeof document.webkitHidden !== 'undefined') {
|
||||||
|
hidden = 'webkitHidden'
|
||||||
|
visibilityChange = 'webkitvisibilitychange'
|
||||||
|
}
|
||||||
|
const handleVisibilityChange = () => !document[hidden] && __NEXTAUTH._getSession({ event: visibilityChange })
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange, false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +153,7 @@ const getProviders = async () => {
|
|||||||
const SessionContext = createContext()
|
const SessionContext = createContext()
|
||||||
|
|
||||||
// Client side method
|
// Client side method
|
||||||
const useSession = (session) => {
|
export const useSession = (session) => {
|
||||||
// Try to use context if we can
|
// Try to use context if we can
|
||||||
const value = useContext(SessionContext)
|
const value = useContext(SessionContext)
|
||||||
|
|
||||||
@@ -223,24 +234,20 @@ const _useSessionHook = (session) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Client side method
|
// Client side method
|
||||||
const signIn = async (provider, args = {}, authParams = {}) => {
|
export const signIn = async (provider, args = {}) => {
|
||||||
const baseUrl = _apiBaseUrl()
|
const baseUrl = _apiBaseUrl()
|
||||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
const callbackUrl = args.callbackUrl ?? window.location
|
||||||
const providers = await getProviders()
|
const providers = await getProviders()
|
||||||
|
|
||||||
// Redirect to sign in page if no valid provider specified
|
// Redirect to sign in page if no valid provider specified
|
||||||
if (!provider || !providers[provider]) {
|
if (!(provider in providers)) {
|
||||||
// If Provider not recognized, redirect to sign in page
|
// If Provider not recognized, redirect to sign in page
|
||||||
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||||
} else {
|
} else {
|
||||||
let signInUrl = (providers[provider].type === 'credentials')
|
const signInUrl = (providers[provider].type === 'credentials')
|
||||||
? `${baseUrl}/callback/${provider}`
|
? `${baseUrl}/callback/${provider}`
|
||||||
: `${baseUrl}/signin/${provider}`
|
: `${baseUrl}/signin/${provider}`
|
||||||
|
|
||||||
if (authParams) {
|
|
||||||
signInUrl += `?${new URLSearchParams(authParams).toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// If is any other provider type, POST to provider URL with CSRF Token,
|
// If is any other provider type, POST to provider URL with CSRF Token,
|
||||||
// callback URL and any other parameters supplied.
|
// callback URL and any other parameters supplied.
|
||||||
const fetchOptions = {
|
const fetchOptions = {
|
||||||
@@ -257,13 +264,13 @@ const signIn = async (provider, args = {}, authParams = {}) => {
|
|||||||
}
|
}
|
||||||
const res = await fetch(signInUrl, fetchOptions)
|
const res = await fetch(signInUrl, fetchOptions)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
window.location = data.url ? data.url : callbackUrl
|
window.location = data.url ?? callbackUrl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client side method
|
// Client side method
|
||||||
const signOut = async (args = {}) => {
|
export const signOut = async (args = {}) => {
|
||||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
const callbackUrl = args.callbackUrl ?? window.location
|
||||||
|
|
||||||
const baseUrl = _apiBaseUrl()
|
const baseUrl = _apiBaseUrl()
|
||||||
const fetchOptions = {
|
const fetchOptions = {
|
||||||
@@ -280,11 +287,11 @@ const signOut = async (args = {}) => {
|
|||||||
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
|
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
_sendMessage({ event: 'session', data: { trigger: 'signout' } })
|
_sendMessage({ event: 'session', data: { trigger: 'signout' } })
|
||||||
window.location = data.url ? data.url : callbackUrl
|
window.location = data.url ?? callbackUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider to wrap the app in to make session data available globally
|
// Provider to wrap the app in to make session data available globally
|
||||||
const Provider = ({ children, session, options }) => {
|
export const Provider = ({ children, session, options }) => {
|
||||||
setOptions(options)
|
setOptions(options)
|
||||||
return createElement(SessionContext.Provider, { value: useSession(session) }, children)
|
return createElement(SessionContext.Provider, { value: useSession(session) }, children)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,44 @@
|
|||||||
:root {
|
:root {
|
||||||
--color-background: #fff;
|
|
||||||
--color-primary: #444;
|
|
||||||
--color-control-border: #bbb;
|
|
||||||
--color-button-active-background: #f9f9f9;
|
|
||||||
--color-button-active-border: #aaa;
|
|
||||||
--border-width: 1px;
|
--border-width: 1px;
|
||||||
--border-radius: .3rem;
|
--border-radius: .3rem;
|
||||||
--color-error: #c94b4b;
|
--color-error: #c94b4b;
|
||||||
--color-info: #157efb;
|
--color-info: #157efb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.__next-auth-theme-auto,
|
||||||
|
.__next-auth-theme-light {
|
||||||
|
--color-background: #fff;
|
||||||
|
--color-text: #000;
|
||||||
|
--color-primary: #444;
|
||||||
|
--color-control-border: #bbb;
|
||||||
|
--color-button-active-background: #f9f9f9;
|
||||||
|
--color-button-active-border: #aaa;
|
||||||
--color-seperator: #ccc;
|
--color-seperator: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.__next-auth-theme-dark {
|
||||||
|
--color-background: #000;
|
||||||
|
--color-text: #fff;
|
||||||
|
--color-primary: #ccc;
|
||||||
|
--color-control-border: #555;
|
||||||
|
--color-button-active-background: #060606;
|
||||||
|
--color-button-active-border: #666;
|
||||||
|
|
||||||
|
--color-seperator: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.__next-auth-theme-auto {
|
||||||
|
--color-background: #000;
|
||||||
|
--color-text: #fff;
|
||||||
|
--color-primary: #ccc;
|
||||||
|
--color-control-border: #555;
|
||||||
|
--color-button-active-background: #060606;
|
||||||
|
--color-button-active-border: #666;
|
||||||
|
--color-seperator: #444;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -22,6 +50,7 @@ h1 {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
@@ -46,7 +75,7 @@ input[type] {
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
box-shadow: inset 0 .1rem .2rem rgba(0,0,0,.2);
|
box-shadow: inset 0 .1rem .2rem rgba(0, 0, 0, .2);
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -63,6 +92,7 @@ p {
|
|||||||
a.button {
|
a.button {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
line-height: 1rem;
|
line-height: 1rem;
|
||||||
|
|
||||||
&:link,
|
&:link,
|
||||||
&:visited {
|
&:visited {
|
||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
@@ -79,17 +109,17 @@ a.button {
|
|||||||
background-color: var(--color-background);
|
background-color: var(--color-background);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
transition: all .1s ease-in-out;
|
transition: all .1s ease-in-out;
|
||||||
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0,0,0,.05);
|
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .05);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0,0,0,.1);
|
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .1);
|
||||||
background-color: var(--color-button-active-background);
|
background-color: var(--color-button-active-background);
|
||||||
border-color: var(--color-button-active-border);
|
border-color: var(--color-button-active-border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -101,20 +131,21 @@ a.site {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: table;
|
display: table;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
> div {
|
>div {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -129,12 +160,14 @@ a.site {
|
|||||||
padding-right: 2rem;
|
padding-right: 2rem;
|
||||||
margin-top: .5rem;
|
margin-top: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.signin {
|
.signin {
|
||||||
|
|
||||||
button,
|
button,
|
||||||
a.button,
|
a.button,
|
||||||
input[type="text"] {
|
input[type="text"] {
|
||||||
@@ -165,7 +198,8 @@ a.site {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 0.3rem;
|
border-radius: 0.3rem;
|
||||||
background: var(--color-info);
|
background: var(--color-info);
|
||||||
color: #fff;
|
color: var(--color-text);
|
||||||
|
|
||||||
p {
|
p {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
@@ -174,16 +208,19 @@ a.site {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
> div,
|
>div,
|
||||||
form {
|
form {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto 0.5rem auto;
|
margin: 0 auto 0.5rem auto;
|
||||||
|
|
||||||
input[type] {
|
input[type] {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
const pathToCss = path.join(__dirname, '/index.css')
|
const pathToCss = path.join(process.cwd(), '/dist/css/index.css')
|
||||||
const css = fs.readFileSync(pathToCss, 'utf8')
|
|
||||||
|
|
||||||
export default () => css
|
export default function css () {
|
||||||
|
return fs.readFileSync(pathToCss, 'utf8')
|
||||||
|
}
|
||||||
|
|||||||
1
src/index.js
Normal file
1
src/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './server'
|
||||||
@@ -25,7 +25,7 @@ class CreateUserError extends UnknownError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Thrown when an Email address is already associated with an account
|
// Thrown when an Email address is already associated with an account
|
||||||
// but the user is trying an oAuth account that is not linked to it.
|
// but the user is trying an OAuth account that is not linked to it.
|
||||||
class AccountNotLinkedError extends UnknownError {
|
class AccountNotLinkedError extends UnknownError {
|
||||||
constructor (message) {
|
constructor (message) {
|
||||||
super(message)
|
super(message)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const DEFAULT_ENCRYPTION_ENABLED = false
|
|||||||
|
|
||||||
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
||||||
|
|
||||||
const encode = async ({
|
async function encode ({
|
||||||
token = {},
|
token = {},
|
||||||
maxAge = DEFAULT_MAX_AGE,
|
maxAge = DEFAULT_MAX_AGE,
|
||||||
secret,
|
secret,
|
||||||
@@ -28,9 +28,9 @@ const encode = async ({
|
|||||||
zip: 'DEF'
|
zip: 'DEF'
|
||||||
},
|
},
|
||||||
encryption = DEFAULT_ENCRYPTION_ENABLED
|
encryption = DEFAULT_ENCRYPTION_ENABLED
|
||||||
} = {}) => {
|
} = {}) {
|
||||||
// Signing Key
|
// Signing Key
|
||||||
const _signingKey = (signingKey)
|
const _signingKey = signingKey
|
||||||
? jose.JWK.asKey(JSON.parse(signingKey))
|
? jose.JWK.asKey(JSON.parse(signingKey))
|
||||||
: getDerivedSigningKey(secret)
|
: getDerivedSigningKey(secret)
|
||||||
|
|
||||||
@@ -39,18 +39,17 @@ const encode = async ({
|
|||||||
|
|
||||||
if (encryption) {
|
if (encryption) {
|
||||||
// Encryption Key
|
// Encryption Key
|
||||||
const _encryptionKey = (encryptionKey)
|
const _encryptionKey = encryptionKey
|
||||||
? jose.JWK.asKey(JSON.parse(encryptionKey))
|
? jose.JWK.asKey(JSON.parse(encryptionKey))
|
||||||
: getDerivedEncryptionKey(secret)
|
: getDerivedEncryptionKey(secret)
|
||||||
|
|
||||||
// Encrypt token
|
// Encrypt token
|
||||||
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
|
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
|
||||||
} else {
|
|
||||||
return signedToken
|
|
||||||
}
|
}
|
||||||
|
return signedToken
|
||||||
}
|
}
|
||||||
|
|
||||||
const decode = async ({
|
async function decode ({
|
||||||
secret,
|
secret,
|
||||||
token,
|
token,
|
||||||
maxAge = DEFAULT_MAX_AGE,
|
maxAge = DEFAULT_MAX_AGE,
|
||||||
@@ -66,14 +65,14 @@ const decode = async ({
|
|||||||
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM]
|
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM]
|
||||||
},
|
},
|
||||||
encryption = DEFAULT_ENCRYPTION_ENABLED
|
encryption = DEFAULT_ENCRYPTION_ENABLED
|
||||||
} = {}) => {
|
} = {}) {
|
||||||
if (!token) return null
|
if (!token) return null
|
||||||
|
|
||||||
let tokenToVerify = token
|
let tokenToVerify = token
|
||||||
|
|
||||||
if (encryption) {
|
if (encryption) {
|
||||||
// Encryption Key
|
// Encryption Key
|
||||||
const _encryptionKey = (decryptionKey)
|
const _encryptionKey = decryptionKey
|
||||||
? jose.JWK.asKey(JSON.parse(decryptionKey))
|
? jose.JWK.asKey(JSON.parse(decryptionKey))
|
||||||
: getDerivedEncryptionKey(secret)
|
: getDerivedEncryptionKey(secret)
|
||||||
|
|
||||||
@@ -83,7 +82,7 @@ const decode = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Signing Key
|
// Signing Key
|
||||||
const _signingKey = (verificationKey)
|
const _signingKey = verificationKey
|
||||||
? jose.JWK.asKey(JSON.parse(verificationKey))
|
? jose.JWK.asKey(JSON.parse(verificationKey))
|
||||||
: getDerivedSigningKey(secret)
|
: getDerivedSigningKey(secret)
|
||||||
|
|
||||||
@@ -91,7 +90,16 @@ const decode = async ({
|
|||||||
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
|
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToken = async (args) => {
|
/**
|
||||||
|
* Server-side method to retrieve the JWT from `req`.
|
||||||
|
* @param {{
|
||||||
|
* req: NextApiRequest
|
||||||
|
* secureCookie?: boolean
|
||||||
|
* cookieName?: string
|
||||||
|
* raw?: boolean
|
||||||
|
* }} params
|
||||||
|
*/
|
||||||
|
async function getToken (params) {
|
||||||
const {
|
const {
|
||||||
req,
|
req,
|
||||||
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
|
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
|
||||||
@@ -99,7 +107,7 @@ const getToken = async (args) => {
|
|||||||
secureCookie = !(!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://')),
|
secureCookie = !(!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://')),
|
||||||
cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token',
|
cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token',
|
||||||
raw = false
|
raw = false
|
||||||
} = args
|
} = params
|
||||||
if (!req) throw new Error('Must pass `req` to JWT getToken()')
|
if (!req) throw new Error('Must pass `req` to JWT getToken()')
|
||||||
|
|
||||||
// Try to get token from cookie
|
// Try to get token from cookie
|
||||||
@@ -108,7 +116,7 @@ const getToken = async (args) => {
|
|||||||
// If cookie not found in cookie look for bearer token in authorization header.
|
// If cookie not found in cookie look for bearer token in authorization header.
|
||||||
// This allows clients that pass through tokens in headers rather than as
|
// This allows clients that pass through tokens in headers rather than as
|
||||||
// cookies to use this helper function.
|
// cookies to use this helper function.
|
||||||
if (!token && req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
|
if (!token && req.headers.authorization?.split(' ')[0] === 'Bearer') {
|
||||||
const urlEncodedToken = req.headers.authorization.split(' ')[1]
|
const urlEncodedToken = req.headers.authorization.split(' ')[1]
|
||||||
token = decodeURIComponent(urlEncodedToken)
|
token = decodeURIComponent(urlEncodedToken)
|
||||||
}
|
}
|
||||||
@@ -118,8 +126,8 @@ const getToken = async (args) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await decode({ token, ...args })
|
return decode({ token, ...params })
|
||||||
} catch (error) {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +136,7 @@ const getToken = async (args) => {
|
|||||||
let DERIVED_SIGNING_KEY_WARNING = false
|
let DERIVED_SIGNING_KEY_WARNING = false
|
||||||
let DERIVED_ENCRYPTION_KEY_WARNING = false
|
let DERIVED_ENCRYPTION_KEY_WARNING = false
|
||||||
|
|
||||||
const getDerivedSigningKey = (secret) => {
|
function getDerivedSigningKey (secret) {
|
||||||
if (!DERIVED_SIGNING_KEY_WARNING) {
|
if (!DERIVED_SIGNING_KEY_WARNING) {
|
||||||
logger.warn('JWT_AUTO_GENERATED_SIGNING_KEY')
|
logger.warn('JWT_AUTO_GENERATED_SIGNING_KEY')
|
||||||
DERIVED_SIGNING_KEY_WARNING = true
|
DERIVED_SIGNING_KEY_WARNING = true
|
||||||
@@ -139,7 +147,7 @@ const getDerivedSigningKey = (secret) => {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDerivedEncryptionKey = (secret) => {
|
function getDerivedEncryptionKey (secret) {
|
||||||
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
|
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
|
||||||
logger.warn('JWT_AUTO_GENERATED_ENCRYPTION_KEY')
|
logger.warn('JWT_AUTO_GENERATED_ENCRYPTION_KEY')
|
||||||
DERIVED_ENCRYPTION_KEY_WARNING = true
|
DERIVED_ENCRYPTION_KEY_WARNING = true
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
const logger = {
|
const logger = {
|
||||||
error: (errorCode, ...text) => {
|
error (code, ...text) {
|
||||||
if (!console) { return }
|
|
||||||
if (text && text.length <= 1) { text = text[0] || '' }
|
|
||||||
console.error(
|
console.error(
|
||||||
`[next-auth][error][${errorCode.toLowerCase()}]`,
|
`[next-auth][error][${code.toLowerCase()}]`,
|
||||||
text,
|
JSON.stringify(text),
|
||||||
`\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}`
|
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
warn: (warnCode, ...text) => {
|
warn (code, ...text) {
|
||||||
if (!console) { return }
|
|
||||||
if (text && text.length <= 1) { text = text[0] || '' }
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[next-auth][warn][${warnCode.toLowerCase()}]`,
|
`[next-auth][warn][${code.toLowerCase()}]`,
|
||||||
text,
|
JSON.stringify(text),
|
||||||
`\nhttps://next-auth.js.org/warnings#${warnCode.toLowerCase()}`
|
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
debug: (debugCode, ...text) => {
|
debug (code, ...text) {
|
||||||
if (!console) { return }
|
if (!process?.env?._NEXTAUTH_DEBUG) return
|
||||||
if (text && text.length <= 1) { text = text[0] || '' }
|
console.log(
|
||||||
if (process && process.env && process.env._NEXTAUTH_DEBUG) {
|
`[next-auth][debug][${code.toLowerCase()}]`,
|
||||||
console.log(
|
JSON.stringify(text)
|
||||||
`[next-auth][debug][${debugCode.toLowerCase()}]`,
|
)
|
||||||
text
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
// Simple universal (client/server) function to split host and path
|
/**
|
||||||
// We use this rather than a library because we need to use the same logic both
|
* Simple universal (client/server) function to split host and path
|
||||||
// client and server side and we only need to parse out the host and path, while
|
* We use this rather than a library because we need to use the same logic both
|
||||||
// supporting a default value, so a simple split is sufficent.
|
* client and server side and we only need to parse out the host and path, while
|
||||||
export default (url) => {
|
* supporting a default value, so a simple split is sufficent.
|
||||||
|
* @param {string} url
|
||||||
|
*/
|
||||||
|
export default function parseUrl (url) {
|
||||||
// Default values
|
// Default values
|
||||||
const defaultHost = 'http://localhost:3000'
|
const defaultHost = 'http://localhost:3000'
|
||||||
const defaultPath = '/api/auth'
|
const defaultPath = '/api/auth'
|
||||||
@@ -20,8 +23,5 @@ export default (url) => {
|
|||||||
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
|
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
|
||||||
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
|
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
|
||||||
|
|
||||||
return {
|
return { baseUrl, basePath }
|
||||||
baseUrl,
|
|
||||||
basePath
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import jwt from 'jsonwebtoken'
|
|
||||||
|
|
||||||
export default (options) => {
|
export default (options) => {
|
||||||
return {
|
return {
|
||||||
id: 'apple',
|
id: 'apple',
|
||||||
@@ -12,7 +10,6 @@ export default (options) => {
|
|||||||
authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post',
|
authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post',
|
||||||
profileUrl: null,
|
profileUrl: null,
|
||||||
idToken: true,
|
idToken: true,
|
||||||
state: false, // Apple doesn't support state verfication
|
|
||||||
profile: (profile) => {
|
profile: (profile) => {
|
||||||
// The name of the user will only return on first login
|
// The name of the user will only return on first login
|
||||||
return {
|
return {
|
||||||
@@ -23,30 +20,10 @@ export default (options) => {
|
|||||||
},
|
},
|
||||||
clientId: null,
|
clientId: null,
|
||||||
clientSecret: {
|
clientSecret: {
|
||||||
appleId: null,
|
|
||||||
teamId: null,
|
teamId: null,
|
||||||
privateKey: null,
|
privateKey: null,
|
||||||
keyId: null
|
keyId: null
|
||||||
},
|
},
|
||||||
clientSecretCallback: async ({ appleId, keyId, teamId, privateKey }) => {
|
|
||||||
const response = jwt.sign(
|
|
||||||
{
|
|
||||||
iss: teamId,
|
|
||||||
iat: Math.floor(Date.now() / 1000),
|
|
||||||
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
|
|
||||||
aud: 'https://appleid.apple.com',
|
|
||||||
sub: appleId
|
|
||||||
},
|
|
||||||
// Automatically convert \\n into \n if found in private key. If the key
|
|
||||||
// is passed in an environment variable \n can get escaped as \\n
|
|
||||||
privateKey.replace(/\\n/g, '\n'),
|
|
||||||
{
|
|
||||||
algorithm: 'ES256',
|
|
||||||
keyid: keyId
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return Promise.resolve(response)
|
|
||||||
},
|
|
||||||
...options
|
...options
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/providers/azure-ad-b2c.js
Normal file
24
src/providers/azure-ad-b2c.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export default (options) => {
|
||||||
|
const tenant = options.tenantId ? options.tenantId : 'common'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'azure-ad-b2c',
|
||||||
|
name: 'Azure Active Directory B2C',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
params: {
|
||||||
|
grant_type: 'authorization_code'
|
||||||
|
},
|
||||||
|
accessTokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
|
||||||
|
authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query`,
|
||||||
|
profileUrl: 'https://graph.microsoft.com/v1.0/me/',
|
||||||
|
profile: (profile) => {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.displayName,
|
||||||
|
email: profile.userPrincipalName
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/providers/bungie.js
Normal file
30
src/providers/bungie.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export default (options) => {
|
||||||
|
return {
|
||||||
|
id: 'bungie',
|
||||||
|
name: 'Bungie',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
scope: '',
|
||||||
|
params: { reauth: 'true', grant_type: 'authorization_code' },
|
||||||
|
accessTokenUrl: 'https://www.bungie.net/platform/app/oauth/token/',
|
||||||
|
requestTokenUrl: 'https://www.bungie.net/platform/app/oauth/token/',
|
||||||
|
authorizationUrl: 'https://www.bungie.net/en/OAuth/Authorize?response_type=code',
|
||||||
|
profileUrl: 'https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/',
|
||||||
|
profile: (profile) => {
|
||||||
|
const { bungieNetUser: user } = profile.Response
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.membershipId,
|
||||||
|
name: user.displayName,
|
||||||
|
image: `https://www.bungie.net${user.profilePicturePath.startsWith('/') ? '' : '/'}${user.profilePicturePath}`,
|
||||||
|
email: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': null
|
||||||
|
},
|
||||||
|
clientId: null,
|
||||||
|
clientSecret: null,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,14 +7,20 @@ export default (options) => {
|
|||||||
scope: 'identify email',
|
scope: 'identify email',
|
||||||
params: { grant_type: 'authorization_code' },
|
params: { grant_type: 'authorization_code' },
|
||||||
accessTokenUrl: 'https://discord.com/api/oauth2/token',
|
accessTokenUrl: 'https://discord.com/api/oauth2/token',
|
||||||
authorizationUrl:
|
authorizationUrl: 'https://discord.com/api/oauth2/authorize?response_type=code&prompt=none',
|
||||||
'https://discord.com/api/oauth2/authorize?response_type=code&prompt=none',
|
|
||||||
profileUrl: 'https://discord.com/api/users/@me',
|
profileUrl: 'https://discord.com/api/users/@me',
|
||||||
profile: (profile) => {
|
profile: (profile) => {
|
||||||
|
if (profile.avatar === null) {
|
||||||
|
const defaultAvatarNumber = parseInt(profile.discriminator) % 5
|
||||||
|
profile.image_url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNumber}.png`
|
||||||
|
} else {
|
||||||
|
const format = profile.premium_type === 1 || profile.premium_type === 2 ? 'gif' : 'png'
|
||||||
|
profile.image_url = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.username,
|
name: profile.username,
|
||||||
image: `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`,
|
image: profile.image_url,
|
||||||
email: profile.email
|
email: profile.email
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
22
src/providers/foursquare.js
Normal file
22
src/providers/foursquare.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export default ({ apiVersion, ...options }) => {
|
||||||
|
return {
|
||||||
|
id: 'foursquare',
|
||||||
|
name: 'Foursquare',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
params: { grant_type: 'authorization_code' },
|
||||||
|
accessTokenUrl: 'https://foursquare.com/oauth2/access_token',
|
||||||
|
authorizationUrl:
|
||||||
|
'https://foursquare.com/oauth2/authenticate?response_type=code',
|
||||||
|
profileUrl: `https://api.foursquare.com/v2/users/self?v=${apiVersion}`,
|
||||||
|
profile: (profile) => {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: `${profile.firstName} ${profile.lastName}`,
|
||||||
|
image: `${profile.prefix}original${profile.suffix}`,
|
||||||
|
email: profile.contact.email
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,6 @@ export default (options) => {
|
|||||||
profile: (profile) => {
|
profile: (profile) => {
|
||||||
return { ...profile, id: profile.sub }
|
return { ...profile, id: profile.sub }
|
||||||
},
|
},
|
||||||
setGetAccessTokenAuthHeader: false,
|
|
||||||
...options
|
...options
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,67 @@
|
|||||||
import Apple from './apple'
|
import Apple from './apple'
|
||||||
import Atlassian from './atlassian'
|
import Atlassian from './atlassian'
|
||||||
import Auth0 from './auth0'
|
import Auth0 from './auth0'
|
||||||
|
import AzureADB2C from './azure-ad-b2c'
|
||||||
import Basecamp from './basecamp'
|
import Basecamp from './basecamp'
|
||||||
import BattleNet from './battlenet'
|
import BattleNet from './battlenet'
|
||||||
import Box from './box'
|
import Box from './box'
|
||||||
import Credentials from './credentials'
|
import Bungie from './bungie'
|
||||||
import Cognito from './cognito'
|
import Cognito from './cognito'
|
||||||
|
import Credentials from './credentials'
|
||||||
import Discord from './discord'
|
import Discord from './discord'
|
||||||
import Email from './email'
|
import Email from './email'
|
||||||
import Facebook from './facebook'
|
import Facebook from './facebook'
|
||||||
|
import Foursquare from './foursquare'
|
||||||
import FusionAuth from './fusionauth'
|
import FusionAuth from './fusionauth'
|
||||||
import GitHub from './github'
|
import GitHub from './github'
|
||||||
import GitLab from './gitlab'
|
import GitLab from './gitlab'
|
||||||
import Google from './google'
|
import Google from './google'
|
||||||
import IdentityServer4 from './identity-server4'
|
import IdentityServer4 from './identity-server4'
|
||||||
|
import LINE from './line'
|
||||||
import LinkedIn from './linkedin'
|
import LinkedIn from './linkedin'
|
||||||
|
import MailRu from './mailru'
|
||||||
import Mixer from './mixer'
|
import Mixer from './mixer'
|
||||||
|
import Netlify from './netlify'
|
||||||
import Okta from './okta'
|
import Okta from './okta'
|
||||||
import Slack from './slack'
|
import Slack from './slack'
|
||||||
import Spotify from './spotify'
|
import Spotify from './spotify'
|
||||||
|
import Strava from './strava'
|
||||||
import Twitch from './twitch'
|
import Twitch from './twitch'
|
||||||
import Twitter from './twitter'
|
import Twitter from './twitter'
|
||||||
|
import VK from './vk'
|
||||||
import Yandex from './yandex'
|
import Yandex from './yandex'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
Apple,
|
||||||
Atlassian,
|
Atlassian,
|
||||||
Auth0,
|
Auth0,
|
||||||
Apple,
|
AzureADB2C,
|
||||||
Basecamp,
|
Basecamp,
|
||||||
BattleNet,
|
BattleNet,
|
||||||
Box,
|
Box,
|
||||||
Credentials,
|
Bungie,
|
||||||
Cognito,
|
Cognito,
|
||||||
|
Credentials,
|
||||||
Discord,
|
Discord,
|
||||||
Email,
|
Email,
|
||||||
Facebook,
|
Facebook,
|
||||||
|
Foursquare,
|
||||||
FusionAuth,
|
FusionAuth,
|
||||||
GitHub,
|
GitHub,
|
||||||
GitLab,
|
GitLab,
|
||||||
Google,
|
Google,
|
||||||
IdentityServer4,
|
IdentityServer4,
|
||||||
|
LINE,
|
||||||
LinkedIn,
|
LinkedIn,
|
||||||
|
MailRu,
|
||||||
Mixer,
|
Mixer,
|
||||||
|
Netlify,
|
||||||
Okta,
|
Okta,
|
||||||
Slack,
|
Slack,
|
||||||
Spotify,
|
Spotify,
|
||||||
Twitter,
|
Strava,
|
||||||
Twitch,
|
Twitch,
|
||||||
|
Twitter,
|
||||||
|
VK,
|
||||||
Yandex
|
Yandex
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/providers/line.js
Normal file
22
src/providers/line.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export default (options) => {
|
||||||
|
return {
|
||||||
|
id: 'line',
|
||||||
|
name: 'LINE',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
scope: 'profile openid',
|
||||||
|
params: { grant_type: 'authorization_code' },
|
||||||
|
accessTokenUrl: 'https://api.line.me/oauth2/v2.1/token',
|
||||||
|
authorizationUrl: 'https://access.line.me/oauth2/v2.1/authorize?response_type=code',
|
||||||
|
profileUrl: 'https://api.line.me/v2/profile',
|
||||||
|
profile: (profile) => {
|
||||||
|
return {
|
||||||
|
id: profile.userId,
|
||||||
|
name: profile.displayName,
|
||||||
|
email: null,
|
||||||
|
image: profile.pictureUrl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/providers/mailru.js
Normal file
25
src/providers/mailru.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export default (options) => {
|
||||||
|
return {
|
||||||
|
id: 'mailru',
|
||||||
|
name: 'Mail.ru',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
scope: 'userinfo',
|
||||||
|
params: {
|
||||||
|
grant_type: 'authorization_code'
|
||||||
|
},
|
||||||
|
accessTokenUrl: 'https://oauth.mail.ru/token',
|
||||||
|
requestTokenUrl: 'https://oauth.mail.ru/token',
|
||||||
|
authorizationUrl: 'https://oauth.mail.ru/login?response_type=code',
|
||||||
|
profileUrl: 'https://oauth.mail.ru/userinfo',
|
||||||
|
profile: (profile) => {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
email: profile.email,
|
||||||
|
image: profile.image
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/providers/netlify.js
Normal file
21
src/providers/netlify.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export default (options) => {
|
||||||
|
return {
|
||||||
|
id: 'netlify',
|
||||||
|
name: 'Netlify',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
params: { grant_type: 'authorization_code' },
|
||||||
|
accessTokenUrl: 'https://api.netlify.com/oauth/token',
|
||||||
|
authorizationUrl: 'https://app.netlify.com/authorize?response_type=code',
|
||||||
|
profileUrl: 'https://api.netlify.com/api/v1/user',
|
||||||
|
profile: (profile) => {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.full_name,
|
||||||
|
email: profile.email,
|
||||||
|
image: profile.avatar_url
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,12 @@ export default (options) => {
|
|||||||
client_secret: options.clientSecret
|
client_secret: options.clientSecret
|
||||||
},
|
},
|
||||||
// These will be different depending on the Org.
|
// These will be different depending on the Org.
|
||||||
accessTokenUrl: `https://${options.domain}/oauth2/v1/token`,
|
accessTokenUrl: `https://${options.domain}/v1/token`,
|
||||||
authorizationUrl: `https://${options.domain}/oauth2/v1/authorize/?response_type=code`,
|
authorizationUrl: `https://${options.domain}/v1/authorize/?response_type=code`,
|
||||||
profileUrl: `https://${options.domain}/oauth2/v1/userinfo/`,
|
profileUrl: `https://${options.domain}/v1/userinfo/`,
|
||||||
profile: (profile) => {
|
profile: (profile) => {
|
||||||
return { ...profile, id: profile.sub }
|
return { ...profile, id: profile.sub }
|
||||||
},
|
},
|
||||||
setGetAccessTokenAuthHeader: false,
|
|
||||||
...options
|
...options
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ export default (options) => {
|
|||||||
name: 'Slack',
|
name: 'Slack',
|
||||||
type: 'oauth',
|
type: 'oauth',
|
||||||
version: '2.0',
|
version: '2.0',
|
||||||
scope: 'identity.basic identity.email identity.avatar',
|
scope: [],
|
||||||
params: { grant_type: 'authorization_code' },
|
params: { grant_type: 'authorization_code' },
|
||||||
accessTokenUrl: 'https://slack.com/api/oauth.access',
|
accessTokenUrl: 'https://slack.com/api/oauth.v2.access',
|
||||||
authorizationUrl: 'https://slack.com/oauth/authorize?response_type=code',
|
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
||||||
|
authorizationParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
|
||||||
profileUrl: 'https://slack.com/api/users.identity',
|
profileUrl: 'https://slack.com/api/users.identity',
|
||||||
profile: (profile) => {
|
profile: (profile) => {
|
||||||
const { user } = profile
|
const { user } = profile
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default (options) => {
|
|||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.display_name,
|
name: profile.display_name,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
image: profile.images.length > 0 ? profile.images[0].url : undefined
|
image: profile.images?.[0]?.url
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
...options
|
...options
|
||||||
|
|||||||
22
src/providers/strava.js
Normal file
22
src/providers/strava.js
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export default (options) => {
|
||||||
|
return {
|
||||||
|
id: 'strava',
|
||||||
|
name: 'Strava',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
scope: 'read',
|
||||||
|
params: { grant_type: 'authorization_code' },
|
||||||
|
accessTokenUrl: 'https://www.strava.com/api/v3/oauth/token',
|
||||||
|
authorizationUrl:
|
||||||
|
'https://www.strava.com/api/v3/oauth/authorize?response_type=code',
|
||||||
|
profileUrl: 'https://www.strava.com/api/v3/athlete',
|
||||||
|
profile: (profile) => {
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.firstname,
|
||||||
|
image: profile.profile
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/providers/vk.js
Normal file
30
src/providers/vk.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export default (options) => {
|
||||||
|
const apiVersion = '5.126' // https://vk.com/dev/versions
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'vk',
|
||||||
|
name: 'vk.com',
|
||||||
|
type: 'oauth',
|
||||||
|
version: '2.0',
|
||||||
|
scope: 'email',
|
||||||
|
params: {
|
||||||
|
grant_type: 'authorization_code'
|
||||||
|
},
|
||||||
|
accessTokenUrl: `https://oauth.vk.com/access_token?v=${apiVersion}`,
|
||||||
|
requestTokenUrl: `https://oauth.vk.com/access_token?v=${apiVersion}`,
|
||||||
|
authorizationUrl:
|
||||||
|
`https://oauth.vk.com/authorize?response_type=code&v=${apiVersion}`,
|
||||||
|
profileUrl: `https://api.vk.com/method/users.get?fields=photo_100&v=${apiVersion}`,
|
||||||
|
profile: (result) => {
|
||||||
|
const profile = result.response?.[0] ?? {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name: [profile.first_name, profile.last_name].filter(Boolean).join(' '),
|
||||||
|
email: profile.email,
|
||||||
|
image: profile.photo_100
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
import { createHash, randomBytes } from 'crypto'
|
import adapters from '../adapters'
|
||||||
import jwt from '../lib/jwt'
|
import jwt from '../lib/jwt'
|
||||||
import parseUrl from '../lib/parse-url'
|
import parseUrl from '../lib/parse-url'
|
||||||
import cookie from './lib/cookie'
|
|
||||||
import callbackUrlHandler from './lib/callback-url-handler'
|
|
||||||
import parseProviders from './lib/providers'
|
|
||||||
import events from './lib/events'
|
|
||||||
import callbacks from './lib/callbacks'
|
|
||||||
import providers from './routes/providers'
|
|
||||||
import signin from './routes/signin'
|
|
||||||
import signout from './routes/signout'
|
|
||||||
import callback from './routes/callback'
|
|
||||||
import session from './routes/session'
|
|
||||||
import pages from './pages'
|
|
||||||
import adapters from '../adapters'
|
|
||||||
import logger from '../lib/logger'
|
import logger from '../lib/logger'
|
||||||
|
import * as cookie from './lib/cookie'
|
||||||
|
import * as defaultEvents from './lib/default-events'
|
||||||
|
import * as defaultCallbacks from './lib/default-callbacks'
|
||||||
|
import parseProviders from './lib/providers'
|
||||||
|
import callbackUrlHandler from './lib/callback-url-handler'
|
||||||
|
import extendRes from './lib/extend-req'
|
||||||
|
import * as routes from './routes'
|
||||||
|
import renderPage from './pages'
|
||||||
|
import csrfTokenHandler from './lib/csrf-token-handler'
|
||||||
|
import createSecret from './lib/create-secret'
|
||||||
|
|
||||||
// To work properly in production with OAuth providers the NEXTAUTH_URL
|
// To work properly in production with OAuth providers the NEXTAUTH_URL
|
||||||
// environment variable must be set.
|
// environment variable must be set.
|
||||||
@@ -21,184 +19,65 @@ 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (req, res, userSuppliedOptions) => {
|
async function NextAuthHandler (req, res, userOptions) {
|
||||||
|
// If debug enabled, set ENV VAR so that logger logs debug messages
|
||||||
|
if (userOptions.debug) {
|
||||||
|
process.env._NEXTAUTH_DEBUG = true
|
||||||
|
}
|
||||||
|
|
||||||
// To the best of my knowledge, we need to return a promise here
|
// To the best of my knowledge, we need to return a promise here
|
||||||
// to avoid early termination of calls to the serverless function
|
// to avoid early termination of calls to the serverless function
|
||||||
// (and then return that promise when we are done) - eslint
|
// (and then return that promise when we are done) - eslint
|
||||||
// complains but I'm not sure there is another way to do this.
|
// complains but I'm not sure there is another way to do this.
|
||||||
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
|
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
|
||||||
// This is passed to all methods that handle responses, and must be called
|
extendRes(req, res, resolve)
|
||||||
// when they are complete so that the serverless function knows when it is
|
|
||||||
// safe to return and that no more data will be sent.
|
if (!req.query.nextauth) {
|
||||||
const done = resolve
|
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)
|
||||||
|
return res.status(500).end(`Error: ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
const { url, query, body } = req
|
|
||||||
const {
|
const {
|
||||||
nextauth,
|
nextauth,
|
||||||
action = nextauth[0],
|
action = nextauth[0],
|
||||||
provider = nextauth[1],
|
providerId = nextauth[1],
|
||||||
error = nextauth[1]
|
error = nextauth[1]
|
||||||
} = query
|
} = req.query
|
||||||
|
|
||||||
const {
|
// @todo refactor all existing references to baseUrl and basePath
|
||||||
csrfToken: csrfTokenFromPost
|
const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
|
||||||
} = body
|
|
||||||
|
|
||||||
// @todo refactor all existing references to site, baseUrl and basePath
|
const cookies = {
|
||||||
const parsedUrl = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
|
...cookie.defaultCookies(userOptions.useSecureCookies || baseUrl.startsWith('https://')),
|
||||||
const baseUrl = parsedUrl.baseUrl
|
// Allow user cookie options to override any cookie settings above
|
||||||
const basePath = parsedUrl.basePath
|
...userOptions.cookies
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = createSecret({ userOptions, basePath, baseUrl })
|
||||||
|
|
||||||
|
const { csrfToken, csrfTokenVerified } = csrfTokenHandler(req, res, cookies, secret)
|
||||||
|
|
||||||
|
const providers = parseProviders({ providers: userOptions.providers, baseUrl, basePath })
|
||||||
|
const provider = providers.find(({ id }) => id === providerId)
|
||||||
|
|
||||||
|
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle
|
||||||
|
|
||||||
// Parse database / adapter
|
// Parse database / adapter
|
||||||
let adapter
|
// If adapter is provided, use it (advanced usage, overrides database)
|
||||||
if (userSuppliedOptions.adapter) {
|
// If database URI or config object is provided, use it (simple usage)
|
||||||
// If adapter is provided, use it (advanced usage, overrides database)
|
const adapter = userOptions.adapter ?? (userOptions.database && adapters.Default(userOptions.database))
|
||||||
adapter = userSuppliedOptions.adapter
|
|
||||||
} else if (userSuppliedOptions.database) {
|
|
||||||
// If database URI or config object is provided, use it (simple usage)
|
|
||||||
adapter = adapters.Default(userSuppliedOptions.database)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Secret used salt cookies and tokens (e.g. for CSRF protection).
|
|
||||||
// If no secret option is specified then it creates one on the fly
|
|
||||||
// based on options passed here. A options contains unique data, such as
|
|
||||||
// oAuth provider secrets and database credentials it should be sufficent.
|
|
||||||
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify({ baseUrl, basePath, ...userSuppliedOptions })).digest('hex')
|
|
||||||
|
|
||||||
// Use secure cookies if the site uses HTTPS
|
|
||||||
// This being conditional allows cookies to work non-HTTPS development URLs
|
|
||||||
// Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
|
|
||||||
// prefix, but enable them by default if the site URL is HTTPS; but not for
|
|
||||||
// non-HTTPS URLs like http://localhost which are used in development).
|
|
||||||
// For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
|
|
||||||
const useSecureCookies = userSuppliedOptions.useSecureCookies || baseUrl.startsWith('https://')
|
|
||||||
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
|
|
||||||
|
|
||||||
// @TODO Review cookie settings (names, options)
|
|
||||||
const cookies = {
|
|
||||||
// default cookie options
|
|
||||||
sessionToken: {
|
|
||||||
name: `${cookiePrefix}next-auth.session-token`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
secure: useSecureCookies
|
|
||||||
}
|
|
||||||
},
|
|
||||||
callbackUrl: {
|
|
||||||
name: `${cookiePrefix}next-auth.callback-url`,
|
|
||||||
options: {
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
secure: useSecureCookies
|
|
||||||
}
|
|
||||||
},
|
|
||||||
csrfToken: {
|
|
||||||
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
|
|
||||||
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
|
|
||||||
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
|
|
||||||
options: {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
path: '/',
|
|
||||||
secure: useSecureCookies
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Allow user cookie options to override any cookie settings above
|
|
||||||
...userSuppliedOptions.cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
// Session options
|
|
||||||
const sessionOptions = {
|
|
||||||
jwt: false,
|
|
||||||
maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle
|
|
||||||
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
|
|
||||||
...userSuppliedOptions.session
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT options
|
|
||||||
const jwtOptions = {
|
|
||||||
secret, // Use application secret if no keys specified
|
|
||||||
maxAge: sessionOptions.maxAge, // maxAge is dereived from session maxAge,
|
|
||||||
encode: jwt.encode,
|
|
||||||
decode: jwt.decode,
|
|
||||||
...userSuppliedOptions.jwt
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no adapter specified, force use of JSON Web Tokens (stateless)
|
|
||||||
if (!adapter) {
|
|
||||||
sessionOptions.jwt = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event messages
|
|
||||||
const eventsOptions = {
|
|
||||||
...events,
|
|
||||||
...userSuppliedOptions.events
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback functions
|
|
||||||
const callbacksOptions = {
|
|
||||||
...callbacks,
|
|
||||||
...userSuppliedOptions.callbacks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure CSRF Token cookie is set for any subsequent requests.
|
|
||||||
// Used as part of the strateigy for mitigation for CSRF tokens.
|
|
||||||
//
|
|
||||||
// Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
|
|
||||||
// where 'token' is the CSRF token and 'hash' is a hash made of the token and
|
|
||||||
// the secret, and the two values are joined by a pipe '|'. By storing the
|
|
||||||
// value and the hash of the value (with the secret used as a salt) we can
|
|
||||||
// verify the cookie was set by the server and not by a malicous attacker.
|
|
||||||
//
|
|
||||||
// For more details, see the following OWASP links:
|
|
||||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
|
||||||
// https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
|
|
||||||
let csrfToken
|
|
||||||
let csrfTokenVerified = false
|
|
||||||
if (req.cookies[cookies.csrfToken.name]) {
|
|
||||||
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
|
|
||||||
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
|
|
||||||
// If hash matches then we trust the CSRF token value
|
|
||||||
csrfToken = csrfTokenValue
|
|
||||||
|
|
||||||
// If this is a POST request and the CSRF Token in the Post request matches
|
|
||||||
// the cookie we have already verified is one we have set, then token is verified!
|
|
||||||
if (req.method === 'POST' && csrfToken === csrfTokenFromPost) { csrfTokenVerified = true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!csrfToken) {
|
|
||||||
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
|
|
||||||
// (e.g. because it's been modifed or because the secret has changed) create a new token.
|
|
||||||
csrfToken = randomBytes(32).toString('hex')
|
|
||||||
const newCsrfTokenCookie = `${csrfToken}|${createHash('sha256').update(`${csrfToken}${secret}`).digest('hex')}`
|
|
||||||
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper method for handling redirects, this is passed to all routes
|
|
||||||
// @TODO Refactor into a lib instead of passing as an option
|
|
||||||
// e.g. and call as redirect(req, res, url)
|
|
||||||
const redirect = (redirectUrl) => {
|
|
||||||
const reponseAsJson = !!((req.body && req.body.json === 'true'))
|
|
||||||
if (reponseAsJson) {
|
|
||||||
res.json({ url: redirectUrl })
|
|
||||||
} else {
|
|
||||||
res.status(302).setHeader('Location', redirectUrl)
|
|
||||||
res.end()
|
|
||||||
}
|
|
||||||
return done()
|
|
||||||
}
|
|
||||||
|
|
||||||
// User provided options are overriden by other options,
|
// User provided options are overriden by other options,
|
||||||
// except for the options with special handling above
|
// except for the options with special handling above
|
||||||
const options = {
|
req.options = {
|
||||||
// Defaults options can be overidden
|
debug: false,
|
||||||
debug: false, // Enable debug messages to be displayed
|
pages: {},
|
||||||
pages: {}, // Custom pages (e.g. sign in, sign out, errors)
|
theme: 'auto',
|
||||||
// Custom options override defaults
|
// Custom options override defaults
|
||||||
...userSuppliedOptions,
|
...userOptions,
|
||||||
// These computed settings can values in userSuppliedOptions but override them
|
// These computed settings can have values in userOptions but we override them
|
||||||
// and are request-specific.
|
// and are request-specific.
|
||||||
adapter,
|
adapter,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -208,108 +87,130 @@ export default async (req, res, userSuppliedOptions) => {
|
|||||||
cookies,
|
cookies,
|
||||||
secret,
|
secret,
|
||||||
csrfToken,
|
csrfToken,
|
||||||
providers: parseProviders(userSuppliedOptions.providers, baseUrl, basePath),
|
providers,
|
||||||
session: sessionOptions,
|
// Session options
|
||||||
jwt: jwtOptions,
|
session: {
|
||||||
events: eventsOptions,
|
jwt: !adapter, // If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||||
callbacks: callbacksOptions,
|
maxAge,
|
||||||
callbackUrl: baseUrl,
|
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
|
||||||
redirect
|
...userOptions.session
|
||||||
|
},
|
||||||
|
// JWT options
|
||||||
|
jwt: {
|
||||||
|
secret, // Use application secret if no keys specified
|
||||||
|
maxAge, // same as session maxAge,
|
||||||
|
encode: jwt.encode,
|
||||||
|
decode: jwt.decode,
|
||||||
|
...userOptions.jwt
|
||||||
|
},
|
||||||
|
// Event messages
|
||||||
|
events: {
|
||||||
|
...defaultEvents,
|
||||||
|
...userOptions.events
|
||||||
|
},
|
||||||
|
// Callback functions
|
||||||
|
callbacks: {
|
||||||
|
...defaultCallbacks,
|
||||||
|
...userOptions.callbacks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If debug enabled, set ENV VAR so that logger logs debug messages
|
await callbackUrlHandler(req, res)
|
||||||
if (options.debug === true) { process.env._NEXTAUTH_DEBUG = true }
|
|
||||||
|
|
||||||
// Get / Set callback URL based on query param / cookie + validation
|
const render = renderPage(req, res)
|
||||||
options.callbackUrl = await callbackUrlHandler(req, res, options)
|
const { pages } = req.options
|
||||||
|
|
||||||
if (req.method === 'GET') {
|
if (req.method === 'GET') {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'providers':
|
case 'providers':
|
||||||
providers(req, res, options, done)
|
return routes.providers(req, res)
|
||||||
break
|
|
||||||
case 'session':
|
case 'session':
|
||||||
session(req, res, options, done)
|
return routes.session(req, res)
|
||||||
break
|
|
||||||
case 'csrf':
|
case 'csrf':
|
||||||
res.json({ csrfToken })
|
return res.json({ csrfToken })
|
||||||
return done()
|
|
||||||
case 'signin':
|
case 'signin':
|
||||||
if (options.pages.signIn) {
|
if (pages.signIn) {
|
||||||
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`
|
let signinUrl = `${pages.signIn}${pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${req.options.callbackUrl}`
|
||||||
if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` }
|
if (error) { signinUrl = `${signinUrl}&error=${error}` }
|
||||||
return redirect(redirectUrl)
|
return res.redirect(signinUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.render(req, res, 'signin', { baseUrl, basePath, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done)
|
return render.signin()
|
||||||
break
|
|
||||||
case 'signout':
|
case 'signout':
|
||||||
if (options.pages.signOut) { return redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`) }
|
if (pages.signOut) {
|
||||||
|
return res.redirect(`${pages.signOut}${pages.signOut.includes('?') ? '&' : '?'}error=${error}`)
|
||||||
pages.render(req, res, 'signout', { baseUrl, basePath, csrfToken, callbackUrl: options.callbackUrl }, done)
|
}
|
||||||
break
|
return render.signout()
|
||||||
case 'callback':
|
case 'callback':
|
||||||
if (provider && options.providers[provider]) {
|
if (provider) {
|
||||||
callback(req, res, options, done)
|
return routes.callback(req, res)
|
||||||
} else {
|
|
||||||
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'verify-request':
|
case 'verify-request':
|
||||||
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
|
if (pages.verifyRequest) {
|
||||||
|
return res.redirect(pages.verifyRequest)
|
||||||
pages.render(req, res, 'verify-request', { baseUrl }, done)
|
}
|
||||||
break
|
return render.verifyRequest()
|
||||||
case 'error':
|
case 'error':
|
||||||
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
|
if (pages.error) {
|
||||||
|
return res.redirect(`${pages.error}${pages.error.includes('?') ? '&' : '?'}error=${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
pages.render(req, res, 'error', { baseUrl, basePath, error }, done)
|
// These error messages are displayed in line on the sign in page
|
||||||
break
|
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:
|
default:
|
||||||
res.status(404).end()
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
} else if (req.method === 'POST') {
|
} else if (req.method === 'POST') {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'signin':
|
case 'signin':
|
||||||
// Verified CSRF Token required for all sign in routes
|
// Verified CSRF Token required for all sign in routes
|
||||||
if (!csrfTokenVerified) {
|
if (csrfTokenVerified && provider) {
|
||||||
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
return routes.signin(req, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider && options.providers[provider]) {
|
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||||
signin(req, res, options, done)
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'signout':
|
case 'signout':
|
||||||
// Verified CSRF Token required for signout
|
// Verified CSRF Token required for signout
|
||||||
if (!csrfTokenVerified) {
|
if (csrfTokenVerified) {
|
||||||
return redirect(`${baseUrl}${basePath}/signout?csrf=true`)
|
return routes.signout(req, res)
|
||||||
}
|
}
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`)
|
||||||
signout(req, res, options, done)
|
|
||||||
break
|
|
||||||
case 'callback':
|
case 'callback':
|
||||||
if (provider && options.providers[provider]) {
|
if (provider) {
|
||||||
// Verified CSRF Token required for credentials providers only
|
// Verified CSRF Token required for credentials providers only
|
||||||
if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) {
|
if (provider.type === 'credentials' && !csrfTokenVerified) {
|
||||||
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(req, res, options, done)
|
return routes.callback(req, res)
|
||||||
} else {
|
|
||||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
|
return res.status(400).end(`Error: HTTP ${req.method} is not supported for ${req.url}`)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tha main entry point to next-auth */
|
||||||
|
export default function NextAuth (...args) {
|
||||||
|
if (args.length === 1) {
|
||||||
|
return (req, res) => NextAuthHandler(req, res, args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextAuthHandler(...args)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,223 +1,221 @@
|
|||||||
// This function handles the complex flow of signing users in, and either creating,
|
|
||||||
// linking (or not linking) accounts depending on if the user is currently logged
|
|
||||||
// in, if they have account already and the authentication mechanism they are using.
|
|
||||||
//
|
|
||||||
// It prevents insecure behaviour, such as linking oAuth accounts unless a user is
|
|
||||||
// signed in and authenticated with an existing valid account.
|
|
||||||
//
|
|
||||||
// All verification (e.g. oAuth flows or email address verificaiton flows) are
|
|
||||||
// done prior to this handler being called to avoid additonal complexity in this
|
|
||||||
// handler.
|
|
||||||
import { AccountNotLinkedError } from '../../lib/errors'
|
import { AccountNotLinkedError } from '../../lib/errors'
|
||||||
import dispatchEvent from '../lib/dispatch-event'
|
import dispatchEvent from '../lib/dispatch-event'
|
||||||
|
|
||||||
export default async (sessionToken, profile, providerAccount, options) => {
|
/**
|
||||||
try {
|
* This function handles the complex flow of signing users in, and either creating,
|
||||||
// Input validation
|
* linking (or not linking) accounts depending on if the user is currently logged
|
||||||
if (!profile) { throw new Error('Missing profile') }
|
* in, if they have account already and the authentication mechanism they are using.
|
||||||
if (!providerAccount || !providerAccount.id || !providerAccount.type) { throw new Error('Missing or invalid provider account') }
|
*
|
||||||
|
* It prevents insecure behaviour, such as linking OAuth accounts unless a user is
|
||||||
|
* signed in and authenticated with an existing valid account.
|
||||||
|
*
|
||||||
|
* All verification (e.g. OAuth flows or email address verificaiton flows) are
|
||||||
|
* done prior to this handler being called to avoid additonal complexity in this
|
||||||
|
* handler.
|
||||||
|
*/
|
||||||
|
export default async function callbackHandler (sessionToken, profile, providerAccount, options) {
|
||||||
|
// Input validation
|
||||||
|
if (!profile) throw new Error('Missing profile')
|
||||||
|
if (!providerAccount?.id || !providerAccount.type) throw new Error('Missing or invalid provider account')
|
||||||
|
if (!['email', 'oauth'].includes(providerAccount.type)) throw new Error('Provider not supported')
|
||||||
|
|
||||||
const { adapter, jwt, events } = options
|
const {
|
||||||
|
adapter,
|
||||||
const useJwtSession = options.session.jwt
|
jwt,
|
||||||
|
events,
|
||||||
// If no adapter is configured then we don't have a database and cannot
|
session: {
|
||||||
// persist data; in this mode we just return a dummy session object.
|
jwt: useJwtSession
|
||||||
if (!adapter) {
|
|
||||||
return {
|
|
||||||
user: profile,
|
|
||||||
account: providerAccount,
|
|
||||||
session: {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} = options
|
||||||
|
|
||||||
const {
|
// If no adapter is configured then we don't have a database and cannot
|
||||||
createUser,
|
// persist data; in this mode we just return a dummy session object.
|
||||||
updateUser,
|
if (!adapter) {
|
||||||
getUser,
|
return {
|
||||||
getUserByProviderAccountId,
|
user: profile,
|
||||||
getUserByEmail,
|
account: providerAccount,
|
||||||
linkAccount,
|
session: {}
|
||||||
createSession,
|
}
|
||||||
getSession,
|
}
|
||||||
deleteSession
|
|
||||||
} = await adapter.getAdapter(options)
|
|
||||||
|
|
||||||
let session = null
|
const {
|
||||||
let user = null
|
createUser,
|
||||||
let isSignedIn = null
|
updateUser,
|
||||||
let isNewUser = false
|
getUser,
|
||||||
|
getUserByProviderAccountId,
|
||||||
|
getUserByEmail,
|
||||||
|
linkAccount,
|
||||||
|
createSession,
|
||||||
|
getSession,
|
||||||
|
deleteSession
|
||||||
|
} = await adapter.getAdapter(options)
|
||||||
|
|
||||||
if (sessionToken) {
|
let session = null
|
||||||
if (useJwtSession) {
|
let user = null
|
||||||
try {
|
let isSignedIn = null
|
||||||
session = await jwt.decode({ ...jwt, token: sessionToken })
|
let isNewUser = false
|
||||||
if (session && session.user) {
|
|
||||||
user = await getUser(session.user.id)
|
if (sessionToken) {
|
||||||
isSignedIn = !!user
|
if (useJwtSession) {
|
||||||
}
|
try {
|
||||||
} catch (e) {
|
session = await jwt.decode({ ...jwt, token: sessionToken })
|
||||||
// If session can't be verified, treat as no session
|
if (session?.sub) {
|
||||||
}
|
user = await getUser(session.sub)
|
||||||
} else {
|
|
||||||
session = await getSession(sessionToken)
|
|
||||||
if (session && session.userId) {
|
|
||||||
user = await getUser(session.userId)
|
|
||||||
isSignedIn = !!user
|
isSignedIn = !!user
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// If session can't be verified, treat as no session
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
session = await getSession(sessionToken)
|
||||||
|
if (session?.userId) {
|
||||||
|
user = await getUser(session.userId)
|
||||||
|
isSignedIn = !!user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (providerAccount.type === 'email') {
|
if (providerAccount.type === 'email') {
|
||||||
// If signing in with an email, check if an account with the same email address exists already
|
// 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 (userByEmail) {
|
||||||
// If they are not already signed in as the same user, this flow will
|
// 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
|
// sign them out of the current session and sign them in as the new user
|
||||||
if (isSignedIn) {
|
if (isSignedIn) {
|
||||||
if (user.id !== userByEmail.id && !useJwtSession) {
|
if (user.id !== userByEmail.id && !useJwtSession) {
|
||||||
// Delete existing session if they are currently signed in as another user.
|
// Delete existing session if they are currently signed in as another user.
|
||||||
// This will switch user accounts for the session in cases where the user was
|
// This will switch user accounts for the session in cases where the user was
|
||||||
// already logged in with a different account.
|
// already logged in with a different account.
|
||||||
await deleteSession(sessionToken)
|
await deleteSession(sessionToken)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update emailVerified property on the user object
|
|
||||||
const currentDate = new Date()
|
|
||||||
user = await updateUser({ ...userByEmail, emailVerified: currentDate })
|
|
||||||
await dispatchEvent(events.updateUser, user)
|
|
||||||
} else {
|
|
||||||
// Create user account if there isn't one for the email address already
|
|
||||||
const currentDate = new Date()
|
|
||||||
user = await createUser({ ...profile, emailVerified: currentDate })
|
|
||||||
await dispatchEvent(events.createUser, user)
|
|
||||||
isNewUser = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new session
|
// Update emailVerified property on the user object
|
||||||
session = useJwtSession ? {} : await createSession(user)
|
const currentDate = new Date()
|
||||||
|
user = await updateUser({ ...userByEmail, emailVerified: currentDate })
|
||||||
|
await dispatchEvent(events.updateUser, user)
|
||||||
|
} else {
|
||||||
|
// Create user account if there isn't one for the email address already
|
||||||
|
const currentDate = new Date()
|
||||||
|
user = await createUser({ ...profile, emailVerified: currentDate })
|
||||||
|
await dispatchEvent(events.createUser, user)
|
||||||
|
isNewUser = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new session
|
||||||
|
session = useJwtSession ? {} : await createSession(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
isNewUser
|
||||||
|
}
|
||||||
|
} else if (providerAccount.type === 'oauth') {
|
||||||
|
// If signing in with oauth account, check to see if the account exists already
|
||||||
|
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
|
||||||
|
if (userByProviderAccountId) {
|
||||||
|
if (isSignedIn) {
|
||||||
|
// If the user is already signed in with this account, we don't need to do anything
|
||||||
|
// Note: These are cast as strings here to ensure they match as in
|
||||||
|
// some flows (e.g. JWT with a database) one of the values might be a
|
||||||
|
// string and the other might be an ObjectID and would otherwise fail.
|
||||||
|
if (`${userByProviderAccountId.id}` === `${user.id}`) {
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
isNewUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If the user is currently signed in, but the new account they are signing in
|
||||||
|
// with is already associated with another account, then we cannot link them
|
||||||
|
// and need to return an error.
|
||||||
|
throw new AccountNotLinkedError()
|
||||||
|
}
|
||||||
|
// If there is no active session, but the account being signed in with is already
|
||||||
|
// associated with a valid user then create session to sign the user in.
|
||||||
|
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user: userByProviderAccountId,
|
||||||
|
isNewUser
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isSignedIn) {
|
||||||
|
// If the user is already signed in and the OAuth account isn't already associated
|
||||||
|
// with another user account then we can go ahead and link the accounts safely.
|
||||||
|
await linkAccount(
|
||||||
|
user.id,
|
||||||
|
providerAccount.provider,
|
||||||
|
providerAccount.type,
|
||||||
|
providerAccount.id,
|
||||||
|
providerAccount.refreshToken,
|
||||||
|
providerAccount.accessToken,
|
||||||
|
providerAccount.accessTokenExpires
|
||||||
|
)
|
||||||
|
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
|
||||||
|
|
||||||
|
// As they are already signed in, we don't need to do anything after linking them
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
isNewUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user is not signed in and it looks like a new OAuth account then we
|
||||||
|
// check there also isn't an user account already associated with the same
|
||||||
|
// email address as the one in the OAuth profile.
|
||||||
|
//
|
||||||
|
// This step is often overlooked in OAuth implementations, but covers the following cases:
|
||||||
|
//
|
||||||
|
// 1. It makes it harder for someone to accidentally create two accounts.
|
||||||
|
// e.g. by signin in with email, then again with an oauth account connected to the same email.
|
||||||
|
// 2. It makes it harder to hijack a user account using a 3rd party OAuth account.
|
||||||
|
// e.g. by creating an oauth account then changing the email address associated with it.
|
||||||
|
//
|
||||||
|
// It's quite common for services to automatically link accounts in this case, but it's
|
||||||
|
// better practice to require the user to sign in *then* link accounts to be sure
|
||||||
|
// someone is not exploiting a problem with a third party OAuth service.
|
||||||
|
//
|
||||||
|
// OAuth providers should require email address verification to prevent this, but in
|
||||||
|
// practice that is not always the case; this helps protect against that.
|
||||||
|
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||||
|
if (userByEmail) {
|
||||||
|
// We end up here when we don't have an account with the same [provider].id *BUT*
|
||||||
|
// we do already have an account with the same email address as the one in the
|
||||||
|
// OAuth profile the user has just tried to sign in with.
|
||||||
|
//
|
||||||
|
// We don't want to have two accounts with the same email address, and we don't
|
||||||
|
// want to link them in case it's not safe to do so, so instead we prompt the user
|
||||||
|
// to sign in via email to verify their identity and then link the accounts.
|
||||||
|
throw new AccountNotLinkedError()
|
||||||
|
}
|
||||||
|
// If the current user is not logged in and the profile isn't linked to any user
|
||||||
|
// accounts (by email or provider account id)...
|
||||||
|
//
|
||||||
|
// If no account matching the same [provider].id or .email exists, we can
|
||||||
|
// create a new account for the user, link it to the OAuth acccount and
|
||||||
|
// create a new session for them so they are signed in with it.
|
||||||
|
user = await createUser(profile)
|
||||||
|
await dispatchEvent(events.createUser, user)
|
||||||
|
|
||||||
|
await linkAccount(
|
||||||
|
user.id,
|
||||||
|
providerAccount.provider,
|
||||||
|
providerAccount.type,
|
||||||
|
providerAccount.id,
|
||||||
|
providerAccount.refreshToken,
|
||||||
|
providerAccount.accessToken,
|
||||||
|
providerAccount.accessTokenExpires
|
||||||
|
)
|
||||||
|
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
|
||||||
|
|
||||||
|
session = useJwtSession ? {} : await createSession(user)
|
||||||
|
isNewUser = true
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
user,
|
user,
|
||||||
isNewUser
|
isNewUser
|
||||||
}
|
}
|
||||||
} else if (providerAccount.type === 'oauth') {
|
|
||||||
// If signing in with oauth account, check to see if the account exists already
|
|
||||||
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
|
|
||||||
if (userByProviderAccountId) {
|
|
||||||
if (isSignedIn) {
|
|
||||||
// If the user is already signed in with this account, we don't need to do anything
|
|
||||||
// Note: These are cast as strings here to ensure they match as in
|
|
||||||
// some flows (e.g. JWT with a database) one of the values might be a
|
|
||||||
// string and the other might be an ObjectID and would otherwise fail.
|
|
||||||
if (`${userByProviderAccountId.id}` === `${user.id}`) {
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
isNewUser
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If the user is currently signed in, but the new account they are signing in
|
|
||||||
// with is already associated with another account, then we cannot link them
|
|
||||||
// and need to return an error.
|
|
||||||
throw new AccountNotLinkedError()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If there is no active session, but the account being signed in with is already
|
|
||||||
// associated with a valid user then create session to sign the user in.
|
|
||||||
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
user: userByProviderAccountId,
|
|
||||||
isNewUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (isSignedIn) {
|
|
||||||
// If the user is already signed in and the oAuth account isn't already associated
|
|
||||||
// with another user account then we can go ahead and link the accounts safely.
|
|
||||||
await linkAccount(
|
|
||||||
user.id,
|
|
||||||
providerAccount.provider,
|
|
||||||
providerAccount.type,
|
|
||||||
providerAccount.id,
|
|
||||||
providerAccount.refreshToken,
|
|
||||||
providerAccount.accessToken,
|
|
||||||
providerAccount.accessTokenExpires
|
|
||||||
)
|
|
||||||
await dispatchEvent(events.linkAccount, { user, providerAccount })
|
|
||||||
|
|
||||||
// As they are already signed in, we don't need to do anything after linking them
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
isNewUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the user is not signed in and it looks like a new oAuth account then we
|
|
||||||
// check there also isn't an user account already associated with the same
|
|
||||||
// email address as the one in the oAuth profile.
|
|
||||||
//
|
|
||||||
// This step is often overlooked in oAuth implementations, but covers the following cases:
|
|
||||||
//
|
|
||||||
// 1. It makes it harder for someone to accidentally create two accounts.
|
|
||||||
// e.g. by signin in with email, then again with an oauth account connected to the same email.
|
|
||||||
// 2. It makes it harder to hijack a user account using a 3rd party oAuth account.
|
|
||||||
// e.g. by creating an oauth account then changing the email address associated with it.
|
|
||||||
//
|
|
||||||
// It's quite common for services to automatically link accounts in this case, but it's
|
|
||||||
// better practice to require the user to sign in *then* link accounts to be sure
|
|
||||||
// someone is not exploiting a problem with a third party oAuth service.
|
|
||||||
//
|
|
||||||
// oAuth providers should require email address verification to prevent this, but in
|
|
||||||
// practice that is not always the case; this helps protect against that.
|
|
||||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
|
||||||
if (userByEmail) {
|
|
||||||
// We end up here when we don't have an account with the same [provider].id *BUT*
|
|
||||||
// we do already have an account with the same email address as the one in the
|
|
||||||
// oAuth profile the user has just tried to sign in with.
|
|
||||||
//
|
|
||||||
// We don't want to have two accounts with the same email address, and we don't
|
|
||||||
// want to link them in case it's not safe to do so, so instead we prompt the user
|
|
||||||
// to sign in via email to verify their identity and then link the accounts.
|
|
||||||
throw new AccountNotLinkedError()
|
|
||||||
} else {
|
|
||||||
// If the current user is not logged in and the profile isn't linked to any user
|
|
||||||
// accounts (by email or provider account id)...
|
|
||||||
//
|
|
||||||
// If no account matching the same [provider].id or .email exists, we can
|
|
||||||
// create a new account for the user, link it to the oAuth acccount and
|
|
||||||
// create a new session for them so they are signed in with it.
|
|
||||||
user = await createUser(profile)
|
|
||||||
await dispatchEvent(events.createUser, user)
|
|
||||||
|
|
||||||
await linkAccount(
|
|
||||||
user.id,
|
|
||||||
providerAccount.provider,
|
|
||||||
providerAccount.type,
|
|
||||||
providerAccount.id,
|
|
||||||
providerAccount.refreshToken,
|
|
||||||
providerAccount.accessToken,
|
|
||||||
providerAccount.accessTokenExpires
|
|
||||||
)
|
|
||||||
await dispatchEvent(events.linkAccount, { user, providerAccount })
|
|
||||||
|
|
||||||
session = useJwtSession ? {} : await createSession(user)
|
|
||||||
isNewUser = true
|
|
||||||
return {
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
isNewUser
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Promise.reject(new Error('Provider not supported'))
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import cookie from '../lib/cookie'
|
import * as cookie from '../lib/cookie'
|
||||||
|
|
||||||
export default async (req, res, options) => {
|
/**
|
||||||
|
* Get callback URL based on query param / cookie + validation,
|
||||||
|
* and add it to `req.options.callbackUrl`.
|
||||||
|
* @note: `req.options` must already be defined when called.
|
||||||
|
*/
|
||||||
|
export default async function callbackUrlHandler (req, res) {
|
||||||
const { query } = req
|
const { query } = req
|
||||||
const { body } = req
|
const { body } = req
|
||||||
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = options
|
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = req.options
|
||||||
|
|
||||||
// Handle preserving and validating callback URLs
|
// Handle preserving and validating callback URLs
|
||||||
// If no defaultCallbackUrl option specified, default to the homepage for the site
|
// If no defaultCallbackUrl option specified, default to the homepage for the site
|
||||||
let callbackUrl = defaultCallbackUrl || baseUrl
|
let callbackUrl = defaultCallbackUrl || baseUrl
|
||||||
|
|
||||||
// Try reading callbackUrlParamValue from request body (form submission) then from query param (get request)
|
// Try reading callbackUrlParamValue from request body (form submission) then from query param (get request)
|
||||||
const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null
|
const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null
|
||||||
const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null
|
const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null
|
||||||
@@ -21,7 +25,9 @@ export default async (req, res, options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save callback URL in a cookie so that can be used for subsequent requests in signin/signout/callback flow
|
// Save callback URL in a cookie so that can be used for subsequent requests in signin/signout/callback flow
|
||||||
if (callbackUrl && (callbackUrl !== callbackUrlCookieValue)) { cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options) }
|
if (callbackUrl && (callbackUrl !== callbackUrlCookieValue)) {
|
||||||
|
cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options)
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve(callbackUrl)
|
req.options.callbackUrl = callbackUrl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// Function to set cookies server side
|
/**
|
||||||
//
|
* Function to set cookies server side
|
||||||
// Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
|
*
|
||||||
// * https://github.com/jshttp/cookie/blob/master/index.js
|
* Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
|
||||||
// * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
|
* * https://github.com/jshttp/cookie/blob/master/index.js
|
||||||
//
|
* * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
|
||||||
// As only partial functionlity is required, only the code we need has been incorporated here
|
*
|
||||||
// (with fixes for specific issues) to keep dependancy size down.
|
* As only partial functionlity is required, only the code we need has been incorporated here
|
||||||
const set = (res, name, value, options = {}) => {
|
* (with fixes for specific issues) to keep dependancy size down.
|
||||||
|
*/
|
||||||
|
export function set (res, name, value, options = {}) {
|
||||||
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
|
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
|
||||||
|
|
||||||
if ('maxAge' in options) {
|
if ('maxAge' in options) {
|
||||||
@@ -99,6 +101,47 @@ function _serialize (name, val, options) {
|
|||||||
return str
|
return str
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
/**
|
||||||
set
|
* Use secure cookies if the site uses HTTPS
|
||||||
|
* This being conditional allows cookies to work non-HTTPS development URLs
|
||||||
|
* Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
|
||||||
|
* prefix, but enable them by default if the site URL is HTTPS; but not for
|
||||||
|
* non-HTTPS URLs like http://localhost which are used in development).
|
||||||
|
* For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
|
||||||
|
*
|
||||||
|
* @TODO Review cookie settings (names, options)
|
||||||
|
*/
|
||||||
|
export function defaultCookies (useSecureCookies) {
|
||||||
|
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
|
||||||
|
return {
|
||||||
|
// default cookie options
|
||||||
|
sessionToken: {
|
||||||
|
name: `${cookiePrefix}next-auth.session-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: useSecureCookies
|
||||||
|
}
|
||||||
|
},
|
||||||
|
callbackUrl: {
|
||||||
|
name: `${cookiePrefix}next-auth.callback-url`,
|
||||||
|
options: {
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: useSecureCookies
|
||||||
|
}
|
||||||
|
},
|
||||||
|
csrfToken: {
|
||||||
|
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
|
||||||
|
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
|
||||||
|
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
secure: useSecureCookies
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
src/server/lib/create-secret.js
Normal file
13
src/server/lib/create-secret.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createHash } from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret used salt cookies and tokens (e.g. for CSRF protection).
|
||||||
|
* If no secret option is specified then it creates one on the fly
|
||||||
|
* based on options passed here. A options contains unique data, such as
|
||||||
|
* OAuth provider secrets and database credentials it should be sufficent.
|
||||||
|
*/
|
||||||
|
export default function createSecret ({ userOptions, basePath, baseUrl }) {
|
||||||
|
return userOptions.secret || createHash('sha256').update(JSON.stringify({
|
||||||
|
baseUrl, basePath, ...userOptions
|
||||||
|
})).digest('hex')
|
||||||
|
}
|
||||||
42
src/server/lib/csrf-token-handler.js
Normal file
42
src/server/lib/csrf-token-handler.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { createHash, randomBytes } from 'crypto'
|
||||||
|
import * as cookie from './cookie'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure CSRF Token cookie is set for any subsequent requests.
|
||||||
|
* Used as part of the strateigy for mitigation for CSRF tokens.
|
||||||
|
*
|
||||||
|
* Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
|
||||||
|
* where 'token' is the CSRF token and 'hash' is a hash made of the token and
|
||||||
|
* the secret, and the two values are joined by a pipe '|'. By storing the
|
||||||
|
* value and the hash of the value (with the secret used as a salt) we can
|
||||||
|
* verify the cookie was set by the server and not by a malicous attacker.
|
||||||
|
*
|
||||||
|
* For more details, see the following OWASP links:
|
||||||
|
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
||||||
|
* https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
|
||||||
|
*/
|
||||||
|
export default function csrfTokenHandler (req, res, cookies, secret) {
|
||||||
|
const { csrfToken: csrfTokenFromRequest } = req.body
|
||||||
|
|
||||||
|
let csrfTokenFromCookie
|
||||||
|
let csrfTokenVerified = false
|
||||||
|
if (req.cookies[cookies.csrfToken.name]) {
|
||||||
|
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
|
||||||
|
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
|
||||||
|
// If hash matches then we trust the CSRF token value
|
||||||
|
csrfTokenFromCookie = csrfTokenValue
|
||||||
|
|
||||||
|
// If this is a POST request and the CSRF Token in the Post request matches
|
||||||
|
// the cookie we have already verified is one we have set, then token is verified!
|
||||||
|
if (req.method === 'POST' && csrfTokenFromCookie === csrfTokenFromRequest) { csrfTokenVerified = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!csrfTokenFromCookie) {
|
||||||
|
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
|
||||||
|
// (e.g. because it's been modifed or because the secret has changed) create a new token.
|
||||||
|
csrfTokenFromCookie = randomBytes(32).toString('hex')
|
||||||
|
const newCsrfTokenCookie = `${csrfTokenFromCookie}|${createHash('sha256').update(`${csrfTokenFromCookie}${secret}`).digest('hex')}`
|
||||||
|
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
|
||||||
|
}
|
||||||
|
return { csrfToken: csrfTokenFromCookie, csrfTokenVerified }
|
||||||
|
}
|
||||||
@@ -9,19 +9,14 @@
|
|||||||
* requests to sign in and again when they activate the link in the sign in
|
* requests to sign in and again when they activate the link in the sign in
|
||||||
* email.
|
* email.
|
||||||
*
|
*
|
||||||
* @param {object} profile User profile (e.g. user id, name, email)
|
* @param {object} profile User profile (e.g. user id, name, email)
|
||||||
* @param {object} account Account used to sign in (e.g. OAuth account)
|
* @param {object} account Account used to sign in (e.g. OAuth account)
|
||||||
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
|
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
|
||||||
* @return {boolean|object} Return `true` (or a modified JWT) to allow sign in
|
* @return {Promise<boolean|never>} Return `true` (or a modified JWT) to allow sign in
|
||||||
* Return `false` to deny access
|
* Return `false` to deny access
|
||||||
*/
|
*/
|
||||||
const signIn = async (profile, account, metadata) => {
|
export async function signIn () {
|
||||||
const isAllowedToSignIn = true
|
return true
|
||||||
if (isAllowedToSignIn) {
|
|
||||||
return Promise.resolve(true)
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,12 +26,13 @@ const signIn = async (profile, account, metadata) => {
|
|||||||
*
|
*
|
||||||
* @param {string} url URL provided as callback URL by the client
|
* @param {string} url URL provided as callback URL by the client
|
||||||
* @param {string} baseUrl Default base URL of site (can be used as fallback)
|
* @param {string} baseUrl Default base URL of site (can be used as fallback)
|
||||||
* @return {string} URL the client will be redirect to
|
* @return {Promise<string>} URL the client will be redirect to
|
||||||
*/
|
*/
|
||||||
const redirect = async (url, baseUrl) => {
|
export async function redirect (url, baseUrl) {
|
||||||
return url.startsWith(baseUrl)
|
if (url.startsWith(baseUrl)) {
|
||||||
? Promise.resolve(url)
|
return url
|
||||||
: Promise.resolve(baseUrl)
|
}
|
||||||
|
return baseUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,31 +41,24 @@ const redirect = async (url, baseUrl) => {
|
|||||||
*
|
*
|
||||||
* @param {object} session Session object
|
* @param {object} session Session object
|
||||||
* @param {object} token JSON Web Token (if enabled)
|
* @param {object} token JSON Web Token (if enabled)
|
||||||
* @return {object} Session that will be returned to the client
|
* @return {Promise<object>} Session that will be returned to the client
|
||||||
*/
|
*/
|
||||||
const session = async (session, token) => {
|
export async function session (session) {
|
||||||
return Promise.resolve(session)
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This callback is called whenever a JSON Web Token is created / updated.
|
* This callback is called whenever a JSON Web Token is created / updated.
|
||||||
* e.g. On sign in, `getSession()`, `useSession()`, `/api/auth/session` (etc)
|
* e.g. On sign in, `getSession()`, `useSession()`, `/api/auth/session` (etc)
|
||||||
*
|
*
|
||||||
* On initial sign in, the raw oAuthProfile is passed if the user is signing in
|
* On initial sign in, the raw OAuthProfile is passed if the user is signing in
|
||||||
* with an OAuth provider. It is not avalible on subsequent calls. You can
|
* with an OAuth provider. It is not avalible on subsequent calls. You can
|
||||||
* take advantage of this to persist additional data you need to in the JWT.
|
* take advantage of this to persist additional data you need to in the JWT.
|
||||||
*
|
*
|
||||||
* @param {object} token Decrypted JSON Web Token
|
* @param {object} token Decrypted JSON Web Token
|
||||||
* @param {object} oAuthProfile OAuth profile - only available on sign in
|
* @param {object} oAuthProfile OAuth profile - only available on sign in
|
||||||
* @return {object} JSON Web Token that will be saved
|
* @return {Promise<object>} JSON Web Token that will be saved
|
||||||
*/
|
*/
|
||||||
const jwt = async (token, oAuthProfile) => {
|
export async function jwt (token) {
|
||||||
return Promise.resolve(token)
|
return token
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
signIn,
|
|
||||||
redirect,
|
|
||||||
session,
|
|
||||||
jwt
|
|
||||||
}
|
}
|
||||||
23
src/server/lib/default-events.js
Normal file
23
src/server/lib/default-events.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
/** Event triggered on successful sign in */
|
||||||
|
export async function signIn (message) {}
|
||||||
|
|
||||||
|
/** Event triggered on sign out */
|
||||||
|
export async function signOut (message) {}
|
||||||
|
|
||||||
|
/** Event triggered on user creation */
|
||||||
|
export async function createUser (message) {}
|
||||||
|
|
||||||
|
/** Event triggered when a user object is updated */
|
||||||
|
export async function updateUser (message) {}
|
||||||
|
|
||||||
|
/** Event triggered when an account is linked to a user */
|
||||||
|
export async function linkAccount (message) {}
|
||||||
|
|
||||||
|
/** Event triggered when a session is active */
|
||||||
|
export async function session (message) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @TODO Event triggered when something goes wrong in an authentication flow
|
||||||
|
* This event may be fired multiple times when an error occurs
|
||||||
|
*/
|
||||||
|
export async function error (message) {}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import logger from '../../lib/logger'
|
import logger from '../../lib/logger'
|
||||||
|
|
||||||
export default async (event, message) => {
|
export default async function dispatchEvent (event, message) {
|
||||||
try {
|
try {
|
||||||
await event(message)
|
await event(message)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
const signIn = async (message) => {
|
|
||||||
// Event triggered on successful sign in
|
|
||||||
}
|
|
||||||
|
|
||||||
const signOut = async (message) => {
|
|
||||||
// Event triggered on sign out
|
|
||||||
}
|
|
||||||
|
|
||||||
const createUser = async (message) => {
|
|
||||||
// Event triggered on user creation
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateUser = async (message) => {
|
|
||||||
// Event triggered when a user object is updated
|
|
||||||
}
|
|
||||||
|
|
||||||
const linkAccount = async (message) => {
|
|
||||||
// Event triggered when an account is linked to a user
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = async (message) => {
|
|
||||||
// Event triggered when a session is active
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = async (message) => {
|
|
||||||
// @TODO Event triggered when something goes wrong in an authentication flow
|
|
||||||
// This event may be fired multiple times when an error occurs
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
signIn,
|
|
||||||
signOut,
|
|
||||||
createUser,
|
|
||||||
updateUser,
|
|
||||||
linkAccount,
|
|
||||||
session,
|
|
||||||
error
|
|
||||||
}
|
|
||||||
35
src/server/lib/extend-req.js
Normal file
35
src/server/lib/extend-req.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Extends res.{end,json,send} with `done()`,
|
||||||
|
* and redirect to support sending url as json.
|
||||||
|
*
|
||||||
|
* When a response is complete, it will call the `done` method,
|
||||||
|
* so that the serverless function knows when it is
|
||||||
|
* safe to return and that no more data will be sent.
|
||||||
|
*/
|
||||||
|
export default function extendRes (req, res, done) {
|
||||||
|
const originalResEnd = res.end.bind(res)
|
||||||
|
res.end = (...args) => {
|
||||||
|
done()
|
||||||
|
return originalResEnd(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalResJson = res.json.bind(res)
|
||||||
|
res.json = (...args) => {
|
||||||
|
done()
|
||||||
|
return originalResJson(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalResSend = res.send.bind(res)
|
||||||
|
res.send = (...args) => {
|
||||||
|
done()
|
||||||
|
return originalResSend(...args)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect = (url) => {
|
||||||
|
if (req.body?.json === 'true') {
|
||||||
|
return res.json({ url })
|
||||||
|
}
|
||||||
|
res.status(302).setHeader('Location', url)
|
||||||
|
return res.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,50 +1,50 @@
|
|||||||
|
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import querystring from 'querystring'
|
import { decode as jwtDecode } from 'jsonwebtoken'
|
||||||
import jwtDecode from 'jwt-decode'
|
|
||||||
import oAuthClient from './client'
|
import oAuthClient from './client'
|
||||||
import logger from '../../../lib/logger'
|
import logger from '../../../lib/logger'
|
||||||
|
class OAuthCallbackError extends Error {
|
||||||
|
constructor (message) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'OAuthCallbackError'
|
||||||
|
this.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// @TODO Refactor monkey patching in _getOAuthAccessToken() and _get()
|
export default async function oAuthCallback (req) {
|
||||||
// These methods have been forked from `node-oauth` to fix bugs; it may make
|
const { provider, csrfToken } = req.options
|
||||||
// sense to migrate all the methods we need from node-oauth to nexth-auth (with
|
|
||||||
// appropriate credit) to make it easier to maintain and address issues as they
|
|
||||||
// come up, as the node-oauth package does not seem to be actively maintained.
|
|
||||||
|
|
||||||
// @TODO Refactor to use promises and not callbacks
|
|
||||||
// @TODO Refactor to use jsonwebtoken instead of jwt-decode & remove dependancy
|
|
||||||
export default async (req, provider, csrfToken, callback) => {
|
|
||||||
// The "user" object is specific to apple provider and is provided on first sign in
|
|
||||||
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
|
|
||||||
let { oauth_token, oauth_verifier, code, user, state } = req.query // eslint-disable-line camelcase
|
|
||||||
const client = oAuthClient(provider)
|
const client = oAuthClient(provider)
|
||||||
|
|
||||||
if (provider.version && provider.version.startsWith('2.')) {
|
if (provider.version?.startsWith('2.')) {
|
||||||
|
// The "user" object is specific to the Apple provider and is provided on first sign in
|
||||||
|
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
|
||||||
|
let { code, user, state } = req.query // eslint-disable-line camelcase
|
||||||
// For OAuth 2.0 flows, check state returned and matches expected value
|
// For OAuth 2.0 flows, check state returned and matches expected value
|
||||||
// (a hash of the NextAuth.js CSRF token).
|
// (a hash of the NextAuth.js CSRF token).
|
||||||
//
|
//
|
||||||
// This check can be disabled for providers that do not support it by
|
// Apple does not support state verification.
|
||||||
// setting `state: false` as a option on the provider (defaults to true).
|
if (provider.id !== 'apple') {
|
||||||
if (!Object.prototype.hasOwnProperty.call(provider, 'state') || provider.state === true) {
|
|
||||||
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
|
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
|
||||||
if (state !== expectedState) {
|
if (state !== expectedState) {
|
||||||
return callback(new Error('Invalid state returned from oAuth provider'))
|
throw new OAuthCallbackError('Invalid state returned from OAuth provider')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(JSON.stringify(req.body))
|
const body = JSON.parse(JSON.stringify(req.body))
|
||||||
if (body.error) { throw new Error(body.error) }
|
if (body.error) {
|
||||||
|
throw new Error(body.error)
|
||||||
|
}
|
||||||
|
|
||||||
code = body.code
|
code = body.code
|
||||||
user = body.user != null ? JSON.parse(body.user) : null
|
user = body.user != null ? JSON.parse(body.user) : null
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', e, req.body, provider.id, code)
|
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error, req.body, provider.id, code)
|
||||||
return callback()
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// REVIEW: Is this used by any of the providers?
|
||||||
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
|
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
|
||||||
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
|
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
|
||||||
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
|
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
|
||||||
@@ -52,78 +52,56 @@ export default async (req, provider, csrfToken, callback) => {
|
|||||||
client.useAuthorizationHeaderforGET(true)
|
client.useAuthorizationHeaderforGET(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom getOAuthAccessToken() method for oAuth2 flows
|
try {
|
||||||
client.getOAuthAccessToken = _getOAuthAccessToken
|
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider)
|
||||||
|
const tokens = { accessToken, refreshToken, idToken: results.id_token }
|
||||||
await client.getOAuthAccessToken(
|
let profileData
|
||||||
code,
|
if (provider.idToken) {
|
||||||
provider,
|
// If we don't have an ID Token most likely the user hit a cancel
|
||||||
(error, accessToken, refreshToken, results) => {
|
// button when signing in (or the provider is misconfigured).
|
||||||
if (error || results.error) {
|
//
|
||||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, results, provider.id, code)
|
// Unfortunately, we can't tell which, so we can't treat it as an
|
||||||
return callback(error || results.error)
|
// error, so instead we just returning nothing, which will cause the
|
||||||
|
// user to be redirected back to the sign in page.
|
||||||
|
if (!results?.id_token) {
|
||||||
|
throw new OAuthCallbackError()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.idToken) {
|
// Support services that use OpenID ID Tokens to encode profile data
|
||||||
// If we don't have an ID Token most likely the user hit a cancel
|
profileData = decodeIdToken(results.id_token)
|
||||||
// button when signing in (or the provider is misconfigured).
|
} else {
|
||||||
//
|
profileData = await client.get(provider, accessToken, results)
|
||||||
// Unfortunately, we can't tell which, so we can't treat it as an
|
|
||||||
// error, so instead we just returning nothing, which will cause the
|
|
||||||
// user to be redirected back to the sign in page.
|
|
||||||
if (!results || !results.id_token) {
|
|
||||||
return callback()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support services that use OpenID ID Tokens to encode profile data
|
|
||||||
_decodeToken(
|
|
||||||
provider,
|
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
results.id_token,
|
|
||||||
async (error, profileData) => {
|
|
||||||
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider, user)
|
|
||||||
callback(error, profile, account, OAuthProfile)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Use custom get() method for oAuth2 flows
|
|
||||||
client.get = _get
|
|
||||||
|
|
||||||
client.get(
|
|
||||||
provider,
|
|
||||||
accessToken,
|
|
||||||
async (error, profileData) => {
|
|
||||||
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
|
|
||||||
callback(error, profile, account, OAuthProfile)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Handle oAuth v1.x
|
|
||||||
await client.getOAuthAccessToken(
|
|
||||||
oauth_token,
|
|
||||||
null,
|
|
||||||
oauth_verifier,
|
|
||||||
(error, accessToken, refreshToken, results) => {
|
|
||||||
// @TODO Handle error
|
|
||||||
if (error || results.error) {
|
|
||||||
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error, results)
|
|
||||||
}
|
|
||||||
|
|
||||||
client.get(
|
return _getProfile({ profileData, provider, tokens, user })
|
||||||
provider.profileUrl,
|
} catch (error) {
|
||||||
accessToken,
|
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, provider.id, code)
|
||||||
refreshToken,
|
throw error
|
||||||
async (error, profileData) => {
|
}
|
||||||
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
|
}
|
||||||
callback(error, profile, account, OAuthProfile)
|
|
||||||
}
|
try {
|
||||||
)
|
// Handle OAuth v1.x
|
||||||
}
|
const {
|
||||||
|
oauth_token: oauthToken, oauth_verifier: oauthVerifier
|
||||||
|
} = req.query
|
||||||
|
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(oauthToken, null, oauthVerifier)
|
||||||
|
const profileData = await client.get(
|
||||||
|
provider.profileUrl,
|
||||||
|
accessToken,
|
||||||
|
refreshToken
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
accessToken, refreshToken, idToken: results.id_token
|
||||||
|
}
|
||||||
|
|
||||||
|
return _getProfile({
|
||||||
|
profileData, tokens, provider
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error)
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,25 +109,41 @@ export default async (req, provider, csrfToken, callback) => {
|
|||||||
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
|
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
|
||||||
* Returns profile, raw profile and auth provider details
|
* Returns profile, raw profile and auth provider details
|
||||||
*/
|
*/
|
||||||
async function _getProfile (error, profileData, accessToken, refreshToken, provider, userData) {
|
async function _getProfile ({
|
||||||
// @TODO Handle error
|
profileData, tokens: { accessToken, refreshToken, idToken }, provider, user
|
||||||
if (error) {
|
}) {
|
||||||
logger.error('OAUTH_GET_PROFILE_ERROR', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
let profile = {}
|
|
||||||
try {
|
try {
|
||||||
// Convert profileData into an object if it's a string
|
// Convert profileData into an object if it's a string
|
||||||
if (typeof profileData === 'string' || profileData instanceof String) { profileData = JSON.parse(profileData) }
|
if (typeof profileData === 'string' || profileData instanceof String) {
|
||||||
|
profileData = JSON.parse(profileData)
|
||||||
|
}
|
||||||
|
|
||||||
// If a user object is supplied (e.g. Apple provider) add it to the profile object
|
// If a user object is supplied (e.g. Apple provider) add it to the profile object
|
||||||
if (userData != null) {
|
if (user != null) {
|
||||||
profileData.user = userData
|
profileData.user = user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profileData.idToken = idToken
|
||||||
|
|
||||||
logger.debug('PROFILE_DATA', profileData)
|
logger.debug('PROFILE_DATA', profileData)
|
||||||
|
|
||||||
profile = await provider.profile(profileData)
|
const profile = await provider.profile(profileData)
|
||||||
|
// Return profile, raw profile and auth provider details
|
||||||
|
return {
|
||||||
|
profile: {
|
||||||
|
...profile,
|
||||||
|
email: profile.email?.toLowerCase() ?? null
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
provider: provider.id,
|
||||||
|
type: provider.type,
|
||||||
|
id: profile.id,
|
||||||
|
refreshToken,
|
||||||
|
accessToken,
|
||||||
|
accessTokenExpires: null
|
||||||
|
},
|
||||||
|
OAuthProfile: profileData
|
||||||
|
}
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
// If we didn't get a response either there was a problem with the provider
|
// 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.
|
// response *or* the user cancelled the action with the provider.
|
||||||
@@ -165,111 +159,11 @@ async function _getProfile (error, profileData, accessToken, refreshToken, provi
|
|||||||
OAuthProfile: profileData
|
OAuthProfile: profileData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return profile, raw profile and auth provider details
|
|
||||||
return {
|
|
||||||
profile: {
|
|
||||||
name: profile.name,
|
|
||||||
email: profile.email ? profile.email.toLowerCase() : null,
|
|
||||||
image: profile.image
|
|
||||||
},
|
|
||||||
account: {
|
|
||||||
provider: provider.id,
|
|
||||||
type: provider.type,
|
|
||||||
id: profile.id,
|
|
||||||
refreshToken,
|
|
||||||
accessToken,
|
|
||||||
accessTokenExpires: null
|
|
||||||
},
|
|
||||||
OAuthProfile: profileData
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
function decodeIdToken (idToken) {
|
||||||
async function _getOAuthAccessToken (code, provider, callback) {
|
if (!idToken) {
|
||||||
const url = provider.accessTokenUrl
|
throw new OAuthCallbackError('Missing JWT ID Token')
|
||||||
const setGetAccessTokenAuthHeader = (provider.setGetAccessTokenAuthHeader !== null) ? provider.setGetAccessTokenAuthHeader : true
|
|
||||||
const params = { ...provider.params } || {}
|
|
||||||
const headers = { ...provider.headers } || {}
|
|
||||||
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
|
|
||||||
|
|
||||||
if (!params[codeParam]) { params[codeParam] = code }
|
|
||||||
|
|
||||||
if (!params.client_id) { params.client_id = provider.clientId }
|
|
||||||
|
|
||||||
if (!params.client_secret) {
|
|
||||||
// For some providers it useful to be able to generate the secret on the fly
|
|
||||||
// e.g. For Sign in With Apple a JWT token using the properties in clientSecret
|
|
||||||
if (provider.clientSecretCallback) {
|
|
||||||
params.client_secret = await provider.clientSecretCallback(provider.clientSecret)
|
|
||||||
} else {
|
|
||||||
params.client_secret = provider.clientSecret
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return jwtDecode(idToken, { json: true })
|
||||||
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
|
|
||||||
|
|
||||||
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
|
|
||||||
|
|
||||||
// Added as a fix to accomodate change in Twitch oAuth API
|
|
||||||
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
|
|
||||||
|
|
||||||
// Okta errors when this is set. Maybe there are other Providers that also wont like this.
|
|
||||||
if (setGetAccessTokenAuthHeader) {
|
|
||||||
if (!headers.Authorization) { headers.Authorization = `Bearer ${code}` }
|
|
||||||
}
|
|
||||||
|
|
||||||
const postData = querystring.stringify(params)
|
|
||||||
|
|
||||||
this._request(
|
|
||||||
'POST',
|
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
postData,
|
|
||||||
null,
|
|
||||||
(error, data, response) => {
|
|
||||||
if (error) {
|
|
||||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
let results
|
|
||||||
try {
|
|
||||||
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
|
|
||||||
// responses should be in JSON
|
|
||||||
results = JSON.parse(data)
|
|
||||||
} catch (e) {
|
|
||||||
// However both Facebook + Github currently use rev05 of the spec and neither
|
|
||||||
// seem to specify a content-type correctly in their response headers. :(
|
|
||||||
// Clients of these services suffer a minor performance cost.
|
|
||||||
results = querystring.parse(data)
|
|
||||||
}
|
|
||||||
const accessToken = results.access_token
|
|
||||||
const refreshToken = results.refresh_token
|
|
||||||
callback(null, accessToken, refreshToken, results)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
|
||||||
function _get (provider, accessToken, callback) {
|
|
||||||
const url = provider.profileUrl
|
|
||||||
const headers = provider.headers || {}
|
|
||||||
|
|
||||||
if (this._useAuthorizationHeaderForGET) {
|
|
||||||
headers.Authorization = this.buildAuthHeader(accessToken)
|
|
||||||
|
|
||||||
// This line is required for Twitch
|
|
||||||
headers['Client-ID'] = provider.clientId
|
|
||||||
accessToken = null
|
|
||||||
}
|
|
||||||
|
|
||||||
this._request('GET', url, headers, null, accessToken, callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
function _decodeToken (provider, accessToken, refreshToken, idToken, callback) {
|
|
||||||
if (!idToken) { throw new Error('Missing JWT ID Token', provider, idToken) }
|
|
||||||
const decodedToken = jwtDecode(idToken)
|
|
||||||
const profileData = JSON.stringify(decodedToken)
|
|
||||||
callback(null, profileData, accessToken, refreshToken, provider)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,226 @@
|
|||||||
// @TODO Refactor to remove dependancy on 'oauth' package
|
|
||||||
// It is already quite monkey patched, we don't use all the features and and it
|
|
||||||
// would be easier to maintain if all the code was native to next-auth.
|
|
||||||
import { OAuth, OAuth2 } from 'oauth'
|
import { OAuth, OAuth2 } from 'oauth'
|
||||||
|
import querystring from 'querystring'
|
||||||
|
import logger from '../../../lib/logger'
|
||||||
|
import { sign as jwtSign } from 'jsonwebtoken'
|
||||||
|
|
||||||
export default (provider) => {
|
/**
|
||||||
if (provider.version && provider.version.startsWith('2.')) {
|
* @TODO Refactor to remove dependancy on 'oauth' package
|
||||||
// Handle oAuth v2.x
|
* It is already quite monkey patched, we don't use all the features and and it
|
||||||
const basePath = new URL(provider.authorizationUrl).origin
|
* would be easier to maintain if all the code was native to next-auth.
|
||||||
const authorizePath = new URL(provider.authorizationUrl).pathname
|
*/
|
||||||
|
export default function oAuthClient (provider) {
|
||||||
|
if (provider.version?.startsWith('2.')) {
|
||||||
|
// Handle OAuth v2.x
|
||||||
|
const authorizationUrl = new URL(provider.authorizationUrl)
|
||||||
|
const basePath = authorizationUrl.origin
|
||||||
|
const authorizePath = authorizationUrl.pathname
|
||||||
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
|
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
|
||||||
return new OAuth2(
|
const oauth2Client = new OAuth2(
|
||||||
provider.clientId,
|
provider.clientId,
|
||||||
provider.clientSecret,
|
provider.clientSecret,
|
||||||
basePath,
|
basePath,
|
||||||
authorizePath,
|
authorizePath,
|
||||||
accessTokenPath,
|
accessTokenPath,
|
||||||
provider.headers)
|
provider.headers
|
||||||
} else {
|
|
||||||
// Handle oAuth v1.x
|
|
||||||
return new OAuth(
|
|
||||||
provider.requestTokenUrl,
|
|
||||||
provider.accessTokenUrl,
|
|
||||||
provider.clientId,
|
|
||||||
provider.clientSecret,
|
|
||||||
(provider.version || '1.0'),
|
|
||||||
provider.callbackUrl,
|
|
||||||
(provider.encoding || 'HMAC-SHA1')
|
|
||||||
)
|
)
|
||||||
|
oauth2Client.getOAuthAccessToken = getOAuth2AccessToken
|
||||||
|
oauth2Client.get = getOAuth2
|
||||||
|
return oauth2Client
|
||||||
}
|
}
|
||||||
|
// Handle OAuth v1.x
|
||||||
|
const oauth1Client = new OAuth(
|
||||||
|
provider.requestTokenUrl,
|
||||||
|
provider.accessTokenUrl,
|
||||||
|
provider.clientId,
|
||||||
|
provider.clientSecret,
|
||||||
|
provider.version || '1.0',
|
||||||
|
provider.callbackUrl,
|
||||||
|
provider.encoding || 'HMAC-SHA1'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Promisify get() and getOAuth2AccessToken() for OAuth1
|
||||||
|
const originalGet = oauth1Client.get
|
||||||
|
oauth1Client.get = (...args) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
originalGet(...args, (error, result) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
resolve(result)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const originalGetOAuth1AccessToken = oauth1Client.getOAuthAccessToken
|
||||||
|
oauth1Client.getOAuthAccessToken = (...args) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
originalGetOAuth1AccessToken(...args, (error, accessToken, refreshToken, results) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
resolve({ accessToken, refreshToken, results })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalGetOAuthRequestToken = oauth1Client.getOAuthRequestToken
|
||||||
|
oauth1Client.getOAuthRequestToken = (...args) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
originalGetOAuthRequestToken(...args, (error, oauthToken) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
resolve(oauthToken)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return oauth1Client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @TODO Refactor monkey patching in OAuth2.getOAuthAccessToken() and OAuth2.get()
|
||||||
|
* These methods have been forked from `node-oauth` to fix bugs; it may make
|
||||||
|
* sense to migrate all the methods we need from node-oauth to nexth-auth (with
|
||||||
|
* appropriate credit) to make it easier to maintain and address issues as they
|
||||||
|
* come up, as the node-oauth package does not seem to be actively maintained.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||||
|
*/
|
||||||
|
async function getOAuth2AccessToken (code, provider) {
|
||||||
|
const url = provider.accessTokenUrl
|
||||||
|
const params = { ...provider.params }
|
||||||
|
const headers = { ...provider.headers }
|
||||||
|
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
|
||||||
|
|
||||||
|
if (!params[codeParam]) { params[codeParam] = code }
|
||||||
|
|
||||||
|
if (!params.client_id) { params.client_id = provider.clientId }
|
||||||
|
|
||||||
|
// For Apple the client secret must be generated on-the-fly.
|
||||||
|
// Using the properties in clientSecret to create a JWT.
|
||||||
|
if (provider.id === 'apple' && typeof provider.clientSecret === 'object') {
|
||||||
|
const { keyId, teamId, privateKey } = provider.clientSecret
|
||||||
|
const clientSecret = jwtSign({
|
||||||
|
iss: teamId,
|
||||||
|
iat: Math.floor(Date.now() / 1000),
|
||||||
|
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
|
||||||
|
aud: 'https://appleid.apple.com',
|
||||||
|
sub: provider.clientId
|
||||||
|
},
|
||||||
|
// Automatically convert \\n into \n if found in private key. If the key
|
||||||
|
// is passed in an environment variable \n can get escaped as \\n
|
||||||
|
privateKey.replace(/\\n/g, '\n'),
|
||||||
|
{ algorithm: 'ES256', keyid: keyId }
|
||||||
|
)
|
||||||
|
params.client_secret = clientSecret
|
||||||
|
} else {
|
||||||
|
params.client_secret = provider.clientSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
|
||||||
|
|
||||||
|
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
|
||||||
|
// Added as a fix to accomodate change in Twitch OAuth API
|
||||||
|
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
|
||||||
|
// Added as a fix for Reddit Authentication
|
||||||
|
if (provider.id === 'reddit') {
|
||||||
|
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((provider.id === 'okta' || provider.id === 'identity-server4') && !headers.Authorization) {
|
||||||
|
headers.Authorization = `Bearer ${code}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const postData = querystring.stringify(params)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._request(
|
||||||
|
'POST',
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
postData,
|
||||||
|
null,
|
||||||
|
(error, data, response) => {
|
||||||
|
if (error) {
|
||||||
|
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
let results
|
||||||
|
try {
|
||||||
|
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
|
||||||
|
// responses should be in JSON
|
||||||
|
results = JSON.parse(data)
|
||||||
|
} catch (e) {
|
||||||
|
// However both Facebook + Github currently use rev05 of the spec and neither
|
||||||
|
// seem to specify a content-type correctly in their response headers. :(
|
||||||
|
// Clients of these services suffer a minor performance cost.
|
||||||
|
results = querystring.parse(data)
|
||||||
|
}
|
||||||
|
let accessToken = results.access_token
|
||||||
|
if (provider.id === 'spotify') {
|
||||||
|
accessToken = results.authed_user.access_token
|
||||||
|
}
|
||||||
|
const refreshToken = results.refresh_token
|
||||||
|
resolve({ accessToken, refreshToken, results })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||||
|
*
|
||||||
|
* 18/08/2020 @robertcraigie added results parameter to pass data to an optional request preparer.
|
||||||
|
* e.g. see providers/bungie
|
||||||
|
*/
|
||||||
|
async function getOAuth2 (provider, accessToken, results) {
|
||||||
|
let url = provider.profileUrl
|
||||||
|
const headers = { ...provider.headers }
|
||||||
|
|
||||||
|
if (this._useAuthorizationHeaderForGET) {
|
||||||
|
headers.Authorization = this.buildAuthHeader(accessToken)
|
||||||
|
|
||||||
|
// Mail.ru & vk.com require 'access_token' as URL request parameter
|
||||||
|
if (['mailru', 'vk'].includes(provider.id)) {
|
||||||
|
const safeAccessTokenURL = new URL(url)
|
||||||
|
safeAccessTokenURL.searchParams.append('access_token', accessToken)
|
||||||
|
url = safeAccessTokenURL.href
|
||||||
|
}
|
||||||
|
|
||||||
|
// This line is required for Twitch
|
||||||
|
if (provider.id === 'twitch') {
|
||||||
|
headers['Client-ID'] = provider.clientId
|
||||||
|
}
|
||||||
|
accessToken = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.id === 'bungie') {
|
||||||
|
url = prepareProfileUrl({ provider, url, results })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this._request('GET', url, headers, null, accessToken, (error, profileData) => {
|
||||||
|
if (error) {
|
||||||
|
return reject(error)
|
||||||
|
}
|
||||||
|
resolve(profileData)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bungie needs special handling */
|
||||||
|
function prepareProfileUrl ({ provider, url, results }) {
|
||||||
|
if (!results.membership_id) {
|
||||||
|
// internal error
|
||||||
|
// @TODO: handle better
|
||||||
|
throw new Error('Expected membership_id to be passed.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider.headers?.['X-API-Key']) {
|
||||||
|
throw new Error('The Bungie provider requires the X-API-Key option to be present in "headers".')
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.replace('{membershipId}', results.membership_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
export default (_providers, baseUrl, basePath) => {
|
/** Adds `signinUrl` and `callbackUrl` to each provider. */
|
||||||
const providers = {}
|
export default function parseProviders ({ providers = [], baseUrl, basePath }) {
|
||||||
|
return providers.map((provider) => ({
|
||||||
_providers.forEach(provider => {
|
...provider,
|
||||||
const providerId = provider.id
|
signinUrl: `${baseUrl}${basePath}/signin/${provider.id}`,
|
||||||
providers[providerId] = {
|
callbackUrl: `${baseUrl}${basePath}/callback/${provider.id}`
|
||||||
...provider,
|
}))
|
||||||
signinUrl: `${baseUrl}${basePath}/signin/${providerId}`,
|
|
||||||
callbackUrl: `${baseUrl}${basePath}/callback/${providerId}`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return providers
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from 'crypto'
|
import { randomBytes } from 'crypto'
|
||||||
|
|
||||||
export default async (email, provider, options) => {
|
export default async function email (email, provider, options) {
|
||||||
try {
|
try {
|
||||||
const { baseUrl, basePath, adapter } = options
|
const { baseUrl, basePath, adapter } = options
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,17 @@ import oAuthClient from '../oauth/client'
|
|||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import logger from '../../../lib/logger'
|
import logger from '../../../lib/logger'
|
||||||
|
|
||||||
export default (provider, csrfToken, callback, authParams) => {
|
export default async function oauth (provider, csrfToken) {
|
||||||
const { callbackUrl } = provider
|
const { callbackUrl } = provider
|
||||||
const client = oAuthClient(provider)
|
const client = oAuthClient(provider)
|
||||||
if (provider.version && provider.version.startsWith('2.')) {
|
if (provider.version?.startsWith('2.')) {
|
||||||
// Handle oAuth v2.x
|
// Handle OAuth v2.x
|
||||||
let url = client.getAuthorizeUrl({
|
let url = client.getAuthorizeUrl({
|
||||||
...authParams,
|
redirect_uri: callbackUrl,
|
||||||
redirect_uri: provider.callbackUrl,
|
|
||||||
scope: provider.scope,
|
scope: provider.scope,
|
||||||
// A hash of the NextAuth.js CSRF token is used as the state
|
// A hash of the NextAuth.js CSRF token is used as the state
|
||||||
state: createHash('sha256').update(csrfToken).digest('hex')
|
state: createHash('sha256').update(csrfToken).digest('hex'),
|
||||||
|
...provider.authorizationParams
|
||||||
})
|
})
|
||||||
|
|
||||||
// If the authorizationUrl specified in the config has query parameters on it
|
// If the authorizationUrl specified in the config has query parameters on it
|
||||||
@@ -28,15 +28,14 @@ export default (provider, csrfToken, callback, authParams) => {
|
|||||||
url = url.replace(baseUrl, provider.authorizationUrl + '&')
|
url = url.replace(baseUrl, provider.authorizationUrl + '&')
|
||||||
}
|
}
|
||||||
|
|
||||||
callback(null, url)
|
return url
|
||||||
} else {
|
}
|
||||||
// Handle oAuth v1.x
|
|
||||||
client.getOAuthRequestToken((error, oAuthToken) => {
|
try {
|
||||||
if (error) {
|
const oAuthToken = await client.getOAuthRequestToken(callbackUrl)
|
||||||
logger.error('GET_AUTHORISATION_URL_ERROR', error)
|
return `${provider.authorizationUrl}?oauth_token=${oAuthToken}`
|
||||||
}
|
} catch (error) {
|
||||||
const url = `${provider.authorizationUrl}?oauth_token=${oAuthToken}`
|
logger.error('GET_AUTHORISATION_URL_ERROR', error)
|
||||||
callback(error, url)
|
throw error
|
||||||
}, callbackUrl)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,58 @@
|
|||||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||||
import render from 'preact-render-to-string'
|
import render from 'preact-render-to-string'
|
||||||
|
|
||||||
export default ({ baseUrl, basePath, error, res }) => {
|
/** Renders an error page. */
|
||||||
|
export default function error ({ baseUrl, basePath, error, res }) {
|
||||||
const signinPageUrl = `${baseUrl}${basePath}/signin`
|
const signinPageUrl = `${baseUrl}${basePath}/signin`
|
||||||
|
|
||||||
let statusCode = 200
|
const errors = {
|
||||||
let heading = <h1>Error</h1>
|
default: {
|
||||||
let message = <p><a className='site' href={baseUrl}>{baseUrl.replace(/^https?:\/\//, '')}</a></p>
|
statusCode: 200,
|
||||||
|
heading: 'Error',
|
||||||
switch (error) {
|
message: <p><a className='site' href={baseUrl}>{baseUrl.replace(/^https?:\/\//, '')}</a></p>
|
||||||
case 'Signin':
|
},
|
||||||
case 'OAuthSignin':
|
configuration: {
|
||||||
case 'OAuthCallback':
|
statusCode: 500,
|
||||||
case 'OAuthCreateAccount':
|
heading: 'Server error',
|
||||||
case 'EmailCreateAccount':
|
message: (
|
||||||
case 'Callback':
|
|
||||||
case 'OAuthAccountNotLinked':
|
|
||||||
case 'EmailSignin':
|
|
||||||
case 'CredentialsSignin':
|
|
||||||
// These messages are displayed in line on the sign in page
|
|
||||||
res.status(302).setHeader('Location', `${signinPageUrl}?error=${error}`)
|
|
||||||
res.end()
|
|
||||||
return false
|
|
||||||
case 'Configuration':
|
|
||||||
statusCode = 500
|
|
||||||
heading = <h1>Server error</h1>
|
|
||||||
message =
|
|
||||||
<div>
|
<div>
|
||||||
<div className='message'>
|
<p>There is a problem with the server configuration.</p>
|
||||||
<p>There is a problem with the server configuration.</p>
|
<p>Check the server logs for more information.</p>
|
||||||
<p>Check the server logs for more information.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
break
|
)
|
||||||
case 'AccessDenied':
|
},
|
||||||
statusCode = 403
|
accessdenied: {
|
||||||
heading = <h1>Access Denied</h1>
|
statusCode: 403,
|
||||||
message =
|
heading: 'Access Denied',
|
||||||
|
message: (
|
||||||
<div>
|
<div>
|
||||||
<div className='message'>
|
<p>You do not have permission to sign in.</p>
|
||||||
<p>You do not have permission to sign in.</p>
|
|
||||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
break
|
|
||||||
case 'Verification':
|
|
||||||
// @TODO Check if user is signed in already with the same email address.
|
|
||||||
// If they are, no need to display this message, can just direct to callbackUrl
|
|
||||||
statusCode = 403
|
|
||||||
heading = <h1>Unable to sign in</h1>
|
|
||||||
message =
|
|
||||||
<div>
|
|
||||||
<div className='message'>
|
|
||||||
<p>The sign in link is no longer valid.</p>
|
|
||||||
<p>It may have be used already or it may have expired.</p>
|
|
||||||
</div>
|
|
||||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||||
</div>
|
</div>
|
||||||
break
|
)
|
||||||
default:
|
},
|
||||||
|
verification: {
|
||||||
|
statusCode: 403,
|
||||||
|
heading: 'Unable to sign in',
|
||||||
|
message: (
|
||||||
|
<div>
|
||||||
|
<p>The sign in link is no longer valid.</p>
|
||||||
|
<p>It may have be used already or it may have expired.</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
signin: <p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { statusCode, heading, message, signin } = errors[error.toLowerCase()] || errors.default
|
||||||
|
|
||||||
res.status(statusCode)
|
res.status(statusCode)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<div className='error'>
|
<div className='error'>
|
||||||
{heading}
|
<h1>{heading}</h1>
|
||||||
{message}
|
<div className='message'>{message}</div>
|
||||||
|
{signin}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,19 @@ import verifyRequest from './verify-request'
|
|||||||
import error from './error'
|
import error from './error'
|
||||||
import css from '../../css'
|
import css from '../../css'
|
||||||
|
|
||||||
function render (req, res, page, props, done) {
|
/** Takes a request and response, and gives renderable pages */
|
||||||
let html = ''
|
export default function renderPage (req, res) {
|
||||||
switch (page) {
|
const { baseUrl, basePath, callbackUrl, csrfToken, providers, theme } = req.options
|
||||||
case 'signin':
|
|
||||||
html = signin({ ...props, req })
|
|
||||||
break
|
|
||||||
case 'signout':
|
|
||||||
html = signout(props)
|
|
||||||
break
|
|
||||||
case 'verify-request':
|
|
||||||
html = verifyRequest(props)
|
|
||||||
break
|
|
||||||
case 'error':
|
|
||||||
html = error({ ...props, res })
|
|
||||||
if (html === false) return done()
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
html = error(props)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html')
|
res.setHeader('Content-Type', 'text/html')
|
||||||
res.send(`<!DOCTYPE html><head><style type="text/css">${css()}</style><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div class="page">${html}</div></body></html>`)
|
function send (html) {
|
||||||
done()
|
res.send(`<!DOCTYPE html><head><style type="text/css">${css()}</style><meta name="viewport" content="width=device-width, initial-scale=1"></head><body class="__next-auth-theme-${theme}"><div class="page">${html}</div></body></html>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
return {
|
||||||
render
|
signin (props) { send(signin({ csrfToken, providers, callbackUrl, ...req.query, ...props })) },
|
||||||
|
signout (props) { send(signout({ csrfToken, baseUrl, basePath, ...props })) },
|
||||||
|
verifyRequest (props) { send(verifyRequest({ baseUrl, ...props })) },
|
||||||
|
error (props) { send(error({ basePath, baseUrl, res, ...props })) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||||
import render from 'preact-render-to-string'
|
import render from 'preact-render-to-string'
|
||||||
|
|
||||||
export default ({ req, csrfToken, providers, callbackUrl }) => {
|
export default function signin ({ csrfToken, providers, callbackUrl, email, error: errorType }) {
|
||||||
const { email, error } = req.query
|
|
||||||
|
|
||||||
// We only want to render providers
|
// We only want to render providers
|
||||||
const providersToRender = providers.filter(provider => {
|
const providersToRender = providers.filter(provider => {
|
||||||
if (provider.type === 'oauth' || provider.type === 'email') {
|
if (provider.type === 'oauth' || provider.type === 'email') {
|
||||||
@@ -12,43 +10,31 @@ export default ({ req, csrfToken, providers, callbackUrl }) => {
|
|||||||
} else if (provider.type === 'credentials' && provider.credentials) {
|
} else if (provider.type === 'credentials' && provider.credentials) {
|
||||||
// Only render credentials type provider if credentials are defined
|
// Only render credentials type provider if credentials are defined
|
||||||
return true
|
return true
|
||||||
} else {
|
|
||||||
// Don't render other provider types
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
// Don't render other provider types
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
let errorMessage
|
const errors = {
|
||||||
if (error) {
|
Signin: 'Try signing with a different account.',
|
||||||
switch (error) {
|
OAuthSignin: 'Try signing with a different account.',
|
||||||
case 'Signin':
|
OAuthCallback: 'Try signing with a different account.',
|
||||||
case 'OAuthSignin':
|
OAuthCreateAccount: 'Try signing with a different account.',
|
||||||
case 'OAuthCallback':
|
EmailCreateAccount: 'Try signing with a different account.',
|
||||||
case 'OAuthCreateAccount':
|
Callback: 'Try signing with a different account.',
|
||||||
case 'EmailCreateAccount':
|
OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.',
|
||||||
case 'Callback':
|
EmailSignin: 'Check your email address.',
|
||||||
errorMessage = <p>Try signing with a different account.</p>
|
CredentialsSignin: 'Sign in failed. Check the details you provided are correct.',
|
||||||
break
|
default: 'Unable to sign in.'
|
||||||
case 'OAuthAccountNotLinked':
|
|
||||||
errorMessage = <p>To confirm your identity, sign in with the same account you used originally.</p>
|
|
||||||
break
|
|
||||||
case 'EmailSignin':
|
|
||||||
errorMessage = <p>Check your email address.</p>
|
|
||||||
break
|
|
||||||
case 'CredentialsSignin':
|
|
||||||
errorMessage = <p>Sign in failed. Check the details you provided are correct.</p>
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
errorMessage = <p>Unable to sign in.</p>
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const error = errorType && (errors[errorType] ?? errors.default)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<div className='signin'>
|
<div className='signin'>
|
||||||
{errorMessage &&
|
{error &&
|
||||||
<div className='error'>
|
<div className='error'>
|
||||||
{errorMessage}
|
<p>{error}</p>
|
||||||
</div>}
|
</div>}
|
||||||
{providersToRender.map((provider, i) =>
|
{providersToRender.map((provider, i) =>
|
||||||
<div key={provider.id} className='provider'>
|
<div key={provider.id} className='provider'>
|
||||||
@@ -59,8 +45,8 @@ export default ({ req, csrfToken, providers, callbackUrl }) => {
|
|||||||
<button type='submit' className='button'>Sign in with {provider.name}</button>
|
<button type='submit' className='button'>Sign in with {provider.name}</button>
|
||||||
</form>}
|
</form>}
|
||||||
{(provider.type === 'email' || provider.type === 'credentials') && (i > 0) &&
|
{(provider.type === 'email' || provider.type === 'credentials') && (i > 0) &&
|
||||||
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
|
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
|
||||||
<hr />}
|
<hr />}
|
||||||
{provider.type === 'email' &&
|
{provider.type === 'email' &&
|
||||||
<form action={provider.signinUrl} method='POST'>
|
<form action={provider.signinUrl} method='POST'>
|
||||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||||
import render from 'preact-render-to-string'
|
import render from 'preact-render-to-string'
|
||||||
|
|
||||||
export default ({ baseUrl, basePath, csrfToken }) => {
|
export default function signout ({ baseUrl, basePath, csrfToken }) {
|
||||||
return render(
|
return render(
|
||||||
<div className='signout'>
|
<div className='signout'>
|
||||||
<h1>Are you sure you want to sign out?</h1>
|
<h1>Are you sure you want to sign out?</h1>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||||
import render from 'preact-render-to-string'
|
import render from 'preact-render-to-string'
|
||||||
|
|
||||||
export default ({ baseUrl }) => {
|
export default function verifyRequest ({ baseUrl }) {
|
||||||
return render(
|
return render(
|
||||||
<div className='verify-request'>
|
<div className='verify-request'>
|
||||||
<h1>Check your email</h1>
|
<h1>Check your email</h1>
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
// Handle callbacks from login services
|
|
||||||
import oAuthCallback from '../lib/oauth/callback'
|
import oAuthCallback from '../lib/oauth/callback'
|
||||||
import callbackHandler from '../lib/callback-handler'
|
import callbackHandler from '../lib/callback-handler'
|
||||||
import cookie from '../lib/cookie'
|
import * as cookie from '../lib/cookie'
|
||||||
import logger from '../../lib/logger'
|
import logger from '../../lib/logger'
|
||||||
import dispatchEvent from '../lib/dispatch-event'
|
import dispatchEvent from '../lib/dispatch-event'
|
||||||
|
|
||||||
export default async (req, res, options, done) => {
|
/** Handle callbacks from login services */
|
||||||
|
export default async function callback (req, res) {
|
||||||
const {
|
const {
|
||||||
provider: providerName,
|
provider,
|
||||||
providers,
|
|
||||||
adapter,
|
adapter,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
basePath,
|
basePath,
|
||||||
@@ -19,133 +18,132 @@ export default async (req, res, options, done) => {
|
|||||||
jwt,
|
jwt,
|
||||||
events,
|
events,
|
||||||
callbacks,
|
callbacks,
|
||||||
csrfToken,
|
session: {
|
||||||
redirect
|
jwt: useJwtSession,
|
||||||
} = options
|
maxAge: sessionMaxAge
|
||||||
const provider = providers[providerName]
|
}
|
||||||
const { type } = provider
|
} = req.options
|
||||||
const useJwtSession = options.session.jwt
|
|
||||||
const sessionMaxAge = options.session.maxAge
|
|
||||||
|
|
||||||
// Get session ID (if set)
|
// Get session ID (if set)
|
||||||
const sessionToken = req.cookies ? req.cookies[cookies.sessionToken.name] : null
|
const sessionToken = req.cookies?.[cookies.sessionToken.name] ?? null
|
||||||
|
|
||||||
if (type === 'oauth') {
|
if (provider.type === 'oauth') {
|
||||||
try {
|
try {
|
||||||
oAuthCallback(req, provider, csrfToken, async (error, profile, account, OAuthProfile) => {
|
const { profile, account, OAuthProfile } = await oAuthCallback(req)
|
||||||
try {
|
try {
|
||||||
if (error) {
|
// Make it easier to debug when adding a new provider
|
||||||
logger.error('CALLBACK_OAUTH_ERROR', error)
|
logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile })
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make it easier to debug when adding a new provider
|
// If we don't have a profile object then either something went wrong
|
||||||
logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile })
|
// or the user cancelled signin in. We don't know which, so we just
|
||||||
|
// direct the user to the signup page for now. We could do something
|
||||||
|
// else in future.
|
||||||
|
//
|
||||||
|
// Note: In oAuthCallback an error is logged with debug info, so it
|
||||||
|
// should at least be visible to developers what happened if it is an
|
||||||
|
// error with the provider.
|
||||||
|
if (!profile) {
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/signin`)
|
||||||
|
}
|
||||||
|
|
||||||
// If we don't have a profile object then either something went wrong
|
// Check if user is allowed to sign in
|
||||||
// or the user cancelled signin in. We don't know which, so we just
|
// Attempt to get Profile from OAuth provider details before invoking
|
||||||
// direct the user to the signup page for now. We could do something
|
// signIn callback - but if no user object is returned, that is fine
|
||||||
// else in future.
|
// (that just means it's a new user signing in for the first time).
|
||||||
//
|
let userOrProfile = profile
|
||||||
// Note: In oAuthCallback an error is logged with debug info, so it
|
if (adapter) {
|
||||||
// should at least be visible to developers what happened if it is an
|
const { getUserByProviderAccountId } = await adapter.getAdapter(req.options)
|
||||||
// error with the provider.
|
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
|
||||||
if (!profile) {
|
if (userFromProviderAccountId) {
|
||||||
return redirect(`${baseUrl}${basePath}/signin`)
|
userOrProfile = userFromProviderAccountId
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is allowed to sign in
|
|
||||||
// Attempt to get Profile from OAuth provider details before invoking
|
|
||||||
// signIn callback - but if no user object is returned, that is fine
|
|
||||||
// (that just means it's a new user signing in for the first time).
|
|
||||||
let userOrProfile = profile
|
|
||||||
if (adapter) {
|
|
||||||
const { getUserByProviderAccountId } = await adapter.getAdapter(options)
|
|
||||||
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
|
|
||||||
if (userFromProviderAccountId) {
|
|
||||||
userOrProfile = userFromProviderAccountId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
|
|
||||||
if (signInCallbackResponse === false) {
|
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
|
||||||
} else {
|
|
||||||
return redirect(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign user in
|
|
||||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
|
||||||
|
|
||||||
if (useJwtSession) {
|
|
||||||
const defaultJwtPayload = {
|
|
||||||
name: user.name,
|
|
||||||
email: user.email,
|
|
||||||
picture: user.image
|
|
||||||
}
|
|
||||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
|
|
||||||
|
|
||||||
// Sign and encrypt token
|
|
||||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
|
||||||
|
|
||||||
// Set cookie expiry date
|
|
||||||
const cookieExpires = new Date()
|
|
||||||
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
|
||||||
|
|
||||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
|
||||||
} else {
|
|
||||||
// Save Session Token in cookie
|
|
||||||
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
|
|
||||||
}
|
|
||||||
|
|
||||||
await dispatchEvent(events.signIn, { user, account, isNewUser })
|
|
||||||
|
|
||||||
// Handle first logins on new accounts
|
|
||||||
// e.g. option to send users to a new account landing page on initial login
|
|
||||||
// Note that the callback URL is preserved, so the journey can still be resumed
|
|
||||||
if (isNewUser && pages.newUser) {
|
|
||||||
return redirect(pages.newUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback URL is already verified at this point, so safe to use if specified
|
|
||||||
return redirect(callbackUrl || baseUrl)
|
|
||||||
} catch (error) {
|
|
||||||
if (error.name === 'AccountNotLinkedError') {
|
|
||||||
// If the email on the account is already linked, but nto with this oAuth account
|
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
|
|
||||||
} else if (error.name === 'CreateUserError') {
|
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
|
|
||||||
} else {
|
|
||||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
|
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
try {
|
||||||
|
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
|
||||||
|
if (signInCallbackResponse === false) {
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||||
|
} else if (typeof signInCallbackResponse === 'string') {
|
||||||
|
return res.redirect(signInCallbackResponse)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||||
|
}
|
||||||
|
// TODO: Remove in a future major release
|
||||||
|
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||||
|
return res.redirect(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign user in
|
||||||
|
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
|
||||||
|
|
||||||
|
if (useJwtSession) {
|
||||||
|
const defaultJwtPayload = {
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
picture: user.image,
|
||||||
|
sub: user.id?.toString()
|
||||||
|
}
|
||||||
|
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
|
||||||
|
|
||||||
|
// Sign and encrypt token
|
||||||
|
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||||
|
|
||||||
|
// Set cookie expiry date
|
||||||
|
const cookieExpires = new Date()
|
||||||
|
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
||||||
|
|
||||||
|
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||||
|
} else {
|
||||||
|
// Save Session Token in cookie
|
||||||
|
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatchEvent(events.signIn, { user, account, isNewUser })
|
||||||
|
|
||||||
|
// Handle first logins on new accounts
|
||||||
|
// e.g. option to send users to a new account landing page on initial login
|
||||||
|
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||||
|
if (isNewUser && pages.newUser) {
|
||||||
|
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Callback URL is already verified at this point, so safe to use if specified
|
||||||
|
return res.redirect(callbackUrl || baseUrl)
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AccountNotLinkedError') {
|
||||||
|
// If the email on the account is already linked, but not with this OAuth account
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
|
||||||
|
} else if (error.name === 'CreateUserError') {
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
|
||||||
|
}
|
||||||
|
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.name === 'OAuthCallbackError') {
|
||||||
|
logger.error('CALLBACK_OAUTH_ERROR', error)
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
||||||
|
}
|
||||||
logger.error('OAUTH_CALLBACK_ERROR', error)
|
logger.error('OAUTH_CALLBACK_ERROR', error)
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||||
}
|
}
|
||||||
} else if (type === 'email') {
|
} else if (provider.type === 'email') {
|
||||||
try {
|
try {
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(options)
|
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(req.options)
|
||||||
const verificationToken = req.query.token
|
const verificationToken = req.query.token
|
||||||
const email = req.query.email
|
const email = req.query.email
|
||||||
|
|
||||||
// Verify email and verification token exist in database
|
// 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) {
|
if (!invite) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Verification`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=Verification`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If verification token is valid, delete verification request token from
|
// If verification token is valid, delete verification request token from
|
||||||
@@ -160,24 +158,28 @@ export default async (req, res, options, done) => {
|
|||||||
try {
|
try {
|
||||||
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
||||||
if (signInCallbackResponse === false) {
|
if (signInCallbackResponse === false) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||||
|
} else if (typeof signInCallbackResponse === 'string') {
|
||||||
|
return res.redirect(signInCallbackResponse)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||||
} else {
|
|
||||||
return redirect(error)
|
|
||||||
}
|
}
|
||||||
|
// TODO: Remove in a future major release
|
||||||
|
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||||
|
return res.redirect(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sign user in
|
// Sign user in
|
||||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
|
||||||
|
|
||||||
if (useJwtSession) {
|
if (useJwtSession) {
|
||||||
const defaultJwtPayload = {
|
const defaultJwtPayload = {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
picture: user.image
|
picture: user.image,
|
||||||
|
sub: user.id?.toString()
|
||||||
}
|
}
|
||||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, profile, isNewUser)
|
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, profile, isNewUser)
|
||||||
|
|
||||||
@@ -200,32 +202,27 @@ export default async (req, res, options, done) => {
|
|||||||
// e.g. option to send users to a new account landing page on initial login
|
// 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
|
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||||
if (isNewUser && pages.newUser) {
|
if (isNewUser && pages.newUser) {
|
||||||
return redirect(pages.newUser)
|
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callback URL is already verified at this point, so safe to use if specified
|
// Callback URL is already verified at this point, so safe to use if specified
|
||||||
if (callbackUrl) {
|
return res.redirect(callbackUrl || baseUrl)
|
||||||
return redirect(callbackUrl)
|
|
||||||
} else {
|
|
||||||
return redirect(baseUrl)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'CreateUserError') {
|
if (error.name === 'CreateUserError') {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
|
||||||
} else {
|
|
||||||
logger.error('CALLBACK_EMAIL_ERROR', error)
|
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
|
||||||
}
|
}
|
||||||
|
logger.error('CALLBACK_EMAIL_ERROR', error)
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||||
}
|
}
|
||||||
} else if (type === 'credentials' && req.method === 'POST') {
|
} else if (provider.type === 'credentials' && req.method === 'POST') {
|
||||||
if (!useJwtSession) {
|
if (!useJwtSession) {
|
||||||
logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled')
|
logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled')
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!provider.authorize) {
|
if (!provider.authorize) {
|
||||||
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
|
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const credentials = req.body
|
const credentials = req.body
|
||||||
@@ -234,14 +231,13 @@ export default async (req, res, options, done) => {
|
|||||||
try {
|
try {
|
||||||
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
|
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
|
||||||
if (!userObjectReturnedFromAuthorizeHandler) {
|
if (!userObjectReturnedFromAuthorizeHandler) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||||
} else {
|
|
||||||
return redirect(error)
|
|
||||||
}
|
}
|
||||||
|
return res.redirect(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = userObjectReturnedFromAuthorizeHandler
|
const user = userObjectReturnedFromAuthorizeHandler
|
||||||
@@ -250,14 +246,13 @@ export default async (req, res, options, done) => {
|
|||||||
try {
|
try {
|
||||||
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
|
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
|
||||||
if (signInCallbackResponse === false) {
|
if (signInCallbackResponse === false) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||||
} else {
|
|
||||||
return redirect(error)
|
|
||||||
}
|
}
|
||||||
|
return res.redirect(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultJwtPayload = {
|
const defaultJwtPayload = {
|
||||||
@@ -278,9 +273,7 @@ export default async (req, res, options, done) => {
|
|||||||
|
|
||||||
await dispatchEvent(events.signIn, { user, account })
|
await dispatchEvent(events.signIn, { user, account })
|
||||||
|
|
||||||
return redirect(callbackUrl || baseUrl)
|
return res.redirect(callbackUrl || baseUrl)
|
||||||
} else {
|
|
||||||
res.status(500).end(`Error: Callback for provider type ${type} not supported`)
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
|
return res.status(500).end(`Error: Callback for provider type ${provider.type} not supported`)
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/server/routes/index.js
Normal file
5
src/server/routes/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as callback } from './callback'
|
||||||
|
export { default as signin } from './signin'
|
||||||
|
export { default as signout } from './signout'
|
||||||
|
export { default as session } from './session'
|
||||||
|
export { default as providers } from './providers'
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
// Return a JSON object with a list of all outh providers currently configured
|
/**
|
||||||
// and their signin and callback URLs. This makes it possible to automatically
|
* Return a JSON object with a list of all OAuth providers currently configured
|
||||||
// generate buttons for all providers when rendering client side.
|
* and their signin and callback URLs. This makes it possible to automatically
|
||||||
export default (req, res, options, done) => {
|
* generate buttons for all providers when rendering client side.
|
||||||
const { providers } = options
|
*/
|
||||||
|
export default function providers (req, res) {
|
||||||
|
const { providers } = req.options
|
||||||
|
|
||||||
const result = {}
|
const result = providers.reduce((acc, { id, name, type, signinUrl, callbackUrl }) => {
|
||||||
Object.entries(providers).map(([provider, providerConfig]) => {
|
acc[id] = { id, name, type, signinUrl, callbackUrl }
|
||||||
result[provider] = {
|
return acc
|
||||||
id: provider,
|
}, {})
|
||||||
name: providerConfig.name,
|
|
||||||
type: providerConfig.type,
|
|
||||||
signinUrl: providerConfig.signinUrl,
|
|
||||||
callbackUrl: providerConfig.callbackUrl
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json')
|
|
||||||
res.json(result)
|
res.json(result)
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
// Return a session object (without any private fields) for Single Page App clients
|
import * as cookie from '../lib/cookie'
|
||||||
import cookie from '../lib/cookie'
|
|
||||||
import logger from '../../lib/logger'
|
import logger from '../../lib/logger'
|
||||||
import dispatchEvent from '../lib/dispatch-event'
|
import dispatchEvent from '../lib/dispatch-event'
|
||||||
|
|
||||||
export default async (req, res, options, done) => {
|
/**
|
||||||
const { cookies, adapter, jwt, events, callbacks } = options
|
* Return a session object (without any private fields)
|
||||||
const useJwtSession = options.session.jwt
|
* for Single Page App clients
|
||||||
const sessionMaxAge = options.session.maxAge
|
*/
|
||||||
|
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]
|
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||||
|
|
||||||
if (!sessionToken) {
|
if (!sessionToken) {
|
||||||
res.setHeader('Content-Type', 'application/json')
|
return res.json({})
|
||||||
res.json({})
|
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = {}
|
let response = {}
|
||||||
@@ -58,7 +59,7 @@ export default async (req, res, options, done) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const { getUser, getSession, updateSession } = await adapter.getAdapter(options)
|
const { getUser, getSession, updateSession } = await adapter.getAdapter(req.options)
|
||||||
const session = await getSession(sessionToken)
|
const session = await getSession(sessionToken)
|
||||||
if (session) {
|
if (session) {
|
||||||
// Trigger update to session object to update session expiry
|
// Trigger update to session object to update session expiry
|
||||||
@@ -98,7 +99,5 @@ export default async (req, res, options, done) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/json')
|
|
||||||
res.json(response)
|
res.json(response)
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +1,43 @@
|
|||||||
// Handle requests to /api/auth/signin
|
|
||||||
import oAuthSignin from '../lib/signin/oauth'
|
import oAuthSignin from '../lib/signin/oauth'
|
||||||
import emailSignin from '../lib/signin/email'
|
import emailSignin from '../lib/signin/email'
|
||||||
import logger from '../../lib/logger'
|
import logger from '../../lib/logger'
|
||||||
|
|
||||||
export default async (req, res, options, done) => {
|
/** Handle requests to /api/auth/signin */
|
||||||
|
export default async function signin (req, res) {
|
||||||
const {
|
const {
|
||||||
provider: providerName,
|
provider,
|
||||||
providers,
|
|
||||||
baseUrl,
|
baseUrl,
|
||||||
basePath,
|
basePath,
|
||||||
adapter,
|
adapter,
|
||||||
callbacks,
|
callbacks,
|
||||||
csrfToken,
|
csrfToken
|
||||||
redirect
|
} = req.options
|
||||||
} = options
|
|
||||||
const provider = providers[providerName]
|
|
||||||
const { type } = provider
|
|
||||||
|
|
||||||
if (!type) {
|
if (!provider.type) {
|
||||||
res.status(500).end(`Error: Type not specified for ${provider}`)
|
return res.status(500).end(`Error: Type not specified for ${provider.name}`)
|
||||||
return done()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'oauth' && req.method === 'POST') {
|
if (provider.type === 'oauth' && req.method === 'POST') {
|
||||||
const authParams = { ...req.query }
|
try {
|
||||||
delete authParams.nextauth // This is probably not intended to be sent to the provider, remove
|
const oAuthSigninUrl = await oAuthSignin(provider, csrfToken)
|
||||||
|
return res.redirect(oAuthSigninUrl)
|
||||||
oAuthSignin(provider, csrfToken, (error, oAuthSigninUrl) => {
|
} catch (error) {
|
||||||
if (error) {
|
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
}
|
||||||
}
|
} else if (provider.type === 'email' && req.method === 'POST') {
|
||||||
|
|
||||||
return redirect(oAuthSigninUrl)
|
|
||||||
}, authParams)
|
|
||||||
} else if (type === 'email' && req.method === 'POST') {
|
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||||
}
|
}
|
||||||
const { getUserByEmail } = await adapter.getAdapter(options)
|
const { getUserByEmail } = await adapter.getAdapter(req.options)
|
||||||
|
|
||||||
// Note: Technically the part of the email address local mailbox element
|
// Note: Technically the part of the email address local mailbox element
|
||||||
// (everything before the @ symbol) should be treated as 'case sensitive'
|
// (everything before the @ symbol) should be treated as 'case sensitive'
|
||||||
// according to RFC 2821, but in practice this causes more problems than
|
// according to RFC 2821, but in practice this causes more problems than
|
||||||
// it solves. We treat email addresses as all lower case. If anyone
|
// it solves. We treat email addresses as all lower case. If anyone
|
||||||
// complains about this we can make strict RFC 2821 compliance an option.
|
// complains about this we can make strict RFC 2821 compliance an option.
|
||||||
const email = req.body.email ? req.body.email.toLowerCase() : null
|
const email = req.body.email?.toLowerCase() ?? null
|
||||||
|
|
||||||
// If is an existing user return a user object (otherwise use placeholder)
|
// If is an existing user return a user object (otherwise use placeholder)
|
||||||
const profile = await getUserByEmail(email) || { email }
|
const profile = await getUserByEmail(email) || { email }
|
||||||
@@ -54,29 +45,31 @@ export default async (req, res, options, done) => {
|
|||||||
|
|
||||||
// Check if user is allowed to sign in
|
// Check if user is allowed to sign in
|
||||||
try {
|
try {
|
||||||
const signinCallbackResponse = await callbacks.signIn(profile, account, { email, verificationRequest: true })
|
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
||||||
if (signinCallbackResponse === false) {
|
if (signInCallbackResponse === false) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||||
|
} else if (typeof signInCallbackResponse === 'string') {
|
||||||
|
return res.redirect(signInCallbackResponse)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||||
} else {
|
|
||||||
return redirect(error)
|
|
||||||
}
|
}
|
||||||
|
// TODO: Remove in a future major release
|
||||||
|
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||||
|
return res.redirect(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await emailSignin(email, provider, options)
|
await emailSignin(email, provider, req.options)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('SIGNIN_EMAIL_ERROR', error)
|
logger.error('SIGNIN_EMAIL_ERROR', error)
|
||||||
return redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
|
return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
|
return res.redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
|
||||||
provider.id
|
provider.id
|
||||||
)}&type=${encodeURIComponent(provider.type)}`)
|
)}&type=${encodeURIComponent(provider.type)}`)
|
||||||
} else {
|
|
||||||
return redirect(`${baseUrl}${basePath}/signin`)
|
|
||||||
}
|
}
|
||||||
|
return res.redirect(`${baseUrl}${basePath}/signin`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// Handle requests to /api/auth/signout
|
import * as cookie from '../lib/cookie'
|
||||||
import cookie from '../lib/cookie'
|
|
||||||
import logger from '../../lib/logger'
|
import logger from '../../lib/logger'
|
||||||
import dispatchEvent from '../lib/dispatch-event'
|
import dispatchEvent from '../lib/dispatch-event'
|
||||||
|
|
||||||
export default async (req, res, options, done) => {
|
/** Handle requests to /api/auth/signout */
|
||||||
const { adapter, cookies, events, jwt, callbackUrl, redirect } = options
|
export default async function signout (req, res) {
|
||||||
const useJwtSession = options.session.jwt
|
const { adapter, cookies, events, jwt, callbackUrl } = req.options
|
||||||
|
const useJwtSession = req.options.session.jwt
|
||||||
const sessionToken = req.cookies[cookies.sessionToken.name]
|
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||||
|
|
||||||
if (useJwtSession) {
|
if (useJwtSession) {
|
||||||
@@ -18,7 +18,7 @@ export default async (req, res, options, done) => {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Get session from database
|
// Get session from database
|
||||||
const { getSession, deleteSession } = await adapter.getAdapter(options)
|
const { getSession, deleteSession } = await adapter.getAdapter(req.options)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dispatch signout event
|
// Dispatch signout event
|
||||||
@@ -43,5 +43,5 @@ export default async (req, res, options, done) => {
|
|||||||
maxAge: 0
|
maxAge: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
return redirect(callbackUrl)
|
return res.redirect(callbackUrl)
|
||||||
}
|
}
|
||||||
|
|||||||
1430
test/docker/app/package-lock.json
generated
1430
test/docker/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
|||||||
"author": "Iain Collins <me@iaincollins.com>",
|
"author": "Iain Collins <me@iaincollins.com>",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^9.5.0",
|
"next": "^9.5.4",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
"react-dom": "^16.13.1"
|
"react-dom": "^16.13.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ const options = {
|
|||||||
|
|
||||||
// You can define your own encode/decode functions for signing and encryption
|
// You can define your own encode/decode functions for signing and encryption
|
||||||
// if you want to override the default behaviour.
|
// if you want to override the default behaviour.
|
||||||
// encode: async ({ secret, token, maxAge }) => {},
|
// async encode({ secret, token, maxAge }) {},
|
||||||
// decode: async ({ secret, token, maxAge }) => {},
|
// async decode({ secret, token, maxAge }) {},
|
||||||
},
|
},
|
||||||
|
|
||||||
// You can define custom pages to override the built-in pages.
|
// You can define custom pages to override the built-in pages.
|
||||||
@@ -101,10 +101,10 @@ const options = {
|
|||||||
// when an action is performed.
|
// when an action is performed.
|
||||||
// https://next-auth.js.org/configuration/callbacks
|
// https://next-auth.js.org/configuration/callbacks
|
||||||
callbacks: {
|
callbacks: {
|
||||||
// signIn: async (user, account, profile) => { return Promise.resolve(true) },
|
// async signIn(user, account, profile) { return Promise.resolve(true) },
|
||||||
// redirect: async (url, baseUrl) => { return Promise.resolve(baseUrl) },
|
// async redirect(url, baseUrl) { return Promise.resolve(baseUrl) },
|
||||||
// session: async (session, user) => { return Promise.resolve(session) },
|
// async session(session, user) { return Promise.resolve(session) },
|
||||||
// jwt: async (token, user, account, profile, isNewUser) => { return Promise.resolve(token) }
|
// async jwt(token, user, account, profile, isNewUser) { return Promise.resolve(token) }
|
||||||
},
|
},
|
||||||
|
|
||||||
// Events are useful for logging
|
// Events are useful for logging
|
||||||
|
|||||||
@@ -34,3 +34,10 @@ services:
|
|||||||
service: postgres
|
service: postgres
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
||||||
|
fauna:
|
||||||
|
extends:
|
||||||
|
file: databases/fauna.yml
|
||||||
|
service: fauna
|
||||||
|
ports:
|
||||||
|
- 8443:8443
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user