Compare commits

..

44 Commits

Author SHA1 Message Date
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
190 changed files with 12452 additions and 18548 deletions

View File

@@ -1,5 +1,3 @@
# Rename file to .env and populate values
# to be able to run tests
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_TWITTER_ID=
NEXTAUTH_TWITTER_SECRET=

View File

@@ -1,33 +0,0 @@
# Rename file to .env.local (or .env) and populate values
# to be able to run the dev app
NEXTAUTH_URL=http://localhost:3000
# You can use `openssl rand -hex 32` or
# https://generate-secret.now.sh/32 to generate a secret.
# Note: Changing a secret may invalidate existing sessions
# and/or verificaion tokens.
SECRET=
AUTH0_ID=
AUTH0_DOMAIN=
AUTH0_SECRET=
GITHUB_ID=
GITHUB_SECRET=
TWITTER_ID=
TWITTER_SECRET=
# Example configuration for a Gmail account (will need SMTP enabled)
EMAIL_SERVER=smtps://user@gmail.com:password@smtp.gmail.com:465
EMAIL_FROM=user@gmail.com
# You can use any of these as the "DATABASE_URL" for
# databases started with Docker using `npm run db:start`.
# Note: If using with Prisma adapter, you need to use a `.env`
# file rather than a `.env.local` file to configure env vars.
# Postgres: DATABASE_URL=postgres://nextauth:password@127.0.0.1:5432/nextauth?synchronize=true
# MySQL: DATABASE_URL=mysql://nextauth:password@127.0.0.1:3306/nextauth?synchronize=true
# MongoDB: DATABASE_URL=mongodb://nextauth:password@127.0.0.1:27017/nextauth?synchronize=true
DATABASE_URL=

View File

@@ -9,7 +9,7 @@ assignees: ''
A clear and concise description of the feature being proposed.
**Purpose of proposed feature**
A clear and concise description of why this feature is necessary and what problems it solves.
A clear and concise description description of why this feature is necessary and what problems it solves.
**Detail about proposed feature**
A detailed description of how the proposal might work (if you have one).

View File

@@ -7,8 +7,10 @@ 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).
Note before creating the Pull Request. Even though the CONTRIBUTONG.md tells otherwise, we ask you to use the `canary` branch as base for your PR. We are tranistioning to a new structure, and the CONTRIBUTONG.md file has not been updated yet. Thank you!
If you're new to contributing to open source projects, you might find this free
video course helpful: https://kcd.im/pull-request
video course helpful: http://kcd.im/pull-request
Please fill out the information below to expedite the review and (hopefully)
merge of your pull request!

35
.github/labeler.yml vendored
View File

@@ -1,35 +0,0 @@
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/**/*
core:
- src/**/*
style:
- src/css/**/*
client:
- src/client/**/*
- www/docs/getting-started/client.md
pages:
- src/server/pages/**/*
- www/docs/configuration/pages.md

1
.github/stale.yml vendored
View File

@@ -7,7 +7,6 @@ exemptLabels:
- pinned
- security
- priority
- bug
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable

View File

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

View File

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

View File

@@ -2,10 +2,9 @@ name: Integration Test
on:
push:
branches:
- main
- next
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
@@ -29,7 +28,7 @@ jobs:
strategy:
matrix:
node-version: [10, 12, 14]
node-version: [12.x]
steps:
- uses: actions/checkout@v2
@@ -38,8 +37,8 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
uses: bahmutov/npm-install@v1
# Install dependencies
- run: npm ci
# Run tests (build library, build + start test app in Docker, run tests)
- run: npm test

View File

@@ -1,11 +0,0 @@
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 }}"

View File

@@ -2,25 +2,29 @@ name: Release
on:
push:
branches:
- 'main'
- 'next'
- '3.x'
pull_request:
- main
- canary
jobs:
release:
name: 'Release'
runs-on: ubuntu-latest
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: 14
node-version: 12
- name: Install dependencies
uses: bahmutov/npm-install@v1
- run: npm run build
- run: npx semantic-release@17
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}}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-release

8
.gitignore vendored
View File

@@ -11,8 +11,6 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock
# Dependencies
node_modules
@@ -26,7 +24,6 @@ node_modules
.docusaurus
.cache-loader
.next
www/providers.json
# VS
/.vs/slnx.sqlite-journal
@@ -36,7 +33,4 @@ www/providers.json
# GitHub Actions runner
/actions-runner
/_work
# Prisma migrations
/prisma/migrations
/_work

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"
]
}

View File

@@ -8,70 +8,115 @@ 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.
## For contributors
## Pull Requests
Anyone can be a contributor. Either you found a typo, or you have an awesome feature request you could implement, we encourage you to create a Pull Request.
### Pull Requests
* The latest changes are always in `main`
* Pull Requests should be raised for larger changes
* Pull Requests do not need approval before merging for those with contributor access (it's just helpful to have them to track changes)
* Rebasing in Pull Requests is 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
* The latest changes are always in `main`, so please make your Pull Request against that branch.
* Pull Requests should be raised for any change
* Pull Requests need approval of a [core contributor](https://next-auth.js.org/contributors#core-team) before merging
* Run `npm run lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this extension](https://marketplace.visualstudio.com/items?itemName=chenxsan.vscode-standardjs) to fix lint issues in development)
* 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
### Setting up local environment
*If you don't rebase and end up with merge commits in a PR then it's not a blocker, we can always squash the commits when merging!*
A quick guide on how to setup *next-auth* locally to work on it and test out any changes:
If you create a branch and there are conflicting updates in the `main` branch, you can resolve them by rebasing from a check out of your branch:
git fetch
git rebase origin/main
If there are any conflicts, you can resolve them and stage the files, then run:
git rebase --continue
*If there are a lot of changes you may be prompted to step more than once.*
When the rebase is complete (i.e. there are no more conflicts) you should push your changes to your branch before doing anything else:
git push --force-with-lease
You should see that any conflicts in your PR are now resolved. You can review changes to make sure it contains changes you intended to make.
*If you accidentally sync before pushing, it will trigger a merge. You can use `git merge --abort` to undo the merge.*
You can use `npm run lint:fix` to automatically apply Standard JS rules to resolve formatting differences (tabs vs spaces, line endings, etc).
## Setting up local environment
A quick and dirty guide on how to setup *next-auth* locally to work on it and test out any changes:
1. Clone the repo:
```sh
git clone git@github.com:nextauthjs/next-auth.git
cd next-auth
```
2. Install packages:
```sh
npm i
```
git clone git@github.com:iaincollins/next-auth.git
cd next-auth/
3. Populate `.env.local`:
Copy `.env.local.example` to `.env.local`, and add your env variables for each provider you want to test.
2. Install packages and run the build command:
> NOTE: You can add any environment variables to .env.local that you would like to use in your dev app.
> You can find the next-auth config under`pages/api/auth/[...nextauth].js`.
npm i
npm run build
1. Start the dev application/server and CSS watching:
```sh
npm run dev
```
3. Link your project back to your local copy of next auth:
Your dev application will be available on ```http://localhost:3000```
cd ../your-application
npm link ../next-auth
That's it! 🎉
4. Finally link React between the repo and the version installed in your project:
cd ../next-auth
npm link ../your-application/node_modules/react
*This is an annoying step and not obvious, but is needed because of how React has been written (otherwise React crashes when you try to use the `useSession()` hook in your project).*
That's it!
Notes: You may need to repeat both `npm link` steps if you install / update additional dependencies with `npm i`.
If you need an example project to link to, you can use [next-auth-example](https://github.com/iaincollins/next-auth-example).
#### Hot reloading
### Hot reloading
When running `npm run dev`, you start a Next.js dev server on `http://localhost:3000`, which includes hot reloading out of the box. Make changes on any of the files in `src` and see the changes immediately.
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.
>NOTE: When working on CSS, you will need to manually refresh the page after changes. (Improving this through a PR is very welcome!)
cd next-auth/
npm run watch
#### Databases
If you are working on `next-auth/src/client/index.js` hot reloading will work as normal in your Next.js app.
However, if you are working on anything else (e.g. `next-auth/src/server/*` etc) then you will need to *stop and start* your app for changes to apply as **Next.js will not hot reload those changes by default**. To facilitate this, you can try [this webpack plugin](https://www.npmjs.com/package/webpack-clear-require-cache-plugin). Note that the `next.config.js` syntax in the plugin README may be out of date. It should look like this:
```
const clearRequireCachePlugin = require('webpack-clear-require-cache-plugin')
module.exports = {
webpack: (config, {
buildId, dev, isServer, defaultLoaders, webpack,
}) => {
config.plugins.push(clearRequireCachePlugin([
/\.next\/server\/static\/development\/pages/,
/\.next\/server\/ssr-module-cache.js/,
/next-auth/,
]))
return config
},
}
```
### Databases
Included is a Docker Compose file that starts up MySQL, Postgres, and MongoDB databases on localhost.
It will use port `3306`, `5432`, and `27017` on localhost respectively; please make sure those ports are not used by other services on localhost.
It will use port 3306, 5432, and 27017 on localhost respectively; it will not work if are running existing databases on localhost.
You can start them with `npm run db:start` and stop them with `npm run db:stop`.
You will need Docker and Docker Compose installed to be able to start / stop the databases.
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`.
@@ -80,39 +125,3 @@ 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 `main`
* Rewrite the commit message to conform to the `Conventional Commits` style. Check the "Recommended Scopes" section for further advice.
* Optionally link issues the PR will resolve (You can add "close" in front of the issue numbers to close the issues automatically, when the PR is merged. `semantic-release` will also comment back to connected issues and PRs, notifying the users that a feature is added/bug fixed, etc.)
### Recommended Scopes
A typical conventional commit looks like this:
```
type(scope): title
body
```
Scope is the part that will help 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,3 +0,0 @@
# https://docs.github.com/en/github/administering-a-repository/displaying-a-sponsor-button-in-your-repository
github: [balazsorban44]

View File

@@ -1,6 +1,6 @@
ISC License
Copyright (c) 2018-2021, Iain Collins
Copyright (c) 2018-2020, Iain Collins
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above

View File

@@ -7,25 +7,12 @@
Open Source. Full Stack. Own Your Data.
</p>
<p align="center" style="align: center;">
<a href="https://github.com/nextauthjs/next-auth/actions?query=workflow%3ARelease">
<img src="https://github.com/nextauthjs/next-auth/workflows/Release/badge.svg" alt="Release" />
</a>
<a href="https://github.com/nextauthjs/next-auth/actions?query=workflow%3A%22Integration+Test%22">
<img src="https://github.com/nextauthjs/next-auth/workflows/Integration%20Test/badge.svg" alt="Integration Test" />
</a>
<a href="https://bundlephobia.com/result?p=next-auth">
<img src="https://img.shields.io/bundlephobia/minzip/next-auth" alt="Bundle Size"/>
</a>
<a href="https://www.npmtrends.com/next-auth">
<img src="https://img.shields.io/npm/dm/next-auth" alt="Downloads" />
</a>
<a href="https://github.com/nextauthjs/next-auth/stargazers">
<img src="https://img.shields.io/github/stars/nextauthjs/next-auth" alt="Github Stars" />
</a>
<a href="https://www.npmjs.com/package/next-auth">
<img src="https://img.shields.io/github/v/release/nextauthjs/next-auth?label=latest" alt="Github Stable Release" />
</a>
<img src="https://img.shields.io/github/v/release/nextauthjs/next-auth?include_prereleases&label=prerelease&sort=semver" alt="Github Prelease" />
<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>
@@ -82,7 +69,7 @@ NextAuth.js can be used with or without a database.
Advanced options allow you to define your own routines to handle controlling what accounts are allowed to sign in, for encoding and decoding JSON Web Tokens and to set custom cookie security policies and session properties, so you can control who is able to sign in and how often sessions have to be re-validated.
### TypeScript
### Typescript
You can install the appropriate types via the following command:
@@ -90,7 +77,9 @@ You can install the appropriate types via the following command:
npm install --save-dev @types/next-auth
```
As of now, TypeScript is a community effort. If you encounter any problems with the types package, please create an issue at [DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/next-auth). Alternatively, you can open a pull request directly with your fixes there. We welcome anyone to start a discussion on migrating this package to TypeScript, or how to improve the TypeScript experience in general.
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
@@ -125,22 +114,26 @@ export default NextAuth({
### Add React Component
```javascript
import {
useSession, signIn, signOut
import React from 'react'
import {
useSession,
signin,
signout
} from 'next-auth/client'
export default function Component() {
export default function myComponent() {
const [ session, loading ] = useSession()
if(session) {
return <>
return <p>
{!session && <>
Not signed in <br/>
<button onClick={signin}>Sign in</button>
</>}
{session && <>
Signed in as {session.user.email} <br/>
<button onClick={() => signOut()}>Sign out</button>
</>
}
return <>
Not signed in <br/>
<button onClick={() => signIn()}>Sign in</button>
</>
<button onClick={signout}>Sign out</button>
</>}
</p>
}
```
@@ -151,18 +144,10 @@ export default function Component() {
<a href="https://github.com/nextauthjs/next-auth/graphs/contributors">
<img width="500px" src="https://contrib.rocks/image?repo=nextauthjs/next-auth" />
</a>
<div>
<a href="https://vercel.com?utm_source=nextauthjs&utm_campaign=oss">
<img width="170px" src="https://raw.githubusercontent.com/nextauthjs/next-auth/canary/www/static/img/powered-by-vercel.svg" alt="Powered By Vercel" />
</a>
</div>
<div>
<p align="left">Thanks to Vercel sponsoring this project by allowing it to be deployed for free for the entire NextAuth.js Team</p>
</div>
## Contributing
We're open to all community contributions! If you'd like to contribute in any way, please first read our [Contributing Guide](https://github.com/nextauthjs/next-auth/blob/canary/CONTRIBUTING.md).
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

12
babel.config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"presets": [
["@babel/preset-env", { "targets": { "esmodules": true } } ]
],
"comments": false,
"overrides": [
{
"test": [ "./src/server/pages/**" ],
"presets": [ "preact" ]
}
]
}

View File

@@ -1,19 +0,0 @@
import { signIn } from 'next-auth/client'
export default function AccessDenied () {
return (
<>
<h1>Access Denied</h1>
<p>
<a
href='/api/auth/signin'
onClick={(e) => {
e.preventDefault()
signIn()
}}
>You must be signed in to view this page
</a>
</p>
</>
)
}

View File

@@ -1,18 +0,0 @@
import Link from 'next/link'
import styles from './footer.module.css'
import { version } from 'package.json'
export default function Footer () {
return (
<footer className={styles.footer}>
<hr />
<ul className={styles.navItems}>
<li className={styles.navItem}><a href='https://next-auth.js.org'>Documentation</a></li>
<li className={styles.navItem}><a href='https://www.npmjs.com/package/next-auth'>NPM</a></li>
<li className={styles.navItem}><a href='https://github.com/nextauthjs/next-auth-example'>GitHub</a></li>
<li className={styles.navItem}><Link href='/policy'><a>Policy</a></Link></li>
<li className={styles.navItem}><em>{version}</em></li>
</ul>
</footer>
)
}

View File

@@ -1,14 +0,0 @@
.footer {
margin-top: 2rem;
}
.navItems {
margin-bottom: 1rem;
padding: 0;
list-style: none;
}
.navItem {
display: inline-block;
margin-right: 1rem;
}

View File

@@ -1,112 +0,0 @@
import Link from 'next/link'
import { signIn, signOut, useSession } from 'next-auth/client'
import styles from './header.module.css'
// The approach used in this component shows how to built a sign in and sign out
// component that works on pages which support both client and server side
// rendering, and avoids any flash incorrect content on initial page load.
export default function Header () {
const [session, loading] = useSession()
return (
<header>
<noscript>
<style>{'.nojs-show { opacity: 1; top: 0; }'}</style>
</noscript>
<div className={styles.signedInStatus}>
<p
className={`nojs-show ${
!session && loading ? styles.loading : styles.loaded
}`}
>
{!session && (
<>
<span className={styles.notSignedInText}>
You are not signed in
</span>
<a
href='/api/auth/signin'
className={styles.buttonPrimary}
onClick={(e) => {
e.preventDefault()
signIn()
}}
>
Sign in
</a>
</>
)}
{session && (
<>
{session.user.image && (
<span
style={{ backgroundImage: `url(${session.user.image})` }}
className={styles.avatar}
/>
)}
<span className={styles.signedInText}>
<small>Signed in as</small>
<br />
<strong>{session.user.email || session.user.name}</strong>
</span>
<a
href='/api/auth/signout'
className={styles.button}
onClick={(e) => {
e.preventDefault()
signOut()
}}
>
Sign out
</a>
</>
)}
</p>
</div>
<nav>
<ul className={styles.navItems}>
<li className={styles.navItem}>
<Link href='/'>
<a>Home</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/client'>
<a>Client</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/server'>
<a>Server</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/protected'>
<a>Protected</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/protected-ssr'>
<a>Protected(SSR)</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/api-example'>
<a>API</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/credentials'>
<a>Credentials</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/email'>
<a>Email</a>
</Link>
</li>
</ul>
</nav>
</header>
)
}

View File

@@ -1,92 +0,0 @@
/* Set min-height to avoid page reflow while session loading */
.signedInStatus {
display: block;
min-height: 4rem;
width: 100%;
}
.loading,
.loaded {
position: relative;
top: 0;
opacity: 1;
overflow: hidden;
border-radius: 0 0 .6rem .6rem;
padding: .6rem 1rem;
margin: 0;
background-color: rgba(0,0,0,.05);
transition: all 0.2s ease-in;
}
.loading {
top: -2rem;
opacity: 0;
}
.signedInText,
.notSignedInText {
position: absolute;
padding-top: .8rem;
left: 1rem;
right: 6.5rem;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
display: inherit;
z-index: 1;
line-height: 1.3rem;
}
.signedInText {
padding-top: 0rem;
left: 4.6rem;
}
.avatar {
border-radius: 2rem;
float: left;
height: 2.8rem;
width: 2.8rem;
background-color: white;
background-size: cover;
background-repeat: no-repeat;
}
.button,
.buttonPrimary {
float: right;
margin-right: -.4rem;
font-weight: 500;
border-radius: .3rem;
cursor: pointer;
font-size: 1rem;
line-height: 1.4rem;
padding: .7rem .8rem;
position: relative;
z-index: 10;
background-color: transparent;
color: #555;
}
.buttonPrimary {
background-color: #346df1;
border-color: #346df1;
color: #fff;
text-decoration: none;
padding: .7rem 1.4rem;
}
.buttonPrimary:hover {
box-shadow: inset 0 0 5rem rgba(0,0,0,0.2)
}
.navItems {
margin-bottom: 2rem;
padding: 0;
list-style: none;
}
.navItem {
display: inline-block;
margin-right: 1rem;
}

View File

@@ -1,14 +0,0 @@
import Header from 'components/header'
import Footer from 'components/footer'
export default function Layout ({ children }) {
return (
<>
<Header />
<main>
{children}
</main>
<Footer />
</>
)
}

View File

@@ -1,12 +0,0 @@
{
"presets": [
["@babel/preset-env", { "targets": { "esmodules": true } }]
],
"comments": false,
"overrides": [
{
"test": ["../src/server/pages/**"],
"presets": ["preact"]
}
]
}

2
next-env.d.ts vendored
View File

@@ -1,2 +0,0 @@
/// <reference types="next" />
/// <reference types="next/types/global" />

7471
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,29 +8,29 @@
"main": "index.js",
"scripts": {
"build": "npm run build:js && npm run build:css",
"build:js": "babel --config-file ./config/babel.config.json src --out-dir dist",
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
"dev": "next | npm run watch:css",
"build:js": "babel src --out-dir dist",
"build:css": "postcss src/**/*.css --base src --dir dist && node scripts/wrap-css.js",
"watch": "npm run watch:js | npm run watch:css",
"watch:js": "babel --config-file ./config/babel.config.json --watch src --out-dir dist",
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
"watch:js": "babel --watch src --out-dir dist",
"watch:css": "postcss --watch src/**/*.css --base src --dir dist",
"test:app:start": "docker-compose -f test/docker/app.yml up -d",
"test:app:rebuild": "npm run build && docker-compose -f test/docker/app.yml up -d --build",
"test:app:stop": "docker-compose -f test/docker/app.yml down",
"test": "npm run test:app:rebuild && npm run test:integration && npm run test:app:stop",
"test:db": "npm run test:db:mysql && npm run test:db:postgres && npm run test:db:mongodb && npm run test:db:mssql",
"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",
"prepublishOnly": "npm run build",
"publish:beta": "npm publish --tag beta",
"publish:canary": "npm publish --tag canary",
"lint": "ts-standard",
"lint:fix": "ts-standard --fix"
"lint": "standard",
"lint:fix": "standard --fix"
},
"files": [
"dist",
@@ -43,17 +43,18 @@
"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.16",
"oauth": "^0.9.15",
"pkce-challenge": "^2.1.0",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.14",
"preact-render-to-string": "^5.1.7",
"querystring": "^0.2.0",
"require_optional": "^1.0.1",
"typeorm": "^0.2.30"
"typeorm": "^0.2.24"
},
"peerDependencies": {
"react": "^16.13.1 || ^17",
@@ -64,57 +65,36 @@
"mysql": "^2.18.1",
"mssql": "^6.2.1",
"pg": "^8.2.1",
"@prisma/client": "^2.16.1"
"@prisma/client": "^2.12.0"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.6",
"@babel/preset-env": "^7.9.6",
"@prisma/client": "^2.16.1",
"@semantic-release/commit-analyzer": "^8.0.1",
"@semantic-release/github": "^7.2.0",
"@semantic-release/npm": "7.0.8",
"@semantic-release/release-notes-generator": "^9.0.1",
"@types/react": "^17.0.0",
"autoprefixer": "^9.7.6",
"babel-preset-preact": "^2.0.0",
"conventional-changelog-conventionalcommits": "4.4.0",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"eslint": "^7.19.0",
"mocha": "^8.1.3",
"mongodb": "^3.5.9",
"mssql": "^6.2.1",
"mysql": "^2.18.1",
"next": "^10.0.5",
"pg": "^8.2.1",
"postcss-cli": "^7.1.1",
"postcss-nested": "^4.2.1",
"prisma": "^2.16.1",
"puppeteer": "^5.2.1",
"puppeteer-extra": "^3.1.15",
"puppeteer-extra-plugin-stealth": "^2.6.1",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"ts-standard": "^10.0.0",
"typescript": "^4.1.3"
"standard": "^16.0.3"
},
"ts-standard": {
"project": "./tsconfig.json",
"standard": {
"ignore": [
"test/",
"next-env.d.ts"
],
"globals": [
"localStorage",
"location",
"fetch"
"test/"
]
},
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/balazsorban44"
}
]
}
}

View File

@@ -1,31 +0,0 @@
import { Provider } from 'next-auth/client'
import './styles.css'
// Use the <Provider> to improve performance and allow components that call
// `useSession()` anywhere in your application to access the `session` object.
export default function App ({ Component, pageProps }) {
return (
<Provider
// Provider options are not required but can be useful in situations where
// you have a short session maxAge time. Shown here with default values.
options={{
// Client Max Age controls how often the useSession in the client should
// contact the server to sync the session state. Value in seconds.
// e.g.
// * 0 - Disabled (always use cache value)
// * 60 - Sync session state with server if it's older than 60 seconds
clientMaxAge: 0,
// Keep Alive tells windows / tabs that are signed in to keep sending
// a keep alive request (which extends the current session expiry) to
// prevent sessions in open windows from expiring. Value in seconds.
//
// Note: If a session has expired when keep alive is triggered, all open
// windows / tabs will be updated to reflect the user is signed out.
keepAlive: 0
}}
session={pageProps.session}
>
<Component {...pageProps} />
</Provider>
)
}

View File

@@ -1,17 +0,0 @@
import Layout from '../components/layout'
export default function Page () {
return (
<Layout>
<h1>API Example</h1>
<p>The examples below show responses from the example API endpoints.</p>
<p><em>You must be signed in to see responses.</em></p>
<h2>Session</h2>
<p>/api/examples/session</p>
<iframe src='/api/examples/session' />
<h2>JSON Web Token</h2>
<p>/api/examples/jwt</p>
<iframe src='/api/examples/jwt' />
</Layout>
)
}

View File

@@ -1,61 +0,0 @@
import NextAuth from 'next-auth'
import Providers from 'next-auth/providers'
// import Adapters from 'next-auth/adapters'
// import { PrismaClient } from '@prisma/client'
// const prisma = new PrismaClient()
export default NextAuth({
providers: [
Providers.Email({
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM
}),
Providers.GitHub({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET
}),
Providers.Auth0({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
domain: process.env.AUTH0_DOMAIN,
protection: 'pkce'
}),
Providers.Twitter({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET
}),
Providers.Credentials({
name: 'Credentials',
credentials: {
password: { label: 'Password', type: 'password' }
},
async authorize (credentials) {
if (credentials.password === 'password') {
return {
id: 1,
name: 'Fill Murray',
email: 'bill@fillmurray.com',
image: 'https://www.fillmurray.com/64/64'
}
}
return null
}
})
],
jwt: {
encryption: true,
secret: process.env.SECRET
},
debug: false,
theme: 'auto'
// Default Database Adapter (TypeORM)
// database: process.env.DATABASE_URL
// Prisma Database Adapter
// To configure this app to use the schema in `prisma/schema.prisma` run:
// npx prisma generate
// npx prisma migrate dev --preview-feature
// adapter: Adapters.Prisma.Adapter({ prisma })
})

View File

@@ -1,9 +0,0 @@
// This is an example of how to read a JSON Web Token from an API route
import jwt from 'next-auth/jwt'
const secret = process.env.SECRET
export default async (req, res) => {
const token = await jwt.getToken({ req, secret })
res.send(JSON.stringify(token, null, 2))
}

View File

@@ -1,12 +0,0 @@
// This is an example of to protect an API route
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })
if (session) {
res.send({ content: 'This is protected content. You can access this content because you are signed in.' })
} else {
res.send({ error: 'You must be sign in to view the protected content on this page.' })
}
}

View File

@@ -1,7 +0,0 @@
// This is an example of how to access a session from an API route
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })
res.send(JSON.stringify(session, null, 2))
}

View File

@@ -1,22 +0,0 @@
import Layout from '../components/layout'
export default function Page () {
return (
<Layout>
<h1>Client Side Rendering</h1>
<p>
This page uses the <strong>useSession()</strong> React Hook in the <strong>&lt;/Header&gt;</strong> component.
</p>
<p>
The <strong>useSession()</strong> React Hook easy to use and allows pages to render very quickly.
</p>
<p>
The advantage of this approach is that session state is shared between pages by using the <strong>Provider</strong> in <strong>_app.js</strong> so
that navigation between pages using <strong>useSession()</strong> is very fast.
</p>
<p>
The disadvantage of <strong>useSession()</strong> is that it requires client side JavaScript.
</p>
</Layout>
)
}

View File

@@ -1,52 +0,0 @@
import * as React from 'react'
import { signIn, signOut, useSession } from 'next-auth/client'
import Layout from 'components/layout'
export default function Page () {
const [response, setResponse] = React.useState(null)
const handleLogin = (options) => async () => {
if (options.redirect) {
return signIn('credentials', options)
}
const response = await signIn('credentials', options)
setResponse(response)
}
const handleLogout = (options) => async () => {
if (options.redirect) {
return signOut(options)
}
const response = await signOut(options)
setResponse(response)
}
const [session] = useSession()
if (session) {
return (
<Layout>
<h1>Test different flows for Credentials logout</h1>
<span className='spacing'>Default:</span>
<button onClick={handleLogout({ redirect: true })}>Logout</button><br />
<span className='spacing'>No redirect:</span>
<button onClick={handleLogout({ redirect: false })}>Logout</button><br />
<p>Response:</p>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
return (
<Layout>
<h1>Test different flows for Credentials login</h1>
<span className='spacing'>Default:</span>
<button onClick={handleLogin({ redirect: true, password: 'password' })}>Login</button><br />
<span className='spacing'>No redirect:</span>
<button onClick={handleLogin({ redirect: false, password: 'password' })}>Login</button><br />
<span className='spacing'>No redirect, wrong password:</span>
<button onClick={handleLogin({ redirect: false, password: '' })}>Login</button>
<p>Response:</p>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

View File

@@ -1,66 +0,0 @@
import * as React from 'react'
import { signIn, signOut, useSession } from 'next-auth/client'
import Layout from 'components/layout'
export default function Page () {
const [response, setResponse] = React.useState(null)
const [email, setEmail] = React.useState('')
const handleChange = (event) => {
setEmail(event.target.value)
}
const handleLogin = (options) => async (event) => {
event.preventDefault()
if (options.redirect) {
return signIn('email', options)
}
const response = await signIn('email', options)
setResponse(response)
}
const handleLogout = (options) => async (event) => {
if (options.redirect) {
return signOut(options)
}
const response = await signOut(options)
setResponse(response)
}
const [session] = useSession()
if (session) {
return (
<Layout>
<h1>Test different flows for Email logout</h1>
<span className='spacing'>Default:</span>
<button onClick={handleLogout({ redirect: true })}>Logout</button><br />
<span className='spacing'>No redirect:</span>
<button onClick={handleLogout({ redirect: false })}>Logout</button><br />
<p>Response:</p>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
return (
<Layout>
<h1>Test different flows for Email login</h1>
<label className='spacing'>
Email address:{' '}
<input type='text' id='email' name='email' value={email} onChange={handleChange} />
</label><br />
<form onSubmit={handleLogin({ redirect: true, email })}>
<span className='spacing'>Default:</span>
<button type='submit'>Sign in with Email</button>
</form>
<form onSubmit={handleLogin({ redirect: false, email })}>
<span className='spacing'>No redirect:</span>
<button type='submit'>Sign in with Email</button>
</form>
<p>Response:</p>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

View File

@@ -1,12 +0,0 @@
import Layout from 'components/layout'
export default function Page () {
return (
<Layout>
<h1>NextAuth.js Example</h1>
<p>
This is an example site to demonstrate how to use <a href='https://next-auth.js.org'>NextAuth.js</a> for authentication.
</p>
</Layout>
)
}

View File

@@ -1,30 +0,0 @@
import Layout from '../components/layout'
export default function Page () {
return (
<Layout>
<p>
This is an example site to demonstrate how to use <a href='https://next-auth.js.org'>NextAuth.js</a> for authentication.
</p>
<h2>Terms of Service</h2>
<p>
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
</p>
<h2>Privacy Policy</h2>
<p>
This site uses JSON Web Tokens and an in-memory database which resets every ~2 hours.
</p>
<p>
Data provided to this site is exclusively used to support signing in
and is not passed to any third party services, other than via SMTP or OAuth for the
purposes of authentication.
</p>
</Layout>
)
}

View File

@@ -1,37 +0,0 @@
// This is an example of how to protect content using server rendering
import { getSession } from 'next-auth/client'
import Layout from '../components/layout'
import AccessDenied from '../components/access-denied'
export default function Page ({ content, session }) {
// If no session exists, display access denied message
if (!session) { return <Layout><AccessDenied /></Layout> }
// If session exists, display content
return (
<Layout>
<h1>Protected Page</h1>
<p><strong>{content}</strong></p>
</Layout>
)
}
export async function getServerSideProps (context) {
const session = await getSession(context)
let content = null
if (session) {
const hostname = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const options = { headers: { cookie: context.req.headers.cookie } }
const res = await fetch(`${hostname}/api/examples/protected`, options)
const json = await res.json()
if (json.content) { content = json.content }
}
return {
props: {
session,
content
}
}
}

View File

@@ -1,33 +0,0 @@
import { useState, useEffect } from 'react'
import { useSession } from 'next-auth/client'
import Layout from '../components/layout'
import AccessDenied from '../components/access-denied'
export default function Page () {
const [session, loading] = useSession()
const [content, setContent] = useState()
// Fetch content from protected route
useEffect(() => {
const fetchData = async () => {
const res = await fetch('/api/examples/protected')
const json = await res.json()
if (json.content) { setContent(json.content) }
}
fetchData()
}, [session])
// When rendering client side don't display anything until loading is complete
if (typeof window !== 'undefined' && loading) return null
// If no session exists, display access denied message
if (!session) { return <Layout><AccessDenied /></Layout> }
// If session exists, display content
return (
<Layout>
<h1>Protected Page</h1>
<p><strong>{content}</strong></p>
</Layout>
)
}

View File

@@ -1,37 +0,0 @@
import { getSession } from 'next-auth/client'
import Layout from '../components/layout'
export default function Page () {
// As this page uses Server Side Rendering, the `session` will be already
// populated on render without needing to go through a loading stage.
// This is possible because of the shared context configured in `_app.js` that
// is used by `useSession()`.
return (
<Layout>
<h1>Server Side Rendering</h1>
<p>
This page uses the universal <strong>getSession()</strong> method in <strong>getServerSideProps()</strong>.
</p>
<p>
Using <strong>getSession()</strong> in <strong>getServerSideProps()</strong> is the recommended approach if you need to
support Server Side Rendering with authentication.
</p>
<p>
The advantage of Server Side Rendering is this page does not require client side JavaScript.
</p>
<p>
The disadvantage of Server Side Rendering is that this page is slower to render.
</p>
</Layout>
)
}
// Export the `session` prop to use sessions with Server Side Rendering
export async function getServerSideProps (context) {
return {
props: {
session: await getSession(context)
}
}
}

View File

@@ -1,30 +0,0 @@
body {
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
padding: 0 1rem 1rem 1rem;
max-width: 680px;
margin: 0 auto;
background: #fff;
color: #333;
}
li,
p {
line-height: 1.5rem;
}
a {
font-weight: 500;
}
hr {
border: 1px solid #ddd;
}
iframe {
background: #ccc;
border: 1px solid #ccc;
height: 10rem;
width: 100%;
border-radius: .5rem;
filter: invert(1);
}

View File

@@ -1,63 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id Int @default(autoincrement()) @id
compoundId String @unique @map(name: "compound_id")
userId Int @map(name: "user_id")
providerType String @map(name: "provider_type")
providerId String @map(name: "provider_id")
providerAccountId String @map(name: "provider_account_id")
refreshToken String? @map(name: "refresh_token")
accessToken String? @map(name: "access_token")
accessTokenExpires DateTime? @map(name: "access_token_expires")
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@index([providerAccountId], name: "providerAccountId")
@@index([providerId], name: "providerId")
@@index([userId], name: "userId")
@@map(name: "accounts")
}
model Session {
id Int @default(autoincrement()) @id
userId Int @map(name: "user_id")
expires DateTime
sessionToken String @unique @map(name: "session_token")
accessToken String @unique @map(name: "access_token")
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@map(name: "sessions")
}
model User {
id Int @default(autoincrement()) @id
name String?
email String? @unique
emailVerified DateTime? @map(name: "email_verified")
image String?
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@map(name: "users")
}
model VerificationRequest {
id Int @default(autoincrement()) @id
identifier String
token String @unique
expires DateTime
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@map(name: "verification_requests")
}

View File

@@ -1,7 +0,0 @@
module.exports = {
branches: [
'+([0-9])?(.{+([0-9]),x}).x',
'main',
{ name: 'next', prerelease: true }
]
}

View File

@@ -1,6 +1,6 @@
// Serverless target in Next.js does not work if you try to read in files at runtime
// that are not JavaScript or JSON (e.g. CSS files).
// https://github.com/nextauthjs/next-auth/issues/281
// https://github.com/iaincollins/next-auth/issues/281
//
// To work around this issue, this script is a manual step that wraps CSS in a
// JavaScript file that has the compiled CSS embedded in it, and exports only

View File

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

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

@@ -0,0 +1,505 @@
import { query as q } from 'faunadb'
import { createHash, randomBytes } from 'crypto'
import logger from '../../lib/logger'
const Adapter = (config, options = {}) => {
const { faunaClient } = 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('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('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('user_by_email'), 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('account_by_provider_account_id'),
[providerId, providerAccountId]
)
},
q.If(
q.Exists(q.Var('ref')),
q.Get(
q.Ref(
q.Collection('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('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('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('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('account_by_provider_account_id'),
[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('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 {
var session = await faunaClient.query(
q.Get(
q.Match(
q.Index('session_by_token'),
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('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('session_by_token'),
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('verification_request'), {
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('vertification_request_by_token'), 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('vertification_request_by_token'), 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

@@ -1,6 +1,7 @@
import { createHash, randomBytes } from 'crypto'
import { CreateUserError } from '../../lib/errors'
import logger from '../../lib/logger'
const Adapter = (config) => {
const {
@@ -20,7 +21,6 @@ const Adapter = (config) => {
}
async function getAdapter (appOptions) {
const { logger } = appOptions
function debug (debugCode, ...args) {
logger.debug(`PRISMA_${debugCode}`, ...args)
}
@@ -219,7 +219,7 @@ const Adapter = (config) => {
}
const { id, expires } = session
return prisma[Session].update({ where: { id }, data: { expires: expires.toISOString() } })
return prisma[Session].update({ where: { id }, data: { expires } })
} catch (error) {
logger.error('UPDATE_SESSION_ERROR', error)
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error))
@@ -280,15 +280,11 @@ 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].findFirst({
where: {
identifier,
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
await prisma[VerificationRequest].deleteMany({ where: { identifier, token: hashedToken } })
await prisma[VerificationRequest].delete({ where: { token: hashedToken } })
return null
}
@@ -304,7 +300,7 @@ const Adapter = (config) => {
try {
// Delete verification entry so it cannot be used again
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
await prisma[VerificationRequest].deleteMany({ where: { identifier, token: hashedToken } })
await prisma[VerificationRequest].delete({ where: { token: hashedToken } })
} catch (error) {
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))

View File

@@ -6,7 +6,7 @@ import { CreateUserError } from '../../lib/errors'
import adapterConfig from './lib/config'
import adapterTransform from './lib/transform'
import Models from './models'
import logger from '../../lib/logger'
import { updateConnectionEntities } from './lib/utils'
const Adapter = (typeOrmConfig, options = {}) => {
@@ -41,12 +41,6 @@ const Adapter = (typeOrmConfig, options = {}) => {
let connection = null
async function getAdapter (appOptions) {
const { logger } = appOptions
// Display debug output if debug option enabled
function debug (debugCode, ...args) {
logger.debug(`TYPEORM_${debugCode}`, ...args)
}
// Helper function to reuse / restablish connections
// (useful if they drop when after being idle)
async function _connect () {
@@ -83,6 +77,12 @@ const Adapter = (typeOrmConfig, options = {}) => {
// https://github.com/typeorm/typeorm/blob/master/docs/entity-manager-api.md
const { manager } = connection
// Display debug output if debug option enabled
// @TODO Refactor logger so is passed in appOptions
function debug (debugCode, ...args) {
logger.debug(`TYPEORM_${debugCode}`, ...args)
}
// The models are primarily designed for ANSI SQL database, but some
// flexiblity is required in the adapter to support non-SQL databases such
// as MongoDB which have different pragmas.
@@ -144,7 +144,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
}
try {
return manager.findOne(User, { [idKey]: id })
return manager.findUnique(User, { [idKey]: id })
} catch (error) {
logger.error('GET_USER_BY_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error))
@@ -155,7 +155,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
debug('GET_USER_BY_EMAIL', email)
try {
if (!email) { return Promise.resolve(null) }
return manager.findOne(User, { email })
return manager.findUnique(User, { email })
} catch (error) {
logger.error('GET_USER_BY_EMAIL_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error))
@@ -165,9 +165,9 @@ const Adapter = (typeOrmConfig, options = {}) => {
async function getUserByProviderAccountId (providerId, providerAccountId) {
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId)
try {
const account = await manager.findOne(Account, { providerId, providerAccountId })
const account = await manager.findUnique(Account, { providerId, providerAccountId })
if (!account) { return null }
return manager.findOne(User, { [idKey]: account.userId })
return manager.findUnique(User, { [idKey]: account.userId })
} catch (error) {
logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error))
@@ -227,7 +227,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
async function getSession (sessionToken) {
debug('GET_SESSION', sessionToken)
try {
const session = await manager.findOne(Session, { sessionToken })
const session = await manager.findUnique(Session, { sessionToken })
// Check session has not expired (do not return it if it has)
if (session && session.expires && new Date() > new Date(session.expires)) {
@@ -327,11 +327,11 @@ const Adapter = (typeOrmConfig, options = {}) => {
// Hash token provided with secret before trying to match it with database
// @TODO Use bcrypt instead of salted SHA-256 hash for token
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
const verificationRequest = await manager.findOne(VerificationRequest, { identifier, token: hashedToken })
const verificationRequest = await manager.findUnique(VerificationRequest, { identifier, token: hashedToken })
if (verificationRequest && verificationRequest.expires && new Date() > new Date(verificationRequest.expires)) {
// Delete verification entry so it cannot be used again
await manager.delete(VerificationRequest, { identifier, token: hashedToken })
await manager.delete(VerificationRequest, { token: hashedToken })
return null
}
@@ -347,7 +347,7 @@ const Adapter = (typeOrmConfig, options = {}) => {
try {
// Delete verification entry so it cannot be used again
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex')
await manager.delete(VerificationRequest, { identifier, token: hashedToken })
await manager.delete(VerificationRequest, { token: hashedToken })
} catch (error) {
logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error)
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error))

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

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

View File

@@ -1,3 +1,5 @@
/// Note: fetch() is built in to Next.js 9.4
//
// Note about signIn() and signOut() methods:
//
// On signIn() and signOut() we pass 'json: true' to request a response in JSON
@@ -8,8 +10,9 @@
//
// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks.
/* global fetch:false */
import { useState, useEffect, useContext, createContext, createElement } from 'react'
import _logger, { proxyLogger } from '../lib/logger'
import logger from '../lib/logger'
import parseUrl from '../lib/parse-url'
// This behaviour mirrors the default behaviour for getting the site name that
@@ -18,81 +21,158 @@ import parseUrl from '../lib/parse-url'
// relative URLs are valid in that context and so defaults to empty.
// 2. When invoked server side the value is picked up from an environment
// variable and defaults to 'http://localhost:3000'.
/** @type {import(".").NextAuthConfig} */
const __NEXTAUTH = {
baseUrl: parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL).baseUrl,
basePath: parseUrl(process.env.NEXTAUTH_URL).basePath,
baseUrlServer: parseUrl(process.env.NEXTAUTH_URL_INTERNAL || process.env.NEXTAUTH_URL || process.env.VERCEL_URL).baseUrl,
basePathServer: parseUrl(process.env.NEXTAUTH_URL_INTERNAL || process.env.NEXTAUTH_URL).basePath,
keepAlive: 0,
clientMaxAge: 0,
keepAlive: 0, // 0 == disabled (don't send); 60 == send every 60 seconds
clientMaxAge: 0, // 0 == disabled (only use cache); 60 == sync if last checked > 60 seconds ago
// Properties starting with _ are used for tracking internal app state
_clientLastSync: 0,
_clientSyncTimer: null,
_eventListenersAdded: false,
_clientSession: undefined,
_clientLastSync: 0, // used for timestamp since last sycned (in seconds)
_clientSyncTimer: null, // stores timer for poll interval
_eventListenersAdded: false, // tracks if event listeners have been added,
_clientSession: undefined, // stores last session response from hook,
// Generate a unique ID to make it possible to identify when a message
// was sent from this tab/window so it can be ignored to avoid event loops.
_clientId: Math.random().toString(36).substring(2) + Date.now().toString(36),
// Used to store to function export by getSession() hook
_getSession: () => {}
}
const logger = proxyLogger(_logger, __NEXTAUTH.basePath)
const broadcast = BroadcastChannel()
// Add event listners on load
if (typeof window !== 'undefined' && !__NEXTAUTH._eventListenersAdded) {
__NEXTAUTH._eventListenersAdded = true
// Listen for storage events and update session if event fired from
// another window (but suppress firing another event to avoid a loop)
// Fetch new session data but tell it to not to fire another event to
// avoid an infinite loop.
// Note: We could pass session data through and do something like
// `setData(message.data)` but that can cause problems depending
// on how the session object is being used in the client; it is
// more robust to have each window/tab fetch it's own copy of the
// session object rather than share it across instances.
broadcast.receive(() => __NEXTAUTH._getSession({ event: 'storage' }))
if (typeof window !== 'undefined') {
if (__NEXTAUTH._eventListenersAdded === false) {
__NEXTAUTH._eventListenersAdded = true
// Listen for document visibility change events and
// if visibility of the document changes, re-fetch the session.
document.addEventListener('visibilitychange', () => {
!document.hidden && __NEXTAUTH._getSession({ event: 'visibilitychange' })
}, false)
// Listen for storage events and update session if event fired from
// another window (but suppress firing another event to avoid a loop)
window.addEventListener('storage', async (event) => {
if (event.key === 'nextauth.message') {
const message = JSON.parse(event.newValue)
if (message.event && message.event === 'session' && message.data) {
// Ignore storage events fired from the same window that created them
if (__NEXTAUTH._clientId === message.clientId) {
return
}
// Fetch new session data but pass 'true' to it not to fire an event to
// avoid an infinite loop.
//
// Note: We could pass session data through and do something like
// `setData(message.data)` but that can cause problems depending
// on how the session object is being used in the client; it is
// more robust to have each window/tab fetch it's own copy of the
// session object rather than share it across instances.
await __NEXTAUTH._getSession({ event: 'storage' })
}
}
})
// Listen for window focus/blur events
window.addEventListener('focus', async (event) => __NEXTAUTH._getSession({ event: 'focus' }))
window.addEventListener('blur', async (event) => __NEXTAUTH._getSession({ event: 'blur' }))
}
}
// Method to set options. The documented way is to use the provider, but this
// method is being left in as an alternative, that will be helpful if/when we
// expose a vanilla JavaScript version that doesn't depend on React.
const setOptions = ({
baseUrl,
basePath,
clientMaxAge,
keepAlive
} = {}) => {
if (baseUrl) { __NEXTAUTH.baseUrl = baseUrl }
if (basePath) { __NEXTAUTH.basePath = basePath }
if (clientMaxAge) { __NEXTAUTH.clientMaxAge = clientMaxAge }
if (keepAlive) {
__NEXTAUTH.keepAlive = keepAlive
if (typeof window !== 'undefined' && keepAlive > 0) {
// Clear existing timer (if there is one)
if (__NEXTAUTH._clientSyncTimer !== null) { clearTimeout(__NEXTAUTH._clientSyncTimer) }
// Set next timer to trigger in number of seconds
__NEXTAUTH._clientSyncTimer = setTimeout(async () => {
// Only invoke keepalive when a session exists
if (__NEXTAUTH._clientSession) {
await __NEXTAUTH._getSession({ event: 'timer' })
}
}, keepAlive * 1000)
}
}
}
// Universal method (client + server)
const getSession = async ({ req, ctx, triggerEvent = true } = {}) => {
// If passed 'appContext' via getInitialProps() in _app.js then get the req
// object from ctx and use that for the req value to allow getSession() to
// work seemlessly in getInitialProps() on server side pages *and* in _app.js.
if (!req && ctx && ctx.req) { req = ctx.req }
const baseUrl = _apiBaseUrl()
const fetchOptions = req ? { headers: { cookie: req.headers.cookie } } : {}
const session = await _fetchData(`${baseUrl}/session`, fetchOptions)
if (triggerEvent) {
_sendMessage({ event: 'session', data: { trigger: 'getSession' } })
}
return session
}
// Universal method (client + server)
const getCsrfToken = async ({ req, ctx } = {}) => {
// If passed 'appContext' via getInitialProps() in _app.js then get the req
// object from ctx and use that for the req value to allow getCsrfToken() to
// work seemlessly in getInitialProps() on server side pages *and* in _app.js.
if (!req && ctx && ctx.req) { req = ctx.req }
const baseUrl = _apiBaseUrl()
const fetchOptions = req ? { headers: { cookie: req.headers.cookie } } : {}
const data = await _fetchData(`${baseUrl}/csrf`, fetchOptions)
return data && data.csrfToken ? data.csrfToken : null
}
// Universal method (client + server); does not require request headers
const getProviders = async () => {
const baseUrl = _apiBaseUrl()
return _fetchData(`${baseUrl}/providers`)
}
// Context to store session data globally
const SessionContext = createContext()
/**
* React Hook that gives you access
* to the logged in user's session data.
*
* [Documentation](https://next-auth.js.org/getting-started/client#usesession)
* @type {import(".").UseSession}
*/
export function useSession (session) {
const context = useContext(SessionContext)
if (context) return context
return _useSessionHook(session)
// Client side method
const useSession = (session) => {
// Try to use context if we can
const value = useContext(SessionContext)
// If we have no Provider in the tree, call the actual hook
if (value === undefined) {
return _useSessionHook(session)
}
return value
}
function _useSessionHook (session) {
// Internal hook for getting session from the api.
const _useSessionHook = (session) => {
const [data, setData] = useState(session)
const [loading, setLoading] = useState(!data)
const [loading, setLoading] = useState(true)
useEffect(() => {
__NEXTAUTH._getSession = async ({ event = null } = {}) => {
const _getSession = async ({ event = null } = {}) => {
try {
const triggredByEvent = event !== null
const triggeredByStorageEvent = event === 'storage'
const triggredByEvent = (event !== null)
const triggeredByStorageEvent = !!((event && event === 'storage'))
const clientMaxAge = __NEXTAUTH.clientMaxAge
const clientLastSync = parseInt(__NEXTAUTH._clientLastSync)
const currentTime = _now()
const currentTime = Math.floor(new Date().getTime() / 1000)
const clientSession = __NEXTAUTH._clientSession
// Updates triggered by a storage event *always* trigger an update and we
// always update if we don't have any value for the current session state.
if (!triggeredByStorageEvent && clientSession !== undefined) {
if (triggeredByStorageEvent === false && clientSession !== undefined) {
if (clientMaxAge === 0 && triggredByEvent !== true) {
// If there is no time defined for when a session should be considered
// stale, then it's okay to use the value we have until an event is
@@ -116,14 +196,13 @@ function _useSessionHook (session) {
// Update clientLastSync before making response to avoid repeated
// invokations that would otherwise be triggered while we are still
// waiting for a response.
__NEXTAUTH._clientLastSync = _now()
__NEXTAUTH._clientLastSync = Math.floor(new Date().getTime() / 1000)
// If this call was invoked via a storage event (i.e. another window) then
// tell getSession not to trigger an event when it calls to avoid an
// infinate loop.
const newClientSessionData = await getSession({
triggerEvent: !triggeredByStorageEvent
})
const triggerEvent = (triggeredByStorageEvent === false)
const newClientSessionData = await getSession({ triggerEvent })
// Save session state internally, just so we can track that we've checked
// if a session exists at least once.
@@ -133,280 +212,117 @@ function _useSessionHook (session) {
setLoading(false)
} catch (error) {
logger.error('CLIENT_USE_SESSION_ERROR', error)
setLoading(false)
}
}
__NEXTAUTH._getSession()
})
__NEXTAUTH._getSession = _getSession
_getSession()
})
return [data, loading]
}
/**
* Can be called client or server side to return a session asynchronously.
* It calls `/api/auth/session` and returns a promise with a session object,
* or null if no session exists.
*
* [Documentation](https://next-auth.js.org/getting-started/client#getsession)
* @type {import(".").GetSession}
*/
export async function getSession (ctx) {
const session = await _fetchData('session', ctx)
if (ctx?.triggerEvent ?? true) {
broadcast.post({ event: 'session', data: { trigger: 'getSession' } })
}
return session
}
/**
* Returns the current Cross Site Request Forgery Token (CSRF Token)
* required to make POST requests (e.g. for signing in and signing out).
* You likely only need to use this if you are not using the built-in
* `signIn()` and `signOut()` methods.
*
* [Documentation](https://next-auth.js.org/getting-started/client#getcsrftoken)
* @type {import(".").GetCsrfToken}
*/
async function getCsrfToken (ctx) {
return (await _fetchData('csrf', ctx))?.csrfToken
}
/**
* It calls `/api/auth/providers` and returns
* a list of the currently configured authentication providers.
* It can be useful if you are creating a dynamic custom sign in page.
*
* [Documentation](https://next-auth.js.org/getting-started/client#getproviders)
* @type {import(".").GetProviders}
*/
export async function getProviders () {
return _fetchData('providers')
}
/**
* Client-side method to initiate a signin flow
* or send the user to the signin page listing all possible providers.
* Automatically adds the CSRF token to the request.
*
* [Documentation](https://next-auth.js.org/getting-started/client#signin)
* @type {import(".").SignIn}
*/
export async function signIn (provider, options = {}, authorizationParams = {}) {
const {
callbackUrl = window.location,
redirect = true
} = options
// Client side method
const signIn = async (provider, args = {}, authParams = {}) => {
const baseUrl = _apiBaseUrl()
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
const providers = await getProviders()
// Redirect to sign in page if no valid provider specified
if (!(provider in providers)) {
if (!provider || !providers[provider]) {
// If Provider not recognized, redirect to sign in page
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
return
}
const isCredentials = providers[provider].type === 'credentials'
const isEmail = providers[provider].type === 'email'
const canRedirectBeDisabled = isCredentials || isEmail
} else {
let signInUrl = (providers[provider].type === 'credentials')
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`
const signInUrl = isCredentials
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`
if (authParams) {
signInUrl += `?${new URLSearchParams(authParams).toString()}`
}
// If is any other provider type, POST to provider URL with CSRF Token,
// callback URL and any other parameters supplied.
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true
})
}
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
const res = await fetch(_signInUrl, fetchOptions)
const data = await res.json()
if (redirect || !canRedirectBeDisabled) {
const url = data.url ?? callbackUrl
window.location = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) window.location.reload()
return
}
const error = new URL(data.url).searchParams.get('error')
if (res.ok) {
await __NEXTAUTH._getSession({ event: 'storage' })
}
return {
error,
status: res.status,
ok: res.ok,
url: error ? null : data.url
// If is any other provider type, POST to provider URL with CSRF Token,
// callback URL and any other parameters supplied.
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: _encodedForm({
...args,
csrfToken: await getCsrfToken(),
callbackUrl: callbackUrl,
json: true
})
}
const res = await fetch(signInUrl, fetchOptions)
const data = await res.json()
window.location = data.url ? data.url : callbackUrl
}
}
/**
* Signs the user out, by removing the session cookie.
* Automatically adds the CSRF token to the request.
*
* [Documentation](https://next-auth.js.org/getting-started/client#signout)
* @type {import(".").SignOut}
*/
export async function signOut (options = {}) {
const {
callbackUrl = window.location,
redirect = true
} = options
// Client side method
const signOut = async (args = {}) => {
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
const baseUrl = _apiBaseUrl()
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
body: _encodedForm({
csrfToken: await getCsrfToken(),
callbackUrl,
callbackUrl: callbackUrl,
json: true
})
}
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
const data = await res.json()
broadcast.post({ event: 'session', data: { trigger: 'signout' } })
if (redirect) {
const url = data.url ?? callbackUrl
window.location = url
// If url contains a hash, the browser does not reload the page. We reload manually
if (url.includes('#')) window.location.reload()
return
}
await __NEXTAUTH._getSession({ event: 'storage' })
return data
_sendMessage({ event: 'session', data: { trigger: 'signout' } })
window.location = data.url ? data.url : callbackUrl
}
// Method to set options. The documented way is to use the provider, but this
// method is being left in as an alternative, that will be helpful if/when we
// expose a vanilla JavaScript version that doesn't depend on React.
/** @type {import(".").SetOptions} */
export function setOptions ({ baseUrl, basePath, clientMaxAge, keepAlive } = {}) {
if (baseUrl) __NEXTAUTH.baseUrl = baseUrl
if (basePath) __NEXTAUTH.basePath = basePath
if (clientMaxAge) __NEXTAUTH.clientMaxAge = clientMaxAge
if (keepAlive) {
__NEXTAUTH.keepAlive = keepAlive
if (typeof window === 'undefined') return
// Clear existing timer (if there is one)
if (__NEXTAUTH._clientSyncTimer !== null) {
clearTimeout(__NEXTAUTH._clientSyncTimer)
}
// Set next timer to trigger in number of seconds
__NEXTAUTH._clientSyncTimer = setTimeout(async () => {
// Only invoke keepalive when a session exists
if (!__NEXTAUTH._clientSession) return
await __NEXTAUTH._getSession({ event: 'timer' })
}, keepAlive * 1000)
}
}
/**
* Provider to wrap the app in to make session data available globally.
* Can also be used to throttle the number of requests to the endpoint
* `/api/auth/session`.
*
* [Documentation](https://next-auth.js.org/getting-started/client#provider)
* @type {import(".").Provider}
*/
export function Provider ({ children, session, options }) {
// Provider to wrap the app in to make session data available globally
const Provider = ({ children, session, options }) => {
setOptions(options)
return createElement(
SessionContext.Provider,
{ value: useSession(session) },
children
)
return createElement(SessionContext.Provider, { value: useSession(session) }, children)
}
/**
* If passed 'appContext' via getInitialProps() in _app.js
* then get the req object from ctx and use that for the
* req value to allow _fetchData to
* work seemlessly in getInitialProps() on server side
* pages *and* in _app.js.
*/
async function _fetchData (path, { ctx, req = ctx?.req } = {}) {
const _fetchData = async (url, options = {}) => {
try {
const baseUrl = await _apiBaseUrl()
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
const res = await fetch(`${baseUrl}/${path}`, options)
const res = await fetch(url, options)
const data = await res.json()
return Object.keys(data).length > 0 ? data : null // Return null if data empty
return Promise.resolve(Object.keys(data).length > 0 ? data : null) // Return null if data empty
} catch (error) {
logger.error('CLIENT_FETCH_ERROR', path, error)
return null
logger.error('CLIENT_FETCH_ERROR', url, error)
return Promise.resolve(null)
}
}
function _apiBaseUrl () {
const _apiBaseUrl = () => {
if (typeof window === 'undefined') {
// NEXTAUTH_URL should always be set explicitly to support server side calls - log warning if not set
if (!process.env.NEXTAUTH_URL) {
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
}
if (!process.env.NEXTAUTH_URL) { logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set') }
// Return absolute path when called server side
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
return `${__NEXTAUTH.baseUrl}${__NEXTAUTH.basePath}`
} else {
// Return relative path when called client side
return __NEXTAUTH.basePath
}
// Return relative path when called client side
return __NEXTAUTH.basePath
}
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
function _now () {
return Math.floor(Date.now() / 1000)
const _encodedForm = (formData) => {
return Object.keys(formData).map((key) => {
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])
}).join('&')
}
/**
* Inspired by [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API)
* Only not using it directly, because Safari does not support it.
*
* https://caniuse.com/?search=broadcastchannel
*/
function BroadcastChannel (name = 'nextauth.message') {
return {
/**
* Get notified by other tabs/windows.
* @param {(message: import(".").BroadcastMessage) => void} onReceive
*/
receive (onReceive) {
if (typeof window === 'undefined') return
window.addEventListener('storage', async (event) => {
if (event.key !== name) return
/** @type {import(".").BroadcastMessage} */
const message = JSON.parse(event.newValue)
if (message?.event !== 'session' || !message?.data) return
onReceive(message)
})
},
/** Notify other tabs/windows. */
post (message) {
if (typeof localStorage === 'undefined') return
localStorage.setItem(name,
JSON.stringify({ ...message, timestamp: _now() })
)
}
const _sendMessage = (message) => {
if (typeof localStorage !== 'undefined') {
const timestamp = Math.floor(new Date().getTime() / 1000)
localStorage.setItem('nextauth.message', JSON.stringify({ ...message, clientId: __NEXTAUTH._clientId, timestamp })) // eslint-disable-line
}
}

View File

@@ -1,44 +1,16 @@
:root {
--border-width: 1px;
--border-radius: .3rem;
--color-error: #c94b4b;
--color-info: #157efb;
--color-info-text: #fff;
}
.__next-auth-theme-auto,
.__next-auth-theme-light {
--color-background: #fff;
--color-text: #000;
--color-primary: #444;
--color-control-border: #bbb;
--color-button-active-background: #f9f9f9;
--color-button-active-border: #aaa;
--border-width: 1px;
--border-radius: .3rem;
--color-error: #c94b4b;
--color-info: #157efb;
--color-seperator: #ccc;
}
.__next-auth-theme-dark {
--color-background: #000;
--color-text: #fff;
--color-primary: #ccc;
--color-control-border: #555;
--color-button-active-background: #060606;
--color-button-active-border: #666;
--color-seperator: #444;
}
@media (prefers-color-scheme: dark) {
.__next-auth-theme-auto {
--color-background: #000;
--color-text: #fff;
--color-primary: #ccc;
--color-control-border: #555;
--color-button-active-background: #060606;
--color-button-active-border: #666;
--color-seperator: #444;
}
}
body {
background-color: var(--color-background);
margin: 0;
@@ -50,11 +22,6 @@ h1 {
font-weight: 400;
margin-bottom: 1.5rem;
padding: 0 1rem;
color: var(--color-text);
}
p {
color: var(--color-text)
}
form {
@@ -79,8 +46,7 @@ input[type] {
background: var(--color-background);
font-size: 1rem;
border-radius: var(--border-radius);
box-shadow: inset 0 .1rem .2rem rgba(0, 0, 0, .2);
color: var(--color-text);
box-shadow: inset 0 .1rem .2rem rgba(0,0,0,.2);
&:focus {
box-shadow: none;
@@ -97,7 +63,6 @@ p {
a.button {
text-decoration: none;
line-height: 1rem;
&:link,
&:visited {
background-color: var(--color-background);
@@ -114,17 +79,17 @@ a.button {
background-color: var(--color-background);
font-size: 1rem;
border-radius: var(--border-radius);
transition: all .1s ease-in-out;
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .05);
transition: all .1s ease-in-out;
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0,0,0,.05);
font-weight: 500;
position: relative;
&:hover {
cursor: pointer;
}
&:active {
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .1);
box-shadow: 0 0.15rem 0.3rem rgba(0,0,0,.15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0,0,0,.1);
background-color: var(--color-button-active-background);
border-color: var(--color-button-active-border);
cursor: pointer;
@@ -136,21 +101,20 @@ a.site {
text-decoration: none;
font-size: 1rem;
line-height: 2rem;
&:hover {
text-decoration: underline;
}
}
.page {
position: absolute;
width: 100%;
position: absolute;
width: 100%;
height: 100%;
display: table;
margin: 0;
padding: 0;
>div {
> div {
display: table-cell;
vertical-align: middle;
text-align: center;
@@ -165,14 +129,12 @@ a.site {
padding-right: 2rem;
margin-top: .5rem;
}
.message {
margin-bottom: 1.5rem;
}
}
.signin {
button,
a.button,
input[type="text"] {
@@ -203,29 +165,25 @@ a.site {
font-weight: 500;
border-radius: 0.3rem;
background: var(--color-info);
color: #fff;
p {
text-align: left;
padding: 0.5rem 1rem;
font-size: 0.9rem;
line-height: 1.2rem;
color: var(--color-info-text);
}
}
>div,
> div,
form {
display: block;
margin: 0 auto 0.5rem auto;
input[type] {
margin-bottom: 0.5rem;
}
button {
width: 100%;
}
max-width: 300px;
}
}

View File

@@ -4,8 +4,7 @@
import fs from 'fs'
import path from 'path'
const pathToCss = path.join(process.cwd(), '/dist/css/index.css')
const pathToCss = path.join(__dirname, '/index.css')
const css = fs.readFileSync(pathToCss, 'utf8')
export default function css () {
return fs.readFileSync(pathToCss, 'utf8')
}
export default () => css

View File

@@ -1,7 +1,8 @@
export class UnknownError extends Error {
class UnknownError extends Error {
constructor (message) {
super(message)
this.name = 'UnknownError'
this.message = message
}
toJSON () {
@@ -15,25 +16,26 @@ export class UnknownError extends Error {
}
}
export class CreateUserError extends UnknownError {
class CreateUserError extends UnknownError {
constructor (message) {
super(message)
this.name = 'CreateUserError'
this.message = message
}
}
// Thrown when an Email address is already associated with an account
// but the user is trying an OAuth account that is not linked to it.
export class AccountNotLinkedError extends UnknownError {
// but the user is trying an oAuth account that is not linked to it.
class AccountNotLinkedError extends UnknownError {
constructor (message) {
super(message)
this.name = 'AccountNotLinkedError'
this.message = message
}
}
export class OAuthCallbackError extends UnknownError {
constructor (message) {
super(message)
this.name = 'OAuthCallbackError'
}
module.exports = {
UnknownError,
CreateUserError,
AccountNotLinkedError
}

View File

@@ -1,5 +1,5 @@
import crypto from 'crypto'
import jose from 'jose'
import hkdf from 'futoin-hkdf'
import logger from './logger'
// Set default algorithm to use for auto-generated signing key
@@ -13,7 +13,7 @@ const DEFAULT_ENCRYPTION_ENABLED = false
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
async function encode ({
const encode = async ({
token = {},
maxAge = DEFAULT_MAX_AGE,
secret,
@@ -28,9 +28,9 @@ async function encode ({
zip: 'DEF'
},
encryption = DEFAULT_ENCRYPTION_ENABLED
} = {}) {
} = {}) => {
// Signing Key
const _signingKey = signingKey
const _signingKey = (signingKey)
? jose.JWK.asKey(JSON.parse(signingKey))
: getDerivedSigningKey(secret)
@@ -39,17 +39,18 @@ async function encode ({
if (encryption) {
// Encryption Key
const _encryptionKey = encryptionKey
const _encryptionKey = (encryptionKey)
? jose.JWK.asKey(JSON.parse(encryptionKey))
: getDerivedEncryptionKey(secret)
// Encrypt token
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
} else {
return signedToken
}
return signedToken
}
async function decode ({
const decode = async ({
secret,
token,
maxAge = DEFAULT_MAX_AGE,
@@ -65,14 +66,14 @@ async function decode ({
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM]
},
encryption = DEFAULT_ENCRYPTION_ENABLED
} = {}) {
} = {}) => {
if (!token) return null
let tokenToVerify = token
if (encryption) {
// Encryption Key
const _encryptionKey = decryptionKey
const _encryptionKey = (decryptionKey)
? jose.JWK.asKey(JSON.parse(decryptionKey))
: getDerivedEncryptionKey(secret)
@@ -82,7 +83,7 @@ async function decode ({
}
// Signing Key
const _signingKey = verificationKey
const _signingKey = (verificationKey)
? jose.JWK.asKey(JSON.parse(verificationKey))
: getDerivedSigningKey(secret)
@@ -90,16 +91,7 @@ async function decode ({
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
}
/**
* Server-side method to retrieve the JWT from `req`.
* @param {{
* req: NextApiRequest
* secureCookie?: boolean
* cookieName?: string
* raw?: boolean
* }} params
*/
async function getToken (params) {
const getToken = async (args) => {
const {
req,
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
@@ -107,7 +99,7 @@ async function getToken (params) {
secureCookie = !(!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://')),
cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token',
raw = false
} = params
} = args
if (!req) throw new Error('Must pass `req` to JWT getToken()')
// Try to get token from cookie
@@ -116,7 +108,7 @@ async function getToken (params) {
// If cookie not found in cookie look for bearer token in authorization header.
// This allows clients that pass through tokens in headers rather than as
// cookies to use this helper function.
if (!token && req.headers.authorization?.split(' ')[0] === 'Bearer') {
if (!token && req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') {
const urlEncodedToken = req.headers.authorization.split(' ')[1]
token = decodeURIComponent(urlEncodedToken)
}
@@ -126,8 +118,8 @@ async function getToken (params) {
}
try {
return decode({ token, ...params })
} catch {
return await decode({ token, ...args })
} catch (error) {
return null
}
}
@@ -136,46 +128,24 @@ async function getToken (params) {
let DERIVED_SIGNING_KEY_WARNING = false
let DERIVED_ENCRYPTION_KEY_WARNING = false
// Do the better hkdf of Node.js one added in `v15.0.0` and Third Party one
function hkdf (secret, { byteLength, encryptionInfo, digest = 'sha256' }) {
if (crypto.hkdfSync) {
return Buffer.from(
crypto.hkdfSync(
digest,
secret,
Buffer.alloc(0),
encryptionInfo,
byteLength
)
)
}
return require('futoin-hkdf')(secret, byteLength, { info: encryptionInfo, hash: digest })
}
function getDerivedSigningKey (secret) {
const getDerivedSigningKey = (secret) => {
if (!DERIVED_SIGNING_KEY_WARNING) {
logger.warn('JWT_AUTO_GENERATED_SIGNING_KEY')
DERIVED_SIGNING_KEY_WARNING = true
}
const buffer = hkdf(secret, {
byteLength: 64,
encryptionInfo: 'NextAuth.js Generated Signing Key'
})
const buffer = hkdf(secret, 64, { info: 'NextAuth.js Generated Signing Key', hash: 'SHA-256' })
const key = jose.JWK.asKey(buffer, { alg: DEFAULT_SIGNATURE_ALGORITHM, use: 'sig', kid: 'nextauth-auto-generated-signing-key' })
return key
}
function getDerivedEncryptionKey (secret) {
const getDerivedEncryptionKey = (secret) => {
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
logger.warn('JWT_AUTO_GENERATED_ENCRYPTION_KEY')
DERIVED_ENCRYPTION_KEY_WARNING = true
}
const buffer = hkdf(secret, {
byteLength: 32,
encryptionInfo: 'NextAuth.js Generated Encryption Key'
})
const buffer = hkdf(secret, 32, { info: 'NextAuth.js Generated Encryption Key', hash: 'SHA-256' })
const key = jose.JWK.asKey(buffer, { alg: DEFAULT_ENCRYPTION_ALGORITHM, use: 'enc', kid: 'nextauth-auto-generated-encryption-key' })
return key
}

10
src/lib/logger.d.ts vendored
View File

@@ -1,10 +0,0 @@
export interface LoggerInstance {
warn: (code?: string, ...message: unknown[]) => void
error: (code?: string, ...message: unknown[]) => void
debug: (code?: string, ...message: unknown[]) => void
}
export declare function proxyLogger (logger: LoggerInstance, basePath: string): LoggerInstance
const _logger: LoggerInstance
export default _logger

View File

@@ -1,82 +1,32 @@
/** @type {import("./logger").LoggerInstance} */
const _logger = {
error (code, ...message) {
const logger = {
error: (errorCode, ...text) => {
if (!console) { return }
if (text && text.length <= 1) { text = text[0] || '' }
console.error(
`[next-auth][error][${code.toLowerCase()}]`,
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
...message
`[next-auth][error][${errorCode.toLowerCase()}]`,
text,
`\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}`
)
},
warn (code, ...message) {
warn: (warnCode, ...text) => {
if (!console) { return }
if (text && text.length <= 1) { text = text[0] || '' }
console.warn(
`[next-auth][warn][${code.toLowerCase()}]`,
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`,
...message
`[next-auth][warn][${warnCode.toLowerCase()}]`,
text,
`\nhttps://next-auth.js.org/warnings#${warnCode.toLowerCase()}`
)
},
debug (code, ...message) {
if (!process?.env?._NEXTAUTH_DEBUG) return
console.log(
`[next-auth][debug][${code.toLowerCase()}]`,
...message
)
debug: (debugCode, ...text) => {
if (!console) { return }
if (text && text.length <= 1) { text = text[0] || '' }
if (process && process.env && process.env._NEXTAUTH_DEBUG) {
console.log(
`[next-auth][debug][${debugCode.toLowerCase()}]`,
text
)
}
}
}
/**
* Override the built-in logger.
* Any `undefined` level will use the default logger.
* @param {Partial<import("./logger").LoggerInstance>} newLogger
*/
export function setLogger (newLogger = {}) {
if (newLogger.error) _logger.error = newLogger.error
if (newLogger.warn) _logger.warn = newLogger.warn
if (newLogger.debug) _logger.debug = newLogger.debug
}
export default _logger
/**
* Serializes client-side log messages and sends them to the server
* @param {import("./logger").LoggerInstance} logger
* @param {string} basePath
* @return {import("./logger").LoggerInstance}
*/
export function proxyLogger (logger = _logger, basePath) {
try {
if (typeof window === 'undefined') {
return logger
}
const clientLogger = {}
for (const level in logger) {
clientLogger[level] = (code, ...message) => {
_logger[level](code, ...message) // Log on client as usual
const url = `${basePath}/_log`
const body = new URLSearchParams({
level,
code,
message: JSON.stringify(message.map(m => {
if (m instanceof Error) {
// Serializing errors: https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
return { name: m.name, message: m.message, stack: m.stack }
}
return m
}))
})
if (navigator.sendBeacon) {
return navigator.sendBeacon(url, body)
}
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body
})
}
}
return clientLogger
} catch {
return _logger
}
}
export default logger

View File

@@ -1,11 +1,8 @@
/**
* 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) {
// 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) => {
// Default values
const defaultHost = 'http://localhost:3000'
const defaultPath = '/api/auth'
@@ -13,7 +10,7 @@ export default function parseUrl (url) {
if (!url) { url = `${defaultHost}${defaultPath}` }
// Default to HTTPS if no protocol explictly specified
const protocol = url.startsWith('http:') ? 'http' : 'https'
const protocol = url.match(/^http?:\/\//) ? 'http' : 'https'
// Normalize URLs by stripping protocol and no trailing slash
url = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
@@ -23,5 +20,8 @@ export default function parseUrl (url) {
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
return { baseUrl, basePath }
return {
baseUrl,
basePath
}
}

View File

@@ -1,3 +1,5 @@
import jwt from 'jsonwebtoken'
export default (options) => {
return {
id: 'apple',
@@ -10,6 +12,7 @@ export default (options) => {
authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post',
profileUrl: null,
idToken: true,
state: false, // Apple doesn't support state verfication
profile: (profile) => {
// The name of the user will only return on first login
return {
@@ -20,11 +23,30 @@ export default (options) => {
},
clientId: null,
clientSecret: {
appleId: null,
teamId: null,
privateKey: null,
keyId: null
},
protection: 'none', // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
clientSecretCallback: async ({ appleId, keyId, teamId, privateKey }) => {
const response = jwt.sign(
{
iss: teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
aud: 'https://appleid.apple.com',
sub: appleId
},
// Automatically convert \\n into \n if found in private key. If the key
// is passed in an environment variable \n can get escaped as \\n
privateKey.replace(/\\n/g, '\n'),
{
algorithm: 'ES256',
keyid: keyId
}
)
return Promise.resolve(response)
},
...options
}
}

View File

@@ -10,6 +10,22 @@ export default (options) => {
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
@@ -20,9 +36,7 @@ export default (options) => {
email: null
}
},
headers: {
'X-API-Key': null
},
apiKey: null,
clientId: null,
clientSecret: null,
...options

View File

@@ -14,7 +14,7 @@ export default (options) => {
const defaultAvatarNumber = parseInt(profile.discriminator) % 5
profile.image_url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNumber}.png`
} else {
const format = profile.avatar.startsWith('a_') ? 'gif' : 'png'
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 {

View File

@@ -1,21 +0,0 @@
export default (options) => {
return {
id: 'eveonline',
name: 'EVE Online',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://login.eveonline.com/oauth/token',
authorizationUrl: 'https://login.eveonline.com/oauth/authorize?response_type=code',
profileUrl: 'https://login.eveonline.com/oauth/verify',
profile: (profile) => {
return {
id: profile.CharacterID,
name: profile.CharacterName,
image: `https://image.eveonline.com/Character/${profile.CharacterID}_128.jpg`,
email: null
}
},
...options
}
}

View File

@@ -1,25 +0,0 @@
export default (options) => {
return {
id: 'faceit',
name: 'FACEIT',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
headers: {
Authorization: `Basic ${Buffer.from(`${options.clientId}:${options.clientSecret}`).toString('base64')}`
},
accessTokenUrl: 'https://api.faceit.com/auth/v1/oauth/token',
authorizationUrl: 'https://accounts.faceit.com/accounts?redirect_popup=true&response_type=code',
profileUrl: 'https://api.faceit.com/auth/v1/resources/userinfo',
profile (profile) {
const { guid: id, nickname: name, email, picture: image } = profile
return {
id,
name,
email,
image
}
},
...options
}
}

View File

@@ -12,6 +12,7 @@ export default (options) => {
profile: (profile) => {
return { ...profile, id: profile.sub }
},
setGetAccessTokenAuthHeader: false,
...options
}
}

View File

@@ -6,78 +6,56 @@ import Basecamp from './basecamp'
import BattleNet from './battlenet'
import Box from './box'
import Bungie from './bungie'
import Cognito from './cognito'
import Credentials from './credentials'
import Cognito from './cognito'
import Discord from './discord'
import Email from './email'
import EVEOnline from './eveonline'
import Facebook from './facebook'
import FACEIT from './faceit'
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 Instagram from './instagram'
import Kakao from './kakao'
import LINE from './line'
import LinkedIn from './linkedin'
import MailRu from './mailru'
import Medium from './medium'
import Mixer from './mixer'
import Netlify from './netlify'
import Okta from './okta'
import Osso from './osso'
import Reddit from './reddit'
import Salesforce from './salesforce'
import Slack from './slack'
import Spotify from './spotify'
import Strava from './strava'
import Twitch from './twitch'
import Twitter from './twitter'
import VK from './vk'
import Yandex from './yandex'
import Zoho from './zoho'
export default {
Apple,
Atlassian,
Auth0,
Apple,
AzureADB2C,
Basecamp,
BattleNet,
Box,
Bungie,
Cognito,
Credentials,
Cognito,
Discord,
Email,
EVEOnline,
Facebook,
FACEIT,
Foursquare,
FusionAuth,
GitHub,
GitLab,
Google,
IdentityServer4,
Instagram,
Kakao,
LINE,
LinkedIn,
MailRu,
Medium,
Mixer,
Netlify,
Okta,
Osso,
Reddit,
Salesforce,
Slack,
Spotify,
Strava,
Twitch,
Twitter,
VK,
Yandex,
Zoho
Twitch,
Yandex
}

View File

@@ -1,50 +0,0 @@
/**
* @param {import("../server").Provider} options
* @example
*
* ```js
* // pages/api/auth/[...nextauth].js
* import Providers from `next-auth/providers`
* ...
* providers: [
* Providers.Instagram({
* clientId: process.env.INSTAGRAM_CLIENT_ID,
* clientSecret: process.env.INSTAGRAM_CLIENT_SECRET
* })
* ]
* ...
*
* // pages/index
* import { signIn } from "next-auth/client"
* ...
* <button onClick={() => signIn("instagram")}>
* Sign in
* </button>
* ...
* ```
* *Resources:*
* - [NextAuth.js Documentation](https://next-auth.js.org/providers/instagram)
* - [Instagram Documentation](https://developers.facebook.com/docs/instagram-basic-display-api/getting-started)
* - [Configuration](https://developers.facebook.com/apps)
*/
export default function Instagram (options) {
return {
id: 'instagram',
name: 'Instagram',
type: 'oauth',
version: '2.0',
scope: 'user_profile',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.instagram.com/oauth/access_token',
authorizationUrl: 'https://api.instagram.com/oauth/authorize?response_type=code',
profileUrl: 'https://graph.instagram.com/me?fields=id,username,account_type,name',
async profile (profile) {
return {
id: profile.id,
name: profile.username,
email: null,
image: null
}
}
}
}

View File

@@ -1,21 +0,0 @@
export default (options) => {
return {
id: 'kakao',
name: 'Kakao',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://kauth.kakao.com/oauth/token',
authorizationUrl: 'https://kauth.kakao.com/oauth/authorize?response_type=code',
profileUrl: 'https://kapi.kakao.com/v2/user/me',
profile: (profile) => {
return {
id: profile.id,
name: profile.kakao_account?.profile.nickname,
email: profile.kakao_account?.email,
image: profile.kakao_account?.profile.profile_image_url
}
},
...options
}
}

View File

@@ -1,22 +0,0 @@
export default (options) => {
return {
id: 'line',
name: 'LINE',
type: 'oauth',
version: '2.0',
scope: 'profile openid',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.line.me/oauth2/v2.1/token',
authorizationUrl: 'https://access.line.me/oauth2/v2.1/authorize?response_type=code',
profileUrl: 'https://api.line.me/v2/profile',
profile: (profile) => {
return {
id: profile.userId,
name: profile.displayName,
email: null,
image: profile.pictureUrl
}
},
...options
}
}

View File

@@ -1,25 +0,0 @@
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
}
}

View File

@@ -1,22 +0,0 @@
export default (options) => {
return {
id: 'medium',
name: 'Medium',
type: 'oauth',
version: '2.0',
scope: 'basicProfile',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.medium.com/v1/tokens',
authorizationUrl: 'https://medium.com/m/oauth/authorize?response_type=code',
profileUrl: 'https://api.medium.com/v1/me',
profile: (profile) => {
return {
id: profile.data.id,
name: profile.data.name,
email: null,
image: profile.data.imageUrl
}
},
...options
}
}

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

@@ -0,0 +1,22 @@
export default (options) => {
return {
id: 'mixer',
name: 'Mixer',
type: 'oauth',
version: '2.0',
scope: 'user:details:self',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://mixer.com/api/v1/oauth/token',
authorizationUrl: 'https://mixer.com/oauth/authorize?response_type=code',
profileUrl: 'https://mixer.com/api/v1/users/current',
profile: (profile) => {
return {
id: profile.id,
name: profile.username,
image: profile.avatarUrl,
email: profile.email
}
},
...options
}
}

View File

@@ -17,6 +17,7 @@ export default (options) => {
profile: (profile) => {
return { ...profile, id: profile.sub }
},
setGetAccessTokenAuthHeader: false,
...options
}
}

View File

@@ -1,20 +0,0 @@
export default (options) => {
return {
id: 'osso',
name: 'SAML SSO',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: `https://${options.domain}/oauth/token`,
authorizationUrl: `https://${options.domain}/oauth/authorize?response_type=code`,
profileUrl: `https://${options.domain}/oauth/me`,
profile: (profile) => {
return {
id: profile.id,
name: profile.name || profile.email,
email: profile.email
}
},
...options
}
}

View File

@@ -1,3 +1,4 @@
// Logging in works but trying to retrieve the profile results in 401 unauthorized
export default (options) => {
return {
id: 'reddit',
@@ -11,12 +12,12 @@ export default (options) => {
'https://www.reddit.com/api/v1/authorize?response_type=code',
profileUrl: 'https://oauth.reddit.com/api/v1/me',
profile: (profile) => {
return {
id: profile.id,
name: profile.name,
image: null,
email: null
}
// return {
// id: profile.id,
// name: profile.name,
// image: null,
// email: null,
// };
},
...options
}

View File

@@ -1,21 +0,0 @@
export default (options) => {
return {
id: 'salesforce',
name: 'Salesforce',
type: 'oauth',
version: '2.0',
params: { display: 'page', grant_type: 'authorization_code' },
accessTokenUrl: 'https://login.salesforce.com/services/oauth2/token',
authorizationUrl: 'https://login.salesforce.com/services/oauth2/authorize?response_type=code',
profileUrl: 'https://login.salesforce.com/services/oauth2/userinfo',
protection: 'none', // REVIEW: Can we use "pkce" ?
profile: (profile) => {
return {
...profile,
id: profile.user_id,
image: profile.picture
}
},
...options
}
}

View File

@@ -7,8 +7,9 @@ export default (options) => {
scope: [],
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://slack.com/api/oauth.v2.access',
accessTokenGetter: (json) => json.authed_user.access_token,
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
authorizationParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
additionalAuthorizeParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
profileUrl: 'https://slack.com/api/users.identity',
profile: (profile) => {
const { user } = profile

View File

@@ -1,30 +0,0 @@
export default (options) => {
const apiVersion = '5.126' // https://vk.com/dev/versions
return {
id: 'vk',
name: 'VK',
type: 'oauth',
version: '2.0',
scope: 'email',
params: {
grant_type: 'authorization_code'
},
accessTokenUrl: `https://oauth.vk.com/access_token?v=${apiVersion}`,
requestTokenUrl: `https://oauth.vk.com/access_token?v=${apiVersion}`,
authorizationUrl:
`https://oauth.vk.com/authorize?response_type=code&v=${apiVersion}`,
profileUrl: `https://api.vk.com/method/users.get?fields=photo_100&v=${apiVersion}`,
profile: (result) => {
const profile = result.response?.[0] ?? {}
return {
id: profile.id,
name: [profile.first_name, profile.last_name].filter(Boolean).join(' '),
email: profile.email,
image: profile.photo_100
}
},
...options
}
}

View File

@@ -1,22 +0,0 @@
export default (options) => {
return {
id: 'zoho',
name: 'Zoho',
type: 'oauth',
version: '2.0',
scope: 'AaaServer.profile.Read',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://accounts.zoho.com/oauth/v2/token',
authorizationUrl: 'https://accounts.zoho.com/oauth/v2/auth?response_type=code',
profileUrl: 'https://accounts.zoho.com/oauth/user/info',
profile: (profile) => {
return {
id: profile.ZUID,
name: `${profile.First_Name} ${profile.Last_Name}`,
email: profile.Email,
image: null
}
},
...options
}
}

94
src/server/index.d.ts vendored
View File

@@ -1,94 +0,0 @@
import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next'
import { LoggerInstance } from 'src/lib/logger'
import { CallbacksOptions } from './lib/callbacks'
import { CookiesOptions } from './lib/cookie'
import { EventsOptions } from './lib/events'
export interface Provider {
id: string
name: string
type: string
version: string
params: Record<string, unknown>
scope: string
accessTokenUrl: string
authorizationUrl: string
profileUrl?: string
grant_type?: string
profile?: (profile: any) => Promise<any>
}
/** @docs https://next-auth.js.org/configuration/options */
export interface NextAuthOptions {
/** @docs https://next-auth.js.org/configuration/options#theme */
theme?: 'auto' | 'dark' | 'light'
/** @docs https://next-auth.js.org/configuration/options#providers */
providers: Provider[]
/** @docs https://next-auth.js.org/configuration/options#database */
database?: any
/** @docs https://next-auth.js.org/configuration/options#secret */
secret?: any
/** @docs https://next-auth.js.org/configuration/options#session */
session?: any
/** @docs https://next-auth.js.org/configuration/options#jwt */
jwt?: any
/** @docs https://next-auth.js.org/configuration/options#pages */
pages?: {
signIn?: string
signOut?: string
/** Error code passed in query string as ?error= */
error?: string
verifyRequest?: string
/** If set, new users will be directed here on first sign in */
newUser?: string
}
/**
* Callbacks are asynchronous functions you can use to control what happens when an action is performed.
* Callbacks are extremely powerful, especially in scenarios involving JSON Web Tokens as
* they allow you to implement access controls without a database and
* to integrate with external databases or APIs.
* @docs https://next-auth.js.org/configuration/options#callbacks
*/
callbacks?: CallbacksOptions
/** @docs https://next-auth.js.org/configuration/options#events */
events?: EventsOptions
/** @docs https://next-auth.js.org/configuration/options#adapter */
adapter?: any
/** @docs https://next-auth.js.org/configuration/options#debug */
debug?: boolean
/** @docs https://next-auth.js.org/configuration/options#usesecurecookies */
useSecureCookies?: boolean
/** @docs https://next-auth.js.org/configuration/options#cookies */
cookies?: CookiesOptions
/** @docs https://next-auth.js.org/configuration/options#logger */
logger: LoggerInstance
}
/** Options that are the same both in internal and user provided options. */
export type NextAuthSharedOptions = 'pages' | 'jwt' | 'events' | 'callbacks' | 'cookies' | 'secret' | 'adapter' | 'theme' | 'debug' | 'logger'
export interface NextAuthInternalOptions extends Pick<NextAuthOptions, NextAuthSharedOptions> {
pkce?: {
code_verifier?: string
/**
* Could be `"plain"`, but not recommended.
* We ignore it for now.
* @spec https://tools.ietf.org/html/rfc7636#section-4.2.
*/
code_challenge_method?: 'S256'
}
provider?: Provider
baseUrl?: string
basePath?: string
action?: string
csrfToken?: string
}
export interface NextAuthRequest extends NextApiRequest {
options: NextAuthInternalOptions
}
export interface NextAuthResponse extends NextApiResponse {}
export declare function NextAuthHandler (req: NextAuthRequest, res: NextAuthResponse, options: NextAuthOptions): ReturnType<NextApiHandler>
export declare function NextAuthHandler (options: NextAuthOptions): ReturnType<NextApiHandler>

View File

@@ -1,19 +1,19 @@
import adapters from '../adapters'
import { createHash, randomBytes } from 'crypto'
import jwt from '../lib/jwt'
import parseUrl from '../lib/parse-url'
import logger, { setLogger } from '../lib/logger'
import * as cookie from './lib/cookie'
import * as defaultEvents from './lib/default-events'
import * as defaultCallbacks from './lib/default-callbacks'
import parseProviders from './lib/providers'
import cookie from './lib/cookie'
import callbackUrlHandler from './lib/callback-url-handler'
import extendRes from './lib/extend-req'
import * as routes from './routes'
import renderPage from './pages'
import csrfTokenHandler from './lib/csrf-token-handler'
import createSecret from './lib/create-secret'
import * as pkce from './lib/oauth/pkce-handler'
import * as state from './lib/oauth/state-handler'
import parseProviders from './lib/providers'
import events from './lib/events'
import callbacks from './lib/callbacks'
import providers from './routes/providers'
import signin from './routes/signin'
import signout from './routes/signout'
import callback from './routes/callback'
import session from './routes/session'
import pages from './pages'
import adapters from '../adapters'
import logger from '../lib/logger'
// To work properly in production with OAuth providers the NEXTAUTH_URL
// environment variable must be set.
@@ -21,80 +21,196 @@ if (!process.env.NEXTAUTH_URL) {
logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set')
}
/**
* @param {import("next").NextApiRequest} req
* @param {import("next").NextApiResponse} res
* @param {import(".").NextAuthOptions} userOptions
*/
async function NextAuthHandler (req, res, userOptions) {
if (userOptions.logger) {
setLogger(userOptions.logger)
}
// If debug enabled, set ENV VAR so that logger logs debug messages
if (userOptions.debug) {
process.env._NEXTAUTH_DEBUG = true
}
async function NextAuth (req, res, userSuppliedOptions) {
// To the best of my knowledge, we need to return a promise here
// to avoid early termination of calls to the serverless function
// (and then return that promise when we are done) - eslint
// complains but I'm not sure there is another way to do this.
return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor
extendRes(req, res, resolve)
// 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
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}`)
res
.status(500)
.end(
`Error: ${error}`
)
return done()
}
const { url, query, body } = req
const {
nextauth,
action = nextauth[0],
providerId = nextauth[1],
provider = nextauth[1],
error = nextauth[1]
} = req.query
} = query
// @todo refactor all existing references to baseUrl and basePath
const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL)
const {
csrfToken: csrfTokenFromPost
} = body
const cookies = {
...cookie.defaultCookies(userOptions.useSecureCookies || baseUrl.startsWith('https://')),
// Allow user cookie options to override any cookie settings above
...userOptions.cookies
}
const secret = createSecret({ userOptions, basePath, baseUrl })
const { csrfToken, csrfTokenVerified } = csrfTokenHandler(req, res, cookies, secret)
const providers = parseProviders({ providers: userOptions.providers, baseUrl, basePath })
const provider = providers.find(({ id }) => id === providerId)
if (provider &&
provider.type === 'oauth' && provider.version?.startsWith('2') &&
(!provider.protection && provider.state !== false)
) {
provider.protection = 'state' // Default to state, as we did in 3.1 REVIEW: should we use "pkce" or "none" as default?
}
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle
// @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
// Parse database / adapter
// If adapter is provided, use it (advanced usage, overrides database)
// If database URI or config object is provided, use it (simple usage)
const adapter = userOptions.adapter ?? (userOptions.database && adapters.Default(userOptions.database))
let adapter
if (userSuppliedOptions.adapter) {
// If adapter is provided, use it (advanced usage, overrides database)
adapter = userSuppliedOptions.adapter
} else if (userSuppliedOptions.database) {
// If database URI or config object is provided, use it (simple usage)
adapter = adapters.Default(userSuppliedOptions.database)
}
// Secret used salt cookies and tokens (e.g. for CSRF protection).
// If no secret option is specified then it creates one on the fly
// based on options passed here. A options contains unique data, such as
// oAuth provider secrets and database credentials it should be sufficent.
const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify({ baseUrl, basePath, ...userSuppliedOptions })).digest('hex')
// Use secure cookies if the site uses HTTPS
// This being conditional allows cookies to work non-HTTPS development URLs
// Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
// prefix, but enable them by default if the site URL is HTTPS; but not for
// non-HTTPS URLs like http://localhost which are used in development).
// For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
const useSecureCookies = userSuppliedOptions.useSecureCookies || baseUrl.startsWith('https://')
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
// @TODO Review cookie settings (names, options)
const cookies = {
// default cookie options
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
options: {
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
// Allow user cookie options to override any cookie settings above
...userSuppliedOptions.cookies
}
// Session options
const sessionOptions = {
jwt: false,
maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
...userSuppliedOptions.session
}
// JWT options
const jwtOptions = {
secret, // Use application secret if no keys specified
maxAge: sessionOptions.maxAge, // maxAge is dereived from session maxAge,
encode: jwt.encode,
decode: jwt.decode,
...userSuppliedOptions.jwt
}
// If no adapter specified, force use of JSON Web Tokens (stateless)
if (!adapter) {
sessionOptions.jwt = true
}
// Event messages
const eventsOptions = {
...events,
...userSuppliedOptions.events
}
// Callback functions
const callbacksOptions = {
...callbacks,
...userSuppliedOptions.callbacks
}
// Ensure CSRF Token cookie is set for any subsequent requests.
// Used as part of the strateigy for mitigation for CSRF tokens.
//
// Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
// where 'token' is the CSRF token and 'hash' is a hash made of the token and
// the secret, and the two values are joined by a pipe '|'. By storing the
// value and the hash of the value (with the secret used as a salt) we can
// verify the cookie was set by the server and not by a malicous attacker.
//
// For more details, see the following OWASP links:
// https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
// https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
let csrfToken
let csrfTokenVerified = false
if (req.cookies[cookies.csrfToken.name]) {
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
// If hash matches then we trust the CSRF token value
csrfToken = csrfTokenValue
// If this is a POST request and the CSRF Token in the Post request matches
// the cookie we have already verified is one we have set, then token is verified!
if (req.method === 'POST' && csrfToken === csrfTokenFromPost) { csrfTokenVerified = true }
}
}
if (!csrfToken) {
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
// (e.g. because it's been modifed or because the secret has changed) create a new token.
csrfToken = randomBytes(32).toString('hex')
const newCsrfTokenCookie = `${csrfToken}|${createHash('sha256').update(`${csrfToken}${secret}`).digest('hex')}`
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
}
// Helper method for handling redirects, this is passed to all routes
// @TODO Refactor into a lib instead of passing as an option
// e.g. and call as redirect(req, res, url)
const redirect = (redirectUrl) => {
const reponseAsJson = !!((req.body && req.body.json === 'true'))
if (reponseAsJson) {
res.json({ url: redirectUrl })
} else {
res.status(302).setHeader('Location', redirectUrl)
res.end()
}
return done()
}
// User provided options are overriden by other options,
// except for the options with special handling above
req.options = {
debug: false,
pages: {},
theme: 'auto',
const options = {
// Defaults options can be overidden
debug: false, // Enable debug messages to be displayed
pages: {}, // Custom pages (e.g. sign in, sign out, errors)
// Custom options override defaults
...userOptions,
// These computed settings can have values in userOptions but we override them
...userSuppliedOptions,
// These computed settings can values in userSuppliedOptions but override them
// and are request-specific.
adapter,
baseUrl,
@@ -104,154 +220,116 @@ async function NextAuthHandler (req, res, userOptions) {
cookies,
secret,
csrfToken,
providers,
// Session options
session: {
jwt: !adapter, // If no adapter specified, force use of JSON Web Tokens (stateless)
maxAge,
updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours)
...userOptions.session
},
// JWT options
jwt: {
secret, // Use application secret if no keys specified
maxAge, // same as session maxAge,
encode: jwt.encode,
decode: jwt.decode,
...userOptions.jwt
},
// Event messages
events: {
...defaultEvents,
...userOptions.events
},
// Callback functions
callbacks: {
...defaultCallbacks,
...userOptions.callbacks
},
pkce: {},
logger
providers: parseProviders(userSuppliedOptions.providers, baseUrl, basePath),
session: sessionOptions,
jwt: jwtOptions,
events: eventsOptions,
callbacks: callbacksOptions,
callbackUrl: baseUrl,
redirect
}
await callbackUrlHandler(req, res)
// If debug enabled, set ENV VAR so that logger logs debug messages
if (options.debug === true) { process.env._NEXTAUTH_DEBUG = true }
const render = renderPage(req, res)
const { pages } = req.options
// Get / Set callback URL based on query param / cookie + validation
options.callbackUrl = await callbackUrlHandler(req, res, options)
if (req.method === 'GET') {
switch (action) {
case 'providers':
return routes.providers(req, res)
providers(req, res, options, done)
break
case 'session':
return routes.session(req, res)
session(req, res, options, done)
break
case 'csrf':
return res.json({ csrfToken })
res.json({ csrfToken })
return done()
case 'signin':
if (pages.signIn) {
let signinUrl = `${pages.signIn}${pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${req.options.callbackUrl}`
if (error) { signinUrl = `${signinUrl}&error=${error}` }
return res.redirect(signinUrl)
if (options.pages.signIn) {
let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`
if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` }
return redirect(redirectUrl)
}
return render.signin()
pages.render(req, res, 'signin', { baseUrl, basePath, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done)
break
case 'signout':
if (pages.signOut) {
return res.redirect(`${pages.signOut}${pages.signOut.includes('?') ? '&' : '?'}error=${error}`)
}
return render.signout()
if (options.pages.signOut) { return redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`) }
pages.render(req, res, 'signout', { baseUrl, basePath, csrfToken, callbackUrl: options.callbackUrl }, done)
break
case 'callback':
if (provider) {
if (await pkce.handleCallback(req, res)) return
if (await state.handleCallback(req, res)) return
return routes.callback(req, res)
if (provider && options.providers[provider]) {
callback(req, res, options, done)
} else {
res.status(400).end(`Error: HTTP GET is not supported for ${url}`)
return done()
}
break
case 'verify-request':
if (pages.verifyRequest) {
return res.redirect(pages.verifyRequest)
}
return render.verifyRequest()
if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) }
pages.render(req, res, 'verify-request', { baseUrl }, done)
break
case 'error':
if (pages.error) {
return res.redirect(`${pages.error}${pages.error.includes('?') ? '&' : '?'}error=${error}`)
}
if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) }
// These error messages are displayed in line on the sign in page
if ([
'Signin',
'OAuthSignin',
'OAuthCallback',
'OAuthCreateAccount',
'EmailCreateAccount',
'Callback',
'OAuthAccountNotLinked',
'EmailSignin',
'CredentialsSignin'
].includes(error)) {
return res.redirect(`${baseUrl}${basePath}/signin?error=${error}`)
}
return render.error({ error })
pages.render(req, res, 'error', { baseUrl, basePath, error }, done)
break
default:
res.status(404).end()
return done()
}
} else if (req.method === 'POST') {
switch (action) {
case 'signin':
// Verified CSRF Token required for all sign in routes
if (csrfTokenVerified && provider) {
if (await pkce.handleSignin(req, res)) return
if (await state.handleSignin(req, res)) return
return routes.signin(req, res)
if (!csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
case 'signout':
// Verified CSRF Token required for signout
if (csrfTokenVerified) {
return routes.signout(req, res)
}
return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`)
case 'callback':
if (provider) {
// Verified CSRF Token required for credentials providers only
if (provider.type === 'credentials' && !csrfTokenVerified) {
return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`)
}
if (await pkce.handleCallback(req, res)) return
if (await state.handleCallback(req, res)) return
return routes.callback(req, res)
if (provider && options.providers[provider]) {
signin(req, res, options, done)
}
break
case '_log':
if (userOptions.logger) {
try {
const {
code = 'CLIENT_ERROR',
level = 'error',
message = '[]'
} = req.body
logger[level](code, ...JSON.parse(message))
} catch (error) {
// If logging itself failed...
logger.error('LOGGER_ERROR', error)
}
case 'signout':
// Verified CSRF Token required for signout
if (!csrfTokenVerified) {
return redirect(`${baseUrl}${basePath}/signout?csrf=true`)
}
return res.end()
signout(req, res, options, done)
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`)
}
callback(req, res, options, done)
} else {
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
}
break
default:
res.status(400).end(`Error: HTTP POST is not supported for ${url}`)
return done()
}
} else {
res.status(400).end(`Error: HTTP ${req.method} is not supported for ${url}`)
return done()
}
return res.status(400).end(`Error: HTTP ${req.method} is not supported for ${req.url}`)
})
}
/** Tha main entry point to next-auth */
export default function NextAuth (...args) {
export default async (...args) => {
if (args.length === 1) {
return (req, res) => NextAuthHandler(req, res, args[0])
return (req, res) => NextAuth(req, res, args[0])
}
return NextAuthHandler(...args)
return NextAuth(...args)
}

View File

@@ -1,221 +1,223 @@
// 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'
/**
* 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) {
// Input validation
if (!profile) throw new Error('Missing profile')
if (!providerAccount?.id || !providerAccount.type) throw new Error('Missing or invalid provider account')
if (!['email', 'oauth'].includes(providerAccount.type)) throw new Error('Provider not supported')
export default async (sessionToken, profile, providerAccount, options) => {
try {
// Input validation
if (!profile) { throw new Error('Missing profile') }
if (!providerAccount || !providerAccount.id || !providerAccount.type) { throw new Error('Missing or invalid provider account') }
const {
adapter,
jwt,
events,
session: {
jwt: useJwtSession
const { adapter, jwt, events } = options
const useJwtSession = options.session.jwt
// If no adapter is configured then we don't have a database and cannot
// persist data; in this mode we just return a dummy session object.
if (!adapter) {
return {
user: profile,
account: providerAccount,
session: {}
}
}
} = options
// If no adapter is configured then we don't have a database and cannot
// persist data; in this mode we just return a dummy session object.
if (!adapter) {
return {
user: profile,
account: providerAccount,
session: {}
}
}
const {
createUser,
updateUser,
getUser,
getUserByProviderAccountId,
getUserByEmail,
linkAccount,
createSession,
getSession,
deleteSession
} = await adapter.getAdapter(options)
const {
createUser,
updateUser,
getUser,
getUserByProviderAccountId,
getUserByEmail,
linkAccount,
createSession,
getSession,
deleteSession
} = await adapter.getAdapter(options)
let session = null
let user = null
let isSignedIn = null
let isNewUser = false
let session = null
let user = null
let isSignedIn = null
let isNewUser = false
if (sessionToken) {
if (useJwtSession) {
try {
session = await jwt.decode({ ...jwt, token: sessionToken })
if (session?.sub) {
user = await getUser(session.sub)
if (sessionToken) {
if (useJwtSession) {
try {
session = await jwt.decode({ ...jwt, token: sessionToken })
if (session && session.sub) {
user = await getUser(session.sub)
isSignedIn = !!user
}
} catch (e) {
// If session can't be verified, treat as no session
}
} else {
session = await getSession(sessionToken)
if (session && session.userId) {
user = await getUser(session.userId)
isSignedIn = !!user
}
} catch {
// If session can't be verified, treat as no session
}
}
session = await getSession(sessionToken)
if (session?.userId) {
user = await getUser(session.userId)
isSignedIn = !!user
}
}
if (providerAccount.type === 'email') {
// If signing in with an email, check if an account with the same email address exists already
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// If they are not already signed in as the same user, this flow will
// sign them out of the current session and sign them in as the new user
if (isSignedIn) {
if (user.id !== userByEmail.id && !useJwtSession) {
// Delete existing session if they are currently signed in as another user.
// This will switch user accounts for the session in cases where the user was
// already logged in with a different account.
await deleteSession(sessionToken)
if (providerAccount.type === 'email') {
// If signing in with an email, check if an account with the same email address exists already
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// If they are not already signed in as the same user, this flow will
// sign them out of the current session and sign them in as the new user
if (isSignedIn) {
if (user.id !== userByEmail.id && !useJwtSession) {
// Delete existing session if they are currently signed in as another user.
// This will switch user accounts for the session in cases where the user was
// already logged in with a different account.
await deleteSession(sessionToken)
}
}
// Update emailVerified property on the user object
const currentDate = new Date()
user = await updateUser({ ...userByEmail, emailVerified: currentDate })
await dispatchEvent(events.updateUser, user)
} else {
// Create user account if there isn't one for the email address already
const currentDate = new Date()
user = await createUser({ ...profile, emailVerified: currentDate })
await dispatchEvent(events.createUser, user)
isNewUser = true
}
// Update emailVerified property on the user object
const currentDate = new Date()
user = await updateUser({ ...userByEmail, emailVerified: currentDate })
await dispatchEvent(events.updateUser, user)
} else {
// Create user account if there isn't one for the email address already
const currentDate = new Date()
user = await createUser({ ...profile, emailVerified: currentDate })
await dispatchEvent(events.createUser, user)
isNewUser = true
}
// Create new session
session = useJwtSession ? {} : await createSession(user)
// Create new session
session = useJwtSession ? {} : await createSession(user)
return {
session,
user,
isNewUser
}
} else if (providerAccount.type === 'oauth') {
// If signing in with oauth account, check to see if the account exists already
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
if (userByProviderAccountId) {
if (isSignedIn) {
// If the user is already signed in with this account, we don't need to do anything
// Note: These are cast as strings here to ensure they match as in
// some flows (e.g. JWT with a database) one of the values might be a
// string and the other might be an ObjectID and would otherwise fail.
if (`${userByProviderAccountId.id}` === `${user.id}`) {
return {
session,
user,
isNewUser
}
} else {
// If the user is currently signed in, but the new account they are signing in
// with is already associated with another account, then we cannot link them
// and need to return an error.
throw new AccountNotLinkedError()
}
} else {
// If there is no active session, but the account being signed in with is already
// associated with a valid user then create session to sign the user in.
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
return {
session,
user: userByProviderAccountId,
isNewUser
}
}
} else {
if (isSignedIn) {
// If the user is already signed in and the oAuth account isn't already associated
// with another user account then we can go ahead and link the accounts safely.
await linkAccount(
user.id,
providerAccount.provider,
providerAccount.type,
providerAccount.id,
providerAccount.refreshToken,
providerAccount.accessToken,
providerAccount.accessTokenExpires
)
await dispatchEvent(events.linkAccount, { user, providerAccount })
return {
session,
user,
isNewUser
}
} else if (providerAccount.type === 'oauth') {
// If signing in with oauth account, check to see if the account exists already
const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id)
if (userByProviderAccountId) {
if (isSignedIn) {
// If the user is already signed in with this account, we don't need to do anything
// Note: These are cast as strings here to ensure they match as in
// some flows (e.g. JWT with a database) one of the values might be a
// string and the other might be an ObjectID and would otherwise fail.
if (`${userByProviderAccountId.id}` === `${user.id}`) {
// As they are already signed in, we don't need to do anything after linking them
return {
session,
user,
isNewUser
}
}
// If the user is currently signed in, but the new account they are signing in
// with is already associated with another account, then we cannot link them
// and need to return an error.
throw new AccountNotLinkedError()
}
// If there is no active session, but the account being signed in with is already
// associated with a valid user then create session to sign the user in.
session = useJwtSession ? {} : await createSession(userByProviderAccountId)
return {
session,
user: userByProviderAccountId,
isNewUser
}
} else {
if (isSignedIn) {
// If the user is already signed in and the OAuth account isn't already associated
// with another user account then we can go ahead and link the accounts safely.
await linkAccount(
user.id,
providerAccount.provider,
providerAccount.type,
providerAccount.id,
providerAccount.refreshToken,
providerAccount.accessToken,
providerAccount.accessTokenExpires
)
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
// As they are already signed in, we don't need to do anything after linking them
return {
session,
user,
isNewUser
// If the user is not signed in and it looks like a new oAuth account then we
// check there also isn't an user account already associated with the same
// email address as the one in the oAuth profile.
//
// This step is often overlooked in oAuth implementations, but covers the following cases:
//
// 1. It makes it harder for someone to accidentally create two accounts.
// e.g. by signin in with email, then again with an oauth account connected to the same email.
// 2. It makes it harder to hijack a user account using a 3rd party oAuth account.
// e.g. by creating an oauth account then changing the email address associated with it.
//
// It's quite common for services to automatically link accounts in this case, but it's
// better practice to require the user to sign in *then* link accounts to be sure
// someone is not exploiting a problem with a third party oAuth service.
//
// oAuth providers should require email address verification to prevent this, but in
// practice that is not always the case; this helps protect against that.
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// We end up here when we don't have an account with the same [provider].id *BUT*
// we do already have an account with the same email address as the one in the
// oAuth profile the user has just tried to sign in with.
//
// We don't want to have two accounts with the same email address, and we don't
// want to link them in case it's not safe to do so, so instead we prompt the user
// to sign in via email to verify their identity and then link the accounts.
throw new AccountNotLinkedError()
} else {
// If the current user is not logged in and the profile isn't linked to any user
// accounts (by email or provider account id)...
//
// If no account matching the same [provider].id or .email exists, we can
// create a new account for the user, link it to the oAuth acccount and
// create a new session for them so they are signed in with it.
user = await createUser(profile)
await dispatchEvent(events.createUser, user)
await linkAccount(
user.id,
providerAccount.provider,
providerAccount.type,
providerAccount.id,
providerAccount.refreshToken,
providerAccount.accessToken,
providerAccount.accessTokenExpires
)
await dispatchEvent(events.linkAccount, { user, providerAccount })
session = useJwtSession ? {} : await createSession(user)
isNewUser = true
return {
session,
user,
isNewUser
}
}
}
// If the user is not signed in and it looks like a new OAuth account then we
// check there also isn't an user account already associated with the same
// email address as the one in the OAuth profile.
//
// This step is often overlooked in OAuth implementations, but covers the following cases:
//
// 1. It makes it harder for someone to accidentally create two accounts.
// e.g. by signin in with email, then again with an oauth account connected to the same email.
// 2. It makes it harder to hijack a user account using a 3rd party OAuth account.
// e.g. by creating an oauth account then changing the email address associated with it.
//
// It's quite common for services to automatically link accounts in this case, but it's
// better practice to require the user to sign in *then* link accounts to be sure
// someone is not exploiting a problem with a third party OAuth service.
//
// OAuth providers should require email address verification to prevent this, but in
// practice that is not always the case; this helps protect against that.
const userByEmail = profile.email ? await getUserByEmail(profile.email) : null
if (userByEmail) {
// We end up here when we don't have an account with the same [provider].id *BUT*
// we do already have an account with the same email address as the one in the
// OAuth profile the user has just tried to sign in with.
//
// We don't want to have two accounts with the same email address, and we don't
// want to link them in case it's not safe to do so, so instead we prompt the user
// to sign in via email to verify their identity and then link the accounts.
throw new AccountNotLinkedError()
}
// If the current user is not logged in and the profile isn't linked to any user
// accounts (by email or provider account id)...
//
// If no account matching the same [provider].id or .email exists, we can
// create a new account for the user, link it to the OAuth acccount and
// create a new session for them so they are signed in with it.
user = await createUser(profile)
await dispatchEvent(events.createUser, user)
await linkAccount(
user.id,
providerAccount.provider,
providerAccount.type,
providerAccount.id,
providerAccount.refreshToken,
providerAccount.accessToken,
providerAccount.accessTokenExpires
)
await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount })
session = useJwtSession ? {} : await createSession(user)
isNewUser = true
return {
session,
user,
isNewUser
}
} else {
return Promise.reject(new Error('Provider not supported'))
}
} catch (error) {
return Promise.reject(error)
}
}

View File

@@ -1,18 +1,14 @@
import * as cookie from '../lib/cookie'
import cookie from '../lib/cookie'
/**
* Get callback URL based on query param / cookie + validation,
* and add it to `req.options.callbackUrl`.
* @note: `req.options` must already be defined when called.
*/
export default async function callbackUrlHandler (req, res) {
export default async (req, res, options) => {
const { query } = req
const { body } = req
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = req.options
const { cookies, baseUrl, defaultCallbackUrl, callbacks } = 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
@@ -25,9 +21,7 @@ export default async function callbackUrlHandler (req, res) {
}
// 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) }
req.options.callbackUrl = callbackUrl
return Promise.resolve(callbackUrl)
}

View File

@@ -1,7 +0,0 @@
export interface CallbacksOptions {
signIn?: (user: any, account: any, profile: any) => Promise<never | string>
jwt?: (token: any, user: any, account: any, profile: any, isNewUser?: boolean) => Promise<any>
session?: (session: any, userOrToken: any) => Promise<any>
redirect?: (url: string, baseUrl: string) => Promise<string>
}

View File

@@ -9,14 +9,19 @@
* 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 {Promise<boolean|never>} 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 {boolean|object} Return `true` (or a modified JWT) to allow sign in
* Return `false` to deny access
*/
export async function signIn () {
return true
const signIn = async (profile, account, metadata) => {
const isAllowedToSignIn = true
if (isAllowedToSignIn) {
return Promise.resolve(true)
} else {
return Promise.resolve(false)
}
}
/**
@@ -26,13 +31,12 @@ export async function signIn () {
*
* @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 {Promise<string>} URL the client will be redirect to
* @return {string} URL the client will be redirect to
*/
export async function redirect (url, baseUrl) {
if (url.startsWith(baseUrl)) {
return url
}
return baseUrl
const redirect = async (url, baseUrl) => {
return url.startsWith(baseUrl)
? Promise.resolve(url)
: Promise.resolve(baseUrl)
}
/**
@@ -41,24 +45,31 @@ export async function redirect (url, baseUrl) {
*
* @param {object} session Session object
* @param {object} token JSON Web Token (if enabled)
* @return {Promise<object>} Session that will be returned to the client
* @return {object} Session that will be returned to the client
*/
export async function session (session) {
return session
const session = async (session, token) => {
return Promise.resolve(session)
}
/**
* This callback is called whenever a JSON Web Token is created / updated.
* e.g. On sign in, `getSession()`, `useSession()`, `/api/auth/session` (etc)
*
* On initial sign in, the raw OAuthProfile is passed if the user is signing in
* 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 {Promise<object>} JSON Web Token that will be saved
* @return {object} JSON Web Token that will be saved
*/
export async function jwt (token) {
return token
const jwt = async (token, oAuthProfile) => {
return Promise.resolve(token)
}
export default {
signIn,
redirect,
session,
jwt
}

View File

@@ -1,16 +0,0 @@
export interface CookieOption {
name: string
options: {
httpOnly: boolean
sameSite: string
path?: string
secure: boolean
}
}
export interface CookiesOptions {
sessionToken: CookieOption
callbackUrl: CookieOption
csrfToken: CookieOption
pkceCodeVerifier: CookieOption
}

View File

@@ -1,14 +1,12 @@
/**
* 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 = {}) {
// Function to set cookies server side
//
// Credit to @huv1k and @jshttp contributors for the code which this is based on (MIT License).
// * https://github.com/jshttp/cookie/blob/master/index.js
// * https://github.com/zeit/next.js/blob/master/examples/api-routes-middleware/utils/cookies.js
//
// As only partial functionlity is required, only the code we need has been incorporated here
// (with fixes for specific issues) to keep dependancy size down.
const set = (res, name, value, options = {}) => {
const stringValue = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)
if ('maxAge' in options) {
@@ -101,57 +99,6 @@ function _serialize (name, val, options) {
return str
}
/**
* Use secure cookies if the site uses HTTPS
* This being conditional allows cookies to work non-HTTPS development URLs
* Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
* prefix, but enable them by default if the site URL is HTTPS; but not for
* non-HTTPS URLs like http://localhost which are used in development).
* For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
*
* @TODO Review cookie settings (names, options)
* @return {import("./cookie").CookiesOptions}
*/
export function defaultCookies (useSecureCookies) {
const cookiePrefix = useSecureCookies ? '__Secure-' : ''
return {
// default cookie options
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
options: {
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: useSecureCookies
}
}
}
export default {
set
}

View File

@@ -1,13 +0,0 @@
import { createHash } from 'crypto'
/**
* Secret used salt cookies and tokens (e.g. for CSRF protection).
* If no secret option is specified then it creates one on the fly
* based on options passed here. A options contains unique data, such as
* OAuth provider secrets and database credentials it should be sufficent.
*/
export default function createSecret ({ userOptions, basePath, baseUrl }) {
return userOptions.secret || createHash('sha256').update(JSON.stringify({
baseUrl, basePath, ...userOptions
})).digest('hex')
}

View File

@@ -1,42 +0,0 @@
import { createHash, randomBytes } from 'crypto'
import * as cookie from './cookie'
/**
* Ensure CSRF Token cookie is set for any subsequent requests.
* Used as part of the strateigy for mitigation for CSRF tokens.
*
* Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash',
* where 'token' is the CSRF token and 'hash' is a hash made of the token and
* the secret, and the two values are joined by a pipe '|'. By storing the
* value and the hash of the value (with the secret used as a salt) we can
* verify the cookie was set by the server and not by a malicous attacker.
*
* For more details, see the following OWASP links:
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
* https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
*/
export default function csrfTokenHandler (req, res, cookies, secret) {
const { csrfToken: csrfTokenFromRequest } = req.body
let csrfTokenFromCookie
let csrfTokenVerified = false
if (req.cookies[cookies.csrfToken.name]) {
const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|')
if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) {
// If hash matches then we trust the CSRF token value
csrfTokenFromCookie = csrfTokenValue
// If this is a POST request and the CSRF Token in the Post request matches
// the cookie we have already verified is one we have set, then token is verified!
if (req.method === 'POST' && csrfTokenFromCookie === csrfTokenFromRequest) { csrfTokenVerified = true }
}
}
if (!csrfTokenFromCookie) {
// If no csrfToken - because it's not been set yet, or because the hash doesn't match
// (e.g. because it's been modifed or because the secret has changed) create a new token.
csrfTokenFromCookie = randomBytes(32).toString('hex')
const newCsrfTokenCookie = `${csrfTokenFromCookie}|${createHash('sha256').update(`${csrfTokenFromCookie}${secret}`).digest('hex')}`
cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options)
}
return { csrfToken: csrfTokenFromCookie, csrfTokenVerified }
}

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
export type EventType=
| 'signIn'
| 'signOut'
| 'createUser'
| 'updateUser'
| 'linkAccount'
| 'session'
| 'error'
export type EventCallback = (message: any) => Promise<void>
export type EventsOptions = Partial<Record<EventType, EventCallback>>

38
src/server/lib/events.js Normal file
View File

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

View File

@@ -1,35 +0,0 @@
/**
* Extends res.{end,json,send} with `done()`,
* and redirect to support sending url as json.
*
* When a response is complete, it will call the `done` method,
* so that the serverless function knows when it is
* safe to return and that no more data will be sent.
*/
export default function extendRes (req, res, done) {
const originalResEnd = res.end.bind(res)
res.end = (...args) => {
done()
return originalResEnd(...args)
}
const originalResJson = res.json.bind(res)
res.json = (...args) => {
done()
return originalResJson(...args)
}
const originalResSend = res.send.bind(res)
res.send = (...args) => {
done()
return originalResSend(...args)
}
res.redirect = (url) => {
if (req.body?.json === 'true') {
return res.json({ url })
}
res.status(302).setHeader('Location', url)
return res.end()
}
}

View File

@@ -1,34 +1,50 @@
import { decode as jwtDecode } from 'jsonwebtoken'
import { createHash } from 'crypto'
import querystring from 'querystring'
import jwtDecode from 'jwt-decode'
import oAuthClient from './client'
import logger from '../../../lib/logger'
import { OAuthCallbackError } from '../../../lib/errors'
/** @param {import("../..").NextAuthRequest} req */
export default async function oAuthCallback (req) {
const { provider, pkce } = req.options
// @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
// @TODO Refactor to use jsonwebtoken instead of jwt-decode & remove dependancy
export default async (req, provider, csrfToken, callback) => {
// The "user" object is specific to apple provider and is provided on first sign in
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
let { oauth_token, oauth_verifier, code, user, state } = req.query // eslint-disable-line camelcase
const client = oAuthClient(provider)
if (provider.version?.startsWith('2.')) {
// The "user" object is specific to the Apple provider and is provided on first sign in
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"}
let { code, user } = req.query // eslint-disable-line camelcase
if (provider.version && provider.version.startsWith('2.')) {
// For OAuth 2.0 flows, check state returned and matches expected value
// (a hash of the NextAuth.js CSRF token).
//
// This check can be disabled for providers that do not support it by
// setting `state: false` as a option on the provider (defaults to true).
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'))
}
}
if (req.method === 'POST') {
try {
const body = JSON.parse(JSON.stringify(req.body))
if (body.error) {
throw new Error(body.error)
}
if (body.error) { throw new Error(body.error) }
code = body.code
user = body.user != null ? JSON.parse(body.user) : null
} catch (error) {
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error, req.body, provider.id, code)
throw error
} catch (e) {
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', e, req.body, provider.id, code)
return callback()
}
}
// REVIEW: Is this used by any of the providers?
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
@@ -36,93 +52,105 @@ export default async function oAuthCallback (req) {
client.useAuthorizationHeaderforGET(true)
}
try {
const tokens = await client.getOAuthAccessToken(code, provider, pkce.code_verifier)
let profileData
if (provider.idToken) {
if (!tokens?.id_token) {
throw new OAuthCallbackError('Missing JWT ID Token')
// 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)
}
// Support services that use OpenID ID Tokens to encode profile data
profileData = jwtDecode(tokens.id_token, { json: true })
} else {
profileData = await client.get(provider, tokens.accessToken, tokens)
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()
}
// Support services that use OpenID ID Tokens to encode profile data
_decodeToken(
provider,
accessToken,
refreshToken,
results.id_token,
async (error, profileData) => {
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider, user)
callback(error, profile, account, OAuthProfile)
}
)
} else {
// Use custom get() method for oAuth2 flows
client.get = _get
client.get(
provider,
accessToken,
results,
async (error, profileData) => {
const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider)
callback(error, profile, account, OAuthProfile)
}
)
}
}
return getProfile({ profileData, provider, tokens, user })
} catch (error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, provider.id, code)
throw error
}
}
try {
// Handle OAuth v1.x
const {
oauth_token: oauthToken, oauth_verifier: oauthVerifier
} = req.query
const tokens = await client.getOAuthAccessToken(oauthToken, null, oauthVerifier)
const profileData = await client.get(
provider.profileUrl,
tokens.accessToken,
tokens.refreshToken
)
} 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)
}
return getProfile({ profileData, tokens, provider })
} catch (error) {
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error)
throw error
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)
}
)
}
)
}
}
/**
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
* Returns profile, raw profile and auth provider details
* @param {{
* profileData: object | string
* tokens: {
* accessToken: string
* idToken?: string
* refreshToken?: string
* access_token: string
* expires_in?: string | Date | null
* refresh_token?: string
* id_token?: string
* }
* provider: import("../..").Provider
* user?: object
* }} profileParams
*/
async function getProfile ({ profileData, tokens, provider, user }) {
async function _getProfile (error, profileData, accessToken, refreshToken, provider, userData) {
// @TODO Handle error
if (error) {
logger.error('OAUTH_GET_PROFILE_ERROR', error)
}
let profile = {}
try {
// Convert profileData into an object if it's a string
if (typeof profileData === 'string' || profileData instanceof String) {
profileData = JSON.parse(profileData)
}
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 (user != null) {
profileData.user = user
if (userData != null) {
profileData.user = userData
}
logger.debug('PROFILE_DATA', profileData)
const profile = await provider.profile(profileData, tokens)
// 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,
...tokens
},
OAuthProfile: profileData
}
profile = await provider.profile(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.
@@ -138,4 +166,123 @@ async function getProfile ({ profileData, tokens, provider, user }) {
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
async function _getOAuthAccessToken (code, provider, callback) {
const url = provider.accessTokenUrl
const setGetAccessTokenAuthHeader = (provider.setGetAccessTokenAuthHeader !== null) ? provider.setGetAccessTokenAuthHeader : true
const params = { ...provider.params } || {}
const headers = { ...provider.headers } || {}
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
if (!params[codeParam]) { params[codeParam] = code }
if (!params.client_id) { params.client_id = provider.clientId }
if (!params.client_secret) {
// For some providers it useful to be able to generate the secret on the fly
// e.g. For Sign in With Apple a JWT token using the properties in clientSecret
if (provider.clientSecretCallback) {
params.client_secret = await provider.clientSecretCallback(provider.clientSecret)
} else {
params.client_secret = provider.clientSecret
}
}
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
// Added as a fix to accomodate change in Twitch oAuth API
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
// 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}` }
}
const postData = querystring.stringify(params)
this._request(
'POST',
url,
headers,
postData,
null,
(error, data, response) => {
if (error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
return callback(error)
}
let results
try {
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
// responses should be in JSON
results = JSON.parse(data)
} catch (e) {
// However both Facebook + Github currently use rev05 of the spec and neither
// seem to specify a content-type correctly in their response headers. :(
// Clients of these services suffer a minor performance cost.
results = querystring.parse(data)
}
const accessToken = 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
*
* 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)
// 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)
}

View File

@@ -1,251 +1,31 @@
// @TODO Refactor to remove dependancy on 'oauth' package
// It is already quite monkey patched, we don't use all the features and and it
// would be easier to maintain if all the code was native to next-auth.
import { OAuth, OAuth2 } from 'oauth'
import querystring from 'querystring'
import logger from '../../../lib/logger'
import { sign as jwtSign } from 'jsonwebtoken'
/**
* @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.
* @param {import("../..").Provider} provider
*/
export default function oAuthClient (provider) {
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
const authorizationUrl = new URL(provider.authorizationUrl)
const basePath = authorizationUrl.origin
const authorizePath = authorizationUrl.pathname
export default (provider) => {
if (provider.version && provider.version.startsWith('2.')) {
// Handle oAuth v2.x
const basePath = new URL(provider.authorizationUrl).origin
const authorizePath = new URL(provider.authorizationUrl).pathname
const accessTokenPath = new URL(provider.accessTokenUrl).pathname
const oauth2Client = new OAuth2(
return new OAuth2(
provider.clientId,
provider.clientSecret,
basePath,
authorizePath,
accessTokenPath,
provider.headers
)
oauth2Client.getOAuthAccessToken = getOAuth2AccessToken
oauth2Client.get = getOAuth2
return oauth2Client
}
// Handle OAuth v1.x
const oauth1Client = new OAuth(
provider.requestTokenUrl,
provider.accessTokenUrl,
provider.clientId,
provider.clientSecret,
provider.version || '1.0',
provider.callbackUrl,
provider.encoding || 'HMAC-SHA1'
)
// Promisify get() and getOAuth2AccessToken() for OAuth1
const originalGet = oauth1Client.get.bind(oauth1Client)
oauth1Client.get = (...args) => {
return new Promise((resolve, reject) => {
originalGet(...args, (error, result) => {
if (error) {
return reject(error)
}
resolve(result)
})
})
}
const originalGetOAuth1AccessToken = oauth1Client.getOAuthAccessToken.bind(oauth1Client)
oauth1Client.getOAuthAccessToken = (...args) => {
return new Promise((resolve, reject) => {
originalGetOAuth1AccessToken(...args, (error, accessToken, refreshToken, results) => {
if (error) {
return reject(error)
}
resolve({ accessToken, refreshToken, results })
})
})
}
const originalGetOAuthRequestToken = oauth1Client.getOAuthRequestToken.bind(oauth1Client)
oauth1Client.getOAuthRequestToken = (...args) => {
return new Promise((resolve, reject) => {
originalGetOAuthRequestToken(...args, (error, oauthToken) => {
if (error) {
return reject(error)
}
resolve(oauthToken)
})
})
}
return oauth1Client
}
/**
* @TODO Refactor monkey patching in OAuth2.getOAuthAccessToken() and OAuth2.get()
* These methods have been forked from `node-oauth` to fix bugs; it may make
* sense to migrate all the methods we need from node-oauth to nexth-auth (with
* appropriate credit) to make it easier to maintain and address issues as they
* come up, as the node-oauth package does not seem to be actively maintained.
*/
/**
* Ported from https://github.com/ciaranj/node-oauth/blob/a7f8a1e21c362eb4ed2039431fb9ac2ae749f26a/lib/oauth2.js
* @param {string} code
* @param {import("../..").Provider} provider
* @param {string | undefined} codeVerifier
*/
async function getOAuth2AccessToken (code, provider, codeVerifier) {
const url = provider.accessTokenUrl
const params = { ...provider.params }
const headers = { ...provider.headers }
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'
if (!params[codeParam]) { params[codeParam] = code }
if (!params.client_id) { params.client_id = provider.clientId }
// For Apple the client secret must be generated on-the-fly.
// Using the properties in clientSecret to create a JWT.
if (provider.id === 'apple' && typeof provider.clientSecret === 'object') {
const { keyId, teamId, privateKey } = provider.clientSecret
const clientSecret = jwtSign({
iss: teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
aud: 'https://appleid.apple.com',
sub: provider.clientId
},
// Automatically convert \\n into \n if found in private key. If the key
// is passed in an environment variable \n can get escaped as \\n
privateKey.replace(/\\n/g, '\n'),
{ algorithm: 'ES256', keyid: keyId }
)
params.client_secret = clientSecret
provider.headers)
} else {
params.client_secret = provider.clientSecret
}
if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
if (!headers['Content-Type']) { headers['Content-Type'] = 'application/x-www-form-urlencoded' }
// Added as a fix to accomodate change in Twitch OAuth API
if (!headers['Client-ID']) { headers['Client-ID'] = provider.clientId }
// Added as a fix for Reddit Authentication
if (provider.id === 'reddit') {
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
}
if (provider.id === 'identity-server4' && !headers.Authorization) {
headers.Authorization = `Bearer ${code}`
}
if (provider.protection === 'pkce') {
params.code_verifier = codeVerifier
}
const postData = querystring.stringify(params)
return new Promise((resolve, reject) => {
this._request(
'POST',
url,
headers,
postData,
null,
(error, data, response) => {
if (error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, data, response)
return reject(error)
}
let raw
try {
// As of http://tools.ietf.org/html/draft-ietf-oauth-v2-07
// responses should be in JSON
raw = JSON.parse(data)
} catch {
// However both Facebook + Github currently use rev05 of the spec and neither
// seem to specify a content-type correctly in their response headers. :(
// Clients of these services suffer a minor performance cost.
raw = querystring.parse(data)
}
let accessToken
if (provider.id === 'slack') {
const { ok, error } = raw
if (!ok) {
return reject(error)
}
accessToken = raw.authed_user.access_token
} else {
accessToken = raw.access_token
}
resolve({
accessToken,
accessTokenExpires: null,
refreshToken: raw.refresh_token,
idToken: raw.id_token,
...raw
})
}
// Handle oAuth v1.x
return new OAuth(
provider.requestTokenUrl,
provider.accessTokenUrl,
provider.clientId,
provider.clientSecret,
(provider.version || '1.0'),
provider.callbackUrl,
(provider.encoding || 'HMAC-SHA1')
)
})
}
/**
* 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
* @param {import("../..").Provider} provider
* @param {string} accessToken
* @param {any} results
*/
async function getOAuth2 (provider, accessToken, results) {
let url = provider.profileUrl
const headers = { ...provider.headers }
if (this._useAuthorizationHeaderForGET) {
headers.Authorization = this.buildAuthHeader(accessToken)
// Mail.ru & vk.com require 'access_token' as URL request parameter
if (['mailru', 'vk'].includes(provider.id)) {
const safeAccessTokenURL = new URL(url)
safeAccessTokenURL.searchParams.append('access_token', accessToken)
url = safeAccessTokenURL.href
}
// This line is required for Twitch
if (provider.id === 'twitch') {
headers['Client-ID'] = provider.clientId
}
accessToken = null
}
if (provider.id === 'bungie') {
url = prepareProfileUrl({ provider, url, results })
}
return new Promise((resolve, reject) => {
this._request('GET', url, headers, null, accessToken, (error, profileData) => {
if (error) {
return reject(error)
}
resolve(profileData)
})
})
}
/** Bungie needs special handling */
function prepareProfileUrl ({ provider, url, results }) {
if (!results.membership_id) {
// internal error
// @TODO: handle better
throw new Error('Expected membership_id to be passed.')
}
if (!provider.headers?.['X-API-Key']) {
throw new Error('The Bungie provider requires the X-API-Key option to be present in "headers".')
}
return url.replace('{membershipId}', results.membership_id)
}
}

View File

@@ -1,90 +0,0 @@
import pkceChallenge from 'pkce-challenge'
import * as cookie from '../cookie'
import jwt from '../../../lib/jwt'
import logger from '../../../lib/logger'
import { OAuthCallbackError } from '../../../lib/errors'
const PKCE_LENGTH = 64
const PKCE_CODE_CHALLENGE_METHOD = 'S256' // can be 'plain', not recommended https://tools.ietf.org/html/rfc7636#section-4.2
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
/**
* Adds `code_verifier` to `req.options.pkce`, and removes the corresponding cookie
* @param {import("../..").NextAuthRequest} req
* @param {import("../..").NextAuthResponse} res
*/
export async function handleCallback (req, res) {
const { cookies, provider, baseUrl, basePath } = req.options
try {
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
return
}
if (!(cookies.pkceCodeVerifier.name in req.cookies)) {
throw new OAuthCallbackError('The code_verifier cookie was not found.')
}
const pkce = await jwt.decode({
...req.options.jwt,
token: req.cookies[cookies.pkceCodeVerifier.name],
maxAge: PKCE_MAX_AGE,
encryption: true
})
req.options.pkce = pkce
logger.debug('OAUTH_CALLBACK_PROTECTION', 'Read PKCE verifier from cookie', {
code_verifier: pkce.code_verifier,
pkceLength: PKCE_LENGTH,
method: PKCE_CODE_CHALLENGE_METHOD
})
cookie.set(res, cookies.pkceCodeVerifier.name, null, { maxAge: 0 }) // remove PKCE after it has been used
} catch (error) {
logger.error('CALLBACK_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
}
}
/**
* Adds `code_challenge` and `code_challenge_method` to `req.options.pkce`.
* @param {import("../..").NextAuthRequest} req
* @param {import("../..").NextAuthResponse} res
*/
export async function handleSignin (req, res) {
const { cookies, provider, baseUrl, basePath } = req.options
try {
if (provider.protection !== 'pkce') { // Provider does not support PKCE, nothing to do.
return
}
// Started login flow, add generated pkce to req.options and (encrypted) code_verifier to a cookie
const pkce = pkceChallenge(PKCE_LENGTH)
logger.debug('OAUTH_SIGNIN_PROTECTION', 'Created PKCE challenge/verifier', {
...pkce,
pkceLength: PKCE_LENGTH,
method: PKCE_CODE_CHALLENGE_METHOD
})
provider.authorizationParams = {
...provider.authorizationParams,
code_challenge: pkce.code_challenge,
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD
}
const encryptedCodeVerifier = await jwt.encode({
...req.options.jwt,
maxAge: PKCE_MAX_AGE,
token: { code_verifier: pkce.code_verifier },
encryption: true
})
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + (PKCE_MAX_AGE * 1000))
cookie.set(res, cookies.pkceCodeVerifier.name, encryptedCodeVerifier, {
expires: cookieExpires.toISOString(),
...cookies.pkceCodeVerifier.options
})
logger.debug('OAUTH_SIGNIN_PROTECTION', 'Created PKCE code_verifier saved in cookie')
} catch (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
}
}
export default { handleSignin, handleCallback }

View File

@@ -1,70 +0,0 @@
import { createHash } from 'crypto'
import logger from '../../../lib/logger'
import { OAuthCallbackError } from '../../../lib/errors'
/**
* For OAuth 2.0 flows, if the provider supports state,
* check if state matches the one sent on signin
* (a hash of the NextAuth.js CSRF token).
* @param {import("../..").NextAuthRequest} req
* @param {import("../..").NextAuthResponse} res
*/
export async function handleCallback (req, res) {
const { csrfToken, provider, baseUrl, basePath } = req.options
try {
if (provider.protection !== 'state') { // Provider does not support state, nothing to do.
return
}
const { state } = req.query
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
logger.debug(
'OAUTH_CALLBACK_PROTECTION',
'Comparing received and expected state',
{ state, expectedState }
)
if (state !== expectedState) {
throw new OAuthCallbackError('Invalid state returned from OAuth provider')
}
} catch (error) {
logger.error('STATE_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
}
}
/**
* Adds CSRF token to the authorizationParams.
* @param {import("../..").NextAuthRequest} req
* @param {import("../..").NextAuthResponse} res
*/
export async function handleSignin (req, res) {
const { provider, baseUrl, basePath, csrfToken } = req.options
try {
if (provider.protection !== 'state') { // Provider does not support state, nothing to do.
return
}
if ('state' in provider) {
logger.warn(
'STATE_OPTION_DEPRECATION',
'The `state` provider option is being replaced with `protection`. See the docs.'
)
}
// A hash of the NextAuth.js CSRF token is used as the state
const state = createHash('sha256').update(csrfToken).digest('hex')
provider.authorizationParams = { ...provider.authorizationParams, state }
logger.debug(
'OAUTH_CALLBACK_PROTECTION',
'Added state to authorization params',
{ state }
)
} catch (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`)
}
}
export default { handleSignin, handleCallback }

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