mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
306 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
388a1c4393 | ||
|
|
5c9aaeae43 | ||
|
|
0eaec78399 | ||
|
|
9349ca3b34 | ||
|
|
52f2dd5c32 | ||
|
|
6eeed21872 | ||
|
|
929a7e5840 | ||
|
|
758b3a88d0 | ||
|
|
4477bd6c80 | ||
|
|
370d2cc121 | ||
|
|
e6c4d6e737 | ||
|
|
5bd2936b90 | ||
|
|
41b6bb7000 | ||
|
|
fd8818c400 | ||
|
|
5929de4249 | ||
|
|
07c6cbccc0 | ||
|
|
f20843fcb1 | ||
|
|
eb6a7a45a5 | ||
|
|
75638db676 | ||
|
|
76fcfa53f4 | ||
|
|
708ec9fbe7 | ||
|
|
ca98750604 | ||
|
|
eb49a47b0a | ||
|
|
ac2fc85d18 | ||
|
|
4ee2b4453d | ||
|
|
0542b6a24a | ||
|
|
ca0053b6cd | ||
|
|
a902b98cfd | ||
|
|
99ff0ffd3d | ||
|
|
5bb1830c9b | ||
|
|
6055ecda24 | ||
|
|
8229a3b420 | ||
|
|
20723fd2b4 | ||
|
|
31d9363954 | ||
|
|
515facf39f | ||
|
|
173ce2aae6 | ||
|
|
935c4f2f82 | ||
|
|
b893b6485b | ||
|
|
6f5b3bd213 | ||
|
|
14fd1c9b40 | ||
|
|
c695ca98e5 | ||
|
|
7649eb7aed | ||
|
|
0119622d18 | ||
|
|
15c6781083 | ||
|
|
574387e09f | ||
|
|
aa1f29dc53 | ||
|
|
bb9a26d4fc | ||
|
|
4b036d3beb | ||
|
|
6dd8dc325e | ||
|
|
22005c7465 | ||
|
|
0686b5ff32 | ||
|
|
f495ecda3a | ||
|
|
1f4bc91d87 | ||
|
|
e54bf254cb | ||
|
|
fc2d3adc1f | ||
|
|
18c99616b3 | ||
|
|
70e3ab7e89 | ||
|
|
9b25a2d245 | ||
|
|
966aa8245d | ||
|
|
d220587018 | ||
|
|
c665631191 | ||
|
|
6032a99a90 | ||
|
|
d130251b41 | ||
|
|
4dd8d3160b | ||
|
|
667fe8cf50 | ||
|
|
554c32c6f1 | ||
|
|
bdc0e8e16f | ||
|
|
3b0527add8 | ||
|
|
2b494357e5 | ||
|
|
7a0624b8db | ||
|
|
bb8a2c94cc | ||
|
|
f3532ebef2 | ||
|
|
c5fad1b933 | ||
|
|
5e9f392ba8 | ||
|
|
f1ed5c1e97 | ||
|
|
5946710fe8 | ||
|
|
5cf0056e69 | ||
|
|
ac12d6a6e2 | ||
|
|
cc0c15e37c | ||
|
|
9a630dcb01 | ||
|
|
d30b112d71 | ||
|
|
5fded4256d | ||
|
|
d2fdfa7528 | ||
|
|
55c3acab9a | ||
|
|
dc903f8059 | ||
|
|
156c8e1e97 | ||
|
|
78ba85e74d | ||
|
|
6d41089d48 | ||
|
|
64b23d484d | ||
|
|
5f65e8c30d | ||
|
|
49d560fa24 | ||
|
|
36c469660e | ||
|
|
416785941b | ||
|
|
799bd2dfaa | ||
|
|
c0ccbc9274 | ||
|
|
0918cdbfa0 | ||
|
|
077f60e7c4 | ||
|
|
96900e77f6 | ||
|
|
f43343bd2c | ||
|
|
8e69940ae6 | ||
|
|
0d825bbc39 | ||
|
|
35123f005a | ||
|
|
b25730fbd5 | ||
|
|
585be4ce4a | ||
|
|
3b5d4b6925 | ||
|
|
39471e9bae | ||
|
|
50039e5a6b | ||
|
|
cb31f9e554 | ||
|
|
f21fb0f46d | ||
|
|
13c6801c45 | ||
|
|
1a1b0ffdc6 | ||
|
|
a638e2b27a | ||
|
|
3ce64da78f | ||
|
|
5537514b4f | ||
|
|
42363101f8 | ||
|
|
bdafc4a2f7 | ||
|
|
bdb1216119 | ||
|
|
e509c28a4f | ||
|
|
4f5954938a | ||
|
|
267a166895 | ||
|
|
e655e3c550 | ||
|
|
6ea00f44dd | ||
|
|
843c258dd3 | ||
|
|
c6c94b1805 | ||
|
|
315d75e40b | ||
|
|
f8bfe0c613 | ||
|
|
50b9743bb6 | ||
|
|
9c9abf19a3 | ||
|
|
51f9f6fe6f | ||
|
|
0c2c4cab69 | ||
|
|
ceb35cd036 | ||
|
|
f9d0719ec4 | ||
|
|
40f36e5ee9 | ||
|
|
e9903d5391 | ||
|
|
a5bc38e61c | ||
|
|
6df7322493 | ||
|
|
0495057458 | ||
|
|
8a2ee7cbce | ||
|
|
a465e2cda8 | ||
|
|
81c22f81ca | ||
|
|
4e4457f3ce | ||
|
|
e993bc4f2a | ||
|
|
c4fe49b0af | ||
|
|
5f211c8d0a | ||
|
|
3e41381a52 | ||
|
|
93488846e2 | ||
|
|
9609d44638 | ||
|
|
59403ec607 | ||
|
|
5f0f403b50 | ||
|
|
fdae191116 | ||
|
|
15424d2d03 | ||
|
|
9d2d7133a1 | ||
|
|
f50013899a | ||
|
|
beb2d08260 | ||
|
|
b39d491df3 | ||
|
|
7560d4ba80 | ||
|
|
e90244b167 | ||
|
|
a72aef7a86 | ||
|
|
39e97c3b96 | ||
|
|
e7d7a7ccab | ||
|
|
8b173efe96 | ||
|
|
f53a7f3b85 | ||
|
|
62f5d7ebe1 | ||
|
|
fd6fceb884 | ||
|
|
74a5f459f5 | ||
|
|
7b38af81cf | ||
|
|
401df2c177 | ||
|
|
ffd9691cd0 | ||
|
|
e7ae32f618 | ||
|
|
97fadb0d9f | ||
|
|
86f072bf4b | ||
|
|
981984b562 | ||
|
|
1e9053d879 | ||
|
|
cb1ce73c92 | ||
|
|
93054578c9 | ||
|
|
d112800b98 | ||
|
|
c8bf342d8b | ||
|
|
63ceb1a260 | ||
|
|
ca519b69ce | ||
|
|
2f16d8448d | ||
|
|
74b334f7ad | ||
|
|
d5a231f51b | ||
|
|
9b24e216fa | ||
|
|
a944870eb2 | ||
|
|
bc6fd4aa32 | ||
|
|
4a00d5aca5 | ||
|
|
c55cb526f7 | ||
|
|
70a728f15b | ||
|
|
e7c9c844dc | ||
|
|
cf8e6980be | ||
|
|
7cd537d58d | ||
|
|
7ad11f73cd | ||
|
|
82ac943e3e | ||
|
|
420bb9a74c | ||
|
|
4c32727b37 | ||
|
|
339f618685 | ||
|
|
2a8337e67c | ||
|
|
bd50714759 | ||
|
|
db9ef09d1d | ||
|
|
3bb4e0ca6f | ||
|
|
b4886295ac | ||
|
|
ef455dcf06 | ||
|
|
5afa4f6e2b | ||
|
|
50678d73bd | ||
|
|
6d7066e4db | ||
|
|
52eb11b385 | ||
|
|
b176c15405 | ||
|
|
021fdbcf1b | ||
|
|
d7d9988cd8 | ||
|
|
e8baee1774 | ||
|
|
79179dad71 | ||
|
|
c8de8a1182 | ||
|
|
a2cfcef0aa | ||
|
|
28d220a42b | ||
|
|
26a8b20459 | ||
|
|
84e0ddf241 | ||
|
|
6e3a6ba287 | ||
|
|
d6e7b09ff7 | ||
|
|
daca296df4 | ||
|
|
dbab5a3505 | ||
|
|
8aa4045651 | ||
|
|
eb9561edab | ||
|
|
332182a67f | ||
|
|
d7a2cde57e | ||
|
|
bb04645a93 | ||
|
|
d25493ae79 | ||
|
|
8522628a11 | ||
|
|
875ecaeb06 | ||
|
|
25c83b2914 | ||
|
|
8a516904b8 | ||
|
|
df4c71496b | ||
|
|
026bef6f60 | ||
|
|
2b168e183b | ||
|
|
c86ea5e9dc | ||
|
|
966577fc02 | ||
|
|
d0d3af5f12 | ||
|
|
c62617532f | ||
|
|
fc28374f88 | ||
|
|
6ec9d8e9d0 | ||
|
|
26d41d4a2b | ||
|
|
b6c2befba7 | ||
|
|
0d96a7e9e5 | ||
|
|
3006161bce | ||
|
|
c653a1cc72 | ||
|
|
301f048ce3 | ||
|
|
3ac6666bee | ||
|
|
73a5be5d6c | ||
|
|
ed6328679a | ||
|
|
8eb9c4822e | ||
|
|
8a9e2305c8 | ||
|
|
7ef2a2ec93 | ||
|
|
67d49fe483 | ||
|
|
cc2753efd5 | ||
|
|
d0a403e56a | ||
|
|
ab9d1d0a91 | ||
|
|
c85ad74508 | ||
|
|
2dca9308e9 | ||
|
|
494a267527 | ||
|
|
4c163d54ca | ||
|
|
b9853b362b | ||
|
|
121e978d76 | ||
|
|
b9142217a9 | ||
|
|
74d67dd801 | ||
|
|
121ed4a58e | ||
|
|
cf903ca82e | ||
|
|
2f61795697 | ||
|
|
d5257fe1db | ||
|
|
822fbee0c4 | ||
|
|
937f9cdfda | ||
|
|
2bb9355933 | ||
|
|
57a9021107 | ||
|
|
71fecfb1f2 | ||
|
|
1b374817f0 | ||
|
|
eee927a6cd | ||
|
|
0fabfa4ef9 | ||
|
|
57bf54c28d | ||
|
|
9bbc9100ab | ||
|
|
e6cd78d71b | ||
|
|
3d66b90cf8 | ||
|
|
ebfb02bd12 | ||
|
|
2032ff1276 | ||
|
|
08582aad83 | ||
|
|
c9944820c6 | ||
|
|
0697609dd0 | ||
|
|
39d3689c22 | ||
|
|
43023293ea | ||
|
|
91f319bc5f | ||
|
|
f847488643 | ||
|
|
731e227cb6 | ||
|
|
f2aafac40c | ||
|
|
5bff4cb07f | ||
|
|
06ef47cc40 | ||
|
|
3e0e4ecb5d | ||
|
|
651f3c9887 | ||
|
|
cfbe24fc24 | ||
|
|
9432cfda90 | ||
|
|
981adaae24 | ||
|
|
ec3da81887 | ||
|
|
d150a7911c | ||
|
|
018738bcc0 | ||
|
|
e37e20faf5 | ||
|
|
4bf13394f1 | ||
|
|
3dad0cc849 | ||
|
|
ea69d1e904 | ||
|
|
b666cde7a7 | ||
|
|
e3784bba9d |
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report a defect with the software
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the defect is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior.
|
||||
|
||||
Include example code (or link to public repository) which can be used to reproduce the behaviour.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots or error logs**
|
||||
If applicable, add screenshots or error logs to help explain the problem.
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
**Documentation feedback**
|
||||
*Documentation refers to searching through [online documentation](https://next-auth.js.org), code comments and issue history. The example project refers to [next-auth-example](https://github.com/iaincollins/next-auth-example).*
|
||||
|
||||
* [ ] Found the documentation helpful
|
||||
* [ ] Found documentation but was incomplete
|
||||
* [ ] Could not find relevant documentation
|
||||
* [ ] Found the example project helpful
|
||||
* [ ] Did not find the example project helpful
|
||||
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
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.
|
||||
|
||||
**Purpose of proposed feature**
|
||||
A clear and concise description description of why this feature is necessary and what problems it solves.
|
||||
|
||||
**Detail about proposed feature**
|
||||
A detailed description of how the proposal might work (if you have one).
|
||||
|
||||
**Potential problems**
|
||||
Describe any potential problems or potential limitations or caveats that might apply to the proposed solution.
|
||||
|
||||
**Describe any alternatives you've considered**
|
||||
A clear and concise description of any alternative options you've considered.
|
||||
|
||||
**Additional context**
|
||||
Any other context, screenshots, etc.
|
||||
|
||||
*Please indicate if you are willing and able to help implement the proposed feature.*
|
||||
23
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/question.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: Question
|
||||
about: Ask for information or support
|
||||
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.*
|
||||
|
||||
**Your question**
|
||||
A clear and concise question.
|
||||
|
||||
**What are you trying to do**
|
||||
A description of what you are trying to do.
|
||||
|
||||
**Documentation feedback**
|
||||
*Documentation refers to searching through [online documentation](https://next-auth.js.org), code comments and issue history. The example project refers to [next-auth-example](https://github.com/iaincollins/next-auth-example).*
|
||||
|
||||
* [ ] Found the documentation helpful
|
||||
* [ ] Found documentation but was incomplete
|
||||
* [ ] Could not find relevant documentation
|
||||
* [ ] Found the example project helpful
|
||||
* [ ] Did not find the example project helpful
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -1,3 +1,28 @@
|
||||
.next
|
||||
.env
|
||||
node_modules
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Docusaurus
|
||||
www/build
|
||||
76
CODE_OF_CONDUCT.md
Normal file
76
CODE_OF_CONDUCT.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting me@iaincollins.com. All complaints will be reviewed and
|
||||
investigated and will result in a response that is deemed necessary and
|
||||
appropriate to the circumstances. The project team is obligated to maintain
|
||||
confidentiality with regard to the reporter of an incident. Further details of
|
||||
specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
108
CONTRIBUTING.md
Normal file
108
CONTRIBUTING.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Contributing guide
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
* 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
|
||||
|
||||
## Rebasing
|
||||
|
||||
*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
|
||||
|
||||
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
|
||||
cd next-auth/
|
||||
|
||||
2. Install packages and run the build command:
|
||||
|
||||
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:
|
||||
|
||||
cd ../your-application
|
||||
npm link ../next-auth
|
||||
|
||||
That's it!
|
||||
|
||||
Notes: You may need to repeat both `npm link` steps if you install / update additional dependancies 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
|
||||
|
||||
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.
|
||||
|
||||
cd next-auth/
|
||||
npm run watch
|
||||
|
||||
If you are working on `next-auth/src/client/index.js` hot reloading will work as normal in your Next.js app.
|
||||
|
||||
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**.
|
||||
|
||||
### Databases
|
||||
|
||||
Included is a Docker Compose file that starts up MySQL, Postgres and MongoDB databases on localhost.
|
||||
|
||||
It will use port 3306, 5432 and 27017 on localhost respectively; it will not work if are running existing databases on localhost.
|
||||
|
||||
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.
|
||||
|
||||
### Testing
|
||||
|
||||
Tests can be run with `npm run test`.
|
||||
|
||||
Automated tests are currently crude and limited in functionality, but improvements are in development.
|
||||
|
||||
Currently to run tests you need to first have started local test databases (e.g. using `npm run db:start`).
|
||||
|
||||
The databases can take a few seconds to start up, so you might need to give it a minute before running the tests.
|
||||
@@ -1,6 +1,6 @@
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2018, Iain Collins
|
||||
Copyright (c) 2018-2020, 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
|
||||
|
||||
289
README.md
289
README.md
@@ -1,221 +1,108 @@
|
||||
# NextAuth
|
||||
# NextAuth.js
|
||||
|
||||
## About NextAuth
|
||||
## Overview
|
||||
|
||||
NextAuth is an authentication library for Next.js projects.
|
||||
NextAuth.js is a complete open source authentication solution for [Next.js](http://nextjs.org/) applications.
|
||||
|
||||
The NextAuth library uses Express and Passport, the most commonly used authentication library for Node.js, to provide support for signing in with email and with services like Facebook, Google and Twitter.
|
||||
It is designed from the ground up to support Next.js and Serverless.
|
||||
|
||||
NextAuth adds Cross Site Request Forgery (CSRF) tokens and HTTP Only cookies, supports universal rendering and does not require client side JavaScript.
|
||||
[Follow the examples](https://next-auth.js.org/getting-started/example) to see how easy it is to use NextAuth.js for authentication.
|
||||
|
||||
It adds session support without using client side accessible session tokens, providing protection against Cross Site Scripting (XSS) and session hijacking, while leveraging localStorage where available to cache non-critical session state for optimal performance in Single Page Apps.
|
||||
Install: `npm i next-auth`
|
||||
|
||||
The NextAuth comes with a client library, designed to work with React pages powered by Next.js to easily add universal session support to sites.
|
||||
See [next-auth.js.org](https://next-auth.js.org) for more information and documentation.
|
||||
|
||||
It contains an [example site](https://github.com/iaincollins/next-auth/tree/master/example) that shows how to use it in a simple project. It's also used in the [nextjs-starter.now.sh](https://nextjs-starter.now.sh) project, which provides a more complete example with a live demo.
|
||||
## Features
|
||||
|
||||
Note: As of version 1.5 NextAuth is also compatible non-Next.js React projects, just pass `null` instead of a nextApp instance when calling `nextAuth()`.
|
||||
### Authentication
|
||||
|
||||
You will need to handle setting up routes before and after initialising NextAuth if you are not using Next.js. NextAuth lets you pass an instance of express as 'expressApp' option (and returns it in the response).
|
||||
* 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)
|
||||
* Supports email / passwordless authentication
|
||||
* Supports both JSON Web Tokens and database sessions
|
||||
|
||||
## Example Client Usage
|
||||
### Own your own data
|
||||
|
||||
````javascript
|
||||
* 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)
|
||||
* Works great with databases from popular hosting providers
|
||||
* 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
|
||||
* 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.
|
||||
|
||||
## Example
|
||||
|
||||
### Add API Route
|
||||
|
||||
```javascript
|
||||
import NextAuth from 'next-auth'
|
||||
import Providers from 'next-auth/providers'
|
||||
|
||||
const options = {
|
||||
site: 'https://example.com'
|
||||
providers: [
|
||||
// OAuth authentication providers
|
||||
Providers.Apple({
|
||||
clientId: process.env.APPLE_ID,
|
||||
clientSecret: process.env.APPLE_SECRET
|
||||
}),
|
||||
Providers.Google({
|
||||
clientId: process.env.GOOGLE_ID,
|
||||
clientSecret: process.env.GOOGLE_SECRET
|
||||
}),
|
||||
// Sign in with passwordless email link
|
||||
Providers.Email({
|
||||
server: process.env.MAIL_SERVER,
|
||||
from: '<no-reply@example.com>'
|
||||
}),
|
||||
],
|
||||
// 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 { NextAuth } from 'next-auth/client'
|
||||
import {
|
||||
useSession,
|
||||
signin,
|
||||
signout
|
||||
} from 'next-auth/client'
|
||||
|
||||
export default class extends React.Component {
|
||||
static async getInitialProps({req}) {
|
||||
return {
|
||||
session: await NextAuth.init({req})
|
||||
}
|
||||
}
|
||||
render() {
|
||||
if (this.props.session.user) {
|
||||
return(
|
||||
<div>
|
||||
<p>You are logged in as {this.props.session.user.name || this.props.session.user.email}.</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return(
|
||||
<div>
|
||||
<p>You are not logged in.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
export default () => {
|
||||
const [ session, loading ] = useSession()
|
||||
|
||||
return <p>
|
||||
{!session && <>
|
||||
Not signed in <br/>
|
||||
<button onClick={signin}>Sign in</button>
|
||||
</>}
|
||||
{session && <>
|
||||
Signed in as {session.user.email} <br/>
|
||||
<button onClick={signout}>Sign out</button>
|
||||
</>}
|
||||
</p>
|
||||
}
|
||||
````
|
||||
```
|
||||
|
||||
See [Documentation for the NextAuth Client](https://github.com/iaincollins/next-auth/blob/master/README-CLIENT.md) for more information on how to interact with the client.
|
||||
## Acknowledgement
|
||||
|
||||
## Routes
|
||||
[NextAuth.js 2.0 is possible thanks to its contributors.](https://next-auth.js.org/contributors)
|
||||
|
||||
NextAuth adds a number of routes under `/auth':
|
||||
## Getting started
|
||||
|
||||
* POST `/auth/email/signin` - Request Sign In Token
|
||||
* GET `/auth/email/signin/:token` - Validate Sign In Token
|
||||
* POST `/auth/signout` - Sign Out
|
||||
* GET `/auth/csrf` - CSRF endpoint for Single Page Apps
|
||||
* GET `/auth/session` - Session endpoint for Single Page Apps
|
||||
* GET `/auth/linked` - View linked accounts for Single Page Apps
|
||||
[Follow the examples to get started.](https://next-auth.js.org/getting-started/example)
|
||||
|
||||
All POST routes request must include a CSRF token.
|
||||
## Contributing
|
||||
|
||||
CSRF, Session and Linked Account endpoints are provided for Single Page Apps.
|
||||
|
||||
Note: Session Tokens are stored in HTTP Only cookies to prevent session hijacking and protect against Cross Site Scripting (XSS) attacks. Only HTTP requests that originate from the original domain are able to read from them.
|
||||
|
||||
In addition, it will add the following routes for each oAuth provider currently configured:
|
||||
|
||||
* GET `/auth/oauth/${provider}`
|
||||
* GET `/auth/oauth/${provider}/callback`
|
||||
* POST `/auth/oauth/${provider}/unlink`
|
||||
|
||||
You can see which routes are configured and the callback URLs defined for them via this route:
|
||||
|
||||
* GET `/auth/providers`
|
||||
|
||||
It will return a JSON object with the current oAuth provider configuration:
|
||||
|
||||
````json
|
||||
{
|
||||
"Facebook": {
|
||||
"signin": "/auth/oauth/facebook",
|
||||
"callback": "/auth/oauth/facebook/callback"
|
||||
},
|
||||
"Google": {
|
||||
"signin": "/auth/oauth/google",
|
||||
"callback": "/auth/oauth/google/callback"
|
||||
},
|
||||
"Twitter": {
|
||||
"signin": "/auth/oauth/twitter",
|
||||
"callback": "/auth/oauth/twitter/callback"
|
||||
}
|
||||
}
|
||||
````
|
||||
|
||||
Note: The `/auth` prefix is configurable via an option in the module, but it is currently hard coded in the client component, so you probably don't want to change it. It will be configurable in future releases.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Create an `index.js` file in the root of your Next.js project containing the following:
|
||||
|
||||
````javascript
|
||||
const next = require('next')
|
||||
const nextAuth = require('next-auth')
|
||||
const nextAuthConfig = require('./next-auth.config')
|
||||
|
||||
require('dotenv').load()
|
||||
|
||||
const nextApp = next({
|
||||
dir: '.',
|
||||
dev: (process.env.NODE_ENV === 'development')
|
||||
})
|
||||
|
||||
nextApp.prepare()
|
||||
.then(async () => {
|
||||
const nextAuthOptions = await nextAuthConfig()
|
||||
const nextAuthApp = await nextAuth(nextApp, nextAuthOptions)
|
||||
console.log(`Ready on http://localhost:${process.env.PORT || 3000}`)
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('An error occurred, unable to start the server')
|
||||
console.log(err)
|
||||
})
|
||||
````
|
||||
|
||||
You can add the following to your `package.json` file to start the project:
|
||||
|
||||
````json
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development node index.js",
|
||||
"build": "next build",
|
||||
"start": "node index.js"
|
||||
}
|
||||
````
|
||||
|
||||
## Pages
|
||||
|
||||
You will need to create following pages under `./pages/auth` in your project:
|
||||
|
||||
* index.js – Sign In and Link/Unlink accounts
|
||||
* error.js – If an authentication error occurs
|
||||
* check-email.js – 'Check your email' messsage
|
||||
* callback.js – Callback page; updates local session on sign in / sign out
|
||||
|
||||
You can [find examples of these](https://github.com/iaincollins/next-auth/tree/master/example) included which you can copy and paste into your project.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration can be split across three files to make it easier to understand and manage.
|
||||
|
||||
You can copy over the following configuration files into the root of your project to get started:
|
||||
|
||||
* [next-auth.config.js](https://github.com/iaincollins/next-auth/tree/master/example/next-auth.config.js)
|
||||
* [next-auth.functions.js](https://github.com/iaincollins/next-auth/tree/master/example/next-auth.functions.js)
|
||||
* [next-auth.providers.js](https://github.com/iaincollins/next-auth/tree/master/example/next-auth.providers.js)
|
||||
|
||||
|
||||
You can also add a **.env** file to the root of the project as a place to specify configuration options. The provided example files for NextAuth will use one if there is is one.
|
||||
|
||||
````
|
||||
SERVER_URL=http://localhost:3000
|
||||
MONGO_URI=mongodb://localhost:27017/my-database
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRET=
|
||||
GOOGLE_ID=
|
||||
GOOGLE_SECRET=
|
||||
TWITTER_KEY=
|
||||
TWITTER_SECRET=
|
||||
EMAIL_FROM=username@gmail.com
|
||||
EMAIL_SERVER=smtp.gmail.com
|
||||
EMAIL_PORT=465
|
||||
EMAIL_USERNAME=username@gmail.com
|
||||
EMAIL_PASSWORD=
|
||||
````
|
||||
|
||||
### next-auth.config.js
|
||||
|
||||
Basic configuration of NextAuth is handled in **next-auth.config.js**.
|
||||
|
||||
It is also where the **next-auth.functions.js** and **next-auth.providers.js** files are loaded.
|
||||
|
||||
### next-auth.functions.js
|
||||
|
||||
Methods for user management and sending email are defined in **next-auth.functions.js**
|
||||
|
||||
The example configuration provided is for Mongo DB. By defining the behaviour in these functions you can use NextAuth with any database, including a relational database that uses SQL.
|
||||
|
||||
#### Required
|
||||
|
||||
* find({id,email,emailToken,provider})
|
||||
* insert(user, oAuthProfile, providerParams)
|
||||
* update(user, oAuthProfile, providerParams)
|
||||
* remove(id)
|
||||
* serialize(user)
|
||||
* deserialize(id)
|
||||
|
||||
#### Optional
|
||||
|
||||
* sendSigninEmail({email, url, req})
|
||||
* signIn({form, req})
|
||||
|
||||
The `sendSigninEmail()` method is used to send an email for email token based sign in (one time use passwords). Omit it or set it to null to disable email based sign in.
|
||||
|
||||
The `signIn()` method is used to handle authenticating with custom credentials (e.g. username and password, 2FA token, etc). Omit it or leave it undefined unless you need it.
|
||||
|
||||
You can use any combination of authentication methods (email, credentials, oAuth providers).
|
||||
|
||||
### next-auth.providers.js
|
||||
|
||||
Configuration for oAuth providers are defined in **next-auth.providers.js**
|
||||
|
||||
It includes configuration examples for Facebook, Google and Twitter oAuth, which can easily be copied and replicated to add support for signing in other oAuth providers.
|
||||
|
||||
For tips on configuring oAuth see [AUTHENTICATION.md](https://github.com/iaincollins/next-auth/tree/master/AUTHENTICATION.md).
|
||||
|
||||
----
|
||||
|
||||
See the included [example site](https://github.com/iaincollins/next-auth/tree/master/example) and the expanded example at [nextjs-starter.now.sh](https://nextjs-starter.now.sh/examples/authentication).
|
||||
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).
|
||||
1
adapters.js
Normal file
1
adapters.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/adapters').default
|
||||
12
babel.config.json
Normal file
12
babel.config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"presets": [
|
||||
["@babel/preset-env", { "targets": { "esmodules": true } } ]
|
||||
],
|
||||
"comments": false,
|
||||
"overrides": [
|
||||
{
|
||||
"test": [ "./src/server/pages/**" ],
|
||||
"presets": [ "preact" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
client.d.ts
vendored
52
client.d.ts
vendored
@@ -1,52 +0,0 @@
|
||||
/// <reference path="./index.d.ts" />
|
||||
import { Store } from "express-session";
|
||||
import { RequestHandler } from "express";
|
||||
import { IpcNetConnectOpts } from "net";
|
||||
|
||||
declare namespace nextAuth {
|
||||
interface ILinkedAccounts {
|
||||
[name: string]: boolean;
|
||||
}
|
||||
interface IProviders {
|
||||
[name: string]: {
|
||||
signin: string;
|
||||
callback: string;
|
||||
};
|
||||
}
|
||||
interface NextAuthRequest<SessionType = nextAuth.NextAuthSession>
|
||||
extends Express.Request {
|
||||
session: NextAuthSession;
|
||||
linked: () => Promise<ILinkedAccounts | Error>;
|
||||
providers: () => Promise<IProviders | Error>;
|
||||
}
|
||||
interface IInitOptions {
|
||||
req?: NextAuthRequest;
|
||||
force: boolean;
|
||||
}
|
||||
interface IClientOptions {
|
||||
req?: NextAuthRequest;
|
||||
}
|
||||
}
|
||||
|
||||
declare class Client {
|
||||
static init(
|
||||
opts: nextAuth.IInitOptions
|
||||
): Promise<Partial<nextAuth.INextAuthSessionData>>;
|
||||
static csrfToken(): Promise<string | Error>;
|
||||
static linked(
|
||||
opts: nextAuth.IClientOptions
|
||||
): Promise<nextAuth.ILinkedAccounts | Error>;
|
||||
static providers(
|
||||
opts: nextAuth.IClientOptions
|
||||
): Promise<nextAuth.IProviders | Error>;
|
||||
static signin(
|
||||
params: string | { [k: string]: string }
|
||||
): Promise<boolean | Error>;
|
||||
static signout(): Promise<true | Error>;
|
||||
|
||||
static _getLocalStore(): Promise<nextAuth.INextAuthSessionData | null>;
|
||||
static _saveLocalStore(): Promise<boolean>;
|
||||
static _removeLocalStore(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export = Client;
|
||||
491
client.js
491
client.js
@@ -1,490 +1 @@
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('babel-polyfill'), require('isomorphic-fetch')) :
|
||||
typeof define === 'function' && define.amd ? define(['exports', 'babel-polyfill', 'isomorphic-fetch'], factory) :
|
||||
(factory((global['next-auth-client'] = {}),null,global.fetch));
|
||||
}(this, (function (exports,babelPolyfill,fetch) { 'use strict';
|
||||
|
||||
fetch = fetch && fetch.hasOwnProperty('default') ? fetch['default'] : fetch;
|
||||
|
||||
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
|
||||
|
||||
function _asyncToGenerator(fn) { return function () { var gen = fn.apply(this, arguments); return new Promise(function (resolve, reject) { function step(key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { return Promise.resolve(value).then(function (value) { step("next", value); }, function (err) { step("throw", err); }); } } return step("next"); }); }; }
|
||||
|
||||
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
|
||||
|
||||
var _class = function () {
|
||||
function _class() {
|
||||
_classCallCheck(this, _class);
|
||||
}
|
||||
|
||||
_createClass(_class, null, [{
|
||||
key: 'init',
|
||||
|
||||
/**
|
||||
* This is an async, isometric method which returns a session object -
|
||||
* either by looking up the current express session object when run on the
|
||||
* server, or by using fetch (and optionally caching the result in local
|
||||
* storage) when run on the client.
|
||||
*
|
||||
* Note that actual session tokens are not stored in local storage, they are
|
||||
* kept in an HTTP Only cookie as protection against session hi-jacking by
|
||||
* malicious JavaScript.
|
||||
**/
|
||||
value: function () {
|
||||
var _ref = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee() {
|
||||
var _this = this;
|
||||
|
||||
var _ref2 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
|
||||
_ref2$req = _ref2.req,
|
||||
req = _ref2$req === undefined ? null : _ref2$req,
|
||||
_ref2$force = _ref2.force,
|
||||
force = _ref2$force === undefined ? false : _ref2$force;
|
||||
|
||||
var session;
|
||||
return regeneratorRuntime.wrap(function _callee$(_context) {
|
||||
while (1) {
|
||||
switch (_context.prev = _context.next) {
|
||||
case 0:
|
||||
session = {};
|
||||
|
||||
if (req) {
|
||||
if (req.session) {
|
||||
// If running on the server session data should be in the req object
|
||||
session.csrfToken = req.connection._httpMessage.locals._csrf;
|
||||
session.expires = req.session.cookie._expires;
|
||||
// If the user is logged in, add the user to the session object
|
||||
if (req.user) {
|
||||
session.user = req.user;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If running in the browser attempt to load session from sessionStore
|
||||
if (force === true) {
|
||||
// If force update is set, reset data store
|
||||
this._removeLocalStore('session');
|
||||
} else {
|
||||
session = this._getLocalStore('session');
|
||||
}
|
||||
}
|
||||
|
||||
// If session data exists, has not expired AND force is not set then
|
||||
// return the stored session we already have.
|
||||
|
||||
if (!(session && Object.keys(session).length > 0 && session.expires && session.expires > Date.now())) {
|
||||
_context.next = 6;
|
||||
break;
|
||||
}
|
||||
|
||||
return _context.abrupt('return', new Promise(function (resolve) {
|
||||
resolve(session);
|
||||
}));
|
||||
|
||||
case 6:
|
||||
if (!(typeof window === 'undefined')) {
|
||||
_context.next = 8;
|
||||
break;
|
||||
}
|
||||
|
||||
return _context.abrupt('return', new Promise(function (resolve) {
|
||||
resolve({});
|
||||
}));
|
||||
|
||||
case 8:
|
||||
return _context.abrupt('return', fetch('/auth/session', {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else {
|
||||
return Promise.reject(Error('HTTP error when trying to get session'));
|
||||
}
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
// Update session with session info
|
||||
session = data;
|
||||
|
||||
// Set a value we will use to check this client should silently
|
||||
// revalidate, using the value for revalidateAge returned by the server.
|
||||
session.expires = Date.now() + session.revalidateAge;
|
||||
|
||||
// Save changes to session
|
||||
_this._saveLocalStore('session', session);
|
||||
|
||||
return session;
|
||||
}).catch(function () {
|
||||
return Error('Unable to get session');
|
||||
}));
|
||||
|
||||
case 9:
|
||||
case 'end':
|
||||
return _context.stop();
|
||||
}
|
||||
}
|
||||
}, _callee, this);
|
||||
}));
|
||||
|
||||
function init() {
|
||||
return _ref.apply(this, arguments);
|
||||
}
|
||||
|
||||
return init;
|
||||
}()
|
||||
|
||||
/**
|
||||
* A simple static method to get the CSRF Token is provided for convenience
|
||||
**/
|
||||
|
||||
}, {
|
||||
key: 'csrfToken',
|
||||
value: function () {
|
||||
var _ref3 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee2() {
|
||||
return regeneratorRuntime.wrap(function _callee2$(_context2) {
|
||||
while (1) {
|
||||
switch (_context2.prev = _context2.next) {
|
||||
case 0:
|
||||
return _context2.abrupt('return', fetch('/auth/csrf', {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else {
|
||||
return Promise.reject(Error('Unexpected response when trying to get CSRF token'));
|
||||
}
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
return data.csrfToken;
|
||||
}).catch(function () {
|
||||
return Error('Unable to get CSRF token');
|
||||
}));
|
||||
|
||||
case 1:
|
||||
case 'end':
|
||||
return _context2.stop();
|
||||
}
|
||||
}
|
||||
}, _callee2, this);
|
||||
}));
|
||||
|
||||
function csrfToken() {
|
||||
return _ref3.apply(this, arguments);
|
||||
}
|
||||
|
||||
return csrfToken;
|
||||
}()
|
||||
|
||||
/**
|
||||
* A static method to get list of currently linked oAuth accounts
|
||||
**/
|
||||
|
||||
}, {
|
||||
key: 'linked',
|
||||
value: function () {
|
||||
var _ref4 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee3() {
|
||||
var _ref5 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
|
||||
_ref5$req = _ref5.req,
|
||||
req = _ref5$req === undefined ? null : _ref5$req;
|
||||
|
||||
return regeneratorRuntime.wrap(function _callee3$(_context3) {
|
||||
while (1) {
|
||||
switch (_context3.prev = _context3.next) {
|
||||
case 0:
|
||||
if (!req) {
|
||||
_context3.next = 2;
|
||||
break;
|
||||
}
|
||||
|
||||
return _context3.abrupt('return', req.linked());
|
||||
|
||||
case 2:
|
||||
return _context3.abrupt('return', fetch('/auth/linked', {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else {
|
||||
return Promise.reject(Error('Unexpected response when trying to get linked accounts'));
|
||||
}
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
return data;
|
||||
}).catch(function () {
|
||||
return Error('Unable to get linked accounts');
|
||||
}));
|
||||
|
||||
case 3:
|
||||
case 'end':
|
||||
return _context3.stop();
|
||||
}
|
||||
}
|
||||
}, _callee3, this);
|
||||
}));
|
||||
|
||||
function linked() {
|
||||
return _ref4.apply(this, arguments);
|
||||
}
|
||||
|
||||
return linked;
|
||||
}()
|
||||
|
||||
/**
|
||||
* A static method to get list of currently configured oAuth providers
|
||||
**/
|
||||
|
||||
}, {
|
||||
key: 'providers',
|
||||
value: function () {
|
||||
var _ref6 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee4() {
|
||||
var _ref7 = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {},
|
||||
_ref7$req = _ref7.req,
|
||||
req = _ref7$req === undefined ? null : _ref7$req;
|
||||
|
||||
return regeneratorRuntime.wrap(function _callee4$(_context4) {
|
||||
while (1) {
|
||||
switch (_context4.prev = _context4.next) {
|
||||
case 0:
|
||||
if (!req) {
|
||||
_context4.next = 2;
|
||||
break;
|
||||
}
|
||||
|
||||
return _context4.abrupt('return', req.providers());
|
||||
|
||||
case 2:
|
||||
return _context4.abrupt('return', fetch('/auth/providers', {
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
return response;
|
||||
} else {
|
||||
console.log("NextAuth Error Fetching Providers");
|
||||
return null;
|
||||
}
|
||||
}).then(function (response) {
|
||||
return response.json();
|
||||
}).then(function (data) {
|
||||
return data;
|
||||
}).catch(function (e) {
|
||||
console.log("NextAuth Error Loading Providers");
|
||||
console.log(e);
|
||||
return null;
|
||||
}));
|
||||
|
||||
case 3:
|
||||
case 'end':
|
||||
return _context4.stop();
|
||||
}
|
||||
}
|
||||
}, _callee4, this);
|
||||
}));
|
||||
|
||||
function providers() {
|
||||
return _ref6.apply(this, arguments);
|
||||
}
|
||||
|
||||
return providers;
|
||||
}()
|
||||
|
||||
/*
|
||||
* Sign in
|
||||
*
|
||||
* Will post a form to /auth/signin auth route if an object is passed.
|
||||
* If the details are valid a session will be created and you should redirect
|
||||
* to your callback page so the session is loaded in the client.
|
||||
*
|
||||
* If just a string containing an email address is specififed will generate a
|
||||
* a one-time use sign in link and send it via email; you should redirect to a
|
||||
* page telling the user to check their inbox for an email with the link.
|
||||
*/
|
||||
|
||||
}, {
|
||||
key: 'signin',
|
||||
value: function () {
|
||||
var _ref8 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee6(params) {
|
||||
var _this2 = this;
|
||||
|
||||
var formData, route, encodedForm;
|
||||
return regeneratorRuntime.wrap(function _callee6$(_context6) {
|
||||
while (1) {
|
||||
switch (_context6.prev = _context6.next) {
|
||||
case 0:
|
||||
// Params can be just string (an email address) or an object (form fields)
|
||||
formData = typeof params === 'string' ? { email: params } : params;
|
||||
|
||||
// Use either the email token generation route or the custom form auth route
|
||||
|
||||
route = typeof params === 'string' ? '/auth/email/signin' : '/auth/signin';
|
||||
|
||||
// Add latest CSRF Token to request
|
||||
|
||||
_context6.next = 4;
|
||||
return this.csrfToken();
|
||||
|
||||
case 4:
|
||||
formData._csrf = _context6.sent;
|
||||
|
||||
|
||||
// Encoded form parser for sending data in the body
|
||||
encodedForm = Object.keys(formData).map(function (key) {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key]);
|
||||
}).join('&');
|
||||
return _context6.abrupt('return', fetch(route, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest' // So Express can detect AJAX post
|
||||
},
|
||||
body: encodedForm,
|
||||
credentials: 'same-origin'
|
||||
}).then(function () {
|
||||
var _ref9 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee5(response) {
|
||||
return regeneratorRuntime.wrap(function _callee5$(_context5) {
|
||||
while (1) {
|
||||
switch (_context5.prev = _context5.next) {
|
||||
case 0:
|
||||
if (!response.ok) {
|
||||
_context5.next = 6;
|
||||
break;
|
||||
}
|
||||
|
||||
_context5.next = 3;
|
||||
return response.json();
|
||||
|
||||
case 3:
|
||||
return _context5.abrupt('return', _context5.sent);
|
||||
|
||||
case 6:
|
||||
throw new Error('HTTP error while attempting to sign in');
|
||||
|
||||
case 7:
|
||||
case 'end':
|
||||
return _context5.stop();
|
||||
}
|
||||
}
|
||||
}, _callee5, _this2);
|
||||
}));
|
||||
|
||||
return function (_x5) {
|
||||
return _ref9.apply(this, arguments);
|
||||
};
|
||||
}()).then(function (data) {
|
||||
if (data.success && data.success === true) {
|
||||
return Promise.resolve(true);
|
||||
} else {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}));
|
||||
|
||||
case 7:
|
||||
case 'end':
|
||||
return _context6.stop();
|
||||
}
|
||||
}
|
||||
}, _callee6, this);
|
||||
}));
|
||||
|
||||
function signin(_x4) {
|
||||
return _ref8.apply(this, arguments);
|
||||
}
|
||||
|
||||
return signin;
|
||||
}()
|
||||
}, {
|
||||
key: 'signout',
|
||||
value: function () {
|
||||
var _ref10 = _asyncToGenerator( /*#__PURE__*/regeneratorRuntime.mark(function _callee7() {
|
||||
var csrfToken, formData, encodedForm;
|
||||
return regeneratorRuntime.wrap(function _callee7$(_context7) {
|
||||
while (1) {
|
||||
switch (_context7.prev = _context7.next) {
|
||||
case 0:
|
||||
_context7.next = 2;
|
||||
return this.csrfToken();
|
||||
|
||||
case 2:
|
||||
csrfToken = _context7.sent;
|
||||
formData = { _csrf: csrfToken
|
||||
|
||||
// Encoded form parser for sending data in the body
|
||||
};
|
||||
encodedForm = Object.keys(formData).map(function (key) {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key]);
|
||||
}).join('&');
|
||||
|
||||
// Remove cached session data
|
||||
|
||||
this._removeLocalStore('session');
|
||||
|
||||
return _context7.abrupt('return', fetch('/auth/signout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: encodedForm,
|
||||
credentials: 'same-origin'
|
||||
}).then(function () {
|
||||
return true;
|
||||
}).catch(function () {
|
||||
return Error('Unable to sign out');
|
||||
}));
|
||||
|
||||
case 7:
|
||||
case 'end':
|
||||
return _context7.stop();
|
||||
}
|
||||
}
|
||||
}, _callee7, this);
|
||||
}));
|
||||
|
||||
function signout() {
|
||||
return _ref10.apply(this, arguments);
|
||||
}
|
||||
|
||||
return signout;
|
||||
}()
|
||||
|
||||
// The Web Storage API is widely supported, but not always available (e.g.
|
||||
// it can be restricted in private browsing mode, triggering an exception).
|
||||
// We handle that silently by just returning null here.
|
||||
|
||||
}, {
|
||||
key: '_getLocalStore',
|
||||
value: function _getLocalStore(name) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(name));
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}, {
|
||||
key: '_saveLocalStore',
|
||||
value: function _saveLocalStore(name, data) {
|
||||
try {
|
||||
localStorage.setItem(name, JSON.stringify(data));
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}, {
|
||||
key: '_removeLocalStore',
|
||||
value: function _removeLocalStore(name) {
|
||||
try {
|
||||
localStorage.removeItem(name);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
return _class;
|
||||
}();
|
||||
|
||||
exports.NextAuth = _class;
|
||||
|
||||
Object.defineProperty(exports, '__esModule', { value: true });
|
||||
|
||||
})));
|
||||
module.exports = require('./dist/client').default
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
SERVER_URL=http://localhost:3000
|
||||
MONGO_URI=mongodb://localhost:27017/my-database
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRET=
|
||||
GOOGLE_ID=
|
||||
GOOGLE_SECRET=
|
||||
TWITTER_KEY=
|
||||
TWITTER_SECRET=
|
||||
EMAIL_FROM=username@gmail.com
|
||||
EMAIL_SERVER=smtp.gmail.com
|
||||
EMAIL_PORT=465
|
||||
EMAIL_USERNAME=username@gmail.com
|
||||
EMAIL_PASSWORD=
|
||||
3
example/.gitignore
vendored
3
example/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
.env
|
||||
/.env.production
|
||||
node_modules
|
||||
@@ -1,68 +0,0 @@
|
||||
# NextAuth Example
|
||||
|
||||
## About NextAuth Example
|
||||
|
||||
This is an example of how to use the [NextAuth](https://www.npmjs.com/package/next-auth) module.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project as is run the same way as any Next.js project.
|
||||
|
||||
To run it locally, just use:
|
||||
|
||||
npm run dev
|
||||
|
||||
To run it it production mode, use:
|
||||
|
||||
npm build
|
||||
npm start
|
||||
|
||||
## Using NextAuth
|
||||
|
||||
NextAuth is included in this project here:
|
||||
|
||||
* index.js
|
||||
|
||||
## Pages
|
||||
|
||||
This example includes the following pages:
|
||||
|
||||
* pages/index.js
|
||||
* pages/auth/index.js
|
||||
* pages/auth/error.js
|
||||
* pages/auth/check-email.js
|
||||
* pages/auth/callback.js
|
||||
|
||||
The file `pages/auth/credentials.js` provides an additional example of how to use a custom authentication handler defined in `next-auth.functions.js`.
|
||||
|
||||
## Configuration
|
||||
|
||||
It also includes the following configuration files:
|
||||
|
||||
* next-auth.config.js
|
||||
* next-auth.functions.js
|
||||
* next-auth.providers.js
|
||||
|
||||
An example **.env** file is provided in **.env.example** which you can copy over to use for simple configuration:
|
||||
|
||||
````
|
||||
SERVER_URL=http://localhost:3000
|
||||
MONGO_URI=mongodb://localhost:27017/my-database
|
||||
FACEBOOK_ID=
|
||||
FACEBOOK_SECRET=
|
||||
GOOGLE_ID=
|
||||
GOOGLE_SECRET=
|
||||
TWITTER_KEY=
|
||||
TWITTER_SECRET=
|
||||
EMAIL_FROM=username@gmail.com
|
||||
EMAIL_SERVER=smtp.gmail.com
|
||||
EMAIL_PORT=465
|
||||
EMAIL_USERNAME=username@gmail.com
|
||||
EMAIL_PASSWORD=
|
||||
````
|
||||
|
||||
If you don't specify a MONGO_URI it will use an in-memory data store for user and session data.
|
||||
|
||||
If you don't specify oAuth or SMTP email details you will not be able to log in.
|
||||
|
||||
For a more complete example with live demo see [nextjs-starter.now.sh](https://nextjs-starter.now.sh/examples/authentication).
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* An example of how to use the NextAuth module.
|
||||
*
|
||||
* To invoke next-auth you will need to define a configuration block for your
|
||||
* site. You can create a next-auth.config.js file and put all your options
|
||||
* in it and pass it to next-auth when calling init().
|
||||
*
|
||||
* A number of sample configuration files for various databases and
|
||||
* authentication options are provided.
|
||||
**/
|
||||
|
||||
// Include Next.js, Next Auth and a Next Auth config
|
||||
const next = require('next')
|
||||
const nextAuth = require('next-auth')
|
||||
const nextAuthConfig = require('./next-auth.config')
|
||||
|
||||
// Load environment variables from .env
|
||||
require('dotenv').config({ path: './.env' })
|
||||
|
||||
// Initialize Next.js
|
||||
const nextApp = next({
|
||||
dir: '.',
|
||||
dev: (process.env.NODE_ENV === 'development')
|
||||
})
|
||||
|
||||
// Add next-auth to next app
|
||||
nextApp.prepare()
|
||||
.then(async () => {
|
||||
// Load configuration and return config object
|
||||
const nextAuthOptions = await nextAuthConfig()
|
||||
|
||||
// Pass Next.js App instance and NextAuth options to NextAuth
|
||||
const nextAuthApp = await nextAuth(nextApp, nextAuthOptions)
|
||||
|
||||
console.log(`Ready on http://localhost:${process.env.PORT || 3000}`)
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('An error occurred, unable to start the server')
|
||||
console.log(err)
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* next-auth.config.js Example
|
||||
*
|
||||
* Environment variables for this example:
|
||||
*
|
||||
* PORT=3000
|
||||
* SERVER_URL=http://localhost:3000
|
||||
* MONGO_URI=mongodb://localhost:27017/my-database
|
||||
*
|
||||
* If you wish, you can put these in a `.env` to seperate your environment
|
||||
* specific configuration from your code.
|
||||
**/
|
||||
|
||||
// Load environment variables from a .env file if one exists
|
||||
require('dotenv').config({ path: './.env' })
|
||||
|
||||
const nextAuthProviders = require('./next-auth.providers')
|
||||
const nextAuthFunctions = require('./next-auth.functions')
|
||||
|
||||
// If we want to pass a custom session store then we also need to pass an
|
||||
// instance of Express Session along with it.
|
||||
const expressSession = require('express-session')
|
||||
const MongoStore = require('connect-mongo')(expressSession)
|
||||
|
||||
// If no store set, NextAuth defaults to using Express Sessions in-memory
|
||||
// session store (the fallback is intended as fallback for testing only).
|
||||
let sessionStore
|
||||
if (process.env.MONGO_URI) {
|
||||
sessionStore = new MongoStore({
|
||||
url: process.env.MONGO_URI,
|
||||
autoRemove: 'interval',
|
||||
autoRemoveInterval: 10, // Removes expired sessions every 10 minutes
|
||||
collection: 'sessions',
|
||||
stringify: false
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
// We connect to the User DB before we define our functions.
|
||||
// next-auth.functions.js returns an async method that does that and returns
|
||||
// an object with the functions needed for authentication.
|
||||
return nextAuthFunctions()
|
||||
.then(functions => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// This is the config block we return, ready to be passed to NextAuth
|
||||
resolve({
|
||||
// Define a port (if none passed, will not start Express)
|
||||
port: process.env.PORT || 3000,
|
||||
// Secret used to encrypt session data on the server.
|
||||
sessionSecret: 'change-me',
|
||||
// Maximum Session Age in ms (optional, default is 7 days).
|
||||
// The expiry time for a session is reset every time a user revisits
|
||||
// the site or revalidates their session token. This is the maximum
|
||||
// idle time value.
|
||||
sessionMaxAge: 60000 * 60 * 24 * 7,
|
||||
// Session Revalidation in X ms (optional, default is 60 seconds).
|
||||
// Specifies how often a Single Page App should revalidate a session.
|
||||
// Does not impact the session life on the server, but causes clients
|
||||
// to refetch session info (even if it is in a local cache) after N
|
||||
// seconds has elapsed since it was last checked so they always display
|
||||
// state correctly.
|
||||
// If set to 0 will revalidate a session before rendering every page.
|
||||
sessionRevalidateAge: 60000,
|
||||
// Canonical URL of the server (optional, but recommended).
|
||||
// e.g. 'http://localhost:3000' or 'https://www.example.com'
|
||||
// Used in callbak URLs and email sign in links. It will be auto
|
||||
// generated if not specified, which may cause problems if your site
|
||||
// uses multiple aliases (e.g. 'example.com and 'www.examples.com').
|
||||
serverUrl: process.env.SERVER_URL || null,
|
||||
// Add an Express Session store.
|
||||
expressSession: expressSession,
|
||||
sessionStore: sessionStore,
|
||||
// Define oAuth Providers
|
||||
providers: nextAuthProviders(),
|
||||
// Define functions for manging users and sending email.
|
||||
functions: functions
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* next-auth.functions.js Example
|
||||
*
|
||||
* This file defines functions NextAuth to look up, add and update users.
|
||||
*
|
||||
* It returns a Promise with the functions matching these signatures:
|
||||
*
|
||||
* {
|
||||
* find: ({
|
||||
* id,
|
||||
* email,
|
||||
* emailToken,
|
||||
* provider,
|
||||
* poviderToken
|
||||
* } = {}) => {},
|
||||
* update: (user) => {},
|
||||
* insert: (user) => {},
|
||||
* remove: (id) => {},
|
||||
* serialize: (user) => {},
|
||||
* deserialize: (id) => {}
|
||||
* }
|
||||
*
|
||||
* Each function returns Promise.resolve() - or Promise.reject() on error.
|
||||
*
|
||||
* This specific example supports both MongoDB and NeDB, but can be refactored
|
||||
* to work with any database.
|
||||
*
|
||||
* Environment variables for this example:
|
||||
*
|
||||
* MONGO_URI=mongodb://localhost:27017/my-database
|
||||
* EMAIL_FROM=username@gmail.com
|
||||
* EMAIL_SERVER=smtp.gmail.com
|
||||
* EMAIL_PORT=465
|
||||
* EMAIL_USERNAME=username@gmail.com
|
||||
* EMAIL_PASSWORD=p4ssw0rd
|
||||
*
|
||||
* If you wish, you can put these in a `.env` to seperate your environment
|
||||
* specific configuration from your code.
|
||||
**/
|
||||
|
||||
// Load environment variables from a .env file if one exists
|
||||
require('dotenv').config({ path: './.env' })
|
||||
|
||||
// This config file uses MongoDB for User accounts, as well as session storage.
|
||||
// This config includes options for NeDB, which it defaults to if no DB URI
|
||||
// is specified. NeDB is an in-memory only database intended here for testing.
|
||||
const MongoClient = require('mongodb').MongoClient
|
||||
const NeDB = require('nedb')
|
||||
const MongoObjectId = (process.env.MONGO_URI) ? require('mongodb').ObjectId : (id) => { return id }
|
||||
|
||||
// Use Node Mailer for email sign in
|
||||
const nodemailer = require('nodemailer')
|
||||
const nodemailerSmtpTransport = require('nodemailer-smtp-transport')
|
||||
const nodemailerDirectTransport = require('nodemailer-direct-transport')
|
||||
|
||||
// Send email direct from localhost if no mail server configured
|
||||
let nodemailerTransport = nodemailerDirectTransport()
|
||||
if (process.env.EMAIL_SERVER && process.env.EMAIL_USERNAME && process.env.EMAIL_PASSWORD) {
|
||||
nodemailerTransport = nodemailerSmtpTransport({
|
||||
host: process.env.EMAIL_SERVER,
|
||||
port: process.env.EMAIL_PORT || 25,
|
||||
secure: process.env.EMAIL_SECURE || true,
|
||||
auth: {
|
||||
user: process.env.EMAIL_USERNAME,
|
||||
pass: process.env.EMAIL_PASSWORD
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (process.env.MONGO_URI) {
|
||||
// Connect to MongoDB Database and return user connection
|
||||
MongoClient.connect(process.env.MONGO_URI, (err, mongoClient) => {
|
||||
if (err) return reject(err)
|
||||
const dbName = process.env.MONGO_URI.split('/').pop().split('?').shift()
|
||||
const db = mongoClient.db(dbName)
|
||||
return resolve(db.collection('users'))
|
||||
})
|
||||
} else {
|
||||
// If no MongoDB URI string specified, use NeDB, an in-memory work-a-like.
|
||||
// NeDB is not persistant and is intended for testing only.
|
||||
let collection = new NeDB({ autoload: true })
|
||||
collection.loadDatabase(err => {
|
||||
if (err) return reject(err)
|
||||
resolve(collection)
|
||||
})
|
||||
}
|
||||
})
|
||||
.then(usersCollection => {
|
||||
return Promise.resolve({
|
||||
// If a user is not found find() should return null (with no error).
|
||||
find: ({id, email, emailToken, provider} = {}) => {
|
||||
let query = {}
|
||||
|
||||
// Find needs to support looking up a user by ID, Email, Email Token,
|
||||
// and Provider Name + Users ID for that Provider
|
||||
if (id) {
|
||||
query = { _id: MongoObjectId(id) }
|
||||
} else if (email) {
|
||||
query = { email: email }
|
||||
} else if (emailToken) {
|
||||
query = { emailToken: emailToken }
|
||||
} else if (provider) {
|
||||
query = { [`${provider.name}.id`]: provider.id }
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
usersCollection.findOne(query, (err, user) => {
|
||||
if (err) return reject(err)
|
||||
return resolve(user)
|
||||
})
|
||||
})
|
||||
},
|
||||
// The user parameter contains a basic user object to be added to the DB.
|
||||
// The oAuthProfile parameter is passed when signing in via oAuth.
|
||||
//
|
||||
// The optional oAuthProfile parameter contains all properties associated
|
||||
// with the users account on the oAuth service they are signing in with.
|
||||
//
|
||||
// You can use this to capture profile.avatar, profile.location, etc.
|
||||
insert: (user, oAuthProfile) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
usersCollection.insert(user, (err, response) => {
|
||||
if (err) return reject(err)
|
||||
|
||||
// Mongo Client automatically adds an id to an inserted object, but
|
||||
// if using a work-a-like we may need to add it from the response.
|
||||
if (!user._id && response._id) user._id = response._id
|
||||
|
||||
return resolve(user)
|
||||
})
|
||||
})
|
||||
},
|
||||
// The user parameter contains a basic user object to be added to the DB.
|
||||
// The oAuthProfile parameter is passed when signing in via oAuth.
|
||||
//
|
||||
// The optional oAuthProfile parameter contains all properties associated
|
||||
// with the users account on the oAuth service they are signing in with.
|
||||
//
|
||||
// You can use this to capture profile.avatar, profile.location, etc.
|
||||
update: (user, profile) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
usersCollection.update({_id: MongoObjectId(user._id)}, user, {}, (err) => {
|
||||
if (err) return reject(err)
|
||||
return resolve(user)
|
||||
})
|
||||
})
|
||||
},
|
||||
// The remove parameter is passed the ID of a user account to delete.
|
||||
//
|
||||
// This method is not used in the current version of next-auth but will
|
||||
// be in a future release, to provide an endpoint for account deletion.
|
||||
remove: (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
usersCollection.remove({_id: MongoObjectId(id)}, (err) => {
|
||||
if (err) return reject(err)
|
||||
return resolve(true)
|
||||
})
|
||||
})
|
||||
},
|
||||
// Seralize turns the value of the ID key from a User object
|
||||
serialize: (user) => {
|
||||
// Supports serialization from Mongo Object *and* deserialize() object
|
||||
if (user.id) {
|
||||
// Handle responses from deserialize()
|
||||
return Promise.resolve(user.id)
|
||||
} else if (user._id) {
|
||||
// Handle responses from find(), insert(), update()
|
||||
return Promise.resolve(user._id)
|
||||
} else {
|
||||
return Promise.reject(new Error("Unable to serialise user"))
|
||||
}
|
||||
},
|
||||
// Deseralize turns a User ID into a normalized User object that is
|
||||
// exported to clients. It should not return private/sensitive fields,
|
||||
// only fields you want to expose via the user interface.
|
||||
deserialize: (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
usersCollection.findOne({ _id: MongoObjectId(id) }, (err, user) => {
|
||||
if (err) return reject(err)
|
||||
|
||||
// If user not found (e.g. account deleted) return null object
|
||||
if (!user) return resolve(null)
|
||||
|
||||
return resolve({
|
||||
id: user._id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
admin: user.admin || false
|
||||
})
|
||||
})
|
||||
})
|
||||
},
|
||||
// Email Sign In
|
||||
//
|
||||
// Accounts are created automatically, as when signing in via oAuth.
|
||||
// Users are sent one-time use sign in tokens in links. This avoids
|
||||
// storing user supplied passwords anywhere, preventing password re-use.
|
||||
//
|
||||
// To disable this option, do not set sendSignInEmail (or set it to null).
|
||||
sendSignInEmail: ({email, url, req}) => {
|
||||
nodemailer
|
||||
.createTransport(nodemailerTransport)
|
||||
.sendMail({
|
||||
to: email,
|
||||
from: process.env.EMAIL_FROM,
|
||||
subject: 'Sign in link',
|
||||
text: `Use the link below to sign in:\n\n${url}\n\n`,
|
||||
html: `<p>Use the link below to sign in:</p><p>${url}</p>`
|
||||
}, (err) => {
|
||||
if (err) {
|
||||
console.error('Error sending email to ' + email, err)
|
||||
}
|
||||
})
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Generated sign in link ' + url + ' for ' + email)
|
||||
}
|
||||
},
|
||||
// Credentials Sign In
|
||||
//
|
||||
// If you use this you will need to define your own way to validate
|
||||
// credentials. Unlike with oAuth or Email Sign In, accounts are not
|
||||
// created automatically so you will need to provide a way to create them.
|
||||
//
|
||||
// This feature is intended for strategies like Two Factor Authentication.
|
||||
//
|
||||
// To disable this option, do not set signin (or set it to null).
|
||||
/*
|
||||
signIn: ({form, req}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Should validate credentials (e.g. hash password, compare 2FA token
|
||||
// etc) and return a valid user object from a database.
|
||||
return usersCollection.findOne({
|
||||
email: form.email
|
||||
}, (err, user) => {
|
||||
if (err) return reject(err)
|
||||
if (!user) return resolve(null)
|
||||
|
||||
// Check credentials - e.g. compare bcrypt password hashes
|
||||
if (form.password === "test1234") {
|
||||
// If valid, return user object - e.g. { id, name, email }
|
||||
return resolve(user)
|
||||
} else {
|
||||
// If invalid, return null
|
||||
return resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
*/
|
||||
// Session Object (optional)
|
||||
//
|
||||
// The session object that gets returned to the client. You don't need to
|
||||
// specify this function here unless you want to override or extend the
|
||||
// default (e.g. with any other properties you have added to req.session)
|
||||
//
|
||||
// Note: The object returned will be stored in localStorage and visible
|
||||
// client side so do not return data you would not want the user to see.
|
||||
/*
|
||||
session: (session, req) => {
|
||||
if (req.session && req.session.someCustomProperty)
|
||||
session.someCustomProperty = req.session.someCustomProperty
|
||||
|
||||
session.someOtherCustomProperty = "Example custom property"
|
||||
|
||||
return session
|
||||
}
|
||||
*/
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* next-auth.providers.js Example
|
||||
*
|
||||
* This file returns a simple array of oAuth Provider objects for NextAuth.
|
||||
*
|
||||
* This example returns an array based on what environment variables are set,
|
||||
* with explicit support for Facebook, Google and Twitter, but it can be used
|
||||
* to add strategies for other oAuth providers.
|
||||
*
|
||||
* Environment variables for this example:
|
||||
*
|
||||
* FACEBOOK_ID=
|
||||
* FACEBOOK_SECRET=
|
||||
* GOOGLE_ID=
|
||||
* GOOGLE_SECRET=
|
||||
* TWITTER_KEY=
|
||||
* TWITTER_SECRET=
|
||||
*
|
||||
* If you wish, you can put these in a `.env` to seperate your environment
|
||||
* specific configuration from your code.
|
||||
**/
|
||||
|
||||
// Load environment variables from a .env file if one exists
|
||||
require('dotenv').config({ path: './.env' })
|
||||
|
||||
module.exports = () => {
|
||||
let providers = []
|
||||
|
||||
if (process.env.FACEBOOK_ID && process.env.FACEBOOK_SECRET) {
|
||||
providers.push({
|
||||
providerName: 'Facebook',
|
||||
providerOptions: {
|
||||
scope: ['email', 'public_profile']
|
||||
},
|
||||
Strategy: require('passport-facebook').Strategy,
|
||||
strategyOptions: {
|
||||
clientID: process.env.FACEBOOK_ID,
|
||||
clientSecret: process.env.FACEBOOK_SECRET,
|
||||
profileFields: ['id', 'displayName', 'email', 'link']
|
||||
},
|
||||
getProfile(profile) {
|
||||
// Normalize profile into one with {id, name, email} keys
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.displayName,
|
||||
email: profile._json.email
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (process.env.GOOGLE_ID && process.env.GOOGLE_SECRET) {
|
||||
providers.push({
|
||||
providerName: 'Google',
|
||||
providerOptions: {
|
||||
scope: ['profile', 'email']
|
||||
},
|
||||
Strategy: require('passport-google-oauth').OAuth2Strategy,
|
||||
strategyOptions: {
|
||||
clientID: process.env.GOOGLE_ID,
|
||||
clientSecret: process.env.GOOGLE_SECRET
|
||||
},
|
||||
getProfile(profile) {
|
||||
// Normalize profile into one with {id, name, email} keys
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.displayName,
|
||||
email: profile.emails[0].value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: Twitter doesn't expose emails by default.
|
||||
* If we don't get one, Passport-stategies.js will create a placeholder.
|
||||
*
|
||||
*
|
||||
* To have your Twitter oAuth return emails go to apps.twitter.com and add
|
||||
* links to your Terms and Conditions and Privacy Policy under the "Settings"
|
||||
* tab, then check the "Request email addresses" from users box under the
|
||||
* "Permissions" tab.
|
||||
**/
|
||||
if (process.env.TWITTER_KEY && process.env.TWITTER_SECRET) {
|
||||
providers.push({
|
||||
providerName: 'Twitter',
|
||||
providerOptions: {
|
||||
scope: []
|
||||
},
|
||||
Strategy: require('passport-twitter').Strategy,
|
||||
strategyOptions: {
|
||||
consumerKey: process.env.TWITTER_KEY,
|
||||
consumerSecret: process.env.TWITTER_SECRET,
|
||||
userProfileURL: 'https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true'
|
||||
},
|
||||
getProfile(profile) {
|
||||
// Normalize profile into one with {id, name, email} keys
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.displayName,
|
||||
email: (profile.emails && profile.emails[0].value) ? profile.emails[0].value : ''
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return providers
|
||||
}
|
||||
6888
example/package-lock.json
generated
6888
example/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "next-auth-examples",
|
||||
"version": "1.12.1",
|
||||
"description": "An example project for next-auth",
|
||||
"repository": "https://github.com/iaincollins/next-auth.git",
|
||||
"main": "",
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development node index.js",
|
||||
"build": "next build",
|
||||
"start": "node index.js"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"connect-mongo": "^2.0.3",
|
||||
"dotenv": "^6.1.0",
|
||||
"express-session": "^1.15.6",
|
||||
"mongodb": "^3.1.10",
|
||||
"nedb": "^1.8.0",
|
||||
"next": "^7.0.2",
|
||||
"next-auth": "^1.12.1",
|
||||
"nodemailer": "^4.7.0",
|
||||
"nodemailer-direct-transport": "^3.3.2",
|
||||
"nodemailer-smtp-transport": "^2.7.4",
|
||||
"passport-facebook": "^2.1.1",
|
||||
"passport-google-oauth": "^1.0.0",
|
||||
"passport-twitter": "^1.0.4",
|
||||
"react": "^16.6.3",
|
||||
"react-dom": "^16.6.3"
|
||||
},
|
||||
"now": {
|
||||
"name": "next-auth-demo",
|
||||
"alias": "next-auth-demo.now.sh"
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import Document, { Head, Main, NextScript } from 'next/document'
|
||||
|
||||
export default class DefaultDocument extends Document {
|
||||
static async getInitialProps(props) {
|
||||
return await Document.getInitialProps(props)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<html lang={this.props.__NEXT_DATA__.props.lang || 'en'}>
|
||||
<Head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous"/>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import Router from 'next/router'
|
||||
import { NextAuth } from 'next-auth/client'
|
||||
|
||||
export default class extends React.Component {
|
||||
|
||||
static async getInitialProps({req}) {
|
||||
return {
|
||||
session: await NextAuth.init({force: true, req: req})
|
||||
}
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
// Get latest session data after rendering on client then redirect.
|
||||
// The ensures client state is always updated after signing in or out.
|
||||
const session = await NextAuth.init({force: true})
|
||||
Router.push('/')
|
||||
}
|
||||
|
||||
render() {
|
||||
// Provide a link for clients without JavaScript as a fallback.
|
||||
return (
|
||||
<React.Fragment>
|
||||
<style jsx global>{`
|
||||
body{
|
||||
background-color: #fff;
|
||||
}
|
||||
.circle-loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
z-index: 100;
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.circle-loader .circle {
|
||||
fill: transparent;
|
||||
stroke: rgba(0,0,0,0.2);
|
||||
stroke-width: 4px;
|
||||
animation: dash 2s ease infinite, rotate 2s linear infinite;
|
||||
}
|
||||
@keyframes dash {
|
||||
0% {
|
||||
stroke-dasharray: 1,95;
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 85,95;
|
||||
stroke-dashoffset: -25;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 85,95;
|
||||
stroke-dashoffset: -93;
|
||||
}
|
||||
}
|
||||
@keyframes rotate {
|
||||
0% {transform: rotate(0deg); }
|
||||
100% {transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
<noscript>
|
||||
<style>{`
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
a {
|
||||
font-weight: bold;
|
||||
}
|
||||
`}</style>
|
||||
</noscript>
|
||||
<a href="/" className="circle-loader">
|
||||
<svg className="circle" width="60" height="60" version="1.1" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="30" cy="30" r="15"/>
|
||||
</svg>
|
||||
<noscript>
|
||||
Click here to continue
|
||||
</noscript>
|
||||
</a>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default class extends React.Component {
|
||||
|
||||
static async getInitialProps({query}) {
|
||||
return {
|
||||
email: query.email
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return(
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-4 mt-5 mb-3">Check your email</h1>
|
||||
<p className="lead">
|
||||
A sign in link has been sent to { (this.props.email) ? <span className="font-weight-bold">{this.props.email}</span> : <span>your inbox</span> }.
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/"><a>Home</a></Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react'
|
||||
import Router from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import { NextAuth } from 'next-auth/client'
|
||||
|
||||
export default class extends React.Component {
|
||||
|
||||
static async getInitialProps({req}) {
|
||||
return {
|
||||
session: await NextAuth.init({req}),
|
||||
linkedAccounts: await NextAuth.linked({req}),
|
||||
providers: await NextAuth.providers({req})
|
||||
}
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
email: '',
|
||||
password: '',
|
||||
session: this.props.session
|
||||
}
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this)
|
||||
this.handlePasswordChange = this.handlePasswordChange.bind(this)
|
||||
this.handleSignInSubmit = this.handleSignInSubmit.bind(this)
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
if (this.props.session.user) {
|
||||
Router.push(`/auth/`)
|
||||
}
|
||||
}
|
||||
|
||||
handleEmailChange(event) {
|
||||
this.setState({
|
||||
email: event.target.value
|
||||
})
|
||||
}
|
||||
|
||||
handlePasswordChange(event) {
|
||||
this.setState({
|
||||
password: event.target.value
|
||||
})
|
||||
}
|
||||
|
||||
handleSignInSubmit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
// An object passed NextAuth.signin will be passed to your signin() function
|
||||
NextAuth.signin({
|
||||
email: this.state.email,
|
||||
password: this.state.password
|
||||
})
|
||||
.then(authenticated => {
|
||||
Router.push(`/auth/callback`)
|
||||
})
|
||||
.catch(() => {
|
||||
alert("Authentication failed.")
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.session.user) {
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-5 mt-4 mb-2">NextAuth - Custom Sign In</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-sm-12 col-md-10 col-lg-8 col-xl-7 mr-auto ml-auto">
|
||||
<p className="mt-3 mb-4 text-center">
|
||||
If you want to support password based sign in, two factor authentication
|
||||
or another sign in method, define a signin() function
|
||||
in <strong>next-auth.functions.js</strong>.
|
||||
</p>
|
||||
<div className="card mt-3 mb-3">
|
||||
<h4 className="card-header">Sign In</h4>
|
||||
<div className="card-body pb-0">
|
||||
<form id="signin" method="post" action="/auth/signin" onSubmit={this.handleSignInSubmit}>
|
||||
<input name="_csrf" type="hidden" value={this.state.session.csrfToken}/>
|
||||
<p>
|
||||
<label htmlFor="email">Email address</label><br/>
|
||||
<input name="email" type="text" placeholder="j.smith@example.com" id="email" className="form-control" value={this.state.email} onChange={this.handleEmailChange}/>
|
||||
</p>
|
||||
<p>
|
||||
<label htmlFor="password">Password</label><br/>
|
||||
<input name="password" type="password" placeholder="" id="password" className="form-control" value={this.state.password} onChange={this.handlePasswordChange}/>
|
||||
</p>
|
||||
<p className="text-right">
|
||||
<button id="submitButton" type="submit" className="btn btn-outline-primary">Sign in</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-italic text-muted text-center small">
|
||||
For this to work, you will need enable the signin() function in <strong>next-auth.functions.js</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center">
|
||||
<Link href="/auth"><a>Back</a></Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default class extends React.Component {
|
||||
|
||||
static async getInitialProps({query}) {
|
||||
return {
|
||||
action: query.action || null,
|
||||
type: query.type || null,
|
||||
service: query.service || null
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.action == 'signin' && this.props.type == 'oauth') {
|
||||
return(
|
||||
<div className="container">
|
||||
<div className="text-center mb-5">
|
||||
<h1 className="display-4 mt-5 mb-3">Unable to sign in</h1>
|
||||
<p className="lead">An account associated with your email address already exists.</p>
|
||||
<p className="lead"><Link href="/auth"><a>Sign in with email or another service</a></Link></p>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-sm-8 mr-auto ml-auto mb-5 mt-5">
|
||||
<div className="text-muted">
|
||||
<h4 className="mb-2">Why am I seeing this?</h4>
|
||||
<p className="mb-3">
|
||||
It looks like you might have already signed up using another service to sign in.
|
||||
</p>
|
||||
<p className="mb-3">
|
||||
If you have previously signed up using another service you must link accounts before you
|
||||
can use a different service to sign in.
|
||||
</p>
|
||||
<p className="mb-5">
|
||||
This is to prevent people from signing up to another service using your email address
|
||||
to try and access your account.
|
||||
</p>
|
||||
<h4 className="mb-2">How do I fix this?</h4>
|
||||
<p className="mb-0">
|
||||
First sign in using your email address then link your account to the service you want
|
||||
to use to sign in with in future. You only need to do this once.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else if (this.props.action == 'signin' && this.props.type == 'token-invalid') {
|
||||
return(
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-4 mt-5 mb-2">Link not valid</h1>
|
||||
<p className="lead">This sign in link is no longer valid.</p>
|
||||
<p className="lead"><Link href="/auth"><a>Get a new sign in link</a></Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return(
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-4 mt-5">Error signing in</h1>
|
||||
<p className="lead">An error occured while trying to sign in.</p>
|
||||
<p className="lead"><Link href="/auth"><a>Sign in with email or another service</a></Link></p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import React from 'react'
|
||||
import Router from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import { NextAuth } from 'next-auth/client'
|
||||
|
||||
export default class extends React.Component {
|
||||
|
||||
static async getInitialProps({req}) {
|
||||
return {
|
||||
session: await NextAuth.init({req}),
|
||||
linkedAccounts: await NextAuth.linked({req}),
|
||||
providers: await NextAuth.providers({req})
|
||||
}
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
email: '',
|
||||
session: this.props.session
|
||||
}
|
||||
this.handleEmailChange = this.handleEmailChange.bind(this)
|
||||
this.handleSignInSubmit = this.handleSignInSubmit.bind(this)
|
||||
}
|
||||
|
||||
handleEmailChange(event) {
|
||||
this.setState({
|
||||
email: event.target.value
|
||||
})
|
||||
}
|
||||
|
||||
handleSignInSubmit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!this.state.email) return
|
||||
|
||||
NextAuth.signin(this.state.email)
|
||||
.then(() => {
|
||||
Router.push(`/auth/check-email?email=${this.state.email}`)
|
||||
})
|
||||
.catch(() => {
|
||||
Router.push(`/auth/error?action=signin&type=email&email=${this.state.email}`)
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.session.user) {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-4 mt-3">NextAuth Example</h1>
|
||||
<p className="lead mt-3 mb-1">You are signed in as <span className="font-weight-bold">{this.props.session.user.email}</span>.</p>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-sm-5 mr-auto ml-auto">
|
||||
<LinkAccounts
|
||||
session={this.props.session}
|
||||
linkedAccounts={this.props.linkedAccounts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center">
|
||||
<Link href="/"><a>Home</a></Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-4 mt-3 mb-3">NextAuth Example</h1>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-sm-6 mr-auto ml-auto">
|
||||
<div className="card mt-3 mb-3">
|
||||
<h4 className="card-header">Sign In</h4>
|
||||
<div className="card-body pb-0">
|
||||
<SignInButtons providers={this.props.providers}/>
|
||||
<form id="signin" method="post" action="/auth/email/signin" onSubmit={this.handleSignInSubmit}>
|
||||
<input name="_csrf" type="hidden" value={this.state.session.csrfToken}/>
|
||||
<p>
|
||||
<label htmlFor="email">Email address</label><br/>
|
||||
<input name="email" type="text" placeholder="j.smith@example.com" id="email" className="form-control" value={this.state.email} onChange={this.handleEmailChange}/>
|
||||
</p>
|
||||
<p className="text-right">
|
||||
<button id="submitButton" type="submit" className="btn btn-outline-primary">Sign in with email</button>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center small">
|
||||
<Link href="/auth/credentials"><a>Sign in with credentials</a></Link>
|
||||
</p>
|
||||
<p className="text-center">
|
||||
<Link href="/"><a>Home</a></Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkAccounts extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="card mt-3 mb-3">
|
||||
<h4 className="card-header">Link Accounts</h4>
|
||||
<div className="card-body pb-0">
|
||||
{
|
||||
Object.keys(this.props.linkedAccounts).map((provider, i) => {
|
||||
return <LinkAccount key={i} provider={provider} session={this.props.session} linked={this.props.linkedAccounts[provider]}/>
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkAccount extends React.Component {
|
||||
render() {
|
||||
if (this.props.linked === true) {
|
||||
return (
|
||||
<form method="post" action={`/auth/oauth/${this.props.provider.toLowerCase()}/unlink`}>
|
||||
<input name="_csrf" type="hidden" value={this.props.session.csrfToken}/>
|
||||
<p>
|
||||
<button className="btn btn-block btn-outline-danger" type="submit">
|
||||
Unlink from {this.props.provider}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<p>
|
||||
<a className="btn btn-block btn-outline-primary" href={`/auth/oauth/${this.props.provider.toLowerCase()}`}>
|
||||
Link with {this.props.provider}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SignInButtons extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
{
|
||||
Object.keys(this.props.providers).map((provider, i) => {
|
||||
return (
|
||||
<p key={i}>
|
||||
<a className="btn btn-block btn-outline-secondary" href={this.props.providers[provider].signin}>
|
||||
Sign in with {provider}
|
||||
</a>
|
||||
</p>
|
||||
)
|
||||
})
|
||||
}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react'
|
||||
import Router from 'next/router'
|
||||
import Link from 'next/link'
|
||||
import { NextAuth } from 'next-auth/client'
|
||||
|
||||
export default class extends React.Component {
|
||||
static async getInitialProps({req}) {
|
||||
return {
|
||||
session: await NextAuth.init({req})
|
||||
}
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.handleSignOutSubmit = this.handleSignOutSubmit.bind(this)
|
||||
}
|
||||
|
||||
handleSignOutSubmit(event) {
|
||||
event.preventDefault()
|
||||
NextAuth.signout()
|
||||
.then(() => {
|
||||
Router.push('/auth/callback')
|
||||
})
|
||||
.catch(err => {
|
||||
Router.push('/auth/error?action=signout')
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
<div className="text-center">
|
||||
<h1 className="display-4 mt-3 mb-3">NextAuth Example</h1>
|
||||
<p className="lead mt-3 mb-3">An example of how to use the <a href="https://www.npmjs.com/package/next-auth">NextAuth</a> module.</p>
|
||||
<SignInMessage {...this.props}/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class SignInMessage extends React.Component {
|
||||
render() {
|
||||
if (this.props.session.user) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<p><Link href="/auth"><a className="btn btn-secondary">Manage Account</a></Link></p>
|
||||
<form id="signout" method="post" action="/auth/signout" onSubmit={this.handleSignOutSubmit}>
|
||||
<input name="_csrf" type="hidden" value={this.props.session.csrfToken}/>
|
||||
<button type="submit" className="btn btn-outline-secondary">Sign out</button>
|
||||
</form>
|
||||
</React.Fragment>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<p><Link href="/auth"><a className="btn btn-primary">Sign in</a></Link></p>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
index.d.ts
vendored
84
index.d.ts
vendored
@@ -1,84 +0,0 @@
|
||||
import { Store } from "express-session";
|
||||
import { RequestHandler } from "express";
|
||||
import { IpcNetConnectOpts } from "net";
|
||||
|
||||
declare namespace nextAuth {
|
||||
interface IOptions {
|
||||
bodyParser: boolean;
|
||||
csrf: boolean;
|
||||
pathPrefix: string;
|
||||
expressApp?: Express.Application;
|
||||
expressSession: RequestHandler;
|
||||
sessionSecret: string;
|
||||
sessionStore: Store;
|
||||
sessionMaxAge: number;
|
||||
sessionRevalidateAge: number;
|
||||
sessionResave: boolean;
|
||||
sessionRolling: boolean;
|
||||
sessionSaveUninitialized: boolean;
|
||||
serverUrl?: string;
|
||||
trustProxy: boolean;
|
||||
providers: any[];
|
||||
port?: number;
|
||||
functions: IFunctions;
|
||||
}
|
||||
|
||||
interface IUserProvider {
|
||||
name: string;
|
||||
id: string;
|
||||
}
|
||||
interface ISendSignInEmailOpts {
|
||||
email?: string;
|
||||
url?: string;
|
||||
req?: Express.Request;
|
||||
}
|
||||
interface ISignInOpts {
|
||||
email?: string;
|
||||
password?: string;
|
||||
}
|
||||
interface INextAuthSessionData<UserType = {}> extends Session {
|
||||
maxAge: number;
|
||||
revalidateAge: number;
|
||||
csrfToken: string;
|
||||
user?: UserType;
|
||||
expires?: number;
|
||||
}
|
||||
interface IFunctions<
|
||||
UserType = {},
|
||||
IDType = string,
|
||||
SessionType extends INextAuthSessionData = INextAuthSessionData
|
||||
> {
|
||||
find(
|
||||
id: IDType,
|
||||
email: string,
|
||||
emailToken: string,
|
||||
provider: IUserProvider
|
||||
): Promise<UserType>;
|
||||
update: (user: UserType, profile: any) => Promise<UserType>;
|
||||
insert: (user: UserType, profile: any) => Promise<UserType>;
|
||||
remove: (id: IDType) => Promise<boolean>;
|
||||
serialize: (user: UserType) => Promise<IDType>;
|
||||
deserialize: (id: IDType) => Promise<UserType>;
|
||||
session?: (
|
||||
session: INextAuthSessionData,
|
||||
req: Express.Request
|
||||
) => SessionType;
|
||||
sendSignInEmail?: (opts: ISendSignInEmailOpts) => Promise<boolean>;
|
||||
signIn?: (opts: ISignInOpts) => Promise<UserType>;
|
||||
}
|
||||
|
||||
interface INextAuthResult {
|
||||
next?: nextApp;
|
||||
express: Express;
|
||||
expressApp: Express.Application;
|
||||
function: IFunctions;
|
||||
providers: any;
|
||||
port?: number;
|
||||
}
|
||||
}
|
||||
|
||||
declare function NextAuth(
|
||||
nextApp?: NextApp,
|
||||
options?: nextAuth.IOptions
|
||||
): Promise<nextAuth.INextAuthResult>;
|
||||
export = NextAuth;
|
||||
479
index.js
479
index.js
@@ -1,478 +1 @@
|
||||
'use strict'
|
||||
|
||||
const BodyParser = require('body-parser')
|
||||
const Express = require('express')
|
||||
const ExpressSession = require('express-session')
|
||||
const lusca = require('lusca')
|
||||
const passportStrategies = require('./src/passport-strategies')
|
||||
const uuid = require('uuid/v4')
|
||||
|
||||
module.exports = (nextApp, {
|
||||
bodyParser = true,
|
||||
// Optional, allows you to set e.g. 'limit' (maximum request body size, default 100kb)
|
||||
bodyParserJsonOptions = {},
|
||||
bodyParserUrlEncodedOptions = { extended: true },
|
||||
csrf = true,
|
||||
// URL base path for authentication routes (optional).
|
||||
// Note: The prefix value of '/auth' is currently hard coded in
|
||||
// next-auth-client so you should not change this unless you also modify it.
|
||||
pathPrefix = '/auth',
|
||||
// Express Server (optional).
|
||||
expressApp = null,
|
||||
// Express Session (optional).
|
||||
expressSession = ExpressSession,
|
||||
// Secret used to encrypt session data on the server.
|
||||
sessionSecret = 'change-me',
|
||||
// Session store for express-session.
|
||||
// Defaults to an in memory store, which is not recommended for production.
|
||||
sessionStore = expressSession.MemoryStore(),
|
||||
// The name of the session ID cookie to set in the response (and read from in
|
||||
// the request). The default value is 'connect.sid'.
|
||||
sessionCookie = 'connect.sid',
|
||||
// Maximum Session Age in ms (optional, default is 7 days).
|
||||
// The expiry time for a session is reset every time a user revisits the site
|
||||
// or revalidates their session token - this is the maximum idle time value.
|
||||
sessionMaxAge = 60000 * 60 * 24 * 7,
|
||||
// The session cookie name. Useful for adding cookie prefixes. E.g. setting
|
||||
// '__HOST-' and '__SECURE-' prefixes on cookie names prevents them from being
|
||||
// overwritten by insecure origins.
|
||||
sessionName = null,
|
||||
// Session Revalidation in X ms (optional, default is 60 seconds).
|
||||
// Specifies how often a Single Page App should revalidate a session.
|
||||
// Does not impact the session life on the server, but causes clients to
|
||||
// refetch session info (even if it is in a local cache) after N seconds has
|
||||
// elapsed since it was last checked so they always display state correctly.
|
||||
// If set to 0 will revalidate a session before rendering every page.
|
||||
sessionRevalidateAge = 60000,
|
||||
// Force the session to be saved back to the session store, even if the
|
||||
// session was not modified during the request.
|
||||
// Note: If this is false, session expiry will not rotate and will expire
|
||||
// after sessionMaxAge unless you write you own code to rotate the session.
|
||||
// This is an option exposed for advanced use cases on people with specific
|
||||
// databases that have session store drivers that do not work well with
|
||||
// the express-session resave option.
|
||||
// https://www.npmjs.com/package/express-session#resave
|
||||
sessionResave = true,
|
||||
// Force a session identifier cookie to be set on every response. The expire
|
||||
// time is reset to the original maxAge, resetting the expiration time.
|
||||
// Note When this option is set to true but the saveUninitialized option
|
||||
// is set to false, the cookie will not be set on a response with an
|
||||
// uninitialized session https://www.npmjs.com/package/express-session#rolling
|
||||
sessionRolling = true,
|
||||
// Prevent cookies from being sent cross-site, protecting against CSRF
|
||||
// attacks.
|
||||
sessionSameSite = null,
|
||||
// Forces a session that is "uninitialized" to be saved to the store.
|
||||
// A session is uninitialized when it is new but not modified. Choosing false
|
||||
// is useful for implementing login sessions, reducing server storage usage,
|
||||
// or complying with laws that require permission before setting a cookie.
|
||||
//
|
||||
// Choosing false will also help with race conditions where a client makes
|
||||
// multiple parallel requests without a session.
|
||||
//
|
||||
// Note that if the build in CSRF protection is enabled (the default) then
|
||||
// sessions will ALWAYS be 'initialized' as it saves to the session.
|
||||
// https://www.npmjs.com/package/express-session#saveuninitialized
|
||||
sessionSaveUninitialized = false,
|
||||
// Canonical URL of the server (optional, but recommended).
|
||||
// e.g. 'http://localhost:3000' or 'https://www.example.com'
|
||||
// Used in callbak URLs and email sign in links. It will be auto generated
|
||||
// if not specified, which may cause problems if your site uses multiple
|
||||
// aliases (e.g. 'example.com and 'www.examples.com').
|
||||
serverUrl = null,
|
||||
// If we are behind a proxy server and it says we are running SSL, trust it.
|
||||
// All this does is make sure we use HTTPS for callback URLs and email links.
|
||||
// You should never need to turn this off.
|
||||
trustProxy = true,
|
||||
// An array of oAuth Provider config blocks (optional).
|
||||
providers = [],
|
||||
// Port to start listening on
|
||||
port = null,
|
||||
// Functions for find, update, insert, serialize and deserialize methods.
|
||||
// They should all return a Promise with resolve() or reject().
|
||||
// find() should return resolve(null) if no matching user found.
|
||||
functions = {
|
||||
find: ({
|
||||
id,
|
||||
email,
|
||||
emailToken,
|
||||
provider // provider = { name: 'twitter', id: '123456' }
|
||||
} = {}) => { Promise.resolve(user) },
|
||||
update: (user, profile) => { Promise.resolve(user) },
|
||||
insert: (user, profile) => { Promise.resolve(user) },
|
||||
remove: (id) => { Promise.resolve(id) },
|
||||
serialize: (user) => { Promise.resolve(id) },
|
||||
deserialize: (id) => { Promise.resolve(user) },
|
||||
session: null,
|
||||
sendSignInEmail: null, /* ({
|
||||
email = null,
|
||||
url = null,
|
||||
req = null
|
||||
} = {}) => { Promise.resolve(true) }
|
||||
*/
|
||||
signIn: null /* ({
|
||||
email = null,
|
||||
password = null
|
||||
} = {}) => { Promise.resolve(user) }
|
||||
*/
|
||||
}
|
||||
} = {}) => {
|
||||
|
||||
if (typeof(functions) !== 'object') {
|
||||
throw new Error('functions must be a an object')
|
||||
}
|
||||
|
||||
if (expressApp === null) {
|
||||
expressApp = Express()
|
||||
}
|
||||
|
||||
// If an instance of nextApp was passed, let all requests to /_next/* pass
|
||||
// to it *before* Express Session and other middleware is called.
|
||||
if (nextApp) {
|
||||
expressApp.all('/_next/*', (req, res) => {
|
||||
let nextRequestHandler = nextApp.getRequestHandler()
|
||||
return nextRequestHandler(req, res)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Set up body parsing, express sessions and add CSRF tokens.
|
||||
*
|
||||
* You can set bodyParser to false and pass an Express instance if you want
|
||||
* to customise how you invoke Body Parser.
|
||||
*/
|
||||
if (bodyParser === true) {
|
||||
expressApp.use(BodyParser.json(bodyParserJsonOptions))
|
||||
expressApp.use(BodyParser.urlencoded(bodyParserUrlEncodedOptions))
|
||||
}
|
||||
expressApp.use(expressSession({
|
||||
name: sessionName,
|
||||
secret: sessionSecret,
|
||||
store: sessionStore,
|
||||
resave: sessionResave,
|
||||
rolling: sessionRolling,
|
||||
saveUninitialized: sessionSaveUninitialized,
|
||||
cookie: {
|
||||
name: sessionCookie,
|
||||
httpOnly: true,
|
||||
secure: 'auto',
|
||||
maxAge: sessionMaxAge,
|
||||
sameSite: sessionSameSite,
|
||||
}
|
||||
}))
|
||||
|
||||
if (csrf === true) {
|
||||
// If csrf is true (default) apply to all routes
|
||||
expressApp.use(lusca.csrf())
|
||||
} else if (csrf !== false) {
|
||||
// If csrf is anything else (except false) then pass it as a config option
|
||||
expressApp.use(lusca.csrf(csrf))
|
||||
} // if csrf is explicitly set to false then doesn't apply CSRF at all
|
||||
|
||||
if (trustProxy === true) {
|
||||
expressApp.set('trust proxy', 1)
|
||||
}
|
||||
|
||||
/*
|
||||
* With sessions configured we need to configure Passport and trigger
|
||||
* passport.initialize() before we add any other routes.
|
||||
*/
|
||||
passportStrategies({
|
||||
expressApp: expressApp,
|
||||
serverUrl: serverUrl,
|
||||
providers: providers,
|
||||
functions: functions
|
||||
})
|
||||
|
||||
/*
|
||||
* Add route to get CSRF token via AJAX
|
||||
*/
|
||||
expressApp.get(`${pathPrefix}/csrf`, (req, res) => {
|
||||
return res.json({
|
||||
csrfToken: res.locals._csrf
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Return session info to client
|
||||
*
|
||||
* Will be stored in local storage, so should not return sensitive data that
|
||||
* could be captured in a Cross Site Scripting attack (i.e. so not the session
|
||||
* token) – or anything you don't want users to see (like private IDs) but is
|
||||
* is okay to return things like access tokens for acessing remote services.
|
||||
*/
|
||||
expressApp.get(`${pathPrefix}/session`, (req, res) => {
|
||||
let session = {
|
||||
maxAge: sessionMaxAge,
|
||||
revalidateAge: sessionRevalidateAge,
|
||||
csrfToken: res.locals._csrf
|
||||
}
|
||||
|
||||
if (req.user)
|
||||
session.user = req.user
|
||||
|
||||
if (functions.session)
|
||||
session = functions.session(session, req)
|
||||
|
||||
return res.json(session)
|
||||
})
|
||||
|
||||
// Server side function for returning list of accounts user has linked.
|
||||
// Called when pages are rendered in on the server (instead of /auth/linked).
|
||||
// Returns all accounts the user has linked (e.g. Twitter, Facebook, Google…)
|
||||
expressApp.use((req, res, next) => {
|
||||
req.linked = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!req.user) return resolve({})
|
||||
|
||||
functions.serialize(req.user)
|
||||
.then(id => {
|
||||
if (!id) throw new Error("Unable to serialize user")
|
||||
return functions.find({ id: id })
|
||||
})
|
||||
.then(user => {
|
||||
if (!user) return resolve({})
|
||||
|
||||
let linkedAccounts = {}
|
||||
providers.forEach(provider => {
|
||||
linkedAccounts[provider.providerName] = (user[provider.providerName.toLowerCase()]) ? true : false
|
||||
})
|
||||
|
||||
return resolve(linkedAccounts)
|
||||
})
|
||||
.catch(err => {
|
||||
return reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
// Client side REST endpoint for returning list of accounts user has linked.
|
||||
// Called when pages are rendered in the browser (instead of req.linked()).
|
||||
// Returns all accounts the user has linked (e.g. Twitter, Facebook, Google…)
|
||||
expressApp.get(`${pathPrefix}/linked`, (req, res) => {
|
||||
if (!req.user) return res.json({})
|
||||
|
||||
// First get the User ID from the User, then look up the user details.
|
||||
// Note: We don't use the User object in req.user directly as it is a
|
||||
// a simplified set of properties set by functions.deserialize().
|
||||
functions.serialize(req.user)
|
||||
.then(id => {
|
||||
return functions.find({ id: id })
|
||||
})
|
||||
.then(user => {
|
||||
if (!user) return res.json({})
|
||||
|
||||
let linkedAccounts = {}
|
||||
providers.forEach(provider => {
|
||||
linkedAccounts[provider.providerName] = (user[provider.providerName.toLowerCase()]) ? true : false
|
||||
})
|
||||
|
||||
return res.json(linkedAccounts)
|
||||
})
|
||||
.catch(err => {
|
||||
return res.status(500).end()
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Return list of configured oAuth Providers
|
||||
*
|
||||
* We define this both as a server side function and a RESTful endpoint so
|
||||
* that it can be used rendering a page both server side and client side.
|
||||
*/
|
||||
// Server side function
|
||||
expressApp.use((req, res, next) => {
|
||||
req.providers = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let configuredProviders = {}
|
||||
providers.forEach(provider => {
|
||||
configuredProviders[provider.providerName] = {
|
||||
signin: (serverUrl || '') + `${pathPrefix}/oauth/${provider.providerName.toLowerCase()}`,
|
||||
callback: (serverUrl || '') + `${pathPrefix}/oauth/${provider.providerName.toLowerCase()}/callback`
|
||||
}
|
||||
})
|
||||
return resolve(configuredProviders)
|
||||
})
|
||||
}
|
||||
next()
|
||||
})
|
||||
// RESTful endpoint
|
||||
expressApp.get(`${pathPrefix}/providers`, (req, res) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let configuredProviders = {}
|
||||
providers.forEach(provider => {
|
||||
configuredProviders[provider.providerName] = {
|
||||
signin: (serverUrl || '') + `${pathPrefix}/oauth/${provider.providerName.toLowerCase()}`,
|
||||
callback: (serverUrl || '') + `${pathPrefix}/oauth/${provider.providerName.toLowerCase()}/callback`
|
||||
}
|
||||
})
|
||||
return res.json(configuredProviders)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
/*
|
||||
* Enable /auth/signin routes if signIn() function is passed
|
||||
*/
|
||||
if (functions.signIn) {
|
||||
expressApp.post(`${pathPrefix}/signin`, (req, res) => {
|
||||
// Passes all supplied credentials to the signIn function
|
||||
functions.signIn({
|
||||
form: req.body,
|
||||
req: req
|
||||
})
|
||||
.then(user => {
|
||||
if (user) {
|
||||
// If signIn() returns a user, sign in as them
|
||||
req.logIn(user, (err) => {
|
||||
if (err) return res.redirect(`${pathPrefix}/error?action=signin&type=credentials`)
|
||||
if (req.xhr) {
|
||||
// If AJAX request (from client with JS), return JSON response
|
||||
return res.json({success: true})
|
||||
} else {
|
||||
// If normal form POST (from client without JS) return redirect
|
||||
return res.redirect(`${pathPrefix}/callback?action=signin&service=credentials`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// If no user object is returned, bounce back to the sign in page
|
||||
return res.redirect(`${pathPrefix}`)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
return res.redirect(`${pathPrefix}/error?action=signin&type=credentials`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Enable /auth/email/signin routes if sendSignInEmail() function is passed
|
||||
*/
|
||||
if (functions.sendSignInEmail) {
|
||||
/*
|
||||
* Generate a one time use sign in link and email it to the user
|
||||
*/
|
||||
expressApp.post(`${pathPrefix}/email/signin`, (req, res) => {
|
||||
const email = req.body.email || null
|
||||
|
||||
if (!email || email.trim() === '') {
|
||||
res.redirect(`${pathPrefix}`)
|
||||
}
|
||||
|
||||
const token = uuid()
|
||||
const url = (serverUrl || `${req.protocol}://${req.headers.host}`) + `${pathPrefix}/email/signin/${token}`
|
||||
|
||||
// Create verification token save it to database
|
||||
functions.find({ email: email })
|
||||
.then(user => {
|
||||
if (user) {
|
||||
// If a user with that email address exists already, update token.
|
||||
user.emailToken = token
|
||||
return functions.update(user)
|
||||
} else {
|
||||
// If the user does not exist, create a new account with the token.
|
||||
return functions.insert({
|
||||
email: email,
|
||||
emailToken: token
|
||||
})
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
functions.sendSignInEmail({
|
||||
email: user.email,
|
||||
url: url,
|
||||
req: req
|
||||
})
|
||||
if (req.xhr) {
|
||||
// If AJAX request (from client with JS), return JSON response
|
||||
return res.json({success: true})
|
||||
} else {
|
||||
// If normal form POST (from client without JS) return redirect
|
||||
return res.redirect(`${pathPrefix}/check-email?email=${email}`)
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
return res.redirect(`${pathPrefix}/error?action=signin&type=email&email=${email}`)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Verify token in callback URL for email sign in
|
||||
*/
|
||||
expressApp.get(`${pathPrefix}/email/signin/:token`, (req, res) => {
|
||||
if (!req.params.token) {
|
||||
return res.redirect(`${pathPrefix}/error?action=signin&type=token-missing`)
|
||||
}
|
||||
|
||||
functions.find({ emailToken: req.params.token })
|
||||
.then(user => {
|
||||
if (user) {
|
||||
// Delete current token so it cannot be used again
|
||||
delete user.emailToken
|
||||
// Mark email as verified now we know they have access to it
|
||||
user.emailVerified = true
|
||||
return functions.update(user, null, { delete: 'emailToken' })
|
||||
} else {
|
||||
return Promise.reject(new Error("Token not valid"))
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
// If the user object is valid, sign the user in
|
||||
req.logIn(user, (err) => {
|
||||
if (err) return res.redirect(`${pathPrefix}/error?action=signin&type=token-invalid`)
|
||||
if (req.xhr) {
|
||||
// If AJAX request (from client with JS), return JSON response
|
||||
return res.json({success: true})
|
||||
} else {
|
||||
// If normal form POST (from client without JS) return redirect
|
||||
return res.redirect(`${pathPrefix}/callback?action=signin&service=email`)
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => {
|
||||
return res.redirect(`${pathPrefix}/error?action=signin&type=token-invalid`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
* Sign a user out
|
||||
*/
|
||||
expressApp.post(`${pathPrefix}/signout`, (req, res) => {
|
||||
// Log user out with Passport and remove their Express session
|
||||
req.logout()
|
||||
req.session.destroy(() => {
|
||||
return res.redirect(`${pathPrefix}/callback?action=signout`)
|
||||
})
|
||||
})
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let response = {
|
||||
next: nextApp,
|
||||
express: Express,
|
||||
expressApp: expressApp,
|
||||
functions: functions,
|
||||
providers: providers,
|
||||
port: port
|
||||
}
|
||||
|
||||
// If no port specified, don't start Express automatically
|
||||
if (!port) return resolve(response)
|
||||
|
||||
// If an instance of nextApp was passed, have it handle all other routes
|
||||
if (nextApp) {
|
||||
expressApp.all('*', (req, res) => {
|
||||
let nextRequestHandler = nextApp.getRequestHandler()
|
||||
return nextRequestHandler(req, res)
|
||||
})
|
||||
}
|
||||
|
||||
// Start Express
|
||||
return expressApp.listen(port, err => {
|
||||
if (err) reject(err)
|
||||
return resolve(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
module.exports = require('./dist/server')
|
||||
|
||||
9424
package-lock.json
generated
9424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
77
package.json
77
package.json
@@ -1,37 +1,66 @@
|
||||
{
|
||||
"name": "next-auth",
|
||||
"version": "1.13.0",
|
||||
"version": "2.0.0",
|
||||
"description": "An authentication library for Next.js",
|
||||
"repository": "https://github.com/iaincollins/next-auth.git",
|
||||
"author": "Iain Collins <me@iaincollins.com>",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "rollup --config",
|
||||
"prepare": "rollup --config"
|
||||
"build": "npm run build:js && npm run build:css",
|
||||
"build:js": "babel src --out-dir dist",
|
||||
"build:css": "postcss src/**/*.css --base src --dir dist && node scripts/wrap-css.js",
|
||||
"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: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",
|
||||
"prepublishOnly": "npm run build",
|
||||
"publish:beta": "npm publish --tag beta",
|
||||
"publish:canary": "npm publish --tag canary",
|
||||
"lint": "standard",
|
||||
"lint:fix": "standard --fix"
|
||||
},
|
||||
"author": "",
|
||||
"files": [
|
||||
"dist",
|
||||
"index.js",
|
||||
"providers.js",
|
||||
"adapters.js",
|
||||
"client.js",
|
||||
"jwt.js"
|
||||
],
|
||||
"license": "ISC",
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"babel-polyfill": "^6.26.0",
|
||||
"body-parser": "^1.18.2",
|
||||
"express": "^4.16.3",
|
||||
"express-session": "^1.15.6",
|
||||
"isomorphic-fetch": "^2.2.1",
|
||||
"lusca": "^1.6.0",
|
||||
"passport": "^0.4.0",
|
||||
"uuid": "^3.2.1"
|
||||
"crypto-js": "^4.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"nodemailer": "^6.4.6",
|
||||
"oauth": "^0.9.15",
|
||||
"preact": "^10.4.1",
|
||||
"preact-render-to-string": "^5.1.7",
|
||||
"querystring": "^0.2.0",
|
||||
"typeorm": "^0.2.24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"babel-core": "^6.26.3",
|
||||
"rollup-plugin-babel": "^3.0.7",
|
||||
"babel-plugin-external-helpers": "^6.22.0",
|
||||
"babel-preset-env": "^1.7.0",
|
||||
"bl": "^2.1.2",
|
||||
"rollup": "^0.67.4",
|
||||
"rollup-plugin-commonjs": "^9.1.3",
|
||||
"rollup-plugin-json": "^3.0.0",
|
||||
"rollup-plugin-node-builtins": "^2.1.2",
|
||||
"rollup-plugin-node-resolve": "^3.3.0",
|
||||
"semver": "^5.6.0"
|
||||
"@babel/cli": "^7.8.4",
|
||||
"@babel/core": "^7.9.6",
|
||||
"@babel/preset-env": "^7.9.6",
|
||||
"autoprefixer": "^9.7.6",
|
||||
"babel-preset-preact": "^2.0.0",
|
||||
"cssnano": "^4.1.10",
|
||||
"mongodb": "^3.5.9",
|
||||
"mysql": "^2.18.1",
|
||||
"pg": "^8.2.1",
|
||||
"postcss-cli": "^7.1.1",
|
||||
"postcss-nested": "^4.2.1",
|
||||
"standard": "^14.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
7
postcss.config.js
Normal file
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: [
|
||||
require('autoprefixer'),
|
||||
require('postcss-nested'),
|
||||
require('cssnano')({ preset: 'default' })
|
||||
]
|
||||
}
|
||||
1
providers.js
Normal file
1
providers.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require('./dist/providers').default
|
||||
@@ -1,21 +0,0 @@
|
||||
import babel from 'rollup-plugin-babel'
|
||||
|
||||
export default {
|
||||
input: 'src/client/index.js',
|
||||
output: {
|
||||
name: 'next-auth-client',
|
||||
file: 'client.js',
|
||||
format: 'umd',
|
||||
globals: {
|
||||
'fetch': 'isomorphic-fetch'
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
babel({
|
||||
babelrc: false,
|
||||
exclude: [ 'node_modules/**' ],
|
||||
presets: [['env', { modules: false }]]
|
||||
})
|
||||
],
|
||||
|
||||
}
|
||||
18
scripts/wrap-css.js
Normal file
18
scripts/wrap-css.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Serverless target in Next.js does 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
|
||||
//
|
||||
// To work around this issue, this script is a manual step that wraps CSS in a
|
||||
// JavaScript file that has the compiled CSS embedded in it, and exports only
|
||||
// a function that returns the CSS as a string.
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const pathToCssJs = path.join(__dirname, '../dist/css/index.js')
|
||||
const pathToCss = path.join(__dirname, '../dist/css/index.css')
|
||||
|
||||
const css = fs.readFileSync(pathToCss, 'utf8')
|
||||
const cssWithEscapedQuotes = css.replace(/"/gm, '\\"')
|
||||
const js = `module.exports = function() { return "${cssWithEscapedQuotes}" }`
|
||||
|
||||
fs.writeFileSync(pathToCssJs, js)
|
||||
111
src/adapters/example/index.js
Normal file
111
src/adapters/example/index.js
Normal file
@@ -0,0 +1,111 @@
|
||||
const Adapter = (config, options = {}) => {
|
||||
async function getAdapter (appOptions) {
|
||||
// Display debug output if debug option enabled
|
||||
function _debug (...args) {
|
||||
if (appOptions.debug) {
|
||||
console.log('[next-auth][debug]', ...args)
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser (profile) {
|
||||
_debug('createUser', profile)
|
||||
return null
|
||||
}
|
||||
|
||||
async function getUser (id) {
|
||||
_debug('getUser', id)
|
||||
return null
|
||||
}
|
||||
|
||||
async function getUserByEmail (email) {
|
||||
_debug('getUserByEmail', email)
|
||||
return null
|
||||
}
|
||||
|
||||
async function getUserByProviderAccountId (providerId, providerAccountId) {
|
||||
_debug('getUserByProviderAccountId', providerId, providerAccountId)
|
||||
return null
|
||||
}
|
||||
|
||||
async function updateUser (user) {
|
||||
_debug('updateUser', user)
|
||||
return null
|
||||
}
|
||||
|
||||
async function deleteUser (userId) {
|
||||
_debug('deleteUser', userId)
|
||||
return null
|
||||
}
|
||||
|
||||
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
|
||||
_debug('linkAccount', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
return null
|
||||
}
|
||||
|
||||
async function unlinkAccount (userId, providerId, providerAccountId) {
|
||||
_debug('unlinkAccount', userId, providerId, providerAccountId)
|
||||
return null
|
||||
}
|
||||
|
||||
async function createSession (user) {
|
||||
_debug('createSession', user)
|
||||
return null
|
||||
}
|
||||
|
||||
async function getSession (sessionToken) {
|
||||
_debug('getSession', sessionToken)
|
||||
return null
|
||||
}
|
||||
|
||||
async function updateSession (session, force) {
|
||||
_debug('updateSession', session)
|
||||
return null
|
||||
}
|
||||
|
||||
async function deleteSession (sessionToken) {
|
||||
_debug('deleteSession', sessionToken)
|
||||
return null
|
||||
}
|
||||
|
||||
async function createVerificationRequest (identifier, url, token, secret, provider) {
|
||||
_debug('createVerificationRequest', identifier)
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVerificationRequest (identifier, token, secret, provider) {
|
||||
_debug('getVerificationRequest', identifier, token)
|
||||
return null
|
||||
}
|
||||
|
||||
async function deleteVerificationRequest (identifier, token, secret, provider) {
|
||||
_debug('deleteVerification', identifier, token)
|
||||
return null
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByProviderAccountId,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
linkAccount,
|
||||
unlinkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createVerificationRequest,
|
||||
getVerificationRequest,
|
||||
deleteVerificationRequest
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getAdapter
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Adapter
|
||||
}
|
||||
6
src/adapters/index.js
Normal file
6
src/adapters/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import TypeORM from './typeorm'
|
||||
|
||||
export default {
|
||||
Default: TypeORM.Adapter,
|
||||
TypeORM
|
||||
}
|
||||
372
src/adapters/typeorm/index.js
Normal file
372
src/adapters/typeorm/index.js
Normal file
@@ -0,0 +1,372 @@
|
||||
import { createConnection, getConnection, getManager } from 'typeorm'
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
import { CreateUserError } from '../../lib/errors'
|
||||
import adapterConfig from './lib/config'
|
||||
import adapterTransform from './lib/transform'
|
||||
import Models from './models'
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
const Adapter = (typeOrmConfig, options = {}) => {
|
||||
// Ensure typeOrmConfigObject is normalized to an object
|
||||
const typeOrmConfigObject = (typeof typeOrmConfig === 'string')
|
||||
? adapterConfig.parseConnectionString(typeOrmConfig)
|
||||
: typeOrmConfig
|
||||
|
||||
// Load any custom models passed as an option, default to built in models
|
||||
const { models: customModels = {} } = options
|
||||
const models = {
|
||||
User: customModels.User ? customModels.User : Models.User,
|
||||
Account: customModels.Account ? customModels.Account : Models.Account,
|
||||
Session: customModels.Session ? customModels.Session : Models.Session,
|
||||
VerificationRequest: customModels.VerificationRequest ? customModels.VerificationRequest : Models.VerificationRequest
|
||||
}
|
||||
|
||||
// The models are designed for ANSI SQL databases first (as a baseline).
|
||||
// For databases that use a different pragma, we transform the models at run
|
||||
// time *unless* the models are user supplied (in which case we don't do
|
||||
// anything to do them). This function updates arguments by reference.
|
||||
adapterTransform(typeOrmConfigObject, models, options)
|
||||
|
||||
const config = adapterConfig.loadConfig(typeOrmConfigObject, { models, ...options })
|
||||
|
||||
// Create objects from models that can be consumed by functions in the adapter
|
||||
const User = models.User.model
|
||||
const Account = models.Account.model
|
||||
const Session = models.Session.model
|
||||
const VerificationRequest = models.VerificationRequest.model
|
||||
|
||||
let connection = null
|
||||
|
||||
async function getAdapter (appOptions) {
|
||||
// Helper function to reuse / restablish connections
|
||||
// (useful if they drop when after being idle)
|
||||
async function _connect () {
|
||||
// Get current connection by name
|
||||
connection = getConnection(config.name)
|
||||
|
||||
// If connection is no longer established, reconnect
|
||||
if (!connection.isConnected) { connection = await connection.connect() }
|
||||
}
|
||||
|
||||
if (!connection) {
|
||||
// If no connection, create new connection
|
||||
try {
|
||||
connection = await createConnection(config)
|
||||
} catch (error) {
|
||||
if (error.name === 'AlreadyHasActiveConnectionError') {
|
||||
// If creating connection fails because it's already
|
||||
// been re-established, check it's really up
|
||||
await _connect()
|
||||
} else {
|
||||
logger.error('ADAPTER_CONNECTION_ERROR', error)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If the connection object already exists, ensure it's valid
|
||||
await _connect()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// The models are primarily designed for ANSI SQL database, but some
|
||||
// flexiblity is required in the adapter to support non-SQL databases such
|
||||
// as MongoDB which have different pragmas.
|
||||
//
|
||||
// TypeORM does some abstraction, but doesn't handle everything (e.g. it
|
||||
// handles translating `id` and `_id` in models, but not queries) so we
|
||||
// need to handle somethings in the adapter to make it compatible.
|
||||
let idKey = 'id'
|
||||
let ObjectId
|
||||
if (config.type === 'mongodb') {
|
||||
idKey = '_id'
|
||||
const mongodb = await import('mongodb')
|
||||
ObjectId = mongodb.ObjectId
|
||||
}
|
||||
|
||||
// These values are stored as seconds, but to use them with dates in
|
||||
// JavaScript we convert them to milliseconds.
|
||||
//
|
||||
// Use a conditional to default to 30 day session age if not set - it should
|
||||
// always be set but a meaningful fallback is helpful to facilitate testing.
|
||||
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) {
|
||||
debugMessage('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) {
|
||||
debugMessage('CREATE_USER', profile)
|
||||
try {
|
||||
// Create user account
|
||||
const user = new User(profile.name, profile.email, profile.image, profile.emailVerified)
|
||||
return await getManager().save(user)
|
||||
} catch (error) {
|
||||
logger.error('CREATE_USER_ERROR', error)
|
||||
return Promise.reject(new CreateUserError(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUser (id) {
|
||||
debugMessage('GET_USER', id)
|
||||
|
||||
// In the very specific case of both using JWT for storing session data
|
||||
// and using MongoDB to store user data, the ID is a string rather than
|
||||
// an ObjectId and we need to turn it into an ObjectId.
|
||||
//
|
||||
// In all other scenarios it is already an ObjectId, because it will have
|
||||
// come from another MongoDB query.
|
||||
if (ObjectId && !(id instanceof ObjectId)) {
|
||||
id = ObjectId(id)
|
||||
}
|
||||
|
||||
try {
|
||||
return connection.getRepository(User).findOne({ [idKey]: id })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getUserByEmail (email) {
|
||||
debugMessage('GET_USER_BY_EMAIL', email)
|
||||
try {
|
||||
if (!email) { return Promise.resolve(null) }
|
||||
return connection.getRepository(User).findOne({ 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) {
|
||||
debugMessage('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
|
||||
try {
|
||||
const account = await connection.getRepository(Account).findOne({ providerId, providerAccountId })
|
||||
if (!account) { return null }
|
||||
return connection.getRepository(User).findOne({ [idKey]: account.userId })
|
||||
} catch (error) {
|
||||
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
|
||||
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser (user) {
|
||||
debugMessage('UPDATE_USER', user)
|
||||
return getManager().save(user)
|
||||
}
|
||||
|
||||
async function deleteUser (userId) {
|
||||
debugMessage('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)
|
||||
try {
|
||||
// Create provider account linked to user
|
||||
const account = new Account(userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
|
||||
return getManager().save(account)
|
||||
} catch (error) {
|
||||
logger.error('LINK_ACCOUNT_ERROR', error)
|
||||
return Promise.reject(new Error('LINK_ACCOUNT_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkAccount (userId, providerId, providerAccountId) {
|
||||
debugMessage('UNLINK_ACCOUNT', userId, providerId, providerAccountId)
|
||||
// @TODO Get current user from DB
|
||||
// @TODO Delete [provider] object from user object
|
||||
// @TODO Save changes to user object in DB
|
||||
return false
|
||||
}
|
||||
|
||||
async function createSession (user) {
|
||||
debugMessage('CREATE_SESSION', user)
|
||||
try {
|
||||
let expires = null
|
||||
if (sessionMaxAge) {
|
||||
const dateExpires = new Date()
|
||||
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
|
||||
expires = dateExpires
|
||||
}
|
||||
|
||||
const session = new Session(user.id, expires)
|
||||
|
||||
return getManager().save(session)
|
||||
} catch (error) {
|
||||
logger.error('CREATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('CREATE_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function getSession (sessionToken) {
|
||||
debugMessage('GET_SESSION', sessionToken)
|
||||
try {
|
||||
const session = await connection.getRepository(Session).findOne({ sessionToken })
|
||||
|
||||
// Check session has not expired (do not return it if it has)
|
||||
if (session && session.expires && new Date() > new Date(session.expires)) {
|
||||
// @TODO Delete old sessions from database
|
||||
return null
|
||||
}
|
||||
|
||||
return session
|
||||
} catch (error) {
|
||||
logger.error('GET_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('GET_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSession (session, force) {
|
||||
debugMessage('UPDATE_SESSION', session)
|
||||
try {
|
||||
if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) {
|
||||
// Calculate last updated date, to throttle write updates to database
|
||||
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
|
||||
// e.g. ({expiry date} - 30 days) + 1 hour
|
||||
//
|
||||
// Default for sessionMaxAge is 30 days.
|
||||
// Default for sessionUpdateAge is 1 hour.
|
||||
const dateSessionIsDueToBeUpdated = new Date(session.expires)
|
||||
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
|
||||
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
|
||||
|
||||
// Trigger update of session expiry date and write to database, only
|
||||
// if the session was last updated more than {sessionUpdateAge} ago
|
||||
if (new Date() > dateSessionIsDueToBeUpdated) {
|
||||
const newExpiryDate = new Date()
|
||||
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
|
||||
session.expires = newExpiryDate
|
||||
} else if (!force) {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// If session MaxAge, session UpdateAge or session.expires are
|
||||
// missing then don't even try to save changes, unless force is set.
|
||||
if (!force) { return null }
|
||||
}
|
||||
|
||||
return getManager().save(session)
|
||||
} catch (error) {
|
||||
logger.error('UPDATE_SESSION_ERROR', error)
|
||||
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession (sessionToken) {
|
||||
debugMessage('DELETE_SESSION', sessionToken)
|
||||
try {
|
||||
return await connection.getRepository(Session).delete({ 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) {
|
||||
debugMessage('CREATE_VERIFICATION_REQUEST', identifier)
|
||||
try {
|
||||
const { site } = appOptions
|
||||
const { sendVerificationRequest, maxAge } = provider
|
||||
|
||||
// Store hashed token (using secret as salt) so that tokens cannot be exploited
|
||||
// even if the contents of the database is compromised.
|
||||
// @TODO Use bcrypt function here instead of simple salted hash
|
||||
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
|
||||
|
||||
let expires = null
|
||||
if (maxAge) {
|
||||
const dateExpires = new Date()
|
||||
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
|
||||
expires = dateExpires
|
||||
}
|
||||
|
||||
// Save to database
|
||||
const newVerificationRequest = new VerificationRequest(identifier, hashedToken, expires)
|
||||
const verificationRequest = await getManager().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 })
|
||||
|
||||
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) {
|
||||
debugMessage('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 })
|
||||
|
||||
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 })
|
||||
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) {
|
||||
debugMessage('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 })
|
||||
} catch (error) {
|
||||
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
|
||||
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
createUser,
|
||||
getUser,
|
||||
getUserByEmail,
|
||||
getUserByProviderAccountId,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
linkAccount,
|
||||
unlinkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createVerificationRequest,
|
||||
getVerificationRequest,
|
||||
deleteVerificationRequest
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
getAdapter
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
Adapter,
|
||||
Models
|
||||
}
|
||||
67
src/adapters/typeorm/lib/config.js
Normal file
67
src/adapters/typeorm/lib/config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { EntitySchema } from 'typeorm'
|
||||
|
||||
const parseConnectionString = (configString) => {
|
||||
if (typeof configString !== 'string') { return configString }
|
||||
|
||||
// If the input is URL string, automatically convert the string to an object
|
||||
// to make configuration easier (in most use cases).
|
||||
//
|
||||
// TypeORM accepts connection string as a 'url' option, but unfortunately
|
||||
// not for all databases (e.g. SQLite) or 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.search) {
|
||||
parsedUrl.search.replace(/^\?/, '').split('&').forEach(keyValuePair => {
|
||||
let [key, value] = keyValuePair.split('=')
|
||||
// Converts true/false strings to actual boolean values
|
||||
if (value === 'true') { value = true }
|
||||
if (value === 'false') { value = false }
|
||||
config[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (error) {
|
||||
// If URL parsing fails for any reason, try letting TypeORM handle it
|
||||
return {
|
||||
url: configString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadConfig = (config, { models, namingStrategy }) => {
|
||||
const defaultConfig = {
|
||||
name: 'default',
|
||||
autoLoadEntities: true,
|
||||
entities: [
|
||||
new EntitySchema(models.User.schema),
|
||||
new EntitySchema(models.Account.schema),
|
||||
new EntitySchema(models.Session.schema),
|
||||
new EntitySchema(models.VerificationRequest.schema)
|
||||
],
|
||||
timezone: 'Z', // Required for timestamps to be treated as UTC in MySQL
|
||||
logging: false,
|
||||
namingStrategy
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultConfig,
|
||||
...config
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
parseConnectionString,
|
||||
loadConfig
|
||||
}
|
||||
45
src/adapters/typeorm/lib/naming-strategies.js
Normal file
45
src/adapters/typeorm/lib/naming-strategies.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Inspired by https://github.com/tonivj5/typeorm-naming-strategies
|
||||
import { DefaultNamingStrategy } from 'typeorm'
|
||||
import { snakeCase, camelCase } from 'typeorm/util/StringUtils'
|
||||
|
||||
export class SnakeCaseNamingStrategy extends DefaultNamingStrategy {
|
||||
// Pluralise table names (set customName to override)
|
||||
tableName (className, customName) {
|
||||
return customName || snakeCase(`${className}s`)
|
||||
}
|
||||
|
||||
columnName (propertyName, customName, embeddedPrefixes) {
|
||||
return `${snakeCase(embeddedPrefixes.join('_'))}${customName || snakeCase(propertyName)}`
|
||||
}
|
||||
|
||||
relationName (propertyName) {
|
||||
return snakeCase(propertyName)
|
||||
}
|
||||
|
||||
joinColumnName (relationName, referencedColumnName) {
|
||||
return snakeCase(`${relationName}_${referencedColumnName}`)
|
||||
}
|
||||
|
||||
joinTableName (firstTableName, secondTableName, firstPropertyName, secondPropertyName) {
|
||||
return snakeCase(`${firstTableName}_${firstPropertyName.replace(/\./gi, '_')}_${secondTableName}`)
|
||||
}
|
||||
|
||||
joinTableColumnName (tableName, propertyName, columnName) {
|
||||
return snakeCase(`${tableName}_${(columnName || propertyName)}`)
|
||||
}
|
||||
|
||||
classTableInheritanceParentColumnName (parentTableName, parentTableIdPropertyName) {
|
||||
return snakeCase(`${parentTableName}_${parentTableIdPropertyName}`)
|
||||
}
|
||||
|
||||
eagerJoinRelationAlias (alias, propertyPath) {
|
||||
return `${alias}__${propertyPath.replace('.', '_')}`
|
||||
}
|
||||
}
|
||||
|
||||
export class CamelCaseNamingStrategy extends DefaultNamingStrategy {
|
||||
// Pluralise collection names, uses (set customName to override)
|
||||
tableName (className, customName) {
|
||||
return customName || camelCase(`${className}s`)
|
||||
}
|
||||
}
|
||||
175
src/adapters/typeorm/lib/transform.js
Normal file
175
src/adapters/typeorm/lib/transform.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// Perform transforms on SQL models so they can be used with other databases
|
||||
import { SnakeCaseNamingStrategy, CamelCaseNamingStrategy } from './naming-strategies'
|
||||
|
||||
const postgres = (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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mongodb = (models, options) => {
|
||||
// A CamelCase naming strategy is used for all document databases
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new CamelCaseNamingStrategy()
|
||||
}
|
||||
|
||||
// Important!
|
||||
//
|
||||
// 1. You must set 'objectId: true' on one property on a model in MongoDB.
|
||||
//
|
||||
// 'objectId' MUST be set on the primary ID field. This overrides other
|
||||
// values on that object in TypeORM (e.g. type: 'int' or 'primary').
|
||||
//
|
||||
// 2. Other properties that are Object IDs in the same model MUST be set to
|
||||
// type: 'objectId' (and should not be set to `objectId: true`).
|
||||
//
|
||||
// If you set 'objectId: true' on multiple properties on a model you will
|
||||
// see the result of queries like find() is wrong. You will see the same
|
||||
// Object ID in every property of type Object ID in the result (but the
|
||||
// database will look fine); so use `type: 'objectId'` for them instead.
|
||||
|
||||
// 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']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (!customModels.Account) {
|
||||
delete models.Account.schema.columns.id.type
|
||||
models.Account.schema.columns.id.objectId = true
|
||||
models.Account.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'
|
||||
}
|
||||
|
||||
if (!customModels.VerificationRequest) {
|
||||
delete models.VerificationRequest.schema.columns.id.type
|
||||
models.VerificationRequest.schema.columns.id.objectId = true
|
||||
}
|
||||
}
|
||||
|
||||
const sqlite = (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.
|
||||
//
|
||||
// `timestamp` is an ANSI SQL specification and widely supported by other
|
||||
// databases so this transform is a specific workaround required for SQLite.
|
||||
//
|
||||
// NB: SQLite adds 'create' and 'update' fields to allow rows, but that is
|
||||
// specific to SQLite and so we ignore that behaviour.
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default (config, models, options) => {
|
||||
if ((config.type && config.type.startsWith('mongodb')) ||
|
||||
(config.url && config.url.startsWith('mongodb'))) {
|
||||
mongodb(models, options)
|
||||
} else if ((config.type && config.type.startsWith('postgres')) ||
|
||||
(config.url && config.url.startsWith('postgres'))) {
|
||||
postgres(models, options)
|
||||
} else if ((config.type && config.type.startsWith('sqlite')) ||
|
||||
(config.url && config.url.startsWith('sqlite'))) {
|
||||
sqlite(models, options)
|
||||
} else {
|
||||
// Apply snake case naming strategy by default for SQL databases
|
||||
if (!options.namingStrategy) {
|
||||
options.namingStrategy = new SnakeCaseNamingStrategy()
|
||||
}
|
||||
}
|
||||
}
|
||||
94
src/adapters/typeorm/models/account.js
Normal file
94
src/adapters/typeorm/models/account.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export class Account {
|
||||
constructor (
|
||||
userId,
|
||||
providerId,
|
||||
providerType,
|
||||
providerAccountId,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
accessTokenExpires
|
||||
) {
|
||||
// The compound ID ensures there is only one entry for a given provider and account
|
||||
this.compoundId = createHash('sha256').update(`${providerId}:${providerAccountId}`).digest('hex')
|
||||
this.userId = userId
|
||||
this.providerType = providerType
|
||||
this.providerId = providerId
|
||||
this.providerAccountId = providerAccountId
|
||||
this.refreshToken = refreshToken
|
||||
this.accessToken = accessToken
|
||||
this.accessTokenExpires = accessTokenExpires
|
||||
}
|
||||
}
|
||||
|
||||
export const AccountSchema = {
|
||||
name: 'Account',
|
||||
target: Account,
|
||||
columns: {
|
||||
id: {
|
||||
// This property has `objectId: true` instead of `type: int` in MongoDB
|
||||
primary: true,
|
||||
type: 'int',
|
||||
generated: true
|
||||
},
|
||||
compoundId: {
|
||||
// The compound ID ensures that there there is only one instance of an
|
||||
// OAuth account in a way that works across different databases.
|
||||
// It is not used for anything else.
|
||||
type: 'varchar',
|
||||
unique: true
|
||||
},
|
||||
userId: {
|
||||
// This property is set to `type: objectId` on MongoDB databases
|
||||
type: 'int'
|
||||
},
|
||||
providerType: {
|
||||
type: 'varchar'
|
||||
},
|
||||
providerId: {
|
||||
type: 'varchar'
|
||||
},
|
||||
providerAccountId: {
|
||||
type: 'varchar'
|
||||
},
|
||||
refreshToken: {
|
||||
type: 'text',
|
||||
nullable: true
|
||||
},
|
||||
accessToken: {
|
||||
// AccessTokens are not (yet) automatically rotated by NextAuth.js
|
||||
// You can update it using the refreshToken and the accessTokenUrl endpoint for the provider
|
||||
type: 'text',
|
||||
nullable: true
|
||||
},
|
||||
accessTokenExpires: {
|
||||
// AccessTokens expiry times are not (yet) updated by NextAuth.js
|
||||
// You can update it using the refreshToken and the accessTokenUrl endpoint for the provider
|
||||
type: 'timestamp',
|
||||
nullable: true
|
||||
},
|
||||
createdAt: {
|
||||
type: 'timestamp',
|
||||
createDate: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'timestamp',
|
||||
updateDate: true
|
||||
}
|
||||
},
|
||||
indices: [
|
||||
{
|
||||
name: 'userId',
|
||||
columns: ['userId']
|
||||
},
|
||||
{
|
||||
name: 'providerId',
|
||||
columns: ['providerId']
|
||||
},
|
||||
{
|
||||
name: 'providerAccountId',
|
||||
columns: ['providerAccountId']
|
||||
}
|
||||
]
|
||||
}
|
||||
23
src/adapters/typeorm/models/index.js
Normal file
23
src/adapters/typeorm/models/index.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Account, AccountSchema } from './account'
|
||||
import { User, UserSchema } from './user'
|
||||
import { Session, SessionSchema } from './session'
|
||||
import { VerificationRequest, VerificationRequestSchema } from './verification-request'
|
||||
|
||||
export default {
|
||||
Account: {
|
||||
model: Account,
|
||||
schema: AccountSchema
|
||||
},
|
||||
User: {
|
||||
model: User,
|
||||
schema: UserSchema
|
||||
},
|
||||
Session: {
|
||||
model: Session,
|
||||
schema: SessionSchema
|
||||
},
|
||||
VerificationRequest: {
|
||||
model: VerificationRequest,
|
||||
schema: VerificationRequestSchema
|
||||
}
|
||||
}
|
||||
50
src/adapters/typeorm/models/session.js
Normal file
50
src/adapters/typeorm/models/session.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export class Session {
|
||||
constructor (userId, expires, sessionToken, accessToken) {
|
||||
this.userId = userId
|
||||
this.expires = expires
|
||||
this.sessionToken = sessionToken || randomBytes(32).toString('hex')
|
||||
this.accessToken = accessToken || randomBytes(32).toString('hex')
|
||||
}
|
||||
}
|
||||
|
||||
export const SessionSchema = {
|
||||
name: 'Session',
|
||||
target: Session,
|
||||
columns: {
|
||||
id: {
|
||||
// This property has `objectId: true` instead of `type: int` in MongoDB
|
||||
primary: true,
|
||||
type: 'int',
|
||||
generated: true
|
||||
},
|
||||
userId: {
|
||||
// This property is set to `type: objectId` on MongoDB databases
|
||||
type: 'int'
|
||||
},
|
||||
expires: {
|
||||
// The date the session expires (is updated when a session is active)
|
||||
type: 'timestamp'
|
||||
},
|
||||
sessionToken: {
|
||||
// The sessionToken should never be exposed to client side JavaScript
|
||||
type: 'varchar',
|
||||
unique: true
|
||||
},
|
||||
accessToken: {
|
||||
// The accessToken can be safely exposed to client side JavaScript to
|
||||
// to identify the owner of a session without exposing the sessionToken
|
||||
type: 'varchar',
|
||||
unique: true
|
||||
},
|
||||
createdAt: {
|
||||
type: 'timestamp',
|
||||
createDate: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'timestamp',
|
||||
updateDate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/adapters/typeorm/models/user.js
Normal file
58
src/adapters/typeorm/models/user.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export class User {
|
||||
constructor (name, email, image, emailVerified) {
|
||||
if (name) { this.name = name }
|
||||
if (email) { this.email = email }
|
||||
if (image) { this.image = image }
|
||||
if (emailVerified) {
|
||||
const currentDate = new Date()
|
||||
this.emailVerified = currentDate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const UserSchema = {
|
||||
name: 'User',
|
||||
target: User,
|
||||
columns: {
|
||||
id: {
|
||||
// This property has `objectId: true` instead of `type: int` in MongoDB
|
||||
primary: true,
|
||||
type: 'int',
|
||||
generated: true
|
||||
},
|
||||
name: {
|
||||
type: 'varchar',
|
||||
nullable: true
|
||||
},
|
||||
email: {
|
||||
// This is inherited from the one in the OAuth provider profile on
|
||||
// initial sign in, if one is specified in that profile.
|
||||
type: 'varchar',
|
||||
unique: true,
|
||||
nullable: true
|
||||
},
|
||||
emailVerified: {
|
||||
// Contains a timestamp of the last time an action was performed that
|
||||
// confirmed this email address was active and used by the user (e.g.
|
||||
// when an email sign in link is clicked on and verified). Is null
|
||||
// if the email address specified has never been verified.
|
||||
type: 'timestamp',
|
||||
nullable: true
|
||||
},
|
||||
image: {
|
||||
// A URL that points to an avatar to use for the user.
|
||||
// This is inherited from the one in the OAuth provider profile on
|
||||
// initial sign in, if one is specified in that profile.
|
||||
type: 'varchar',
|
||||
nullable: true
|
||||
},
|
||||
createdAt: {
|
||||
type: 'timestamp',
|
||||
createDate: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'timestamp',
|
||||
updateDate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
44
src/adapters/typeorm/models/verification-request.js
Normal file
44
src/adapters/typeorm/models/verification-request.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// This model is used for sign in emails, but is designed to support other
|
||||
// mechanisms in future (e.g. 2FA via text message or short codes)
|
||||
export class VerificationRequest {
|
||||
constructor (identifier, token, expires) {
|
||||
if (identifier) { this.identifier = identifier }
|
||||
if (token) { this.token = token }
|
||||
if (expires) { this.expires = expires }
|
||||
}
|
||||
}
|
||||
|
||||
export const VerificationRequestSchema = {
|
||||
name: 'VerificationRequest',
|
||||
target: VerificationRequest,
|
||||
columns: {
|
||||
id: {
|
||||
// This property has `objectId: true` instead of `type: int` in MongoDB
|
||||
primary: true,
|
||||
type: 'int',
|
||||
generated: true
|
||||
},
|
||||
identifier: {
|
||||
// An email address, phone number, username or other unique identifier
|
||||
// associated with the request (used to track who it was on behalf of)
|
||||
type: 'varchar'
|
||||
},
|
||||
token: {
|
||||
// The token used verify the request (maybe hashed or encrypted)
|
||||
type: 'varchar',
|
||||
unique: true
|
||||
},
|
||||
expires: {
|
||||
// After this time, the request will no longer ve valid
|
||||
type: 'timestamp'
|
||||
},
|
||||
createdAt: {
|
||||
type: 'timestamp',
|
||||
createDate: true
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'timestamp',
|
||||
updateDate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,250 @@
|
||||
'use strict'
|
||||
// fetch() is built in to Next.js 9.4 (you can use a polyfill if using an older version)
|
||||
/* global fetch:false */
|
||||
import { useState, useEffect, useContext, createContext, createElement } from 'react'
|
||||
import logger from '../lib/logger'
|
||||
|
||||
import "babel-polyfill"
|
||||
import NextAuth from './next-auth-client'
|
||||
// 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.
|
||||
|
||||
export {
|
||||
NextAuth
|
||||
// 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
|
||||
|
||||
let NEXTAUTH_EVENT_LISTENER_ADDED = false
|
||||
|
||||
// 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' } })
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const value = useContext(SessionContext)
|
||||
// If we have no Provider in the tree we call the actual hook for fetching the session
|
||||
if (value === undefined) {
|
||||
return useSessionData(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 [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' } })
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// If CLIENT_MAXAGE is greater than zero, trigger auto re-fetching session
|
||||
if (clientMaxAge > 0) {
|
||||
setTimeout(async (session) => {
|
||||
await _getSession()
|
||||
}, clientMaxAge)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CLIENT_USE_SESSION_ERROR', error)
|
||||
}
|
||||
}
|
||||
useEffect(() => { _getSession() }, [])
|
||||
return [data, loading]
|
||||
}
|
||||
|
||||
// Client side method
|
||||
const signin = async (provider, args) => {
|
||||
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]) {
|
||||
// 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 = {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: _encodedForm({
|
||||
csrfToken: await getCsrfToken(),
|
||||
callbackUrl: callbackUrl,
|
||||
...args
|
||||
})
|
||||
}
|
||||
const res = await fetch(providers[provider].signinUrl, options)
|
||||
window.location = res.url ? res.url : callbackUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Client side method
|
||||
const signout = async (args) => {
|
||||
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
|
||||
|
||||
const baseUrl = _baseUrl()
|
||||
const options = {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: _encodedForm({
|
||||
csrfToken: await getCsrfToken(),
|
||||
callbackUrl: callbackUrl
|
||||
})
|
||||
}
|
||||
const res = await fetch(`${baseUrl}/signout`, options)
|
||||
|
||||
_sendMessage({ event: 'session', data: { triggeredBy: 'signout' } })
|
||||
|
||||
window.location = res.url ? res.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 _fetchData = async (url, options) => {
|
||||
try {
|
||||
const res = await fetch(url, options)
|
||||
const data = await res.json()
|
||||
return Promise.resolve(Object.keys(data).length > 0 ? data : null) // Return null if data empty
|
||||
} catch (error) {
|
||||
logger.error('CLIENT_FETCH_ERROR', url, error)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {}
|
||||
}
|
||||
}
|
||||
|
||||
const _encodedForm = (formData) => {
|
||||
return Object.keys(formData).map((key) => {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])
|
||||
}).join('&')
|
||||
}
|
||||
|
||||
const _sendMessage = (message) => {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('nextauth.message', JSON.stringify(message)) // 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'
|
||||
session: getSession,
|
||||
providers: getProviders,
|
||||
csrfToken: getCsrfToken,
|
||||
getSession,
|
||||
getProviders,
|
||||
getCsrfToken,
|
||||
useSession,
|
||||
Provider,
|
||||
signin,
|
||||
signout
|
||||
}
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
import fetch from 'isomorphic-fetch'
|
||||
|
||||
export default class {
|
||||
/**
|
||||
* This is an async, isometric method which returns a session object -
|
||||
* either by looking up the current express session object when run on the
|
||||
* server, or by using fetch (and optionally caching the result in local
|
||||
* storage) when run on the client.
|
||||
*
|
||||
* Note that actual session tokens are not stored in local storage, they are
|
||||
* kept in an HTTP Only cookie as protection against session hi-jacking by
|
||||
* malicious JavaScript.
|
||||
**/
|
||||
static async init({
|
||||
req = null,
|
||||
force = false
|
||||
} = {}) {
|
||||
let session = {}
|
||||
if (req) {
|
||||
if (req.session) {
|
||||
// If running on the server session data should be in the req object
|
||||
session.csrfToken = req.connection._httpMessage.locals._csrf
|
||||
session.expires = req.session.cookie._expires
|
||||
// If the user is logged in, add the user to the session object
|
||||
if (req.user) {
|
||||
session.user = req.user
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If running in the browser attempt to load session from sessionStore
|
||||
if (force === true) {
|
||||
// If force update is set, reset data store
|
||||
this._removeLocalStore('session')
|
||||
} else {
|
||||
session = this._getLocalStore('session')
|
||||
}
|
||||
}
|
||||
|
||||
// If session data exists, has not expired AND force is not set then
|
||||
// return the stored session we already have.
|
||||
if (session && Object.keys(session).length > 0 && session.expires && session.expires > Date.now()) {
|
||||
return new Promise(resolve => {
|
||||
resolve(session)
|
||||
})
|
||||
} else {
|
||||
// If running on server, but session has expired return empty object
|
||||
// (no valid session)
|
||||
if (typeof window === 'undefined') {
|
||||
return new Promise(resolve => {
|
||||
resolve({})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have session data, or it's expired, or force is set
|
||||
// to true then revalidate it by fetching it again from the server.
|
||||
return fetch('/auth/session', {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response
|
||||
} else {
|
||||
return Promise.reject(Error('HTTP error when trying to get session'))
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Update session with session info
|
||||
session = data
|
||||
|
||||
// Set a value we will use to check this client should silently
|
||||
// revalidate, using the value for revalidateAge returned by the server.
|
||||
session.expires = Date.now() + session.revalidateAge
|
||||
|
||||
// Save changes to session
|
||||
this._saveLocalStore('session', session)
|
||||
|
||||
return session
|
||||
})
|
||||
.catch(() => Error('Unable to get session'))
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple static method to get the CSRF Token is provided for convenience
|
||||
**/
|
||||
static async csrfToken() {
|
||||
return fetch('/auth/csrf', {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response
|
||||
} else {
|
||||
return Promise.reject(Error('Unexpected response when trying to get CSRF token'))
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => data.csrfToken)
|
||||
.catch(() => Error('Unable to get CSRF token'))
|
||||
}
|
||||
|
||||
/**
|
||||
* A static method to get list of currently linked oAuth accounts
|
||||
**/
|
||||
static async linked({
|
||||
req = null
|
||||
} = {}) {
|
||||
// If running server side, uses server side method
|
||||
if (req) return req.linked()
|
||||
|
||||
// If running client side, use RESTful endpoint
|
||||
return fetch('/auth/linked', {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response
|
||||
} else {
|
||||
return Promise.reject(Error('Unexpected response when trying to get linked accounts'))
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => data)
|
||||
.catch(() => Error('Unable to get linked accounts'))
|
||||
}
|
||||
|
||||
/**
|
||||
* A static method to get list of currently configured oAuth providers
|
||||
**/
|
||||
static async providers({
|
||||
req = null
|
||||
} = {}) {
|
||||
// If running server side, uses server side method
|
||||
if (req) return req.providers()
|
||||
|
||||
// If running client side, use RESTful endpoint
|
||||
return fetch('/auth/providers', {
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
return response
|
||||
} else {
|
||||
console.log("NextAuth Error Fetching Providers")
|
||||
return null
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => data)
|
||||
.catch((e) => {
|
||||
console.log("NextAuth Error Loading Providers")
|
||||
console.log(e)
|
||||
return null
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Sign in
|
||||
*
|
||||
* Will post a form to /auth/signin auth route if an object is passed.
|
||||
* If the details are valid a session will be created and you should redirect
|
||||
* to your callback page so the session is loaded in the client.
|
||||
*
|
||||
* If just a string containing an email address is specififed will generate a
|
||||
* a one-time use sign in link and send it via email; you should redirect to a
|
||||
* page telling the user to check their inbox for an email with the link.
|
||||
*/
|
||||
static async signin(params) {
|
||||
// Params can be just string (an email address) or an object (form fields)
|
||||
const formData = (typeof params === 'string') ? { email: params } : params
|
||||
|
||||
// Use either the email token generation route or the custom form auth route
|
||||
const route = (typeof params === 'string') ? '/auth/email/signin' : '/auth/signin'
|
||||
|
||||
// Add latest CSRF Token to request
|
||||
formData._csrf = await this.csrfToken()
|
||||
|
||||
// Encoded form parser for sending data in the body
|
||||
const encodedForm = Object.keys(formData).map((key) => {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])
|
||||
}).join('&')
|
||||
|
||||
return fetch(route, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'XMLHttpRequest' // So Express can detect AJAX post
|
||||
},
|
||||
body: encodedForm,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(async response => {
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
} else {
|
||||
throw new Error('HTTP error while attempting to sign in')
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success && data.success === true) {
|
||||
return Promise.resolve(true)
|
||||
} else {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async signout() {
|
||||
// Signout from the server
|
||||
const csrfToken = await this.csrfToken()
|
||||
const formData = { _csrf: csrfToken }
|
||||
|
||||
// Encoded form parser for sending data in the body
|
||||
const encodedForm = Object.keys(formData).map((key) => {
|
||||
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])
|
||||
}).join('&')
|
||||
|
||||
// Remove cached session data
|
||||
this._removeLocalStore('session')
|
||||
|
||||
return fetch('/auth/signout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: encodedForm,
|
||||
credentials: 'same-origin'
|
||||
})
|
||||
.then(() => {
|
||||
return true
|
||||
})
|
||||
.catch(() => Error('Unable to sign out'))
|
||||
}
|
||||
|
||||
// The Web Storage API is widely supported, but not always available (e.g.
|
||||
// it can be restricted in private browsing mode, triggering an exception).
|
||||
// We handle that silently by just returning null here.
|
||||
static _getLocalStore(name) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(name))
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
static _saveLocalStore(name, data) {
|
||||
try {
|
||||
localStorage.setItem(name, JSON.stringify(data))
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
static _removeLocalStore(name) {
|
||||
try {
|
||||
localStorage.removeItem(name)
|
||||
return true
|
||||
} catch (err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/css/index.css
Normal file
171
src/css/index.css
Normal file
@@ -0,0 +1,171 @@
|
||||
:root {
|
||||
--color-primary: #444;
|
||||
--color-control-border: #bbb;
|
||||
--color-button-hover-background: #f9f9f9;
|
||||
--color-button-active-background: #f5f5f5;
|
||||
--color-button-active-border: #aaa;
|
||||
--border-width: 1px;
|
||||
--border-radius: .3rem;
|
||||
}
|
||||
|
||||
body {
|
||||
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';
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 400;
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
margin-bottom: 0.25rem;
|
||||
display: block;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
input[type] {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: .5rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
background: #fff;
|
||||
font-size: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: inset 0 .1rem .2rem rgba(0,0,0,.2);
|
||||
|
||||
&:focus {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
padding: 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
a.button {
|
||||
text-decoration: none;
|
||||
line-height: 1rem;
|
||||
&:link,
|
||||
&:visited {
|
||||
background-color: #fff;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
button,
|
||||
a.button {
|
||||
margin: 0 0 .75rem 0;
|
||||
padding: .75rem 1rem;
|
||||
border: var(--border-width) solid var(--color-control-border);
|
||||
color: var(--color-primary);
|
||||
background-color: #fff;
|
||||
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);
|
||||
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);
|
||||
background-color: var(--color-button-active-background);
|
||||
border-color: var(--color-button-active-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
a.site {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 1rem;
|
||||
line-height: 2rem;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: table;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
> div {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
a.button {
|
||||
display: inline-block;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
margin-top: .5rem;
|
||||
}
|
||||
.message {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.signin {
|
||||
button,
|
||||
a.button,
|
||||
input[type="text"] {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
display: block;
|
||||
border: 0;
|
||||
border-top: 1px solid #ccc;
|
||||
margin: 1.5em auto 0 auto;
|
||||
overflow: visible;
|
||||
|
||||
&::before {
|
||||
content: "or";
|
||||
background: #fff;
|
||||
color: #888;
|
||||
padding: 0 .4rem;
|
||||
position: relative;
|
||||
top: -.6rem;
|
||||
}
|
||||
}
|
||||
> div,
|
||||
form {
|
||||
display: block;
|
||||
margin: 0 auto 0.5rem auto;
|
||||
input[type] {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
10
src/css/index.js
Normal file
10
src/css/index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// To support serverless targets (which don't work if you try to read in things
|
||||
// like CSS files at run time) this file is replaced in production builds with
|
||||
// a function that returns compiled CSS (embedded as a string in the function).
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const pathToCss = path.join(__dirname, '/index.css')
|
||||
const css = fs.readFileSync(pathToCss, 'utf8')
|
||||
|
||||
export default () => css
|
||||
41
src/lib/errors.js
Normal file
41
src/lib/errors.js
Normal file
@@ -0,0 +1,41 @@
|
||||
class UnknownError extends Error {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'UnknownError'
|
||||
this.message = message
|
||||
}
|
||||
|
||||
toJSON () {
|
||||
return {
|
||||
error: {
|
||||
name: this.name,
|
||||
message: this.message
|
||||
// stack: this.stack
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CreateUserError extends UnknownError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'CreateUserError'
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown when an Email address is already associated with an account
|
||||
// but the user is trying an oAuth account that is not linked to it.
|
||||
class AccountNotLinkedError extends UnknownError {
|
||||
constructor (message) {
|
||||
super(message)
|
||||
this.name = 'AccountNotLinkedError'
|
||||
this.message = message
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
UnknownError,
|
||||
CreateUserError,
|
||||
AccountNotLinkedError
|
||||
}
|
||||
44
src/lib/jwt.js
Normal file
44
src/lib/jwt.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
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 }
|
||||
}
|
||||
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 }) => {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 secureCookieName = '__Secure-next-auth.session-token'
|
||||
const insecureCookieName = 'next-auth.session-token'
|
||||
const cookieValue = cookieName ? req.cookies[cookieName] : req.cookies[secureCookieName] || req.cookies[insecureCookieName]
|
||||
|
||||
if (!cookieValue) { return null }
|
||||
|
||||
try {
|
||||
return await decode({ secret, token: cookieValue })
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
encode,
|
||||
decode,
|
||||
getJwt
|
||||
}
|
||||
23
src/lib/logger.js
Normal file
23
src/lib/logger.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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()}`
|
||||
)
|
||||
}
|
||||
},
|
||||
debug: (debugCode, ...text) => {
|
||||
if (process && process.env && process.env._NEXT_AUTH_DEBUG) {
|
||||
console.log(
|
||||
`[next-auth][debug][${debugCode}]`,
|
||||
text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default logger
|
||||
@@ -1,347 +0,0 @@
|
||||
/*
|
||||
* Configures Passport Strategies
|
||||
*/
|
||||
'use strict'
|
||||
|
||||
const passport = require('passport')
|
||||
|
||||
module.exports = ({
|
||||
expressApp = null, // Express Server
|
||||
pathPrefix = '/auth', // URL base path for authentication routes
|
||||
providers = [],
|
||||
serverUrl = null,
|
||||
functions = {
|
||||
find: ({
|
||||
id,
|
||||
email,
|
||||
emailToken,
|
||||
provider
|
||||
} = {}) => {},
|
||||
update: (user, profile) => {},
|
||||
insert: (user, profile) => {},
|
||||
serialize: (user) => {},
|
||||
deserialize: (id) => {}
|
||||
}
|
||||
} = {}) => {
|
||||
if (expressApp === null) {
|
||||
throw new Error('expressApp must be an instance of an express server')
|
||||
}
|
||||
|
||||
if (typeof(functions) !== 'object') {
|
||||
throw new Error('functions must be a an object')
|
||||
}
|
||||
|
||||
/*
|
||||
* Return functions ID property from a functions object
|
||||
*/
|
||||
passport.serializeUser((user, next) => {
|
||||
functions.serialize(user)
|
||||
.then(id => {
|
||||
next(null, id)
|
||||
})
|
||||
.catch(err => {
|
||||
next(err, false)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Return functions from a functions ID
|
||||
*/
|
||||
passport.deserializeUser((id, next) => {
|
||||
functions.deserialize(id)
|
||||
.then(user => {
|
||||
if (!user) return next(null, false)
|
||||
next(null, user)
|
||||
})
|
||||
.catch(err => {
|
||||
next(err, false)
|
||||
})
|
||||
})
|
||||
|
||||
// Define a Passport strategy for provider
|
||||
providers.forEach(({
|
||||
providerName,
|
||||
Strategy,
|
||||
strategyOptions,
|
||||
getProfile
|
||||
}) => {
|
||||
|
||||
strategyOptions.callbackURL = (strategyOptions.callbackURL || (serverUrl || '') + `${pathPrefix}/oauth/${providerName.toLowerCase()}/callback`)
|
||||
strategyOptions.passReqToCallback = true
|
||||
|
||||
passport.use(providerName, new Strategy(strategyOptions, (req, accessToken, refreshToken, _params, _profile, next) => {
|
||||
|
||||
try {
|
||||
// Normalise the provider specific profile into a standard basic
|
||||
// profile object with just { id, name, email } properties.
|
||||
let profile = getProfile(_profile)
|
||||
|
||||
const linkToExistingAccount = user => {
|
||||
const nextUser = Object.assign({}, user)
|
||||
|
||||
// If we don't already have a name for the user, use value the
|
||||
// name value specfied in their profile on the remote service.
|
||||
nextUser.name = user.name || profile.name
|
||||
|
||||
// If we don't have a real email address for the user, use the
|
||||
// email value specified in their profile on the remote service.
|
||||
if (
|
||||
user.email &&
|
||||
user.email.match(/.*@localhost\.localdomain$/) &&
|
||||
profile.email &&
|
||||
!profile.email.match(/.*@localhost\.localdomain$/)
|
||||
) {
|
||||
nextUser.emailVerified = false
|
||||
nextUser.email = profile.email
|
||||
}
|
||||
|
||||
// Save Profile ID, Access Token and Refresh Token values
|
||||
// to the users local account, which links the accounts.
|
||||
nextUser[providerName.toLowerCase()] = {
|
||||
id: profile.id,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
};
|
||||
|
||||
// Update details for the new provider for this user.
|
||||
return functions.update(nextUser, _profile, _params)
|
||||
}
|
||||
|
||||
// Save the Access Token to the current session.
|
||||
req.session[providerName.toLowerCase()] = {
|
||||
accessToken: accessToken
|
||||
}
|
||||
|
||||
// If we didn't get an email address from the oAuth provider then
|
||||
// generate a unique one as placeholder, using Provider name and ID.
|
||||
//
|
||||
// If you want users to specify a valid email address after signing in,
|
||||
// you can check for email addresses ending "@localhost.localdomain"
|
||||
// and prompt those users to supply a valid address.
|
||||
if (!profile.email) {
|
||||
profile.email = `${providerName.toLowerCase()}-${profile.id}@localhost.localdomain`
|
||||
}
|
||||
|
||||
// Look for a user in the database associated with this account.
|
||||
functions.find({
|
||||
provider: {
|
||||
name: providerName.toLowerCase(),
|
||||
id: profile.id
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
if (req.user) {
|
||||
// This section handles scenarios when a user is already signed in.
|
||||
|
||||
if (user) {
|
||||
// This section handles if the user is already logged in
|
||||
if (req.user.id === user.id) {
|
||||
// This section handles if the user is already logged in and is
|
||||
// already linked to local account they are signed in with.
|
||||
// If they are, all we need to do is update the Refresh Token
|
||||
// value if we got one.
|
||||
if (refreshToken) {
|
||||
user[providerName.toLowerCase()] = {
|
||||
id: profile.id,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
}
|
||||
|
||||
functions.update(user, _profile, _params)
|
||||
.then(user => {
|
||||
return next(null, user)
|
||||
})
|
||||
.catch(err => {
|
||||
next(err)
|
||||
})
|
||||
} else {
|
||||
return next(null, user)
|
||||
}
|
||||
} else {
|
||||
// This section handles if a user is logged in but the oAuth
|
||||
// account they are trying to link to is already linked to a
|
||||
// different local account.
|
||||
|
||||
// This prevents users from linking an oAuth account to more
|
||||
// than one local account at the same time.
|
||||
return next(null, false)
|
||||
}
|
||||
} else {
|
||||
// This secion handles if a user is already logged in and is
|
||||
// trying to link a new account to the existing local account.
|
||||
|
||||
// First get the User ID from the User, then look up the user
|
||||
// details. Note: We don't use the User object in req.user
|
||||
// directly as it is a simplified set of properties set by
|
||||
// functions.deserialize().
|
||||
return functions.serialize(req.user)
|
||||
.then(id => {
|
||||
if (!id) throw new Error("Unable to serialize user")
|
||||
return functions.find({ id: id })
|
||||
})
|
||||
.then(user => {
|
||||
// This error should not happen, unless the currently signed in
|
||||
// user has been deleted deleted from the database since
|
||||
// signing in (or there is a problem talking to the database).
|
||||
if (!user) return next(new Error("Unable to look up account for current user"), false);
|
||||
return linkToExistingAccount(user)
|
||||
})
|
||||
.then(user => {
|
||||
next(null, user)
|
||||
})
|
||||
.catch(err => next(err, false));
|
||||
}
|
||||
|
||||
} else {
|
||||
// This section handles scenarios when a user is not logged in.
|
||||
|
||||
if (user) {
|
||||
// This section handles senarios where the user is not logged in
|
||||
// but they seem to have an account already, so we sign them in
|
||||
// as that user.
|
||||
|
||||
// Update Access and Refresh Tokens for the user if we got them.
|
||||
if (accessToken || refreshToken) {
|
||||
if (accessToken) user[providerName.toLowerCase()].accessToken = accessToken
|
||||
if (refreshToken) user[providerName.toLowerCase()].refreshToken = refreshToken
|
||||
return functions.update(user, _profile, _params)
|
||||
.then(user => {
|
||||
return next(null, user)
|
||||
})
|
||||
.catch(err => {
|
||||
return next(err, false)
|
||||
})
|
||||
} else {
|
||||
return next(null, user)
|
||||
}
|
||||
} else {
|
||||
// This section handles senarios where the user is not logged in
|
||||
// and they don't have a local account already.
|
||||
|
||||
// First we check to see if a local account with the same email
|
||||
// address as the one associated with their oAuth profile exists.
|
||||
//
|
||||
// This is so they can't accidentally end up with two accounts
|
||||
// linked to the same email address.
|
||||
return functions.find({email: profile.email})
|
||||
.then(user => {
|
||||
|
||||
if (user) {
|
||||
// We already have a local account associated with their
|
||||
// email address.
|
||||
|
||||
// If the provider is trustworthy and doesn't allow to create accounts
|
||||
// for another user emails, then we allow to link this new account to
|
||||
// the corresponding local account.
|
||||
//
|
||||
// Note: By default, we treat all providers as not trustworthy. Enable
|
||||
// the `trustedIdentity` flag at your own risk and only to those providers,
|
||||
// who you are sure will guarantee that identities can not be spoofed.
|
||||
const provider = providers.find(provider => provider.providerName === providerName)
|
||||
if (provider && provider.trustedIdentity) {
|
||||
return linkToExistingAccount(user)
|
||||
.then(user => {
|
||||
next(null, user)
|
||||
})
|
||||
.catch(err => next(err, false))
|
||||
}
|
||||
|
||||
// Otherwise, the user should sign in with his/her local account first -
|
||||
// and then they can link accounts if they wish.
|
||||
//
|
||||
// Note: Automatically linking insufficiently trusted accounts here could
|
||||
// expose a potential security exploit allowing someone to pre-register
|
||||
// or create an account elsewhere for another users email
|
||||
// address then trying to sign in from it, so don't do that.
|
||||
return next(null, false)
|
||||
}
|
||||
|
||||
// If an account does not exist, create one for them and return
|
||||
// a user object to passport, which will sign them in.
|
||||
return functions.insert({
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
[providerName.toLowerCase()]: {
|
||||
id: profile.id,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
}
|
||||
}, _profile, _params)
|
||||
.then(user => {
|
||||
return next(null, user)
|
||||
})
|
||||
.catch(err => {
|
||||
return next(err, false)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
next(err, false)
|
||||
})
|
||||
} catch (err) {
|
||||
return next(err, false)
|
||||
}
|
||||
|
||||
}))
|
||||
})
|
||||
|
||||
// Initialise Passport
|
||||
expressApp.use(passport.initialize())
|
||||
expressApp.use(passport.session())
|
||||
|
||||
// Add routes for each provider
|
||||
providers.forEach(({
|
||||
providerName,
|
||||
providerOptions
|
||||
}) => {
|
||||
// Route to start sign in
|
||||
expressApp.get(`${pathPrefix}/oauth/${providerName.toLowerCase()}`, passport.authenticate(providerName, providerOptions))
|
||||
|
||||
// Route to call back to after signing in
|
||||
expressApp.get(`${pathPrefix}/oauth/${providerName.toLowerCase()}/callback`,
|
||||
passport.authenticate(providerName, {
|
||||
successRedirect: `${pathPrefix}/callback?action=signin&service=${providerName}`,
|
||||
failureRedirect: `${pathPrefix}/error?action=signin&type=oauth&service=${providerName}`
|
||||
})
|
||||
)
|
||||
|
||||
// Route to post to unlink accounts
|
||||
expressApp.post(`${pathPrefix}/oauth/${providerName.toLowerCase()}/unlink`, (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next(new Error('Not signed in'))
|
||||
}
|
||||
|
||||
// First get the User ID from the User, then look up the user details.
|
||||
// Note: We don't use the User object in req.user directly as it is a
|
||||
// a simplified set of properties set by functions.deserialize().
|
||||
functions.serialize(req.user)
|
||||
.then(id => {
|
||||
if (!id) throw new Error("Unable to serialize user")
|
||||
return functions.find({ id: id })
|
||||
})
|
||||
.then(user => {
|
||||
if (!user) return next(new Error('Unable to look up account for current user'))
|
||||
|
||||
// Remove connection between user account and oauth provider
|
||||
if (user[providerName.toLowerCase()]) {
|
||||
delete user[providerName.toLowerCase()]
|
||||
}
|
||||
|
||||
return functions.update(user, null, { delete: providerName.toLowerCase() })
|
||||
.then(user => {
|
||||
return res.redirect(`${pathPrefix}/callback?action=unlink&service=${providerName.toLowerCase()}`)
|
||||
})
|
||||
.catch(err => {
|
||||
return next(err, false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// A catch all for providers that are not configured
|
||||
expressApp.get(`${pathPrefix}/oauth/:provider`, (req, res) => {
|
||||
return res.redirect(`${pathPrefix}/error?action=signin&type=unsupported`)
|
||||
})
|
||||
}
|
||||
48
src/providers/apple.js
Normal file
48
src/providers/apple.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'apple',
|
||||
name: 'Apple',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'name email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://appleid.apple.com/auth/token',
|
||||
authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post',
|
||||
profileUrl: null,
|
||||
idToken: true,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.name == null ? profile.sub : profile.name,
|
||||
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
|
||||
}
|
||||
}
|
||||
22
src/providers/auth0.js
Normal file
22
src/providers/auth0.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'auth0',
|
||||
name: 'Auth0',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
params: { grant_type: 'authorization_code', response_type: '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`,
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.sub,
|
||||
name: profile.nickname,
|
||||
email: profile.email,
|
||||
image: profile.picture
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
22
src/providers/box.js
Normal file
22
src/providers/box.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'box',
|
||||
name: 'Box',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: '',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://api.box.com/oauth2/token',
|
||||
authorizationUrl: 'https://account.box.com/api/oauth2/authorize?response_type=code',
|
||||
profileUrl: 'https://api.box.com/2.0/users/me',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.login,
|
||||
image: profile.avatar_url
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
10
src/providers/credentials.js
Normal file
10
src/providers/credentials.js
Normal file
@@ -0,0 +1,10 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'credentials',
|
||||
name: 'Credentials',
|
||||
type: 'credentials',
|
||||
authorize: null,
|
||||
credentials: null,
|
||||
...options
|
||||
}
|
||||
}
|
||||
23
src/providers/discord.js
Normal file
23
src/providers/discord.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'discord',
|
||||
name: 'Discord',
|
||||
type: 'oauth',
|
||||
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',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.username,
|
||||
image: `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`,
|
||||
email: profile.email
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
73
src/providers/email.js
Normal file
73
src/providers/email.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import nodemailer from 'nodemailer'
|
||||
import logger from '../lib/logger'
|
||||
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'email',
|
||||
type: 'email',
|
||||
name: 'Email',
|
||||
// Server can be an SMTP connection string or a nodemailer config object
|
||||
server: {
|
||||
host: 'localhost',
|
||||
port: 25,
|
||||
auth: {
|
||||
user: '',
|
||||
pass: ''
|
||||
}
|
||||
},
|
||||
from: 'NextAuth <no-reply@example.com>',
|
||||
maxAge: 24 * 60 * 60, // How long email links are valid for (default 24h)
|
||||
sendVerificationRequest,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
const sendVerificationRequest = ({ identifier: emailAddress, url, token, site, provider }) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { server, from } = provider
|
||||
const siteName = site.replace(/^https?:\/\//, '')
|
||||
|
||||
nodemailer
|
||||
.createTransport(server)
|
||||
.sendMail({
|
||||
to: emailAddress,
|
||||
from,
|
||||
subject: `Sign in to ${siteName}`,
|
||||
text: text({ url, siteName }),
|
||||
html: html({ url, siteName })
|
||||
}, (error) => {
|
||||
if (error) {
|
||||
logger.error('SEND_VERIFICATION_EMAIL_ERROR', emailAddress, error)
|
||||
return reject(new Error('SEND_VERIFICATION_EMAIL_ERROR', error))
|
||||
}
|
||||
return resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Email HTML body
|
||||
const html = ({ url, siteName }) => {
|
||||
const buttonBackgroundColor = '#444444'
|
||||
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>
|
||||
`
|
||||
}
|
||||
|
||||
// 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`
|
||||
21
src/providers/facebook.js
Normal file
21
src/providers/facebook.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'facebook',
|
||||
name: 'Facebook',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'email',
|
||||
accessTokenUrl: 'https://graph.facebook.com/oauth/access_token',
|
||||
authorizationUrl: 'https://www.facebook.com/v7.0/dialog/oauth?response_type=code',
|
||||
profileUrl: 'https://graph.facebook.com/me?fields=email,name,picture',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture.data.url
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
21
src/providers/github.js
Normal file
21
src/providers/github.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'user',
|
||||
accessTokenUrl: 'https://github.com/login/oauth/access_token',
|
||||
authorizationUrl: 'https://github.com/login/oauth/authorize',
|
||||
profileUrl: 'https://api.github.com/user',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.avatar_url
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
22
src/providers/gitlab.js
Normal file
22
src/providers/gitlab.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'gitlab',
|
||||
name: 'GitLab',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'read_user',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://gitlab.com/oauth/token',
|
||||
authorizationUrl: 'https://gitlab.com/oauth/authorize?response_type=code',
|
||||
profileUrl: 'https://gitlab.com/api/v4/user',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.username,
|
||||
email: profile.email,
|
||||
image: profile.avatar_url
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
23
src/providers/google.js
Normal file
23
src/providers/google.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://accounts.google.com/o/oauth2/token',
|
||||
requestTokenUrl: 'https://accounts.google.com/o/oauth2/auth',
|
||||
authorizationUrl: 'https://accounts.google.com/o/oauth2/auth?response_type=code',
|
||||
profileUrl: 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.picture
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
18
src/providers/identity-server4.js
Normal file
18
src/providers/identity-server4.js
Normal file
@@ -0,0 +1,18 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'identity-server4',
|
||||
name: 'IdentityServer4',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'openid profile email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: `https://${options.domain}/connect/token`,
|
||||
authorizationUrl: `https://${options.domain}/connect/authorize?response_type=code`,
|
||||
profileUrl: `https://${options.domain}/connect/userinfo`,
|
||||
profile: (profile) => {
|
||||
return { ...profile, id: profile.sub }
|
||||
},
|
||||
setGetAccessTokenAuthHeader: false,
|
||||
...options
|
||||
}
|
||||
}
|
||||
37
src/providers/index.js
Normal file
37
src/providers/index.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import Auth0 from './auth0'
|
||||
import Apple from './apple'
|
||||
import Box from './box'
|
||||
import Credentials from './credentials'
|
||||
import Discord from './discord'
|
||||
import Email from './email'
|
||||
import Facebook from './facebook' // @TODO
|
||||
import GitHub from './github'
|
||||
import GitLab from './gitlab'
|
||||
import Google from './google'
|
||||
import IdentityServer4 from './identity-server4'
|
||||
import Mixer from './mixer'
|
||||
import Okta from './okta'
|
||||
import Slack from './slack'
|
||||
import Twitch from './twitch'
|
||||
import Twitter from './twitter'
|
||||
import Yandex from './yandex'
|
||||
|
||||
export default {
|
||||
Auth0,
|
||||
Apple,
|
||||
Box,
|
||||
Credentials,
|
||||
Discord,
|
||||
Email,
|
||||
Facebook,
|
||||
GitHub,
|
||||
GitLab,
|
||||
Google,
|
||||
IdentityServer4,
|
||||
Mixer,
|
||||
Okta,
|
||||
Slack,
|
||||
Twitter,
|
||||
Twitch,
|
||||
Yandex
|
||||
}
|
||||
22
src/providers/mixer.js
Normal file
22
src/providers/mixer.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'mixer',
|
||||
name: 'Mixer',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'user:details:self',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://mixer.com/api/v1/oauth/token',
|
||||
authorizationUrl: 'https://mixer.com/oauth/authorize?response_type=code',
|
||||
profileUrl: 'https://mixer.com/api/v1/users/current',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.username,
|
||||
image: profile.avatarUrl,
|
||||
email: profile.email
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
23
src/providers/okta.js
Normal file
23
src/providers/okta.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'okta',
|
||||
name: 'Okta',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'openid profile email',
|
||||
params: {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: options.clientId,
|
||||
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/`,
|
||||
profile: (profile) => {
|
||||
return { ...profile, id: profile.sub }
|
||||
},
|
||||
setGetAccessTokenAuthHeader: false,
|
||||
...options
|
||||
}
|
||||
}
|
||||
24
src/providers/reddit.js
Normal file
24
src/providers/reddit.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Logging in works but trying to retrieve the profile results in 401 unauthorized
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'reddit',
|
||||
name: 'Reddit',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'identity',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: ' https://www.reddit.com/api/v1/access_token',
|
||||
authorizationUrl:
|
||||
'https://www.reddit.com/api/v1/authorize?response_type=code',
|
||||
profileUrl: 'https://oauth.reddit.com/api/v1/me',
|
||||
profile: (profile) => {
|
||||
// return {
|
||||
// id: profile.id,
|
||||
// name: profile.name,
|
||||
// image: null,
|
||||
// email: null,
|
||||
// };
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
23
src/providers/slack.js
Normal file
23
src/providers/slack.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'identity.basic identity.email identity.avatar',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://slack.com/api/oauth.access',
|
||||
authorizationUrl: 'https://slack.com/oauth/authorize?response_type=code',
|
||||
profileUrl: 'https://slack.com/api/users.identity',
|
||||
profile: (profile) => {
|
||||
const { user } = profile
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
image: user.image_512,
|
||||
email: user.email
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
24
src/providers/twitch.js
Normal file
24
src/providers/twitch.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'twitch',
|
||||
name: 'Twitch',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'user:read:email',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://id.twitch.tv/oauth2/token',
|
||||
authorizationUrl:
|
||||
'https://id.twitch.tv/oauth2/authorize?response_type=code',
|
||||
profileUrl: 'https://api.twitch.tv/helix/users',
|
||||
profile: (profile) => {
|
||||
const data = profile.data[0]
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.display_name,
|
||||
image: data.profile_image_url,
|
||||
email: data.email
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
22
src/providers/twitter.js
Normal file
22
src/providers/twitter.js
Normal file
@@ -0,0 +1,22 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'twitter',
|
||||
name: 'Twitter',
|
||||
type: 'oauth',
|
||||
version: '1.0A',
|
||||
scope: '',
|
||||
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',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
image: profile.profile_image_url_https.replace(/_normal\.jpg$/, '.jpg')
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
23
src/providers/yandex.js
Normal file
23
src/providers/yandex.js
Normal file
@@ -0,0 +1,23 @@
|
||||
export default (options) => {
|
||||
return {
|
||||
id: 'yandex',
|
||||
name: 'Yandex',
|
||||
type: 'oauth',
|
||||
version: '2.0',
|
||||
scope: 'login:email login:info',
|
||||
params: { grant_type: 'authorization_code' },
|
||||
accessTokenUrl: 'https://oauth.yandex.ru/token',
|
||||
requestTokenUrl: 'https://oauth.yandex.ru/token',
|
||||
authorizationUrl: 'https://oauth.yandex.ru/authorize?response_type=code',
|
||||
profileUrl: 'https://login.yandex.ru/info?format=json',
|
||||
profile: (profile) => {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.real_name,
|
||||
email: profile.default_email,
|
||||
image: null
|
||||
}
|
||||
},
|
||||
...options
|
||||
}
|
||||
}
|
||||
317
src/server/index.js
Normal file
317
src/server/index.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import jwt from '../lib/jwt'
|
||||
import cookie from './lib/cookie'
|
||||
import callbackUrlHandler from './lib/callback-url-handler'
|
||||
import parseProviders from './lib/providers'
|
||||
import events from './lib/events'
|
||||
import callbacks from './lib/callbacks'
|
||||
import providers from './routes/providers'
|
||||
import signin from './routes/signin'
|
||||
import signout from './routes/signout'
|
||||
import callback from './routes/callback'
|
||||
import session from './routes/session'
|
||||
import pages from './pages'
|
||||
import adapters from '../adapters'
|
||||
|
||||
const DEFAULT_SITE = 'http://localhost:3000'
|
||||
const DEFAULT_BASE_PATH = '/api/auth'
|
||||
|
||||
export default async (req, res, userSuppliedOptions) => {
|
||||
// To the best of my knowledge, we need to return a promise here
|
||||
// to avoid early termination of calls to the serverless function
|
||||
// (and then return that promise when we are done) - eslint
|
||||
// complains but I'm not sure there is another way to do this.
|
||||
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
|
||||
// This is passed to all methods that handle responses, and must be called
|
||||
// when they are complete so that the serverless function knows when it is
|
||||
// safe to return and that no more data will be sent.
|
||||
const done = resolve
|
||||
|
||||
const { url, query, body } = req
|
||||
const {
|
||||
nextauth,
|
||||
action = nextauth[0],
|
||||
provider = nextauth[1],
|
||||
error
|
||||
} = 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}`
|
||||
|
||||
// Parse database / adapter
|
||||
let adapter
|
||||
if (userSuppliedOptions.adapter) {
|
||||
// If adapter is provided, use it (advanced usage, overrides database)
|
||||
adapter = userSuppliedOptions.adapter
|
||||
} else if (userSuppliedOptions.database) {
|
||||
// If database URI or config object is provided, use it (simple usage)
|
||||
adapter = adapters.Default(userSuppliedOptions.database)
|
||||
}
|
||||
|
||||
// Secret used salt cookies and tokens (e.g. for CSRF protection).
|
||||
// If no secret option is specified then it creates one on the fly
|
||||
// based on options passed here. A options contains unique data, such as
|
||||
// oAuth provider secrets and database credentials it should be sufficent.
|
||||
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify(userSuppliedOptions)).digest('hex')
|
||||
|
||||
// Use secure cookies if the site uses HTTPS
|
||||
// This being conditional allows cookies to work non-HTTPS development URLs
|
||||
// Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
|
||||
// prefix, but enable them by default if the site URL is HTTPS; but not for
|
||||
// non-HTTPS URLs like http://localhost which are used in development).
|
||||
// For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
|
||||
const useSecureCookies = userSuppliedOptions.useSecureCookies || baseUrl.startsWith('https://')
|
||||
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
|
||||
|
||||
// @TODO Review cookie settings (names, options)
|
||||
const cookies = {
|
||||
// default cookie options
|
||||
sessionToken: {
|
||||
name: `${cookiePrefix}next-auth.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
callbackUrl: {
|
||||
name: `${cookiePrefix}next-auth.callback-url`,
|
||||
options: {
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
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.
|
||||
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
secure: useSecureCookies
|
||||
}
|
||||
},
|
||||
// Allow user cookie options to override any cookie settings above
|
||||
...userSuppliedOptions.cookies
|
||||
}
|
||||
|
||||
// Session options
|
||||
const sessionOption = {
|
||||
jwt: false,
|
||||
maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle
|
||||
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
|
||||
...userSuppliedOptions.session
|
||||
}
|
||||
|
||||
// JWT options
|
||||
const jwtOptions = {
|
||||
secret,
|
||||
key: secret,
|
||||
encode: jwt.encode,
|
||||
decode: jwt.decode,
|
||||
...userSuppliedOptions.jwt
|
||||
}
|
||||
|
||||
// If no adapter specified, force use of JSON Web Tokens (stateless)
|
||||
if (!adapter) {
|
||||
sessionOption.jwt = true
|
||||
}
|
||||
|
||||
// Event messages
|
||||
const eventsOption = {
|
||||
...events,
|
||||
...userSuppliedOptions.events
|
||||
}
|
||||
|
||||
// Callback functions
|
||||
const callbacksOption = {
|
||||
...callbacks,
|
||||
...userSuppliedOptions.callbacks
|
||||
}
|
||||
|
||||
// Ensure CSRF Token cookie is set for any subsequent requests.
|
||||
// Used as part of the strateigy for mitigation for CSRF tokens.
|
||||
//
|
||||
// Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
|
||||
// where 'token' is the CSRF token and 'hash' is a hash made of the token and
|
||||
// the secret, and the two values are joined by a pipe '|'. By storing the
|
||||
// value and the hash of the value (with the secret used as a salt) we can
|
||||
// verify the cookie was set by the server and not by a malicous attacker.
|
||||
//
|
||||
// For more details, see the following OWASP links:
|
||||
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
|
||||
// https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
|
||||
let csrfToken
|
||||
let csrfTokenVerified = false
|
||||
if (req.cookies[cookies.csrfToken.name]) {
|
||||
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
|
||||
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
|
||||
// If hash matches then we trust the CSRF token value
|
||||
csrfToken = csrfTokenValue
|
||||
|
||||
// If this is a POST request and the CSRF Token in the Post request matches
|
||||
// the cookie we have already verified is one we have set, then token is verified!
|
||||
if (req.method === 'POST' && csrfToken === csrfTokenFromPost) { csrfTokenVerified = true }
|
||||
}
|
||||
}
|
||||
if (!csrfToken) {
|
||||
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
|
||||
// (e.g. because it's been modifed or because the secret has changed) create a new token.
|
||||
csrfToken = randomBytes(32).toString('hex')
|
||||
const newCsrfTokenCookie = `${csrfToken}|${createHash('sha256').update(`${csrfToken}${secret}`).digest('hex')}`
|
||||
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Custom options override defaults
|
||||
...userSuppliedOptions,
|
||||
// These computed settings can values in userSuppliedOptions but override them
|
||||
// and are request-specific.
|
||||
adapter,
|
||||
site,
|
||||
basePath,
|
||||
baseUrl,
|
||||
action,
|
||||
provider,
|
||||
cookies,
|
||||
secret,
|
||||
csrfToken,
|
||||
csrfTokenVerified,
|
||||
providers: parseProviders(userSuppliedOptions.providers, baseUrl),
|
||||
session: sessionOption,
|
||||
jwt: jwtOptions,
|
||||
events: eventsOption,
|
||||
callbacks: callbacksOption,
|
||||
callbackUrl: site
|
||||
}
|
||||
|
||||
// If debug enabled, set ENV VAR so that logger logs debug messages
|
||||
if (options.debug === true) { process.env._NEXT_AUTH_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()
|
||||
}
|
||||
|
||||
if (req.method === 'GET') {
|
||||
switch (action) {
|
||||
case 'providers':
|
||||
providers(req, res, options, done)
|
||||
break
|
||||
case 'session':
|
||||
session(req, res, options, done)
|
||||
break
|
||||
case 'csrf':
|
||||
res.json({ csrfToken })
|
||||
return done()
|
||||
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)
|
||||
}
|
||||
break
|
||||
case 'signout':
|
||||
if (options.pages.signout) { return redirect(`${options.pages.signout}${options.pages.signout.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) }
|
||||
|
||||
pages.render(req, res, 'signout', { site, baseUrl, csrfToken, callbackUrl: options.callbackUrl }, done)
|
||||
break
|
||||
case 'callback':
|
||||
if (provider && options.providers[provider]) {
|
||||
callback(req, res, options, done)
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
|
||||
return done()
|
||||
}
|
||||
break
|
||||
case 'verify-request':
|
||||
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
|
||||
|
||||
pages.render(req, res, 'verify-request', { site }, done)
|
||||
break
|
||||
case 'error':
|
||||
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
|
||||
|
||||
pages.render(req, res, 'error', { site, error, baseUrl }, done)
|
||||
break
|
||||
default:
|
||||
res.status(404).end()
|
||||
return done()
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
switch (action) {
|
||||
case 'signin':
|
||||
// Signin POST requests are used for email sign in
|
||||
if (provider && options.providers[provider]) {
|
||||
signin(req, res, options, done)
|
||||
break
|
||||
}
|
||||
break
|
||||
case 'signout':
|
||||
signout(req, res, options, done)
|
||||
break
|
||||
case 'callback':
|
||||
if (provider && options.providers[provider]) {
|
||||
callback(req, res, options, done)
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
||||
return done()
|
||||
}
|
||||
break
|
||||
default:
|
||||
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
|
||||
return done()
|
||||
}
|
||||
} else {
|
||||
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
|
||||
return done()
|
||||
}
|
||||
})
|
||||
}
|
||||
224
src/server/lib/callback-handler.js
Normal file
224
src/server/lib/callback-handler.js
Normal file
@@ -0,0 +1,224 @@
|
||||
// This function handles the complex flow of signing users in, and either creating,
|
||||
// linking (or not linking) accounts depending on if the user is currently logged
|
||||
// in, if they have account already and the authentication mechanism they are using.
|
||||
//
|
||||
// It prevents insecure behaviour, such as linking oAuth accounts unless a user is
|
||||
// signed in and authenticated with an existing valid account.
|
||||
//
|
||||
// All verification (e.g. oAuth flows or email address verificaiton flows) are
|
||||
// done prior to this handler being called to avoid additonal complexity in this
|
||||
// handler.
|
||||
import { AccountNotLinkedError } from '../../lib/errors'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (sessionToken, profile, providerAccount, options) => {
|
||||
try {
|
||||
// Input validation
|
||||
if (!profile) { throw new Error('Missing profile') }
|
||||
if (!providerAccount || !providerAccount.id || !providerAccount.type) { throw new Error('Missing or invalid provider account') }
|
||||
|
||||
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.
|
||||
if (!adapter) {
|
||||
return {
|
||||
user: profile,
|
||||
account: providerAccount,
|
||||
session: {}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
createUser,
|
||||
updateUser,
|
||||
getUser,
|
||||
getUserByProviderAccountId,
|
||||
getUserByEmail,
|
||||
linkAccount,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession
|
||||
} = await adapter.getAdapter(options)
|
||||
|
||||
let session = null
|
||||
let user = null
|
||||
let isSignedIn = null
|
||||
let isNewUser = false
|
||||
|
||||
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)
|
||||
isSignedIn = !!user
|
||||
}
|
||||
} catch (e) {
|
||||
// If session can't be verified, treat as no session
|
||||
}
|
||||
} else {
|
||||
session = await getSession(sessionToken)
|
||||
if (session && session.userId) {
|
||||
user = await getUser(session.userId)
|
||||
isSignedIn = !!user
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providerAccount.type === 'email') {
|
||||
// If signing in with an email, check if an account with the same email address exists already
|
||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||
if (userByEmail) {
|
||||
// If they are not already signed in as the same user, this flow will
|
||||
// sign them out of the current session and sign them in as the new user
|
||||
if (isSignedIn) {
|
||||
if (user.id !== userByEmail.id && !useJwtSession) {
|
||||
// Delete existing session if they are currently signed in as another user.
|
||||
// This will switch user accounts for the session in cases where the user was
|
||||
// already logged in with a different account.
|
||||
await deleteSession(sessionToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Update emailVerified property on the user object
|
||||
const currentDate = new Date()
|
||||
userByEmail.emailVerified = currentDate
|
||||
user = await updateUser(userByEmail)
|
||||
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 })
|
||||
await dispatchEvent(events.createUser, user)
|
||||
isNewUser = true
|
||||
}
|
||||
|
||||
// Create new session
|
||||
session = useJwtSession ? {} : await createSession(user)
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
} else if (providerAccount.type === 'oauth') {
|
||||
// If signing in with oauth account, check to see if the account exists already
|
||||
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
|
||||
if (userByProviderAccountId) {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in with this account, we don't need to do anything
|
||||
// Note: These are cast as strings here to ensure they match as in
|
||||
// some flows (e.g. JWT with a database) one of the values might be a
|
||||
// string and the other might be an ObjectID and would otherwise fail.
|
||||
if (`${userByProviderAccountId.id}` === `${user.id}`) {
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
} else {
|
||||
// If the user is currently signed in, but the new account they are signing in
|
||||
// with is already associated with another account, then we cannot link them
|
||||
// and need to return an error.
|
||||
throw new AccountNotLinkedError()
|
||||
}
|
||||
} else {
|
||||
// If there is no active session, but the account being signed in with is already
|
||||
// associated with a valid user then create session to sign the user in.
|
||||
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
|
||||
return {
|
||||
session,
|
||||
user: userByProviderAccountId,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isSignedIn) {
|
||||
// If the user is already signed in and the oAuth account isn't already associated
|
||||
// with another user account then we can go ahead and link the accounts safely.
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerAccount.provider,
|
||||
providerAccount.type,
|
||||
providerAccount.id,
|
||||
providerAccount.refreshToken,
|
||||
providerAccount.accessToken,
|
||||
providerAccount.accessTokenExpires
|
||||
)
|
||||
await dispatchEvent(events.linkAccount, { user, providerAccount })
|
||||
|
||||
// As they are already signed in, we don't need to do anything after linking them
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
|
||||
// If the user is not signed in and it looks like a new oAuth account then we
|
||||
// check there also isn't an user account already associated with the same
|
||||
// email address as the one in the oAuth profile.
|
||||
//
|
||||
// This step is often overlooked in oAuth implementations, but covers the following cases:
|
||||
//
|
||||
// 1. It makes it harder for someone to accidentally create two accounts.
|
||||
// e.g. by signin in with email, then again with an oauth account connected to the same email.
|
||||
// 2. It makes it harder to hijack a user account using a 3rd party oAuth account.
|
||||
// e.g. by creating an oauth account then changing the email address associated with it.
|
||||
//
|
||||
// It's quite common for services to automatically link accounts in this case, but it's
|
||||
// better practice to require the user to sign in *then* link accounts to be sure
|
||||
// someone is not exploiting a problem with a third party oAuth service.
|
||||
//
|
||||
// oAuth providers should require email address verification to prevent this, but in
|
||||
// practice that is not always the case; this helps protect against that.
|
||||
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
|
||||
if (userByEmail) {
|
||||
// We end up here when we don't have an account with the same [provider].id *BUT*
|
||||
// we do already have an account with the same email address as the one in the
|
||||
// oAuth profile the user has just tried to sign in with.
|
||||
//
|
||||
// We don't want to have two accounts with the same email address, and we don't
|
||||
// want to link them in case it's not safe to do so, so instead we prompt the user
|
||||
// to sign in via email to verify their identity and then link the accounts.
|
||||
throw new AccountNotLinkedError()
|
||||
} else {
|
||||
// If the current user is not logged in and the profile isn't linked to any user
|
||||
// accounts (by email or provider account id)...
|
||||
//
|
||||
// If no account matching the same [provider].id or .email exists, we can
|
||||
// create a new account for the user, link it to the oAuth acccount and
|
||||
// create a new session for them so they are signed in with it.
|
||||
user = await createUser(profile)
|
||||
await dispatchEvent(events.createUser, user)
|
||||
|
||||
await linkAccount(
|
||||
user.id,
|
||||
providerAccount.provider,
|
||||
providerAccount.type,
|
||||
providerAccount.id,
|
||||
providerAccount.refreshToken,
|
||||
providerAccount.accessToken,
|
||||
providerAccount.accessTokenExpires
|
||||
)
|
||||
await dispatchEvent(events.linkAccount, { user, providerAccount })
|
||||
|
||||
session = useJwtSession ? {} : await createSession(user)
|
||||
isNewUser = true
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
isNewUser
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Promise.reject(new Error('Provider not supported'))
|
||||
}
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
27
src/server/lib/callback-url-handler.js
Normal file
27
src/server/lib/callback-url-handler.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import cookie from '../lib/cookie'
|
||||
|
||||
export default async (req, res, options) => {
|
||||
const { query } = req
|
||||
const { body } = req
|
||||
const { cookies, site, defaultCallbackUrl, callbacks } = options
|
||||
|
||||
// Handle preserving and validating callback URLs
|
||||
// If no defaultCallbackUrl option specified, default to the homepage for the site
|
||||
let callbackUrl = defaultCallbackUrl || site
|
||||
|
||||
// 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)
|
||||
} else if (callbackUrlCookieValue) {
|
||||
// If no callbackUrl specified, try using the value from the cookie if allowed
|
||||
callbackUrl = await callbacks.redirect(callbackUrlCookieValue, site)
|
||||
}
|
||||
|
||||
// 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) }
|
||||
|
||||
return Promise.resolve(callbackUrl)
|
||||
}
|
||||
75
src/server/lib/callbacks.js
Normal file
75
src/server/lib/callbacks.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 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,
|
||||
* depending on the sign in flow and if they have an account already.
|
||||
*
|
||||
* When using email sign in, this method is triggered both when the user
|
||||
* 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
|
||||
*/
|
||||
const signin = async (profile, account, metadata) => {
|
||||
const isAllowedToSignIn = true
|
||||
if (isAllowedToSignIn) {
|
||||
return Promise.resolve(true)
|
||||
} else {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirect is called anytime the user is redirected on signin or signout.
|
||||
* By default, for security, only Callback URLs on the same URL as the site
|
||||
* are allowed, you can use this callback to customise that behaviour.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
const redirect = async (url, baseUrl) => {
|
||||
return url.startsWith(baseUrl)
|
||||
? Promise.resolve(url)
|
||||
: Promise.resolve(baseUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* The session callback is called whenever a session is checked.
|
||||
* e.g. `getSession()`, `useSession()`, `/api/auth/session` (etc)
|
||||
*
|
||||
* @param {object} session Session object
|
||||
* @param {object} token JSON Web Token (if enabled)
|
||||
* @return {object} Session that will be returned to the client
|
||||
*/
|
||||
const session = async (session, token) => {
|
||||
return Promise.resolve(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
|
||||
* 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
|
||||
*/
|
||||
const jwt = async (token, oAuthProfile) => {
|
||||
return Promise.resolve(token)
|
||||
}
|
||||
|
||||
export default {
|
||||
signin,
|
||||
redirect,
|
||||
session,
|
||||
jwt
|
||||
}
|
||||
102
src/server/lib/cookie.js
Normal file
102
src/server/lib/cookie.js
Normal file
@@ -0,0 +1,102 @@
|
||||
// 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 = {}) => {
|
||||
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
|
||||
|
||||
if ('maxAge' in options) {
|
||||
options.expires = new Date(Date.now() + options.maxAge)
|
||||
options.maxAge /= 1000
|
||||
}
|
||||
|
||||
// Preserve any existing cookies that have already been set in the same session
|
||||
const setCookieHeader = res.getHeader('Set-Cookie') || []
|
||||
setCookieHeader.push(_serialize(name, String(stringValue), options))
|
||||
res.setHeader('Set-Cookie', setCookieHeader)
|
||||
}
|
||||
|
||||
function _serialize (name, val, options) {
|
||||
const fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/ // eslint-disable-line no-control-regex
|
||||
|
||||
const opt = options || {}
|
||||
const enc = opt.encode || encodeURIComponent
|
||||
|
||||
if (typeof enc !== 'function') { throw new TypeError('option encode is invalid') }
|
||||
|
||||
if (!fieldContentRegExp.test(name)) { throw new TypeError('argument name is invalid') }
|
||||
|
||||
const value = enc(val)
|
||||
|
||||
if (value && !fieldContentRegExp.test(value)) { throw new TypeError('argument val is invalid') }
|
||||
|
||||
let str = name + '=' + value
|
||||
|
||||
if (opt.maxAge != null) {
|
||||
const maxAge = opt.maxAge - 0
|
||||
|
||||
if (isNaN(maxAge) || !isFinite(maxAge)) { throw new TypeError('option maxAge is invalid') }
|
||||
|
||||
str += '; Max-Age=' + Math.floor(maxAge)
|
||||
}
|
||||
|
||||
if (opt.domain) {
|
||||
if (!fieldContentRegExp.test(opt.domain)) { throw new TypeError('option domain is invalid') }
|
||||
|
||||
str += '; Domain=' + opt.domain
|
||||
}
|
||||
|
||||
if (opt.path) {
|
||||
if (!fieldContentRegExp.test(opt.path)) { throw new TypeError('option path is invalid') }
|
||||
|
||||
str += '; Path=' + opt.path
|
||||
} else {
|
||||
str += '; Path=/'
|
||||
}
|
||||
|
||||
if (opt.expires) {
|
||||
let expires = opt.expires
|
||||
if (typeof opt.expires.toUTCString === 'function') {
|
||||
expires = opt.expires.toUTCString()
|
||||
} else {
|
||||
const dateExpires = new Date(opt.expires)
|
||||
expires = dateExpires.toUTCString()
|
||||
}
|
||||
str += '; Expires=' + expires
|
||||
}
|
||||
|
||||
if (opt.httpOnly) { str += '; HttpOnly' }
|
||||
|
||||
if (opt.secure) { str += '; Secure' }
|
||||
|
||||
if (opt.sameSite) {
|
||||
const sameSite = typeof opt.sameSite === 'string' ? opt.sameSite.toLowerCase() : opt.sameSite
|
||||
|
||||
switch (sameSite) {
|
||||
case true:
|
||||
str += '; SameSite=Strict'
|
||||
break
|
||||
case 'lax':
|
||||
str += '; SameSite=Lax'
|
||||
break
|
||||
case 'strict':
|
||||
str += '; SameSite=Strict'
|
||||
break
|
||||
case 'none':
|
||||
str += '; SameSite=None'
|
||||
break
|
||||
default:
|
||||
throw new TypeError('option sameSite is invalid')
|
||||
}
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
export default {
|
||||
set
|
||||
}
|
||||
9
src/server/lib/dispatch-event.js
Normal file
9
src/server/lib/dispatch-event.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import logger from '../../lib/logger'
|
||||
|
||||
export default async (event, message) => {
|
||||
try {
|
||||
await event(message)
|
||||
} catch (e) {
|
||||
logger.error('EVENT_ERROR', e)
|
||||
}
|
||||
}
|
||||
38
src/server/lib/events.js
Normal file
38
src/server/lib/events.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const signin = async (message) => {
|
||||
// Event triggered on successful sign in
|
||||
}
|
||||
|
||||
const signout = async (message) => {
|
||||
// Event triggered on signout
|
||||
}
|
||||
|
||||
const createUser = async (message) => {
|
||||
// Event triggered on user creation
|
||||
}
|
||||
|
||||
const updateUser = async (message) => {
|
||||
// Event triggered when a user object is updated
|
||||
}
|
||||
|
||||
const linkAccount = async (message) => {
|
||||
// Event triggered when an account is linked to a user
|
||||
}
|
||||
|
||||
const session = async (message) => {
|
||||
// Event triggered when a session is active
|
||||
}
|
||||
|
||||
const error = async (message) => {
|
||||
// @TODO Event triggered when something goes wrong in an authentication flow
|
||||
// This event may be fired multiple times when an error occurs
|
||||
}
|
||||
|
||||
export default {
|
||||
signin,
|
||||
signout,
|
||||
createUser,
|
||||
updateUser,
|
||||
linkAccount,
|
||||
session,
|
||||
error
|
||||
}
|
||||
210
src/server/lib/oauth/callback.js
Normal file
210
src/server/lib/oauth/callback.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import oAuthClient from './client'
|
||||
import querystring from 'querystring'
|
||||
import jwtDecode from 'jwt-decode'
|
||||
import logger from '../../../lib/logger'
|
||||
|
||||
// @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
|
||||
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
|
||||
}
|
||||
|
||||
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
|
||||
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
|
||||
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} 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))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function _getProfile (error, profileData, accessToken, refreshToken, provider) {
|
||||
// @TODO Handle error
|
||||
if (error) {
|
||||
logger.error('OAUTH_GET_PROFILE_ERROR', error)
|
||||
}
|
||||
|
||||
let profile = {}
|
||||
try {
|
||||
// Convert profileData into an object if it's a string
|
||||
if (typeof profileData === 'string' || profileData instanceof String) { profileData = JSON.parse(profileData) }
|
||||
|
||||
profile = await provider.profile(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 (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
|
||||
|
||||
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
|
||||
|
||||
// Added as a fix to accomodate change in Twitch oAuth API
|
||||
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
|
||||
|
||||
// Okta errors when this is set. Maybe there are other Providers that also wont like this.
|
||||
if (setGetAccessTokenAuthHeader) {
|
||||
if (!headers.Authorization) { headers.Authorization = `Bearer ${code}` }
|
||||
}
|
||||
|
||||
const postData = querystring.stringify(params)
|
||||
|
||||
this._request(
|
||||
'POST',
|
||||
url,
|
||||
headers,
|
||||
postData,
|
||||
null,
|
||||
(error, data, response) => {
|
||||
if (error) {
|
||||
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
|
||||
return callback(error)
|
||||
}
|
||||
|
||||
let results
|
||||
try {
|
||||
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
|
||||
// responses should be in JSON
|
||||
results = JSON.parse(data)
|
||||
} catch (e) {
|
||||
// However both Facebook + Github currently use rev05 of the spec and neither
|
||||
// seem to specify a content-type correctly in their response headers. :(
|
||||
// Clients of these services suffer a minor performance cost.
|
||||
results = querystring.parse(data)
|
||||
}
|
||||
const accessToken = results.access_token
|
||||
const refreshToken = results.refresh_token
|
||||
callback(null, accessToken, refreshToken, results)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
|
||||
function _get (provider, accessToken, callback) {
|
||||
const url = provider.profileUrl
|
||||
const headers = provider.headers || {}
|
||||
|
||||
if (this._useAuthorizationHeaderForGET) {
|
||||
headers.Authorization = this.buildAuthHeader(accessToken)
|
||||
|
||||
// This line is required for Twitch
|
||||
headers['Client-ID'] = provider.clientId
|
||||
accessToken = null
|
||||
}
|
||||
|
||||
this._request('GET', url, headers, null, accessToken, callback)
|
||||
}
|
||||
|
||||
function _decodeToken (provider, accessToken, refreshToken, idToken, callback) {
|
||||
if (!idToken) { throw new Error('Missing JWT ID Token', provider, idToken) }
|
||||
const decodedToken = jwtDecode(idToken)
|
||||
const profileData = JSON.stringify(decodedToken)
|
||||
callback(null, profileData, accessToken, refreshToken, provider)
|
||||
}
|
||||
31
src/server/lib/oauth/client.js
Normal file
31
src/server/lib/oauth/client.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// @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'
|
||||
|
||||
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
|
||||
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
|
||||
return 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')
|
||||
)
|
||||
}
|
||||
}
|
||||
14
src/server/lib/providers.js
Normal file
14
src/server/lib/providers.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export default (_providers, baseUrl) => {
|
||||
const providers = {}
|
||||
|
||||
_providers.forEach(provider => {
|
||||
const providerId = provider.id
|
||||
providers[providerId] = {
|
||||
...provider,
|
||||
signinUrl: `${baseUrl}/signin/${providerId}`,
|
||||
callbackUrl: `${baseUrl}/callback/${providerId}`
|
||||
}
|
||||
})
|
||||
|
||||
return providers
|
||||
}
|
||||
26
src/server/lib/signin/email.js
Normal file
26
src/server/lib/signin/email.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export default async (email, provider, options) => {
|
||||
try {
|
||||
const { baseUrl, adapter } = options
|
||||
|
||||
const { createVerificationRequest } = await adapter.getAdapter(options)
|
||||
|
||||
// Prefer provider specific secret, but use default secret if none specified
|
||||
const secret = provider.secret || options.secret
|
||||
|
||||
// Generate token
|
||||
const token = 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)}`
|
||||
|
||||
// @TODO Create invite (send secret so can be hashed)
|
||||
await createVerificationRequest(email, url, token, secret, provider, options)
|
||||
|
||||
// Return promise
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
40
src/server/lib/signin/oauth.js
Normal file
40
src/server/lib/signin/oauth.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import oAuthClient from '../oauth/client'
|
||||
import crypto from 'crypto'
|
||||
import logger from '../../../lib/logger'
|
||||
|
||||
export default (provider, callback) => {
|
||||
const { callbackUrl } = provider
|
||||
const client = oAuthClient(provider)
|
||||
if (provider.version && provider.version.startsWith('2.')) {
|
||||
// Handle oAuth v2.x
|
||||
let url = client.getAuthorizeUrl({
|
||||
redirect_uri: provider.callbackUrl,
|
||||
scope: provider.scope,
|
||||
state: crypto.randomBytes(64).toString('hex')
|
||||
})
|
||||
|
||||
// If the authorizationUrl specified in the config has query parameters on it
|
||||
// make sure they are included in the URL we return.
|
||||
//
|
||||
// This is a fix for an open issue with the oAuthClient library we are using
|
||||
// which inadvertantly strips them.
|
||||
//
|
||||
// https://github.com/ciaranj/node-oauth/pull/193
|
||||
if (provider.authorizationUrl.includes('?')) {
|
||||
const parseUrl = new URL(provider.authorizationUrl)
|
||||
const baseUrl = `${parseUrl.origin}${parseUrl.pathname}?`
|
||||
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)
|
||||
}
|
||||
}
|
||||
102
src/server/pages/error.js
Normal file
102
src/server/pages/error.js
Normal file
@@ -0,0 +1,102 @@
|
||||
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
|
||||
|
||||
let heading = <h1>Error</h1>
|
||||
let message = <p><a className='site' href={site}>{site.replace(/^https?:\/\//, '')}</a></p>
|
||||
|
||||
switch (error) {
|
||||
case 'Signin':
|
||||
case 'OAuthSignin':
|
||||
case 'OAuthCallback':
|
||||
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
|
||||
case 'Configuration':
|
||||
heading = <h1>Server error</h1>
|
||||
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':
|
||||
heading = <h1>Access Denied</h1>
|
||||
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
|
||||
heading = <h1>Unable to sign in</h1>
|
||||
message =
|
||||
<div>
|
||||
<div className='message'>
|
||||
<p>The sign in link is no longer valid.</p>
|
||||
<p>It may have be used already or it may have expired.</p>
|
||||
</div>
|
||||
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
|
||||
</div>
|
||||
break
|
||||
default:
|
||||
}
|
||||
|
||||
return render(
|
||||
<div className='error'>
|
||||
{heading}
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
36
src/server/pages/index.js
Normal file
36
src/server/pages/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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) {
|
||||
let html = ''
|
||||
switch (page) {
|
||||
case 'signin':
|
||||
html = signin({ ...props, req })
|
||||
break
|
||||
case 'signout':
|
||||
html = signout(props)
|
||||
break
|
||||
case 'verify-request':
|
||||
html = verifyRequest(props)
|
||||
break
|
||||
case 'error':
|
||||
html = error(props)
|
||||
break
|
||||
default:
|
||||
html = error(props)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
66
src/server/pages/signin.js
Normal file
66
src/server/pages/signin.js
Normal file
@@ -0,0 +1,66 @@
|
||||
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
|
||||
|
||||
// We only want to render providers
|
||||
const providersToRender = providers.filter(provider => {
|
||||
if (provider.type === 'oauth' || provider.type === 'email') {
|
||||
// Always render oauth and email type providers
|
||||
return true
|
||||
} else if (provider.type === 'credentials' && provider.credentials) {
|
||||
// Only render credentials type provider if credentials are defined
|
||||
return true
|
||||
} else {
|
||||
// Don't render other provider types
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
return render(
|
||||
<div className='signin'>
|
||||
{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>}
|
||||
{(provider.type === 'email' || provider.type === 'credentials') && (i > 0) &&
|
||||
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
|
||||
<hr />}
|
||||
{provider.type === 'email' &&
|
||||
<form action={provider.signinUrl} method='POST'>
|
||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||
<label for={`input-email-for-${provider.id}-provider`}>Email</label>
|
||||
<input id={`input-email-for-${provider.id}-provider`} autoFocus type='text' name='email' value={email} placeholder='email@example.com' />
|
||||
<button type='submit'>Sign in with {provider.name}</button>
|
||||
</form>}
|
||||
{provider.type === 'credentials' &&
|
||||
<form action={provider.callbackUrl} method='POST'>
|
||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||
{Object.keys(provider.credentials).map(credential => {
|
||||
return (
|
||||
<div key={`input-group-${provider.id}`}>
|
||||
<label
|
||||
for={`input-${credential}-for-${provider.id}-provider`}
|
||||
>{provider.credentials[credential].label || credential}
|
||||
</label>
|
||||
<input
|
||||
name={credential}
|
||||
id={`input-${credential}-for-${provider.id}-provider`}
|
||||
type={provider.credentials[credential].type || 'text'}
|
||||
value={provider.credentials[credential].value || ''}
|
||||
placeholder={provider.credentials[credential].placeholder || ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<button type='submit'>Sign in with {provider.name}</button>
|
||||
</form>}
|
||||
{(provider.type === 'email' || provider.type === 'credentials') && ((i + 1) < providersToRender.length) &&
|
||||
<hr />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
src/server/pages/signout.js
Normal file
14
src/server/pages/signout.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ baseUrl, csrfToken }) => {
|
||||
return render(
|
||||
<div className='signout'>
|
||||
<h1>Are you sure you want to sign out?</h1>
|
||||
<form action={`${baseUrl}/signout`} method='POST'>
|
||||
<input type='hidden' name='csrfToken' value={csrfToken} />
|
||||
<button type='submit'>Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
src/server/pages/verify-request.js
Normal file
12
src/server/pages/verify-request.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { h } from 'preact' // eslint-disable-line no-unused-vars
|
||||
import render from 'preact-render-to-string'
|
||||
|
||||
export default ({ site }) => {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
267
src/server/routes/callback.js
Normal file
267
src/server/routes/callback.js
Normal file
@@ -0,0 +1,267 @@
|
||||
// Handle callbacks from login services
|
||||
import oAuthCallback from '../lib/oauth/callback'
|
||||
import callbackHandler from '../lib/callback-handler'
|
||||
import cookie from '../lib/cookie'
|
||||
import logger from '../../lib/logger'
|
||||
import dispatchEvent from '../lib/dispatch-event'
|
||||
|
||||
export default async (req, res, options, done) => {
|
||||
const {
|
||||
provider: providerName,
|
||||
providers,
|
||||
adapter,
|
||||
site,
|
||||
secret,
|
||||
baseUrl,
|
||||
cookies,
|
||||
callbackUrl,
|
||||
pages,
|
||||
jwt,
|
||||
events,
|
||||
callbacks
|
||||
} = 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
|
||||
|
||||
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 oauthAccount
|
||||
|
||||
// Check if user is allowed to sign in
|
||||
const signinCallbackResponse = await callbacks.signin(profile, account, oAuthProfile)
|
||||
|
||||
if (signinCallbackResponse === false) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = { user, account, isNewUser }
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload, oAuthProfile)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
} else {
|
||||
// Save Session Token in cookie
|
||||
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
|
||||
}
|
||||
|
||||
await dispatchEvent(events.signin, { user, account, isNewUser })
|
||||
|
||||
// Handle first logins on new accounts
|
||||
// e.g. option to send users to a new account landing page on initial login
|
||||
// Note that the callback URL is preserved, so the journey can still be resumed
|
||||
if (isNewUser && pages.newUser) {
|
||||
res.status(302).setHeader('Location', pages.newUser)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
} 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`)
|
||||
} else if (error.name === 'CreateUserError') {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthCreateAccount`)
|
||||
} else {
|
||||
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
|
||||
res.status(302).setHeader('Location', `${baseUrl}/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()
|
||||
}
|
||||
return done()
|
||||
})
|
||||
} 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()
|
||||
}
|
||||
|
||||
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(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()
|
||||
}
|
||||
|
||||
// If verification token is valid, delete verification request token from
|
||||
// the database so it cannot be used again
|
||||
await deleteVerificationRequest(email, verificationToken, secret, provider)
|
||||
|
||||
// If is an existing user return a user object (otherwise use placeholder)
|
||||
const profile = await getUserByEmail(email) || { email }
|
||||
const account = { id: provider.id, type: 'email', providerAccountId: email }
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// Sign user in
|
||||
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
|
||||
|
||||
if (useJwtSession) {
|
||||
const defaultJwtPayload = { user, account, isNewUser }
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
} else {
|
||||
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 })
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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()
|
||||
} catch (error) {
|
||||
if (error.name === 'CreateUserError') {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=EmailCreateAccount`)
|
||||
} else {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Callback`)
|
||||
logger.error('CALLBACK_EMAIL_ERROR', error)
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const credentials = req.body
|
||||
|
||||
// If promise is rejected / throws error then display Configuration error
|
||||
let userObjectReturnedFromAuthorizeHandler
|
||||
try {
|
||||
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
|
||||
} catch (error) {
|
||||
res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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 = { user, account }
|
||||
const jwtPayload = await callbacks.jwt(defaultJwtPayload)
|
||||
|
||||
// Sign and encrypt token
|
||||
const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge })
|
||||
|
||||
// Set cookie expiry date
|
||||
const cookieExpires = new Date()
|
||||
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
|
||||
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
|
||||
|
||||
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()
|
||||
} else {
|
||||
res.status(500).end(`Error: Callback for provider type ${type} not supported`)
|
||||
return done()
|
||||
}
|
||||
}
|
||||
21
src/server/routes/providers.js
Normal file
21
src/server/routes/providers.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// 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
|
||||
|
||||
const result = {}
|
||||
Object.entries(providers).map(([provider, providerConfig]) => {
|
||||
result[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()
|
||||
}
|
||||
104
src/server/routes/session.js
Normal file
104
src/server/routes/session.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// Return a session object (without any private fields) for Single Page App clients
|
||||
import 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
|
||||
const sessionToken = req.cookies[cookies.sessionToken.name]
|
||||
|
||||
if (!sessionToken) {
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json({})
|
||||
return done()
|
||||
}
|
||||
|
||||
let response = {}
|
||||
if (useJwtSession) {
|
||||
try {
|
||||
// Decrypt and verify token
|
||||
const decodedJwt = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge })
|
||||
|
||||
// Generate new session expiry date
|
||||
const sessionExpiresDate = new Date()
|
||||
sessionExpiresDate.setTime(sessionExpiresDate.getTime() + (sessionMaxAge * 1000))
|
||||
const sessionExpires = sessionExpiresDate.toISOString()
|
||||
|
||||
// By default, only exposes a limited subset of information to the client
|
||||
// as needed for presentation purposes (e.g. "you are logged in as…").
|
||||
const 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
|
||||
},
|
||||
expires: sessionExpires
|
||||
}
|
||||
|
||||
// Pass Session and JSON Web Token through to the session callback
|
||||
const jwtPayload = await callbacks.jwt(decodedJwt)
|
||||
const sessionPayload = await callbacks.session(defaultSessionPayload, jwtPayload)
|
||||
|
||||
// Return session payload as response
|
||||
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 })
|
||||
|
||||
// Set cookie, to also update expiry date on cookie
|
||||
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: sessionExpires, ...cookies.sessionToken.options })
|
||||
|
||||
await dispatchEvent(events.session, { session: sessionPayload, jwt: jwtPayload })
|
||||
} catch (error) {
|
||||
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
|
||||
logger.error('JWT_SESSION_ERROR', error)
|
||||
cookie.set(res, cookies.sessionToken.name, '', { ...cookies.sessionToken.options, maxAge: 0 })
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { getUser, getSession, updateSession } = await adapter.getAdapter(options)
|
||||
const session = await getSession(sessionToken)
|
||||
if (session) {
|
||||
// Trigger update to session object to update session expiry
|
||||
await updateSession(session)
|
||||
|
||||
const user = await getUser(session.userId)
|
||||
|
||||
// By default, only exposes a limited subset of information to the client
|
||||
// as needed for presentation purposes (e.g. "you are logged in as…").
|
||||
const defaultSessionPayload = {
|
||||
user: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image
|
||||
},
|
||||
accessToken: session.accessToken,
|
||||
expires: session.expires
|
||||
}
|
||||
|
||||
// Pass Session through to the session callback
|
||||
const sessionPayload = await callbacks.session(defaultSessionPayload)
|
||||
|
||||
// Return session payload as response
|
||||
response = sessionPayload
|
||||
|
||||
// Set cookie again to update expiry
|
||||
cookie.set(res, cookies.sessionToken.name, sessionToken, { expires: session.expires, ...cookies.sessionToken.options })
|
||||
|
||||
await dispatchEvent(events.session, { session: sessionPayload })
|
||||
} else if (sessionToken) {
|
||||
// If sessionToken was found set but it's not valid for a session then
|
||||
// remove the sessionToken cookie from browser.
|
||||
cookie.set(res, cookies.sessionToken.name, '', { ...cookies.sessionToken.options, maxAge: 0 })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SESSION_ERROR', error)
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.json(response)
|
||||
return done()
|
||||
}
|
||||
111
src/server/routes/signin.js
Normal file
111
src/server/routes/signin.js
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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) => {
|
||||
const {
|
||||
provider: providerName,
|
||||
providers,
|
||||
baseUrl,
|
||||
csrfTokenVerified,
|
||||
adapter,
|
||||
callbacks
|
||||
} = options
|
||||
const provider = providers[providerName]
|
||||
const { type } = provider
|
||||
|
||||
if (!type) {
|
||||
res.status(500).end(`Error: Type not specified for ${provider}`)
|
||||
return done()
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
} 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()
|
||||
}
|
||||
const { getUserByEmail } = await adapter.getAdapter(options)
|
||||
|
||||
// Note: Technically the part of the email address local mailbox element
|
||||
// (everything before the @ symbol) should be treated as 'case sensitive'
|
||||
// according to RFC 2821, but in practice this causes more problems than
|
||||
// it solves. We treat email addresses as all lower case. If anyone
|
||||
// complains about this we can make strict RFC 2821 compliance an option.
|
||||
const email = req.body.email ? req.body.email.toLowerCase() : null
|
||||
|
||||
// If is an existing user return a user object (otherwise use placeholder)
|
||||
const profile = await getUserByEmail(email) || { email }
|
||||
const account = { id: provider.id, type: 'email', providerAccountId: email }
|
||||
|
||||
// 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 {
|
||||
await emailSignin(email, provider, options)
|
||||
} catch (error) {
|
||||
logger.error('SIGNIN_EMAIL_ERROR', error)
|
||||
res
|
||||
.status(302)
|
||||
.setHeader('Location', `${baseUrl}/error?error=EmailSignin`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
|
||||
res
|
||||
.status(302)
|
||||
.setHeader(
|
||||
'Location',
|
||||
`${baseUrl}/verify-request?provider=${encodeURIComponent(
|
||||
provider.id
|
||||
)}&type=${encodeURIComponent(provider.type)}`
|
||||
)
|
||||
res.end()
|
||||
return done()
|
||||
} else {
|
||||
// If provider not supported, redirect to sign in page
|
||||
res.status(302).setHeader('Location', `${baseUrl}/signin`)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
}
|
||||
62
src/server/routes/signout.js
Normal file
62
src/server/routes/signout.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Handle requests to /api/auth/signout
|
||||
import 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
|
||||
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)
|
||||
} catch (error) {
|
||||
// Do nothing if decoding the JWT fails
|
||||
}
|
||||
} else {
|
||||
// Get session from database
|
||||
const { getSession, deleteSession } = await adapter.getAdapter(options)
|
||||
|
||||
try {
|
||||
// Dispatch signout event
|
||||
const session = await getSession(sessionToken)
|
||||
await dispatchEvent(events.signout, session)
|
||||
} catch (error) {
|
||||
// Do nothing if looking up the session fails
|
||||
}
|
||||
|
||||
try {
|
||||
// Remove session from database
|
||||
await deleteSession(sessionToken)
|
||||
} catch (error) {
|
||||
// If error, log it but continue
|
||||
logger.error('SIGNOUT_ERROR', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove Session Token
|
||||
cookie.set(res, cookies.sessionToken.name, '', {
|
||||
...cookies.sessionToken.options,
|
||||
maxAge: 0
|
||||
})
|
||||
|
||||
res.status(302).setHeader('Location', callbackUrl)
|
||||
res.end()
|
||||
return done()
|
||||
}
|
||||
21
test/docker/docker-compose.yml
Normal file
21
test/docker/docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
# Start Mongo, MySQL and Postgres databases
|
||||
# Though other databases will be supported, these are the initial targets.
|
||||
# Uses Docker Compose v2 as v3 doesn't support extends
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
mongo:
|
||||
extends:
|
||||
file: mongo.yml
|
||||
service: mongo
|
||||
|
||||
mysql:
|
||||
extends:
|
||||
file: mysql.yml
|
||||
service: mysql
|
||||
|
||||
postgres:
|
||||
extends:
|
||||
file: postgres.yml
|
||||
service: postgres
|
||||
13
test/docker/mongo.yml
Normal file
13
test/docker/mongo.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
mongo:
|
||||
image: bitnami/mongodb
|
||||
restart: always
|
||||
environment:
|
||||
MONGODB_USERNAME: nextauth
|
||||
MONGODB_PASSWORD: password
|
||||
MONGODB_DATABASE: nextauth
|
||||
ports:
|
||||
- "27017:27017"
|
||||
15
test/docker/mysql.yml
Normal file
15
test/docker/mysql.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
version: '2'
|
||||
|
||||
services:
|
||||
|
||||
mysql:
|
||||
image: mysql
|
||||
command: --default-authentication-plugin=mysql_native_password
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_USER: nextauth
|
||||
MYSQL_PASSWORD: password
|
||||
MYSQL_DATABASE: nextauth
|
||||
MYSQL_RANDOM_ROOT_PASSWORD: 'yes'
|
||||
ports:
|
||||
- "3306:3306"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user