Compare commits

...

59 Commits

Author SHA1 Message Date
Balázs Orbán
4d89b27784 fix: miscellaneous bugfixes (#1030)
* fix: use named params to fix order

* fix: avoid recursive redirects

* fix: revert to use parsed baseUrl

* fix: avoid recursive res.end calls

* fix: use named params in renderPage

* fix: promisify lib/oauth/callback result
2021-01-02 21:28:54 +01:00
Balázs Orbán
e17acb6762 chore: rename labeler.yaml to labeler.yml [skip release] 2021-01-02 17:57:33 +01:00
Balázs Orbán
91e26ca475 chore: add auto labeling to PRs [skip release] (#1025)
* chore: add auto labeling to PRs [skip release]

* chore: allow any file type for test label to be added
2021-01-01 23:05:13 +01:00
Balázs Orbán
c8e76b4b5d feat: forward id_token to jwt and signIn callbacks (#1024) 2021-01-01 21:49:27 +01:00
Didi Keke
a8362ec380 feat(provider): Add Mail.ru OAuth Service Provider and Callback snippet (#522)
* Update callback.js

- Fix Mail.ru bug (missing request parameter: access_token)

Note: setGetAccessTokenProfileUrl should be added to Mail.ru provider to enable support.

* Add Mail.ru OAuth Service Provider

* Update callbacks.md

- Fix broken callbacks snippet.

* Update callback.js

- Bug fix https://github.com/nextauthjs/next-auth/pull/522#issuecomment-669851914
- Minor refactoring.

* Fix: Code linting.

* Update callback.js

Improve approach for building of URL based review recommendation.

* Feat: Reduce API surface expansion

Make use of provider.id === "mailru" as suggested in review discussion in place of setGetAccessTokenProfileUrl.

* Fix: Code linting
2021-01-01 19:05:21 +01:00
Balázs Orbán
f2ad69358f refactor: code base improvements (#959)
* chore: fix casing of OAuth

* refacotr: simplify default callbacks lib file

* refactor: use native URL instead of string concats

* refactor: move redirect to res.redirect, done to res.end

* refactor: move options to req

* refactor: improve IntelliSense, name all functions

* fix(lint): fix lint errors

* refactor: remove jwt-decode dependency

* refactor: refactor some callbacks to Promises

* revert: "refactor: use native URL instead of string concats"

Refs: 690c55b04089e4f3157424c816d43ee4cecb77a0

* chore: misc changes

Co-authored-by: Balazs Orban <balazs@nhi.no>
2021-01-01 14:53:06 +01:00
Balázs Orbán
ca06976422 docs: fix typos in CONTRIBUTING.md [skip release] 2021-01-01 13:43:19 +01:00
Balázs Orbán
7fa4275340 docs: update contributing information [skip release] (#1011)
* docs: update CONTRIBUTING.md

* docs:  use db instead of database for more space

* docs: update CONTRIBUTING.md

* docs: update PR template

* docs: add note about skipping a release
2021-01-01 13:37:46 +01:00
Melanie Seltzer
c684336b32 docs: small update to sign in/out examples (#1016)
* Update examples in client.md

* Update more examples

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-01-01 13:11:49 +01:00
Balázs Orbán
82d16e6ac4 feat: allow to return string in signIn callback (#1019) 2020-12-31 21:55:30 +01:00
Balázs Orbán
bf7efbc252 docs: Remove unnecessary promises (#915) 2020-12-31 12:16:03 +01:00
Florian Michaut
b9862b86b5 feat(db): make Fauna DB collections & indexes configurable (#968)
* Add collections & indexes overrides for Fauna DB

* Fix the name of the verification token index

Co-authored-by: Florian Michaut <florian@coding-days.com>
2020-12-31 10:26:26 +01:00
Ben West
9b579b5fcb Change image to text from varchar (#777)
Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-31 06:25:10 +01:00
Yuma Matsune
abcf845ebf fix(adapter): use findOne for typeorm (#1014) 2020-12-30 21:08:09 +01:00
Balázs Orbán
ee398d1acd fix: treat user.id as optional param (#1010) 2020-12-30 14:23:59 +01:00
Balázs Orbán
c31cbbcd30 chore(release): trigger release on docs type 2020-12-29 23:02:07 +01:00
Balázs Orbán
1728f50952 chore(release): delete old workflow 2020-12-29 22:51:00 +01:00
Junior Vidotti
2eb17cba1a docs(database): add mssql indexes in docs, fix typos (#925)
* added mssql indexes in docs, fixed typo

* docs: fix typo in www/docs/schemas/mssql.md

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2020-12-29 22:49:38 +01:00
Balazs Orban
15196ee3d1 chore(release): change semantic-release/git to semantic-release/github 2020-12-29 22:42:32 +01:00
Balázs Orbán
aa4439e182 feat: add semantic-release (#920) 2020-12-29 22:00:08 +01:00
Nico Domino
66ec439b4d Update README.md 2020-12-26 01:56:20 +01:00
Nico Domino
a49068643c Update README.md 2020-12-25 20:21:09 +01:00
Paul Kenneth Kent
1a315fe5ac feat: add strava provider (#986)
* Add Strava as a provider

* Add documentation for Strava provider

* Fix lint errors

Co-authored-by: Paul Kenneth Kent <paul@ventureharbour.com>
2020-12-23 19:02:36 +01:00
Nico Domino
652ac7de35 Update README.md
Updated the readme to include the projects logo, fixed some typos, and added license info and contributor image.
2020-12-22 00:34:31 +01:00
Balázs Orbán
28ce71d99e chore: hide comments from pull request template 2020-12-17 18:25:17 +01:00
pkabore
28e2afbd3a docs: Correcting a typo. "available" Line 70 (#965)
* chore: use stale label, instead of wontfix

* chore: add link to issue explaining stalebot

* chore: fix typo in stalebot comment

* chore: run build GitHub Action on canary also

* chore: run build GitHub Actions on canary as well

* chore: add reproduction section to questions

* feat(provider): Add Azure Active Directory B2C (#809)

* add provider: Microsoft

* documentation

* support no tenant setup

* fix code style

* chore: rename Microsoft provider to AzureADB2C

* chore: alphabetical order in providers/index

* Revert "feat(provider): Add Azure Active Directory B2C (#809)" (#919)

This reverts commit 6e6a24a7af.

* chore: add myself to the contributors list 🙈

* Correcting a typo. "available" Line 70

Co-authored-by: Balázs Orbán <info@balazsorban.com>
Co-authored-by: Vladimir Evdokimov <evdokimov.vladimir@gmail.com>
2020-12-17 18:23:58 +01:00
pkabore
eb828d42f8 docs: We have twice the word "side" (#964)
* chore: use stale label, instead of wontfix

* chore: add link to issue explaining stalebot

* chore: fix typo in stalebot comment

* chore: run build GitHub Action on canary also

* chore: run build GitHub Actions on canary as well

* chore: add reproduction section to questions

* feat(provider): Add Azure Active Directory B2C (#809)

* add provider: Microsoft

* documentation

* support no tenant setup

* fix code style

* chore: rename Microsoft provider to AzureADB2C

* chore: alphabetical order in providers/index

* Revert "feat(provider): Add Azure Active Directory B2C (#809)" (#919)

This reverts commit 6e6a24a7af.

* chore: add myself to the contributors list 🙈

* We have twice the word "side"

Co-authored-by: Balázs Orbán <info@balazsorban.com>
Co-authored-by: Vladimir Evdokimov <evdokimov.vladimir@gmail.com>
2020-12-17 18:21:31 +01:00
imgregduh
d03504c6ef docs: fix typo Adapater -> Adapter (#960)
Co-authored-by: Balázs Orbán <info@balazsorban.com>
Co-authored-by: Vladimir Evdokimov <evdokimov.vladimir@gmail.com>
2020-12-16 09:18:53 +01:00
dependabot[bot]
8827950f12 chore(deps): Bump ini from 1.3.5 to 1.3.8 in /www (#953)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2020-12-13 02:33:08 +01:00
Jakub Naskręski
4f89d74d78 feat: Display error if no [...nextauth].js found (#678)
* Display error if no [...nextauth].js found

fixes #647

* Log the error and describe it inside errors.md

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2020-12-13 02:28:09 +01:00
Haldun Anil
be159b1b18 docs: fix incorrect references in cypress docs (#932)
* chore: use stale label, instead of wontfix

* chore: add link to issue explaining stalebot

* chore: fix typo in stalebot comment

* chore: run build GitHub Action on canary also

* chore: run build GitHub Actions on canary as well

* chore: add reproduction section to questions

* feat(provider): Add Azure Active Directory B2C (#809)

* add provider: Microsoft

* documentation

* support no tenant setup

* fix code style

* chore: rename Microsoft provider to AzureADB2C

* chore: alphabetical order in providers/index

* Revert "feat(provider): Add Azure Active Directory B2C (#809)" (#919)

This reverts commit 6e6a24a7af.

* chore: add myself to the contributors list 🙈

* docs: fix incorrect references in cypress docs

* chore: add additional docs clarification

Co-authored-by: Balázs Orbán <info@balazsorban.com>
Co-authored-by: Vladimir Evdokimov <evdokimov.vladimir@gmail.com>
2020-12-09 17:07:12 +01:00
Luke Lau
19f2664a78 feat: Store user ID in sub claim of default JWT (#784)
This allows us to check if the user is signed in when using JWTs

Part of #625
2020-12-08 18:53:47 +01:00
Balázs Orbán
bd86e7c7c7 chore: reword PR template 2020-12-08 00:23:40 +01:00
Balázs Orbán
7ce37c71d7 chore: create PULL_REQUEST_TEMPLATE.md 2020-12-08 00:12:44 +01:00
Balázs Orbán
3c3a4d2c4f chore: add note about conveting questions to discussions 2020-12-07 17:09:53 +01:00
Balázs Orbán
5fcf80ce81 chore: disallow issues without template 2020-12-07 17:08:51 +01:00
dependabot[bot]
7a4534a6b1 chore(dep): Bump highlight.js from 9.18.1 to 9.18.5 (#880)
Bumps [highlight.js](https://github.com/highlightjs/highlight.js) from 9.18.1 to 9.18.5.
- [Release notes](https://github.com/highlightjs/highlight.js/releases)
- [Changelog](https://github.com/highlightjs/highlight.js/blob/9.18.5/CHANGES.md)
- [Commits](https://github.com/highlightjs/highlight.js/compare/9.18.1...9.18.5)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Balázs Orbán <info@balazsorban.com>
Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-07 10:48:01 +01:00
Kristóf Poduszló
ddaa830e10 refactor(db): update Prisma calls to support 2.12+ (#881)
Co-authored-by: Balázs Orbán <info@balazsorban.com>
Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-07 00:44:22 +01:00
Cathy Chen
9dbd372f08 update(provider): Update Slack provider to use V2 OAuth endpoints (#895)
* Update Slack to v2 authorize urls, option for additional authorize params
* acessTokenGetter + documentation
2020-12-07 00:31:32 +01:00
Vladimir Evdokimov
dde908b54a feat(provider): Add Azure Active Directory B2C (#921)
* add provider: Microsoft

* documentation

* support no tenant setup

* fix code style

* chore: rename Microsoft provider to AzureADB2C

* chore: alphabetical order in providers/index

* doc: add provider to FAQ
2020-12-06 22:57:54 +01:00
Joe Bell
831c59dd5c feat: add foursquare (#584) 2020-12-06 20:56:00 +01:00
RobertCraigie
3abb0c8223 feat(provider): Add Bungie (#589)
* Add Bungie provider

* Use absolute URL for images

* Correct image URL and use consistent formatting

Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-06 20:34:25 +01:00
dependabot[bot]
8c56e13577 Bump next from 9.5.3 to 9.5.4 in /test/docker/app (#759)
Bumps [next](https://github.com/vercel/next.js) from 9.5.3 to 9.5.4.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v9.5.3...v9.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-06 20:31:09 +01:00
Joost Jansky
12d7856640 feat(provider): add netlify (#555)
Co-authored-by: styxlab <cws@DE01WP777.scdom.net>
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2020-12-06 20:25:14 +01:00
Joseph Vaughan
4635113133 add(db): Add support for Fauna DB (#708)
* Add support for Fauna DB

* Add integration tests

Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-06 20:19:14 +01:00
Fabrizio Ruggeri
1aea187d5e Include callbackUrl in newUser page (#790)
* Include callbackUrl in newUser page

* Update src/server/routes/callback.js

Co-authored-by: Iain Collins <me@iaincollins.com>

* Update src/server/routes/callback.js

Co-authored-by: Iain Collins <me@iaincollins.com>

Co-authored-by: Iain Collins <me@iaincollins.com>
Co-authored-by: Nico Domino <yo@ndo.dev>
2020-12-06 19:50:41 +01:00
Nico Domino
47b8788249 WIP: Update Docusaurus + Site dependencies (#802)
* update: deps

* fix: broken link

* fix: search upgrade change
2020-12-06 19:47:33 +01:00
Aymeric
06a160aa0c Fix for Reddit Authentication (#866)
* Fixed Reddit Authentication

* updated fix for build test

* updated buffer to avoid deprecation message

* Updated for passing tests
2020-12-06 19:30:16 +01:00
Manish Chiniwalar
93f4dc0622 docs: Update default ports for support Databases (#839)
https://next-auth.js.org/configuration/databases
2020-12-06 19:17:47 +01:00
Balázs Orbán
6088a05204 Merge main into canary (#917)
* chore: use stale label, instead of wontfix

* chore: add link to issue explaining stalebot

* chore: fix typo in stalebot comment

* chore: run build GitHub Action on canary also

* chore: run build GitHub Actions on canary as well

* chore: add reproduction section to questions
2020-12-06 10:24:28 +01:00
Balázs Orbán
d242d72106 fix(provider): handle no profile image for Spotify (#914)
* chore(deps): upgrade "standard"

* style(lint): run lint fix

* fix(provider): optional chain Spotify provider profile img
2020-12-05 18:55:12 +01:00
Alan Ray
766874dbd8 fix: update Okta routes (#763)
the current routing for the Okta provider does not follow the standard
set by Okta, and as such doesn't allow for custom subdomains. this
update amends the routes to allow for customer subdomains, and also
aligns next-auth with Okta's documentation.
2020-12-05 11:33:13 +01:00
Daggy1234
0b7343702f fix: ensure Images are produced for discord (#734) 2020-12-05 11:28:16 +01:00
Josh Padnick
0327b9049a fix: update nodemailer version in response to CVE. (#860)
https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-7769 reports a high-severity issue with the current version of nodemailer. This should be merged and released right away if possible.
2020-12-05 11:26:04 +01:00
Pauldic
2ee460de00 docs: fix typo in callbacks.md (#815)
This is a simple typographical error changed accesed to accessed
2020-12-05 11:24:04 +01:00
Joshua K. Martinez
c8de34d003 docs: fix discord example code (#850) 2020-12-05 11:23:07 +01:00
James Perkins
d15572074f docs: update for Now to Vercel (#847)
Vercel archived their now packages a while back, so you can use vercel env pull to pull in the .env
2020-12-05 11:20:48 +01:00
Luke Lau
7b6fd818a5 feat: allow react 17 as a peer dependency (#819)
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2020-12-05 11:18:36 +01:00
Balázs Orbán
e031591468 feat: simplify NextAuth instantiation (#911) 2020-12-05 11:11:08 +01:00
95 changed files with 13340 additions and 4436 deletions

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

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

41
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,41 @@
<!--
Thanks for your interest in the project. Bugs filed and PRs submitted are appreciated!
Please make sure that you are familiar with and follow the Code of Conduct for
this project (found in the CODE_OF_CONDUCT.md file).
Also, please make sure you're familiar with and follow the instructions in the
contributing guidelines (found in the CONTRIBUTING.md file).
If you're new to contributing to open source projects, you might find this free
video course helpful: https://kcd.im/pull-request
Please fill out the information below to expedite the review and (hopefully)
merge of your pull request!
-->
<!-- What changes are being made? (What feature/bug is being fixed here?) -->
**What**:
<!-- Why are these changes necessary? -->
**Why**:
<!-- How were these changes implemented? -->
**How**:
<!-- Have you done all of these things? -->
**Checklist**:
<!-- add "N/A" to the end of each line that's irrelevant to your changes -->
<!-- to check an item, place an "x" in the box like so: "- [x] Documentation" -->
- [ ] Documentation
- [ ] Tests
- [ ] Ready to be merged
<!-- In your opinion, is this ready to be merged as soon as it's reviewed? -->
<!-- feel free to add additional comments -->

21
.github/labeler.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
test:
- test/**/*
documentation:
- www/**/*
- ./**/*.md
providers:
- src/providers/**/*
- www/docs/configuration/providers.md
- test/integration/**/*
adapters:
- src/adapters/**/*
- www/docs/schemas/adapters.md
databases:
- www/docs/schemas/*.md
- test/docker/databases/**/*
- www/docs/configuration/databases.md
- test/fixtures/**/*

8
.github/stale.yml vendored
View File

@@ -8,15 +8,17 @@ exemptLabels:
- security
- priority
# Label to use when marking an issue as stale
staleLabel: wontfix
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
Hi there! It looks like this issue hasn't had any activity for a while.
It will be closed if no further activity occurs. If you think your issue
is still relevant, feel free to comment on it to keep ot open. Thanks!
is still relevant, feel free to comment on it to keep it open. (Read more at #912)
Thanks!
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: >
Hi there! It looks like this issue hasn't had any activity for a while.
To keep things tidy, I am going to close this issue for now.
If you think your issue is still relevant, just leave a comment
and I will reopen it. Thanks!
and I will reopen it. (Read more at #912)
Thanks!

View File

@@ -4,9 +4,13 @@ name: Build Test
on:
push:
branches: [ main ]
branches:
- main
- canary
pull_request:
branches: [ main ]
branches:
- main
- canary
jobs:
build:

12
.github/workflows/labeler.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
name: "Pull Request Labeler"
on:
- pull_request_target
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@main
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
sync-labels: true

View File

@@ -1,34 +0,0 @@
# Publishes module to registry when a new release is created.
# The following secrets need to be configured for this workflow:
# * NPM_TOKEN - Auth token from npmjs.com
name: Publish to NPM
on:
release:
types: [created]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
- run: npm ci
- run: npm run build
- run: npm run lint
publish-npm:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: 12
registry-url: https://registry.npmjs.org/
- run: npm ci
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

30
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Release
on:
push:
branches:
- main
- canary
jobs:
release:
name: Release
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v1
with:
node-version: 12
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Build
run: npm run build
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

39
.releaserc.json Normal file
View File

@@ -0,0 +1,39 @@
{
"branches": [
"main",
{ "name": "canary", "prerelease": true }
],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [
{ "breaking": true, "release": "major" },
{ "revert": true, "release": "patch" },
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "docs", "release": "patch" }
]
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{ "type": "feat", "section": "Features", "hidden": false },
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
{ "type": "perf", "section": "Performance Improvements", "hidden": false },
{ "type": "revert", "section": "Reverts", "hidden": false },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "style", "section": "Styles", "hidden": false },
{ "type": "chore", "section": "Miscellaneous Chores", "hidden": false },
{ "type": "refactor", "section": "Code Refactoring", "hidden": false },
{ "type": "test", "section": "Tests", "hidden": false },
{ "type": "build", "section": "Build System", "hidden": false },
{ "type": "ci", "section": "Continuous Integration", "hidden": false }
]
}
}],
"@semantic-release/github",
"@semantic-release/npm"
]
}

5
CHANGELOG.md Normal file
View File

@@ -0,0 +1,5 @@
# CHANGELOG
The changelog is automatically updated using
[semantic-release](https://github.com/semantic-release/semantic-release). You
can see it on the [releases page](../../releases).

View File

@@ -8,48 +8,26 @@ Please see the [Code of Conduct](CODE_OF_CONDUCT.md) and follow any templates co
Please raise any significant new functionality or breaking change an issue for discussion before raising a Pull Request for it.
## Pull Requests
## For contributors
* The latest changes are always in `main`
* Pull Requests should be raised for larger changes
* Pull Requests do not need approval before merging for those with contributor access (it's just helpful to have them to track changes)
Anyone can be a contributor. Either you found a typo, or you have an awesome feature request you could implement, we encourage you to create a Pull Request.
### Pull Requests
* The latest changes are always in `canary`, so please make your Pull Request against that branch.
* Pull Requests should be raised for any change
* Pull Requests need approval of a [core contributor](https://next-auth.js.org/contributors#core-team) before merging
* Rebasing in Pull Requests is preferred to keep a clean commit history (see below)
* Running `npm run lint:fix` before committing can make resolving conflicts easier, 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
* Running `npm run lint:fix` before committing can make resolving conflicts easier
* We encourage you to test your changes, and if you have the opportunity, please make those tests part of the Pull Request
* If you add new functionality, please provide the corresponding documentation as well and make it part of the Pull Request
## Rebasing
*If you don't rebase and end up with merge commits in a PR then it's not a blocker, we can always squash the commits when merging!*
If you create a branch and there are conflicting updates in the `main` branch, you can resolve them by rebasing from a check out of your branch:
git fetch
git rebase origin/main
If there are any conflicts, you can resolve them and stage the files, then run:
git rebase --continue
*If there are a lot of changes you may be prompted to step more than once.*
When the rebase is complete (i.e. there are no more conflicts) you should push your changes to your branch before doing anything else:
git push --force-with-lease
You should see that any conflicts in your PR are now resolved. You can review changes to make sure it contains changes you intended to make.
*If you accidentally sync before pushing, it will trigger a merge. You can use `git merge --abort` to undo the merge.*
You can use `npm run lint:fix` to automatically apply Standard JS rules to resolve formatting differences (tabs vs spaces, line endings, etc).
## Setting up local environment
### Setting up local environment
A quick and dirty guide on how to setup *next-auth* locally to work on it and test out any changes:
1. Clone the repo:
git clone git@github.com:iaincollins/next-auth.git
git clone git@github.com:nextauthjs/next-auth.git
cd next-auth/
2. Install packages and run the build command:
@@ -75,7 +53,7 @@ Notes: You may need to repeat both `npm link` steps if you install / update addi
If you need an example project to link to, you can use [next-auth-example](https://github.com/iaincollins/next-auth-example).
### Hot reloading
#### Hot reloading
You might find it helpful to use the `npm run watch` command in the next-auth project, which will automatically (and silently) rebuild JS and CSS files as you edit them.
@@ -104,11 +82,11 @@ module.exports = {
}
```
### Databases
#### 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.
It will use port `3306`, `5432`, and `27017` on localhost respectively; please make sure those ports are not used by other services on localhost.
You can start them with `npm run db:start` and stop them with `npm run db:stop`.
@@ -116,7 +94,7 @@ You will need Docker installed to be able to start / stop the databases.
When stopping the databases, it will reset their contents.
### Testing
#### Testing
Tests can be run with `npm run test`.
@@ -125,3 +103,39 @@ Automated tests are currently crude and limited in functionality, but improvemen
Currently, to run tests you need to first have started local test databases (e.g. using `npm run db:start`).
The databases can take a few seconds to start up, so you might need to give it a minute before running the tests.
## For maintainers
We use [semantic-release](https://github.com/semantic-release/semantic-release) together with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0) to automate releases. This makes the maintainenance process easier and less error-prone to human error. Please study the "Conventional Commits" site to understand how to write a good commit message.
When accepting Pull Requests, make sure the following:
* Use "Squash and merge"
* Make sure you merge contributor PRs into `canary`
* Rewrite the commit message to conform to the `Conventional Commits` style. Check the "Recommended Scopes" section for further advice.
* Optionally link issues the PR will resolve (You can add "close" in front of the issue numbers to close the issues automatically, when the PR is merged. `semantic-release` will also comment back to connected issues and PRs, notifying the users that a feature is added/bug fixed, etc.)
### Recommended Scopes
A typical conventional commit looks like this:
```
type(scope): title
body
```
Scope is the part that will help groupping the different commit types in the release notes.
Some recommened scopes are:
- **provider** - Provider related changes. (eg.: "feat(provider): add X provider", "docs(provider): fix typo in X documentation"
- **adapter** - Adapter related changes. (eg.: "feat(adapter): add X provider", "docs(provider): fix typo in X documentation"
- **db** - Database related changes. (eg.: "feat(db): add X database", "docs(db): fix typo in X documentation"
- **deps** - Adding/removing/updating a dependency (eg.: "chore(deps): add X")
> NOTE: If you are not sure which scope to use, you can simply ignore it. (eg.: "feat: add something"). Adding the correct type already helps a lot when analyzing the commit messages.
### Skipping a release
Every commit that contains [skip release] or [release skip] in their message will be excluded from the commit analysis and won't participate in the release type determination. This is useful, if the PR being merged should not trigger a new `npm` release.

View File

@@ -1,7 +1,20 @@
# NextAuth.js
![Build Test](https://github.com/nextauthjs/next-auth/workflows/Build%20Test/badge.svg)
![Integration Test](https://github.com/nextauthjs/next-auth/workflows/Integration%20Test/badge.svg)
<p align="center">
<br/>
<a href="https://next-auth.js.org" target="_blank"><img width="150px" src="https://next-auth.js.org/img/logo/logo-sm.png" /></a>
<h3 align="center">NextAuth.js</h3>
<p align="center">Authentication for Next.js</p>
<p align="center">
Open Source. Full Stack. Own Your Data.
</p>
<p align="center" style="align: center;">
<img src="https://github.com/nextauthjs/next-auth/workflows/Build%20Test/badge.svg" alt="Build Test" />
<img src="https://github.com/nextauthjs/next-auth/workflows/Integration%20Test/badge.svg" alt="Integration Test" />
<img src="https://img.shields.io/bundlephobia/minzip/next-auth" alt="Bundle Size"/>
<img src="https://img.shields.io/npm/dm/next-auth" alt="Downloads" />
<img src="https://img.shields.io/github/stars/nextauthjs/next-auth" alt="Github Stars" />
<img src="https://img.shields.io/github/v/release/nextauthjs/next-auth?include_prereleases" alt="Github Release" />
</p>
</p>
## Overview
@@ -9,9 +22,15 @@ NextAuth.js is a complete open source authentication solution for [Next.js](http
It is designed from the ground up to support Next.js and Serverless.
[Follow the examples](https://next-auth.js.org/getting-started/example) to see how easy it is to use NextAuth.js for authentication.
## Getting Started
Install: `npm i next-auth`
```
npm install --save next-auth
```
The easiest way to continue getting started, is to follow the [getting started](https://next-auth.js.org/getting-started/example) section in our docs.
We also have a section of [tutorials](https://next-auth.js.org/tutorials) for those looking for more specific examples.
See [next-auth.js.org](https://next-auth.js.org) for more information and documentation.
@@ -52,13 +71,15 @@ Advanced options allow you to define your own routines to handle controlling wha
### Typescript
This library gained Typescript support recently. You can install the types in the following way:
```
$ npm i -D @types/next-auth
```
In you encounter any issue with them, please raise an issue and add the "typescript" label to it, we'll try to help you with it as soon as possible.
You can install the appropriate types via the following command:
Alternatively you can raise a PR directly with your fixes on [**DefinitelyTyped**](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth).
```
npm install --save-dev @types/next-auth
```
If you encounter any problems with the types package, please create an issue and add the `typescript` label to it.
Alternatively, you can open a pull request directly with your fixes on the [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth) repository, where you'll find a `next-auth` subfolder.
## Example
@@ -68,7 +89,7 @@ Alternatively you can raise a PR directly with your fixes on [**DefinitelyTyped*
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
const options = {
export default NextAuth({
providers: [
// OAuth authentication providers
Providers.Apple({
@@ -87,9 +108,7 @@ const options = {
],
// SQL or MongoDB database (or leave empty)
database: process.env.DATABASE_URL
}
export default (req, res) => NextAuth(req, res, options)
})
```
### Add React Component
@@ -98,8 +117,8 @@ export default (req, res) => NextAuth(req, res, options)
import React from 'react'
import {
useSession,
signin,
signout
signIn,
signOut
} from 'next-auth/client'
export default function myComponent() {
@@ -108,24 +127,28 @@ export default function myComponent() {
return <p>
{!session && <>
Not signed in <br/>
<button onClick={signin}>Sign in</button>
<button onClick={() => signIn()}>Sign in</button>
</>}
{session && <>
Signed in as {session.user.email} <br/>
<button onClick={signout}>Sign out</button>
<button onClick={() => signOut()}>Sign out</button>
</>}
</p>
}
```
## Acknowledgement
## Acknowledgements
[NextAuth.js is possible thanks to its contributors.](https://next-auth.js.org/contributors)
[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors)
## Getting started
[Follow the examples to get started.](https://next-auth.js.org/getting-started/example)
<a href="https://github.com/nextauthjs/next-auth/graphs/contributors">
<img width="500px" src="https://contrib.rocks/image?repo=nextauthjs/next-auth" />
</a>
## Contributing
If you'd like to contribute to you can find useful information in our [Contributing Guide](https://github.com/iaincollins/next-auth/blob/main/CONTRIBUTING.md).
We're open to all community contributions! If you'd like to contribute in any way, please first read our [Contributing Guide](https://github.com/iaincollins/next-auth/blob/main/CONTRIBUTING.md).
## License
ISC

6203
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth",
"version": "3.1.0",
"version": "0.0.0-semantically-released",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",
@@ -17,11 +17,12 @@
"test:app:rebuild": "npm run build && docker-compose -f test/docker/app.yml up -d --build",
"test:app:stop": "docker-compose -f test/docker/app.yml down",
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop",
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql",
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql && npm run test:db:fauna",
"test:db:mysql": "node test/mysql.js",
"test:db:postgres": "node test/postgres.js",
"test:db:mongodb": "node test/mongodb.js",
"test:db:mssql": "node test/mssql.js",
"test:db:fauna": "node test/fauna.js",
"test:integration": "mocha test/integration",
"db:start": "docker-compose -f test/docker/databases.yml up -d",
"db:stop": "docker-compose -f test/docker/databases.yml down",
@@ -42,11 +43,11 @@
"license": "ISC",
"dependencies": {
"crypto-js": "^4.0.0",
"faunadb": "^3.0.1",
"futoin-hkdf": "^1.3.2",
"jose": "^1.27.2",
"jsonwebtoken": "^8.5.1",
"jwt-decode": "^2.2.0",
"nodemailer": "^6.4.6",
"nodemailer": "^6.4.16",
"oauth": "^0.9.15",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.7",
@@ -55,22 +56,27 @@
"typeorm": "^0.2.24"
},
"peerDependencies": {
"react": "^16.13.1",
"react-dom": "^16.13.1"
"react": "^16.13.1 || ^17",
"react-dom": "^16.13.1 || ^17"
},
"peerOptionalDependencies": {
"mongodb": "^3.5.9",
"mysql": "^2.18.1",
"mssql": "^6.2.1",
"pg": "^8.2.1",
"@prisma/client": "^2.3.0"
"@prisma/client": "^2.12.0"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@semantic-release/commit-analyzer": "^8.0.1",
"@semantic-release/github": "^7.2.0",
"@semantic-release/npm": "7.0.8",
"@semantic-release/release-notes-generator": "^9.0.1",
"autoprefixer": "^9.7.6",
"babel-preset-preact": "^2.0.0",
"conventional-changelog-conventionalcommits": "4.4.0",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"mocha": "^8.1.3",
@@ -83,7 +89,7 @@
"puppeteer": "^5.2.1",
"puppeteer-extra": "^3.1.15",
"puppeteer-extra-plugin-stealth": "^2.6.1",
"standard": "^14.3.3"
"standard": "^16.0.3"
},
"standard": {
"ignore": [

519
src/adapters/fauna/index.js Normal file
View File

@@ -0,0 +1,519 @@
import { query as q } from 'faunadb'
import { createHash, randomBytes } from 'crypto'
import logger from '../../lib/logger'
const Adapter = (config, options = {}) => {
const {
faunaClient,
collections = {
User: 'user',
Account: 'account',
Session: 'session',
VerificationRequest: 'verification_request'
},
indexes = {
Account: 'account_by_provider_account_id',
User: 'user_by_email',
Session: 'session_by_token',
VerificationRequest: 'verification_request_by_token'
}
} = config
async function getAdapter (appOptions) {
function _debug (debugCode, ...args) {
logger.debug(`fauna_${debugCode}`, ...args)
}
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000
const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge)
? appOptions.session.maxAge * 1000
: defaultSessionMaxAge
const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge)
? appOptions.session.updateAge * 1000
: 0
async function createUser (profile) {
_debug('createUser', profile)
const timestamp = new Date().toISOString()
const FQL = q.Create(
q.Collection(collections.User), {
data: {
name: profile.name,
email: profile.email,
image: profile.image,
emailVerified: profile.emailVerified
? profile.emailVerified
: false,
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp)
}
})
try {
const newUser = await faunaClient.query(FQL)
newUser.data.id = newUser.ref.id
return newUser.data
} catch (error) {
console.error('CREATE_USER', error)
return Promise.reject(new Error('CREATE_USER'))
}
}
async function getUser (id) {
_debug('getUser', id)
const FQL = q.Get(
q.Ref(q.Collection(collections.User), id)
)
try {
const user = await faunaClient.query(FQL)
user.data.id = user.ref.id
return user.data
} catch (error) {
console.error('GET_USER', error)
return Promise.reject(new Error('GET_USER'))
}
}
async function getUserByEmail (email) {
_debug('getUserByEmail', email)
if (!email) {
return null
}
const FQL = q.Let(
{
ref: q.Match(q.Index(indexes.User), email)
},
q.If(
q.Exists(q.Var('ref')),
q.Get(q.Var('ref')),
null
)
)
try {
const user = await faunaClient.query(FQL)
if (user == null) {
return null
}
user.data.id = user.ref.id
return user.data
} catch (error) {
console.error('GET_USER_BY_EMAIL', error)
return Promise.reject(new Error('GET_USER_BY_EMAIL'))
}
}
async function getUserByProviderAccountId (providerId, providerAccountId) {
_debug('getUserByProviderAccountId', providerId, providerAccountId)
const FQL = q.Let(
{
ref: q.Match(
q.Index(indexes.Account),
[providerId, providerAccountId]
)
},
q.If(
q.Exists(q.Var('ref')),
q.Get(
q.Ref(
q.Collection(collections.User),
q.Select(['data', 'userId'],
q.Get(q.Var('ref'))
)
)
),
null
)
)
try {
const user = await faunaClient.query(FQL)
if (user == null) {
return null
}
user.data.id = user.ref.id
return user.data
} catch (error) {
console.error('GET_USER_BY_PROVIDER_ACCOUNT_ID', error)
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID'))
}
}
async function updateUser (user) {
_debug('updateUser', user)
const timestamp = new Date().toISOString()
const FQL = q.Update(
q.Ref(q.Collection(collections.User), user.id),
{
data: {
name: user.name,
email: user.email,
image: user.image,
emailVerified: user.emailVerified ? user.emailVerified : false,
updatedAt: q.Time(timestamp)
}
}
)
try {
const user = await faunaClient.query(FQL)
user.data.id = user.ref.id
return user.data
} catch (error) {
console.error('UPDATE_USER_ERROR', error)
return Promise.reject(new Error('UPDATE_USER_ERROR'))
}
}
async function deleteUser (userId) {
_debug('deleteUser', userId)
const FQL = q.Delete(
q.Ref(q.Collection(collections.User), userId)
)
try {
await faunaClient.query(FQL)
} catch (error) {
console.error('DELETE_USER_ERROR', error)
return Promise.reject(new Error('DELETE_USER_ERROR'))
}
}
async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) {
_debug('linkAccount', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires)
try {
const timestamp = new Date().toISOString()
const account = await faunaClient.query(
q.Create(q.Collection(collections.Account), {
data: {
userId: userId,
providerId: providerId,
providerType: providerType,
providerAccountId: providerAccountId,
refreshToken: refreshToken,
accessToken: accessToken,
accessTokenExpires: accessTokenExpires,
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp)
}
})
)
return account.data
} catch (error) {
console.error('LINK_ACCOUNT_ERROR', error)
return Promise.reject(new Error('LINK_ACCOUNT_ERROR'))
}
}
async function unlinkAccount (userId, providerId, providerAccountId) {
_debug('unlinkAccount', userId, providerId, providerAccountId)
const FQL = q.Delete(
q.Select('ref',
q.Get(
q.Match(
q.Index(indexes.Account),
[providerId, providerAccountId]
)
)
)
)
try {
await faunaClient.query(FQL)
} catch (error) {
console.error('UNLINK_ACCOUNT_ERROR', error)
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR'))
}
}
async function createSession (user) {
_debug('createSession', user)
let expires = null
if (sessionMaxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
expires = dateExpires.toISOString()
}
const timestamp = new Date().toISOString()
const FQL =
q.Create(q.Collection(collections.Session), {
data: {
userId: user.id,
expires: q.Time(expires),
sessionToken: randomBytes(32).toString('hex'),
accessToken: randomBytes(32).toString('hex'),
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp)
}
})
try {
const session = await faunaClient.query(FQL)
session.data.id = session.ref.id
return session.data
} catch (error) {
console.error('CREATE_SESSION_ERROR', error)
return Promise.reject(new Error('CREATE_SESSION_ERROR'))
}
}
async function getSession (sessionToken) {
_debug('getSession', sessionToken)
try {
const session = await faunaClient.query(
q.Get(
q.Match(
q.Index(indexes.Session),
sessionToken
)
)
)
// Check session has not expired (do not return it if it has)
if (session && session.expires && new Date() > session.expires) {
await _deleteSession(sessionToken)
return null
}
session.data.id = session.ref.id
return session.data
} catch (error) {
console.error('GET_SESSION_ERROR', error)
return Promise.reject(new Error('GET_SESSION_ERROR'))
}
}
async function updateSession (session, force) {
_debug('updateSession', session)
try {
const shouldUpdate = sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires
if (!shouldUpdate && !force) {
return null
}
// Calculate last updated date, to throttle write updates to database
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
// e.g. ({expiry date} - 30 days) + 1 hour
//
// Default for sessionMaxAge is 30 days.
// Default for sessionUpdateAge is 1 hour.
const dateSessionIsDueToBeUpdated = new Date(session.expires)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge)
dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge)
// Trigger update of session expiry date and write to database, only
// if the session was last updated more than {sessionUpdateAge} ago
const currentDate = new Date()
if (currentDate < dateSessionIsDueToBeUpdated && !force) {
return null
}
const newExpiryDate = new Date()
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge)
const updatedSession = await faunaClient.query(
q.Update(
q.Ref(q.Collection(collections.Session), session.id),
{
data: {
expires: q.Time(newExpiryDate.toISOString()),
updatedAt: q.Time(new Date().toISOString())
}
}
)
)
updatedSession.data.id = updatedSession.ref.id
return updatedSession.data
} catch (error) {
console.error('UPDATE_SESSION_ERROR', error)
return Promise.reject(new Error('UPDATE_SESSION_ERROR'))
}
}
async function _deleteSession (sessionToken) {
const FQL = q.Delete(
q.Select('ref',
q.Get(
q.Match(
q.Index(indexes.Session),
sessionToken
)
)
)
)
return faunaClient.query(FQL)
}
async function deleteSession (sessionToken) {
_debug('deleteSession', sessionToken)
try {
return await _deleteSession(sessionToken)
} catch (error) {
console.error('DELETE_SESSION_ERROR', error)
return Promise.reject(new Error('DELETE_SESSION_ERROR'))
}
}
async function createVerificationRequest (identifier, url, token, secret, provider) {
_debug('createVerificationRequest', identifier)
const { baseUrl } = appOptions
const { sendVerificationRequest, maxAge } = provider
// Store hashed token (using secret as salt) so that tokens cannot be exploited
// even if the contents of the database is compromised
// @TODO Use bcrypt function here instead of simple salted hash
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
let expires = null
if (maxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000))
expires = dateExpires.toISOString()
}
const timestamp = new Date().toISOString()
const FQL = q.Create(
q.Collection(collections.VerificationRequest), {
data: {
identifier: identifier,
token: hashedToken,
expires: expires === null ? null : q.Time(expires),
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp)
}
}
)
try {
const verificationRequest = await faunaClient.query(FQL)
// With the verificationCallback on a provider, you can send an email, or queue
// an email to be sent, or perform some other action (e.g. send a text message)
await sendVerificationRequest({ identifier, url, token, baseUrl, provider })
return verificationRequest.data
} catch (error) {
console.error('CREATE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR'))
}
}
async function getVerificationRequest (identifier, token, secret, provider) {
_debug('getVerificationRequest', identifier, token)
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
const FQL = q.Let(
{
ref: q.Match(q.Index(indexes.VerificationRequest), hashedToken)
},
q.If(
q.Exists(q.Var('ref')),
{
ref: q.Var('ref'),
request: q.Select('data', q.Get(q.Var('ref')))
},
null
)
)
try {
const { ref, request: verificationRequest } = await faunaClient.query(FQL)
const nowDate = Date.now()
if (verificationRequest && verificationRequest.expires && verificationRequest.expires < nowDate) {
// Delete the expired request so it cannot be used
await faunaClient.query(
q.Delete(ref)
)
return null
}
return verificationRequest
} catch (error) {
console.error('GET_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR'))
}
}
async function deleteVerificationRequest (identifier, token, secret, provider) {
_debug('deleteVerification', identifier, token)
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
const FQL = q.Delete(
q.Select('ref',
q.Get(
q.Match(
q.Index(indexes.VerificationRequest), hashedToken
)
)
)
)
try {
await faunaClient.query(FQL)
} catch (error) {
console.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR'))
}
}
return Promise.resolve({
createUser,
getUser,
getUserByEmail,
getUserByProviderAccountId,
updateUser,
deleteUser,
linkAccount,
unlinkAccount,
createSession,
getSession,
updateSession,
deleteSession,
createVerificationRequest,
getVerificationRequest,
deleteVerificationRequest
})
}
return {
getAdapter
}
}
export default {
Adapter
}

View File

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

View File

@@ -57,7 +57,7 @@ const Adapter = (config) => {
async function getUser (id) {
debug('GET_USER', id)
try {
return prisma[User].findOne({ where: { id } })
return prisma[User].findUnique({ where: { id } })
} catch (error) {
logger.error('GET_USER_BY_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
@@ -68,7 +68,7 @@ const Adapter = (config) => {
debug('GET_USER_BY_EMAIL', email)
try {
if (!email) { return Promise.resolve(null) }
return prisma[User].findOne({ where: { email } })
return prisma[User].findUnique({ where: { email } })
} catch (error) {
logger.error('GET_USER_BY_EMAIL_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
@@ -78,9 +78,9 @@ const Adapter = (config) => {
async function getUserByProviderAccountId (providerId, providerAccountId) {
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
try {
const account = await prisma[Account].findOne({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
const account = await prisma[Account].findUnique({ where: { compoundId: getCompoundId(providerId, providerAccountId) } })
if (!account) { return null }
return prisma[User].findOne({ where: { id: account.userId } })
return prisma[User].findUnique({ where: { id: account.userId } })
} catch (error) {
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
@@ -174,7 +174,7 @@ const Adapter = (config) => {
async function getSession (sessionToken) {
debug('GET_SESSION', sessionToken)
try {
const session = await prisma[Session].findOne({ where: { sessionToken } })
const session = await prisma[Session].findUnique({ where: { sessionToken } })
// Check session has not expired (do not return it if it has)
if (session && session.expires && new Date() > session.expires) {
@@ -280,7 +280,7 @@ const Adapter = (config) => {
// Hash token provided with secret before trying to match it with database
// @TODO Use bcrypt instead of salted SHA-256 hash for token
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
const verificationRequest = await prisma[VerificationRequest].findOne({ where: { token: hashedToken } })
const verificationRequest = await prisma[VerificationRequest].findUnique({ where: { token: hashedToken } })
if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) {
// Delete verification entry so it cannot be used again

View File

@@ -25,7 +25,7 @@ class CreateUserError extends UnknownError {
}
// Thrown when an Email address is already associated with an account
// but the user is trying an oAuth account that is not linked to it.
// but the user is trying an OAuth account that is not linked to it.
class AccountNotLinkedError extends UnknownError {
constructor (message) {
super(message)

View File

@@ -1,8 +1,11 @@
// Simple universal (client/server) function to split host and path
// We use this rather than a library because we need to use the same logic both
// client and server side and we only need to parse out the host and path, while
// supporting a default value, so a simple split is sufficent.
export default (url) => {
/**
* Simple universal (client/server) function to split host and path
* We use this rather than a library because we need to use the same logic both
* client and server side and we only need to parse out the host and path, while
* supporting a default value, so a simple split is sufficent.
* @param {string} url
*/
export default function parseUrl (url) {
// Default values
const defaultHost = 'http://localhost:3000'
const defaultPath = '/api/auth'
@@ -20,8 +23,5 @@ export default (url) => {
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
return {
baseUrl,
basePath
}
return { baseUrl, basePath }
}

View File

@@ -0,0 +1,24 @@
export default (options) => {
const tenant = options.tenantId ? options.tenantId : 'common'
return {
id: 'azure-ad-b2c',
name: 'Azure Active Directory B2C',
type: 'oauth',
version: '2.0',
params: {
grant_type: 'authorization_code'
},
accessTokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?response_type=code&response_mode=query`,
profileUrl: 'https://graph.microsoft.com/v1.0/me/',
profile: (profile) => {
return {
id: profile.id,
name: profile.displayName,
email: profile.userPrincipalName
}
},
...options
}
}

44
src/providers/bungie.js Normal file
View File

@@ -0,0 +1,44 @@
export default (options) => {
return {
id: 'bungie',
name: 'Bungie',
type: 'oauth',
version: '2.0',
scope: '',
params: { reauth: 'true', grant_type: 'authorization_code' },
accessTokenUrl: 'https://www.bungie.net/platform/app/oauth/token/',
requestTokenUrl: 'https://www.bungie.net/platform/app/oauth/token/',
authorizationUrl: 'https://www.bungie.net/en/OAuth/Authorize?response_type=code',
profileUrl: 'https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/',
prepareProfileRequest: ({ provider, url, headers, results }) => {
if (!results.membership_id) {
// internal error
// @TODO: handle better
throw new Error('Expected membership_id to be passed.')
}
if (!provider.apiKey) {
throw new Error('The Bungie provider requires the apiKey option to be present.')
}
headers['X-API-Key'] = provider.apiKey
url = url.replace('{membershipId}', results.membership_id)
return url
},
profile: (profile) => {
const { bungieNetUser: user } = profile.Response
return {
id: user.membershipId,
name: user.displayName,
image: `https://www.bungie.net${user.profilePicturePath.startsWith('/') ? '' : '/'}${user.profilePicturePath}`,
email: null
}
},
apiKey: null,
clientId: null,
clientSecret: null,
...options
}
}

View File

@@ -7,14 +7,20 @@ export default (options) => {
scope: 'identify email',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://discord.com/api/oauth2/token',
authorizationUrl:
'https://discord.com/api/oauth2/authorize?response_type=code&prompt=none',
authorizationUrl: 'https://discord.com/api/oauth2/authorize?response_type=code&prompt=none',
profileUrl: 'https://discord.com/api/users/@me',
profile: (profile) => {
if (profile.avatar === null) {
const defaultAvatarNumber = parseInt(profile.discriminator) % 5
profile.image_url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNumber}.png`
} else {
const format = profile.premium_type === 1 || profile.premium_type === 2 ? 'gif' : 'png'
profile.image_url = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`
}
return {
id: profile.id,
name: profile.username,
image: `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`,
image: profile.image_url,
email: profile.email
}
},

View File

@@ -0,0 +1,22 @@
export default ({ apiVersion, ...options }) => {
return {
id: 'foursquare',
name: 'Foursquare',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://foursquare.com/oauth2/access_token',
authorizationUrl:
'https://foursquare.com/oauth2/authenticate?response_type=code',
profileUrl: `https://api.foursquare.com/v2/users/self?v=${apiVersion}`,
profile: (profile) => {
return {
id: profile.id,
name: `${profile.firstName} ${profile.lastName}`,
image: `${profile.prefix}original${profile.suffix}`,
email: profile.contact.email
}
},
...options
}
}

View File

@@ -1,24 +1,30 @@
import Apple from './apple'
import Atlassian from './atlassian'
import Auth0 from './auth0'
import AzureADB2C from './azure-ad-b2c'
import Basecamp from './basecamp'
import BattleNet from './battlenet'
import Box from './box'
import Bungie from './bungie'
import Credentials from './credentials'
import Cognito from './cognito'
import Discord from './discord'
import Email from './email'
import Facebook from './facebook'
import Foursquare from './foursquare'
import FusionAuth from './fusionauth'
import GitHub from './github'
import GitLab from './gitlab'
import Google from './google'
import IdentityServer4 from './identity-server4'
import LinkedIn from './linkedin'
import MailRu from './mailru'
import Mixer from './mixer'
import Netlify from './netlify'
import Okta from './okta'
import Slack from './slack'
import Spotify from './spotify'
import Strava from './strava'
import Twitch from './twitch'
import Twitter from './twitter'
import Yandex from './yandex'
@@ -27,24 +33,30 @@ export default {
Atlassian,
Auth0,
Apple,
AzureADB2C,
Basecamp,
BattleNet,
Box,
Bungie,
Credentials,
Cognito,
Discord,
Email,
Facebook,
Foursquare,
FusionAuth,
GitHub,
GitLab,
Google,
IdentityServer4,
LinkedIn,
MailRu,
Mixer,
Netlify,
Okta,
Slack,
Spotify,
Strava,
Twitter,
Twitch,
Yandex

25
src/providers/mailru.js Normal file
View File

@@ -0,0 +1,25 @@
export default (options) => {
return {
id: 'mailru',
name: 'Mail.ru',
type: 'oauth',
version: '2.0',
scope: 'userinfo',
params: {
grant_type: 'authorization_code'
},
accessTokenUrl: 'https://oauth.mail.ru/token',
requestTokenUrl: 'https://oauth.mail.ru/token',
authorizationUrl: 'https://oauth.mail.ru/login?response_type=code',
profileUrl: 'https://oauth.mail.ru/userinfo',
profile: (profile) => {
return {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.image
}
},
...options
}
}

21
src/providers/netlify.js Normal file
View File

@@ -0,0 +1,21 @@
export default (options) => {
return {
id: 'netlify',
name: 'Netlify',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.netlify.com/oauth/token',
authorizationUrl: 'https://app.netlify.com/authorize?response_type=code',
profileUrl: 'https://api.netlify.com/api/v1/user',
profile: (profile) => {
return {
id: profile.id,
name: profile.full_name,
email: profile.email,
image: profile.avatar_url
}
},
...options
}
}

View File

@@ -11,9 +11,9 @@ export default (options) => {
client_secret: options.clientSecret
},
// These will be different depending on the Org.
accessTokenUrl: `https://${options.domain}/oauth2/v1/token`,
authorizationUrl: `https://${options.domain}/oauth2/v1/authorize/?response_type=code`,
profileUrl: `https://${options.domain}/oauth2/v1/userinfo/`,
accessTokenUrl: `https://${options.domain}/v1/token`,
authorizationUrl: `https://${options.domain}/v1/authorize/?response_type=code`,
profileUrl: `https://${options.domain}/v1/userinfo/`,
profile: (profile) => {
return { ...profile, id: profile.sub }
},

View File

@@ -4,10 +4,12 @@ export default (options) => {
name: 'Slack',
type: 'oauth',
version: '2.0',
scope: 'identity.basic identity.email identity.avatar',
scope: [],
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://slack.com/api/oauth.access',
authorizationUrl: 'https://slack.com/oauth/authorize?response_type=code',
accessTokenUrl: 'https://slack.com/api/oauth.v2.access',
accessTokenGetter: (json) => json.authed_user.access_token,
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
additionalAuthorizeParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
profileUrl: 'https://slack.com/api/users.identity',
profile: (profile) => {
const { user } = profile

View File

@@ -15,7 +15,7 @@ export default (options) => {
id: profile.id,
name: profile.display_name,
email: profile.email,
image: profile.images.length > 0 ? profile.images[0].url : undefined
image: profile.images?.[0]?.url
}
},
...options

22
src/providers/strava.js Normal file
View File

@@ -0,0 +1,22 @@
export default (options) => {
return {
id: 'strava',
name: 'Strava',
type: 'oauth',
version: '2.0',
scope: 'read',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://www.strava.com/api/v3/oauth/token',
authorizationUrl:
'https://www.strava.com/api/v3/oauth/authorize?response_type=code',
profileUrl: 'https://www.strava.com/api/v3/athlete',
profile: (profile) => {
return {
id: profile.id,
name: profile.firstname,
image: profile.profile
}
},
...options
}
}

View File

@@ -1,19 +1,20 @@
import { createHash, randomBytes } from 'crypto'
import jwt from '../lib/jwt'
import parseUrl from '../lib/parse-url'
import cookie from './lib/cookie'
import * as cookie from './lib/cookie'
import callbackUrlHandler from './lib/callback-url-handler'
import parseProviders from './lib/providers'
import events from './lib/events'
import callbacks from './lib/callbacks'
import * as events from './lib/events'
import * as defaultCallbacks from './lib/defaultCallbacks'
import providers from './routes/providers'
import signin from './routes/signin'
import signout from './routes/signout'
import callback from './routes/callback'
import session from './routes/session'
import pages from './pages'
import renderPage from './pages'
import adapters from '../adapters'
import logger from '../lib/logger'
import redirect from './lib/redirect'
// To work properly in production with OAuth providers the NEXTAUTH_URL
// environment variable must be set.
@@ -21,7 +22,7 @@ if (!process.env.NEXTAUTH_URL) {
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
}
export default async (req, res, userSuppliedOptions) => {
async function NextAuthHandler (req, res, 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
@@ -30,7 +31,20 @@ export default async (req, res, userSuppliedOptions) => {
// This is passed to all methods that handle responses, and must be called
// when they are complete so that the serverless function knows when it is
// safe to return and that no more data will be sent.
const done = resolve
const originalResEnd = res.end.bind(res)
res.end = (...args) => {
resolve()
return originalResEnd(...args)
}
res.redirect = redirect(req, res)
if (!req.query.nextauth) {
const error = 'Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly.'
logger.error('MISSING_NEXTAUTH_API_ROUTE_ERROR', error)
return res.status(500).end(`Error: ${error}`).end()
}
const { url, query, body } = req
const {
@@ -44,10 +58,8 @@ export default async (req, res, userSuppliedOptions) => {
csrfToken: csrfTokenFromPost
} = body
// @todo refactor all existing references to site, baseUrl and basePath
const parsedUrl = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
const baseUrl = parsedUrl.baseUrl
const basePath = parsedUrl.basePath
// @todo refactor all existing references to baseUrl and basePath
const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
// Parse database / adapter
let adapter
@@ -62,8 +74,10 @@ export default async (req, res, userSuppliedOptions) => {
// Secret used salt cookies and tokens (e.g. for CSRF protection).
// If no secret option is specified then it creates one on the fly
// based on options passed here. A options contains unique data, such as
// oAuth provider secrets and database credentials it should be sufficent.
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify({ baseUrl, basePath, ...userSuppliedOptions })).digest('hex')
// OAuth provider secrets and database credentials it should be sufficent.
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify({
baseUrl, basePath, ...userSuppliedOptions
})).digest('hex')
// Use secure cookies if the site uses HTTPS
// This being conditional allows cookies to work non-HTTPS development URLs
@@ -139,7 +153,7 @@ export default async (req, res, userSuppliedOptions) => {
// Callback functions
const callbacksOptions = {
...callbacks,
...defaultCallbacks,
...userSuppliedOptions.callbacks
}
@@ -176,26 +190,11 @@ export default async (req, res, userSuppliedOptions) => {
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
}
// Helper method for handling redirects, this is passed to all routes
// @TODO Refactor into a lib instead of passing as an option
// e.g. and call as redirect(req, res, url)
const redirect = (redirectUrl) => {
const reponseAsJson = !!((req.body && req.body.json === 'true'))
if (reponseAsJson) {
res.json({ url: redirectUrl })
} else {
res.status(302).setHeader('Location', redirectUrl)
res.end()
}
return done()
}
// User provided options are overriden by other options,
// except for the options with special handling above
const options = {
// Defaults options can be overidden
debug: false, // Enable debug messages to be displayed
pages: {}, // Custom pages (e.g. sign in, sign out, errors)
debug: false,
pages: {},
// Custom options override defaults
...userSuppliedOptions,
// These computed settings can values in userSuppliedOptions but override them
@@ -208,108 +207,114 @@ export default async (req, res, userSuppliedOptions) => {
cookies,
secret,
csrfToken,
providers: parseProviders(userSuppliedOptions.providers, baseUrl, basePath),
providers: parseProviders({ providers: userSuppliedOptions.providers, baseUrl, basePath }),
session: sessionOptions,
jwt: jwtOptions,
events: eventsOptions,
callbacks: callbacksOptions,
callbackUrl: baseUrl,
redirect
callbacks: callbacksOptions
}
req.options = options
// If debug enabled, set ENV VAR so that logger logs debug messages
if (options.debug === true) { process.env._NEXTAUTH_DEBUG = true }
if (options.debug) {
process.env._NEXTAUTH_DEBUG = true
}
// Get / Set callback URL based on query param / cookie + validation
options.callbackUrl = await callbackUrlHandler(req, res, options)
const callbackUrl = await callbackUrlHandler(req, res)
if (req.method === 'GET') {
switch (action) {
case 'providers':
providers(req, res, options, done)
providers(req, res)
break
case 'session':
session(req, res, options, done)
session(req, res)
break
case 'csrf':
res.json({ csrfToken })
return done()
return res.json({ csrfToken }).end()
case 'signin':
if (options.pages.signIn) {
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${callbackUrl}`
if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` }
return redirect(redirectUrl)
return res.redirect(redirectUrl)
}
pages.render(req, res, 'signin', { baseUrl, basePath, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done)
renderPage(req, res, 'signin', { providers: Object.values(options.providers), callbackUrl, csrfToken })
break
case 'signout':
if (options.pages.signOut) { return redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`) }
if (options.pages.signOut) {
return res.redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`)
}
pages.render(req, res, 'signout', { baseUrl, basePath, csrfToken, callbackUrl: options.callbackUrl }, done)
renderPage(req, res, 'signout', { csrfToken, callbackUrl })
break
case 'callback':
if (provider && options.providers[provider]) {
callback(req, res, options, done)
callback(req, res)
} else {
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
return done()
return res.status(400).end(`Error: HTTP GET is not supported for ${url}`).end()
}
break
case 'verify-request':
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
if (options.pages.verifyRequest) { return res.redirect(options.pages.verifyRequest) }
pages.render(req, res, 'verify-request', { baseUrl }, done)
renderPage(req, res, 'verify-request')
break
case 'error':
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
if (options.pages.error) { return res.redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
pages.render(req, res, 'error', { baseUrl, basePath, error }, done)
renderPage(req, res, 'error', { error })
break
default:
res.status(404).end()
return done()
return res.status(404).end()
}
} else if (req.method === 'POST') {
switch (action) {
case 'signin':
// Verified CSRF Token required for all sign in routes
if (!csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
if (provider && options.providers[provider]) {
signin(req, res, options, done)
signin(req, res)
}
break
case 'signout':
// Verified CSRF Token required for signout
if (!csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signout?csrf=true`)
return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`)
}
signout(req, res, options, done)
signout(req, res)
break
case 'callback':
if (provider && options.providers[provider]) {
// Verified CSRF Token required for credentials providers only
if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
callback(req, res, options, done)
callback(req, res)
} else {
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
return res.status(400).end(`Error: HTTP POST is not supported for ${url}`).end()
}
break
default:
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
return res.status(400).end(`Error: HTTP POST is not supported for ${url}`).end()
}
} else {
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
return done()
return res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`).end()
}
})
}
/** Tha main entry point to next-auth */
export default async function NextAuth (...args) {
if (args.length === 1) {
return (req, res) => NextAuthHandler(req, res, args[0])
}
return NextAuthHandler(...args)
}

View File

@@ -1,17 +1,19 @@
// This function handles the complex flow of signing users in, and either creating,
// linking (or not linking) accounts depending on if the user is currently logged
// in, if they have account already and the authentication mechanism they are using.
//
// It prevents insecure behaviour, such as linking oAuth accounts unless a user is
// signed in and authenticated with an existing valid account.
//
// All verification (e.g. oAuth flows or email address verificaiton flows) are
// done prior to this handler being called to avoid additonal complexity in this
// handler.
import { AccountNotLinkedError } from '../../lib/errors'
import dispatchEvent from '../lib/dispatch-event'
export default async (sessionToken, profile, providerAccount, options) => {
/**
* This function handles the complex flow of signing users in, and either creating,
* linking (or not linking) accounts depending on if the user is currently logged
* in, if they have account already and the authentication mechanism they are using.
*
* It prevents insecure behaviour, such as linking OAuth accounts unless a user is
* signed in and authenticated with an existing valid account.
*
* All verification (e.g. OAuth flows or email address verificaiton flows) are
* done prior to this handler being called to avoid additonal complexity in this
* handler.
*/
export default async function callbackHandler (sessionToken, profile, providerAccount, options) {
try {
// Input validation
if (!profile) { throw new Error('Missing profile') }
@@ -52,8 +54,8 @@ export default async (sessionToken, profile, providerAccount, options) => {
if (useJwtSession) {
try {
session = await jwt.decode({ ...jwt, token: sessionToken })
if (session && session.user) {
user = await getUser(session.user.id)
if (session && session.sub) {
user = await getUser(session.sub)
isSignedIn = !!user
}
} catch (e) {
@@ -136,7 +138,7 @@ export default async (sessionToken, profile, providerAccount, options) => {
}
} else {
if (isSignedIn) {
// If the user is already signed in and the oAuth account isn't already associated
// If the user is already signed in and the OAuth account isn't already associated
// with another user account then we can go ahead and link the accounts safely.
await linkAccount(
user.id,
@@ -157,28 +159,28 @@ export default async (sessionToken, profile, providerAccount, options) => {
}
}
// If the user is not signed in and it looks like a new oAuth account then we
// If the user is not signed in and it looks like a new OAuth account then we
// check there also isn't an user account already associated with the same
// email address as the one in the oAuth profile.
// email address as the one in the OAuth profile.
//
// This step is often overlooked in oAuth implementations, but covers the following cases:
// This step is often overlooked in OAuth implementations, but covers the following cases:
//
// 1. It makes it harder for someone to accidentally create two accounts.
// e.g. by signin in with email, then again with an oauth account connected to the same email.
// 2. It makes it harder to hijack a user account using a 3rd party oAuth account.
// 2. It makes it harder to hijack a user account using a 3rd party OAuth account.
// e.g. by creating an oauth account then changing the email address associated with it.
//
// It's quite common for services to automatically link accounts in this case, but it's
// better practice to require the user to sign in *then* link accounts to be sure
// someone is not exploiting a problem with a third party oAuth service.
// someone is not exploiting a problem with a third party OAuth service.
//
// oAuth providers should require email address verification to prevent this, but in
// OAuth providers should require email address verification to prevent this, but in
// practice that is not always the case; this helps protect against that.
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// We end up here when we don't have an account with the same [provider].id *BUT*
// we do already have an account with the same email address as the one in the
// oAuth profile the user has just tried to sign in with.
// OAuth profile the user has just tried to sign in with.
//
// We don't want to have two accounts with the same email address, and we don't
// want to link them in case it's not safe to do so, so instead we prompt the user
@@ -189,7 +191,7 @@ export default async (sessionToken, profile, providerAccount, options) => {
// accounts (by email or provider account id)...
//
// If no account matching the same [provider].id or .email exists, we can
// create a new account for the user, link it to the oAuth acccount and
// create a new account for the user, link it to the OAuth acccount and
// create a new session for them so they are signed in with it.
user = await createUser(profile)
await dispatchEvent(events.createUser, user)

View File

@@ -1,14 +1,13 @@
import cookie from '../lib/cookie'
import * as cookie from '../lib/cookie'
export default async (req, res, options) => {
export default async function callbackUrlHandler (req, res) {
const { query } = req
const { body } = req
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = options
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = req.options
// Handle preserving and validating callback URLs
// If no defaultCallbackUrl option specified, default to the homepage for the site
let callbackUrl = defaultCallbackUrl || baseUrl
// Try reading callbackUrlParamValue from request body (form submission) then from query param (get request)
const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null
const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null
@@ -21,7 +20,9 @@ export default async (req, res, options) => {
}
// Save callback URL in a cookie so that can be used for subsequent requests in signin/signout/callback flow
if (callbackUrl && (callbackUrl !== callbackUrlCookieValue)) { cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options) }
if (callbackUrl && (callbackUrl !== callbackUrlCookieValue)) {
cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options)
}
return Promise.resolve(callbackUrl)
return callbackUrl
}

View File

@@ -1,12 +1,14 @@
// Function to set cookies server side
//
// Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
// * https://github.com/jshttp/cookie/blob/master/index.js
// * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
//
// As only partial functionlity is required, only the code we need has been incorporated here
// (with fixes for specific issues) to keep dependancy size down.
const set = (res, name, value, options = {}) => {
/**
* Function to set cookies server side
*
* Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
* * https://github.com/jshttp/cookie/blob/master/index.js
* * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
*
* As only partial functionlity is required, only the code we need has been incorporated here
* (with fixes for specific issues) to keep dependancy size down.
*/
export function set (res, name, value, options = {}) {
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
if ('maxAge' in options) {
@@ -98,7 +100,3 @@ function _serialize (name, val, options) {
return str
}
export default {
set
}

View File

@@ -9,19 +9,14 @@
* requests to sign in and again when they activate the link in the sign in
* email.
*
* @param {object} profile User profile (e.g. user id, name, email)
* @param {object} account Account used to sign in (e.g. OAuth account)
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
* @return {boolean|object} Return `true` (or a modified JWT) to allow sign in
* Return `false` to deny access
* @param {object} profile User profile (e.g. user id, name, email)
* @param {object} account Account used to sign in (e.g. OAuth account)
* @param {object} metadata Provider specific metadata (e.g. OAuth Profile)
* @return {Promise<boolean|never>} Return `true` (or a modified JWT) to allow sign in
* Return `false` to deny access
*/
const signIn = async (profile, account, metadata) => {
const isAllowedToSignIn = true
if (isAllowedToSignIn) {
return Promise.resolve(true)
} else {
return Promise.resolve(false)
}
export async function signIn () {
return true
}
/**
@@ -31,12 +26,13 @@ const signIn = async (profile, account, metadata) => {
*
* @param {string} url URL provided as callback URL by the client
* @param {string} baseUrl Default base URL of site (can be used as fallback)
* @return {string} URL the client will be redirect to
* @return {Promise<string>} URL the client will be redirect to
*/
const redirect = async (url, baseUrl) => {
return url.startsWith(baseUrl)
? Promise.resolve(url)
: Promise.resolve(baseUrl)
export async function redirect (url, baseUrl) {
if (url.startsWith(baseUrl)) {
return url
}
return baseUrl
}
/**
@@ -45,31 +41,24 @@ const redirect = async (url, baseUrl) => {
*
* @param {object} session Session object
* @param {object} token JSON Web Token (if enabled)
* @return {object} Session that will be returned to the client
* @return {Promise<object>} Session that will be returned to the client
*/
const session = async (session, token) => {
return Promise.resolve(session)
export async function session (session) {
return session
}
/**
* This callback is called whenever a JSON Web Token is created / updated.
* e.g. On sign in, `getSession()`, `useSession()`, `/api/auth/session` (etc)
*
* On initial sign in, the raw oAuthProfile is passed if the user is signing in
* On initial sign in, the raw OAuthProfile is passed if the user is signing in
* with an OAuth provider. It is not avalible on subsequent calls. You can
* take advantage of this to persist additional data you need to in the JWT.
*
* @param {object} token Decrypted JSON Web Token
* @param {object} oAuthProfile OAuth profile - only available on sign in
* @return {object} JSON Web Token that will be saved
* @return {Promise<object>} JSON Web Token that will be saved
*/
const jwt = async (token, oAuthProfile) => {
return Promise.resolve(token)
}
export default {
signIn,
redirect,
session,
jwt
export async function jwt (token) {
return token
}

View File

@@ -1,6 +1,6 @@
import logger from '../../lib/logger'
export default async (event, message) => {
export default async function dispatchEvent (event, message) {
try {
await event(message)
} catch (e) {

View File

@@ -1,38 +1,23 @@
const signIn = async (message) => {
// Event triggered on successful sign in
}
/** Event triggered on successful sign in */
export async function signIn (message) {}
const signOut = async (message) => {
// Event triggered on sign out
}
/** Event triggered on sign out */
export async function signOut (message) {}
const createUser = async (message) => {
// Event triggered on user creation
}
/** Event triggered on user creation */
export async function createUser (message) {}
const updateUser = async (message) => {
// Event triggered when a user object is updated
}
/** Event triggered when a user object is updated */
export async function updateUser (message) {}
const linkAccount = async (message) => {
// Event triggered when an account is linked to a user
}
/** Event triggered when an account is linked to a user */
export async function linkAccount (message) {}
const session = async (message) => {
// Event triggered when a session is active
}
/** Event triggered when a session is active */
export async function session (message) {}
const error = async (message) => {
// @TODO Event triggered when something goes wrong in an authentication flow
// This event may be fired multiple times when an error occurs
}
export default {
signIn,
signOut,
createUser,
updateUser,
linkAccount,
session,
error
}
/**
* @TODO Event triggered when something goes wrong in an authentication flow
* This event may be fired multiple times when an error occurs
*/
export async function error (message) {}

View File

@@ -1,25 +1,34 @@
import { createHash } from 'crypto'
import querystring from 'querystring'
import jwtDecode from 'jwt-decode'
import { decode as jwtDecode } from 'jsonwebtoken'
import oAuthClient from './client'
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.
class OAuthCallbackError extends Error {
constructor (message) {
super(message)
this.name = 'OAuthCallbackError'
this.message = message
}
}
// @TODO Refactor to use promises and not callbacks
// @TODO Refactor to use jsonwebtoken instead of jwt-decode & remove dependancy
export default async (req, provider, csrfToken, callback) => {
// The "user" object is specific to apple provider and is provided on first sign in
/**
* @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.
* @TODO Refactor to use promises and not callbacks
*/
export default async function oAuthCallback (req, csrfToken) {
// The "user" object is specific to the Apple provider and is provided on first sign in
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
let { oauth_token, oauth_verifier, code, user, state } = req.query // eslint-disable-line camelcase
const provider = req.options.providers[req.options.provider]
const client = oAuthClient(provider)
if (provider.version && provider.version.startsWith('2.')) {
if (provider.version?.startsWith('2.')) {
// For OAuth 2.0 flows, check state returned and matches expected value
// (a hash of the NextAuth.js CSRF token).
//
@@ -28,7 +37,7 @@ export default async (req, provider, csrfToken, callback) => {
if (!Object.prototype.hasOwnProperty.call(provider, 'state') || provider.state === true) {
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
if (state !== expectedState) {
return callback(new Error('Invalid state returned from oAuth provider'))
throw new OAuthCallbackError('Invalid state returned from OAuth provider')
}
}
@@ -41,7 +50,7 @@ export default async (req, provider, csrfToken, callback) => {
user = body.user != null ? JSON.parse(body.user) : null
} catch (e) {
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', e, req.body, provider.id, code)
return callback()
throw new OAuthCallbackError()
}
}
@@ -55,75 +64,78 @@ export default async (req, provider, csrfToken, callback) => {
// Use custom getOAuthAccessToken() method for oAuth2 flows
client.getOAuthAccessToken = _getOAuthAccessToken
await client.getOAuthAccessToken(
code,
provider,
(error, accessToken, refreshToken, results) => {
if (error || results.error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, results, provider.id, code)
return callback(error || results.error)
}
if (provider.idToken) {
// If we don't have an ID Token most likely the user hit a cancel
// button when signing in (or the provider is misconfigured).
//
// Unfortunately, we can't tell which, so we can't treat it as an
// error, so instead we just returning nothing, which will cause the
// user to be redirected back to the sign in page.
if (!results || !results.id_token) {
return callback()
return new Promise((resolve) => {
client.getOAuthAccessToken(
code,
provider,
async (error, accessToken, refreshToken, results) => {
if (error || results.error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, results, provider.id, code)
throw new OAuthCallbackError(error || results.error)
}
// Support services that use OpenID ID Tokens to encode profile data
_decodeToken(
provider,
accessToken,
refreshToken,
results.id_token,
async (error, profileData) => {
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider, user)
callback(error, profile, account, OAuthProfile)
if (provider.idToken) {
// If we don't have an ID Token most likely the user hit a cancel
// button when signing in (or the provider is misconfigured).
//
// Unfortunately, we can't tell which, so we can't treat it as an
// error, so instead we just returning nothing, which will cause the
// user to be redirected back to the sign in page.
if (!results?.id_token) {
throw new OAuthCallbackError()
}
)
} else {
// Use custom get() method for oAuth2 flows
client.get = _get
// Support services that use OpenID ID Tokens to encode profile data
const profileData = decodeIdToken(results.id_token)
return _getProfile({
error, profileData, accessToken, refreshToken, provider, user, idToken: results.id_token
})
} else {
// Use custom get() method for oAuth2 flows
client.get = _get
client.get(
provider,
accessToken,
results,
async (error, profileData) => {
const result = await _getProfile({
error, profileData, accessToken, refreshToken, provider, idToken: results.id_token
})
resolve(result)
}
)
}
}
)
})
} else {
// Handle OAuth v1.x
return new Promise((resolve) => {
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,
provider.profileUrl,
accessToken,
refreshToken,
async (error, profileData) => {
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
callback(error, profile, account, OAuthProfile)
const result = await _getProfile({
error, profileData, accessToken, refreshToken, provider, idToken: results.id_token
})
resolve(result)
}
)
}
}
)
} 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,
async (error, profileData) => {
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
callback(error, profile, account, OAuthProfile)
}
)
}
)
)
})
}
}
@@ -131,25 +143,46 @@ export default async (req, provider, csrfToken, callback) => {
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
* Returns profile, raw profile and auth provider details
*/
async function _getProfile (error, profileData, accessToken, refreshToken, provider, userData) {
// @TODO Handle error
async function _getProfile ({
error, profileData, accessToken, refreshToken, provider, userData, idToken
}) {
if (error) {
logger.error('OAUTH_GET_PROFILE_ERROR', error)
throw new OAuthCallbackError(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) }
if (typeof profileData === 'string' || profileData instanceof String) {
profileData = JSON.parse(profileData)
}
// If a user object is supplied (e.g. Apple provider) add it to the profile object
if (userData != null) {
profileData.user = userData
}
profileData.idToken = idToken
logger.debug('PROFILE_DATA', profileData)
profile = await provider.profile(profileData)
const profile = await provider.profile(profileData)
// Return profile, raw profile and auth provider details
return {
profile: {
...profile,
email: profile.email?.toLowerCase() ?? null
},
account: {
provider: provider.id,
type: provider.type,
id: profile.id,
refreshToken,
accessToken,
accessTokenExpires: null
},
OAuthProfile: profileData
}
} catch (exception) {
// If we didn't get a response either there was a problem with the provider
// response *or* the user cancelled the action with the provider.
@@ -165,24 +198,6 @@ async function _getProfile (error, profileData, accessToken, refreshToken, provi
OAuthProfile: profileData
}
}
// Return profile, raw profile and auth provider details
return {
profile: {
name: profile.name,
email: profile.email ? profile.email.toLowerCase() : null,
image: profile.image
},
account: {
provider: provider.id,
type: provider.type,
id: profile.id,
refreshToken,
accessToken,
accessTokenExpires: null
},
OAuthProfile: profileData
}
}
// Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
@@ -210,10 +225,12 @@ async function _getOAuthAccessToken (code, provider, callback) {
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
// Added as a fix to accomodate change in Twitch OAuth API
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
// Added as a fix for Reddit Authentication
if (provider.id === 'reddit') {
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
}
// 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}` }
@@ -244,32 +261,49 @@ async function _getOAuthAccessToken (code, provider, callback) {
// Clients of these services suffer a minor performance cost.
results = querystring.parse(data)
}
const accessToken = results.access_token
const accessToken = provider.accessTokenGetter ? provider.accessTokenGetter(results) : 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
/**
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
*
* 18/08/2020 @robertcraigie added results parameter to pass data to an optional request preparer.
* e.g. see providers/bungie
*/
function _get (provider, accessToken, results, callback) {
let url = provider.profileUrl
const headers = provider.headers || {}
if (this._useAuthorizationHeaderForGET) {
headers.Authorization = this.buildAuthHeader(accessToken)
// Mail.ru requires 'access_token' as URL request parameter
if (provider.id === 'mailru') {
const safeAccessTokenURL = new URL(url)
safeAccessTokenURL.searchParams.append('access_token', accessToken)
url = safeAccessTokenURL.href
}
// This line is required for Twitch
headers['Client-ID'] = provider.clientId
accessToken = null
}
const prepareRequest = provider.prepareProfileRequest
if (prepareRequest) {
url = prepareRequest({ provider, url, headers, results }) || url
}
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)
function decodeIdToken (idToken) {
if (!idToken) {
throw new OAuthCallbackError('Missing JWT ID Token')
}
return jwtDecode(idToken, { json: true })
}

View File

@@ -1,11 +1,13 @@
// @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) => {
/**
* @TODO Refactor to remove dependancy on 'oauth' package
* It is already quite monkey patched, we don't use all the features and and it
* would be easier to maintain if all the code was native to next-auth.
*/
export default function oAuthClient (provider) {
if (provider.version && provider.version.startsWith('2.')) {
// Handle oAuth v2.x
// 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
@@ -17,7 +19,7 @@ export default (provider) => {
accessTokenPath,
provider.headers)
} else {
// Handle oAuth v1.x
// Handle OAuth v1.x
return new OAuth(
provider.requestTokenUrl,
provider.accessTokenUrl,

View File

@@ -1,14 +1,11 @@
export default (_providers, baseUrl, basePath) => {
const providers = {}
_providers.forEach(provider => {
export default function parseProviders ({ providers, baseUrl, basePath }) {
return providers.reduce((acc, provider) => {
const providerId = provider.id
providers[providerId] = {
acc[providerId] = {
...provider,
signinUrl: `${baseUrl}${basePath}/signin/${providerId}`,
callbackUrl: `${baseUrl}${basePath}/callback/${providerId}`
}
})
return providers
return acc
}, {})
}

View File

@@ -0,0 +1,12 @@
export default function redirect (req, res) {
// This is the one you will use. The wrapper is just to set it up in src/server/index.
return function redirect (url) {
const reponseAsJson = req.body?.json === 'true'
if (reponseAsJson) {
res.json({ url })
} else {
res.status(302).setHeader('Location', url)
}
return res.end()
}
}

View File

@@ -1,6 +1,6 @@
import { randomBytes } from 'crypto'
export default async (email, provider, options) => {
export default async function email (email, provider, options) {
try {
const { baseUrl, basePath, adapter } = options

View File

@@ -2,17 +2,18 @@ import oAuthClient from '../oauth/client'
import { createHash } from 'crypto'
import logger from '../../../lib/logger'
export default (provider, csrfToken, callback, authParams) => {
export default function oauth (provider, csrfToken, callback, authParams) {
const { callbackUrl } = provider
const client = oAuthClient(provider)
if (provider.version && provider.version.startsWith('2.')) {
// Handle oAuth v2.x
// Handle OAuth v2.x
let url = client.getAuthorizeUrl({
...authParams,
redirect_uri: provider.callbackUrl,
scope: provider.scope,
// A hash of the NextAuth.js CSRF token is used as the state
state: createHash('sha256').update(csrfToken).digest('hex')
state: createHash('sha256').update(csrfToken).digest('hex'),
...provider.additionalAuthorizeParams
})
// If the authorizationUrl specified in the config has query parameters on it
@@ -30,7 +31,7 @@ export default (provider, csrfToken, callback, authParams) => {
callback(null, url)
} else {
// Handle oAuth v1.x
// Handle OAuth v1.x
client.getOAuthRequestToken((error, oAuthToken) => {
if (error) {
logger.error('GET_AUTHORISATION_URL_ERROR', error)

View File

@@ -1,7 +1,7 @@
import { h } from 'preact' // eslint-disable-line no-unused-vars
import render from 'preact-render-to-string'
export default ({ baseUrl, basePath, error, res }) => {
export default function error ({ baseUrl, basePath, error, res }) {
const signinPageUrl = `${baseUrl}${basePath}/signin`
let statusCode = 200
@@ -19,37 +19,38 @@ export default ({ baseUrl, basePath, error, res }) => {
case 'EmailSignin':
case 'CredentialsSignin':
// These messages are displayed in line on the sign in page
res.status(302).setHeader('Location', `${signinPageUrl}?error=${error}`)
res.end()
res.redirect(`${signinPageUrl}?error=${error}`)
return false
case 'Configuration':
statusCode = 500
heading = <h1>Server error</h1>
message =
message = (
<div>
<div className='message'>
<p>There is a problem with the server configuration.</p>
<p>Check the server logs for more information.</p>
</div>
</div>
)
break
case 'AccessDenied':
statusCode = 403
heading = <h1>Access Denied</h1>
message =
message = (
<div>
<div className='message'>
<p>You do not have permission to sign in.</p>
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
</div>
</div>
)
break
case 'Verification':
// @TODO Check if user is signed in already with the same email address.
// If they are, no need to display this message, can just direct to callbackUrl
statusCode = 403
heading = <h1>Unable to sign in</h1>
message =
message = (
<div>
<div className='message'>
<p>The sign in link is no longer valid.</p>
@@ -57,6 +58,7 @@ export default ({ baseUrl, basePath, error, res }) => {
</div>
<p><a className='button' href={signinPageUrl}>Sign in</a></p>
</div>
)
break
default:
}

View File

@@ -4,7 +4,9 @@ import verifyRequest from './verify-request'
import error from './error'
import css from '../../css'
function render (req, res, page, props, done) {
export default function renderPage (req, res, page, props = {}) {
props.baseUrl = req.options.baseUrl
props.basePath = req.options.basePath
let html = ''
switch (page) {
case 'signin':
@@ -18,18 +20,15 @@ function render (req, res, page, props, done) {
break
case 'error':
html = error({ ...props, res })
if (html === false) return done()
if (html === false) return res.end()
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
res
.setHeader('Content-Type', 'text/html')
.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>`)
.end()
}

View File

@@ -1,7 +1,7 @@
import { h } from 'preact' // eslint-disable-line no-unused-vars
import render from 'preact-render-to-string'
export default ({ req, csrfToken, providers, callbackUrl }) => {
export default function signin ({ req, csrfToken, providers, callbackUrl }) {
const { email, error } = req.query
// We only want to render providers
@@ -59,8 +59,8 @@ export default ({ req, csrfToken, providers, callbackUrl }) => {
<button type='submit' className='button'>Sign in with {provider.name}</button>
</form>}
{(provider.type === 'email' || provider.type === 'credentials') && (i > 0) &&
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
<hr />}
providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
<hr />}
{provider.type === 'email' &&
<form action={provider.signinUrl} method='POST'>
<input type='hidden' name='csrfToken' value={csrfToken} />

View File

@@ -1,7 +1,7 @@
import { h } from 'preact' // eslint-disable-line no-unused-vars
import render from 'preact-render-to-string'
export default ({ baseUrl, basePath, csrfToken }) => {
export default function signout ({ baseUrl, basePath, csrfToken }) {
return render(
<div className='signout'>
<h1>Are you sure you want to sign out?</h1>

View File

@@ -1,7 +1,7 @@
import { h } from 'preact' // eslint-disable-line no-unused-vars
import render from 'preact-render-to-string'
export default ({ baseUrl }) => {
export default function verifyRequest ({ baseUrl }) {
return render(
<div className='verify-request'>
<h1>Check your email</h1>

View File

@@ -1,11 +1,11 @@
// Handle callbacks from login services
import oAuthCallback from '../lib/oauth/callback'
import callbackHandler from '../lib/callback-handler'
import cookie from '../lib/cookie'
import * as cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'
export default async (req, res, options, done) => {
/** Handle callbacks from login services */
export default async function callback (req, res) {
const {
provider: providerName,
providers,
@@ -19,133 +19,134 @@ export default async (req, res, options, done) => {
jwt,
events,
callbacks,
csrfToken,
redirect
} = options
csrfToken
} = req.options
const provider = providers[providerName]
const { type } = provider
const useJwtSession = options.session.jwt
const sessionMaxAge = options.session.maxAge
const useJwtSession = req.options.session.jwt
const sessionMaxAge = req.options.session.maxAge
// Get session ID (if set)
const sessionToken = req.cookies ? req.cookies[cookies.sessionToken.name] : null
if (type === 'oauth') {
try {
oAuthCallback(req, provider, csrfToken, async (error, profile, account, OAuthProfile) => {
try {
if (error) {
logger.error('CALLBACK_OAUTH_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
}
const { profile, account, OAuthProfile } = await oAuthCallback(req, csrfToken)
try {
// Make it easier to debug when adding a new provider
logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile })
// Make it easier to debug when adding a new provider
logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile })
// If we don't have a profile object then either something went wrong
// or the user cancelled signin in. We don't know which, so we just
// direct the user to the signup page for now. We could do something
// else in future.
//
// Note: In oAuthCallback an error is logged with debug info, so it
// should at least be visible to developers what happened if it is an
// error with the provider.
if (!profile) {
return res.redirect(`${baseUrl}${basePath}/signin`)
}
// If we don't have a profile object then either something went wrong
// or the user cancelled signin in. We don't know which, so we just
// direct the user to the signup page for now. We could do something
// else in future.
//
// Note: In oAuthCallback an error is logged with debug info, so it
// should at least be visible to developers what happened if it is an
// error with the provider.
if (!profile) {
return redirect(`${baseUrl}${basePath}/signin`)
}
// Check if user is allowed to sign in
// Attempt to get Profile from OAuth provider details before invoking
// signIn callback - but if no user object is returned, that is fine
// (that just means it's a new user signing in for the first time).
let userOrProfile = profile
if (adapter) {
const { getUserByProviderAccountId } = await adapter.getAdapter(options)
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
if (userFromProviderAccountId) {
userOrProfile = userFromProviderAccountId
}
}
try {
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
if (signInCallbackResponse === false) {
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
}
} catch (error) {
if (error instanceof Error) {
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
} else {
return redirect(error)
}
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
if (useJwtSession) {
const defaultJwtPayload = {
name: user.name,
email: user.email,
picture: user.image
}
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
} else {
// Save Session Token in cookie
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
}
await dispatchEvent(events.signIn, { user, account, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return redirect(pages.newUser)
}
// Callback URL is already verified at this point, so safe to use if specified
return redirect(callbackUrl || baseUrl)
} catch (error) {
if (error.name === 'AccountNotLinkedError') {
// If the email on the account is already linked, but nto with this oAuth account
return redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
} else if (error.name === 'CreateUserError') {
return redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
} else {
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
// Check if user is allowed to sign in
// Attempt to get Profile from OAuth provider details before invoking
// signIn callback - but if no user object is returned, that is fine
// (that just means it's a new user signing in for the first time).
let userOrProfile = profile
if (adapter) {
const { getUserByProviderAccountId } = await adapter.getAdapter(req.options)
const userFromProviderAccountId = await getUserByProviderAccountId(account.provider, account.id)
if (userFromProviderAccountId) {
userOrProfile = userFromProviderAccountId
}
}
})
try {
const signInCallbackResponse = await callbacks.signIn(userOrProfile, account, OAuthProfile)
if (signInCallbackResponse === false) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
}
// TODO: Remove in a future major release
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
return res.redirect(error)
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
if (useJwtSession) {
const defaultJwtPayload = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString()
}
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, OAuthProfile, isNewUser)
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token: jwtPayload })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000))
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options })
} else {
// Save Session Token in cookie
cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options })
}
await dispatchEvent(events.signIn, { user, account, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
}
// Callback URL is already verified at this point, so safe to use if specified
return res.redirect(callbackUrl || baseUrl)
} catch (error) {
if (error.name === 'AccountNotLinkedError') {
// If the email on the account is already linked, but not with this OAuth account
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`)
} else if (error.name === 'CreateUserError') {
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`)
} else {
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
}
} catch (error) {
if (error.name === 'OAuthCallbackError') {
logger.error('CALLBACK_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
}
logger.error('OAUTH_CALLBACK_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
} else if (type === 'email') {
try {
if (!adapter) {
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(options)
const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(req.options)
const verificationToken = req.query.token
const email = req.query.email
// Verify email and verification token exist in database
const invite = await getVerificationRequest(email, verificationToken, secret, provider)
if (!invite) {
return redirect(`${baseUrl}${basePath}/error?error=Verification`)
return res.redirect(`${baseUrl}${basePath}/error?error=Verification`)
}
// If verification token is valid, delete verification request token from
@@ -160,24 +161,28 @@ export default async (req, res, options, done) => {
try {
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
if (signInCallbackResponse === false) {
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
} else {
return redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
}
// TODO: Remove in a future major release
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
return res.redirect(error)
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options)
const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, req.options)
if (useJwtSession) {
const defaultJwtPayload = {
name: user.name,
email: user.email,
picture: user.image
picture: user.image,
sub: user.id?.toString()
}
const jwtPayload = await callbacks.jwt(defaultJwtPayload, user, account, profile, isNewUser)
@@ -200,32 +205,28 @@ export default async (req, res, options, done) => {
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return redirect(pages.newUser)
return res.redirect(`${pages.newUser}${pages.newUser.includes('?') ? '&' : '?'}callbackUrl=${encodeURIComponent(callbackUrl)}`)
}
// Callback URL is already verified at this point, so safe to use if specified
if (callbackUrl) {
return redirect(callbackUrl)
} else {
return redirect(baseUrl)
}
return res.redirect(callbackUrl || baseUrl)
} catch (error) {
if (error.name === 'CreateUserError') {
return redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
return res.redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`)
} else {
logger.error('CALLBACK_EMAIL_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=Callback`)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
}
} 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')
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
if (!provider.authorize) {
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const credentials = req.body
@@ -234,13 +235,13 @@ export default async (req, res, options, done) => {
try {
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
if (!userObjectReturnedFromAuthorizeHandler) {
return redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
return res.redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
}
} catch (error) {
if (error instanceof Error) {
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
} else {
return redirect(error)
return res.redirect(error)
}
}
@@ -250,13 +251,13 @@ export default async (req, res, options, done) => {
try {
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
if (signInCallbackResponse === false) {
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
}
} catch (error) {
if (error instanceof Error) {
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
} else {
return redirect(error)
return res.redirect(error)
}
}
@@ -278,9 +279,8 @@ export default async (req, res, options, done) => {
await dispatchEvent(events.signIn, { user, account })
return redirect(callbackUrl || baseUrl)
return res.redirect(callbackUrl || baseUrl)
} else {
res.status(500).end(`Error: Callback for provider type ${type} not supported`)
return done()
return res.status(500).end(`Error: Callback for provider type ${type} not supported`)
}
}

View File

@@ -1,21 +1,22 @@
// Return a JSON object with a list of all outh providers currently configured
// and their signin and callback URLs. This makes it possible to automatically
// generate buttons for all providers when rendering client side.
export default (req, res, options, done) => {
const { providers } = options
/**
* Return a JSON object with a list of all outh providers currently configured
* and their signin and callback URLs. This makes it possible to automatically
* generate buttons for all providers when rendering client side.
*/
export default function providers (req, res) {
const { providers } = req.options
const result = {}
Object.entries(providers).map(([provider, providerConfig]) => {
result[provider] = {
id: provider,
name: providerConfig.name,
type: providerConfig.type,
signinUrl: providerConfig.signinUrl,
callbackUrl: providerConfig.callbackUrl
}
})
const result = Object.entries(providers)
.reduce((acc, [provider, providerConfig]) => ({
...acc,
[provider]: {
id: provider,
name: providerConfig.name,
type: providerConfig.type,
signinUrl: providerConfig.signinUrl,
callbackUrl: providerConfig.callbackUrl
}
}), {})
res.setHeader('Content-Type', 'application/json')
res.json(result)
return done()
return res.setHeader('Content-Type', 'application/json').json(result).end()
}

View File

@@ -1,18 +1,19 @@
// Return a session object (without any private fields) for Single Page App clients
import cookie from '../lib/cookie'
import * as cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'
export default async (req, res, options, done) => {
const { cookies, adapter, jwt, events, callbacks } = options
const useJwtSession = options.session.jwt
const sessionMaxAge = options.session.maxAge
/**
* Return a session object (without any private fields)
* for Single Page App clients
*/
export default async function session (req, res) {
const { cookies, adapter, jwt, events, callbacks } = req.options
const useJwtSession = req.options.session.jwt
const sessionMaxAge = req.options.session.maxAge
const sessionToken = req.cookies[cookies.sessionToken.name]
if (!sessionToken) {
res.setHeader('Content-Type', 'application/json')
res.json({})
return done()
return res.setHeader('Content-Type', 'application/json').json({}).end()
}
let response = {}
@@ -58,7 +59,7 @@ export default async (req, res, options, done) => {
}
} else {
try {
const { getUser, getSession, updateSession } = await adapter.getAdapter(options)
const { getUser, getSession, updateSession } = await adapter.getAdapter(req.options)
const session = await getSession(sessionToken)
if (session) {
// Trigger update to session object to update session expiry
@@ -98,7 +99,5 @@ export default async (req, res, options, done) => {
}
}
res.setHeader('Content-Type', 'application/json')
res.json(response)
return done()
return res.setHeader('Content-Type', 'application/json').json(response).end()
}

View File

@@ -1,9 +1,9 @@
// Handle requests to /api/auth/signin
import oAuthSignin from '../lib/signin/oauth'
import emailSignin from '../lib/signin/email'
import logger from '../../lib/logger'
export default async (req, res, options, done) => {
/** Handle requests to /api/auth/signin */
export default async function signin (req, res) {
const {
provider: providerName,
providers,
@@ -11,15 +11,13 @@ export default async (req, res, options, done) => {
basePath,
adapter,
callbacks,
csrfToken,
redirect
} = options
csrfToken
} = req.options
const provider = providers[providerName]
const { type } = provider
if (!type) {
res.status(500).end(`Error: Type not specified for ${provider}`)
return done()
return res.status(500).end(`Error: Type not specified for ${provider}`)
}
if (type === 'oauth' && req.method === 'POST') {
@@ -29,17 +27,17 @@ export default async (req, res, options, done) => {
oAuthSignin(provider, csrfToken, (error, oAuthSigninUrl) => {
if (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
}
return redirect(oAuthSigninUrl)
return res.redirect(oAuthSigninUrl)
}, authParams)
} else if (type === 'email' && req.method === 'POST') {
if (!adapter) {
logger.error('EMAIL_REQUIRES_ADAPTER_ERROR')
return redirect(`${baseUrl}${basePath}/error?error=Configuration`)
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const { getUserByEmail } = await adapter.getAdapter(options)
const { getUserByEmail } = await adapter.getAdapter(req.options)
// Note: Technically the part of the email address local mailbox element
// (everything before the @ symbol) should be treated as 'case sensitive'
@@ -54,29 +52,32 @@ export default async (req, res, options, done) => {
// Check if user is allowed to sign in
try {
const signinCallbackResponse = await callbacks.signIn(profile, account, { email, verificationRequest: true })
if (signinCallbackResponse === false) {
return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
const signInCallbackResponse = await callbacks.signIn(profile, account, { email })
if (signInCallbackResponse === false) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
} else if (typeof signInCallbackResponse === 'string') {
return res.redirect(signInCallbackResponse)
}
} catch (error) {
if (error instanceof Error) {
return redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
} else {
return redirect(error)
return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`)
}
// TODO: Remove in a future major release
logger.warn('SIGNIN_CALLBACK_REJECT_REDIRECT')
return res.redirect(error)
}
try {
await emailSignin(email, provider, options)
await emailSignin(email, provider, req.options)
} catch (error) {
logger.error('SIGNIN_EMAIL_ERROR', error)
return redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`)
}
return redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
return res.redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent(
provider.id
)}&type=${encodeURIComponent(provider.type)}`)
} else {
return redirect(`${baseUrl}${basePath}/signin`)
return res.redirect(`${baseUrl}${basePath}/signin`)
}
}

View File

@@ -1,11 +1,11 @@
// Handle requests to /api/auth/signout
import cookie from '../lib/cookie'
import * as cookie from '../lib/cookie'
import logger from '../../lib/logger'
import dispatchEvent from '../lib/dispatch-event'
export default async (req, res, options, done) => {
const { adapter, cookies, events, jwt, callbackUrl, redirect } = options
const useJwtSession = options.session.jwt
/** Handle requests to /api/auth/signout */
export default async function signout (req, res) {
const { adapter, cookies, events, jwt, callbackUrl } = req.options
const useJwtSession = req.options.session.jwt
const sessionToken = req.cookies[cookies.sessionToken.name]
if (useJwtSession) {
@@ -18,7 +18,7 @@ export default async (req, res, options, done) => {
}
} else {
// Get session from database
const { getSession, deleteSession } = await adapter.getAdapter(options)
const { getSession, deleteSession } = await adapter.getAdapter(req.options)
try {
// Dispatch signout event
@@ -43,5 +43,5 @@ export default async (req, res, options, done) => {
maxAge: 0
})
return redirect(callbackUrl)
return res.redirect(callbackUrl)
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,7 @@
"author": "Iain Collins <me@iaincollins.com>",
"license": "ISC",
"dependencies": {
"next": "^9.5.0",
"next": "^9.5.4",
"react": "^16.13.1",
"react-dom": "^16.13.1"
}

View File

@@ -34,3 +34,10 @@ services:
service: postgres
ports:
- "5432:5432"
fauna:
extends:
file: databases/fauna.yml
service: fauna
ports:
- 8443:8443

View File

@@ -0,0 +1,7 @@
version: '2'
services:
fauna:
image: fauna/faunadb
restart: always

200
test/fauna.js Normal file
View File

@@ -0,0 +1,200 @@
/* eslint-disable */
const Adapters = require('../adapters');
const assert = require('assert');
const fauna = require('faunadb');
const q = fauna.query;
const adminClient = new fauna.Client({
secret: 'secret',
domain: 'localhost',
port: '8443',
scheme: 'http'
});
// Authenticated client against the new DB used for tests
let client = null;
const InitialiseDb = async () => {
await adminClient.query(
q.CreateDatabase({name: 'nextauth'})
);
const key = await adminClient.query(
q.CreateKey({
database: q.Database('nextauth'),
role: 'server'
})
);
client = new fauna.Client({
secret: key.secret,
domain: 'localhost',
port: '8443',
scheme: 'http'
});
await client.query(q.CreateCollection({name: 'account'}));
await client.query(q.CreateCollection({name: 'session'}));
await client.query(q.CreateCollection({name: 'user'}));
await client.query(q.CreateCollection({name: 'verification_request'}));
await client.query(q.CreateIndex({
name: 'account_by_provider_account_id',
source: q.Collection('account'),
unique: true,
terms: [
{ field: ['data', 'providerId'] },
{ field: ['data', 'providerAccountId'] }
]
}));
await client.query(q.CreateIndex({
name: 'session_by_token',
source: q.Collection('session'),
unique: true,
terms: [
{ field: ['data', 'sessionToken'] }
]
}));
await client.query(q.CreateIndex({
name: 'user_by_email',
source: q.Collection('user'),
unique: true,
terms: [
{ field: ['data', 'email'] }
]
}));
await client.query(q.CreateIndex({
name: 'verification_request_by_token',
source: q.Collection('verification_request'),
unique: true,
terms: [
{ field: ['data', 'token'] }
]
}));
}
const RunTests = async (adapter) => {
// createUser
const newUserResult = await adapter.createUser({
name: 'test user',
email: 'user@name.test',
image: 'https://www.gravatar.com/avatar/0'
});
assert.strictEqual(newUserResult.name, 'test user');
assert(newUserResult.createdAt !== null);
const userId = newUserResult.id;
// getUser
const user = await adapter.getUser(newUserResult.id);
assert.strictEqual(user.id, userId);
// getUserByEmail
const userByEmaiil = await adapter.getUserByEmail('user@name.test');
assert.strictEqual(userByEmaiil.id, userId);
// updateUser
const update = {
...user,
name: 'updated name'
};
const updatedUser = await adapter.updateUser(update);
assert.strictEqual(updatedUser.name, 'updated name');
assert.strictEqual(updatedUser.id, userId);
// linkAccount
const account = await adapter.linkAccount(
userId,
'github',
'oauth',
756832,
undefined,
'b7e3b00f2c596abc445f11abc445f1104c1b2b',
null
);
assert.strictEqual(account.userId, userId);
assert.strictEqual(account.providerId, 'github');
assert(account.createdAt !== null);
// getUserByProviderAccountId
const userByProviderAccountId = await adapter.getUserByProviderAccountId('github', 756832);
assert.strictEqual(userByProviderAccountId.email, user.email);
// createSession
const newSession = await adapter.createSession(user);
assert(newSession.sessionToken !== null);
assert(newSession.createdAt !== null);
assert(newSession.expires !== null);
// getSession
const session = await adapter.getSession(newSession.sessionToken);
assert.strictEqual(session.sessionToken, newSession.sessionToken);
// updateSession
const updatedSession = await adapter.updateSession(session);
assert(updatedSession.expires !== session.expires);
// deleteSession
await adapter.deleteSession(session.sessionToken);
// unlinkAccount
await adapter.unlinkAccount(userId, 'github', 756832);
// deleteUser
await adapter.deleteUser(userId);
// createVerificationRequest
let requestSent = false;
const newVerificationRequest = await adapter.createVerificationRequest(
'user@test.test',
'http://localhost/callback/email?email=test@test.test&token=123',
'123',
'abc',
{
sendVerificationRequest: ({}) => {
requestSent = true;
}
}
);
assert.strictEqual(newVerificationRequest.identifier, 'user@test.test');
assert(newVerificationRequest.token !== null && newVerificationRequest.token !== '');
assert(requestSent === true);
// getVerificationRequest
const verificationRequest = await adapter.getVerificationRequest('user@test.test', '123', 'abc');
assert.strictEqual(verificationRequest.identifier, 'user@test.test');
assert.strictEqual(verificationRequest.token, newVerificationRequest.token);
// deleteVerificationRequest
await adapter.deleteVerificationRequest('user@test.test', '123', 'abc');
}
;(async () => {
let error = false;
try {
// Initialise collections and create indexes
await InitialiseDb();
const adapterFactory = Adapters.Fauna.Adapter({faunaClient: client});
const adapter = await adapterFactory.getAdapter({baseUrl: 'http://localhost'});
await RunTests(adapter);
console.log('FaunaDB loaded ok');
} catch (error) {
console.error('FaunaDB error', error);
error = true;
} finally {
// Clean up the DB
await adminClient.query(
q.Delete(q.Database('nextauth'))
);
}
const retCode = error ? 1 : 0;
process.exit(retCode);
})();

View File

@@ -17,16 +17,16 @@ You can specify a handler for any of the callbacks below.
...
callbacks: {
signIn: async (user, account, profile) => {
return Promise.resolve(true)
return true
},
redirect: async (url, baseUrl) => {
return Promise.resolve(baseUrl)
return baseUrl
},
session: async (session, user) => {
return Promise.resolve(session)
return session
},
jwt: async (token, user, account, profile, isNewUser) => {
return Promise.resolve(token)
return token
}
...
}
@@ -44,19 +44,19 @@ callbacks: {
* @param {object} user User object
* @param {object} account Provider account
* @param {object} profile Provider profile
* @return {boolean} Return `true` (or a modified JWT) to allow sign in
* @return {boolean|string} Return `true` to allow sign in
* Return `false` to deny access
* Return `string` to redirect to (eg.: "/unauthorized")
*/
signIn: async (user, account, profile) => {
const isAllowedToSignIn = true
if (isAllowedToSignIn) {
return Promise.resolve(true)
return true
} else {
// Return false to display a default error message
return Promise.resolve(false)
// You can also Reject this callback with an Error or with a URL:
// return Promise.reject(new Error('error message')) // Redirect to error page
// return Promise.reject('/path/to/redirect') // Redirect to a URL
return false
// Or you can return a URL to redirect to:
// return '/unauthorized'
}
}
}
@@ -97,8 +97,8 @@ callbacks: {
*/
redirect: async (url, baseUrl) => {
return url.startsWith(baseUrl)
? Promise.resolve(url)
: Promise.resolve(baseUrl)
? url
: baseUrl
}
}
```
@@ -127,7 +127,7 @@ callbacks: {
*/
session: async (session, user) => {
session.foo = 'bar' // Add property to session
return Promise.resolve(session)
return session
}
}
```
@@ -148,7 +148,7 @@ If using JSON Web Tokens instead of database sessions, you should use the User I
## JWT callback
This JSON Web Token callback is called whenever a JSON Web Token is created (i.e. at sign
in) or updated (i.e whenever a session is accesed in the client).
in) or updated (i.e whenever a session is accessed in the client).
e.g. `/api/auth/signin`, `getSession()`, `useSession()`, `/api/auth/session`
@@ -171,7 +171,7 @@ callbacks: {
const isSignIn = (user) ? true : false
// Add auth_time to token on signin in
if (isSignIn) { token.auth_time = Math.floor(Date.now() / 1000) }
return Promise.resolve(token)
return token
}
}
```

View File

@@ -144,7 +144,7 @@ Install module:
#### Example
```js
database: 'postgres://username:password@127.0.0.1:3306/database_name'
database: 'postgres://username:password@127.0.0.1:5432/database_name'
```
### Microsoft SQL Server
@@ -166,7 +166,7 @@ Install module:
#### Example
```js
database: 'mongodb://username:password@127.0.0.1:3306/database_name'
database: 'mongodb://username:password@127.0.0.1:27017/database_name'
```
### SQLite

View File

@@ -231,16 +231,16 @@ You can specify a handler for any of the callbacks below.
```js
callbacks: {
signIn: async (user, account, profile) => {
return Promise.resolve(true)
return true
},
redirect: async (url, baseUrl) => {
return Promise.resolve(baseUrl)
return baseUrl
},
session: async (session, user) => {
return Promise.resolve(session)
return session
},
jwt: async (token, user, account, profile, isNewUser) => {
return Promise.resolve(token)
return token
}
}
```
@@ -277,7 +277,7 @@ events: {
### adapter
* **Default value**: *Adapater.Default()*
* **Default value**: *Adapter.Default()*
* **Required**: *No*
#### Description

View File

@@ -14,22 +14,27 @@ NextAuth.js is designed to work with any OAuth service, it supports OAuth 1.0, 1
* [Apple](/providers/apple)
* [Atlassian](/providers/atlassian)
* [Auth0](/providers/auth0)
* [Azure Active Directory B2C](/providers/azure-ad-b2c)
* [Basecamp](/providers/basecamp)
* [Battle.net](/providers/battlenet)
* [Battle.net](/providers/battle.net)
* [Box](/providers/box)
* [Amazon Cognito](/providers/cognito)
* [Discord](/providers/discord)
* [Facebook](/providers/facebook)
* [Foursquare](/providers/foursquare)
* [FusionAuth](/providers/fusionauth)
* [GitHub](/providers/github)
* [GitLab](/providers/gitlab)
* [Google](/providers/google)
* [IdentityServer4](/providers/identity-server4)
* [LinkedIn](/providers/LinkedIn)
* [Mail.ru](/providers/Mail.ru)
* [Mixer](/providers/Mixer)
* [Netlify](/providers/Netlify)
* [Okta](/providers/Okta)
* [Slack](/providers/slack)
* [Spotify](/providers/spotify)
* [Strava](/providers/strava)
* [Twitch](/providers/Twitch)
* [Twitter](/providers/twitter)
* [Yandex](/providers/yandex)
@@ -103,7 +108,7 @@ As an example of what this looks like, this is the the provider object returned
clientSecret: ''
}
```
You can replace all the options in this JSON object with the ones from your custom provider  be sure to give it a unique ID and specify the correct OAuth version - and add it to the providers option:
You can replace all the options in this JSON object with the ones from your custom provider be sure to give it a unique ID and specify the correct OAuth version - and add it to the providers option:
```js title="pages/api/auth/[...nextauth].js"
...
@@ -135,6 +140,7 @@ providers: [
| scope | OAuth access scopes (expects array or string) | No |
| params | Additional authorization URL parameters | No |
| accessTokenUrl | Endpoint to retrieve an access token | Yes |
| accessTokenGetter | Default `(json) => json.access_token` | No |
| requestTokenUrl | Endpoint to retrieve a request token | No |
| authorizationUrl | Endpoint to request authorization from the user | Yes |
| profileUrl | Endpoint to retrieve the user's profile | No |
@@ -203,9 +209,9 @@ providers: [
}
if (user) {
// Any user object returned here will be saved in the JSON Web Token
return Promise.resolve(user)
return user
} else {
return Promise.resolve(null)
return null
}
}
})
@@ -220,7 +226,7 @@ The Credentials provider can only be used if JSON Web Tokens are enabled for ses
:::
<!-- React Image Component -->
export const Image = ({ children, src, alt = '' }) => (
export const Image = ({ children, src, alt = '' }) => (
<div
style={{
padding: '0.2rem',

View File

@@ -12,6 +12,7 @@ title: Contributors
* <a href="https://github.com/geraldnolan">Gerald Nolan</a>
* <a href="https://github.com/lluia">Lluis Agusti</a>
* <a href="https://github.com/JeffersonBledsoe">Jefferson Bledsoe</a>
* <a href="https://github.com/balazsorban44">Balázs Orbán</a>
_Special thanks to Lori Karikari for creating most of the providers, to Nico Domino for creating this site, to Fredrik Pettersen for creating the Prisma adapter, to Gerald Nolan for adding support for Sign in with Apple, to Lluis Agusti for work to add TypeScript definitions and to Jefferson Bledsoe for working on automating testing._

View File

@@ -70,6 +70,7 @@ If you are using a Credentials Provider, NextAuth.js will not persist users or s
In _most cases_ it does not make sense to specify a database in NextAuth.js options and support a Credentials Provider.
#### CALLBACK_CREDENTIALS_HANDLER_ERROR
---
### Session Handling
@@ -127,3 +128,9 @@ They all indicate a problem interacting with the database.
This error occurs when the Email Authentication Provider is unable to send an email.
Check your mail server configuration.
#### MISSING_NEXTAUTH_API_ROUTE_ERROR
This error happens when `[...nextauth].js` file is not found inside `pages/api/auth`.
Make sure the file is there and the filename is written correctly.

View File

@@ -23,7 +23,7 @@ You can use also NextAuth.js with any database using a custom database adapter,
### What authentication services does NextAuth.js support?
NextAuth.js includes built-in support for signing in with Apple, Atlassian, Auth0, Google, Battle.net, Box, AWS Cognito, Discord, Facebook, FusionAuth, GitHub, GitLab, Google, Open ID Identity Server, Mixer, Okta, Slack, Spotify, Twitch, Twitter and Yandex.
NextAuth.js includes built-in support for signing in with Apple, Atlassian, Auth0, Azure Active Directory B2C, Google, Battle.net, Box, AWS Cognito, Discord, Facebook, Foursquare, FusionAuth, GitHub, GitLab, Google, Open ID Identity Server, Mixer, Netlify, Okta, Slack, Spotify, Strava, Twitch, Twitter and Yandex.
NextAuth.js also supports email for passwordless sign in, which is useful for account recovery or for people who are not able to use an account with the configured OAuth services (e.g. due to service outage, account suspension or otherwise becoming locked out of an account).

View File

@@ -169,7 +169,7 @@ The `signIn()` method can be called from the client in different ways, as shown
import { signIn } from 'next-auth/client'
export default () => (
<button onClick={signIn}>Sign in</button>
<button onClick={() => signIn()}>Sign in</button>
)
```
@@ -224,7 +224,7 @@ It reloads the page in the browser when complete.
import { signOut } from 'next-auth/client'
export default () => (
<button onClick={signOut}>Sign out</button>
<button onClick={() => signOut()}>Sign out</button>
)
```

View File

@@ -21,7 +21,7 @@ To add NextAuth.js to a project create a file called `[...nextauth].js` in `page
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
const options = {
export default NextAuth({
// Configure one or more authentication providers
providers: [
Providers.GitHub({
@@ -33,9 +33,7 @@ const options = {
// A database is optional, but required to persist accounts in a database
database: process.env.DATABASE_URL,
}
export default (req, res) => NextAuth(req, res, options)
})
```
All requests to `/api/auth/*` (signin, callback, signout, etc) will automatically be handed by NextAuth.js.
@@ -58,11 +56,11 @@ export default function Page() {
return <>
{!session && <>
Not signed in <br/>
<button onClick={signIn}>Sign in</button>
<button onClick={() => signIn()}>Sign in</button>
</>}
{session && <>
Signed in as {session.user.email} <br/>
<button onClick={signOut}>Sign out</button>
<button onClick={() => signOut()}>Sign out</button>
</>}
</>
}
@@ -103,5 +101,5 @@ NEXTAUTH_URL=https://example.com
:::tip
In production, this needs to be set as an environment variable on the service you use to deploy your app.
To set environment variables on Vercel, you can use the [dashboard](https://vercel.com/dashboard) or the `now env` command.
To set environment variables on Vercel, you can use the [dashboard](https://vercel.com/dashboard) or the `vercel env pull` [command](https://vercel.com/docs/build-step#development-environment-variables).
:::

View File

@@ -0,0 +1,28 @@
---
id: azure-ad-b2c
title: Azure Active Directory B2C
---
## Documentation
https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
## Configuration
https://docs.microsoft.com/en-us/azure/active-directory-b2c/tutorial-create-tenant
## Example
```js
import Providers from 'next-auth/providers';
...
providers: [
Providers.AzureADB2C({
clientId: process.env.AZURE_CLIENT_ID,
clientSecret: process.env.AZURE_CLIENT_SECRET,
scope: 'offline_access User.Read',
tenantId: process.env.AZURE_TENANT_ID,
}),
]
...
```

View File

@@ -0,0 +1,132 @@
---
id: bungie
title: Bungie
---
## Documentation
https://github.com/Bungie-net/api/wiki/OAuth-Documentation
## Configuration
https://www.bungie.net/en/Application
## Example
```js
import Providers from `next-auth/providers`
...
providers: [
Providers.Bungie({
clientId: process.env.BUNGIE_CLIENT_ID,
clientSecret: process.env.BUNGIE_SECRET,
apiKey: process.env.BUNGIE_API_KEY
}),
}
...
```
## Instructions
### Configuration
:::tip
Bungie require all sites to run HTTPS (including local development instances).
:::
:::tip
Bungie doesn't allow you to use localhost as the website URL, instead you need to use https://127.0.0.1:3000
:::
Navigate to https://www.bungie.net/en/Application and fill in the required details:
* Application name
* Application Status
* Website
* OAuth Client Type
- Confidential
* Redirect URL
- https://localhost:3000/api/auth/callback/bungie
* Scope
- `Access items like your Bungie.net notifications, memberships, and recent Bungie.Net forum activity.`
* Origin Header
The following guide may be helpful:
* [How to setup localhost with HTTPS with a Next.js app](https://medium.com/@anMagpie/secure-your-local-development-server-with-https-next-js-81ac6b8b3d68)
### Example server
You will need to edit your host file and point your site at `127.0.0.1`
[How to edit my host file?](https://phoenixnap.com/kb/how-to-edit-hosts-file-in-windows-mac-or-linux)
On Windows (Run Powershell as administrator)
```ps
Add-Content -Path C:\Windows\System32\drivers\etc\hosts -Value "127.0.0.1`tdev.example.com" -Force
```
```
127.0.0.1 dev.example.com
```
#### Create certificate
Creating a certificate for localhost is easy with openssl . Just put the following command in the terminal. The output will be two files: localhost.key and localhost.crt.
```bash
openssl req -x509 -out localhost.crt -keyout localhost.key \
-newkey rsa:2048 -nodes -sha256 \
-subj '/CN=localhost' -extensions EXT -config <( \
printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
```
:::tip
**Windows**
The OpenSSL executable is distributed with [Git](https://git-scm.com/download/win]9) for Windows.
Once installed you will find the openssl.exe file in `C:/Program Files/Git/mingw64/bin` which you can add to the system PATH environment variable if its not already done.
Add environment variable `OPENSSL_CONF=C:/Program Files/Git/mingw64/ssl/openssl.cnf`
```bash
req -x509 -out localhost.crt -keyout localhost.key \
-newkey rsa:2048 -nodes -sha256 \
-subj '/CN=localhost'
```
:::
Create directory `certificates` and place `localhost.key` and `localhost.crt`
You can create a `server.js` in the root of your project and run it with `node server.js` to test Sign in with Bungie integration locally:
```js
const { createServer } = require('https')
const { parse } = require('url')
const next = require('next')
const fs = require('fs')
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
const httpsOptions = {
key: fs.readFileSync('./certificates/localhost.key'),
cert: fs.readFileSync('./certificates/localhost.crt')
}
app.prepare().then(() => {
createServer(httpsOptions, (req, res) => {
const parsedUrl = parse(req.url, true)
handle(req, res, parsedUrl)
}).listen(3000, err => {
if (err) throw err
console.log('> Ready on https://localhost:3000')
})
})
```

View File

@@ -27,9 +27,9 @@ The Credentials provider is specified like other providers, except that you need
If you return `false` or `null` then an error will be displayed advising the user to check their details.
3. `Promise.Rejected()` with an Error or a URL.
3. You can throw an Error or a URL (a string).
If you reject the promise with an Error, the user will be sent to the error page with the error message as a query parameter. If you reject the promise with a URL (a string), the user will be redirected to the URL.
If you throw an Error, the user will be sent to the error page with the error message as a query parameter. If throw a URL (a string), the user will be redirected to the URL.
```js title="pages/api/auth/[...nextauth].js"
import Providers from `next-auth/providers`
@@ -51,13 +51,13 @@ providers: [
if (user) {
// Any object returned will be saved in `user` property of the JWT
return Promise.resolve(user)
return user
} else {
// If you return null or false then the credentials will be rejected
return Promise.resolve(null)
return null
// You can also Reject this callback with an Error or with a URL:
// return Promise.reject(new Error('error message')) // Redirect to error page
// return Promise.reject('/path/to/redirect') // Redirect to a URL
// throw new Error('error message') // Redirect to error page
// throw '/path/to/redirect' // Redirect to a URL
}
}
})
@@ -84,7 +84,7 @@ As with all providers, the order you specify them is the order they are displaye
name: "Domain Account",
authorize: async (credentials) => {
const user = { /* add function to get user */ }
return Promise.resolve(user)
return user
},
credentials: {
domain: { label: "Domain", type: "text ", placeholder: "CORPNET", value: "CORPNET" },
@@ -97,7 +97,7 @@ As with all providers, the order you specify them is the order they are displaye
name: "Two Factor Auth",
authorize: async (credentials) => {
const user = { /* add function to get user */ }
return Promise.resolve(user)
return user
},
credentials: {
email: { label: "Username", type: "text ", placeholder: "jsmith" },

View File

@@ -21,6 +21,6 @@ providers: [
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET
})
}
]
...
```

View File

@@ -0,0 +1,30 @@
---
id: foursquare
title: Foursquare
---
## Documentation
https://developer.foursquare.com/docs/places-api/authentication/#web-applications
## Configuration
https://developer.foursquare.com/
:::warning
Foursquare requires an additional `apiVersion` parameter in [`YYYYMMDD` format](https://developer.foursquare.com/docs/places-api/versioning/), which essentially states "I'm prepared for API changes up to this date".
## Example
```js
import Providers from `next-auth/providers`
...
providers: [
Providers.Foursquare({
clientId: process.env.FOURSQUARE_CLIENT_ID,
clientSecret: process.env.FOURSQUARE_CLIENT_SECRET,
apiVersion: 'YYYYMMDD'
})
}
...
```

View File

@@ -63,9 +63,9 @@ const options = {
if (account.provider === 'google' &&
profile.verified_email === true &&
profile.email.endsWith('@example.com')) {
return Promise.resolve(true)
return true
} else {
return Promise.resolve(false)
return false
}
},
}

View File

@@ -0,0 +1,25 @@
---
id: Mail.ru
title: Mail.ru
---
## Documentation
https://o2.mail.ru/docs
## Configuration
https://o2.mail.ru/app/
## Example
```js
import Providers from `next-auth/providers`
...
providers: [
Providers.MailRu({
clientId: process.env.MAILRU_CLIENT_ID,
clientSecret: process.env.MAILRU_CLIENT_SECRET
})
]
...

View File

@@ -0,0 +1,26 @@
---
id: netlify
title: Netlify
---
## Documentation
https://www.netlify.com/blog/2016/10/10/integrating-with-netlify-oauth2/
## Configuration
https://github.com/netlify/netlify-oauth-example
## Example
```js
import Providers from `next-auth/providers`
...
providers: [
Providers.Netlify({
clientId: process.env.NETLIFY_CLIENT_ID,
clientSecret: process.env.NETLIFY_CLIENT_SECRET
})
}
...
```

View File

@@ -0,0 +1,22 @@
---
id: strava
title: Strava
---
## Documentation
http://developers.strava.com/docs/reference/
## Example
```js
import Providers from 'next-auth/providers'
...
providers: [
Providers.Strava({
clientId: process.env.STRAVA_CLIENT_ID,
clientSecret: process.env.STRAVA_CLIENT_SECRET,
})
}
...
```

View File

@@ -74,7 +74,7 @@ import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const options = {
export default NextAuth({
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
@@ -82,9 +82,7 @@ const options = {
})
],
adapter: Adapters.Prisma.Adapter({ prisma }),
}
export default (req, res) => NextAuth(req, res, options)
})
```
:::tip

View File

@@ -58,10 +58,31 @@ CREATE TABLE verification_requests
created_at datetime NOT NULL DEFAULT getdate(),
updated_at datetime NOT NULL DEFAULT getdate()
);
CREATE UNIQUE INDEX compound_id
ON accounts(compound_id);
CREATE INDEX provider_account_id
ON accounts(provider_account_id);
CREATE INDEX provider_id
ON accounts(provider_id);
CREATE INDEX user_id
ON accounts(user_id);
CREATE UNIQUE INDEX session_token
ON sessions(session_token);
CREATE UNIQUE INDEX access_token
ON sessions(access_token);
CREATE UNIQUE INDEX email
ON users(email);
CREATE UNIQUE INDEX token
ON verification_requests(token);
```
:::warning
The above schema is incomplete, it does not include indexes.
When using NextAuth.js with SQL Server fir the first time, run NextAuth.js once against your database with `?syncronize=true` on the connection string and export the schema that is created.
:::
When using NextAuth.js with SQL Server for the first time, run NextAuth.js once against your database with `?synchronize=true` on the connection string and export the schema that is created.
:::

View File

@@ -46,7 +46,7 @@ CREATE TABLE users
name VARCHAR(255),
email VARCHAR(255),
email_verified TIMESTAMPTZ,
image VARCHAR(255),
image TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
@@ -87,4 +87,4 @@ CREATE UNIQUE INDEX email
CREATE UNIQUE INDEX token
ON verification_requests(token);
```
```

View File

@@ -149,7 +149,7 @@ const Adapter = (config, options = {}) => {
return null
}
return Promise.resolve({
return {
createUser,
getUser,
getUserByEmail,
@@ -166,7 +166,7 @@ const Adapter = (config, options = {}) => {
createVerificationRequest,
getVerificationRequest,
deleteVerificationRequest
})
}
}
return {

View File

@@ -14,7 +14,7 @@ const ldap = require("ldapjs");
import NextAuth from "next-auth";
import Providers from "next-auth/providers";
const options = {
export default NextAuth({
providers: [
Providers.Credentials({
name: "LDAP",
@@ -64,9 +64,7 @@ const options = {
secret: process.env.NEXTAUTH_SECRET,
encryption: true, // Very important to encrypt the JWT, otherwise you're leaking username+password into the browser
},
};
export default (req, res) => NextAuth(req, res, options);
});
```
The idea is that once one is authenticated with the LDAP server, one can pass through both the username/DN and password to the JWT stored in the browser.

View File

@@ -3,7 +3,7 @@ id: securing-pages-and-api-routes
title: Securing pages and API routes
---
You can easily protect client and server side side rendered pages and API routes with NextAuth.js.
You can easily protect client and server side rendered pages and API routes with NextAuth.js.
_You can find working examples of the approaches shown below in the [example project](https://github.com/iaincollins/next-auth-example/)._
@@ -67,7 +67,7 @@ export async function getServerSideProps(context) {
```
:::tip
This example assumes you have configured `_app.js` to pass the `session` prop through so that it's immediately avalible on page load to `useSession`.
This example assumes you have configured `_app.js` to pass the `session` prop through so that it's immediately available on page load to `useSession`.
```js title="pages/_app.js"
import { Provider } from 'next-auth/client'

View File

@@ -36,7 +36,7 @@ Second, a cypress file for environment variables. These can be defined in `cypre
{
"GOOGLE_USER": "username@company.com",
"GOOGLE_PW": "password",
"COOKIE_NAME": "__Secure-next-auth.session-token",
"COOKIE_NAME": "next-auth.session-token",
"SITE_NAME": "http://localhost:3000"
}
```
@@ -111,8 +111,11 @@ describe('Login page', () => {
})
Cypress.Cookies.defaults({
whitelist: cookieName,
preserve: cookieName,
})
// remove the two lines below if you need to stay logged in
// for your remaining tests
cy.visit('/api/auth/signout')
cy.get('form').submit()
}

View File

@@ -62,7 +62,7 @@ import Adapters from "next-auth/adapters"
import Models from "../../../models"
const options = {
export default NextAuth({
providers: [
// Your providers
],
@@ -77,9 +77,7 @@ const options = {
},
}
),
}
export default (req, res) => NextAuth(req, res, options)
})
```

View File

@@ -48,3 +48,23 @@ You can use [node-jose-tools](https://www.npmjs.com/package/node-jose-tools) to
#### JWT_AUTO_GENERATED_ENCRYPTION_KEY
#### SIGNIN_CALLBACK_REJECT_REDIRECT
You returned something in the `signIn` callback, that is being deprecated.
You probably had something similar in the callback:
```js
return Promise.reject("/some/url")
```
or
```js
throw "/some/url"
```
To remedy this, simply return the url instead:
```js
return "/some/url"
```

View File

@@ -17,7 +17,7 @@ module.exports = {
alt: 'NextAuth Logo',
src: 'img/logo/logo-xs.png'
},
links: [
items: [
{
to: '/getting-started/introduction',
activeBasePath: 'docs',

7060
www/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,16 +10,16 @@
"lint:fix": "standard --fix"
},
"dependencies": {
"@docusaurus/core": "^2.0.0-alpha.54",
"@docusaurus/preset-classic": "^2.0.0-alpha.54",
"@docusaurus/core": "^2.0.0-alpha.66",
"@docusaurus/preset-classic": "^2.0.0-alpha.66",
"classnames": "^2.2.6",
"docusaurus-lunr-search": "^1.0.3",
"jose": "^1.27.2",
"docusaurus-lunr-search": "^2.1.7",
"jose": "^2.0.2",
"lodash.times": "^4.3.2",
"react": "^16.8.4",
"react-dom": "^16.8.4",
"react-marquee-slider": "^1.0.16",
"styled-components": "^5.1.1"
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-marquee-slider": "^1.1.2",
"styled-components": "^5.2.0"
},
"browserslist": {
"production": [
@@ -34,6 +34,6 @@
]
},
"devDependencies": {
"standard": "^14.3.4"
"standard": "^15.0.0"
}
}

View File

@@ -26,9 +26,11 @@ module.exports = {
'providers/apple',
'providers/atlassian',
'providers/auth0',
'providers/azure-ad-b2c',
'providers/basecamp',
'providers/battle.net',
'providers/box',
'providers/bungie',
'providers/cognito',
'providers/discord',
'providers/email',
@@ -40,10 +42,12 @@ module.exports = {
'providers/google',
'providers/identity-server4',
'providers/linkedin',
'providers/Mail.ru',
'providers/mixer',
'providers/okta',
'providers/slack',
'providers/spotify',
'providers/strava',
'providers/twitch',
'providers/twitter',
'providers/yandex'

View File

@@ -9,7 +9,7 @@
/* You can override the default Infima variables here. */
:root {
--ifm-color-link: #289EF9;
--ifm-color-link: #289ef9;
--ifm-color-primary: #1eb1fc;
--ifm-color-primary-dark: #03a7fa;
--ifm-color-primary-darker: #039eed;
@@ -21,22 +21,24 @@
--ifm-color-info: #1eb1fc;
--ifm-color-success: #1eb1fc;
--ifm-color-warning: #c94b4b;
--ifm-font-family-base: -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';
--ifm-font-family-base: -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";
--ifm-background-color: #fff;
--ifm-footer-background-color: #f9f9f9;
--ifm-hero-background-color: #f5f5f5;
--ifm-navbar-background-color: rgba(255,255,255,0.95);
--ifm-navbar-background-color: rgba(255, 255, 255, 0.95);
--ifm-h1-font-size: 3rem;
--ifm-h1-font-size: 2rem;
}
html[data-theme='dark']:root {
--ifm-color-link: #289EF9;
html[data-theme="dark"]:root {
--ifm-color-link: #289ef9;
--ifm-footer-background-color: #111;
--ifm-html-background-color: #242526;
--ifm-background-color: #000000;
--ifm-hero-background-color: #111111;
--ifm-navbar-background-color: rgba(0,0,0,0.95);
--ifm-navbar-background-color: rgba(0, 0, 0, 0.95);
}
@import "hero.css";
@@ -63,7 +65,6 @@ a {
a,
a:hover,
.navbar .navbar__link:hover {
text-decoration: underline;
color: var(--ifm-color-link);
}
@@ -109,12 +110,12 @@ a:hover,
.navbar__title {
font-size: 1.2rem;
margin-left: .2rem;
margin-left: 0.2rem;
display: none;
}
.navbar-sidebar__brand .navbar__title {
display: inline;
.navbar-sidebar__brand .navbar__title {
display: inline;
}
@media screen and (min-width: 420px) {
@@ -137,14 +138,14 @@ a:hover,
padding: 0;
height: 100%;
font-size: 0.9rem;
background: #1E1E1E;
background: #1e1e1e;
overflow: hidden;
border-radius: .5rem;
border-radius: 0.5rem;
}
.home-main .code .code-heading {
color: rgba(255,255,255,1);
background: #191A1B;
color: rgba(255, 255, 255, 1);
background: #191a1b;
margin: 0;
padding: 0.5rem 1rem;
font-size: 1rem;
@@ -171,7 +172,7 @@ hr {
border-color: #ddd;
}
html[data-theme='dark'] hr {
html[data-theme="dark"] hr {
border-color: #242526;
}
@@ -190,7 +191,7 @@ html[data-theme='dark'] hr {
background-repeat: no-repeat;
}
html[data-theme='dark'] .navbar__item.navbar__link[href*="github"]:before {
html[data-theme="dark"] .navbar__item.navbar__link[href*="github"]:before {
background-image: url("/img/brand-github-inverted.svg");
}
@@ -198,12 +199,12 @@ html[data-theme='dark'] .navbar__item.navbar__link[href*="github"]:before {
content: "";
width: 3rem;
height: 1.2rem;
margin-top: .2rem;
margin-top: 0.2rem;
background-image: url("/img/brand-npm.svg");
background-repeat: no-repeat;
}
html[data-theme='dark'] .navbar__item.navbar__link[href*="npm"]:before {
html[data-theme="dark"] .navbar__item.navbar__link[href*="npm"]:before {
background-image: url("/img/brand-npm-inverted.svg");
}
@@ -214,8 +215,8 @@ html[data-theme='dark'] .navbar__item.navbar__link[href*="npm"]:before {
}
}
html[data-theme='dark'] {
--ifm-background-color: #1F201C;
html[data-theme="dark"] {
--ifm-background-color: #1f201c;
}
.home-main .feature-image-wrapper {
@@ -225,22 +226,21 @@ html[data-theme='dark'] {
border-radius: 10rem;
overflow: visible;
background-color: var(--ifm-color-primary);
box-shadow: 0 0 2rem rgba(0,0,0,.1);
background-image: linear-gradient(0deg, #1786FB 0%, #45EABA 100%);
box-shadow: 0 0 2rem rgba(0, 0, 0, 0.1);
background-image: linear-gradient(0deg, #1786fb 0%, #45eaba 100%);
}
.home-main .section-features .row .col:nth-child(2) .feature-image-wrapper {
background-image: linear-gradient(0deg, #605BD4 0%, #E95595 100%);
background-image: linear-gradient(0deg, #605bd4 0%, #e95595 100%);
}
.home-main .section-features .row .col:nth-child(3) .feature-image-wrapper {
background-image: linear-gradient(0deg, #FD5D21 0%, #FBB12E 100%);
background-image: linear-gradient(0deg, #fd5d21 0%, #fbb12e 100%);
}
.home-main .feature-image-wrapper img {
filter: saturate(1.2) contrast(2);
zoom: .80;
zoom: 0.8;
}
@media screen and (max-width: 996px) {
@@ -255,14 +255,14 @@ html[data-theme='dark'] {
font-size: 1rem !important;
color: #000;
text-align: left;
padding: .1rem 2rem;
padding: 0.1rem 2rem;
text-decoration: underline;
font-weight: bold;
display: inline;
opacity: 1;
}
html[data-theme='dark'] .button.button--secondary.button--sm.menu__button {
html[data-theme="dark"] .button.button--secondary.button--sm.menu__button {
color: #fff;
}
@@ -271,4 +271,4 @@ html[data-theme='dark'] {
opacity: 0.8;
display: block;
}
}
}

View File

@@ -3,6 +3,10 @@
border-right: none !important;
}
.menu__list .menu__link {
text-decoration: none;
}
.menu__list .menu__list-item a[href="#!"],
.menu__list .menu__list-item a:not(.menu__link--sublist) {
background: transparent;
@@ -22,13 +26,18 @@
font-weight: 700;
}
.menu__list .menu__list-item a.menu__link.menu__link--active:not(.menu__link--sublist) {
background: transparent;
.menu__list
.menu__list-item
a.menu__link.menu__link--active:not(.menu__link--sublist) {
background: transparent;
color: var(--ifm-color-link);
}
html[data-theme='dark'] .menu__list .menu__list-item a:not(.menu__link--sublist),
html[data-theme='dark'] .menu__list .menu__list-item a[href="#!"] {
html[data-theme="dark"]
.menu__list
.menu__list-item
a:not(.menu__link--sublist),
html[data-theme="dark"] .menu__list .menu__list-item a[href="#!"] {
color: #fff;
}

View File

@@ -6,6 +6,10 @@
border: none !important;
}
.table-of-contents__link {
text-decoration: none;
}
.contents a {
text-decoration: none;
}
@@ -36,10 +40,10 @@
}
.contents li > ul {
margin-left: .25rem;
margin-left: 0.25rem;
}
.contents li > ul li > a {
font-weight: 400;
font-size: 0.95rem;
}
}

View File

@@ -205,11 +205,11 @@ export default function myComponent() {
return <>
{!session && <>
Not signed in <br/>
<button onClick={signIn}>Sign in</button>
<button onClick={() => signIn()}>Sign in</button>
</>}
{session && <>
Signed in as {session.user.email} <br/>
<button onClick={signOut}>Sign out</button>
<button onClick={() => signOut()}>Sign out</button>
</>}
</>
}
@@ -219,7 +219,7 @@ const serverlessFunctionCode = `
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
const options = {
export default NextAuth({
providers: [
// OAuth authentication providers...
Providers.Apple({
@@ -242,9 +242,7 @@ const options = {
],
// Optional SQL or MongoDB database to persist users
database: process.env.DATABASE_URL
}
export default (req, res) => NextAuth(req, res, options)
})
`.trim()
export default Home

View File

@@ -5,95 +5,95 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, { useRef, useCallback } from 'react'
import classnames from 'classnames'
import { useHistory } from '@docusaurus/router'
import useDocusaurusContext from '@docusaurus/useDocusaurusContext'
let loaded = false
const Search = props => {
const initialized = useRef(false)
const searchBarRef = useRef(null)
const history = useHistory()
const { siteConfig = {} } = useDocusaurusContext()
const { baseUrl } = siteConfig
import React, { useRef, useCallback } from "react";
import classnames from "classnames";
import { useHistory } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
let loaded = false;
const Search = (props) => {
const initialized = useRef(false);
const searchBarRef = useRef(null);
const history = useHistory();
const { siteConfig = {} } = useDocusaurusContext();
const { baseUrl } = siteConfig;
const initAlgolia = () => {
if (!initialized.current) {
new window.DocSearch({
searchData: window.searchData,
inputSelector: '#search_input_react',
inputSelector: "#search_input_react",
// Override algolia's default selection event, allowing us to do client-side
// navigation and avoiding a full page refresh.
handleSelected: (_input, _event, suggestion) => {
const url = baseUrl + suggestion.url
const url = baseUrl + suggestion.url;
// Use an anchor tag to parse the absolute url into a relative url
// Alternatively, we can use new URL(suggestion.url) but its not supported in IE
const a = document.createElement('a')
a.href = url
const a = document.createElement("a");
a.href = url;
// Algolia use closest parent element id #__docusaurus when a h1 page title does not have an id
// So, we can safely remove it. See https://github.com/facebook/docusaurus/issues/1828 for more details.
history.push(url)
}
})
initialized.current = true
history.push(url);
},
});
initialized.current = true;
}
}
};
const getSearchData = () =>
process.env.NODE_ENV === 'production'
? fetch(`${baseUrl}search-data.json`).then((content) => content.json())
: Promise.resolve([])
process.env.NODE_ENV === "production"
? fetch(`${baseUrl}search-doc.json`).then((content) => content.json())
: Promise.resolve([]);
const loadAlgolia = () => {
if (!loaded) {
Promise.all([
getSearchData(),
import('./lib/DocSearch'),
import('./algolia.css')
import("./lib/DocSearch"),
import("./algolia.css"),
]).then(([searchData, { default: DocSearch }]) => {
loaded = true
window.searchData = searchData
window.DocSearch = DocSearch
initAlgolia()
})
loaded = true;
window.searchData = searchData;
window.DocSearch = DocSearch;
initAlgolia();
});
} else {
initAlgolia()
initAlgolia();
}
}
};
const toggleSearchIconClick = useCallback(
e => {
(e) => {
if (!searchBarRef.current.contains(e.target)) {
searchBarRef.current.focus()
searchBarRef.current.focus();
}
props.handleSearchBarToggle(!props.isSearchBarExpanded)
props.handleSearchBarToggle(!props.isSearchBarExpanded);
},
[props.isSearchBarExpanded]
)
);
return (
<div className='navbar__search' key='search-box'>
<div className="navbar__search" key="search-box">
<span
aria-label='expand searchbar'
role='button'
className={classnames('search-icon', {
'search-icon-hidden': props.isSearchBarExpanded
aria-label="expand searchbar"
role="button"
className={classnames("search-icon", {
"search-icon-hidden": props.isSearchBarExpanded,
})}
onClick={toggleSearchIconClick}
onKeyDown={toggleSearchIconClick}
tabIndex={0}
/>
<input
id='search_input_react'
type='search'
placeholder='Search'
aria-label='Search'
id="search_input_react"
type="search"
placeholder="Search"
aria-label="Search"
className={classnames(
'navbar__search-input',
{ 'search-bar-expanded': props.isSearchBarExpanded },
{ 'search-bar': !props.isSearchBarExpanded }
"navbar__search-input",
{ "search-bar-expanded": props.isSearchBarExpanded },
{ "search-bar": !props.isSearchBarExpanded }
)}
onClick={loadAlgolia}
onMouseOver={loadAlgolia}
@@ -102,7 +102,7 @@ const Search = props => {
ref={searchBarRef}
/>
</div>
)
}
);
};
export default Search
export default Search;