mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
367 Commits
v2.0
...
v3.2.0-can
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
341fae28d4 | ||
|
|
b86ffa5dd5 | ||
|
|
5415a9c3ab | ||
|
|
dc516e8be8 | ||
|
|
29a0d9d295 | ||
|
|
5f5174f6e2 | ||
|
|
424b4ee257 | ||
|
|
545a7e752e | ||
|
|
c564b84182 | ||
|
|
0db233d208 | ||
|
|
5126f4e342 | ||
|
|
e09dfc6a7f | ||
|
|
ccfa1d55bb | ||
|
|
d7dc7b0753 | ||
|
|
0407e130c4 | ||
|
|
64084d634b | ||
|
|
438a737837 | ||
|
|
a482a64f10 | ||
|
|
2227d34725 | ||
|
|
9c6ef951a1 | ||
|
|
01c897f23e | ||
|
|
ea65d87d07 | ||
|
|
8d1e479d12 | ||
|
|
435b630849 | ||
|
|
773c74a756 | ||
|
|
6867bc92c8 | ||
|
|
eb6a4c46d9 | ||
|
|
cd3d2a138b | ||
|
|
0c356456bb | ||
|
|
6d44a34f7d | ||
|
|
7bda639361 | ||
|
|
40e453076e | ||
|
|
e065552784 | ||
|
|
a3104a009c | ||
|
|
e9eb6bc57e | ||
|
|
95e31b46af | ||
|
|
d5e70323f0 | ||
|
|
4e4d1eac28 | ||
|
|
15316f069e | ||
|
|
e6995d21cd | ||
|
|
433f096a63 | ||
|
|
9f487593fa | ||
|
|
65caaa6c4c | ||
|
|
0adfba8c5c | ||
|
|
2f0f738e2e | ||
|
|
1777a87be3 | ||
|
|
e94fd3b484 | ||
|
|
3b40335202 | ||
|
|
6d63b74db9 | ||
|
|
eb26722833 | ||
|
|
4937047d19 | ||
|
|
4305964864 | ||
|
|
91d93fb8fd | ||
|
|
e2e28fcfd0 | ||
|
|
66afc69a57 | ||
|
|
3046691119 | ||
|
|
88b87a53ff | ||
|
|
f1ae26efb6 | ||
|
|
ba83685a86 | ||
|
|
d514733f13 | ||
|
|
15cd608b19 | ||
|
|
08d7f5d778 | ||
|
|
a2ba7e9229 | ||
|
|
7c71a15699 | ||
|
|
351b804606 | ||
|
|
8f0501b7fe | ||
|
|
73d21e66dd | ||
|
|
6310311d52 | ||
|
|
d0caba1933 | ||
|
|
2f3291e48f | ||
|
|
43d8e3b894 | ||
|
|
5d4eb5d4e0 | ||
|
|
7ccdec22cb | ||
|
|
2ea64045cb | ||
|
|
daf97d298d | ||
|
|
ababc7ecdb | ||
|
|
33e72b2ae1 | ||
|
|
bf5716c674 | ||
|
|
c17a3b94f5 | ||
|
|
19a9c313e0 | ||
|
|
68043e65e4 | ||
|
|
a6ec60284d | ||
|
|
ff79c4b95b | ||
|
|
9c4e41a4c6 | ||
|
|
07ef3d59c5 | ||
|
|
4fe7162652 | ||
|
|
950a937633 | ||
|
|
1cc31def3e | ||
|
|
92f53c532b | ||
|
|
c6d6d9c002 | ||
|
|
85b859231c | ||
|
|
ea093dc0fc | ||
|
|
cd61178f44 | ||
|
|
eb53219cbd | ||
|
|
18d70ffbe9 | ||
|
|
bdcf823d26 | ||
|
|
3aeba2aa09 | ||
|
|
0793e2c8d8 | ||
|
|
0f01279c91 | ||
|
|
8fa9d00958 | ||
|
|
ab6ef8a19c | ||
|
|
8d68807bfe | ||
|
|
35fc38c328 | ||
|
|
85eeda5755 | ||
|
|
2e52c500a1 | ||
|
|
5886f9bea8 | ||
|
|
c497dcba26 | ||
|
|
493c45a864 | ||
|
|
b243b26a3d | ||
|
|
1d0749970a | ||
|
|
3474d3e250 | ||
|
|
a35c3a424c | ||
|
|
6e65ba87a6 | ||
|
|
ae7247f14f | ||
|
|
12a5d6b1f4 | ||
|
|
19da066b04 | ||
|
|
8115a7c66c | ||
|
|
1ab029c60a | ||
|
|
d0dbacfc4b | ||
|
|
f6b7e0aad9 | ||
|
|
4a23f88180 | ||
|
|
f8dbd67a16 | ||
|
|
9406f8b332 | ||
|
|
b0410ed9d4 | ||
|
|
8c0d0c4dea | ||
|
|
9446c26419 | ||
|
|
cdfa6008c7 | ||
|
|
b4bb8bda26 | ||
|
|
2c32504cc9 | ||
|
|
bd188ff410 | ||
|
|
89aedb1285 | ||
|
|
db8c0820b6 | ||
|
|
d86464c822 | ||
|
|
fcfeb0ce88 | ||
|
|
01e472912e | ||
|
|
bbfeac408e | ||
|
|
364de1fc6c | ||
|
|
52af06cd33 | ||
|
|
8f472c5987 | ||
|
|
dcbd7a6703 | ||
|
|
e6fd4c2edc | ||
|
|
e19ca19a82 | ||
|
|
7b1b68e1c4 | ||
|
|
56d848c868 | ||
|
|
100eece7a2 | ||
|
|
278ecc1e48 | ||
|
|
a3d379554b | ||
|
|
983dd98a66 | ||
|
|
ca3f26b8d2 | ||
|
|
d2a2352e9a | ||
|
|
3043a9525a | ||
|
|
e1c6632b6f | ||
|
|
56e64e322e | ||
|
|
cbd056f225 | ||
|
|
22ab66f9d8 | ||
|
|
3597733dae | ||
|
|
cb9ce69ba3 | ||
|
|
c19a79cbca | ||
|
|
e97e090b65 | ||
|
|
eda4a6d18b | ||
|
|
94f66b60d8 | ||
|
|
9a85e27c0c | ||
|
|
7fb7e3d1bc | ||
|
|
90066fdbec | ||
|
|
475f0e7b51 | ||
|
|
a9131724d6 | ||
|
|
55bfb6d9dc | ||
|
|
af3da3abf8 | ||
|
|
339d9f2d03 | ||
|
|
a24fb8b380 | ||
|
|
65319e3927 | ||
|
|
19917972ef | ||
|
|
c1b412814a | ||
|
|
53ea8407ea | ||
|
|
66f46e8cc7 | ||
|
|
fec69a21be | ||
|
|
505ebb8ae1 | ||
|
|
fb4381d8eb | ||
|
|
4772f5b571 | ||
|
|
481db425d6 | ||
|
|
b886729bb8 | ||
|
|
3a21a9c9f1 | ||
|
|
9e4a6fec59 | ||
|
|
86921022dc | ||
|
|
f57f11e6ff | ||
|
|
77ad6bd97e | ||
|
|
78c7041b3f | ||
|
|
99edead0f2 | ||
|
|
b0b3dbc0fc | ||
|
|
8b5af54e1c | ||
|
|
0b5b04a22f | ||
|
|
890be1de0d | ||
|
|
40ae747bc1 | ||
|
|
5a8022e9a2 | ||
|
|
3e512b5cf5 | ||
|
|
81071d7776 | ||
|
|
fc05140c1f | ||
|
|
faec6824ba | ||
|
|
b91bfef16d | ||
|
|
ba9dc17e44 | ||
|
|
c220bcc57e | ||
|
|
f8a4808aa7 | ||
|
|
495d0a47db | ||
|
|
8cda627fe6 | ||
|
|
d0a0ccc6bc | ||
|
|
999222cd97 | ||
|
|
72eb7fda3f | ||
|
|
3c94940ae6 | ||
|
|
1a8ed2aec1 | ||
|
|
0e2321dc14 | ||
|
|
78d1983f9a | ||
|
|
5435df110c | ||
|
|
32853b8d1e | ||
|
|
9737b4c6ab | ||
|
|
e9bdd5c355 | ||
|
|
9728567296 | ||
|
|
ef6579a7ee | ||
|
|
8e810aa765 | ||
|
|
37596edf2b | ||
|
|
229a3e430e | ||
|
|
1d80f595c5 | ||
|
|
189a2c8e0e | ||
|
|
97096fb811 | ||
|
|
e8b75e40b1 | ||
|
|
d41c38e002 | ||
|
|
966bc7b433 | ||
|
|
e7b06d3362 | ||
|
|
d5d8eb8d7c | ||
|
|
8ec07f0224 | ||
|
|
558536db1e | ||
|
|
0c2fe054d1 | ||
|
|
b5a69fd787 | ||
|
|
9b29ed347d | ||
|
|
c5c4ff4d51 | ||
|
|
008b1a9f8d | ||
|
|
4a6f153aa6 | ||
|
|
9eccc78e3a | ||
|
|
09938cc368 | ||
|
|
5db05e1031 | ||
|
|
f6ba72b4fa | ||
|
|
bf7e555cfa | ||
|
|
26abc70a99 | ||
|
|
d38cd54dee | ||
|
|
200690ad6c | ||
|
|
52b69a6d68 | ||
|
|
f319b2af05 | ||
|
|
b80a005733 | ||
|
|
34936aecc0 | ||
|
|
b021f26f03 | ||
|
|
fcf7197120 | ||
|
|
bec8d8dff1 | ||
|
|
781c63e966 | ||
|
|
2da1883726 | ||
|
|
83ffac7cd2 | ||
|
|
6198903cdf | ||
|
|
bd98f8188c | ||
|
|
73ea402b1c | ||
|
|
4284684a3b | ||
|
|
b5d522410a | ||
|
|
284cb8e2a7 | ||
|
|
079aab2315 | ||
|
|
645ee382cf | ||
|
|
e947a772ce | ||
|
|
5d63adf7df | ||
|
|
f1a872f861 | ||
|
|
02b1d02f09 | ||
|
|
a3479b3503 | ||
|
|
740535a8f2 | ||
|
|
19ed684a52 | ||
|
|
bd72949fa7 | ||
|
|
a277cd5b0c | ||
|
|
fd6e7e94df | ||
|
|
2f6403478d | ||
|
|
a4372ffc61 | ||
|
|
d6ce92811e | ||
|
|
e5aecdf315 | ||
|
|
6d1c457a75 | ||
|
|
6e16aec6d3 | ||
|
|
f899d7bb04 | ||
|
|
e36646ce7f | ||
|
|
f3d36a74c9 | ||
|
|
4e11c9c36e | ||
|
|
0a7ac36584 | ||
|
|
fc4850f354 | ||
|
|
6e9a8d2074 | ||
|
|
c712d7da07 | ||
|
|
5183181d1c | ||
|
|
b024f89ba8 | ||
|
|
fbbe516b9a | ||
|
|
d48a3fd948 | ||
|
|
86f0c53bd3 | ||
|
|
7f6cc2048b | ||
|
|
b2829f6384 | ||
|
|
67c5041860 | ||
|
|
33df9e3132 | ||
|
|
602bc28a45 | ||
|
|
5a7a494701 | ||
|
|
9fa82cedbd | ||
|
|
b0408284b8 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Exclude directories we don't need from Docker context to improve build time
|
||||
node_modules
|
||||
www
|
||||
src
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
NEXTAUTH_TWITTER_ID=
|
||||
NEXTAUTH_TWITTER_SECRET=
|
||||
NEXTAUTH_TWITTER_USERNAME=
|
||||
NEXTAUTH_TWITTER_PASSWORD=
|
||||
NEXTAUTH_GITHUB_ID=
|
||||
NEXTAUTH_GITHUB_SECRET=
|
||||
NEXTAUTH_GITHUB_USERNAME=
|
||||
NEXTAUTH_GITHUB_PASSWORD=
|
||||
NEXTAUTH_GOOGLE_ID=
|
||||
NEXTAUTH_GOOGLE_SECRET=
|
||||
NEXTAUTH_GOOGLE_USERNAME=
|
||||
NEXTAUTH_GOOGLE_PASSWORD=
|
||||
14
.github/ISSUE_TEMPLATE/bug_report.md
vendored
14
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,28 +1,30 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a defect with the software
|
||||
about: Report a defect with NextAuth.js
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the defect is.
|
||||
A clear and concise description of the bug in NextAuth.js.
|
||||
|
||||
**To Reproduce**
|
||||
Do not report bugs with your own project here, ask from help by raising a question instead - this helps us a lot with administration overhead.
|
||||
|
||||
**Steps to reproduce**
|
||||
Steps to reproduce the behavior.
|
||||
|
||||
Include example code (or link to public repository) which can be used to reproduce the behaviour.
|
||||
Include a link to public repository which can be used to reproduce the behaviour.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots or error logs**
|
||||
If applicable, add screenshots or error logs to help explain the problem.
|
||||
If applicable add screenshots or error logs to help explain the problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Documentation 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).*
|
||||
|
||||
* [ ] Found the documentation helpful
|
||||
|
||||
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
|
||||
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
4
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,12 +1,10 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
about: Suggest an idea for NextAuth.js
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
*Please stick to one distinct feature request per issue where possible and raise additional feature quests as separate issues. Try to avoid adding feature requests to existing issues in the comments of issues raised by other users.*
|
||||
|
||||
**Summary of proposed feature**
|
||||
A clear and concise description of the feature being proposed.
|
||||
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/question.md
vendored
14
.github/ISSUE_TEMPLATE/question.md
vendored
@@ -1,19 +1,21 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask for information or support
|
||||
about: Ask a question about NextAuth.js or for help using it
|
||||
labels: question
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
*Please refer to the [documentation](https://next-auth.js.org/getting-started/introduction), the [example project](https://github.com/iaincollins/next-auth-example) and existing issues before creating a new issue.*
|
||||
<!-- NOTE: Questions will be converted to Discussions. You can find them at https://github.com/nextauthjs/next-auth/discussions! -->
|
||||
|
||||
**Your question**
|
||||
A clear and concise question.
|
||||
<!-- A clear and concise question. -->
|
||||
|
||||
**What are you trying to do**
|
||||
A description of what you are trying to do.
|
||||
<!-- A description of what you are trying to do, for context. -->
|
||||
|
||||
**Documentation feedback**
|
||||
**Reproduction**
|
||||
<!-- If your question is code related, adding a reproduction to your use case can greatly reduce the time it takes us to figure out how to better help you. -->
|
||||
|
||||
**Feedback**
|
||||
*Documentation refers to searching through [online documentation](https://next-auth.js.org), code comments and issue history. The example project refers to [next-auth-example](https://github.com/iaincollins/next-auth-example).*
|
||||
|
||||
* [ ] Found the documentation helpful
|
||||
|
||||
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/**/*
|
||||
24
.github/stale.yml
vendored
Normal file
24
.github/stale.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 60
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 7
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
- security
|
||||
- priority
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
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
|
||||
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
|
||||
closeComment: >
|
||||
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.
|
||||
If you think your issue is still relevant, just leave a comment
|
||||
and I will reopen it. (Read more at #912)
|
||||
Thanks!
|
||||
31
.github/workflows/build.yml
vendored
Normal file
31
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
# Simple check that the build is valid and no linting errors.
|
||||
# Currently is run as a seperate workflow as it's fast to fail.
|
||||
name: Build Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- canary
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- canary
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [10.x, 12.x, 14.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm run lint
|
||||
55
.github/workflows/integration.yml
vendored
Normal file
55
.github/workflows/integration.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Integration Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, canary ]
|
||||
pull_request:
|
||||
branches: [ main, canary ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Only run tests integration against Pull Requests from branches in
|
||||
# this repository. We do this as integration tests require access to
|
||||
# secrets in GitHub and they are not exposed to tests run against
|
||||
# forks (for security reasons), so integration test against
|
||||
# Pull Requests from external repos just fail and generate noise.
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
|
||||
# We use self-hosted runners as cloud based runnners (e.g. AWS, GPC)
|
||||
# fail due to IP Address checks done by providers, which enforce
|
||||
# CAPTCHA checks on login request from cloud compute IP addresses to
|
||||
# prevent abuse.
|
||||
runs-on: self-hosted
|
||||
|
||||
# Target time is under 5 minutes to run all tests. If it takes longer than
|
||||
# 10 minutes should look at running tests in parallel. No individual flow
|
||||
# should take longer than 5 minutes to build and run.
|
||||
timeout-minutes: 10
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
# Install dependencies
|
||||
- run: npm ci
|
||||
|
||||
# Run tests (build library, build + start test app in Docker, run tests)
|
||||
- run: npm test
|
||||
# TODO Tests should exit out if env vars not set (currently hangs)
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||
NEXTAUTH_TWITTER_ID: ${{secrets.NEXTAUTH_TWITTER_ID}}
|
||||
NEXTAUTH_TWITTER_SECRET: ${{secrets.NEXTAUTH_TWITTER_SECRET}}
|
||||
NEXTAUTH_TWITTER_USERNAME: ${{secrets.NEXTAUTH_TWITTER_USERNAME}}
|
||||
NEXTAUTH_TWITTER_PASSWORD: ${{secrets.NEXTAUTH_TWITTER_PASSWORD}}
|
||||
NEXTAUTH_GITHUB_ID: ${{secrets.NEXTAUTH_GITHUB_ID}}
|
||||
NEXTAUTH_GITHUB_SECRET: ${{secrets.NEXTAUTH_GITHUB_SECRET}}
|
||||
NEXTAUTH_GITHUB_USERNAME: ${{secrets.NEXTAUTH_GITHUB_USERNAME}}
|
||||
NEXTAUTH_GITHUB_PASSWORD: ${{secrets.NEXTAUTH_GITHUB_PASSWORD}}
|
||||
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
|
||||
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
|
||||
42
.gitignore
vendored
42
.gitignore
vendored
@@ -1,20 +1,7 @@
|
||||
.next
|
||||
.env
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
@@ -24,5 +11,26 @@ npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Docusaurus
|
||||
www/build
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build dirs
|
||||
.next
|
||||
/build
|
||||
/dist
|
||||
/www/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
.next
|
||||
|
||||
# VS
|
||||
/.vs/slnx.sqlite-journal
|
||||
/.vs/slnx.sqlite
|
||||
/.vs
|
||||
.vscode
|
||||
|
||||
# GitHub Actions runner
|
||||
/actions-runner
|
||||
/_work
|
||||
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).
|
||||
137
CONTRIBUTING.md
137
CONTRIBUTING.md
@@ -2,54 +2,32 @@
|
||||
|
||||
Contributions and feedback on your experience of using this software are welcome.
|
||||
|
||||
This includes bug reports, feature requests, ideas, pull requests and examples of how you have used this software.
|
||||
This includes bug reports, feature requests, ideas, pull requests, and examples of how you have used this software.
|
||||
|
||||
Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates configured in GitHub when reporting bugs, requesting enhancements or contributing code.
|
||||
Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates configured in GitHub when reporting bugs, requesting enhancements, or contributing code.
|
||||
|
||||
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
|
||||
|
||||
## Pull Requests
|
||||
## For contributors
|
||||
|
||||
* The latest changes are always in `main`
|
||||
* Pull Requests should be raised for larger changes
|
||||
* Pull Requests do not need approval before merging for those with contributor access (it's just helpful to have them to track changes)
|
||||
* Rebasing in Pull Requests is prefered to keep a clean commit history (see below)
|
||||
* Running `npm run lint:fix` before committing can make resolving conflicts easier, but is not required
|
||||
* Merge commits (and pushing merge commits to `main`) are disabled in this repo; but commits in PR can be squashed so this is not a blocker
|
||||
* Pushing directly to main should ideally be reserved for minor updates (e.g. correcting typos) or small single-commit fixes
|
||||
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
|
||||
|
||||
## Rebasing
|
||||
* The latest changes are always in `canary`, so please make your Pull Request against that branch.
|
||||
* Pull Requests should be raised for any change
|
||||
* Pull Requests need approval of a [core contributor](https://next-auth.js.org/contributors#core-team) before merging
|
||||
* Rebasing in Pull Requests is preferred to keep a clean commit history (see below)
|
||||
* Running `npm run lint:fix` before committing can make resolving conflicts easier
|
||||
* We encourage you to test your changes, and if you have the opportunity, please make those tests part of the Pull Request
|
||||
* If you add new functionality, please provide the corresponding documentation as well and make it part of the Pull Request
|
||||
|
||||
*If you don't rebase and end up with merge commits in a PR then it's not a blocker, we can alway squash the commits when merging!*
|
||||
|
||||
If you create a branch and there are conflicting updates in the `main` branch, you can resolve them by rebasing from a check out of your branch:
|
||||
|
||||
git fetch
|
||||
git rebase origin/main
|
||||
|
||||
If there are any conflicts, you can resolve them and stage the files, then run:
|
||||
|
||||
git rebase --continue
|
||||
|
||||
*If there are a lot of changes you may be prompted to step more than once.*
|
||||
|
||||
When the rebase is complete (i.e. there are no more conflicts) you should push your changes to your branch before doing anyhing 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. Uou 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
|
||||
### 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:
|
||||
|
||||
git clone git@github.com:iaincollins/next-auth.git
|
||||
git clone git@github.com:nextauthjs/next-auth.git
|
||||
cd next-auth/
|
||||
|
||||
2. Install packages and run the build command:
|
||||
@@ -57,24 +35,25 @@ A quick and dirty guide on how to setup *next-auth* locally to work on it and te
|
||||
npm i
|
||||
npm run build
|
||||
|
||||
3. Link React between the repo and the version installed in your project:
|
||||
|
||||
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).*
|
||||
|
||||
4. Finally link your project back to your local copy of next auth:
|
||||
3. Link your project back to your local copy of next auth:
|
||||
|
||||
cd ../your-application
|
||||
npm link ../next-auth
|
||||
|
||||
4. Finally link React between the repo and the version installed in your project:
|
||||
|
||||
cd ../next-auth
|
||||
npm link ../your-application/node_modules/react
|
||||
|
||||
*This is an annoying step and not obvious, but is needed because of how React has been written (otherwise React crashes when you try to use the `useSession()` hook in your project).*
|
||||
|
||||
That's it!
|
||||
|
||||
Notes: You may need to repeat both `npm link` steps if you install / update additional dependancies with `npm i`.
|
||||
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).
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -83,26 +62,80 @@ You might find it helpful to use the `npm run watch` command in the next-auth pr
|
||||
|
||||
If you are working on `next-auth/src/client/index.js` hot reloading will work as normal in your Next.js app.
|
||||
|
||||
However if you are working on anything else (e.g. `next-auth/src/server/*` etc) then you will need to *stop and start* your app for changes to apply as **Next.js will not hot reload those changes**.
|
||||
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:
|
||||
|
||||
### Databases
|
||||
```
|
||||
const clearRequireCachePlugin = require('webpack-clear-require-cache-plugin')
|
||||
|
||||
Included is a Docker Compose file that starts up MySQL, Postgres and MongoDB databases on localhost.
|
||||
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/,
|
||||
]))
|
||||
|
||||
It will use port 3306, 5432 and 27017 on localhost respectively; it will not work if are running existing databases on localhost.
|
||||
return config
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Databases
|
||||
|
||||
Included is a Docker Compose file that starts up MySQL, Postgres, and MongoDB databases on localhost.
|
||||
|
||||
It will use port `3306`, `5432`, and `27017` on localhost respectively; please make sure those ports are not used by other services on localhost.
|
||||
|
||||
You can start them with `npm run db:start` and stop them with `npm run db:stop`.
|
||||
|
||||
You will need Docker installed to be able to start / stop the databases.
|
||||
|
||||
When stop 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`.
|
||||
|
||||
Automated tests are currently crude and limited in functionality, but improvements are in development.
|
||||
|
||||
Currently to run tests you need to first have started local test databases (e.g. using `npm run db:start`).
|
||||
Currently, to run tests you need to first have started local test databases (e.g. using `npm run db:start`).
|
||||
|
||||
The databases can take a few seconds to start up, so you might need to give it a minute before running the tests.
|
||||
|
||||
## For maintainers
|
||||
|
||||
We use [semantic-release](https://github.com/semantic-release/semantic-release) together with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to automate releases. This makes the maintainenance process easier and less error-prone to human error. Please study the "Conventional Commits" site to understand how to write a good commit message.
|
||||
|
||||
When accepting Pull Requests, make sure the following:
|
||||
|
||||
* Use "Squash and merge"
|
||||
* Make sure you merge contributor PRs into `canary`
|
||||
* Rewrite the commit message to conform to the `Conventional Commits` style. Check the "Recommended Scopes" section for further advice.
|
||||
* Optionally link issues the PR will resolve (You can add "close" in front of the issue numbers to close the issues automatically, when the PR is merged. `semantic-release` will also comment back to connected issues and PRs, notifying the users that a feature is added/bug fixed, etc.)
|
||||
|
||||
### Recommended Scopes
|
||||
|
||||
A typical conventional commit looks like this:
|
||||
```
|
||||
type(scope): title
|
||||
|
||||
body
|
||||
```
|
||||
|
||||
Scope is the part that will help groupping the different commit types in the release notes.
|
||||
|
||||
Some recommened scopes are:
|
||||
|
||||
- **provider** - Provider related changes. (eg.: "feat(provider): add X provider", "docs(provider): fix typo in X documentation"
|
||||
- **adapter** - Adapter related changes. (eg.: "feat(adapter): add X provider", "docs(provider): fix typo in X documentation"
|
||||
- **db** - Database related changes. (eg.: "feat(db): add X database", "docs(db): fix typo in X documentation"
|
||||
- **deps** - Adding/removing/updating a dependency (eg.: "chore(deps): add X")
|
||||
|
||||
> NOTE: If you are not sure which scope to use, you can simply ignore it. (eg.: "feat: add something"). Adding the correct type already helps a lot when analyzing the commit messages.
|
||||
|
||||
|
||||
### Skipping a release
|
||||
|
||||
Every commit that contains [skip release] or [release skip] in their message will be excluded from the commit analysis and won't participate in the release type determination. This is useful, if the PR being merged should not trigger a new `npm` release.
|
||||
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
# Multi stage build to allow us to improve performance
|
||||
FROM node:10-alpine as base
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install basic dependancies (Next.js, React)
|
||||
COPY test/docker/app/package*.json ./
|
||||
RUN npm ci --only=production
|
||||
|
||||
FROM node:10-alpine as app
|
||||
COPY --from=base /usr/src/app ./
|
||||
|
||||
# Copy last build of library into the image and install dependences for it.
|
||||
# This ensures the build is valid and package.json contains everything needed
|
||||
# to actually run the library.
|
||||
# Note: You must run `npm run build` first to build a release of the library
|
||||
RUN mkdir -p node_modules/next-auth
|
||||
# Copy all entrypoints for the library (if creating a new one, add it here)
|
||||
COPY index.js providers.js adapters.js client.js jwt.js node_modules/next-auth/
|
||||
# Copy the dist dir
|
||||
COPY dist node_modules/next-auth/dist
|
||||
# Copy the package.json for the library and install it's dependences
|
||||
COPY package*.json node_modules/next-auth/
|
||||
RUN cd node_modules/next-auth/ && npm ci --only=production
|
||||
|
||||
# Copy test pages across
|
||||
COPY test/docker/app/pages ./pages
|
||||
|
||||
RUN npm run build
|
||||
|
||||
CMD [ "npm", "start" ]
|
||||
@@ -1,6 +1,6 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2018-2020, Iain Collins
|
||||
Copyright (c) 2018-2021, Iain Collins
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
|
||||
114
README.md
114
README.md
@@ -1,4 +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
|
||||
|
||||
@@ -6,35 +22,64 @@ 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.
|
||||
|
||||
[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.
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
### Flexible and easy to use
|
||||
|
||||
* Designed to work with any OAuth service, it supports OAuth 1.0, 1.0A and 2.0
|
||||
* Built-in support for [many popular OAuth sign-in services](https://next-auth.js.org/configuration/providers)
|
||||
* Built-in support for [many popular sign-in services](https://next-auth.js.org/configuration/providers)
|
||||
* Supports email / passwordless authentication
|
||||
* Supports stateless authentication with any backend (Active Directory, LDAP, etc)
|
||||
* Supports both JSON Web Tokens and database sessions
|
||||
* Designed for Serverless but runs anywhere (AWS Lambda, Docker, Heroku, etc…)
|
||||
|
||||
### Own your own data
|
||||
|
||||
NextAuth.js can be used with or without a database.
|
||||
|
||||
* An open source solution that allows you to keep control of your data
|
||||
* Supports Bring Your Own Database (BYOD) and can be used with any database
|
||||
* Built-in support for for [MySQL, MariaDB, Postgres, MongoDB and SQLite](https://next-auth.js.org/configuration/database)
|
||||
* Built-in support for [MySQL, MariaDB, Postgres, Microsoft SQL Server, MongoDB and SQLite](https://next-auth.js.org/configuration/databases)
|
||||
* Works great with databases from popular hosting providers
|
||||
* Can also be used without a database (e.g. OAuth + JWT)
|
||||
* Can also be used *without a database* (e.g. OAuth + JWT)
|
||||
|
||||
### Secure by default
|
||||
|
||||
* Designed to be secure by default and promote best practice for safeguarding user data
|
||||
* Promotes the use of passwordless sign in mechanisms
|
||||
* Designed to be secure by default and encourage best practice for safeguarding user data
|
||||
* Uses Cross Site Request Forgery Tokens on POST routes (sign in, sign out)
|
||||
* Default cookie policy aims for the most restrictive policy appropriate for each cookie
|
||||
* When JSON Web Tokens are enabled, they are signed by default (JWS) with HS512
|
||||
* Use JWT encryption (JWE) by setting the option `encryption: true` (defaults to A256GCM)
|
||||
* Auto-generates symmetric signing and encryption keys for developer convenience
|
||||
* Features tab/window syncing and keepalive messages to support short lived sessions
|
||||
* Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org/)
|
||||
|
||||
Security focused features include CSRF protection, use of signed cookies, cookie prefixes, secure cookies, HTTP only, host only and secure only cookies and promoting passwordless sign-in.
|
||||
Advanced options allow you to define your own routines to handle controlling what accounts are allowed to sign in, for encoding and decoding JSON Web Tokens and to set custom cookie security policies and session properties, so you can control who is able to sign in and how often sessions have to be re-validated.
|
||||
|
||||
### Typescript
|
||||
|
||||
You can install the appropriate types via the following command:
|
||||
|
||||
```
|
||||
npm install --save-dev @types/next-auth
|
||||
```
|
||||
|
||||
If you encounter any problems with the types package, please create an issue and add the `typescript` label to it.
|
||||
|
||||
Alternatively, you can open a pull request directly with your fixes on the [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth) repository, where you'll find a `next-auth` subfolder.
|
||||
|
||||
## Example
|
||||
|
||||
@@ -44,8 +89,7 @@ Security focused features include CSRF protection, use of signed cookies, cookie
|
||||
import NextAuth from 'next-auth'
|
||||
import Providers from 'next-auth/providers'
|
||||
|
||||
const options = {
|
||||
site: 'https://example.com'
|
||||
export default NextAuth({
|
||||
providers: [
|
||||
// OAuth authentication providers
|
||||
Providers.Apple({
|
||||
@@ -64,45 +108,43 @@ const options = {
|
||||
],
|
||||
// SQL or MongoDB database (or leave empty)
|
||||
database: process.env.DATABASE_URL
|
||||
}
|
||||
|
||||
export default (req, res) => NextAuth(req, res, options)
|
||||
})
|
||||
```
|
||||
|
||||
### Add React Component
|
||||
|
||||
```javascript
|
||||
import React from 'react'
|
||||
import {
|
||||
useSession,
|
||||
signin,
|
||||
signout
|
||||
import {
|
||||
useSession, signIn, signOut
|
||||
} from 'next-auth/client'
|
||||
|
||||
export default () => {
|
||||
export default function Component() {
|
||||
const [ session, loading ] = useSession()
|
||||
|
||||
return <p>
|
||||
{!session && <>
|
||||
Not signed in <br/>
|
||||
<button onClick={signin}>Sign in</button>
|
||||
</>}
|
||||
{session && <>
|
||||
if(session) {
|
||||
return <>
|
||||
Signed in as {session.user.email} <br/>
|
||||
<button onClick={signout}>Sign out</button>
|
||||
</>}
|
||||
</p>
|
||||
<button onClick={() => signOut()}>Sign out</button>
|
||||
</>
|
||||
}
|
||||
return <>
|
||||
Not signed in <br/>
|
||||
<button onClick={() => signIn()}>Sign in</button>
|
||||
</>
|
||||
}
|
||||
```
|
||||
|
||||
## Acknowledgement
|
||||
## Acknowledgements
|
||||
|
||||
[NextAuth.js 2.0 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
|
||||
|
||||
[Follow the examples to get started.](https://next-auth.js.org/getting-started/example)
|
||||
<a href="https://github.com/nextauthjs/next-auth/graphs/contributors">
|
||||
<img width="500px" src="https://contrib.rocks/image?repo=nextauthjs/next-auth" />
|
||||
</a>
|
||||
|
||||
## 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
|
||||
|
||||
24
SECURITY.md
Normal file
24
SECURITY.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Security Policy
|
||||
|
||||
NextAuth.js practices responsible disclosure.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are only released for the current version.
|
||||
|
||||
Old releases are not maintained and do not receive updates.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We request that you contact us directly to report serious issues that might impact the security of sites using NextAuth.js.
|
||||
|
||||
If you contact us regarding a serious issue:
|
||||
|
||||
* We will endeavor to get back to you within 72 hours.
|
||||
* We will aim to publish a fix within 30 days.
|
||||
* We will disclose the issue (and credit you, with your consent) once a fix to resolve the issue has been released.
|
||||
* If 90 days has elapsed and we still don't have a fix, we will disclose the issue publically.
|
||||
|
||||
Currently, the best way to report an issue is by emailing me@iaincollins.com
|
||||
|
||||
For less serious issues (e.g. RFC compliance for unsupported flows or potential issues that may cause a problem future or default behaviour / options) it is appropriate to submit these these publically as bug reports or feature requests or to raise a question to open a discussion around them.
|
||||
8042
package-lock.json
generated
8042
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
57
package.json
57
package.json
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "next-auth",
|
||||
"version": "2.0.0",
|
||||
"description": "An authentication library for Next.js",
|
||||
"repository": "https://github.com/iaincollins/next-auth.git",
|
||||
"version": "0.0.0-semantically-released",
|
||||
"description": "Authentication for Next.js",
|
||||
"homepage": "https://next-auth.js.org",
|
||||
"repository": "https://github.com/nextauthjs/next-auth.git",
|
||||
"author": "Iain Collins <me@iaincollins.com>",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -12,13 +13,19 @@
|
||||
"watch": "npm run watch:js | npm run watch:css",
|
||||
"watch:js": "babel --watch src --out-dir dist",
|
||||
"watch:css": "postcss --watch src/**/*.css --base src --dir dist",
|
||||
"test": "npm run test:db",
|
||||
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb",
|
||||
"test:app:start": "docker-compose -f test/docker/app.yml up -d",
|
||||
"test:app:rebuild": "npm run build && docker-compose -f test/docker/app.yml up -d --build",
|
||||
"test:app:stop": "docker-compose -f test/docker/app.yml down",
|
||||
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop",
|
||||
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql && npm run test:db:fauna",
|
||||
"test:db:mysql": "node test/mysql.js",
|
||||
"test:db:postgres": "node test/postgres.js",
|
||||
"test:db:mongodb": "node test/mongodb.js",
|
||||
"db:start": "docker-compose -f test/docker/docker-compose.yml up -d",
|
||||
"db:stop": "docker-compose -f test/docker/docker-compose.yml down",
|
||||
"test:db:mssql": "node test/mssql.js",
|
||||
"test:db:fauna": "node test/fauna.js",
|
||||
"test:integration": "mocha test/integration",
|
||||
"db:start": "docker-compose -f test/docker/databases.yml up -d",
|
||||
"db:stop": "docker-compose -f test/docker/databases.yml down",
|
||||
"prepublishOnly": "npm run build",
|
||||
"publish:beta": "npm publish --tag beta",
|
||||
"publish:canary": "npm publish --tag canary",
|
||||
@@ -36,31 +43,57 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.0.0",
|
||||
"faunadb": "^3.0.1",
|
||||
"futoin-hkdf": "^1.3.2",
|
||||
"jose": "^1.27.2",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"nodemailer": "^6.4.6",
|
||||
"nodemailer": "^6.4.16",
|
||||
"oauth": "^0.9.15",
|
||||
"preact": "^10.4.1",
|
||||
"preact-render-to-string": "^5.1.7",
|
||||
"querystring": "^0.2.0",
|
||||
"require_optional": "^1.0.1",
|
||||
"typeorm": "^0.2.24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1"
|
||||
"react": "^16.13.1 || ^17",
|
||||
"react-dom": "^16.13.1 || ^17"
|
||||
},
|
||||
"peerOptionalDependencies": {
|
||||
"mongodb": "^3.5.9",
|
||||
"mysql": "^2.18.1",
|
||||
"mssql": "^6.2.1",
|
||||
"pg": "^8.2.1",
|
||||
"@prisma/client": "^2.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.8.4",
|
||||
"@babel/core": "^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",
|
||||
"babel-preset-preact": "^2.0.0",
|
||||
"conventional-changelog-conventionalcommits": "4.4.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"dotenv": "^8.2.0",
|
||||
"mocha": "^8.1.3",
|
||||
"mongodb": "^3.5.9",
|
||||
"mssql": "^6.2.1",
|
||||
"mysql": "^2.18.1",
|
||||
"pg": "^8.2.1",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"postcss-nested": "^4.2.1",
|
||||
"standard": "^14.3.3"
|
||||
"puppeteer": "^5.2.1",
|
||||
"puppeteer-extra": "^3.1.15",
|
||||
"puppeteer-extra-plugin-stealth": "^2.6.1",
|
||||
"standard": "^16.0.3"
|
||||
},
|
||||
"standard": {
|
||||
"ignore": [
|
||||
"test/"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Serverless target in Next.js does 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).
|
||||
// https://github.com/iaincollins/next-auth/issues/281
|
||||
// https://github.com/nextauthjs/next-auth/issues/281
|
||||
//
|
||||
// To work around this issue, this script is a manual step that wraps CSS in a
|
||||
// JavaScript file that has the compiled CSS embedded in it, and exports only
|
||||
@@ -15,4 +15,4 @@ const css = fs.readFileSync(pathToCss, 'utf8')
|
||||
const cssWithEscapedQuotes = css.replace(/"/gm, '\\"')
|
||||
const js = `module.exports = function() { return "${cssWithEscapedQuotes}" }`
|
||||
|
||||
fs.writeFileSync(pathToCssJs, js)
|
||||
fs.writeFileSync(pathToCssJs, js)
|
||||
|
||||
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,6 +1,10 @@
|
||||
import TypeORM from './typeorm'
|
||||
import Prisma from './prisma'
|
||||
import Fauna from './fauna'
|
||||
|
||||
export default {
|
||||
Default: TypeORM.Adapter,
|
||||
TypeORM
|
||||
TypeORM,
|
||||
Prisma,
|
||||
Fauna
|
||||
}
|
||||
|
||||
336
src/adapters/prisma/index.js
Normal file
336
src/adapters/prisma/index.js
Normal file
@@ -0,0 +1,336 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
|
||||
import { CreateUserError } from '../../lib/errors'
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
const Adapter = (config) => {
|
||||
const {
|
||||
prisma,
|
||||
modelMapping = {
|
||||
User: 'user',
|
||||
Account: 'account',
|
||||
Session: 'session',
|
||||
VerificationRequest: 'verificationRequest'
|
||||
}
|
||||
} = config
|
||||
|
||||
const { User, Account, Session, VerificationRequest } = modelMapping
|
||||
|
||||
function getCompoundId (providerId, providerAccountId) {
|
||||
return createHash('sha256').update(`${providerId}:${providerAccountId}`).digest('hex')
|
||||
}
|
||||
|
||||
async function getAdapter (appOptions) {
|
||||
function debug (debugCode, ...args) {
|
||||
logger.debug(`PRISMA_${debugCode}`, ...args)
|
||||
}
|
||||
|
||||
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) {
|
||||
debug('GET_ADAPTER', 'Session expiry not configured (defaulting to 30 days')
|
||||
}
|
||||
|
||||
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
|
||||
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
|
||||
? appOptions.session.maxAge * 1000
|
||||
: defaultSessionMaxAge
|
||||
const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge)
|
||||
? appOptions.session.updateAge * 1000
|
||||
: 0
|
||||
|
||||
async function createUser (profile) {
|
||||
debug('CREATE_USER', profile)
|
||||
try {
|
||||
return prisma[User].create({
|
||||
data: {
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.image,
|
||||
emailVerified: profile.emailVerified ? profile.emailVerified.toISOString() : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('CREATE_USER_ERROR', error)
|
||||
return Promise.reject(new CreateUserError(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser (id) {
|
||||
debug('GET_USER', id)
|
||||
try {
|
||||
return prisma[User].findUnique({ where: { id } })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail (email) {
|
||||
debug('GET_USER_BY_EMAIL', email)
|
||||
try {
|
||||
if (!email) { return Promise.resolve(null) }
|
||||
return prisma[User].findUnique({ where: { email } })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_EMAIL_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
||||
try {
|
||||
const account = await prisma[Account].findUnique({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
|
||||
if (!account) { return null }
|
||||
return prisma[User].findUnique({ where: { id: account.userId } })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser (user) {
|
||||
debug('UPDATE_USER', user)
|
||||
try {
|
||||
const { id, name, email, image, emailVerified } = user
|
||||
return prisma[User].update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
image,
|
||||
emailVerified: emailVerified ? emailVerified.toISOString() : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('UPDATE_USER_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_USER_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser (userId) {
|
||||
debug('DELETE_USER', userId)
|
||||
try {
|
||||
return prisma[User].delete({ where: { id: userId } })
|
||||
} catch (error) {
|
||||
logger.error('DELETE_USER_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_USER_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
|
||||
debug('LINK_ACCOUNT', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
try {
|
||||
return prisma[Account].create({
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
compoundId: getCompoundId(providerId, providerAccountId),
|
||||
providerAccountId: `${providerAccountId}`,
|
||||
providerId,
|
||||
providerType,
|
||||
accessTokenExpires,
|
||||
userId
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('LINK_ACCOUNT_ERROR', error)
|
||||
return Promise.reject(new Error('LINK_ACCOUNT_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount (userId, providerId, providerAccountId) {
|
||||
debug('UNLINK_ACCOUNT', userId, providerId, providerAccountId)
|
||||
try {
|
||||
return prisma[Account].delete({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
|
||||
} catch (error) {
|
||||
logger.error('UNLINK_ACCOUNT_ERROR', error)
|
||||
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession (user) {
|
||||
debug('CREATE_SESSION', user)
|
||||
try {
|
||||
let expires = null
|
||||
if (sessionMaxAge) {
|
||||
const dateExpires = new Date()
|
||||
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
|
||||
expires = dateExpires.toISOString()
|
||||
}
|
||||
|
||||
return prisma[Session].create({
|
||||
data: {
|
||||
expires,
|
||||
userId: user.id,
|
||||
sessionToken: randomBytes(32).toString('hex'),
|
||||
accessToken: randomBytes(32).toString('hex')
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('CREATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('CREATE_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession (sessionToken) {
|
||||
debug('GET_SESSION', sessionToken)
|
||||
try {
|
||||
const session = await prisma[Session].findUnique({ where: { sessionToken } })
|
||||
|
||||
// Check session has not expired (do not return it if it has)
|
||||
if (session && session.expires && new Date() > session.expires) {
|
||||
await prisma[Session].delete({ where: { sessionToken } })
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
logger.error('GET_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('GET_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSession (session, force) {
|
||||
debug('UPDATE_SESSION', session)
|
||||
try {
|
||||
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
|
||||
// Calculate last updated date, to throttle write updates to database
|
||||
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||
//
|
||||
// Default for sessionMaxAge is 30 days.
|
||||
// Default for sessionUpdateAge is 1 hour.
|
||||
const dateSessionIsDueToBeUpdated = new Date(session.expires)
|
||||
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
|
||||
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
|
||||
|
||||
// Trigger update of session expiry date and write to database, only
|
||||
// if the session was last updated more than {sessionUpdateAge} ago
|
||||
if (new Date() > dateSessionIsDueToBeUpdated) {
|
||||
const newExpiryDate = new Date()
|
||||
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
|
||||
session.expires = newExpiryDate
|
||||
} else if (!force) {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// If session MaxAge, session UpdateAge or session.expires are
|
||||
// missing then don't even try to save changes, unless force is set.
|
||||
if (!force) { return null }
|
||||
}
|
||||
|
||||
const { id, expires } = session
|
||||
return prisma[Session].update({ where: { id }, data: { expires } })
|
||||
} catch (error) {
|
||||
logger.error('UPDATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession (sessionToken) {
|
||||
debug('DELETE_SESSION', sessionToken)
|
||||
try {
|
||||
return prisma[Session].delete({ where: { sessionToken } })
|
||||
} catch (error) {
|
||||
logger.error('DELETE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function createVerificationRequest (identifier, url, token, secret, provider) {
|
||||
debug('CREATE_VERIFICATION_REQUEST', identifier)
|
||||
try {
|
||||
const { baseUrl } = appOptions
|
||||
const { sendVerificationRequest, maxAge } = provider
|
||||
|
||||
// Store hashed token (using secret as salt) so that tokens cannot be exploited
|
||||
// even if the contents of the database is compromised.
|
||||
// @TODO Use bcrypt function here instead of simple salted hash
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
|
||||
let expires = null
|
||||
if (maxAge) {
|
||||
const dateExpires = new Date()
|
||||
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
|
||||
expires = dateExpires.toISOString()
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const verificationRequest = await prisma[VerificationRequest].create({
|
||||
data: {
|
||||
identifier,
|
||||
token: hashedToken,
|
||||
expires
|
||||
}
|
||||
})
|
||||
|
||||
// With the verificationCallback on a provider, you can send an email, or queue
|
||||
// an email to be sent, or perform some other action (e.g. send a text message)
|
||||
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
|
||||
|
||||
return verificationRequest
|
||||
} catch (error) {
|
||||
logger.error('CREATE_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getVerificationRequest (identifier, token, secret, provider) {
|
||||
debug('GET_VERIFICATION_REQUEST', identifier, token)
|
||||
try {
|
||||
// Hash token provided with secret before trying to match it with database
|
||||
// @TODO Use bcrypt instead of salted SHA-256 hash for token
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
const verificationRequest = await prisma[VerificationRequest].findUnique({ where: { token: hashedToken } })
|
||||
|
||||
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
|
||||
// Delete verification entry so it cannot be used again
|
||||
await prisma[VerificationRequest].delete({ where: { token: hashedToken } })
|
||||
return null
|
||||
}
|
||||
|
||||
return verificationRequest
|
||||
} catch (error) {
|
||||
logger.error('GET_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteVerificationRequest (identifier, token, secret, provider) {
|
||||
debug('DELETE_VERIFICATION', identifier, token)
|
||||
try {
|
||||
// Delete verification entry so it cannot be used again
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
await prisma[VerificationRequest].delete({ where: { token: hashedToken } })
|
||||
} catch (error) {
|
||||
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByProviderAccountId,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
linkAccount,
|
||||
unlinkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createVerificationRequest,
|
||||
getVerificationRequest,
|
||||
deleteVerificationRequest
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getAdapter
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Adapter
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createConnection, getConnection, getManager } from 'typeorm'
|
||||
import { createConnection, getConnection } from 'typeorm'
|
||||
import { createHash } from 'crypto'
|
||||
import require_optional from 'require_optional' // eslint-disable-line camelcase
|
||||
|
||||
import { CreateUserError } from '../../lib/errors'
|
||||
import adapterConfig from './lib/config'
|
||||
import adapterTransform from './lib/transform'
|
||||
import Models from './models'
|
||||
import logger from '../../lib/logger'
|
||||
import { updateConnectionEntities } from './lib/utils'
|
||||
|
||||
const Adapter = (typeOrmConfig, options = {}) => {
|
||||
// Ensure typeOrmConfigObject is normalized to an object
|
||||
@@ -28,7 +30,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
// anything to do them). This function updates arguments by reference.
|
||||
adapterTransform(typeOrmConfigObject, models, options)
|
||||
|
||||
const config = adapterConfig.loadConfig(typeOrmConfigObject, { models, ...options })
|
||||
const config = adapterConfig.loadConfig(typeOrmConfigObject, { ...options, models })
|
||||
|
||||
// Create objects from models that can be consumed by functions in the adapter
|
||||
const User = models.User.model
|
||||
@@ -67,12 +69,18 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
await _connect()
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
await updateConnectionEntities(connection, config.entities)
|
||||
}
|
||||
|
||||
// Get manager from connection object
|
||||
// https://github.com/typeorm/typeorm/blob/master/docs/entity-manager-api.md
|
||||
const { manager } = connection
|
||||
|
||||
// Display debug output if debug option enabled
|
||||
// @TODO Refactor logger so is passed in appOptions
|
||||
function debugMessage (debugCode, ...args) {
|
||||
if (appOptions && appOptions.debug) {
|
||||
logger.debug(`TYPEORM_${debugCode}`, ...args)
|
||||
}
|
||||
function debug (debugCode, ...args) {
|
||||
logger.debug(`TYPEORM_${debugCode}`, ...args)
|
||||
}
|
||||
|
||||
// The models are primarily designed for ANSI SQL database, but some
|
||||
@@ -86,7 +94,11 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
let ObjectId
|
||||
if (config.type === 'mongodb') {
|
||||
idKey = '_id'
|
||||
const mongodb = await import('mongodb')
|
||||
// Using a dynamic import causes problems for some compilers/bundlers
|
||||
// that don't handle dynamic imports. To try and work around this we are
|
||||
// using the same method mongodb uses to load Object ID type, which is to
|
||||
// use the require_optional loader.
|
||||
const mongodb = require_optional('mongodb')
|
||||
ObjectId = mongodb.ObjectId
|
||||
}
|
||||
|
||||
@@ -96,7 +108,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
// Use a conditional to default to 30 day session age if not set - it should
|
||||
// always be set but a meaningful fallback is helpful to facilitate testing.
|
||||
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) {
|
||||
debugMessage('GET_ADAPTER', 'Session expiry not configured (defaulting to 30 days')
|
||||
debug('GET_ADAPTER', 'Session expiry not configured (defaulting to 30 days')
|
||||
}
|
||||
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
|
||||
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
|
||||
@@ -107,11 +119,11 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
: 0
|
||||
|
||||
async function createUser (profile) {
|
||||
debugMessage('CREATE_USER', profile)
|
||||
debug('CREATE_USER', profile)
|
||||
try {
|
||||
// Create user account
|
||||
const user = new User(profile.name, profile.email, profile.image, profile.emailVerified)
|
||||
return await getManager().save(user)
|
||||
return await manager.save(user)
|
||||
} catch (error) {
|
||||
logger.error('CREATE_USER_ERROR', error)
|
||||
return Promise.reject(new CreateUserError(error))
|
||||
@@ -119,7 +131,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function getUser (id) {
|
||||
debugMessage('GET_USER', id)
|
||||
debug('GET_USER', id)
|
||||
|
||||
// In the very specific case of both using JWT for storing session data
|
||||
// and using MongoDB to store user data, the ID is a string rather than
|
||||
@@ -132,7 +144,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
try {
|
||||
return connection.getRepository(User).findOne({ [idKey]: id })
|
||||
return manager.findOne(User, { [idKey]: id })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
|
||||
@@ -140,10 +152,10 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function getUserByEmail (email) {
|
||||
debugMessage('GET_USER_BY_EMAIL', email)
|
||||
debug('GET_USER_BY_EMAIL', email)
|
||||
try {
|
||||
if (!email) { return Promise.resolve(null) }
|
||||
return connection.getRepository(User).findOne({ email })
|
||||
return manager.findOne(User, { email })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_EMAIL_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
|
||||
@@ -151,11 +163,11 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||
debugMessage('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
||||
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
||||
try {
|
||||
const account = await connection.getRepository(Account).findOne({ providerId, providerAccountId })
|
||||
const account = await manager.findOne(Account, { providerId, providerAccountId })
|
||||
if (!account) { return null }
|
||||
return connection.getRepository(User).findOne({ [idKey]: account.userId })
|
||||
return manager.findOne(User, { [idKey]: account.userId })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
|
||||
@@ -163,22 +175,22 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function updateUser (user) {
|
||||
debugMessage('UPDATE_USER', user)
|
||||
return getManager().save(user)
|
||||
debug('UPDATE_USER', user)
|
||||
return manager.save(User, user)
|
||||
}
|
||||
|
||||
async function deleteUser (userId) {
|
||||
debugMessage('DELETE_USER', userId)
|
||||
debug('DELETE_USER', userId)
|
||||
// @TODO Delete user from DB
|
||||
return false
|
||||
}
|
||||
|
||||
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
|
||||
debugMessage('LINK_ACCOUNT', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
debug('LINK_ACCOUNT', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
try {
|
||||
// Create provider account linked to user
|
||||
const account = new Account(userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
return getManager().save(account)
|
||||
return manager.save(account)
|
||||
} catch (error) {
|
||||
logger.error('LINK_ACCOUNT_ERROR', error)
|
||||
return Promise.reject(new Error('LINK_ACCOUNT_ERROR', error))
|
||||
@@ -186,7 +198,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function unlinkAccount (userId, providerId, providerAccountId) {
|
||||
debugMessage('UNLINK_ACCOUNT', userId, providerId, providerAccountId)
|
||||
debug('UNLINK_ACCOUNT', userId, providerId, providerAccountId)
|
||||
// @TODO Get current user from DB
|
||||
// @TODO Delete [provider] object from user object
|
||||
// @TODO Save changes to user object in DB
|
||||
@@ -194,7 +206,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function createSession (user) {
|
||||
debugMessage('CREATE_SESSION', user)
|
||||
debug('CREATE_SESSION', user)
|
||||
try {
|
||||
let expires = null
|
||||
if (sessionMaxAge) {
|
||||
@@ -205,7 +217,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
|
||||
const session = new Session(user.id, expires)
|
||||
|
||||
return getManager().save(session)
|
||||
return manager.save(session)
|
||||
} catch (error) {
|
||||
logger.error('CREATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('CREATE_SESSION_ERROR', error))
|
||||
@@ -213,9 +225,9 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function getSession (sessionToken) {
|
||||
debugMessage('GET_SESSION', sessionToken)
|
||||
debug('GET_SESSION', sessionToken)
|
||||
try {
|
||||
const session = await connection.getRepository(Session).findOne({ sessionToken })
|
||||
const session = await manager.findOne(Session, { sessionToken })
|
||||
|
||||
// Check session has not expired (do not return it if it has)
|
||||
if (session && session.expires && new Date() > new Date(session.expires)) {
|
||||
@@ -231,7 +243,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function updateSession (session, force) {
|
||||
debugMessage('UPDATE_SESSION', session)
|
||||
debug('UPDATE_SESSION', session)
|
||||
try {
|
||||
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
|
||||
// Calculate last updated date, to throttle write updates to database
|
||||
@@ -259,7 +271,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
if (!force) { return null }
|
||||
}
|
||||
|
||||
return getManager().save(session)
|
||||
return manager.save(Session, session)
|
||||
} catch (error) {
|
||||
logger.error('UPDATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
|
||||
@@ -267,9 +279,9 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function deleteSession (sessionToken) {
|
||||
debugMessage('DELETE_SESSION', sessionToken)
|
||||
debug('DELETE_SESSION', sessionToken)
|
||||
try {
|
||||
return await connection.getRepository(Session).delete({ sessionToken })
|
||||
return await manager.delete(Session, { sessionToken })
|
||||
} catch (error) {
|
||||
logger.error('DELETE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_SESSION_ERROR', error))
|
||||
@@ -277,9 +289,9 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function createVerificationRequest (identifier, url, token, secret, provider) {
|
||||
debugMessage('CREATE_VERIFICATION_REQUEST', identifier)
|
||||
debug('CREATE_VERIFICATION_REQUEST', identifier)
|
||||
try {
|
||||
const { site } = appOptions
|
||||
const { baseUrl } = appOptions
|
||||
const { sendVerificationRequest, maxAge } = provider
|
||||
|
||||
// Store hashed token (using secret as salt) so that tokens cannot be exploited
|
||||
@@ -296,11 +308,11 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
|
||||
// Save to database
|
||||
const newVerificationRequest = new VerificationRequest(identifier, hashedToken, expires)
|
||||
const verificationRequest = await getManager().save(newVerificationRequest)
|
||||
const verificationRequest = await manager.save(newVerificationRequest)
|
||||
|
||||
// With the verificationCallback on a provider, you can send an email, or queue
|
||||
// an email to be sent, or perform some other action (e.g. send a text message)
|
||||
await sendVerificationRequest({ identifier, url, token, site, provider })
|
||||
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
|
||||
|
||||
return verificationRequest
|
||||
} catch (error) {
|
||||
@@ -310,16 +322,16 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function getVerificationRequest (identifier, token, secret, provider) {
|
||||
debugMessage('GET_VERIFICATION_REQUEST', identifier, token)
|
||||
debug('GET_VERIFICATION_REQUEST', identifier, token)
|
||||
try {
|
||||
// Hash token provided with secret before trying to match it with database
|
||||
// @TODO Use bcrypt instead of salted SHA-256 hash for token
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
const verificationRequest = await connection.getRepository(VerificationRequest).findOne({ identifier, token: hashedToken })
|
||||
const verificationRequest = await manager.findOne(VerificationRequest, { identifier, token: hashedToken })
|
||||
|
||||
if (verificationRequest && verificationRequest.expires && new Date() > new Date(verificationRequest.expires)) {
|
||||
// Delete verification entry so it cannot be used again
|
||||
await connection.getRepository(VerificationRequest).delete({ token: hashedToken })
|
||||
await manager.delete(VerificationRequest, { token: hashedToken })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -331,11 +343,11 @@ const Adapter = (typeOrmConfig, options = {}) => {
|
||||
}
|
||||
|
||||
async function deleteVerificationRequest (identifier, token, secret, provider) {
|
||||
debugMessage('DELETE_VERIFICATION', identifier, token)
|
||||
debug('DELETE_VERIFICATION', identifier, token)
|
||||
try {
|
||||
// Delete verification entry so it cannot be used again
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
await connection.getRepository(VerificationRequest).delete({ token: hashedToken })
|
||||
await manager.delete(VerificationRequest, { token: hashedToken })
|
||||
} catch (error) {
|
||||
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))
|
||||
|
||||
@@ -7,19 +7,36 @@ const parseConnectionString = (configString) => {
|
||||
// to make configuration easier (in most use cases).
|
||||
//
|
||||
// TypeORM accepts connection string as a 'url' option, but unfortunately
|
||||
// not for all databases (e.g. SQLite) or options, so we handle parsing it
|
||||
// in this function..
|
||||
// not for all databases (e.g. SQLite) or for all options, so we handle
|
||||
// parsing it in this function.
|
||||
try {
|
||||
const parsedUrl = new URL(configString)
|
||||
const config = {}
|
||||
|
||||
// Remove : and convert strings like 'mongodb+srv' into 'mongodb'
|
||||
config.type = parsedUrl.protocol.replace(/:$/, '').replace(/\+(.*)?$/, '')
|
||||
config.host = parsedUrl.hostname
|
||||
config.port = Number(parsedUrl.port)
|
||||
config.username = parsedUrl.username
|
||||
config.password = parsedUrl.password
|
||||
config.database = parsedUrl.pathname.replace(/^\//, '')
|
||||
if (parsedUrl.protocol.startsWith('mongodb+srv')) {
|
||||
// Special case handling is required for mongodb+srv with TypeORM
|
||||
config.type = 'mongodb'
|
||||
config.url = configString.replace(/\?(.*)$/, '')
|
||||
config.useNewUrlParser = true
|
||||
} else {
|
||||
config.type = parsedUrl.protocol.replace(/:$/, '')
|
||||
config.host = parsedUrl.hostname
|
||||
config.port = Number(parsedUrl.port)
|
||||
config.username = parsedUrl.username
|
||||
config.password = parsedUrl.password
|
||||
config.database = parsedUrl.pathname.replace(/^\//, '').replace(/\?(.*)$/, '')
|
||||
config.options = {}
|
||||
}
|
||||
|
||||
// This option is recommended by mongodb
|
||||
if (config.type === 'mongodb') {
|
||||
config.useUnifiedTopology = true
|
||||
}
|
||||
|
||||
// Prevents warning about deprecated option (sets default value)
|
||||
if (config.type === 'mssql') {
|
||||
config.options.enableArithAbort = true
|
||||
}
|
||||
|
||||
if (parsedUrl.search) {
|
||||
parsedUrl.search.replace(/^\?/, '').split('&').forEach(keyValuePair => {
|
||||
@@ -42,7 +59,7 @@ const parseConnectionString = (configString) => {
|
||||
|
||||
const loadConfig = (config, { models, namingStrategy }) => {
|
||||
const defaultConfig = {
|
||||
name: 'default',
|
||||
name: 'nextauth',
|
||||
autoLoadEntities: true,
|
||||
entities: [
|
||||
new EntitySchema(models.User.schema),
|
||||
|
||||
@@ -1,51 +1,45 @@
|
||||
// Perform transforms on SQL models so they can be used with other databases
|
||||
import { SnakeCaseNamingStrategy, CamelCaseNamingStrategy } from './naming-strategies'
|
||||
|
||||
const postgres = (models, options) => {
|
||||
const postgresTransform = (models, options) => {
|
||||
// Apply snake case naming strategy for Postgres databases
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
|
||||
// Only transforms models that are not custom models
|
||||
const { models: customModels = {} } = options
|
||||
|
||||
// For Postgres we need to use the `timestamp with time zone` type
|
||||
// aka `timestamptz` to store timestamps correctly in UTC.
|
||||
if (!customModels.User) {
|
||||
for (const column in models.User.schema.columns) {
|
||||
if (models.User.schema.columns[column].type === 'timestamp') {
|
||||
models.User.schema.columns[column].type = 'timestamptz'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customModels.Account) {
|
||||
for (const column in models.Account.schema.columns) {
|
||||
if (models.Account.schema.columns[column].type === 'timestamp') {
|
||||
models.Account.schema.columns[column].type = 'timestamptz'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customModels.Session) {
|
||||
for (const column in models.Session.schema.columns) {
|
||||
if (models.Session.schema.columns[column].type === 'timestamp') {
|
||||
models.Session.schema.columns[column].type = 'timestamptz'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customModels.VerificationRequest) {
|
||||
for (const column in models.VerificationRequest.schema.columns) {
|
||||
if (models.VerificationRequest.schema.columns[column].type === 'timestamp') {
|
||||
models.VerificationRequest.schema.columns[column].type = 'timestamptz'
|
||||
for (const model in models) {
|
||||
for (const column in models[model].schema.columns) {
|
||||
if (models[model].schema.columns[column].type === 'timestamp') {
|
||||
models[model].schema.columns[column].type = 'timestamptz'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mongodb = (models, options) => {
|
||||
const mysqlTransform = (models, options) => {
|
||||
// Apply snake case naming strategy for MySQL databases
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
|
||||
// For MySQL we default milisecond precision of all timestamps to 6 digits.
|
||||
// This ensures all timestamp fields use the same precision (unless explictly
|
||||
// configured otherwise) and that values in MySQL match those Postgress.
|
||||
for (const model in models) {
|
||||
for (const column in models[model].schema.columns) {
|
||||
if (models[model].schema.columns[column].type === 'timestamp') {
|
||||
// If precision explictly set (including to null) don't change it
|
||||
if (typeof models[model].schema.columns[column].precision === 'undefined') {
|
||||
models[model].schema.columns[column].precision = 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mongodbTransform = (models, options) => {
|
||||
// A CamelCase naming strategy is used for all document databases
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new CamelCaseNamingStrategy()
|
||||
@@ -65,56 +59,38 @@ const mongodb = (models, options) => {
|
||||
// see the result of queries like find() is wrong. You will see the same
|
||||
// Object ID in every property of type Object ID in the result (but the
|
||||
// database will look fine); so use `type: 'objectId'` for them instead.
|
||||
|
||||
// Only transforms models that are not custom models
|
||||
const { models: customModels = {} } = options
|
||||
|
||||
if (!customModels.User) {
|
||||
delete models.User.schema.columns.id.type
|
||||
models.User.schema.columns.id.objectId = true
|
||||
|
||||
// The options `unique: true` and `nullable: true` don't work the same
|
||||
// with MongoDB as they do with SQL databases like MySQL and Postgres,
|
||||
// we also to add sparce to the index. This still doesn't allow multiple
|
||||
// *null* values, but does allow some records to omit the property.
|
||||
delete models.User.schema.columns.email.unique
|
||||
models.User.schema.indices = [
|
||||
{
|
||||
name: 'email',
|
||||
unique: true,
|
||||
sparse: true,
|
||||
columns: ['email']
|
||||
}
|
||||
]
|
||||
for (const model in models) {
|
||||
delete models[model].schema.columns.id.type
|
||||
models[model].schema.columns.id.objectId = true
|
||||
}
|
||||
|
||||
if (!customModels.Account) {
|
||||
delete models.Account.schema.columns.id.type
|
||||
models.Account.schema.columns.id.objectId = true
|
||||
models.Account.schema.columns.userId.type = 'objectId'
|
||||
}
|
||||
// Ensure reference to User ID in other models are Object IDs
|
||||
// This needs to done for any properties that reference another entity by ID
|
||||
models.Account.schema.columns.userId.type = 'objectId'
|
||||
models.Session.schema.columns.userId.type = 'objectId'
|
||||
|
||||
if (!customModels.Session) {
|
||||
delete models.Session.schema.columns.id.type
|
||||
models.Session.schema.columns.id.objectId = true
|
||||
models.Session.schema.columns.userId.type = 'objectId'
|
||||
}
|
||||
// The options `unique: true` and `nullable: true` don't work the same
|
||||
// with MongoDB as they do with SQL databases like MySQL and Postgres,
|
||||
// we need to create a sparse index to only allow unique values, while
|
||||
// still allowing multiple entires to omit the email address.
|
||||
delete models.User.schema.columns.email.unique
|
||||
|
||||
if (!customModels.VerificationRequest) {
|
||||
delete models.VerificationRequest.schema.columns.id.type
|
||||
models.VerificationRequest.schema.columns.id.objectId = true
|
||||
}
|
||||
if (!models.User.schema.indices) { models.User.schema.indices = [] }
|
||||
|
||||
models.User.schema.indices.push({
|
||||
name: 'email',
|
||||
unique: true,
|
||||
sparse: true,
|
||||
columns: ['email']
|
||||
})
|
||||
}
|
||||
|
||||
const sqlite = (models, options) => {
|
||||
const sqliteTransform = (models, options) => {
|
||||
// Apply snake case naming strategy for SQLite databases
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
|
||||
// Only transforms models that are not custom models
|
||||
const { models: customModels = {} } = options
|
||||
|
||||
// SQLite does not support `timestamp` fields so we remap them to `datetime`
|
||||
// in all models.
|
||||
//
|
||||
@@ -123,51 +99,66 @@ const sqlite = (models, options) => {
|
||||
//
|
||||
// NB: SQLite adds 'create' and 'update' fields to allow rows, but that is
|
||||
// specific to SQLite and so we ignore that behaviour.
|
||||
if (!customModels.User) {
|
||||
for (const column in models.User.schema.columns) {
|
||||
if (models.User.schema.columns[column].type === 'timestamp') {
|
||||
models.User.schema.columns[column].type = 'datetime'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customModels.Account) {
|
||||
for (const column in models.Account.schema.columns) {
|
||||
if (models.Account.schema.columns[column].type === 'timestamp') {
|
||||
models.Account.schema.columns[column].type = 'datetime'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customModels.Session) {
|
||||
for (const column in models.Session.schema.columns) {
|
||||
if (models.Session.schema.columns[column].type === 'timestamp') {
|
||||
models.Session.schema.columns[column].type = 'datetime'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customModels.VerificationRequest) {
|
||||
for (const column in models.VerificationRequest.schema.columns) {
|
||||
if (models.VerificationRequest.schema.columns[column].type === 'timestamp') {
|
||||
models.VerificationRequest.schema.columns[column].type = 'datetime'
|
||||
for (const model in models) {
|
||||
for (const column in models[model].schema.columns) {
|
||||
if (models[model].schema.columns[column].type === 'timestamp') {
|
||||
models[model].schema.columns[column].type = 'datetime'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mssqlTransform = (models, options) => {
|
||||
// Apply snake case naming strategy for SQL Server databases
|
||||
if (!options.namingStrategy) {
|
||||
// @TODO Add TitleCase instead as more common MSSQL convention?
|
||||
options.namingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
|
||||
// SQL Server deprecated TIMESTAMP in favor of ROWVERSION.
|
||||
// But ROWVERSION is not what it was intended in the other adapters.
|
||||
for (const model in models) {
|
||||
for (const column in models[model].schema.columns) {
|
||||
if (models[model].schema.columns[column].type === 'timestamp') {
|
||||
models[model].schema.columns[column].type = 'datetime'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Support UNIQUE on on User.email that allows duplicate NULL values
|
||||
// Note: This is ANSI SQL behaviour for UNIQUE not default in SQL Server
|
||||
delete models.User.schema.columns.email.unique
|
||||
|
||||
if (!models.User.schema.indices) { models.User.schema.indices = [] }
|
||||
|
||||
models.User.schema.indices.push({
|
||||
name: 'email',
|
||||
columns: ['email'],
|
||||
unique: true,
|
||||
where: 'email IS NOT NULL'
|
||||
})
|
||||
}
|
||||
|
||||
export default (config, models, options) => {
|
||||
// @TODO Refactor into switch statement
|
||||
if ((config.type && config.type.startsWith('mongodb')) ||
|
||||
(config.url && config.url.startsWith('mongodb'))) {
|
||||
mongodb(models, options)
|
||||
mongodbTransform(models, options)
|
||||
} else if ((config.type && config.type.startsWith('postgres')) ||
|
||||
(config.url && config.url.startsWith('postgres'))) {
|
||||
postgres(models, options)
|
||||
postgresTransform(models, options)
|
||||
} else if ((config.type && config.type.startsWith('mysql')) ||
|
||||
(config.url && config.url.startsWith('mysql'))) {
|
||||
mysqlTransform(models, options)
|
||||
} else if ((config.type && config.type.startsWith('sqlite')) ||
|
||||
(config.url && config.url.startsWith('sqlite'))) {
|
||||
sqlite(models, options)
|
||||
sqliteTransform(models, options)
|
||||
} else if ((config.type && config.type.startsWith('mssql')) ||
|
||||
(config.url && config.url.startsWith('mssql'))) {
|
||||
mssqlTransform(models, options)
|
||||
} else {
|
||||
// Apply snake case naming strategy by default for SQL databases
|
||||
// For all other SQL databases (e.g. MySQL) apply snake case naming
|
||||
// strategy, but otherwise use the models and schemas as they are.
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
|
||||
18
src/adapters/typeorm/lib/utils.js
Normal file
18
src/adapters/typeorm/lib/utils.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const entitiesChanged = (prevEntities, newEntities) => {
|
||||
if (prevEntities.length !== newEntities.length) return true
|
||||
for (let i = 0; i < prevEntities.length; i++) {
|
||||
if (prevEntities[i] !== newEntities[i]) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const updateConnectionEntities = async (connection, entities) => {
|
||||
// Check if the entities passed have changed and if so replace them
|
||||
// and re-sync the typeorm connection.
|
||||
if (!connection || !entitiesChanged(connection.options.entities, entities)) return
|
||||
connection.options.entities = entities
|
||||
connection.buildMetadatas()
|
||||
if (connection.options.synchronize) {
|
||||
await connection.synchronize()
|
||||
}
|
||||
}
|
||||
@@ -1,176 +1,291 @@
|
||||
// fetch() is built in to Next.js 9.4 (you can use a polyfill if using an older version)
|
||||
/// Note: fetch() is built in to Next.js 9.4
|
||||
//
|
||||
// Note about signIn() and signOut() methods:
|
||||
//
|
||||
// On signIn() and signOut() we pass 'json: true' to request a response in JSON
|
||||
// instead of HTTP as redirect URLs on other domains are not returned to
|
||||
// requests made using the fetch API in the browser, and we need to ask the API
|
||||
// to return the response as a JSON object (the end point still defaults to
|
||||
// returning an HTTP response with a redirect for non-JavaScript clients).
|
||||
//
|
||||
// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks.
|
||||
|
||||
/* global fetch:false */
|
||||
import { useState, useEffect, useContext, createContext, createElement } from 'react'
|
||||
import logger from '../lib/logger'
|
||||
import parseUrl from '../lib/parse-url'
|
||||
|
||||
// Note: In calls to fetch() from universal methods, all cookies are passed
|
||||
// through from the browser, when the server makes the HTTP request, so that
|
||||
// it can authenticate as the browser.
|
||||
// This behaviour mirrors the default behaviour for getting the site name that
|
||||
// happens server side in server/index.js
|
||||
// 1. An empty value is legitimate when the code is being invoked client side as
|
||||
// relative URLs are valid in that context and so defaults to empty.
|
||||
// 2. When invoked server side the value is picked up from an environment
|
||||
// variable and defaults to 'http://localhost:3000'.
|
||||
const __NEXTAUTH = {
|
||||
baseUrl: parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL).baseUrl,
|
||||
basePath: parseUrl(process.env.NEXTAUTH_URL).basePath,
|
||||
keepAlive: 0, // 0 == disabled (don't send); 60 == send every 60 seconds
|
||||
clientMaxAge: 0, // 0 == disabled (only use cache); 60 == sync if last checked > 60 seconds ago
|
||||
// Properties starting with _ are used for tracking internal app state
|
||||
_clientLastSync: 0, // used for timestamp since last sycned (in seconds)
|
||||
_clientSyncTimer: null, // stores timer for poll interval
|
||||
_eventListenersAdded: false, // tracks if event listeners have been added,
|
||||
_clientSession: undefined, // stores last session response from hook,
|
||||
// Generate a unique ID to make it possible to identify when a message
|
||||
// was sent from this tab/window so it can be ignored to avoid event loops.
|
||||
_clientId: Math.random().toString(36).substring(2) + Date.now().toString(36),
|
||||
// Used to store to function export by getSession() hook
|
||||
_getSession: () => {}
|
||||
}
|
||||
|
||||
// These can be overridden with NEXTAUTH_ env vars in next.config.js
|
||||
// e.g. process.env.NEXTAUTH_SITE
|
||||
const NEXTAUTH_DEFAULT_BASE_URL_COOKIE_NAME = 'next-auth.base-url'
|
||||
const NEXTAUTH_DEFAULT_SITE = ''
|
||||
const NEXTAUTH_DEFAULT_BASE_PATH = '/api/auth'
|
||||
const NEXTAUTH_DEFAULT_CLIENT_MAXAGE = 0 // e.g. 0 == disabled, 60 == 60 seconds
|
||||
// Add event listners on load
|
||||
if (typeof window !== 'undefined') {
|
||||
if (__NEXTAUTH._eventListenersAdded === false) {
|
||||
__NEXTAUTH._eventListenersAdded = true
|
||||
|
||||
let NEXTAUTH_EVENT_LISTENER_ADDED = false
|
||||
// Listen for storage events and update session if event fired from
|
||||
// another window (but suppress firing another event to avoid a loop)
|
||||
window.addEventListener('storage', async (event) => {
|
||||
if (event.key === 'nextauth.message') {
|
||||
const message = JSON.parse(event.newValue)
|
||||
if (message.event && message.event === 'session' && message.data) {
|
||||
// Ignore storage events fired from the same window that created them
|
||||
if (__NEXTAUTH._clientId === message.clientId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch new session data but pass 'true' to it not to fire an event to
|
||||
// avoid an infinite loop.
|
||||
//
|
||||
// Note: We could pass session data through and do something like
|
||||
// `setData(message.data)` but that can cause problems depending
|
||||
// on how the session object is being used in the client; it is
|
||||
// more robust to have each window/tab fetch it's own copy of the
|
||||
// session object rather than share it across instances.
|
||||
await __NEXTAUTH._getSession({ event: 'storage' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for window focus/blur events
|
||||
window.addEventListener('focus', async (event) => __NEXTAUTH._getSession({ event: 'focus' }))
|
||||
window.addEventListener('blur', async (event) => __NEXTAUTH._getSession({ event: 'blur' }))
|
||||
}
|
||||
}
|
||||
|
||||
// Method to set options. The documented way is to use the provider, but this
|
||||
// method is being left in as an alternative, that will be helpful if/when we
|
||||
// expose a vanilla JavaScript version that doesn't depend on React.
|
||||
const setOptions = ({
|
||||
baseUrl,
|
||||
basePath,
|
||||
clientMaxAge,
|
||||
keepAlive
|
||||
} = {}) => {
|
||||
if (baseUrl) { __NEXTAUTH.baseUrl = baseUrl }
|
||||
if (basePath) { __NEXTAUTH.basePath = basePath }
|
||||
if (clientMaxAge) { __NEXTAUTH.clientMaxAge = clientMaxAge }
|
||||
if (keepAlive) {
|
||||
__NEXTAUTH.keepAlive = keepAlive
|
||||
|
||||
if (typeof window !== 'undefined' && keepAlive > 0) {
|
||||
// Clear existing timer (if there is one)
|
||||
if (__NEXTAUTH._clientSyncTimer !== null) { clearTimeout(__NEXTAUTH._clientSyncTimer) }
|
||||
|
||||
// Set next timer to trigger in number of seconds
|
||||
__NEXTAUTH._clientSyncTimer = setTimeout(async () => {
|
||||
// Only invoke keepalive when a session exists
|
||||
if (__NEXTAUTH._clientSession) {
|
||||
await __NEXTAUTH._getSession({ event: 'timer' })
|
||||
}
|
||||
}, keepAlive * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Universal method (client + server)
|
||||
const getSession = async ({ req } = {}) => {
|
||||
const baseUrl = _baseUrl({ req })
|
||||
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
|
||||
const session = await _fetchData(`${baseUrl}/session`, options)
|
||||
_sendMessage({ event: 'session', data: { triggeredBy: 'getSession' } })
|
||||
const getSession = async ({ req, ctx, triggerEvent = true } = {}) => {
|
||||
// If passed 'appContext' via getInitialProps() in _app.js then get the req
|
||||
// object from ctx and use that for the req value to allow getSession() to
|
||||
// work seemlessly in getInitialProps() on server side pages *and* in _app.js.
|
||||
if (!req && ctx && ctx.req) { req = ctx.req }
|
||||
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const fetchOptions = req ? { headers: { cookie: req.headers.cookie } } : {}
|
||||
const session = await _fetchData(`${baseUrl}/session`, fetchOptions)
|
||||
if (triggerEvent) {
|
||||
_sendMessage({ event: 'session', data: { trigger: 'getSession' } })
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
// Universal method (client + server)
|
||||
const getProviders = async ({ req } = {}) => {
|
||||
const baseUrl = _baseUrl({ req })
|
||||
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
|
||||
return _fetchData(`${baseUrl}/providers`, options)
|
||||
const getCsrfToken = async ({ req, ctx } = {}) => {
|
||||
// If passed 'appContext' via getInitialProps() in _app.js then get the req
|
||||
// object from ctx and use that for the req value to allow getCsrfToken() to
|
||||
// work seemlessly in getInitialProps() on server side pages *and* in _app.js.
|
||||
if (!req && ctx && ctx.req) { req = ctx.req }
|
||||
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const fetchOptions = req ? { headers: { cookie: req.headers.cookie } } : {}
|
||||
const data = await _fetchData(`${baseUrl}/csrf`, fetchOptions)
|
||||
return data && data.csrfToken ? data.csrfToken : null
|
||||
}
|
||||
|
||||
// Universal method (client + server)
|
||||
const getCsrfToken = async ({ req } = {}) => {
|
||||
const baseUrl = _baseUrl({ req })
|
||||
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
|
||||
const data = await _fetchData(`${baseUrl}/csrf`, options)
|
||||
return data.csrfToken
|
||||
// Universal method (client + server); does not require request headers
|
||||
const getProviders = async () => {
|
||||
const baseUrl = _apiBaseUrl()
|
||||
return _fetchData(`${baseUrl}/providers`)
|
||||
}
|
||||
|
||||
// Context to store session data globally
|
||||
const SessionContext = createContext()
|
||||
|
||||
// Client side method
|
||||
// Hook to access the session data stored in the context
|
||||
const useSession = (session) => {
|
||||
// Try to use context if we can
|
||||
const value = useContext(SessionContext)
|
||||
// If we have no Provider in the tree we call the actual hook for fetching the session
|
||||
|
||||
// If we have no Provider in the tree, call the actual hook
|
||||
if (value === undefined) {
|
||||
return useSessionData(session)
|
||||
return _useSessionHook(session)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// Internal hook for getting session from the api.
|
||||
const useSessionData = (session) => {
|
||||
const clientMaxAge = (process.env.NEXTAUTH_CLIENT_MAXAGE || NEXTAUTH_DEFAULT_CLIENT_MAXAGE) * 1000
|
||||
|
||||
const _useSessionHook = (session) => {
|
||||
const [data, setData] = useState(session)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const _getSession = async (sendEvent = true) => {
|
||||
try {
|
||||
setData(await getSession())
|
||||
setLoading(false)
|
||||
|
||||
// Send event to trigger other tabs to update (unless sendEvent is false)
|
||||
if (sendEvent) {
|
||||
_sendMessage({ event: 'session', data: { triggeredBy: 'useSessionData' } })
|
||||
}
|
||||
useEffect(() => {
|
||||
const _getSession = async ({ event = null } = {}) => {
|
||||
try {
|
||||
const triggredByEvent = (event !== null)
|
||||
const triggeredByStorageEvent = !!((event && event === 'storage'))
|
||||
|
||||
if (typeof window !== 'undefined' && NEXTAUTH_EVENT_LISTENER_ADDED === false) {
|
||||
NEXTAUTH_EVENT_LISTENER_ADDED = true
|
||||
window.addEventListener('storage', async (event) => {
|
||||
if (event.key === 'nextauth.message') {
|
||||
const message = JSON.parse(event.newValue)
|
||||
if (message.event && message.event === 'session' && message.data) {
|
||||
// Fetch new session data but tell it not to fire an event to
|
||||
// avoid an infinate loop.
|
||||
//
|
||||
// Note: We could pass session data through and do something like
|
||||
// `setData(message.data)` but that causes problems depending on
|
||||
// how the session object is being used and may expose session
|
||||
// data to 3rd party scripts, it's safer to update the session
|
||||
// this way.
|
||||
await _getSession(false)
|
||||
}
|
||||
const clientMaxAge = __NEXTAUTH.clientMaxAge
|
||||
const clientLastSync = parseInt(__NEXTAUTH._clientLastSync)
|
||||
const currentTime = Math.floor(new Date().getTime() / 1000)
|
||||
const clientSession = __NEXTAUTH._clientSession
|
||||
|
||||
// Updates triggered by a storage event *always* trigger an update and we
|
||||
// always update if we don't have any value for the current session state.
|
||||
if (triggeredByStorageEvent === false && clientSession !== undefined) {
|
||||
if (clientMaxAge === 0 && triggredByEvent !== true) {
|
||||
// If there is no time defined for when a session should be considered
|
||||
// stale, then it's okay to use the value we have until an event is
|
||||
// triggered which updates it.
|
||||
return
|
||||
} else if (clientMaxAge > 0 && clientSession === null) {
|
||||
// If the client doesn't have a session then we don't need to call
|
||||
// the server to check if it does (if they have signed in via another
|
||||
// tab or window that will come through as a triggeredByStorageEvent
|
||||
// event and will skip this logic)
|
||||
return
|
||||
} else if (clientMaxAge > 0 && currentTime < (clientLastSync + clientMaxAge)) {
|
||||
// If the session freshness is within clientMaxAge then don't request
|
||||
// it again on this call (avoids too many invokations).
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If CLIENT_MAXAGE is greater than zero, trigger auto re-fetching session
|
||||
if (clientMaxAge > 0) {
|
||||
setTimeout(async (session) => {
|
||||
await _getSession()
|
||||
}, clientMaxAge)
|
||||
if (clientSession === undefined) { __NEXTAUTH._clientSession = null }
|
||||
|
||||
// Update clientLastSync before making response to avoid repeated
|
||||
// invokations that would otherwise be triggered while we are still
|
||||
// waiting for a response.
|
||||
__NEXTAUTH._clientLastSync = Math.floor(new Date().getTime() / 1000)
|
||||
|
||||
// If this call was invoked via a storage event (i.e. another window) then
|
||||
// tell getSession not to trigger an event when it calls to avoid an
|
||||
// infinate loop.
|
||||
const triggerEvent = (triggeredByStorageEvent === false)
|
||||
const newClientSessionData = await getSession({ triggerEvent })
|
||||
|
||||
// Save session state internally, just so we can track that we've checked
|
||||
// if a session exists at least once.
|
||||
__NEXTAUTH._clientSession = newClientSessionData
|
||||
|
||||
setData(newClientSessionData)
|
||||
setLoading(false)
|
||||
} catch (error) {
|
||||
logger.error('CLIENT_USE_SESSION_ERROR', error)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CLIENT_USE_SESSION_ERROR', error)
|
||||
}
|
||||
}
|
||||
useEffect(() => { _getSession() }, [])
|
||||
|
||||
__NEXTAUTH._getSession = _getSession
|
||||
|
||||
_getSession()
|
||||
})
|
||||
return [data, loading]
|
||||
}
|
||||
|
||||
// Client side method
|
||||
const signin = async (provider, args) => {
|
||||
const signIn = async (provider, args = {}) => {
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
||||
|
||||
if (!provider) {
|
||||
// Redirect to sign in page if no provider specified
|
||||
const baseUrl = _baseUrl()
|
||||
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
return
|
||||
}
|
||||
|
||||
const providers = await getProviders()
|
||||
if (!providers[provider]) {
|
||||
|
||||
// Redirect to sign in page if no valid provider specified
|
||||
if (!provider || !providers[provider]) {
|
||||
// If Provider not recognized, redirect to sign in page
|
||||
const baseUrl = _baseUrl()
|
||||
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
} else if (providers[provider].type === 'oauth') {
|
||||
// If is an OAuth provider, redirect to providers[provider].signinUrl
|
||||
window.location = `${providers[provider].signinUrl}?callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
} else {
|
||||
// If is any other provider type, POST to providers[provider].signinUrl (with CSRF Token)
|
||||
const options = {
|
||||
const signInUrl = (providers[provider].type === 'credentials')
|
||||
? `${baseUrl}/callback/${provider}`
|
||||
: `${baseUrl}/signin/${provider}`
|
||||
|
||||
// If is any other provider type, POST to provider URL with CSRF Token,
|
||||
// callback URL and any other parameters supplied.
|
||||
const fetchOptions = {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: _encodedForm({
|
||||
...args,
|
||||
csrfToken: await getCsrfToken(),
|
||||
callbackUrl: callbackUrl,
|
||||
...args
|
||||
json: true
|
||||
})
|
||||
}
|
||||
const res = await fetch(providers[provider].signinUrl, options)
|
||||
window.location = res.url ? res.url : callbackUrl
|
||||
const res = await fetch(signInUrl, fetchOptions)
|
||||
const data = await res.json()
|
||||
window.location = data.url ? data.url : callbackUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Client side method
|
||||
const signout = async (args) => {
|
||||
const signOut = async (args = {}) => {
|
||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
||||
|
||||
const baseUrl = _baseUrl()
|
||||
const options = {
|
||||
const baseUrl = _apiBaseUrl()
|
||||
const fetchOptions = {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: _encodedForm({
|
||||
csrfToken: await getCsrfToken(),
|
||||
callbackUrl: callbackUrl
|
||||
callbackUrl: callbackUrl,
|
||||
json: true
|
||||
})
|
||||
}
|
||||
const res = await fetch(`${baseUrl}/signout`, options)
|
||||
|
||||
_sendMessage({ event: 'session', data: { triggeredBy: 'signout' } })
|
||||
|
||||
window.location = res.url ? res.url : callbackUrl
|
||||
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
|
||||
const data = await res.json()
|
||||
_sendMessage({ event: 'session', data: { trigger: 'signout' } })
|
||||
window.location = data.url ? data.url : callbackUrl
|
||||
}
|
||||
|
||||
// Provider to wrap the app in to make session data available globally
|
||||
const Provider = ({ children, session }) => {
|
||||
const value = useSession(session)
|
||||
return createElement(SessionContext.Provider, { value }, children)
|
||||
const Provider = ({ children, session, options }) => {
|
||||
setOptions(options)
|
||||
return createElement(SessionContext.Provider, { value: useSession(session) }, children)
|
||||
}
|
||||
|
||||
const _fetchData = async (url, options) => {
|
||||
const _fetchData = async (url, options = {}) => {
|
||||
try {
|
||||
const res = await fetch(url, options)
|
||||
const data = await res.json()
|
||||
@@ -181,42 +296,16 @@ const _fetchData = async (url, options) => {
|
||||
}
|
||||
}
|
||||
|
||||
const _baseUrl = ({ req } = {}) => {
|
||||
if (req) {
|
||||
// Server Side
|
||||
// If we have a 'req' object are running sever side, so we should grab the
|
||||
// base URL from cookie that is set by the API route - which is how config
|
||||
// is shared automatically between the API route and the client.
|
||||
const cookies = req ? _parseCookies(req.headers.cookie) : null
|
||||
const baseUrlCookieName = process.env.NEXTAUTH_BASE_URL_COOKIE_NAME || NEXTAUTH_DEFAULT_BASE_URL_COOKIE_NAME
|
||||
const cookieValue = cookies[`__Secure-${baseUrlCookieName}`] || cookies[baseUrlCookieName]
|
||||
const [baseUrl] = cookieValue ? cookieValue.split('|') : [null]
|
||||
return baseUrl
|
||||
} else {
|
||||
// Client Side
|
||||
// Note: 'site' is empty by default; URL is normally relative.
|
||||
const site = process.env.NEXTAUTH_SITE || NEXTAUTH_DEFAULT_SITE
|
||||
const basePath = process.env.NEXTAUTH_BASE_PATH || NEXTAUTH_DEFAULT_BASE_PATH
|
||||
return `${site}${basePath}`
|
||||
}
|
||||
}
|
||||
const _apiBaseUrl = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
// NEXTAUTH_URL should always be set explicitly to support server side calls - log warning if not set
|
||||
if (!process.env.NEXTAUTH_URL) { logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set') }
|
||||
|
||||
// Adapted from https://github.com/felixfong227/simple-cookie-parser/blob/master/index.js
|
||||
const _parseCookies = (string) => {
|
||||
if (!string) { return {} }
|
||||
try {
|
||||
const object = {}
|
||||
const a = string.split(';')
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const b = a[i].split('=')
|
||||
if (b[0].length > 1 && b[1]) {
|
||||
object[b[0].trim()] = decodeURIComponent(b[1])
|
||||
}
|
||||
}
|
||||
return object
|
||||
} catch (error) {
|
||||
logger.error('CLIENT_COOKIE_PARSE_ERROR', error)
|
||||
return {}
|
||||
// Return absolute path when called server side
|
||||
return `${__NEXTAUTH.baseUrl}${__NEXTAUTH.basePath}`
|
||||
} else {
|
||||
// Return relative path when called client side
|
||||
return __NEXTAUTH.basePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,23 +317,29 @@ const _encodedForm = (formData) => {
|
||||
|
||||
const _sendMessage = (message) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('nextauth.message', JSON.stringify(message)) // eslint-disable-line
|
||||
const timestamp = Math.floor(new Date().getTime() / 1000)
|
||||
localStorage.setItem('nextauth.message', JSON.stringify({ ...message, clientId: __NEXTAUTH._clientId, timestamp })) // eslint-disable-line
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
// Some methods are exported with more than one name. This provides
|
||||
// flexibility over how they can be invoked and compatibility with earlier
|
||||
// releases (going back to v1 and earlier v2 beta releases).
|
||||
// e.g. NextAuth.session() or const { getSession } from 'next-auth/client'
|
||||
getSession,
|
||||
getCsrfToken,
|
||||
getProviders,
|
||||
useSession,
|
||||
signIn,
|
||||
signOut,
|
||||
Provider,
|
||||
/* Deprecated / unsupported features below this line */
|
||||
// Use setOptions() set options globally in the app.
|
||||
setOptions,
|
||||
// Some methods are exported with more than one name. This provides some
|
||||
// flexibility over how they can be invoked and backwards compatibility
|
||||
// with earlier releases.
|
||||
options: setOptions,
|
||||
session: getSession,
|
||||
providers: getProviders,
|
||||
csrfToken: getCsrfToken,
|
||||
getSession,
|
||||
getProviders,
|
||||
getCsrfToken,
|
||||
useSession,
|
||||
Provider,
|
||||
signin,
|
||||
signout
|
||||
signin: signIn,
|
||||
signout: signOut
|
||||
}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
:root {
|
||||
--color-background: #fff;
|
||||
--color-primary: #444;
|
||||
--color-control-border: #bbb;
|
||||
--color-button-hover-background: #f9f9f9;
|
||||
--color-button-active-background: #f5f5f5;
|
||||
--color-button-active-background: #f9f9f9;
|
||||
--color-button-active-border: #aaa;
|
||||
--border-width: 1px;
|
||||
--border-radius: .3rem;
|
||||
--color-error: #c94b4b;
|
||||
--color-info: #157efb;
|
||||
--color-seperator: #ccc;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
@@ -39,7 +43,7 @@ input[type] {
|
||||
width: 100%;
|
||||
padding: .5rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
background: #fff;
|
||||
background: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: inset 0 .1rem .2rem rgba(0,0,0,.2);
|
||||
@@ -61,7 +65,7 @@ a.button {
|
||||
line-height: 1rem;
|
||||
&:link,
|
||||
&:visited {
|
||||
background-color: #fff;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
@@ -72,21 +76,20 @@ a.button {
|
||||
padding: .75rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
color: var(--color-primary);
|
||||
background-color: #fff;
|
||||
background-color: var(--color-background);
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all .1s ease-in-out;
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem #fff, 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;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-button-hover-background);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem #fff, inset 0 -.1rem .1rem rgba(0,0,0,.1);
|
||||
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0,0,0,.1);
|
||||
background-color: var(--color-button-active-background);
|
||||
border-color: var(--color-button-active-border);
|
||||
cursor: pointer;
|
||||
@@ -143,19 +146,34 @@ a.site {
|
||||
hr {
|
||||
display: block;
|
||||
border: 0;
|
||||
border-top: 1px solid #ccc;
|
||||
border-top: 1px solid var(--color-seperator);
|
||||
margin: 1.5em auto 0 auto;
|
||||
overflow: visible;
|
||||
|
||||
&::before {
|
||||
content: "or";
|
||||
background: #fff;
|
||||
background: var(--color-background);
|
||||
color: #888;
|
||||
padding: 0 .4rem;
|
||||
position: relative;
|
||||
top: -.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f5f5f5;
|
||||
font-weight: 500;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-info);
|
||||
color: #fff;
|
||||
p {
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
> div,
|
||||
form {
|
||||
display: block;
|
||||
|
||||
@@ -7,4 +7,4 @@ import path from 'path'
|
||||
const pathToCss = path.join(__dirname, '/index.css')
|
||||
const css = fs.readFileSync(pathToCss, 'utf8')
|
||||
|
||||
export default () => css
|
||||
export default () => css
|
||||
|
||||
@@ -25,7 +25,7 @@ class CreateUserError extends UnknownError {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
|
||||
161
src/lib/jwt.js
161
src/lib/jwt.js
@@ -1,44 +1,157 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import jose from 'jose'
|
||||
import hkdf from 'futoin-hkdf'
|
||||
import logger from './logger'
|
||||
|
||||
const encode = async ({ secret, key = secret, token = {}, maxAge }) => {
|
||||
// If maxAge is set remove any existing created/expiry dates and replace them
|
||||
if (maxAge) {
|
||||
if (token.iat) { delete token.iat }
|
||||
if (token.exp) { delete token.exp }
|
||||
// Set default algorithm to use for auto-generated signing key
|
||||
const DEFAULT_SIGNATURE_ALGORITHM = 'HS512'
|
||||
|
||||
// Set default algorithm for auto-generated symmetric encryption key
|
||||
const DEFAULT_ENCRYPTION_ALGORITHM = 'A256GCM'
|
||||
|
||||
// Use encryption or not by default
|
||||
const DEFAULT_ENCRYPTION_ENABLED = false
|
||||
|
||||
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
const encode = async ({
|
||||
token = {},
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
secret,
|
||||
signingKey,
|
||||
signingOptions = {
|
||||
expiresIn: `${maxAge}s`
|
||||
},
|
||||
encryptionKey,
|
||||
encryptionOptions = {
|
||||
alg: 'dir',
|
||||
enc: DEFAULT_ENCRYPTION_ALGORITHM,
|
||||
zip: 'DEF'
|
||||
},
|
||||
encryption = DEFAULT_ENCRYPTION_ENABLED
|
||||
} = {}) => {
|
||||
// Signing Key
|
||||
const _signingKey = (signingKey)
|
||||
? jose.JWK.asKey(JSON.parse(signingKey))
|
||||
: getDerivedSigningKey(secret)
|
||||
|
||||
// Sign token
|
||||
const signedToken = jose.JWT.sign(token, _signingKey, signingOptions)
|
||||
|
||||
if (encryption) {
|
||||
// Encryption Key
|
||||
const _encryptionKey = (encryptionKey)
|
||||
? jose.JWK.asKey(JSON.parse(encryptionKey))
|
||||
: getDerivedEncryptionKey(secret)
|
||||
|
||||
// Encrypt token
|
||||
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
|
||||
} else {
|
||||
return signedToken
|
||||
}
|
||||
const signedToken = jwt.sign(token, secret, { expiresIn: maxAge })
|
||||
const encryptedToken = CryptoJS.AES.encrypt(signedToken, key).toString()
|
||||
return encryptedToken
|
||||
}
|
||||
|
||||
const decode = async ({ secret, key = secret, token, maxAge }) => {
|
||||
const decode = async ({
|
||||
secret,
|
||||
token,
|
||||
maxAge = DEFAULT_MAX_AGE,
|
||||
signingKey,
|
||||
verificationKey = signingKey, // Optional (defaults to encryptionKey)
|
||||
verificationOptions = {
|
||||
maxTokenAge: `${maxAge}s`,
|
||||
algorithms: [DEFAULT_SIGNATURE_ALGORITHM]
|
||||
},
|
||||
encryptionKey,
|
||||
decryptionKey = encryptionKey, // Optional (defaults to encryptionKey)
|
||||
decryptionOptions = {
|
||||
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM]
|
||||
},
|
||||
encryption = DEFAULT_ENCRYPTION_ENABLED
|
||||
} = {}) => {
|
||||
if (!token) return null
|
||||
const decryptedBytes = CryptoJS.AES.decrypt(token, key)
|
||||
const decryptedToken = decryptedBytes.toString(CryptoJS.enc.Utf8)
|
||||
const verifiedToken = jwt.verify(decryptedToken, secret, { maxAge })
|
||||
return verifiedToken
|
||||
|
||||
let tokenToVerify = token
|
||||
|
||||
if (encryption) {
|
||||
// Encryption Key
|
||||
const _encryptionKey = (decryptionKey)
|
||||
? jose.JWK.asKey(JSON.parse(decryptionKey))
|
||||
: getDerivedEncryptionKey(secret)
|
||||
|
||||
// Decrypt token
|
||||
const decryptedToken = jose.JWE.decrypt(token, _encryptionKey, decryptionOptions)
|
||||
tokenToVerify = decryptedToken.toString('utf8')
|
||||
}
|
||||
|
||||
// Signing Key
|
||||
const _signingKey = (verificationKey)
|
||||
? jose.JWK.asKey(JSON.parse(verificationKey))
|
||||
: getDerivedSigningKey(secret)
|
||||
|
||||
// Verify token
|
||||
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
|
||||
}
|
||||
|
||||
// This is a simple helper method to make it easier to use JWT from an API route
|
||||
const getJwt = async ({ req, secret, cookieName }) => {
|
||||
if (!req || !secret) throw new Error('Must pass { req, secret } to getJWT()')
|
||||
const getToken = async (args) => {
|
||||
const {
|
||||
req,
|
||||
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
|
||||
// or not set (e.g. development or test instance) case use unprefixed name
|
||||
secureCookie = !(!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://')),
|
||||
cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token',
|
||||
raw = false
|
||||
} = args
|
||||
if (!req) throw new Error('Must pass `req` to JWT getToken()')
|
||||
|
||||
const secureCookieName = '__Secure-next-auth.session-token'
|
||||
const insecureCookieName = 'next-auth.session-token'
|
||||
const cookieValue = cookieName ? req.cookies[cookieName] : req.cookies[secureCookieName] || req.cookies[insecureCookieName]
|
||||
// Try to get token from cookie
|
||||
let token = req.cookies[cookieName]
|
||||
|
||||
if (!cookieValue) { return null }
|
||||
// If cookie not found in cookie look for bearer token in authorization header.
|
||||
// This allows clients that pass through tokens in headers rather than as
|
||||
// cookies to use this helper function.
|
||||
if (!token && req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
|
||||
const urlEncodedToken = req.headers.authorization.split(' ')[1]
|
||||
token = decodeURIComponent(urlEncodedToken)
|
||||
}
|
||||
|
||||
if (raw) {
|
||||
return token
|
||||
}
|
||||
|
||||
try {
|
||||
return await decode({ secret, token: cookieValue })
|
||||
return await decode({ token, ...args })
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Generate warning (but only once at startup) when auto-generated keys are used
|
||||
let DERIVED_SIGNING_KEY_WARNING = false
|
||||
let DERIVED_ENCRYPTION_KEY_WARNING = false
|
||||
|
||||
const getDerivedSigningKey = (secret) => {
|
||||
if (!DERIVED_SIGNING_KEY_WARNING) {
|
||||
logger.warn('JWT_AUTO_GENERATED_SIGNING_KEY')
|
||||
DERIVED_SIGNING_KEY_WARNING = true
|
||||
}
|
||||
|
||||
const buffer = hkdf(secret, 64, { info: 'NextAuth.js Generated Signing Key', hash: 'SHA-256' })
|
||||
const key = jose.JWK.asKey(buffer, { alg: DEFAULT_SIGNATURE_ALGORITHM, use: 'sig', kid: 'nextauth-auto-generated-signing-key' })
|
||||
return key
|
||||
}
|
||||
|
||||
const getDerivedEncryptionKey = (secret) => {
|
||||
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
|
||||
logger.warn('JWT_AUTO_GENERATED_ENCRYPTION_KEY')
|
||||
DERIVED_ENCRYPTION_KEY_WARNING = true
|
||||
}
|
||||
|
||||
const buffer = hkdf(secret, 32, { info: 'NextAuth.js Generated Encryption Key', hash: 'SHA-256' })
|
||||
const key = jose.JWK.asKey(buffer, { alg: DEFAULT_ENCRYPTION_ALGORITHM, use: 'enc', kid: 'nextauth-auto-generated-encryption-key' })
|
||||
return key
|
||||
}
|
||||
|
||||
export default {
|
||||
encode,
|
||||
decode,
|
||||
getJwt
|
||||
getToken
|
||||
}
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
const logger = {
|
||||
error: (errorCode, ...text) => {
|
||||
if (console) {
|
||||
!text
|
||||
? console.error(errorCode)
|
||||
: console.error(
|
||||
`[next-auth][error][${errorCode}]`,
|
||||
text,
|
||||
`\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}`
|
||||
)
|
||||
}
|
||||
if (!console) { return }
|
||||
if (text && text.length <= 1) { text = text[0] || '' }
|
||||
console.error(
|
||||
`[next-auth][error][${errorCode.toLowerCase()}]`,
|
||||
text,
|
||||
`\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}`
|
||||
)
|
||||
},
|
||||
warn: (warnCode, ...text) => {
|
||||
if (!console) { return }
|
||||
if (text && text.length <= 1) { text = text[0] || '' }
|
||||
console.warn(
|
||||
`[next-auth][warn][${warnCode.toLowerCase()}]`,
|
||||
text,
|
||||
`\nhttps://next-auth.js.org/warnings#${warnCode.toLowerCase()}`
|
||||
)
|
||||
},
|
||||
debug: (debugCode, ...text) => {
|
||||
if (process && process.env && process.env._NEXT_AUTH_DEBUG) {
|
||||
if (!console) { return }
|
||||
if (text && text.length <= 1) { text = text[0] || '' }
|
||||
if (process && process.env && process.env._NEXTAUTH_DEBUG) {
|
||||
console.log(
|
||||
`[next-auth][debug][${debugCode}]`,
|
||||
`[next-auth][debug][${debugCode.toLowerCase()}]`,
|
||||
text
|
||||
)
|
||||
}
|
||||
|
||||
27
src/lib/parse-url.js
Normal file
27
src/lib/parse-url.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Simple universal (client/server) function to split host and path
|
||||
* We use this rather than a library because we need to use the same logic both
|
||||
* client and server side and we only need to parse out the host and path, while
|
||||
* supporting a default value, so a simple split is sufficent.
|
||||
* @param {string} url
|
||||
*/
|
||||
export default function parseUrl (url) {
|
||||
// Default values
|
||||
const defaultHost = 'http://localhost:3000'
|
||||
const defaultPath = '/api/auth'
|
||||
|
||||
if (!url) { url = `${defaultHost}${defaultPath}` }
|
||||
|
||||
// Default to HTTPS if no protocol explictly specified
|
||||
const protocol = url.match(/^http?:\/\//) ? 'http' : 'https'
|
||||
|
||||
// Normalize URLs by stripping protocol and no trailing slash
|
||||
url = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
||||
|
||||
// Simple split based on first /
|
||||
const [_host, ..._path] = url.split('/')
|
||||
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
|
||||
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
|
||||
|
||||
return { baseUrl, basePath }
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'apple',
|
||||
@@ -13,36 +11,19 @@ export default (options) => {
|
||||
profileUrl: null,
|
||||
idToken: true,
|
||||
profile: (profile) => {
|
||||
// The name of the user will only return on first login
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name == null ? profile.sub : profile.name,
|
||||
name: profile.user != null ? profile.user.name.firstName + ' ' + profile.user.name.lastName : null,
|
||||
email: profile.email
|
||||
}
|
||||
},
|
||||
clientId: null,
|
||||
clientSecret: {
|
||||
appleId: null,
|
||||
teamId: null,
|
||||
privateKey: null,
|
||||
keyId: null
|
||||
},
|
||||
clientSecretCallback: async ({ appleId, keyId, teamId, privateKey }) => {
|
||||
const response = jwt.sign(
|
||||
{
|
||||
iss: teamId,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
|
||||
aud: 'https://appleid.apple.com',
|
||||
sub: appleId
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
algorithm: 'ES256',
|
||||
keyid: keyId
|
||||
}
|
||||
)
|
||||
return Promise.resolve(response)
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
24
src/providers/atlassian.js
Normal file
24
src/providers/atlassian.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'atlassian',
|
||||
name: 'Atlassian',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
params: {
|
||||
grant_type: 'authorization_code'
|
||||
},
|
||||
accessTokenUrl: 'https://auth.atlassian.com/oauth/token',
|
||||
authorizationUrl:
|
||||
'https://auth.atlassian.com/authorize?audience=api.atlassian.com&response_type=code&prompt=consent',
|
||||
profileUrl: 'https://api.atlassian.com/me',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.account_id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,11 @@ export default (options) => {
|
||||
name: 'Auth0',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
params: { grant_type: 'authorization_code', response_type: 'code' },
|
||||
params: { grant_type: 'authorization_code' },
|
||||
scope: 'openid email profile',
|
||||
accessTokenUrl: `https://${options.subdomain}.auth0/oauth/token`,
|
||||
authorizationUrl: `https://${options.subdomain}.auth0.com/authorize?`,
|
||||
profileUrl: `http://${options.subdomain}.auth0.com/userinfo`,
|
||||
accessTokenUrl: `https://${options.domain}/oauth/token`,
|
||||
authorizationUrl: `https://${options.domain}/authorize?response_type=code`,
|
||||
profileUrl: `https://${options.domain}/userinfo`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
20
src/providers/basecamp.js
Normal file
20
src/providers/basecamp.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'basecamp',
|
||||
name: 'Basecamp',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
accessTokenUrl: 'https://launchpad.37signals.com/authorization/token?type=web_server',
|
||||
authorizationUrl: 'https://launchpad.37signals.com/authorization/new?type=web_server',
|
||||
profileUrl: 'https://launchpad.37signals.com/authorization.json',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.identity.id,
|
||||
name: `${profile.identity.first_name} ${profile.identity.last_name}`,
|
||||
email: profile.identity.email_address,
|
||||
image: null
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
29
src/providers/battlenet.js
Normal file
29
src/providers/battlenet.js
Normal file
@@ -0,0 +1,29 @@
|
||||
export default (options) => {
|
||||
const { region } = options
|
||||
return {
|
||||
id: 'battlenet',
|
||||
name: 'Battle.net',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'openid',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl:
|
||||
region === 'CN'
|
||||
? 'https://www.battlenet.com.cn/oauth/token'
|
||||
: `https://${region}.battle.net/oauth/token`,
|
||||
authorizationUrl:
|
||||
region === 'CN'
|
||||
? 'https://www.battlenet.com.cn/oauth/authorize?response_type=code'
|
||||
: `https://${region}.battle.net/oauth/authorize?response_type=code`,
|
||||
profileUrl: 'https://us.battle.net/oauth/userinfo',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.battletag,
|
||||
email: null,
|
||||
image: null
|
||||
}
|
||||
},
|
||||
...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
|
||||
}
|
||||
}
|
||||
23
src/providers/cognito.js
Normal file
23
src/providers/cognito.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
const { domain } = options
|
||||
return {
|
||||
id: 'cognito',
|
||||
name: 'Cognito',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'openid profile email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: `https://${domain}/oauth2/token`,
|
||||
authorizationUrl: `https://${domain}/oauth2/authorize?response_type=code`,
|
||||
profileUrl: `https://${domain}/oauth2/userInfo`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.username,
|
||||
email: profile.email,
|
||||
image: null
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,21 @@ export default (options) => {
|
||||
version: '2.0',
|
||||
scope: 'identify email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://discordapp.com/api/oauth2/token',
|
||||
authorizationUrl:
|
||||
'https://discordapp.com/api/oauth2/authorize?response_type=code&prompt=consent',
|
||||
profileUrl: 'https://discordapp.com/api/users/@me',
|
||||
accessTokenUrl: 'https://discord.com/api/oauth2/token',
|
||||
authorizationUrl: 'https://discord.com/api/oauth2/authorize?response_type=code&prompt=none',
|
||||
profileUrl: 'https://discord.com/api/users/@me',
|
||||
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 {
|
||||
id: profile.id,
|
||||
name: profile.username,
|
||||
image: `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`,
|
||||
image: profile.image_url,
|
||||
email: profile.email
|
||||
}
|
||||
},
|
||||
|
||||
@@ -22,22 +22,23 @@ export default (options) => {
|
||||
}
|
||||
}
|
||||
|
||||
const sendVerificationRequest = ({ identifier: emailAddress, url, token, site, provider }) => {
|
||||
const sendVerificationRequest = ({ identifier: email, url, baseUrl, provider }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { server, from } = provider
|
||||
const siteName = site.replace(/^https?:\/\//, '')
|
||||
// Strip protocol from URL and use domain as site name
|
||||
const site = baseUrl.replace(/^https?:\/\//, '')
|
||||
|
||||
nodemailer
|
||||
.createTransport(server)
|
||||
.sendMail({
|
||||
to: emailAddress,
|
||||
to: email,
|
||||
from,
|
||||
subject: `Sign in to ${siteName}`,
|
||||
text: text({ url, siteName }),
|
||||
html: html({ url, siteName })
|
||||
subject: `Sign in to ${site}`,
|
||||
text: text({ url, site, email }),
|
||||
html: html({ url, site, email })
|
||||
}, (error) => {
|
||||
if (error) {
|
||||
logger.error('SEND_VERIFICATION_EMAIL_ERROR', emailAddress, error)
|
||||
logger.error('SEND_VERIFICATION_EMAIL_ERROR', email, error)
|
||||
return reject(new Error('SEND_VERIFICATION_EMAIL_ERROR', error))
|
||||
}
|
||||
return resolve()
|
||||
@@ -46,28 +47,55 @@ const sendVerificationRequest = ({ identifier: emailAddress, url, token, site, p
|
||||
}
|
||||
|
||||
// Email HTML body
|
||||
const html = ({ url, siteName }) => {
|
||||
const buttonBackgroundColor = '#444444'
|
||||
const html = ({ url, site, email }) => {
|
||||
// Insert invisible space into domains and email address to prevent both the
|
||||
// email address and the domain from being turned into a hyperlink by email
|
||||
// clients like Outlook and Apple mail, as this is confusing because it seems
|
||||
// like they are supposed to click on their email address to sign in.
|
||||
const escapedEmail = `${email.replace(/\./g, '​.')}`
|
||||
const escapedSite = `${site.replace(/\./g, '​.')}`
|
||||
|
||||
// Some simple styling options
|
||||
const backgroundColor = '#f9f9f9'
|
||||
const textColor = '#444444'
|
||||
const mainBackgroundColor = '#ffffff'
|
||||
const buttonBackgroundColor = '#346df1'
|
||||
const buttonBorderColor = '#346df1'
|
||||
const buttonTextColor = '#ffffff'
|
||||
|
||||
return `
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 8px 0; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: #888888;">
|
||||
${siteName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 16px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 3px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 3px; padding: 12px 18px; border: 1px solid ${buttonBackgroundColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<body style="background: ${backgroundColor};">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
<strong>${escapedSite}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
Sign in as <strong>${escapedEmail}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 20px 0;">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
|
||||
If you did not request this email you can safely ignore it.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
`
|
||||
}
|
||||
|
||||
// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
|
||||
const text = ({ url, siteName }) => `Sign in to ${siteName}\n${url}\n\n`
|
||||
const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n`
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
27
src/providers/fusionauth.js
Normal file
27
src/providers/fusionauth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
export default (options) => {
|
||||
let authorizationUrl = `https://${options.domain}/oauth2/authorize?response_type=code`
|
||||
if (options.tenantId) {
|
||||
authorizationUrl += `&tenantId=${options.tenantId}`
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'fusionauth',
|
||||
name: 'FusionAuth',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'openid',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: `https://${options.domain}/oauth2/token`,
|
||||
authorizationUrl,
|
||||
profileUrl: `https://${options.domain}/oauth2/userinfo`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default (options) => {
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
name: profile.name || profile.login,
|
||||
email: profile.email,
|
||||
image: profile.avatar_url
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ export default (options) => {
|
||||
profile: (profile) => {
|
||||
return { ...profile, id: profile.sub }
|
||||
},
|
||||
setGetAccessTokenAuthHeader: false,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,62 @@
|
||||
import Auth0 from './auth0'
|
||||
import Apple from './apple'
|
||||
import Atlassian from './atlassian'
|
||||
import Auth0 from './auth0'
|
||||
import AzureADB2C from './azure-ad-b2c'
|
||||
import Basecamp from './basecamp'
|
||||
import BattleNet from './battlenet'
|
||||
import Box from './box'
|
||||
import Bungie from './bungie'
|
||||
import Credentials from './credentials'
|
||||
import Cognito from './cognito'
|
||||
import Discord from './discord'
|
||||
import Email from './email'
|
||||
import Facebook from './facebook' // @TODO
|
||||
import Facebook from './facebook'
|
||||
import Foursquare from './foursquare'
|
||||
import FusionAuth from './fusionauth'
|
||||
import GitHub from './github'
|
||||
import GitLab from './gitlab'
|
||||
import Google from './google'
|
||||
import IdentityServer4 from './identity-server4'
|
||||
import LinkedIn from './linkedin'
|
||||
import MailRu from './mailru'
|
||||
import Mixer from './mixer'
|
||||
import Netlify from './netlify'
|
||||
import Okta from './okta'
|
||||
import Slack from './slack'
|
||||
import Spotify from './spotify'
|
||||
import Strava from './strava'
|
||||
import Twitch from './twitch'
|
||||
import Twitter from './twitter'
|
||||
import Yandex from './yandex'
|
||||
|
||||
export default {
|
||||
Atlassian,
|
||||
Auth0,
|
||||
Apple,
|
||||
AzureADB2C,
|
||||
Basecamp,
|
||||
BattleNet,
|
||||
Box,
|
||||
Bungie,
|
||||
Credentials,
|
||||
Cognito,
|
||||
Discord,
|
||||
Email,
|
||||
Facebook,
|
||||
Foursquare,
|
||||
FusionAuth,
|
||||
GitHub,
|
||||
GitLab,
|
||||
Google,
|
||||
IdentityServer4,
|
||||
LinkedIn,
|
||||
MailRu,
|
||||
Mixer,
|
||||
Netlify,
|
||||
Okta,
|
||||
Slack,
|
||||
Spotify,
|
||||
Strava,
|
||||
Twitter,
|
||||
Twitch,
|
||||
Yandex
|
||||
|
||||
26
src/providers/linkedin.js
Normal file
26
src/providers/linkedin.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'r_liteprofile',
|
||||
params: {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: options.clientId,
|
||||
client_secret: options.clientSecret
|
||||
},
|
||||
accessTokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
|
||||
authorizationUrl: 'https://www.linkedin.com/oauth/v2/authorization?response_type=code',
|
||||
profileUrl: 'https://api.linkedin.com/v2/me?projection=(id,localizedFirstName,localizedLastName)',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.localizedFirstName + ' ' + profile.localizedLastName,
|
||||
email: null,
|
||||
image: null
|
||||
}
|
||||
},
|
||||
...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
|
||||
},
|
||||
// These will be different depending on the Org.
|
||||
accessTokenUrl: `https://${options.domain}/oauth2/v1/token`,
|
||||
authorizationUrl: `https://${options.domain}/oauth2/v1/authorize/?response_type=code`,
|
||||
profileUrl: `https://${options.domain}/oauth2/v1/userinfo/`,
|
||||
accessTokenUrl: `https://${options.domain}/v1/token`,
|
||||
authorizationUrl: `https://${options.domain}/v1/authorize/?response_type=code`,
|
||||
profileUrl: `https://${options.domain}/v1/userinfo/`,
|
||||
profile: (profile) => {
|
||||
return { ...profile, id: profile.sub }
|
||||
},
|
||||
setGetAccessTokenAuthHeader: false,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ export default (options) => {
|
||||
name: 'Slack',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'identity.basic identity.email identity.avatar',
|
||||
scope: [],
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://slack.com/api/oauth.access',
|
||||
authorizationUrl: 'https://slack.com/oauth/authorize?response_type=code',
|
||||
accessTokenUrl: 'https://slack.com/api/oauth.v2.access',
|
||||
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
|
||||
authorizationParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
|
||||
profileUrl: 'https://slack.com/api/users.identity',
|
||||
profile: (profile) => {
|
||||
const { user } = profile
|
||||
|
||||
23
src/providers/spotify.js
Normal file
23
src/providers/spotify.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'spotify',
|
||||
name: 'Spotify',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'user-read-email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://accounts.spotify.com/api/token',
|
||||
authorizationUrl:
|
||||
'https://accounts.spotify.com/authorize?response_type=code',
|
||||
profileUrl: 'https://api.spotify.com/v1/me',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.display_name,
|
||||
email: profile.email,
|
||||
image: profile.images?.[0]?.url
|
||||
}
|
||||
},
|
||||
...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
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@ export default (options) => {
|
||||
accessTokenUrl: 'https://api.twitter.com/oauth/access_token',
|
||||
requestTokenUrl: 'https://api.twitter.com/oauth/request_token',
|
||||
authorizationUrl: 'https://api.twitter.com/oauth/authenticate',
|
||||
profileUrl: 'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true',
|
||||
profileUrl:
|
||||
'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
id: profile.id_str,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.profile_image_url_https.replace(/_normal\.jpg$/, '.jpg')
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import jwt from '../lib/jwt'
|
||||
import cookie from './lib/cookie'
|
||||
import parseUrl from '../lib/parse-url'
|
||||
import * as 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 * as events from './lib/events'
|
||||
import * as defaultCallbacks from './lib/defaultCallbacks'
|
||||
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 renderPage from './pages'
|
||||
import adapters from '../adapters'
|
||||
import logger from '../lib/logger'
|
||||
import redirect from './lib/redirect'
|
||||
|
||||
const DEFAULT_SITE = 'http://localhost:3000'
|
||||
const DEFAULT_BASE_PATH = '/api/auth'
|
||||
// To work properly in production with OAuth providers the NEXTAUTH_URL
|
||||
// environment variable must be set.
|
||||
if (!process.env.NEXTAUTH_URL) {
|
||||
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
|
||||
}
|
||||
|
||||
export default async (req, res, userSuppliedOptions) => {
|
||||
async function NextAuthHandler (req, res, userOptions) {
|
||||
// To the best of my knowledge, we need to return a promise here
|
||||
// to avoid early termination of calls to the serverless function
|
||||
// (and then return that promise when we are done) - eslint
|
||||
@@ -25,40 +31,54 @@ export default async (req, res, userSuppliedOptions) => {
|
||||
// This is passed to all methods that handle responses, and must be called
|
||||
// when they are complete so that the serverless function knows when it is
|
||||
// safe to return and that no more data will be sent.
|
||||
const done = resolve
|
||||
|
||||
const originalResEnd = res.end.bind(res)
|
||||
res.end = (...args) => {
|
||||
resolve()
|
||||
return originalResEnd(...args)
|
||||
}
|
||||
res.redirect = redirect(req, res)
|
||||
|
||||
if (!req.query.nextauth) {
|
||||
const error = 'Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly.'
|
||||
|
||||
logger.error('MISSING_NEXTAUTH_API_ROUTE_ERROR', error)
|
||||
res.status(500)
|
||||
return res.end(`Error: ${error}`)
|
||||
}
|
||||
|
||||
const { url, query, body } = req
|
||||
const {
|
||||
nextauth,
|
||||
action = nextauth[0],
|
||||
provider = nextauth[1],
|
||||
error
|
||||
error = nextauth[1]
|
||||
} = query
|
||||
|
||||
const {
|
||||
csrfToken: csrfTokenFromPost
|
||||
} = body
|
||||
|
||||
// Allow site name, path prefix to be overriden
|
||||
const site = userSuppliedOptions.site || DEFAULT_SITE
|
||||
const basePath = userSuppliedOptions.basePath || DEFAULT_BASE_PATH
|
||||
const baseUrl = `${site}${basePath}`
|
||||
// @todo refactor all existing references to baseUrl and basePath
|
||||
const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
|
||||
|
||||
// Parse database / adapter
|
||||
let adapter
|
||||
if (userSuppliedOptions.adapter) {
|
||||
if (userOptions.adapter) {
|
||||
// If adapter is provided, use it (advanced usage, overrides database)
|
||||
adapter = userSuppliedOptions.adapter
|
||||
} else if (userSuppliedOptions.database) {
|
||||
adapter = userOptions.adapter
|
||||
} else if (userOptions.database) {
|
||||
// If database URI or config object is provided, use it (simple usage)
|
||||
adapter = adapters.Default(userSuppliedOptions.database)
|
||||
adapter = adapters.Default(userOptions.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(userSuppliedOptions)).digest('hex')
|
||||
// OAuth provider secrets and database credentials it should be sufficent.
|
||||
const secret = userOptions.secret || createHash('sha256').update(JSON.stringify({
|
||||
baseUrl, basePath, ...userOptions
|
||||
})).digest('hex')
|
||||
|
||||
// Use secure cookies if the site uses HTTPS
|
||||
// This being conditional allows cookies to work non-HTTPS development URLs
|
||||
@@ -66,7 +86,7 @@ export default async (req, res, userSuppliedOptions) => {
|
||||
// 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 useSecureCookies = userOptions.useSecureCookies || baseUrl.startsWith('https://')
|
||||
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
|
||||
|
||||
// @TODO Review cookie settings (names, options)
|
||||
@@ -89,15 +109,6 @@ export default async (req, res, userSuppliedOptions) => {
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
baseUrl: {
|
||||
name: `${cookiePrefix}next-auth.base-url`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
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.
|
||||
@@ -110,41 +121,41 @@ export default async (req, res, userSuppliedOptions) => {
|
||||
}
|
||||
},
|
||||
// Allow user cookie options to override any cookie settings above
|
||||
...userSuppliedOptions.cookies
|
||||
...userOptions.cookies
|
||||
}
|
||||
|
||||
// Session options
|
||||
const sessionOption = {
|
||||
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
|
||||
...userOptions.session
|
||||
}
|
||||
|
||||
// JWT options
|
||||
const jwtOptions = {
|
||||
secret,
|
||||
key: secret,
|
||||
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
|
||||
...userOptions.jwt
|
||||
}
|
||||
|
||||
// If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||
if (!adapter) {
|
||||
sessionOption.jwt = true
|
||||
sessionOptions.jwt = true
|
||||
}
|
||||
|
||||
// Event messages
|
||||
const eventsOption = {
|
||||
const eventsOptions = {
|
||||
...events,
|
||||
...userSuppliedOptions.events
|
||||
...userOptions.events
|
||||
}
|
||||
|
||||
// Callback functions
|
||||
const callbacksOption = {
|
||||
...callbacks,
|
||||
...userSuppliedOptions.callbacks
|
||||
const callbacksOptions = {
|
||||
...defaultCallbacks,
|
||||
...userOptions.callbacks
|
||||
}
|
||||
|
||||
// Ensure CSRF Token cookie is set for any subsequent requests.
|
||||
@@ -180,138 +191,137 @@ export default async (req, res, userSuppliedOptions) => {
|
||||
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
|
||||
}
|
||||
|
||||
// Set canonical site name + API route in a cookie to facilitate passing configuration
|
||||
// to the NextAuth client. There are potential security considerations around this
|
||||
// relating to trying to prevent attackers from exploiting this by setting this cookie
|
||||
// on the client first if they can get control of a sub domain or exploit a XSS
|
||||
// vulnerability, but this approach attempts to mitgate that by always verifying
|
||||
// the cookie and updating it if fails the verification check.
|
||||
let setUrlPrefixCookie = true
|
||||
if (req.cookies[cookies.baseUrl.name]) {
|
||||
const [baseUrlValue, baseUrlHash] = req.cookies[cookies.baseUrl.name].split('|')
|
||||
// If the hash on the cookie is verified, then we must have set the cookie and don't need to update it
|
||||
if (baseUrlValue === baseUrl && baseUrlHash === createHash('sha256').update(`${baseUrlValue}${secret}`).digest('hex')) { setUrlPrefixCookie = false }
|
||||
}
|
||||
// If the cookie is not set already (or if it is set, but failed verification) set header to update the cookie
|
||||
if (setUrlPrefixCookie) {
|
||||
const newUrlPrefixCookie = `${baseUrl}|${createHash('sha256').update(`${baseUrl}${secret}`).digest('hex')}`
|
||||
cookie.set(res, cookies.baseUrl.name, newUrlPrefixCookie, cookies.baseUrl.options)
|
||||
}
|
||||
|
||||
// User provided options are overriden by other options,
|
||||
// except for the options with special handling above
|
||||
const options = {
|
||||
// Defaults options can be overidden
|
||||
debug: false, // Enable debug messages to be displayed
|
||||
pages: {}, // Custom pages (e.g. sign in, sign out, errors)
|
||||
debug: false,
|
||||
pages: {},
|
||||
// Custom options override defaults
|
||||
...userSuppliedOptions,
|
||||
...userOptions,
|
||||
// These computed settings can values in userSuppliedOptions but override them
|
||||
// and are request-specific.
|
||||
adapter,
|
||||
site,
|
||||
basePath,
|
||||
baseUrl,
|
||||
basePath,
|
||||
action,
|
||||
provider,
|
||||
cookies,
|
||||
secret,
|
||||
csrfToken,
|
||||
csrfTokenVerified,
|
||||
providers: parseProviders(userSuppliedOptions.providers, baseUrl),
|
||||
session: sessionOption,
|
||||
providers: parseProviders({ providers: userOptions.providers, baseUrl, basePath }),
|
||||
session: sessionOptions,
|
||||
jwt: jwtOptions,
|
||||
events: eventsOption,
|
||||
callbacks: callbacksOption,
|
||||
callbackUrl: site
|
||||
events: eventsOptions,
|
||||
callbacks: callbacksOptions
|
||||
}
|
||||
req.options = options
|
||||
|
||||
// If debug enabled, set ENV VAR so that logger logs debug messages
|
||||
if (options.debug === true) { process.env._NEXT_AUTH_DEBUG = true }
|
||||
if (options.debug) {
|
||||
process.env._NEXTAUTH_DEBUG = true
|
||||
}
|
||||
|
||||
// Get / Set callback URL based on query param / cookie + validation
|
||||
options.callbackUrl = await callbackUrlHandler(req, res, options)
|
||||
|
||||
const redirect = (redirectUrl) => {
|
||||
res.status(302).setHeader('Location', redirectUrl)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
const callbackUrl = await callbackUrlHandler(req, res)
|
||||
|
||||
if (req.method === 'GET') {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
providers(req, res, options, done)
|
||||
providers(req, res)
|
||||
break
|
||||
case 'session':
|
||||
session(req, res, options, done)
|
||||
session(req, res)
|
||||
break
|
||||
case 'csrf':
|
||||
res.json({ csrfToken })
|
||||
return done()
|
||||
return res.end()
|
||||
case 'signin':
|
||||
if (provider && options.providers[provider]) {
|
||||
signin(req, res, options, done)
|
||||
} else {
|
||||
if (options.pages.signin) { return redirect(`${options.pages.signin}${options.pages.signin.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) }
|
||||
|
||||
pages.render(req, res, 'signin', { site, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done)
|
||||
if (options.pages.signIn) {
|
||||
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${callbackUrl}`
|
||||
if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` }
|
||||
return res.redirect(redirectUrl)
|
||||
}
|
||||
|
||||
renderPage(req, res, 'signin', { providers: Object.values(options.providers), callbackUrl, csrfToken })
|
||||
break
|
||||
case 'signout':
|
||||
if (options.pages.signout) { return redirect(`${options.pages.signout}${options.pages.signout.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) }
|
||||
if (options.pages.signOut) {
|
||||
return res.redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`)
|
||||
}
|
||||
|
||||
pages.render(req, res, 'signout', { site, baseUrl, csrfToken, callbackUrl: options.callbackUrl }, done)
|
||||
renderPage(req, res, 'signout', { csrfToken, callbackUrl })
|
||||
break
|
||||
case 'callback':
|
||||
if (provider && options.providers[provider]) {
|
||||
callback(req, res, options, done)
|
||||
callback(req, res)
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
|
||||
return done()
|
||||
res.status(400)
|
||||
return res.end(`Error: HTTP GET is not supported for ${url}`)
|
||||
}
|
||||
break
|
||||
case 'verify-request':
|
||||
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
|
||||
if (options.pages.verifyRequest) { return res.redirect(options.pages.verifyRequest) }
|
||||
|
||||
pages.render(req, res, 'verify-request', { site }, done)
|
||||
renderPage(req, res, 'verify-request')
|
||||
break
|
||||
case 'error':
|
||||
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
|
||||
if (options.pages.error) { return res.redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
|
||||
|
||||
pages.render(req, res, 'error', { site, error, baseUrl }, done)
|
||||
renderPage(req, res, 'error', { error })
|
||||
break
|
||||
default:
|
||||
res.status(404).end()
|
||||
return done()
|
||||
res.status(404)
|
||||
return res.end()
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
switch (action) {
|
||||
case 'signin':
|
||||
// Signin POST requests are used for email sign in
|
||||
// Verified CSRF Token required for all sign in routes
|
||||
if (!csrfTokenVerified) {
|
||||
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||
}
|
||||
|
||||
if (provider && options.providers[provider]) {
|
||||
signin(req, res, options, done)
|
||||
break
|
||||
signin(req, res)
|
||||
}
|
||||
break
|
||||
case 'signout':
|
||||
signout(req, res, options, done)
|
||||
// Verified CSRF Token required for signout
|
||||
if (!csrfTokenVerified) {
|
||||
return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`)
|
||||
}
|
||||
|
||||
signout(req, res)
|
||||
break
|
||||
case 'callback':
|
||||
if (provider && options.providers[provider]) {
|
||||
callback(req, res, options, done)
|
||||
// Verified CSRF Token required for credentials providers only
|
||||
if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) {
|
||||
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
|
||||
}
|
||||
|
||||
callback(req, res)
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
||||
return done()
|
||||
res.status(400)
|
||||
return res.end(`Error: HTTP POST is not supported for ${url}`)
|
||||
}
|
||||
break
|
||||
default:
|
||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
||||
return done()
|
||||
res.status(400)
|
||||
return res.end(`Error: HTTP POST is not supported for ${url}`)
|
||||
}
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
|
||||
return done()
|
||||
res.status(400)
|
||||
return res.end(`Error: HTTP ${req.method} is not supported for ${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,17 +1,19 @@
|
||||
// This function handles the complex flow of signing users in, and either creating,
|
||||
// linking (or not linking) accounts depending on if the user is currently logged
|
||||
// in, if they have account already and the authentication mechanism they are using.
|
||||
//
|
||||
// It prevents insecure behaviour, such as linking oAuth accounts unless a user is
|
||||
// signed in and authenticated with an existing valid account.
|
||||
//
|
||||
// All verification (e.g. oAuth flows or email address verificaiton flows) are
|
||||
// done prior to this handler being called to avoid additonal complexity in this
|
||||
// handler.
|
||||
import { AccountNotLinkedError } from '../../lib/errors'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (sessionToken, profile, providerAccount, options) => {
|
||||
/**
|
||||
* This function handles the complex flow of signing users in, and either creating,
|
||||
* linking (or not linking) accounts depending on if the user is currently logged
|
||||
* in, if they have account already and the authentication mechanism they are using.
|
||||
*
|
||||
* It prevents insecure behaviour, such as linking OAuth accounts unless a user is
|
||||
* signed in and authenticated with an existing valid account.
|
||||
*
|
||||
* All verification (e.g. OAuth flows or email address verificaiton flows) are
|
||||
* done prior to this handler being called to avoid additonal complexity in this
|
||||
* handler.
|
||||
*/
|
||||
export default async function callbackHandler (sessionToken, profile, providerAccount, options) {
|
||||
try {
|
||||
// Input validation
|
||||
if (!profile) { throw new Error('Missing profile') }
|
||||
@@ -20,7 +22,6 @@ export default async (sessionToken, profile, providerAccount, options) => {
|
||||
const { adapter, jwt, events } = options
|
||||
|
||||
const useJwtSession = options.session.jwt
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
|
||||
// If no adapter is configured then we don't have a database and cannot
|
||||
// persist data; in this mode we just return a dummy session object.
|
||||
@@ -52,9 +53,9 @@ export default async (sessionToken, profile, providerAccount, options) => {
|
||||
if (sessionToken) {
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
session = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge })
|
||||
if (session && session.user) {
|
||||
user = await getUser(session.user.id)
|
||||
session = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
if (session && session.sub) {
|
||||
user = await getUser(session.sub)
|
||||
isSignedIn = !!user
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -86,12 +87,12 @@ export default async (sessionToken, profile, providerAccount, options) => {
|
||||
|
||||
// Update emailVerified property on the user object
|
||||
const currentDate = new Date()
|
||||
userByEmail.emailVerified = currentDate
|
||||
user = await updateUser(userByEmail)
|
||||
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
|
||||
user = await createUser({ ...profile, emailVerified: true })
|
||||
const currentDate = new Date()
|
||||
user = await createUser({ ...profile, emailVerified: currentDate })
|
||||
await dispatchEvent(events.createUser, user)
|
||||
isNewUser = true
|
||||
}
|
||||
@@ -137,7 +138,7 @@ export default async (sessionToken, profile, providerAccount, options) => {
|
||||
}
|
||||
} else {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in and the oAuth account isn't already associated
|
||||
// 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,
|
||||
@@ -158,28 +159,28 @@ export default async (sessionToken, profile, providerAccount, options) => {
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is not signed in and it looks like a new oAuth account then we
|
||||
// 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.
|
||||
// email address as the one in the OAuth profile.
|
||||
//
|
||||
// This step is often overlooked in oAuth implementations, but covers the following cases:
|
||||
// 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.
|
||||
// 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.
|
||||
// someone is not exploiting a problem with a third party OAuth service.
|
||||
//
|
||||
// oAuth providers should require email address verification to prevent this, but in
|
||||
// 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.
|
||||
// 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
|
||||
@@ -190,7 +191,7 @@ export default async (sessionToken, profile, providerAccount, options) => {
|
||||
// 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 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)
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
|
||||
export default async (req, res, options) => {
|
||||
export default async function callbackUrlHandler (req, res) {
|
||||
const { query } = req
|
||||
const { body } = req
|
||||
const { cookies, site, defaultCallbackUrl, callbacks } = options
|
||||
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = req.options
|
||||
|
||||
// Handle preserving and validating callback URLs
|
||||
// If no defaultCallbackUrl option specified, default to the homepage for the site
|
||||
let callbackUrl = defaultCallbackUrl || site
|
||||
|
||||
let callbackUrl = defaultCallbackUrl || baseUrl
|
||||
// Try reading callbackUrlParamValue from request body (form submission) then from query param (get request)
|
||||
const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null
|
||||
const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null
|
||||
if (callbackUrlParamValue) {
|
||||
// If callbackUrl form field or query parameter is passed try to use it if allowed
|
||||
callbackUrl = await callbacks.redirect(callbackUrlParamValue, site)
|
||||
callbackUrl = await callbacks.redirect(callbackUrlParamValue, baseUrl)
|
||||
} else if (callbackUrlCookieValue) {
|
||||
// If no callbackUrl specified, try using the value from the cookie if allowed
|
||||
callbackUrl = await callbacks.redirect(callbackUrlCookieValue, site)
|
||||
callbackUrl = await callbacks.redirect(callbackUrlCookieValue, baseUrl)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return callbackUrl
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Function to set cookies server side
|
||||
//
|
||||
// Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
|
||||
// * https://github.com/jshttp/cookie/blob/master/index.js
|
||||
// * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
|
||||
//
|
||||
// As only partial functionlity is required, only the code we need has been incorporated here
|
||||
// (with fixes for specific issues) to keep dependancy size down.
|
||||
const set = (res, name, value, options = {}) => {
|
||||
/**
|
||||
* Function to set cookies server side
|
||||
*
|
||||
* Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
|
||||
* * https://github.com/jshttp/cookie/blob/master/index.js
|
||||
* * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
|
||||
*
|
||||
* As only partial functionlity is required, only the code we need has been incorporated here
|
||||
* (with fixes for specific issues) to keep dependancy size down.
|
||||
*/
|
||||
export function set (res, name, value, options = {}) {
|
||||
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
|
||||
|
||||
if ('maxAge' in options) {
|
||||
@@ -15,7 +17,9 @@ const set = (res, name, value, options = {}) => {
|
||||
}
|
||||
|
||||
// Preserve any existing cookies that have already been set in the same session
|
||||
const setCookieHeader = res.getHeader('Set-Cookie') || []
|
||||
let setCookieHeader = res.getHeader('Set-Cookie') || []
|
||||
// If not an array (i.e. a string with a single cookie) convert it into an array
|
||||
if (!Array.isArray(setCookieHeader)) { setCookieHeader = [setCookieHeader] }
|
||||
setCookieHeader.push(_serialize(name, String(stringValue), options))
|
||||
res.setHeader('Set-Cookie', setCookieHeader)
|
||||
}
|
||||
@@ -96,7 +100,3 @@ function _serialize (name, val, options) {
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
export default {
|
||||
set
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Use the signin callback to control if a user is allowed to sign in or not.
|
||||
* Use the signIn callback to control if a user is allowed to sign in or not.
|
||||
*
|
||||
* This is triggered before sign in flow completes, so the user profile may be
|
||||
* a user object (with an ID) or it may be just their name and email address,
|
||||
@@ -9,19 +9,14 @@
|
||||
* requests to sign in and again when they activate the link in the sign in
|
||||
* email.
|
||||
*
|
||||
* @param {object} profile User profile (e.g. user id, name, email)
|
||||
* @param {object} account Account used to sign in (e.g. OAuth account)
|
||||
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
|
||||
* @return {boolean|object} Return `true` (or a modified JWT) to allow sign in
|
||||
* Return `false` to deny access
|
||||
* @param {object} profile User profile (e.g. user id, name, email)
|
||||
* @param {object} account Account used to sign in (e.g. OAuth account)
|
||||
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
|
||||
* @return {Promise<boolean|never>} Return `true` (or a modified JWT) to allow sign in
|
||||
* Return `false` to deny access
|
||||
*/
|
||||
const signin = async (profile, account, metadata) => {
|
||||
const isAllowedToSignIn = true
|
||||
if (isAllowedToSignIn) {
|
||||
return Promise.resolve(true)
|
||||
} else {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
export async function signIn () {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,12 +26,13 @@ const signin = async (profile, account, metadata) => {
|
||||
*
|
||||
* @param {string} url URL provided as callback URL by the client
|
||||
* @param {string} baseUrl Default base URL of site (can be used as fallback)
|
||||
* @return {string} URL the client will be redirect to
|
||||
* @return {Promise<string>} URL the client will be redirect to
|
||||
*/
|
||||
const redirect = async (url, baseUrl) => {
|
||||
return url.startsWith(baseUrl)
|
||||
? Promise.resolve(url)
|
||||
: Promise.resolve(baseUrl)
|
||||
export async function redirect (url, baseUrl) {
|
||||
if (url.startsWith(baseUrl)) {
|
||||
return url
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,31 +41,24 @@ const redirect = async (url, baseUrl) => {
|
||||
*
|
||||
* @param {object} session Session object
|
||||
* @param {object} token JSON Web Token (if enabled)
|
||||
* @return {object} Session that will be returned to the client
|
||||
* @return {Promise<object>} Session that will be returned to the client
|
||||
*/
|
||||
const session = async (session, token) => {
|
||||
return Promise.resolve(session)
|
||||
export async function session (session) {
|
||||
return session
|
||||
}
|
||||
|
||||
/**
|
||||
* This callback is called whenever a JSON Web Token is created / updated.
|
||||
* e.g. On sign in, `getSession()`, `useSession()`, `/api/auth/session` (etc)
|
||||
*
|
||||
* On initial sign in, the raw oAuthProfile is passed if the user is signing in
|
||||
* On initial sign in, the raw OAuthProfile is passed if the user is signing in
|
||||
* with an OAuth provider. It is not avalible on subsequent calls. You can
|
||||
* take advantage of this to persist additional data you need to in the JWT.
|
||||
*
|
||||
* @param {object} token Decrypted JSON Web Token
|
||||
* @param {object} oAuthProfile OAuth profile - only available on sign in
|
||||
* @return {object} JSON Web Token that will be saved
|
||||
* @return {Promise<object>} JSON Web Token that will be saved
|
||||
*/
|
||||
const jwt = async (token, oAuthProfile) => {
|
||||
return Promise.resolve(token)
|
||||
}
|
||||
|
||||
export default {
|
||||
signin,
|
||||
redirect,
|
||||
session,
|
||||
jwt
|
||||
export async function jwt (token) {
|
||||
return token
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
export default async (event, message) => {
|
||||
export default async function dispatchEvent (event, message) {
|
||||
try {
|
||||
await event(message)
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
const signin = async (message) => {
|
||||
// Event triggered on successful sign in
|
||||
}
|
||||
/** Event triggered on successful sign in */
|
||||
export async function signIn (message) {}
|
||||
|
||||
const signout = async (message) => {
|
||||
// Event triggered on signout
|
||||
}
|
||||
/** Event triggered on sign out */
|
||||
export async function signOut (message) {}
|
||||
|
||||
const createUser = async (message) => {
|
||||
// Event triggered on user creation
|
||||
}
|
||||
/** Event triggered on user creation */
|
||||
export async function createUser (message) {}
|
||||
|
||||
const updateUser = async (message) => {
|
||||
// Event triggered when a user object is updated
|
||||
}
|
||||
/** Event triggered when a user object is updated */
|
||||
export async function updateUser (message) {}
|
||||
|
||||
const linkAccount = async (message) => {
|
||||
// Event triggered when an account is linked to a user
|
||||
}
|
||||
/** Event triggered when an account is linked to a user */
|
||||
export async function linkAccount (message) {}
|
||||
|
||||
const session = async (message) => {
|
||||
// Event triggered when a session is active
|
||||
}
|
||||
/** Event triggered when a session is active */
|
||||
export async function session (message) {}
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* @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,25 +1,50 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { decode as jwtDecode } from 'jsonwebtoken'
|
||||
import oAuthClient from './client'
|
||||
import querystring from 'querystring'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
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()
|
||||
// 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.
|
||||
|
||||
export default async (req, provider, callback) => {
|
||||
let { oauth_token, oauth_verifier, code } = req.query // eslint-disable-line camelcase
|
||||
export default async function oAuthCallback (req, csrfToken) {
|
||||
// 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"}
|
||||
const provider = req.options.providers[req.options.provider]
|
||||
const client = oAuthClient(provider)
|
||||
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
if (req.method === 'POST') {
|
||||
// Get the CODE from Body
|
||||
const body = JSON.parse(JSON.stringify(req.body))
|
||||
code = body.code
|
||||
if (provider.version?.startsWith('2.')) {
|
||||
let { code, user, state } = req.query // eslint-disable-line camelcase
|
||||
// For OAuth 2.0 flows, check state returned and matches expected value
|
||||
// (a hash of the NextAuth.js CSRF token).
|
||||
//
|
||||
// Apple does not support state verification.
|
||||
if (provider.id !== 'apple') {
|
||||
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
|
||||
if (state !== expectedState) {
|
||||
throw new OAuthCallbackError('Invalid state returned from OAuth provider')
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
const body = JSON.parse(JSON.stringify(req.body))
|
||||
if (body.error) {
|
||||
throw new Error(body.error)
|
||||
}
|
||||
|
||||
code = body.code
|
||||
user = body.user != null ? JSON.parse(body.user) : null
|
||||
} catch (error) {
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error, req.body, provider.id, code)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// REVIEW: Is this used by any of the providers?
|
||||
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
|
||||
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
|
||||
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
|
||||
@@ -27,184 +52,118 @@ export default async (req, provider, callback) => {
|
||||
client.useAuthorizationHeaderforGET(true)
|
||||
}
|
||||
|
||||
// Use custom getOAuthAccessToken() method for oAuth2 flows
|
||||
client.getOAuthAccessToken = _getOAuthAccessToken
|
||||
|
||||
await client.getOAuthAccessToken(
|
||||
code,
|
||||
provider,
|
||||
(error, accessToken, refreshToken, results) => {
|
||||
// @TODO Handle error
|
||||
if (error || results.error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, results, provider.id, code)
|
||||
try {
|
||||
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider)
|
||||
const tokens = { accessToken, refreshToken, idToken: results.id_token }
|
||||
let profileData
|
||||
if (provider.idToken) {
|
||||
// If we don't have an ID Token most likely the user hit a cancel
|
||||
// button when signing in (or the provider is misconfigured).
|
||||
//
|
||||
// Unfortunately, we can't tell which, so we can't treat it as an
|
||||
// error, so instead we just returning nothing, which will cause the
|
||||
// user to be redirected back to the sign in page.
|
||||
if (!results?.id_token) {
|
||||
throw new OAuthCallbackError()
|
||||
}
|
||||
|
||||
if (provider.idToken) {
|
||||
// Support services that use OpenID ID Tokens to encode profile data
|
||||
_decodeToken(
|
||||
provider,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
results.id_token,
|
||||
(error, profileData) => callback(error, _getProfile(error, profileData, accessToken, refreshToken, provider))
|
||||
)
|
||||
} else {
|
||||
// Use custom get() method for oAuth2 flows
|
||||
client.get = _get
|
||||
|
||||
client.get(
|
||||
provider,
|
||||
accessToken,
|
||||
(error, profileData) => callback(error, _getProfile(error, profileData, accessToken, refreshToken, provider))
|
||||
)
|
||||
}
|
||||
// Support services that use OpenID ID Tokens to encode profile data
|
||||
profileData = decodeIdToken(results.id_token)
|
||||
} else {
|
||||
profileData = await client.get(provider, accessToken, results)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Handle oAuth v1.x
|
||||
await client.getOAuthAccessToken(
|
||||
oauth_token,
|
||||
null,
|
||||
oauth_verifier,
|
||||
(error, accessToken, refreshToken, results) => {
|
||||
// @TODO Handle error
|
||||
if (error || results.error) {
|
||||
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error, results)
|
||||
}
|
||||
|
||||
client.get(
|
||||
provider.profileUrl,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
(error, profileData) => callback(error, _getProfile(error, profileData, accessToken, refreshToken, provider))
|
||||
)
|
||||
}
|
||||
return _getProfile({ profileData, provider, tokens, user })
|
||||
} catch (error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, provider.id, code)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle OAuth v1.x
|
||||
const {
|
||||
oauth_token: oauthToken, oauth_verifier: oauthVerifier
|
||||
} = req.query
|
||||
const { 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
|
||||
}
|
||||
}
|
||||
|
||||
async function _getProfile (error, profileData, accessToken, refreshToken, provider) {
|
||||
// @TODO Handle error
|
||||
if (error) {
|
||||
logger.error('OAUTH_GET_PROFILE_ERROR', error)
|
||||
}
|
||||
|
||||
let profile = {}
|
||||
/**
|
||||
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
|
||||
* Returns profile, raw profile and auth provider details
|
||||
*/
|
||||
async function _getProfile ({
|
||||
profileData, tokens: { accessToken, refreshToken, idToken }, provider, user
|
||||
}) {
|
||||
try {
|
||||
// Convert profileData into an object if it's a string
|
||||
if (typeof profileData === 'string' || profileData instanceof String) { profileData = JSON.parse(profileData) }
|
||||
if (typeof profileData === 'string' || profileData instanceof String) {
|
||||
profileData = JSON.parse(profileData)
|
||||
}
|
||||
|
||||
profile = await provider.profile(profileData)
|
||||
// If a user object is supplied (e.g. Apple provider) add it to the profile object
|
||||
if (user != null) {
|
||||
profileData.user = user
|
||||
}
|
||||
|
||||
profileData.idToken = idToken
|
||||
|
||||
logger.debug('PROFILE_DATA', 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) {
|
||||
// @TODO Handle parsing error
|
||||
logger.error('OAUTH_PARSE_PROFILE_ERROR', exception)
|
||||
throw new Error('Failed to get OAuth profile')
|
||||
}
|
||||
|
||||
// Return profile, raw profile and auth provider details
|
||||
return ({
|
||||
profile: {
|
||||
name: profile.name,
|
||||
email: profile.email ? profile.email.toLowerCase() : null,
|
||||
image: profile.image
|
||||
},
|
||||
account: {
|
||||
provider: provider.id,
|
||||
type: provider.type,
|
||||
id: profile.id,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
accessTokenExpires: null
|
||||
},
|
||||
oAuthProfile: profileData
|
||||
})
|
||||
}
|
||||
|
||||
// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||
async function _getOAuthAccessToken (code, provider, callback) {
|
||||
const url = provider.accessTokenUrl
|
||||
const setGetAccessTokenAuthHeader = (provider.setGetAccessTokenAuthHeader !== null) ? provider.setGetAccessTokenAuthHeader : true
|
||||
const params = { ...provider.params } || {}
|
||||
const headers = { ...provider.headers } || {}
|
||||
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
|
||||
|
||||
if (!params[codeParam]) { params[codeParam] = code }
|
||||
|
||||
if (!params.client_id) { params.client_id = provider.clientId }
|
||||
|
||||
if (!params.client_secret) {
|
||||
// For some providers it useful to be able to generate the secret on the fly
|
||||
// e.g. For Sign in With Apple a JWT token using the properties in clientSecret
|
||||
if (provider.clientSecretCallback) {
|
||||
params.client_secret = await provider.clientSecretCallback(provider.clientSecret)
|
||||
} else {
|
||||
params.client_secret = provider.clientSecret
|
||||
// If we didn't get a response either there was a problem with the provider
|
||||
// response *or* the user cancelled the action with the provider.
|
||||
//
|
||||
// Unfortuately, we can't tell which - at least not in a way that works for
|
||||
// all providers, so we return an empty object; the user should then be
|
||||
// redirected back to the sign up page. We log the error to help developers
|
||||
// who might be trying to debug this when configuring a new provider.
|
||||
logger.error('OAUTH_PARSE_PROFILE_ERROR', exception, profileData)
|
||||
return {
|
||||
profile: null,
|
||||
account: null,
|
||||
OAuthProfile: profileData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}` }
|
||||
function decodeIdToken (idToken) {
|
||||
if (!idToken) {
|
||||
throw new OAuthCallbackError('Missing JWT ID Token')
|
||||
}
|
||||
|
||||
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)
|
||||
return jwtDecode(idToken, { json: true })
|
||||
}
|
||||
|
||||
@@ -1,31 +1,228 @@
|
||||
// @TODO Refactor to remove dependancy on 'oauth' package
|
||||
// It is already quite monkey patched, we don't use all the features and and it
|
||||
// would be easier to maintain if all the code was native to next-auth.
|
||||
import { OAuth, OAuth2 } from 'oauth'
|
||||
import querystring from 'querystring'
|
||||
import logger from '../../../lib/logger'
|
||||
import { sign as jwtSign } from 'jsonwebtoken'
|
||||
|
||||
export default (provider) => {
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
// Handle oAuth v2.x
|
||||
const basePath = new URL(provider.authorizationUrl).origin
|
||||
const authorizePath = new URL(provider.authorizationUrl).pathname
|
||||
/**
|
||||
* @TODO Refactor to remove dependancy on 'oauth' package
|
||||
* It is already quite monkey patched, we don't use all the features and and it
|
||||
* would be easier to maintain if all the code was native to next-auth.
|
||||
*/
|
||||
export default function oAuthClient (provider) {
|
||||
if (provider.version?.startsWith('2.')) {
|
||||
// Handle OAuth v2.x
|
||||
const authorizationUrl = new URL(provider.authorizationUrl)
|
||||
const basePath = authorizationUrl.origin
|
||||
const authorizePath = authorizationUrl.pathname
|
||||
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
|
||||
return new OAuth2(
|
||||
const oauth2Client = new OAuth2(
|
||||
provider.clientId,
|
||||
provider.clientSecret,
|
||||
basePath,
|
||||
authorizePath,
|
||||
accessTokenPath,
|
||||
provider.headers)
|
||||
} else {
|
||||
// Handle oAuth v1.x
|
||||
return new OAuth(
|
||||
provider.requestTokenUrl,
|
||||
provider.accessTokenUrl,
|
||||
provider.clientId,
|
||||
provider.clientSecret,
|
||||
(provider.version || '1.0'),
|
||||
provider.callbackUrl,
|
||||
(provider.encoding || 'HMAC-SHA1')
|
||||
provider.headers
|
||||
)
|
||||
oauth2Client.getOAuthAccessToken = getOAuth2AccessToken
|
||||
oauth2Client.get = getOAuth2
|
||||
return oauth2Client
|
||||
}
|
||||
// Handle OAuth v1.x
|
||||
const oauth1Client = new OAuth(
|
||||
provider.requestTokenUrl,
|
||||
provider.accessTokenUrl,
|
||||
provider.clientId,
|
||||
provider.clientSecret,
|
||||
provider.version || '1.0',
|
||||
provider.callbackUrl,
|
||||
provider.encoding || 'HMAC-SHA1'
|
||||
)
|
||||
|
||||
// Promisify get() and getOAuth2AccessToken() for OAuth1
|
||||
const originalGet = oauth1Client.get
|
||||
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
|
||||
if (provider.id === 'spotify') {
|
||||
accessToken = results.authed_user.access_token
|
||||
} else {
|
||||
accessToken = results.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 requires 'access_token' as URL request parameter
|
||||
if (provider.id === 'mailru') {
|
||||
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,11 @@
|
||||
export default (_providers, baseUrl) => {
|
||||
const providers = {}
|
||||
|
||||
_providers.forEach(provider => {
|
||||
export default function parseProviders ({ providers, baseUrl, basePath }) {
|
||||
return providers.reduce((acc, provider) => {
|
||||
const providerId = provider.id
|
||||
providers[providerId] = {
|
||||
acc[providerId] = {
|
||||
...provider,
|
||||
signinUrl: `${baseUrl}/signin/${providerId}`,
|
||||
callbackUrl: `${baseUrl}/callback/${providerId}`
|
||||
signinUrl: `${baseUrl}${basePath}/signin/${providerId}`,
|
||||
callbackUrl: `${baseUrl}${basePath}/callback/${providerId}`
|
||||
}
|
||||
})
|
||||
|
||||
return providers
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
12
src/server/lib/redirect.js
Normal file
12
src/server/lib/redirect.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export default function redirect (req, res) {
|
||||
// This is the one you will use. The wrapper is just to set it up in src/server/index.
|
||||
return function redirect (url) {
|
||||
const reponseAsJson = req.body?.json === 'true'
|
||||
if (reponseAsJson) {
|
||||
res.json({ url })
|
||||
} else {
|
||||
res.status(302).setHeader('Location', url)
|
||||
}
|
||||
return res.end()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export default async (email, provider, options) => {
|
||||
export default async function email (email, provider, options) {
|
||||
try {
|
||||
const { baseUrl, adapter } = options
|
||||
const { baseUrl, basePath, adapter } = options
|
||||
|
||||
const { createVerificationRequest } = await adapter.getAdapter(options)
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async (email, provider, options) => {
|
||||
const token = randomBytes(32).toString('hex')
|
||||
|
||||
// Send email with link containing token (the unhashed version)
|
||||
const url = `${baseUrl}/callback/${encodeURIComponent(provider.id)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
const url = `${baseUrl}${basePath}/callback/${encodeURIComponent(provider.id)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}`
|
||||
|
||||
// @TODO Create invite (send secret so can be hashed)
|
||||
await createVerificationRequest(email, url, token, secret, provider, options)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import oAuthClient from '../oauth/client'
|
||||
import crypto from 'crypto'
|
||||
import { createHash } from 'crypto'
|
||||
import logger from '../../../lib/logger'
|
||||
|
||||
export default (provider, callback) => {
|
||||
export default async function oauth (provider, csrfToken) {
|
||||
const { callbackUrl } = provider
|
||||
const client = oAuthClient(provider)
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
// Handle oAuth v2.x
|
||||
if (provider.version?.startsWith('2.')) {
|
||||
// Handle OAuth v2.x
|
||||
let url = client.getAuthorizeUrl({
|
||||
redirect_uri: provider.callbackUrl,
|
||||
redirect_uri: callbackUrl,
|
||||
scope: provider.scope,
|
||||
state: crypto.randomBytes(64).toString('hex')
|
||||
// A hash of the NextAuth.js CSRF token is used as the state
|
||||
state: createHash('sha256').update(csrfToken).digest('hex'),
|
||||
...provider.authorizationParams
|
||||
})
|
||||
|
||||
// If the authorizationUrl specified in the config has query parameters on it
|
||||
@@ -26,15 +28,14 @@ export default (provider, callback) => {
|
||||
url = url.replace(baseUrl, provider.authorizationUrl + '&')
|
||||
}
|
||||
|
||||
callback(null, url)
|
||||
} else {
|
||||
// Handle oAuth v1.x
|
||||
client.getOAuthRequestToken((error, oAuthToken) => {
|
||||
if (error) {
|
||||
logger.error('GET_AUTHORISATION_URL_ERROR', error)
|
||||
}
|
||||
const url = `${provider.authorizationUrl}?oauth_token=${oAuthToken}`
|
||||
callback(error, url)
|
||||
}, callbackUrl)
|
||||
return url
|
||||
}
|
||||
|
||||
try {
|
||||
const oAuthToken = await client.getOAuthRequestToken(callbackUrl)
|
||||
return `${provider.authorizationUrl}?oauth_token=${oAuthToken}`
|
||||
} catch (error) {
|
||||
logger.error('GET_AUTHORISATION_URL_ERROR', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ site, error, baseUrl }) => {
|
||||
const signinPageUrl = `${baseUrl}/signin` // @TODO Make sign in URL configurable
|
||||
export default function error ({ baseUrl, basePath, error, res }) {
|
||||
const signinPageUrl = `${baseUrl}${basePath}/signin`
|
||||
|
||||
let statusCode = 200
|
||||
let heading = <h1>Error</h1>
|
||||
let message = <p><a className='site' href={site}>{site.replace(/^https?:\/\//, '')}</a></p>
|
||||
let message = <p><a className='site' href={baseUrl}>{baseUrl.replace(/^https?:\/\//, '')}</a></p>
|
||||
|
||||
switch (error) {
|
||||
case 'Signin':
|
||||
@@ -14,74 +15,42 @@ export default ({ site, error, baseUrl }) => {
|
||||
case 'OAuthCreateAccount':
|
||||
case 'EmailCreateAccount':
|
||||
case 'Callback':
|
||||
heading = <h1>Sign in failed</h1>
|
||||
message =
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>Try signing with a different account.</p>
|
||||
</div>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
break
|
||||
case 'OAuthAccountNotLinked':
|
||||
heading = <h1>Sign in failed</h1>
|
||||
message =
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>An account associated with your email address already exists.</p>
|
||||
<p>Sign in with the same account you used originally to confirm your identity.</p>
|
||||
</div>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
// @TODO Add this text when account linking is complete
|
||||
// <p>Once you are signed in, you can link your accounts.</p>
|
||||
// @TODO Display email sign in option if an email provider is configured
|
||||
break
|
||||
case 'EmailSignin':
|
||||
heading = <h1>Sign in failed</h1>
|
||||
message =
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>Unable to send email.</p>
|
||||
</div>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
break
|
||||
case 'CredentialsSignin':
|
||||
heading = <h1>Sign in failed</h1>
|
||||
message =
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>Check the details you provided are correct.</p>
|
||||
</div>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
break
|
||||
// These messages are displayed in line on the sign in page
|
||||
res.redirect(`${signinPageUrl}?error=${error}`)
|
||||
return false
|
||||
case 'Configuration':
|
||||
statusCode = 500
|
||||
heading = <h1>Server error</h1>
|
||||
message =
|
||||
message = (
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>There is a problem with the server configuration.</p>
|
||||
<p>Check the server logs for more information.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'AccessDenied':
|
||||
statusCode = 403
|
||||
heading = <h1>Access Denied</h1>
|
||||
message =
|
||||
message = (
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>You do not have permission to sign in.</p>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
case 'Verification':
|
||||
// @TODO Check if user is signed in already with the same email address.
|
||||
// If they are, no need to display this message, can just direct to callbackUrl
|
||||
statusCode = 403
|
||||
heading = <h1>Unable to sign in</h1>
|
||||
message =
|
||||
message = (
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>The sign in link is no longer valid.</p>
|
||||
@@ -89,10 +58,13 @@ export default ({ site, error, baseUrl }) => {
|
||||
</div>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
)
|
||||
break
|
||||
default:
|
||||
}
|
||||
|
||||
res.status(statusCode)
|
||||
|
||||
return render(
|
||||
<div className='error'>
|
||||
{heading}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import signin from './signin'
|
||||
import signout from './signout'
|
||||
import verifyRequest from './verify-request'
|
||||
import error from './error'
|
||||
import css from '../../css'
|
||||
|
||||
function render (req, res, page, props, done) {
|
||||
export default function renderPage (req, res, page, props = {}) {
|
||||
props.baseUrl = req.options.baseUrl
|
||||
props.basePath = req.options.basePath
|
||||
let html = ''
|
||||
switch (page) {
|
||||
case 'signin':
|
||||
@@ -19,7 +19,8 @@ function render (req, res, page, props, done) {
|
||||
html = verifyRequest(props)
|
||||
break
|
||||
case 'error':
|
||||
html = error(props)
|
||||
html = error({ ...props, res })
|
||||
if (html === false) return res.end()
|
||||
break
|
||||
default:
|
||||
html = error(props)
|
||||
@@ -28,9 +29,5 @@ function render (req, res, page, props, done) {
|
||||
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.send(`<!DOCTYPE html><head><style type="text/css">${css()}</style><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div class="page">${html}</div></body></html>`)
|
||||
done()
|
||||
}
|
||||
|
||||
export default {
|
||||
render
|
||||
res.end()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ req, csrfToken, providers, callbackUrl }) => {
|
||||
const withCallbackUrl = callbackUrl ? `?callbackUrl=${callbackUrl}` : ''
|
||||
const { email } = req.query
|
||||
export default function signin ({ req, csrfToken, providers, callbackUrl }) {
|
||||
const { email, error } = req.query
|
||||
|
||||
// We only want to render providers
|
||||
const providersToRender = providers.filter(provider => {
|
||||
@@ -19,15 +18,49 @@ export default ({ req, csrfToken, providers, callbackUrl }) => {
|
||||
}
|
||||
})
|
||||
|
||||
let errorMessage
|
||||
if (error) {
|
||||
switch (error) {
|
||||
case 'Signin':
|
||||
case 'OAuthSignin':
|
||||
case 'OAuthCallback':
|
||||
case 'OAuthCreateAccount':
|
||||
case 'EmailCreateAccount':
|
||||
case 'Callback':
|
||||
errorMessage = <p>Try signing with a different account.</p>
|
||||
break
|
||||
case 'OAuthAccountNotLinked':
|
||||
errorMessage = <p>To confirm your identity, sign in with the same account you used originally.</p>
|
||||
break
|
||||
case 'EmailSignin':
|
||||
errorMessage = <p>Check your email address.</p>
|
||||
break
|
||||
case 'CredentialsSignin':
|
||||
errorMessage = <p>Sign in failed. Check the details you provided are correct.</p>
|
||||
break
|
||||
default:
|
||||
errorMessage = <p>Unable to sign in.</p>
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return render(
|
||||
<div className='signin'>
|
||||
{errorMessage &&
|
||||
<div className='error'>
|
||||
{errorMessage}
|
||||
</div>}
|
||||
{providersToRender.map((provider, i) =>
|
||||
<div key={provider.id} className='provider'>
|
||||
{provider.type === 'oauth' &&
|
||||
<a className='button' data-provider={provider.id} href={`${provider.signinUrl}${withCallbackUrl}`}>Sign in with {provider.name}</a>}
|
||||
<form action={provider.signinUrl} method='POST'>
|
||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||
{callbackUrl && <input type='hidden' name='callbackUrl' value={callbackUrl} />}
|
||||
<button type='submit' className='button'>Sign in with {provider.name}</button>
|
||||
</form>}
|
||||
{(provider.type === 'email' || provider.type === 'credentials') && (i > 0) &&
|
||||
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
|
||||
<hr />}
|
||||
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
|
||||
<hr />}
|
||||
{provider.type === 'email' &&
|
||||
<form action={provider.signinUrl} method='POST'>
|
||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ baseUrl, csrfToken }) => {
|
||||
export default function signout ({ baseUrl, basePath, csrfToken }) {
|
||||
return render(
|
||||
<div className='signout'>
|
||||
<h1>Are you sure you want to sign out?</h1>
|
||||
<form action={`${baseUrl}/signout`} method='POST'>
|
||||
<form action={`${baseUrl}${basePath}/signout`} method='POST'>
|
||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||
<button type='submit'>Sign out</button>
|
||||
</form>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ site }) => {
|
||||
export default function verifyRequest ({ baseUrl }) {
|
||||
return render(
|
||||
<div className='verify-request'>
|
||||
<h1>Check your email</h1>
|
||||
<p>A sign in link has been sent to your email address.</p>
|
||||
<p><a className='site' href={site}>{site.replace(/^https?:\/\//, '')}</a></p>
|
||||
<p><a className='site' href={baseUrl}>{baseUrl.replace(/^https?:\/\//, '')}</a></p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +1,98 @@
|
||||
// Handle callbacks from login services
|
||||
import oAuthCallback from '../lib/oauth/callback'
|
||||
import callbackHandler from '../lib/callback-handler'
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
/** Handle callbacks from login services */
|
||||
export default async function callback (req, res) {
|
||||
const {
|
||||
provider: providerName,
|
||||
providers,
|
||||
adapter,
|
||||
site,
|
||||
secret,
|
||||
baseUrl,
|
||||
basePath,
|
||||
secret,
|
||||
cookies,
|
||||
callbackUrl,
|
||||
pages,
|
||||
jwt,
|
||||
events,
|
||||
callbacks
|
||||
} = options
|
||||
callbacks,
|
||||
csrfToken,
|
||||
session: {
|
||||
jwt: useJwtSession,
|
||||
maxAge: sessionMaxAge
|
||||
}
|
||||
} = req.options
|
||||
const provider = providers[providerName]
|
||||
const { type } = provider
|
||||
const useJwtSession = options.session.jwt
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
|
||||
// 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') {
|
||||
oAuthCallback(req, provider, async (error, oauthAccount) => {
|
||||
if (error) {
|
||||
logger.error('CALLBACK_OAUTH_ERROR', error)
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=oAuthCallback`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
try {
|
||||
const { profile, account, OAuthProfile } = await oAuthCallback(req, csrfToken)
|
||||
try {
|
||||
const { profile, account, oAuthProfile } = await oauthAccount
|
||||
// Make it easier to debug when adding a new provider
|
||||
logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile })
|
||||
|
||||
// If we don't have a profile object then either something went wrong
|
||||
// or the user cancelled 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`)
|
||||
}
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
const signinCallbackResponse = await callbacks.signin(profile, account, oAuthProfile)
|
||||
// Attempt to get Profile from OAuth provider details before invoking
|
||||
// signIn callback - but if no user object is returned, that is fine
|
||||
// (that just means it's a new user signing in for the first time).
|
||||
let userOrProfile = profile
|
||||
if (adapter) {
|
||||
const { getUserByProviderAccountId } = await adapter.getAdapter(req.options)
|
||||
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
|
||||
if (userFromProviderAccountId) {
|
||||
userOrProfile = userFromProviderAccountId
|
||||
}
|
||||
}
|
||||
|
||||
if (signinCallbackResponse === false) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`)
|
||||
res.end()
|
||||
return done()
|
||||
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, options)
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = { user, account, isNewUser }
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, oAuthProfile)
|
||||
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({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
@@ -68,59 +104,51 @@ export default async (req, res, options, done) => {
|
||||
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
|
||||
}
|
||||
|
||||
await dispatchEvent(events.signin, { user, account, isNewUser })
|
||||
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) {
|
||||
res.status(302).setHeader('Location', pages.newUser)
|
||||
res.end()
|
||||
return done()
|
||||
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 nto with this oAuth account
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthAccountNotLinked`)
|
||||
// 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') {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthCreateAccount`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
|
||||
} else {
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Callback`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
}
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
if (callbackUrl) {
|
||||
res.status(302).setHeader('Location', callbackUrl)
|
||||
res.end()
|
||||
} else {
|
||||
res.status(302).setHeader('Location', site)
|
||||
res.end()
|
||||
} catch (error) {
|
||||
if (error.name === 'OAuthCallbackError') {
|
||||
logger.error('CALLBACK_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
|
||||
}
|
||||
return done()
|
||||
})
|
||||
logger.error('OAUTH_CALLBACK_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
}
|
||||
} else if (type === 'email') {
|
||||
try {
|
||||
if (!adapter) {
|
||||
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
|
||||
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(options)
|
||||
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(req.options)
|
||||
const verificationToken = req.query.token
|
||||
const email = req.query.email
|
||||
|
||||
// Verify email and verification token exist in database
|
||||
const invite = await getVerificationRequest(email, verificationToken, secret, provider)
|
||||
if (!invite) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Verification`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Verification`)
|
||||
}
|
||||
|
||||
// If verification token is valid, delete verification request token from
|
||||
@@ -132,23 +160,36 @@ export default async (req, res, options, done) => {
|
||||
const account = { id: provider.id, type: 'email', providerAccountId: email }
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
const signinCallbackResponse = await callbacks.signin(profile, account, null)
|
||||
|
||||
if (signinCallbackResponse === false) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`)
|
||||
res.end()
|
||||
return done()
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
||||
if (signInCallbackResponse === false) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
} else if (typeof signInCallbackResponse === 'string') {
|
||||
return res.redirect(signInCallbackResponse)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
// TODO: Remove in a future major release
|
||||
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = { user, account, isNewUser }
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload)
|
||||
const defaultJwtPayload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image,
|
||||
sub: user.id?.toString()
|
||||
}
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, profile, isNewUser)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
@@ -156,92 +197,81 @@ export default async (req, res, options, done) => {
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
} else {
|
||||
console.log('debug.cookies', cookies)
|
||||
console.log('debug.session', session)
|
||||
// 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 })
|
||||
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) {
|
||||
res.status(302).setHeader('Location', pages.newUser)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
|
||||
}
|
||||
|
||||
// Callback URL is already verified at this point, so safe to use if specified
|
||||
if (callbackUrl) {
|
||||
res.status(302).setHeader('Location', callbackUrl)
|
||||
res.end()
|
||||
} else {
|
||||
res.status(302).setHeader('Location', site)
|
||||
res.end()
|
||||
}
|
||||
return done()
|
||||
return res.redirect(callbackUrl || baseUrl)
|
||||
} catch (error) {
|
||||
if (error.name === 'CreateUserError') {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=EmailCreateAccount`)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
|
||||
} else {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Callback`)
|
||||
logger.error('CALLBACK_EMAIL_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
|
||||
}
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
} else if (type === 'credentials' && req.method === 'POST') {
|
||||
if (!useJwtSession) {
|
||||
logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled')
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
|
||||
if (!provider.authorize) {
|
||||
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
|
||||
const credentials = req.body
|
||||
|
||||
// If promise is rejected / throws error then display Configuration error
|
||||
let userObjectReturnedFromAuthorizeHandler
|
||||
try {
|
||||
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
|
||||
if (!userObjectReturnedFromAuthorizeHandler) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`)
|
||||
res.end()
|
||||
return done()
|
||||
if (error instanceof Error) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return res.redirect(error)
|
||||
}
|
||||
}
|
||||
|
||||
const user = userObjectReturnedFromAuthorizeHandler
|
||||
const account = { id: provider.id, type: 'credentials' }
|
||||
|
||||
// If no user is returned, credentials are not valid
|
||||
if (!user) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
|
||||
res.end()
|
||||
return done()
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
|
||||
if (signInCallbackResponse === false) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
} else {
|
||||
return res.redirect(error)
|
||||
}
|
||||
}
|
||||
|
||||
const signinCallbackResponse = await callbacks.signin(user, account, credentials)
|
||||
|
||||
if (signinCallbackResponse === false) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`)
|
||||
res.end()
|
||||
return done()
|
||||
const defaultJwtPayload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
picture: user.image
|
||||
}
|
||||
|
||||
const defaultJwtPayload = { user, account }
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload)
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, userObjectReturnedFromAuthorizeHandler, false)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
@@ -249,19 +279,10 @@ export default async (req, res, options, done) => {
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
|
||||
await dispatchEvent(events.signin, { user, account })
|
||||
await dispatchEvent(events.signIn, { user, account })
|
||||
|
||||
if (callbackUrl) {
|
||||
res.status(302).setHeader('Location', callbackUrl)
|
||||
res.end()
|
||||
} else {
|
||||
res.status(302).setHeader('Location', site)
|
||||
res.end()
|
||||
}
|
||||
|
||||
return done()
|
||||
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 ${type} not supported`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
// Return a JSON object with a list of all outh providers currently configured
|
||||
// and their signin and callback URLs. This makes it possible to automatically
|
||||
// generate buttons for all providers when rendering client side.
|
||||
export default (req, res, options, done) => {
|
||||
const { providers } = options
|
||||
/**
|
||||
* Return a JSON object with a list of all outh providers currently configured
|
||||
* and their signin and callback URLs. This makes it possible to automatically
|
||||
* generate buttons for all providers when rendering client side.
|
||||
*/
|
||||
export default function providers (req, res) {
|
||||
const { providers } = req.options
|
||||
|
||||
const result = {}
|
||||
Object.entries(providers).map(([provider, providerConfig]) => {
|
||||
result[provider] = {
|
||||
id: provider,
|
||||
name: providerConfig.name,
|
||||
type: providerConfig.type,
|
||||
signinUrl: providerConfig.signinUrl,
|
||||
callbackUrl: providerConfig.callbackUrl
|
||||
}
|
||||
})
|
||||
const result = Object.entries(providers)
|
||||
.reduce((acc, [provider, providerConfig]) => ({
|
||||
...acc,
|
||||
[provider]: {
|
||||
id: provider,
|
||||
name: providerConfig.name,
|
||||
type: providerConfig.type,
|
||||
signinUrl: providerConfig.signinUrl,
|
||||
callbackUrl: providerConfig.callbackUrl
|
||||
}
|
||||
}), {})
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json(result)
|
||||
return done()
|
||||
return res.end()
|
||||
}
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
// Return a session object (without any private fields) for Single Page App clients
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
const { cookies, adapter, jwt, events, callbacks } = options
|
||||
const useJwtSession = options.session.jwt
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
/**
|
||||
* Return a session object (without any private fields)
|
||||
* for Single Page App clients
|
||||
*/
|
||||
export default async function session (req, res) {
|
||||
const { cookies, adapter, jwt, events, callbacks } = req.options
|
||||
const useJwtSession = req.options.session.jwt
|
||||
const sessionMaxAge = req.options.session.maxAge
|
||||
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||
|
||||
if (!sessionToken) {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json({})
|
||||
return done()
|
||||
return res.end()
|
||||
}
|
||||
|
||||
let response = {}
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
// Decrypt and verify token
|
||||
const decodedJwt = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge })
|
||||
const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
|
||||
// Generate new session expiry date
|
||||
const sessionExpiresDate = new Date()
|
||||
@@ -30,9 +33,9 @@ export default async (req, res, options, done) => {
|
||||
// as needed for presentation purposes (e.g. "you are logged in as…").
|
||||
const defaultSessionPayload = {
|
||||
user: {
|
||||
name: decodedJwt.user && decodedJwt.user.name ? decodedJwt.user.name : null,
|
||||
email: decodedJwt.user && decodedJwt.user.email ? decodedJwt.user.email : null,
|
||||
image: decodedJwt.user && decodedJwt.user.image ? decodedJwt.user.image : null
|
||||
name: decodedJwt.name || null,
|
||||
email: decodedJwt.email || null,
|
||||
image: decodedJwt.picture || null
|
||||
},
|
||||
expires: sessionExpires
|
||||
}
|
||||
@@ -45,7 +48,7 @@ export default async (req, res, options, done) => {
|
||||
response = sessionPayload
|
||||
|
||||
// Refresh JWT expiry by re-signing it, with an updated expiry date
|
||||
const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
|
||||
|
||||
// Set cookie, to also update expiry date on cookie
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: sessionExpires, ...cookies.sessionToken.options })
|
||||
@@ -58,7 +61,7 @@ export default async (req, res, options, done) => {
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { getUser, getSession, updateSession } = await adapter.getAdapter(options)
|
||||
const { getUser, getSession, updateSession } = await adapter.getAdapter(req.options)
|
||||
const session = await getSession(sessionToken)
|
||||
if (session) {
|
||||
// Trigger update to session object to update session expiry
|
||||
@@ -79,7 +82,7 @@ export default async (req, res, options, done) => {
|
||||
}
|
||||
|
||||
// Pass Session through to the session callback
|
||||
const sessionPayload = await callbacks.session(defaultSessionPayload)
|
||||
const sessionPayload = await callbacks.session(defaultSessionPayload, user)
|
||||
|
||||
// Return session payload as response
|
||||
response = sessionPayload
|
||||
@@ -100,5 +103,5 @@ export default async (req, res, options, done) => {
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json(response)
|
||||
return done()
|
||||
return res.end()
|
||||
}
|
||||
|
||||
@@ -1,50 +1,40 @@
|
||||
// Handle requests to /api/auth/signin
|
||||
import oAuthSignin from '../lib/signin/oauth'
|
||||
import emailSignin from '../lib/signin/email'
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
/** Handle requests to /api/auth/signin */
|
||||
export default async function signin (req, res) {
|
||||
const {
|
||||
provider: providerName,
|
||||
providers,
|
||||
baseUrl,
|
||||
csrfTokenVerified,
|
||||
basePath,
|
||||
adapter,
|
||||
callbacks
|
||||
} = options
|
||||
callbacks,
|
||||
csrfToken
|
||||
} = req.options
|
||||
const provider = providers[providerName]
|
||||
const { type } = provider
|
||||
|
||||
if (!type) {
|
||||
res.status(500).end(`Error: Type not specified for ${provider}`)
|
||||
return done()
|
||||
res.status(500)
|
||||
return res.end(`Error: Type not specified for ${provider}`)
|
||||
}
|
||||
|
||||
if (type === 'oauth') {
|
||||
oAuthSignin(provider, (error, oAuthSigninUrl) => {
|
||||
if (error) {
|
||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||
res
|
||||
.status(302)
|
||||
.setHeader('Location', `${baseUrl}/error?error=oAuthSignin`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
res.status(302).setHeader('Location', oAuthSigninUrl)
|
||||
res.end()
|
||||
return done()
|
||||
})
|
||||
if (type === 'oauth' && req.method === 'POST') {
|
||||
try {
|
||||
const oAuthSigninUrl = await oAuthSignin(provider, csrfToken)
|
||||
return res.redirect(oAuthSigninUrl)
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_OAUTH_ERROR', error)
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
|
||||
}
|
||||
} else if (type === 'email' && req.method === 'POST') {
|
||||
if (!adapter) {
|
||||
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
|
||||
res
|
||||
.status(302)
|
||||
.setHeader('Location', `${baseUrl}/error?error=Configuration`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
|
||||
}
|
||||
const { getUserByEmail } = await adapter.getAdapter(options)
|
||||
const { getUserByEmail } = await adapter.getAdapter(req.options)
|
||||
|
||||
// Note: Technically the part of the email address local mailbox element
|
||||
// (everything before the @ symbol) should be treated as 'case sensitive'
|
||||
@@ -58,54 +48,33 @@ export default async (req, res, options, done) => {
|
||||
const account = { id: provider.id, type: 'email', providerAccountId: email }
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
const signinCallbackResponse = await callbacks.signin(profile, account)
|
||||
|
||||
if (signinCallbackResponse === false) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
// If CSRF token not verified, send the user to sign in page, which will
|
||||
// display a new form with a valid token so that submitting it should work.
|
||||
//
|
||||
// Note: Adds ?csrf=true query string param to URL for debugging/tracking
|
||||
if (!csrfTokenVerified) {
|
||||
res
|
||||
.status(302)
|
||||
.setHeader(
|
||||
'Location',
|
||||
`${baseUrl}/signin?email=${encodeURIComponent(email)}&csrf=true`
|
||||
)
|
||||
res.end()
|
||||
return done()
|
||||
try {
|
||||
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
|
||||
if (signInCallbackResponse === false) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
|
||||
} else if (typeof signInCallbackResponse === 'string') {
|
||||
return res.redirect(signInCallbackResponse)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
|
||||
}
|
||||
// TODO: Remove in a future major release
|
||||
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
|
||||
return res.redirect(error)
|
||||
}
|
||||
|
||||
try {
|
||||
await emailSignin(email, provider, options)
|
||||
await emailSignin(email, provider, req.options)
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_EMAIL_ERROR', error)
|
||||
res
|
||||
.status(302)
|
||||
.setHeader('Location', `${baseUrl}/error?error=EmailSignin`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
|
||||
}
|
||||
|
||||
res
|
||||
.status(302)
|
||||
.setHeader(
|
||||
'Location',
|
||||
`${baseUrl}/verify-request?provider=${encodeURIComponent(
|
||||
provider.id
|
||||
)}&type=${encodeURIComponent(provider.type)}`
|
||||
)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
|
||||
provider.id
|
||||
)}&type=${encodeURIComponent(provider.type)}`)
|
||||
} else {
|
||||
// If provider not supported, redirect to sign in page
|
||||
res.status(302).setHeader('Location', `${baseUrl}/signin`)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(`${baseUrl}${basePath}/signin`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,29 @@
|
||||
// Handle requests to /api/auth/signout
|
||||
import cookie from '../lib/cookie'
|
||||
import * as cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
const { adapter, cookies, events, jwt, callbackUrl, csrfTokenVerified, baseUrl } = options
|
||||
const sessionMaxAge = options.session.maxAge
|
||||
const useJwtSession = options.session.jwt
|
||||
/** Handle requests to /api/auth/signout */
|
||||
export default async function signout (req, res) {
|
||||
const { adapter, cookies, events, jwt, callbackUrl } = req.options
|
||||
const useJwtSession = req.options.session.jwt
|
||||
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||
|
||||
if (!csrfTokenVerified) {
|
||||
// If a csrfToken was not verified with this request, send the user to
|
||||
// the signout page, as they should have a valid one now and clicking
|
||||
// the signout button should work.
|
||||
//
|
||||
// Note: Adds ?csrf=true query string param to URL for debugging/tracking.
|
||||
// @TODO Add support for custom signin URLs
|
||||
res.status(302).setHeader('Location', `${baseUrl}/signout?csrf=true`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
if (useJwtSession) {
|
||||
// Dispatch signout event
|
||||
try {
|
||||
const decodedJwt = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge })
|
||||
await dispatchEvent(events.signout, decodedJwt)
|
||||
const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken })
|
||||
await dispatchEvent(events.signOut, decodedJwt)
|
||||
} catch (error) {
|
||||
// Do nothing if decoding the JWT fails
|
||||
}
|
||||
} else {
|
||||
// Get session from database
|
||||
const { getSession, deleteSession } = await adapter.getAdapter(options)
|
||||
const { getSession, deleteSession } = await adapter.getAdapter(req.options)
|
||||
|
||||
try {
|
||||
// Dispatch signout event
|
||||
const session = await getSession(sessionToken)
|
||||
await dispatchEvent(events.signout, session)
|
||||
await dispatchEvent(events.signOut, session)
|
||||
} catch (error) {
|
||||
// Do nothing if looking up the session fails
|
||||
}
|
||||
@@ -56,7 +43,5 @@ export default async (req, res, options, done) => {
|
||||
maxAge: 0
|
||||
})
|
||||
|
||||
res.status(302).setHeader('Location', callbackUrl)
|
||||
res.end()
|
||||
return done()
|
||||
return res.redirect(callbackUrl)
|
||||
}
|
||||
|
||||
53
test/docker/app.yml
Normal file
53
test/docker/app.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
# Start test app with local databases inside the container.
|
||||
#
|
||||
# Note: Uses Docker Compose v2 as v3 doesn't currently support extends.
|
||||
# https://docs.docker.com/compose/compose-file/compose-file-v2/
|
||||
version: '2.3'
|
||||
|
||||
services:
|
||||
|
||||
app:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
# Set env vars in your current terminal or in .env in the root directory
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- NEXTAUTH_DATABASE_URL=${NEXTAUTH_DATABASE_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- NEXTAUTH_JWT_SESSIONS=${NEXTAUTH_JWT_SESSIONS}
|
||||
- NEXTAUTH_AUTH0_ID=${NEXTAUTH_AUTH0_ID}
|
||||
- NEXTAUTH_AUTH0_SECRET=${NEXTAUTH_AUTH0_SECRET}
|
||||
- NEXTAUTH_AUTH0_DOMAIN=${NEXTAUTH_AUTH0_DOMAIN}
|
||||
- NEXTAUTH_FACEBOOK_ID=${NEXTAUTH_FACEBOOK_ID}
|
||||
- NEXTAUTH_FACEBOOK_SECRET=${NEXTAUTH_FACEBOOK_SECRET}
|
||||
- NEXTAUTH_GITHUB_ID=${NEXTAUTH_GITHUB_ID}
|
||||
- NEXTAUTH_GITHUB_SECRET=${NEXTAUTH_GITHUB_SECRET}
|
||||
- NEXTAUTH_GOOGLE_ID=${NEXTAUTH_GOOGLE_ID}
|
||||
- NEXTAUTH_GOOGLE_SECRET=${NEXTAUTH_GOOGLE_SECRET}
|
||||
- NEXTAUTH_TWITTER_ID=${NEXTAUTH_TWITTER_ID}
|
||||
- NEXTAUTH_TWITTER_SECRET=${NEXTAUTH_TWITTER_SECRET}
|
||||
- NEXTAUTH_EMAIL_SERVER=${NEXTAUTH_EMAIL_SERVER}
|
||||
- NEXTAUTH_EMAIL_FROM=${NEXTAUTH_EMAIL_FROM}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
# mongo:
|
||||
# extends:
|
||||
# file: databases/mongo.yml
|
||||
# service: mongo
|
||||
|
||||
# mssql:
|
||||
# extends:
|
||||
# file: databases/mssql.yml
|
||||
# service: mssql
|
||||
|
||||
# mysql:
|
||||
# extends:
|
||||
# file: databases/mysql.yml
|
||||
# service: mysql
|
||||
|
||||
# postgres:
|
||||
# extends:
|
||||
# file: databases/postgres.yml
|
||||
# service: postgres
|
||||
5918
test/docker/app/package-lock.json
generated
Normal file
5918
test/docker/app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
test/docker/app/package.json
Normal file
18
test/docker/app/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "next-auth-test",
|
||||
"version": "0.0.1",
|
||||
"description": "Test application for NextAuth.js",
|
||||
"main": "",
|
||||
"scripts": {
|
||||
"dev": "next",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"author": "Iain Collins <me@iaincollins.com>",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"next": "^9.5.4",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1"
|
||||
}
|
||||
}
|
||||
26
test/docker/app/pages/_app.js
Normal file
26
test/docker/app/pages/_app.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Provider } from 'next-auth/client'
|
||||
|
||||
export default function App ({ Component, pageProps }) {
|
||||
return (
|
||||
<Provider
|
||||
options={{
|
||||
// Client Max Age controls how often the useSession in the client should
|
||||
// contact the server to sync the session state. Value in seconds.
|
||||
// e.g.
|
||||
// * 0 - Disabled (always use cache value)
|
||||
// * 60 - Sync session state with server if it's older than 60 seconds
|
||||
clientMaxAge: 0,
|
||||
// Keep Alive tells windows / tabs that are signed in to keep sending
|
||||
// a keep alive request (which extends the current session expiry) to
|
||||
// prevent sessions in open windows from expiring. Value in seconds.
|
||||
//
|
||||
// Note: If a session has expired when keep alive is triggered, all open
|
||||
// windows / tabs will be updated to reflect the user is signed out.
|
||||
keepAlive: 0
|
||||
}}
|
||||
session={pageProps.session}
|
||||
>
|
||||
<Component {...pageProps} />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
118
test/docker/app/pages/api/auth/[...nextauth].js
Normal file
118
test/docker/app/pages/api/auth/[...nextauth].js
Normal file
@@ -0,0 +1,118 @@
|
||||
import NextAuth from 'next-auth'
|
||||
import Providers from 'next-auth/providers'
|
||||
|
||||
// For more information on each option (and a full list of options) go to
|
||||
// https://next-auth.js.org/configuration/options
|
||||
const options = {
|
||||
// https://next-auth.js.org/configuration/providers
|
||||
providers: [
|
||||
Providers.Email({
|
||||
server: process.env.NEXTAUTH_EMAIL_SERVER,
|
||||
from: process.env.NEXTAUTH_EMAIL_FROM
|
||||
}),
|
||||
Providers.Apple({
|
||||
clientId: process.env.NEXTAUTH_APPLE_ID,
|
||||
clientSecret: {
|
||||
appleId: process.env.NEXTAUTH_APPLE_ID,
|
||||
teamId: process.env.NEXTAUTH_APPLE_TEAM_ID,
|
||||
privateKey: process.env.NEXTAUTH_APPLE_PRIVATE_KEY,
|
||||
keyId: process.env.NEXTAUTH_APPLE_KEY_ID
|
||||
}
|
||||
}),
|
||||
Providers.Auth0({
|
||||
clientId: process.env.NEXTAUTH_AUTH0_ID,
|
||||
clientSecret: process.env.NEXTAUTH_AUTH0_SECRET,
|
||||
domain: process.env.NEXTAUTH_AUTH0_DOMAIN
|
||||
}),
|
||||
Providers.Facebook({
|
||||
clientId: process.env.NEXTAUTH_FACEBOOK_ID,
|
||||
clientSecret: process.env.NEXTAUTH_FACEBOOK_SECRET
|
||||
}),
|
||||
Providers.GitHub({
|
||||
clientId: process.env.NEXTAUTH_GITHUB_ID,
|
||||
clientSecret: process.env.NEXTAUTH_GITHUB_SECRET
|
||||
}),
|
||||
Providers.Google({
|
||||
clientId: process.env.NEXTAUTH_GOOGLE_ID,
|
||||
clientSecret: process.env.NEXTAUTH_GOOGLE_SECRET
|
||||
}),
|
||||
Providers.Twitter({
|
||||
clientId: process.env.NEXTAUTH_TWITTER_ID,
|
||||
clientSecret: process.env.NEXTAUTH_TWITTER_SECRET
|
||||
})
|
||||
],
|
||||
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
|
||||
// https://next-auth.js.org/configuration/database
|
||||
//
|
||||
// Notes:
|
||||
// * You must to install an appropriate node_module for your database
|
||||
// * The Email provider requires a database (OAuth providers do not)
|
||||
database: process.env.NEXTAUTH_DATABASE_URL,
|
||||
|
||||
// The secret should be set to a reasonably long random string.
|
||||
// It is used to sign cookies and to sign and encrypt JSON Web Tokens, unless
|
||||
// a seperate secret is defined explicitly for encrypting the JWT.
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
|
||||
session: {
|
||||
// Use JSON Web Tokens for session instead of database sessions.
|
||||
// This option can be used with or without a database for users/accounts.
|
||||
// Note: `jwt` is automatically set to `true` if no database is specified.
|
||||
jwt: true
|
||||
|
||||
// Seconds - How long until an idle session expires and is no longer valid.
|
||||
// maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
|
||||
// Seconds - Throttle how frequently to write to database to extend a session.
|
||||
// Use it to limit write operations. Set to 0 to always update the database.
|
||||
// Note: This option is ignored if using JSON Web Tokens
|
||||
// updateAge: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
|
||||
// JSON Web tokens are only used for sessions if the `jwt: true` session
|
||||
// option is set - or by default if no database is specified.
|
||||
// https://next-auth.js.org/configuration/options#jwt
|
||||
jwt: {
|
||||
// A secret to use for key generation (you should set this explicitly)
|
||||
// secret: 'INp8IvdIyeMcoGAgFGoA61DdBglwwSqnXJZkgz8PSnw',
|
||||
|
||||
// Set to true to use encryption (default: false)
|
||||
// encryption: true,
|
||||
|
||||
// You can define your own encode/decode functions for signing and encryption
|
||||
// if you want to override the default behaviour.
|
||||
// async encode({ secret, token, maxAge }) {},
|
||||
// async decode({ secret, token, maxAge }) {},
|
||||
},
|
||||
|
||||
// You can define custom pages to override the built-in pages.
|
||||
// The routes shown here are the default URLs that will be used when a custom
|
||||
// pages is not specified for that route.
|
||||
// https://next-auth.js.org/configuration/pages
|
||||
pages: {
|
||||
// signIn: '/api/auth/signin', // Displays signin buttons
|
||||
// signOut: '/api/auth/signout', // Displays form with sign out button
|
||||
// error: '/api/auth/error', // Error code passed in query string as ?error=
|
||||
// verifyRequest: '/api/auth/verify-request', // Used for check email page
|
||||
// newUser: null // If set, new users will be directed here on first sign in
|
||||
},
|
||||
|
||||
// Callbacks are asynchronous functions you can use to control what happens
|
||||
// when an action is performed.
|
||||
// https://next-auth.js.org/configuration/callbacks
|
||||
callbacks: {
|
||||
// async signIn(user, account, profile) { return Promise.resolve(true) },
|
||||
// async redirect(url, baseUrl) { return Promise.resolve(baseUrl) },
|
||||
// async session(session, user) { return Promise.resolve(session) },
|
||||
// async jwt(token, user, account, profile, isNewUser) { return Promise.resolve(token) }
|
||||
},
|
||||
|
||||
// Events are useful for logging
|
||||
// https://next-auth.js.org/configuration/events
|
||||
events: { },
|
||||
|
||||
// Enable debug messages in the console if you are having problems
|
||||
debug: false
|
||||
}
|
||||
|
||||
export default (req, res) => NextAuth(req, res, options)
|
||||
3
test/docker/app/pages/api/env.js
Normal file
3
test/docker/app/pages/api/env.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export default (req, res) => {
|
||||
res.send(JSON.stringify(process.env, null, 2))
|
||||
}
|
||||
8
test/docker/app/pages/api/jwt.js
Normal file
8
test/docker/app/pages/api/jwt.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import jwt from 'next-auth/jwt'
|
||||
|
||||
const secret = process.env.SECRET
|
||||
|
||||
export default async (req, res) => {
|
||||
const token = await jwt.getToken({ req, secret })
|
||||
res.send(JSON.stringify(token, null, 2))
|
||||
}
|
||||
11
test/docker/app/pages/api/protected.js
Normal file
11
test/docker/app/pages/api/protected.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getSession } from 'next-auth/client'
|
||||
|
||||
export default async (req, res) => {
|
||||
const session = await getSession({ req })
|
||||
|
||||
if (session) {
|
||||
res.send({ content: 'Protected content.' })
|
||||
} else {
|
||||
res.send({ content: 'Unprotected content.' })
|
||||
}
|
||||
}
|
||||
6
test/docker/app/pages/api/session.js
Normal file
6
test/docker/app/pages/api/session.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getSession } from 'next-auth/client'
|
||||
|
||||
export default async (req, res) => {
|
||||
const session = await getSession({ req })
|
||||
res.send(JSON.stringify(session, null, 2))
|
||||
}
|
||||
5
test/docker/app/pages/api/version.js
Normal file
5
test/docker/app/pages/api/version.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import Package from 'next-auth/package.json'
|
||||
|
||||
export default (req, res) => {
|
||||
res.send(Package.version)
|
||||
}
|
||||
7
test/docker/app/pages/index.js
Normal file
7
test/docker/app/pages/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function IndexPage () {
|
||||
return (
|
||||
<div id='nextauth-test-app'>
|
||||
<h1>NextAuth.js Test App</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
test/docker/app/pages/test.js
Normal file
13
test/docker/app/pages/test.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useSession } from 'next-auth/client'
|
||||
|
||||
export default function TestPage () {
|
||||
const [ session, loading ] = useSession()
|
||||
|
||||
return (
|
||||
<div id='nextauth-test-page'>
|
||||
<h1>NextAuth.js Test Page</h1>
|
||||
{session && <p id="nextauth-signed-in">Signed in</p>}
|
||||
{!session && !loading && <p id="nextauth-signed-out">Signed out</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
test/docker/databases.yml
Normal file
43
test/docker/databases.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
# Start Mongo, MSSQL, MySQL and Postgres databases on the current host running
|
||||
# on their respective default ports. This is intended for developer convenience
|
||||
# to make it easier to develop and test features manually.
|
||||
#
|
||||
# Note: Uses Docker Compose v2 as v3 doesn't currently support extends.
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
mongo:
|
||||
extends:
|
||||
file: databases/mongo.yml
|
||||
service: mongo
|
||||
ports:
|
||||
- "27017:27017"
|
||||
|
||||
mssql:
|
||||
extends:
|
||||
file: databases/mssql.yml
|
||||
service: mssql
|
||||
ports:
|
||||
- "1433:1433"
|
||||
|
||||
mysql:
|
||||
extends:
|
||||
file: databases/mysql.yml
|
||||
service: mysql
|
||||
ports:
|
||||
- "3306:3306"
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: databases/postgres.yml
|
||||
service: postgres
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
fauna:
|
||||
extends:
|
||||
file: databases/fauna.yml
|
||||
service: fauna
|
||||
ports:
|
||||
- 8443:8443
|
||||
7
test/docker/databases/fauna.yml
Normal file
7
test/docker/databases/fauna.yml
Normal file
@@ -0,0 +1,7 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
fauna:
|
||||
image: fauna/faunadb
|
||||
restart: always
|
||||
@@ -9,5 +9,3 @@ services:
|
||||
MONGODB_USERNAME: nextauth
|
||||
MONGODB_PASSWORD: password
|
||||
MONGODB_DATABASE: nextauth
|
||||
ports:
|
||||
- "27017:27017"
|
||||
13
test/docker/databases/mssql.yml
Normal file
13
test/docker/databases/mssql.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: "2"
|
||||
|
||||
services:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2017-latest
|
||||
restart: always
|
||||
environment:
|
||||
SA_PASSWORD: Pa55w0rd # minimum password complexity
|
||||
ACCEPT_EULA: Y
|
||||
# WARN: command overrides, default image start sequence, start.sh starts 'sql-server'
|
||||
command: '/var/setup/start.sh'
|
||||
volumes:
|
||||
- ./mssql:/var/setup # mount setup files
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user