Compare commits

..

67 Commits

Author SHA1 Message Date
Balázs Orbán
29db75ad28 chore(release): bump version [skip ci] 2022-07-25 12:31:40 +02:00
Balázs Orbán
d348ca1dc1 fix: reduce logger.error context 2022-07-25 11:14:49 +02:00
Balázs Orbán
d53e1ea6c4 chore: gitignore v4 files 2022-07-25 11:13:44 +02:00
Balázs Orbán
e701342b1a update package-lock.json 2022-07-05 13:49:23 +02:00
Balázs Orbán
8a133bf5fd fix: don't render email in email's HTML body 2022-07-05 13:47:28 +02:00
Balázs Orbán
35a3ea6620 fix: handle invalid email 2022-07-01 12:42:20 +02:00
Balázs Orbán
289800fbb4 chore: bump version 2022-06-23 12:05:39 +02:00
Sylvain Bellone
28eccc3e64 fix: ReferenceError: defaultCookies is not defined (#4711) 2022-06-23 10:50:28 +02:00
Balázs Orbán
e16bf939a9 chore: bump version 2022-06-20 10:07:04 +02:00
Balázs Orbán
9b078c92b2 fix: don't show error on relative callbackUrl 2022-06-20 10:05:36 +02:00
Balázs Orbán
87f6f576b1 fix: handle invalid callbackUrl 2022-06-10 15:11:41 +02:00
Balázs Orbán
50584bdc4c chore: bump version 2022-04-26 12:22:45 +02:00
Balázs Orbán
b4429235c0 fix: more strict default callback url handling 2022-04-26 12:22:11 +02:00
Balázs Orbán
e1b297d06d fix: update default callbacks.redirect 2022-04-14 11:32:16 +02:00
Balázs Orbán
ab764e3793 chore: bump release 2022-03-15 22:50:13 +01:00
Balázs Orbán
c8941e4b3e fix: remove action from bad request response 2022-03-15 22:45:10 +01:00
Nico Domino
ead715219a fix(deps): update built-in adapter dependencies (#2589)
* fix(deps): update prisma-legacy-adapter and typeorm-legacy-adapter dependencies

* chore: add missing package-lock update
2021-08-23 21:55:33 +02:00
Ashutosh Kumar
8faa7553dd docs: add suggestions for secret and encryption key generation (#2578) 2021-08-21 23:08:56 +02:00
Eduard Babinyan
90a6a0084b feat(provider): return image for Yandex by default (#2563)
Uploading an user image.
2021-08-20 09:37:30 +02:00
Aaron Powell
cb844a2436 docs(provider): remove en-us from Azure urls (#2554)
MS Docs has a lot of local language translations, so it's best to remove locale information from the URLs so that when someone follows them, they land on the right language version of the content.
2021-08-18 09:46:32 +02:00
Sercan Altundas
74558d6cc2 docs(email): remove duplicate CSS property from html (#2546)
- The CSS property 'text-decoration: none;' was duplicated in the example html code and is removed.
2021-08-17 12:17:54 +02:00
Jaye Hackett
d03125a77b docs(ts): mention module augmentation on callbacks (#2541) 2021-08-17 01:01:19 +02:00
Liam Tait
66d16f8bf4 fix(ts): allow scope as string array type (#2511) 2021-08-12 17:51:31 +02:00
Nico Domino
be74dd0e7e docs(security): email contact update (#2467)
* chore(docs): email contact update

* chore(docs): add me@iaincollins.com back
2021-08-02 17:18:17 +02:00
Aryan Beezadhur
9bf867ddcf docs: Update faq.md (#2458) 2021-07-30 22:34:32 +02:00
Nico Domino
0f460c22da docs(client): add text regarding 'logout' (#2432) 2021-07-28 20:10:08 +02:00
Sigurd Heggemsnes
887cb00877 docs(adapter): Typo in filepath for firebase auth in docs. (#2436) 2021-07-28 12:48:47 +02:00
Douglas
75ca097ff7 docs: Fix link to code (#2405) 2021-07-19 15:36:37 +02:00
Nicolas Azari
bcb9383aec docs: fix typos in options.md (#2393)
* Update options.md

* Update www/docs/configuration/options.md

Co-authored-by: Balázs Orbán <info@balazsorban.com>

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-07-17 22:21:45 +02:00
John Michael Kuhn Jr
b953963101 chore(core): fix typo in csrf-token-handler.js where 'strategy' is misspelled (#2391) 2021-07-17 12:02:38 +02:00
Nico Domino
4649f1968b docs(readme): add opencollective details to readme (#2388)
* docs(readme): add opencollective details to readme

* docs(www): add sponsors to docs footer

* docs(readme): move support under ack

* docs(www): dropped docusaurus link in footer
2021-07-16 18:05:15 +02:00
Angelo Annunziata
45f4a69a4e docs(configuration): remove comments in JWT example (#2378) 2021-07-16 09:28:19 +02:00
Prabhdeep Singh
2155c93a3c feat(providers): add OneLogin (#2345)
Co-authored-by: Lluis Agusti <hi@llu.lu>
2021-07-14 11:07:56 +02:00
Angelo Annunziata
d5958571a4 docs(provider): fix typo (#2369) 2021-07-13 21:36:00 +02:00
James Q Quick
ebecaa6a4b docs(adapter): match Fauna index name with implementation(#2360)
* Update Fauna Adapter 

- added one-liner to explain how to use the setup scripts inside of the Fauna dashboard
- updated the `verification_request_by_token` index name to match what is expected inside of the SDK which is `verification_request_by_token_and_identifier`

* Update Typo

Co-authored-by: Balázs Orbán <info@balazsorban.com>

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-07-13 17:58:58 +02:00
Vincent Grafé
1c5173a818 docs(callbacks): fix typo (#2363) 2021-07-13 10:24:05 +02:00
Ben Goshow
35ce332cc6 feat(providers): add Freshbooks (#2322)
Contains the following squashed commits: 

* Create freshbooks.js
* Create freshbooks.md
* Update providers.d.ts
* Update freshbooks.md
* Update src/providers/freshbooks.js
* Update providers.test.ts
* Update freshbooks.md
2021-07-11 20:25:26 +02:00
Imamuzzaki Abu Salam
ec295287f1 docs: delete can word in "can can" (#2348) 2021-07-11 15:08:05 +02:00
Nick Arciero
46978ac02f docs(tutorial): Add link to blog post about integrating with Magic (#2340) 2021-07-10 09:56:13 +02:00
Pol
f546e550dd fix(oauth): correctly remove code_verifier cookie when used (#2325)
Co-authored-by: Pol Bonastre <pbonastre@plainconcepts.com>
2021-07-08 17:24:56 +02:00
Balázs Orbán
ac5b4db0f2 chore: add OpenCollective link to FUNDING.yml 2021-07-05 17:54:34 +02:00
Mahieyin Rahmun
8bbffdd08c docs(github): remove title property (#2308) 2021-07-04 13:23:44 +02:00
Mahieyin Rahmun
a22a0a36fd docs(github): remove title prefix and make reproductions required (#2306) 2021-07-04 11:19:13 +02:00
Mahieyin Rahmun
797272afe1 docs: use issue template forms (#2274)
* (docs) initial issue template forms as per #2271

* (typo) fix grammar and typo

* (forms) make the requested changes

* (chore) delete the old .md files

* (forms) fix type key
2021-07-02 21:13:03 +02:00
Mahieyin Rahmun
13e56bcf2f docs(adapters): update outdated documentation (#2296) 2021-07-02 12:50:27 +02:00
yokinist
b0f7f87c04 docs: update 'pages' option in example code (#2270) 2021-07-01 17:12:01 +02:00
Balázs Orbán
9c0851c0f9 chore(ci): shorten names in release.yml workflow 2021-06-30 21:36:28 +02:00
Andriy Komm
f5b3c29ab1 fix(ts): improve authorize typing on Credentials provider (#2227)
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-06-30 15:49:38 +02:00
Nico Domino
b4f2a0106a chore(ci): add environment approval (#2214)
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-06-30 15:28:12 +02:00
Balázs Orbán
9c095b0532 chore(dev): fix dev app when running locally (#2280)
* fix: fix console warning in dev app

* chore: add `npm i` to `dev:setup` script

* chore(deps): update dev dependencies (react+next)

* chore: update package-lock.json

* chore: use node 16 in actions
2021-06-29 22:11:55 +02:00
Nico Domino
0475964a0f chore(pages): typo in error messages (#2265) 2021-06-28 02:57:35 +02:00
Justin Forlenza
ad6c13cdc9 fix(ts): extend server type in Email provider from nodemailer (#2259)
* Added optional secure & TLS settings for SMTP

* Replaced custom interface with nodemailers

* Fix lockfile version

* Apply suggestions from code review

* Apply suggestions from code review

* Apply suggestions from code review

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-06-27 18:51:34 +02:00
Nico Domino
591aa7cc7e docs(adapter): rm @canary from adapters' install instructions (#2260) 2021-06-27 18:28:58 +02:00
ndom91
9abb392b4e chore: fix gh action typo 2021-06-27 03:39:38 +02:00
ndom91
b89ae87fb1 docs: respect color mode 2021-06-27 03:38:04 +02:00
ndom91
3687d17724 Merge branch 'main' of ssh://github.com/nextauthjs/next-auth 2021-06-27 03:11:07 +02:00
Balázs Orbán
b04ff82fb9 chore: clarify where to run envinfo in bug report template 2021-06-24 01:46:02 +02:00
Balázs Orbán
c11915ba9c chore: update bug report template 2021-06-24 01:44:33 +02:00
Balázs Orbán
24ee459f97 chore(ci): run tests and typechecks only 2021-06-24 00:38:17 +02:00
Balázs Orbán
ac4851d238 chore(ci): run test:ci (linting+test+typecheck) 2021-06-24 00:33:32 +02:00
can-mihci
84094b0ee7 docs(client): fix code block typo (#2217) 2021-06-22 20:11:18 +02:00
Vikrant Bhat
f09ab4a04f docs(providers): fix typo (#2220) 2021-06-22 20:08:43 +02:00
Vikrant Bhat
067364381b docs(providers): fix english sentence in Email provider section (#2222) 2021-06-22 09:28:47 +02:00
ndom91
6ee36b6842 ci: test release environment approval 2021-06-18 20:03:07 +02:00
Sangwon Park
5a89ab69d3 feat(provider): add Naver provider (#2172)
* add Naver provider

* fix typo

* Update src/providers/naver.js

Co-authored-by: Balázs Orbán <info@balazsorban.com>

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-06-16 00:46:41 +02:00
Balázs Orbán
665445818e docs(config): link to next documentation instead of canary 2021-06-12 17:11:53 +02:00
ndom91
67cf2a11bb docs: fix alt client provider example 2021-06-12 16:42:48 +02:00
335 changed files with 51375 additions and 10789 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1,2 +1 @@
/types/ @balazsorban44 @lluia
/__tests__/ @lluia

10
.github/labeler.yml vendored
View File

@@ -2,15 +2,23 @@ test:
- test/**/*
- types/tests/**/*
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:
@@ -21,9 +29,11 @@ style:
client:
- src/client/**/*
- www/docs/getting-started/client.md
pages:
- src/server/pages/**/*
- www/docs/configuration/pages.md
TypeScript:
- types/**/*

View File

@@ -25,7 +25,7 @@ jobs:
- name: Build
run: npm run build
- name: Run tests
run: npm test -- --coverage --verbose
run: npm test -- --coverage --verbose && npm run test:types
- name: Coverage
uses: codecov/codecov-action@v1
with:
@@ -66,21 +66,11 @@ jobs:
node-version: 16
- name: Dependencies
uses: bahmutov/npm-install@v1
- name: Determine version
uses: ./config/version-pr
id: determine-version
env:
PR_NUMBER: ${{ github.event.number }}
- name: Publish to npm
run: |
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> .npmrc
npm run version:pr
npm publish --access public --tag experimental
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Comment version on PR
uses: NejcZdovc/comment-pr@v1
with:
message: "🎉 Experimental release [published on npm](https://www.npmjs.com/package/next-auth/v/${{ env.VERSION }})!\n\n```sh\nnpm i next-auth@${{ env.VERSION }}\n```\n```sh\nyarn add next-auth@${{ env.VERSION }}\n```"
env:
VERSION: ${{ steps.determine-version.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.number }}

31
.gitignore vendored
View File

@@ -20,28 +20,32 @@ node_modules
.next
/build
/dist
/www/build
# Generated files
.docusaurus
.cache-loader
/providers
/src/providers/oauth-types.ts
/client
/css
/lib
/server
/jwt
/react
www/providers.json
src/providers/index.js
/internals
/adapters.d.ts
/adapters.js
/client.d.ts
/client.js
/index.d.ts
/index.js
/jwt.d.ts
/jwt.js
/providers.d.ts
/providers.js
/errors.js
/errors.d.ts
# Development app
app/src/css
app/next-auth
app/dist/css
app/package-lock.json
app/yarn.lock
app/prisma/migrations
app/prisma/dev.db*
# VS
/.vs/slnx.sqlite-journal
@@ -58,3 +62,8 @@ app/prisma/dev.db*
# Tests
/coverage
# v4
packages
apps
docs/providers.json

View File

@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# npx pretty-quick --staged
npx pretty-quick --staged

View File

@@ -25,7 +25,7 @@ Anyone can be a contributor. Either you found a typo, or you have an awesome fea
A quick guide on how to setup _next-auth_ locally to work on it and test out any changes:
The developer application requires you to use `npm@7`.
The dev application requires you to use `npm@7`.
1. Clone the repo:
@@ -34,7 +34,7 @@ git clone git@github.com:nextauthjs/next-auth.git
cd next-auth
```
2. Install packages and set up the developer application:
2. Install packages, set up the dev application:
```sh
npm run dev:setup
@@ -47,13 +47,13 @@ npm run dev:setup
> 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`app/pages/api/auth/[...nextauth].js`.
1. Start the developer application/server:
1. Start the dev application/server:
```sh
npm run dev
```
Your developer application will be available on `http://localhost:3000`
Your dev application will be available on `http://localhost:3000`
That's it! 🎉
@@ -61,9 +61,9 @@ If you need an example project to link to, you can use [next-auth-example](https
#### Hot reloading
When running `npm run dev`, you start a Next.js developer 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.
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.
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered (using API routes). (Improving this through a PR is very welcome!)
> NOTE: When working on CSS, you will have to manually refresh the page after changes. The reason for this is our pages using CSS are server-side rendered. (Improving this through a PR is very welcome!)
> NOTE: The setup is as follows: The development application lives inside the `app` folder, and whenever you make a change to the `src` folder in the root (where next-auth is), it gets copied into `app` every time (gitignored), so Next.js can pick them up and apply hot reloading. This is to avoid some annoying issues with how symlinks are working with different React builds, and also to provide a super-fast feedback loop while developing core features.

View File

@@ -67,15 +67,15 @@ NextAuth.js can be used with or without a database.
### Secure by default
- Promotes the use of passwordless sign-in mechanisms
- Designed to be secure by default and encourage best practices for safeguarding user data
- Uses Cross-Site Request Forgery (CSRF) Tokens on POST routes (sign in, sign out)
- Promotes the use of passwordless sign in mechanisms
- Designed to be secure by default and encourage best practice for safeguarding user data
- Uses Cross Site Request Forgery Tokens on POST routes (sign in, sign out)
- Default cookie policy aims for the most restrictive policy appropriate for each cookie
- When JSON Web Tokens are enabled, they are signed by default (JWS) with HS512
- Use JWT encryption (JWE) by setting the option `encryption: true` (defaults to A256GCM)
- Auto-generates symmetric signing and encryption keys for developer convenience
- Features tab/window syncing and session polling to support short lived sessions
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org)
- Features tab/window syncing and keepalive messages to support short lived sessions
- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org/)
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.
@@ -83,12 +83,13 @@ Advanced options allow you to define your own routines to handle controlling wha
NextAuth.js comes with built-in types. For more information and usage, check out the [TypeScript section](https://next-auth.js.org/getting-started/typescript) in the documentation.
The package at `@types/next-auth` is now deprecated.
## Example
### Add API Route
```javascript
// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth"
import Providers from "next-auth/providers"
@@ -109,18 +110,18 @@ export default NextAuth({
from: "<no-reply@example.com>",
}),
],
// SQL or MongoDB database (or leave empty)
database: process.env.DATABASE_URL,
})
```
### Add React Hook
The `useSession()` React Hook in the NextAuth.js client is the easiest way to check if someone is signed in.
### Add React Component
```javascript
import { useSession, signIn, signOut } from "next-auth/react"
import { useSession, signIn, signOut } from "next-auth/client"
export default function Component() {
const { data: session } = useSession()
const [session, loading] = useSession()
if (session) {
return (
<>
@@ -138,26 +139,7 @@ export default function Component() {
}
```
### Share/configure session state
Use the `<SessionProvider>` to allows instances of `useSession()` to share the session object across components. It also takes care of keeping the session updated and synced between tabs/windows.
```jsx title="pages/_app.js"
import { SessionProvider } from "next-auth/react"
export default function App({
Component,
pageProps: { session, ...pageProps }
}) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
</SessionProvider>
)
}
```
## Acknowledgments
## Acknowledgements
[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors)
@@ -197,13 +179,6 @@ We're happy to announce we've recently created an [OpenCollective](https://openc
<div>Checkly</div><br />
<sub>☁️ Infrastructure Support</sub>
</td>
<td align="center" valign="top">
<a href="https://superblog.ai/" target="_blank">
<img width="128px" src="https://d33wubrfki0l68.cloudfront.net/cdc4a3833bd878933fcc131655878dbf226ac1c5/10cd6/images/logo_bolt_small.png" alt="superblog Logo" />
</a><br />
<div>superblog</div><br />
<sub>☁️ Infrastructure Support</sub>
</td>
</tr><tr></tr>
</tbody>
</table>

View File

@@ -6,27 +6,16 @@ NEXTAUTH_URL=http://localhost:3000
# You can use `openssl rand -hex 32` or
# https://generate-secret.vercel.app/32 to generate a secret.
# Note: Changing a secret may invalidate existing sessions
# and/or verification tokens.
SECRET=secret
# and/or verificaion tokens.
SECRET=
AUTH0_ID=
AUTH0_DOMAIN=
AUTH0_SECRET=
AUTH0_ISSUER=
KEYCLOAK_ID=
KEYCLOAK_SECRET=
KEYCLOAK_ISSUER=
IDS4_ID=
IDS4_SECRET=
IDS4_ISSUER=
GITHUB_ID=
GITHUB_SECRET=
TWITCH_ID=
TWITCH_SECRET=
TWITTER_ID=
TWITTER_SECRET=
@@ -39,4 +28,4 @@ EMAIL_FROM=user@gmail.com
# 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=
DATABASE_URL=

View File

@@ -1 +0,0 @@
16

View File

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

View File

@@ -1,22 +1,22 @@
import Link from "next/link"
import { signIn, signOut, useSession } from "next-auth/react"
import styles from "./header.module.css"
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 { data: session, status } = useSession()
export default function Header () {
const [session, loading] = useSession()
return (
<header>
<noscript>
<style>{".nojs-show { opacity: 1; top: 0; }"}</style>
<style>{'.nojs-show { opacity: 1; top: 0; }'}</style>
</noscript>
<div className={styles.signedInStatus}>
<p
className={`nojs-show ${
!session && status === "loading" ? styles.loading : styles.loaded
!session && loading ? styles.loading : styles.loaded
}`}
>
{!session && (
@@ -25,7 +25,7 @@ export default function Header() {
You are not signed in
</span>
<a
href="/api/auth/signin"
href='/api/auth/signin'
className={styles.buttonPrimary}
onClick={(e) => {
e.preventDefault()
@@ -39,16 +39,18 @@ export default function Header() {
{session && (
<>
{session.user.image && (
<img src={session.user.image} className={styles.avatar} />
<span
style={{ backgroundImage: `url(${session.user.image})` }}
className={styles.avatar}
/>
)}
<span className={styles.signedInText}>
<small>Signed in as</small>
<br />
<strong>{session.user.email} </strong>
{session.user.name ? `(${session.user.name})` : null}
<strong>{session.user.email || session.user.name}</strong>
</span>
<a
href="/api/auth/signout"
href='/api/auth/signout'
className={styles.button}
onClick={(e) => {
e.preventDefault()
@@ -64,42 +66,42 @@ export default function Header() {
<nav>
<ul className={styles.navItems}>
<li className={styles.navItem}>
<Link href="/">
<Link href='/'>
<a>Home</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/client">
<Link href='/client'>
<a>Client</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/server">
<Link href='/server'>
<a>Server</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/protected">
<Link href='/protected'>
<a>Protected</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/protected-ssr">
<Link href='/protected-ssr'>
<a>Protected(SSR)</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/api-example">
<Link href='/api-example'>
<a>API</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/credentials">
<Link href='/credentials'>
<a>Credentials</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/email">
<Link href='/email'>
<a>Email</a>
</Link>
</li>

5
app/jsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"compilerOptions": {
"baseUrl": "."
}
}

4
app/next-env.d.ts vendored
View File

@@ -1,6 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -6,19 +6,14 @@ module.exports = {
...config.resolve,
alias: {
...config.resolve.alias,
react: path.join(process.cwd(), "node_modules/react"),
nodemailer: path.join(process.cwd(), "node_modules/nodemailer"),
"react-dom": path.join(process.cwd(), "node_modules/react-dom"),
"react/jsx-dev-runtime": path.join(
process.cwd(),
"node_modules/react/jsx-dev-runtime"
),
"next-auth$": path.join(process.cwd(), "next-auth/server"),
"next-auth/client$": path.join(process.cwd(), "next-auth/client"),
"next-auth/jwt$": path.join(process.cwd(), "next-auth/lib/jwt"),
"next-auth/adapters": path.join(process.cwd(), "next-auth/adapters"),
"next-auth/providers": path.join(process.cwd(), "next-auth/providers"),
},
}
return config
},
experimental: {
externalDir: true,
},
}

View File

@@ -4,29 +4,22 @@
"description": "NextAuth.js Developer app",
"private": true,
"scripts": {
"clean": "rm -rf .next",
"dev": "npm-run-all --parallel dev:next watch:css copy:css ",
"dev": "npm-run-all --parallel copy:app dev:css dev:next",
"dev:next": "next dev",
"copy:css": "cpx \"../css/**/*\" src/css --watch",
"copy:app": "cpx \"../src/**/*\" next-auth --watch",
"copy:css": "cpx \"../dist/css/**/*\" dist/css --watch",
"watch:css": "cd .. && npm run watch:css",
"start": "next start",
"start:email": "npx fake-smtp-server"
"dev:css": "npm-run-all --parallel watch:css copy:css",
"start": "next start"
},
"license": "ISC",
"dependencies": {
"@next-auth/fauna-adapter": "0.2.2-next.4",
"@next-auth/prisma-adapter": "0.5.2-next.5",
"@prisma/client": "^2.29.1",
"fake-smtp-server": "^0.8.0",
"faunadb": "^4.3.0",
"next": "^11.1.0",
"nodemailer": "^6.6.3",
"next": "^11.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"cpx": "^1.5.0",
"npm-run-all": "^4.1.5",
"prisma": "^2.29.1"
"npm-run-all": "^4.1.5"
}
}

View File

@@ -1,13 +1,31 @@
import { SessionProvider } from "next-auth/react"
import { Provider } from "next-auth/client"
import "./styles.css"
export default function App({
Component,
pageProps: { session, ...pageProps },
}) {
// 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 (
<SessionProvider session={session}>
<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} />
</SessionProvider>
</Provider>
)
}

View File

@@ -0,0 +1,91 @@
import NextAuth from "next-auth"
import EmailProvider from "next-auth/providers/email"
import GitHubProvider from "next-auth/providers/github"
import Auth0Provider from "next-auth/providers/auth0"
import TwitterProvider from "next-auth/providers/twitter"
import CredentialsProvider from "next-auth/providers/credentials"
// import Adapters from 'next-auth/adapters'
// import { PrismaClient } from '@prisma/client'
// const prisma = new PrismaClient()
export default NextAuth({
// Used to debug https://github.com/nextauthjs/next-auth/issues/1664
// cookies: {
// csrfToken: {
// name: 'next-auth.csrf-token',
// options: {
// httpOnly: true,
// sameSite: 'none',
// path: '/',
// secure: true
// }
// },
// pkceCodeVerifier: {
// name: 'next-auth.pkce.code_verifier',
// options: {
// httpOnly: true,
// sameSite: 'none',
// path: '/',
// secure: true
// }
// }
// },
providers: [
EmailProvider({
server: process.env.EMAIL_SERVER,
from: process.env.EMAIL_FROM,
}),
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Auth0Provider({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
domain: process.env.AUTH0_DOMAIN,
// Used to debug https://github.com/nextauthjs/next-auth/issues/1664
// protection: ["pkce", "state"],
// authorizationParams: {
// response_mode: 'form_post'
// }
protection: "pkce",
}),
TwitterProvider({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
}),
CredentialsProvider({
name: "Credentials",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
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
// adapter: Adapters.Prisma.Adapter({ prisma })
})

View File

@@ -1,181 +0,0 @@
import NextAuth from "next-auth"
import EmailProvider from "next-auth/providers/email"
import GitHubProvider from "next-auth/providers/github"
import Auth0Provider from "next-auth/providers/auth0"
import KeycloakProvider from "next-auth/providers/keycloak"
import TwitterProvider from "next-auth/providers/twitter"
import CredentialsProvider from "next-auth/providers/credentials"
import IDS4Provider from "next-auth/providers/identity-server4"
import Twitch from "next-auth/providers/twitch"
import GoogleProvider from "next-auth/providers/google"
import FacebookProvider from "next-auth/providers/facebook"
import FoursquareProvider from "next-auth/providers/foursquare"
// import FreshbooksProvider from "next-auth/providers/freshbooks"
import GitlabProvider from "next-auth/providers/gitlab"
import InstagramProvider from "next-auth/providers/instagram"
import LineProvider from "next-auth/providers/line"
import LinkedInProvider from "next-auth/providers/linkedin"
import MailchimpProvider from "next-auth/providers/mailchimp"
import DiscordProvider from "next-auth/providers/discord"
import AzureADProvider from "next-auth/providers/azure-ad"
import SpotifyProvider from "next-auth/providers/spotify"
import CognitoProvider from "next-auth/providers/cognito"
import SlackProvider from "next-auth/providers/slack"
import Okta from "next-auth/providers/okta"
import AzureB2C from "next-auth/providers/azure-ad-b2c"
// import { PrismaAdapter } from "@next-auth/prisma-adapter"
// import { PrismaClient } from "@prisma/client"
// const prisma = new PrismaClient()
// const adapter = PrismaAdapter(prisma)
// import { Client as FaunaClient } from "faunadb"
// import { FaunaAdapter } from "@next-auth/fauna-adapter"
// const client = new FaunaClient({
// secret: process.env.FAUNA_SECRET,
// domain: process.env.FAUNA_DOMAIN,
// })
// const adapter = FaunaAdapter(client)
export default NextAuth({
// adapter,
providers: [
// E-mail
// Start fake e-mail server with `npm run start:email`
EmailProvider({
server: {
host: "127.0.0.1",
auth: null,
secure: false,
port: 1025,
tls: { rejectUnauthorized: false },
},
}),
// Credentials
CredentialsProvider({
name: "Credentials",
credentials: {
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if (credentials.password === "password") {
return {
name: "Fill Murray",
email: "bill@fillmurray.com",
image: "https://www.fillmurray.com/64/64",
}
}
return null
},
}),
// OAuth 1
TwitterProvider({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
}),
// OAuth 2 / OIDC
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
Auth0Provider({
clientId: process.env.AUTH0_ID,
clientSecret: process.env.AUTH0_SECRET,
issuer: process.env.AUTH0_ISSUER,
}),
KeycloakProvider({
clientId: process.env.KEYCLOAK_ID,
clientSecret: process.env.KEYCLOAK_SECRET,
issuer: process.env.KEYCLOAK_ISSUER,
}),
Twitch({
clientId: process.env.TWITCH_ID,
clientSecret: process.env.TWITCH_SECRET,
}),
GoogleProvider({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
FacebookProvider({
clientId: process.env.FACEBOOK_ID,
clientSecret: process.env.FACEBOOK_SECRET,
}),
FoursquareProvider({
clientId: process.env.FOURSQUARE_ID,
clientSecret: process.env.FOURSQUARE_SECRET,
}),
// FreshbooksProvider({
// clientId: process.env.FRESHBOOKS_ID,
// clientSecret: process.env.FRESHBOOKS_SECRET,
// }),
GitlabProvider({
clientId: process.env.GITLAB_ID,
clientSecret: process.env.GITLAB_SECRET,
}),
InstagramProvider({
clientId: process.env.INSTAGRAM_ID,
clientSecret: process.env.INSTAGRAM_SECRET,
}),
LineProvider({
clientId: process.env.LINE_ID,
clientSecret: process.env.LINE_SECRET,
}),
LinkedInProvider({
clientId: process.env.LINKEDIN_ID,
clientSecret: process.env.LINKEDIN_SECRET,
}),
MailchimpProvider({
clientId: process.env.MAILCHIMP_ID,
clientSecret: process.env.MAILCHIMP_SECRET,
}),
IDS4Provider({
clientId: process.env.IDS4_ID,
clientSecret: process.env.IDS4_SECRET,
issuer: process.env.IDS4_ISSUER,
}),
DiscordProvider({
clientId: process.env.DISCORD_ID,
clientSecret: process.env.DISCORD_SECRET,
}),
AzureADProvider({
clientId: process.env.AZURE_AD_CLIENT_ID,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET,
tenantId: process.env.AZURE_AD_TENANT_ID,
}),
SpotifyProvider({
clientId: process.env.SPOTIFY_ID,
clientSecret: process.env.SPOTIFY_SECRET,
}),
CognitoProvider({
clientId: process.env.COGNITO_ID,
clientSecret: process.env.COGNITO_SECRET,
issuer: process.env.COGNITO_ISSUER,
}),
Okta({
clientId: process.env.OKTA_ID,
clientSecret: process.env.OKTA_SECRET,
issuer: process.env.OKTA_ISSUER,
}),
SlackProvider({
clientId: process.env.SLACK_ID,
clientSecret: process.env.SLACK_SECRET,
}),
AzureB2C({
clientId: process.env.AZURE_B2C_ID,
clientSecret: process.env.AZURE_B2C_SECRET,
tenantId: process.env.AZURE_B2C_TENANT_ID,
primaryUserFlow: process.env.AZURE_B2C_PRIMARY_USER_FLOW,
}),
],
jwt: {
encryption: true,
secret: process.env.SECRET,
},
debug: true,
theme: {
colorScheme: "auto",
logo: "https://next-auth.js.org/img/logo/logo-sm.png",
brandColor: "#1786fb",
},
})

View File

@@ -1,5 +1,5 @@
// This is an example of how to read a JSON Web Token from an API route
import jwt from "next-auth/jwt"
import jwt from 'next-auth/jwt'
const secret = process.env.SECRET

View File

@@ -1,17 +1,12 @@
// This is an example of to protect an API route
import { getSession } from "next-auth/react"
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.",
})
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.",
})
res.send({ error: 'You must be sign in to view the protected content on this page.' })
}
}

View File

@@ -1,5 +1,5 @@
// This is an example of how to access a session from an API route
import { getSession } from "next-auth/react"
import { getSession } from 'next-auth/client'
export default async (req, res) => {
const session = await getSession({ req })

View File

@@ -1,15 +1,15 @@
// eslint-disable-next-line no-use-before-define
import * as React from "react"
import { signIn, signOut, useSession } from "next-auth/react"
import Layout from "components/layout"
import * as React from 'react'
import { signIn, signOut, useSession } from 'next-auth/client'
import Layout from 'components/layout'
export default function Page() {
export default function Page () {
const [response, setResponse] = React.useState(null)
const handleLogin = (options) => async () => {
if (options.redirect) {
return signIn("credentials", options)
return signIn('credentials', options)
}
const response = await signIn("credentials", options)
const response = await signIn('credentials', options)
setResponse(response)
}
@@ -21,22 +21,18 @@ export default function Page() {
setResponse(response)
}
const { data: session } = useSession()
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 />
<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>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
@@ -44,24 +40,14 @@ export default function Page() {
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>
<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>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

View File

@@ -1,11 +1,11 @@
// eslint-disable-next-line no-use-before-define
import * as React from "react"
import { signIn, signOut, useSession } from "next-auth/react"
import Layout from "components/layout"
import * as React from 'react'
import { signIn, signOut, useSession } from 'next-auth/client'
import Layout from 'components/layout'
export default function Page() {
export default function Page () {
const [response, setResponse] = React.useState(null)
const [email, setEmail] = React.useState("")
const [email, setEmail] = React.useState('')
const handleChange = (event) => {
setEmail(event.target.value)
@@ -15,9 +15,9 @@ export default function Page() {
event.preventDefault()
if (options.redirect) {
return signIn("email", options)
return signIn('email', options)
}
const response = await signIn("email", options)
const response = await signIn('email', options)
setResponse(response)
}
@@ -29,22 +29,18 @@ export default function Page() {
setResponse(response)
}
const { data: session } = useSession()
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 />
<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>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
@@ -52,29 +48,20 @@ export default function Page() {
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 />
<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>
<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>
<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>
<pre style={{ background: '#eee', padding: 16 }}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

View File

@@ -1,47 +1,37 @@
// This is an example of how to protect content using server rendering
import { getSession } from "next-auth/react"
import Layout from "../components/layout"
import AccessDenied from "../components/access-denied"
import { getSession } from 'next-auth/client'
import Layout from '../components/layout'
import AccessDenied from '../components/access-denied'
export default function Page({ content, session }) {
export default function Page ({ content, session }) {
// If no session exists, display access denied message
if (!session) {
return (
<Layout>
<AccessDenied />
</Layout>
)
}
if (!session) { return <Layout><AccessDenied /></Layout> }
// If session exists, display content
return (
<Layout>
<h1>Protected Page</h1>
<p>
<strong>{content}</strong>
</p>
<p><strong>{content}</strong></p>
</Layout>
)
}
export async function getServerSideProps(context) {
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 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
}
if (json.content) { content = json.content }
}
return {
props: {
session,
content,
},
content
}
}
}

View File

@@ -1,35 +1,33 @@
import { useState, useEffect } from "react"
import { useSession } from "next-auth/react"
import Layout from "../components/layout"
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 { status } = useSession({
required: true,
})
export default function Page () {
const [session, loading] = useSession()
const [content, setContent] = useState()
// Fetch content from protected route
useEffect(() => {
if (status === "loading") return
const fetchData = async () => {
const res = await fetch("/api/examples/protected")
const res = await fetch('/api/examples/protected')
const json = await res.json()
if (json.content) {
setContent(json.content)
}
if (json.content) { setContent(json.content) }
}
fetchData()
}, [status])
}, [session])
if (status === "loading") return <Layout>Loading...</Layout>
// 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>
<p><strong>{content}</strong></p>
</Layout>
)
}

View File

@@ -1,7 +1,7 @@
import { getSession } from "next-auth/react"
import Layout from "../components/layout"
import { getSession } from 'next-auth/client'
import Layout from '../components/layout'
export default function Page() {
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
@@ -11,31 +11,27 @@ export default function Page() {
<Layout>
<h1>Server Side Rendering</h1>
<p>
This page uses the universal <strong>getSession()</strong> method in{" "}
<strong>getServerSideProps()</strong>.
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.
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.
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.
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) {
export async function getServerSideProps (context) {
return {
props: {
session: await getSession(context),
},
session: await getSession(context)
}
}
}

View File

@@ -1,57 +1,63 @@
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
oauth_token_secret String?
oauth_token String?
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")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
@@index([providerAccountId], name: "providerAccountId")
@@index([providerId], name: "providerId")
@@index([userId], name: "userId")
@@unique([provider, providerAccountId])
@@map(name: "accounts")
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
id Int @default(autoincrement()) @id
userId Int @map(name: "user_id")
expires DateTime
user User @relation(fields: [userId], references: [id])
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 String @id @default(cuid())
id Int @default(autoincrement()) @id
name String?
email String? @unique
emailVerified DateTime?
emailVerified DateTime? @map(name: "email_verified")
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @map(name: "updated_at")
@@map(name: "users")
}
model VerificationToken {
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")
@@unique([identifier, token])
}
@@map(name: "verification_requests")
}

View File

@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"next-auth": [ "../src" ],
"next-auth/*": [ "../src/*" ]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@@ -2,55 +2,32 @@
// https://nextjs.org/docs/getting-started#system-requirements
// https://nextjs.org/docs/basic-features/supported-browsers-features
module.exports = (api) => {
const isTest = api.env("test")
if (isTest) {
return {
module.exports = {
presets: [["@babel/preset-env", { targets: { node: "10.13" } }]],
plugins: [
"@babel/plugin-proposal-optional-catch-binding",
"@babel/plugin-transform-runtime",
],
comments: false,
overrides: [
{
test: ["../src/client/**"],
presets: [["@babel/preset-env", { targets: { ie: "11" } }]],
},
{
test: ["../src/server/pages/**"],
presets: ["preact"],
},
{
test: ["../src/**/*.test.js"],
presets: [
"@babel/preset-env",
["@babel/preset-react", { runtime: "automatic" }],
["@babel/preset-typescript", { isTSX: true, allExtensions: true }],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
],
}
}
return {
presets: [
["@babel/preset-env", { targets: { node: 12 } }],
"@babel/preset-typescript",
],
plugins: [
"@babel/plugin-proposal-optional-catch-binding",
"@babel/plugin-transform-runtime",
],
ignore: [
"../src/**/__tests__/**",
"../src/adapters.ts",
"../src/lib/types.ts",
"../src/providers/oauth-types.ts",
],
comments: false,
overrides: [
{
test: ["../src/react/index.tsx"],
presets: [
["@babel/preset-env", { targets: { ie: 11 } }],
["@babel/preset-react", { runtime: "automatic" }],
],
},
{
test: ["../src/server/pages/*.tsx"],
presets: ["preact"],
plugins: [
[
"jsx-pragmatic",
{
module: "preact",
export: "h",
import: "h",
},
],
],
},
],
}
},
],
}

91
config/build.js Normal file
View File

@@ -0,0 +1,91 @@
const fs = require("fs-extra")
const path = require("path")
const MODULE_ENTRIES = {
SERVER: "index",
CLIENT: "client",
PROVIDERS: "providers",
ADAPTERS: "adapters",
JWT: "jwt",
ERRORS: "errors",
}
// Building submodule entries
const BUILD_TARGETS = {
[`${MODULE_ENTRIES.SERVER}.js`]: "module.exports = require('./dist/server').default\n",
[`${MODULE_ENTRIES.CLIENT}.js`]: "module.exports = require('./dist/client').default\n",
[`${MODULE_ENTRIES.ADAPTERS}.js`]: "module.exports = require('./dist/adapters').default\n",
[`${MODULE_ENTRIES.PROVIDERS}.js`]: "module.exports = require('./dist/providers').default\n",
[`${MODULE_ENTRIES.JWT}.js`]: "module.exports = require('./dist/lib/jwt').default\n",
[`${MODULE_ENTRIES.ERRORS}.js`]: "module.exports = require('./dist/lib/errors').default\n",
}
Object.entries(BUILD_TARGETS).forEach(([target, content]) => {
fs.writeFile(path.join(process.cwd(), target), content, (err) => {
if (err) throw err
console.log(`[build] created "${target}" in root folder`)
})
})
// Building types
const TYPES_TARGETS = [
`${MODULE_ENTRIES.SERVER}.d.ts`,
`${MODULE_ENTRIES.CLIENT}.d.ts`,
`${MODULE_ENTRIES.ADAPTERS}.d.ts`,
`${MODULE_ENTRIES.PROVIDERS}.d.ts`,
`${MODULE_ENTRIES.JWT}.d.ts`,
`${MODULE_ENTRIES.ERRORS}.d.ts`,
"internals",
]
TYPES_TARGETS.forEach((target) => {
fs.copy(
path.resolve("types", target),
path.join(process.cwd(), target),
(err) => {
if (err) throw err
console.log(`[build-types] copying "${target}" to root folder`)
}
)
})
// Building providers
const providersDir = path.join(process.cwd(), "/src/providers")
const files = fs
.readdirSync(providersDir, "utf8")
.filter((file) => file !== "index.js")
let importLines = ""
let exportLines = `export default {\n`
files.forEach((file) => {
const provider = fs.readFileSync(path.join(providersDir, file), "utf8")
try {
// NOTE: If this fails, the default export probably wasn't a named function.
// Always use a named function as default export.
// Eg.: export default function YourProvider ...
const { functionName } = provider.match(
/export default function (?<functionName>.+)\s?\(/
).groups
importLines += `import ${functionName} from "./${file}"\n`
exportLines += ` ${functionName},\n`
} catch (error) {
console.error(
[
`\nThe provider file '${file}' should have a single named default export`,
"Example: 'export default function YourProvider'\n\n",
].join("\n")
)
process.exit(1)
}
})
exportLines += `}\n`
fs.writeFile(
path.join(process.cwd(), "src/providers/index.js"),
[importLines, exportLines].join("\n")
)

View File

@@ -1,18 +0,0 @@
const path = require("path")
const fs = require("fs")
const providersPath = path.join(process.cwd(), "/src/providers")
const files = fs.readdirSync(providersPath, "utf8")
const providers = files.map((file) => {
const strippedProviderName = file.substring(0, file.indexOf("."))
return `"${strippedProviderName}"`
})
const result = `
// THIS FILE IS AUTOGENERATED. DO NOT EDIT.
export type OAuthProviderType =
| ${providers.join("\n | ")}`
fs.writeFileSync(path.join(providersPath, "oauth-types.ts"), result)

View File

@@ -1,3 +1,2 @@
import "regenerator-runtime/runtime"
import "@testing-library/jest-dom"
import "whatwg-fetch"

View File

@@ -1,15 +1,11 @@
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
transform: {
"\\.(js|jsx|ts|tsx)$": [
"babel-jest",
{ configFile: "./config/babel.config.js" },
],
"\\.js$": ["babel-jest", { configFile: "./config/babel.config.js" }],
},
rootDir: "../src",
setupFilesAfterEnv: ["../config/jest-setup.js"],
collectCoverageFrom: ["!client/__tests__/**"],
testMatch: ["**/*.test.js"],
coverageDirectory: "../coverage",
testEnvironment: "jsdom",
}

View File

@@ -1,8 +0,0 @@
name: "Determine version"
description: "Determines npm package version based on PR number and commit SHA"
outputs:
version:
description: "npm package version"
runs:
using: "node12"
main: "index.js"

View File

@@ -1,18 +0,0 @@
const fs = require("fs-extra")
const path = require("path")
const core = require("@actions/core")
try {
const packageJSONPath = path.join(process.cwd(), "package.json")
const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, "utf8"))
const sha8 = process.env.GITHUB_SHA.substr(0, 8)
const prNumber = process.env.PR_NUMBER
const packageVersion = `0.0.0-pr.${prNumber}.${sha8}`
packageJSON.version = packageVersion
core.setOutput("version", packageVersion)
fs.writeFileSync(packageJSONPath, JSON.stringify(packageJSON))
} catch (error) {
core.setFailed(error.message)
}

View File

@@ -5,13 +5,14 @@
// To work around this issue, this script is a manual step that wraps CSS in a
// JavaScript file that has the compiled CSS embedded in it, and exports only
// a function that returns the CSS as a string.
const fs = require("fs")
const path = require("path")
const fs = require('fs')
const path = require('path')
const pathToCss = path.join(__dirname, "../css/index.css")
const css = fs.readFileSync(pathToCss, "utf8")
const pathToCssJs = path.join(__dirname, '../dist/css/index.js')
const pathToCss = path.join(__dirname, '../dist/css/index.css')
const css = fs.readFileSync(pathToCss, 'utf8')
const cssWithEscapedQuotes = css.replace(/"/gm, '\\"')
const js = `module.exports = function() { return "${cssWithEscapedQuotes}" }`
const pathToCssJs = path.join(__dirname, "../css/index.js")
fs.writeFileSync(pathToCssJs, js)

15241
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
{
"name": "next-auth",
"version": "0.0.0-semantically-released",
"version": "3.29.9",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",
"author": "Iain Collins <me@iaincollins.com>",
"main": "index.js",
"module": "index.js",
"types": "index.d.ts",
"types": "./index.d.ts",
"keywords": [
"react",
"nodejs",
@@ -21,102 +20,113 @@
"nextauth"
],
"exports": {
".": "./index.js",
"./jwt": "./jwt/index.js",
"./react": "./react/index.js",
"./providers/*": "./providers/*.js"
".": "./dist/server/index.js",
"./jwt": "./dist/lib/jwt.js",
"./adapters": "./dist/adapters/index.js",
"./client": "./dist/client/index.js",
"./providers": "./dist/providers/index.js",
"./providers/*": "./dist/providers/*.js",
"./errors": "./dist/lib/errors.js"
},
"scripts": {
"build": "npm run build:js && npm run build:css",
"clean": "rm -rf client css lib providers server jwt react index.d.ts index.js adapters.d.ts",
"build:js": "npm run clean && npm run generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"",
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js",
"dev:setup": "npm i && npm run generate-providers && npm run build:css && cd app && npm i",
"build:js": "node ./config/build.js && babel --config-file ./config/babel.config.js src --out-dir dist",
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js",
"dev:setup": "npm i && npm run build:css && cd app && npm i",
"dev": "cd app && npm run dev",
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .",
"watch": "npm run watch:js | npm run watch:css",
"watch:js": "babel --config-file ./config/babel.config.js --watch src --out-dir dist",
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir dist",
"test": "jest --config ./config/jest.config.js",
"test:ci": "npm run lint && npm run test -- --ci",
"test:ci": "npm run lint && npm run test:types && npm run test -- --ci",
"test:types": "dtslint types --onlyTestTsNext",
"prepublishOnly": "npm run build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"version:pr": "node ./config/version-pr",
"generate-providers": "node ./config/generate-providers.js"
"version:pr": "node ./config/version-pr"
},
"files": [
"lib",
"css",
"jwt",
"react",
"providers",
"server",
"index.d.ts",
"dist",
"index.js",
"adapters.d.ts"
"index.d.ts",
"providers.js",
"providers.d.ts",
"adapters.js",
"adapters.d.ts",
"client.js",
"client.d.ts",
"errors.js",
"errors.d.ts",
"jwt.js",
"jwt.d.ts",
"internals"
],
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.14.6",
"futoin-hkdf": "^1.3.3",
"@babel/runtime": "^7.14.0",
"@next-auth/prisma-legacy-adapter": "0.1.2",
"@next-auth/typeorm-legacy-adapter": "0.1.4",
"futoin-hkdf": "^1.3.2",
"jose": "^1.27.2",
"jsonwebtoken": "^8.5.1",
"nodemailer": "^6.4.16",
"oauth": "^0.9.15",
"openid-client": "^4.7.4",
"preact": "^10.5.13",
"preact-render-to-string": "^5.1.19"
"pkce-challenge": "^2.1.0",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.14",
"querystring": "^0.2.0"
},
"peerDependencies": {
"nodemailer": "^6.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"react": "^16.13.1 || ^17",
"react-dom": "^16.13.1 || ^17"
},
"peerDependenciesMeta": {
"nodemailer": {
"optional": true
}
"peerOptionalDependencies": {
"mongodb": "^3.5.9",
"mysql": "^2.18.1",
"mssql": "^6.2.1",
"pg": "^8.2.1",
"@prisma/client": "^2.16.1"
},
"devDependencies": {
"@actions/core": "^1.4.0",
"@babel/cli": "^7.14.5",
"@babel/core": "^7.14.6",
"@babel/plugin-proposal-optional-catch-binding": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.14.5",
"@babel/preset-env": "^7.14.7",
"@babel/preset-react": "^7.14.5",
"@babel/preset-typescript": "^7.15.0",
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^12.0.0",
"@testing-library/react-hooks": "^7.0.1",
"@babel/cli": "^7.8.4",
"@babel/core": "^7.9.6",
"@babel/plugin-proposal-optional-catch-binding": "^7.14.2",
"@babel/plugin-transform-runtime": "^7.13.15",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.13.13",
"@testing-library/jest-dom": "^5.12.0",
"@testing-library/react": "^11.2.6",
"@testing-library/user-event": "^13.1.9",
"@types/nodemailer": "^6.4.4",
"@types/oauth": "^0.9.1",
"@types/react": "^17.0.19",
"@types/react-dom": "^17.0.9",
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"autoprefixer": "^10.2.6",
"babel-jest": "^27.0.5",
"babel-plugin-jsx-pragmatic": "^1.0.2",
"@types/nodemailer": "^6.4.2",
"@types/react": "^17.0.0",
"@typescript-eslint/eslint-plugin": "^4.22.0",
"@typescript-eslint/parser": "^4.22.0",
"autoprefixer": "^9.7.6",
"babel-jest": "^26.6.3",
"babel-preset-preact": "^2.0.0",
"conventional-changelog-conventionalcommits": "4.6.0",
"cssnano": "^5.0.6",
"eslint": "^7.29.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard-with-typescript": "^20.0.0",
"eslint-plugin-import": "^2.23.4",
"conventional-changelog-conventionalcommits": "4.4.0",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"dtslint": "^4.0.8",
"eslint": "^7.19.0",
"eslint-config-prettier": "^8.2.0",
"eslint-config-standard-with-typescript": "^19.0.1",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jest": "^24.3.6",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0",
"fs-extra": "^10.0.0",
"eslint-plugin-promise": "^4.3.1",
"eslint-plugin-standard": "^5.0.0",
"husky": "^6.0.0",
"jest": "^27.0.5",
"msw": "^0.30.0",
"next": "v11.1.3-canary.0",
"postcss-cli": "^8.3.1",
"postcss-nested": "^5.0.5",
"prettier": "^2.3.1",
"pretty-quick": "^3.1.1",
"jest": "^26.6.3",
"msw": "^0.28.2",
"next": "^11.0.1",
"postcss-cli": "^7.1.1",
"postcss-nested": "^4.2.1",
"prettier": "^2.2.1",
"pretty-quick": "^3.1.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.3.5",
"typescript": "^4.1.3",
"whatwg-fetch": "^3.6.2"
},
"prettier": {
@@ -133,25 +143,18 @@
],
"ignorePatterns": [
"node_modules",
"test",
"next-env.d.ts",
"types",
"www",
".next",
"dist",
"/server",
"/react.js"
"dist"
],
"globals": {
"localStorage": "readonly",
"location": "readonly",
"fetch": "readonly"
},
"rules": {
"camelcase": "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/restrict-template-expressions": "off"
},
"overrides": [
{
"files": [

View File

@@ -1,109 +0,0 @@
import { Account, User, Awaitable } from "."
export interface AdapterUser extends User {
id: string
emailVerified: Date | null
}
export interface AdapterSession {
id: string
/** A randomly generated value that is used to get hold of the session. */
sessionToken: string
/** Used to connect the session to a particular user */
userId: string
expires: Date
}
export interface VerificationToken {
identifier: string
expires: Date
token: string
}
/**
* Using a custom adapter you can connect to any database backend or even several different databases.
* Custom adapters created and maintained by our community can be found in the adapters repository.
* Feel free to add a custom adapter from your project to the repository,
* or even become a maintainer of a certain adapter.
* Custom adapters can still be created and used in a project without being added to the repository.
*
* **Required methods**
*
* _(These methods are required for all sign in flows)_
* - `createUser`
* - `getUser`
* - `getUserByEmail`
* - `getUserByAccount`
* - `linkAccount`
* - `createSession`
* - `getSessionAndUser`
* - `updateSession`
* - `deleteSession`
* - `updateUser`
*
* _(Required to support email / passwordless sign in)_
*
* - `createVerificationToken`
* - `useVerificationToken`
*
* **Unimplemented methods**
*
* _(These methods will be required in a future release, but are not yet invoked)_
* - `deleteUser`
* - `unlinkAccount`
*
* [Community adapters](https://github.com/nextauthjs/adapters) |
* [Create a custom adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter)
*/
export interface Adapter {
createUser: (user: Omit<AdapterUser, "id">) => Awaitable<AdapterUser>
getUser: (id: string) => Awaitable<AdapterUser | null>
getUserByEmail: (email: string) => Awaitable<AdapterUser | null>
/** Using the provider id and the id of the user for a specific account, get the user. */
getUserByAccount: (
providerAccountId: Pick<Account, "provider" | "providerAccountId">
) => Awaitable<AdapterUser | null>
updateUser: (user: Partial<AdapterUser>) => Awaitable<AdapterUser>
/** @todo Implement */
deleteUser?: (
userId: string
) => Promise<void> | Awaitable<AdapterUser | null | undefined>
linkAccount: (
account: Account
) => Promise<void> | Awaitable<Account | null | undefined>
/** @todo Implement */
unlinkAccount?: (
providerAccountId: Pick<Account, "provider" | "providerAccountId">
) => Promise<void> | Awaitable<Account | undefined>
/** Creates a session for the user and returns it. */
createSession: (session: {
sessionToken: string
userId: string
expires: Date
}) => Awaitable<AdapterSession>
getSessionAndUser: (
sessionToken: string
) => Awaitable<{ session: AdapterSession; user: AdapterUser } | null>
updateSession: (
session: Partial<AdapterSession> & Pick<AdapterSession, "sessionToken">
) => Awaitable<AdapterSession | null | undefined>
/**
* Deletes a session from the database.
* It is preferred that this method also returns the session
* that is being deleted for logging purposes.
*/
deleteSession: (
sessionToken: string
) => Promise<void> | Awaitable<AdapterSession | null | undefined>
createVerificationToken?: (
verificationToken: VerificationToken
) => Awaitable<VerificationToken | null | undefined>
/**
* Return verification token from the database
* and delete it so it cannot be used again.
*/
useVerificationToken?: (params: {
identifier: string
token: string
}) => Awaitable<VerificationToken | null>
}

View File

@@ -0,0 +1,36 @@
import { UnknownError } from "../lib/errors"
/**
* Handles adapter induced errors.
* @param {import("types/adapters").AdapterInstance} adapter
* @param {import("types").LoggerInstance} logger
* @return {import("types/adapters").AdapterInstance}
*/
export default function adapterErrorHandler(adapter, logger) {
return Object.keys(adapter).reduce((acc, method) => {
const name = capitalize(method)
const code = upperSnake(name, adapter.displayName)
const adapterMethod = adapter[method]
acc[method] = async (...args) => {
try {
logger.debug(code, ...args)
return await adapterMethod(...args)
} catch (error) {
logger.error(`${code}_ERROR`, error)
const e = new UnknownError(error)
e.name = `${name}Error`
throw e
}
}
return acc
}, {})
}
function capitalize(s) {
return `${s[0].toUpperCase()}${s.slice(1)}`
}
function upperSnake(s, prefix = "ADAPTER") {
return `${prefix}_${s.replace(/([A-Z])/g, "_$1")}`.toUpperCase()
}

10
src/adapters/index.js Normal file
View File

@@ -0,0 +1,10 @@
import * as TypeORM from "./typeorm"
import * as Prisma from "./prisma"
export { TypeORM, Prisma }
export default {
Default: TypeORM.Adapter,
TypeORM,
Prisma,
}

6
src/adapters/prisma.js Normal file
View File

@@ -0,0 +1,6 @@
/*
* Source code can be found at:
* https://github.com/nextauthjs/adapters/tree/canary/packages/prisma-legacy
*/
export { PrismaLegacyAdapter as Adapter } from "@next-auth/prisma-legacy-adapter"

9
src/adapters/typeorm.js Normal file
View File

@@ -0,0 +1,9 @@
/*
* Source code can be found at:
* https://github.com/nextauthjs/adapters/tree/canary/packages/typeorm-legacy
*/
export {
TypeORMLegacyAdapter as Adapter,
Models,
} from "@next-auth/typeorm-legacy-adapter"

View File

@@ -1,20 +1,17 @@
import { useState } from "react"
import { rest } from "msw"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockSession } from "./helpers/mocks"
import { printFetchCalls } from "./helpers/utils"
import { SessionProvider, useSession, signOut, getSession } from "../../react"
const origDocumentVisibility = document.visibilityState
const fetchSpy = jest.spyOn(global, "fetch")
import { Provider, useSession } from ".."
import userEvent from "@testing-library/user-event"
beforeAll(() => {
server.listen()
})
afterEach(() => {
jest.clearAllMocks()
server.resetHandlers()
changeTabVisibility(origDocumentVisibility)
fetchSpy.mockClear()
})
afterAll(() => {
@@ -22,167 +19,46 @@ afterAll(() => {
})
test("fetches the session once and re-uses it for different consumers", async () => {
const sessionRouteCall = jest.fn()
server.use(
rest.get("/api/auth/session", (req, res, ctx) => {
sessionRouteCall()
res(ctx.status(200), ctx.json(mockSession))
})
)
render(<ProviderFlow />)
expect(screen.getByTestId("session-1")).toHaveTextContent("loading")
expect(screen.getByTestId("session-2")).toHaveTextContent("loading")
await waitFor(() => {
expect(sessionRouteCall).toHaveBeenCalledTimes(1)
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/session",
expect.anything()
)
const session1 = screen.getByTestId("session-1").textContent
const session2 = screen.getByTestId("session-2").textContent
const session1 = screen.getByTestId("session-consumer-1").textContent
const session2 = screen.getByTestId("session-consumer-2").textContent
expect(session1).toEqual(session2)
})
})
test("when there's an existing session, it won't try to fetch a new one straightaway", async () => {
render(<ProviderFlow session={mockSession} />)
expect(fetchSpy).not.toHaveBeenCalled()
})
test("will refetch the session when the browser tab becomes active again", async () => {
render(<ProviderFlow session={mockSession} />)
expect(fetchSpy).not.toHaveBeenCalled()
// Hide the current tab
changeTabVisibility("hidden")
// Given the current tab got hidden, it should not attempt to re-fetch the session
expect(fetchSpy).not.toHaveBeenCalled()
// Make the tab again visible
changeTabVisibility("visible")
// Given the user made the tab visible again, now attempts to sync and re-fetch the session
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/session",
expect.anything()
)
})
})
test("will refetch the session if told to do so programmatically from another window", async () => {
render(<ProviderFlow session={mockSession} />)
expect(fetchSpy).not.toHaveBeenCalled()
// Hide the current tab
changeTabVisibility("hidden")
// Given the current tab got hidden, it should not attempt to re-fetch the session
expect(fetchSpy).not.toHaveBeenCalled()
// simulate sign-out triggered by another tab
signOut({ redirect: false })
// Given signed out in another tab, it attempts to sync and re-fetch the session
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/session",
expect.anything()
)
// We should have a call to sign-out and a call to refetch the session accordingly
expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(`
Array [
"GET /api/auth/csrf",
"POST /api/auth/signout",
"GET /api/auth/session",
]
`)
})
})
test("allows to customize how often the session will be re-fetched through polling", () => {
jest.useFakeTimers()
render(<ProviderFlow session={mockSession} refetchInterval={1} />)
// we provided a mock session so it shouldn't try to fetch a new one
expect(fetchSpy).not.toHaveBeenCalled()
jest.advanceTimersByTime(1000)
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith("/api/auth/session", expect.anything())
jest.advanceTimersByTime(1000)
// it should have tried to refetch the session, hence counting 2 calls to the session endpoint
expect(fetchSpy).toHaveBeenCalledTimes(2)
expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(`
Array [
"GET /api/auth/session",
"GET /api/auth/session",
]
`)
})
test("allows to customize the URL for session fetching", async () => {
const myPath = "/api/v1/auth"
server.use(
rest.get(`${myPath}/session`, (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSession))
)
)
render(<ProviderFlow session={mockSession} basePath={myPath} />)
// there's an existing session so it should not try to fetch a new one
expect(fetchSpy).not.toHaveBeenCalled()
// force a session refetch across all clients...
getSession()
return waitFor(() => {
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith(
`${myPath}/session`,
expect.anything()
)
})
})
function ProviderFlow(props) {
function ProviderFlow({ options = {} }) {
return (
<SessionProvider {...props}>
<SessionConsumer />
<SessionConsumer testId="2" />
</SessionProvider>
<>
<Provider options={options}>
<SessionConsumer />
<SessionConsumer testId="2" />
</Provider>
</>
)
}
function SessionConsumer({ testId = 1, ...rest }) {
const { data: session, status } = useSession(rest)
function SessionConsumer({ testId = 1 }) {
const [session, loading] = useSession()
if (loading) return <span>loading</span>
return (
<div data-testid={`session-${testId}`}>
{status === "loading" ? "loading" : JSON.stringify(session)}
<div data-testid={`session-consumer-${testId}`}>
{JSON.stringify(session)}
</div>
)
}
function changeTabVisibility(status) {
const visibleStates = ["visible", "hidden"]
if (!visibleStates.includes(status)) return
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: status,
})
document.dispatchEvent(new Event("visibilitychange"))
}

View File

@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockCSRFToken } from "./helpers/mocks"
import logger from "../../lib/logger"
import { getCsrfToken } from "../../react"
import { getCsrfToken } from ".."
import { rest } from "msw"
jest.mock("../../lib/logger", () => ({
@@ -78,10 +78,11 @@ test("when the fetch fails it'll throw a client fetch error", async () => {
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
path: "csrf",
error: new SyntaxError("Unexpected token s in JSON at position 0"),
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"csrf",
new SyntaxError("Unexpected token s in JSON at position 0")
)
})
})

View File

@@ -6,9 +6,3 @@ export function getBroadcastEvents() {
return { eventName, value: rest }
})
}
export function printFetchCalls(mockCalls) {
return mockCalls.map(([path, { method = "GET" }]) => {
return `${method.toUpperCase()} ${path}`
})
}

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockProviders } from "./helpers/mocks"
import { getProviders } from "../../react"
import { getProviders } from ".."
import logger from "../../lib/logger"
import { rest } from "msw"
@@ -56,10 +56,11 @@ test("when failing to fetch the providers, it'll log the error", async () => {
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
path: "providers",
error: new SyntaxError("Unexpected token s in JSON at position 0"),
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"providers",
new SyntaxError("Unexpected token s in JSON at position 0")
)
})
})

View File

@@ -3,7 +3,7 @@ import { rest } from "msw"
import { server, mockSession } from "./helpers/mocks"
import logger from "../../lib/logger"
import { useState, useEffect } from "react"
import { getSession } from "../../react"
import { getSession } from ".."
import { getBroadcastEvents } from "./helpers/utils"
jest.mock("../../lib/logger", () => ({
@@ -70,10 +70,11 @@ test("if there's an error fetching the session, it should log it", async () => {
await waitFor(() => {
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
path: "session",
error: new SyntaxError("Unexpected token S in JSON at position 0"),
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"session",
new SyntaxError("Unexpected token S in JSON at position 0")
)
})
})

View File

@@ -8,7 +8,7 @@ import {
mockEmailResponse,
mockGithubResponse,
} from "./helpers/mocks"
import { signIn } from "../../react"
import { signIn } from ".."
import { rest } from "msw"
const { location } = window
@@ -250,10 +250,11 @@ test("when it fails to fetch the providers, it redirected back to signin page",
expect(window.location.replace).toHaveBeenCalledWith(`/api/auth/error`)
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
error: "Error when retrieving providers",
path: "providers",
})
expect(logger.error).toBeCalledWith(
"CLIENT_FETCH_ERROR",
"providers",
errorMsg
)
})
})

View File

@@ -2,7 +2,7 @@ import { useState } from "react"
import userEvent from "@testing-library/user-event"
import { render, screen, waitFor } from "@testing-library/react"
import { server, mockSignOutResponse } from "./helpers/mocks"
import { signOut } from "../../react"
import { signOut } from ".."
import { rest } from "msw"
import { getBroadcastEvents } from "./helpers/utils"

View File

@@ -1,142 +0,0 @@
import { rest } from "msw"
import { renderHook } from "@testing-library/react-hooks"
import { render, waitFor } from "@testing-library/react"
import { SessionProvider, useSession, signOut } from "../../react"
import { server, mockSession } from "./helpers/mocks"
const origConsoleError = console.error
const origLocation = window.location
const locationReplace = jest.fn()
beforeAll(() => {
// Prevent noise on the terminal... `next-auth` will log to `console.error`
// every time a request fails, which makes the tests output very noisy...
console.error = jest.fn()
// Allows to spy on `window.location.replace`...
delete window.location
window.location = { ...origLocation, replace: locationReplace }
server.listen()
})
afterEach(() => {
server.resetHandlers()
locationReplace.mockClear()
// clear the internal session cache...
signOut({ redirect: false })
})
afterAll(() => {
console.error = origConsoleError
window.location = origLocation
server.close()
})
test("it won't allow to fetch the session in isolation without a session context", () => {
function App() {
useSession()
return null
}
expect(() => render(<App />)).toThrow(
"[next-auth]: `useSession` must be wrapped in a <SessionProvider />"
)
})
test("when fetching the session, there won't be `data` and `status` will be 'loading'", () => {
const { result } = renderHook(() => useSession(), {
wrapper: SessionProvider,
})
expect(result.current.data).toBe(undefined)
expect(result.current.status).toBe("loading")
})
test("when session is fetched, `data` will contain the session data and `status` will be 'authenticated'", async () => {
const { result } = renderHook(() => useSession(), {
wrapper: SessionProvider,
})
await waitFor(() => {
expect(result.current.data).toEqual(mockSession)
expect(result.current.status).toBe("authenticated")
})
})
test("when it fails to fetch the session, `data` will be null and `status` will be 'unauthenticated'", async () => {
server.use(
rest.get(`/api/auth/session`, (req, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const { result } = renderHook(() => useSession(), {
wrapper: SessionProvider,
})
return waitFor(() => {
expect(result.current.data).toEqual(null)
expect(result.current.status).toBe("unauthenticated")
})
})
test("it'll redirect to sign-in page if the session is required and the user is not authenticated", async () => {
server.use(
rest.get(`/api/auth/session`, (req, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const { result } = renderHook(() => useSession({ required: true }), {
wrapper: SessionProvider,
})
await waitFor(() => {
expect(result.current.data).toEqual(null)
expect(result.current.status).toBe("loading")
})
expect(locationReplace).toHaveBeenCalledTimes(1)
expect(locationReplace).toHaveBeenCalledWith(
expect.stringContaining("/api/auth/signin")
)
expect(locationReplace).toHaveBeenCalledWith(
expect.stringContaining(
new URLSearchParams({
error: "SessionRequired",
callbackUrl: window.location.href,
}).toString()
)
)
})
test("will call custom redirect logic if supplied when the user could not authenticate", async () => {
server.use(
rest.get(`/api/auth/session`, (req, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const customRedirect = jest.fn()
const { result } = renderHook(
() => useSession({ required: true, onUnauthenticated: customRedirect }),
{
wrapper: SessionProvider,
}
)
await waitFor(() => {
expect(result.current.data).toEqual(null)
expect(result.current.status).toBe("loading")
})
// it shouldn't have tried to re-direct to sign-in page (default behavior)
expect(locationReplace).not.toHaveBeenCalled()
expect(customRedirect).toHaveBeenCalledTimes(1)
})

418
src/client/index.js Normal file
View File

@@ -0,0 +1,418 @@
// Note about signIn() and signOut() methods:
//
// On signIn() and signOut() we pass 'json: true' to request a response in JSON
// instead of HTTP as redirect URLs on other domains are not returned to
// requests made using the fetch API in the browser, and we need to ask the API
// to return the response as a JSON object (the end point still defaults to
// returning an HTTP response with a redirect for non-JavaScript clients).
//
// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks.
import {
useState,
useEffect,
useContext,
createContext,
createElement,
} from "react"
import _logger, { proxyLogger } from "../lib/logger"
import parseUrl from "../lib/parse-url"
// This behaviour mirrors the default behaviour for getting the site name that
// happens server side in server/index.js
// 1. An empty value is legitimate when the code is being invoked client side as
// relative URLs are valid in that context and so defaults to empty.
// 2. When invoked server side the value is picked up from an environment
// variable and defaults to 'http://localhost:3000'.
/** @type {import("types/internals/client").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,
// Properties starting with _ are used for tracking internal app state
_clientLastSync: 0,
_clientSyncTimer: null,
_eventListenersAdded: false,
_clientSession: undefined,
_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" }))
// 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
)
}
// Context to store session data globally
/** @type {import("types/internals/client").SessionContext} */
const SessionContext = createContext()
export function useSession(session) {
const context = useContext(SessionContext)
if (context) return context
return _useSessionHook(session)
}
function _useSessionHook(session) {
const [data, setData] = useState(session)
const [loading, setLoading] = useState(!data)
useEffect(() => {
__NEXTAUTH._getSession = async ({ event = null } = {}) => {
try {
const triggredByEvent = event !== null
const triggeredByStorageEvent = event === "storage"
const clientMaxAge = __NEXTAUTH.clientMaxAge
const clientLastSync = parseInt(__NEXTAUTH._clientLastSync)
const currentTime = _now()
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 (clientMaxAge === 0 && triggredByEvent !== true) {
// If there is no time defined for when a session should be considered
// stale, then it's okay to use the value we have until an event is
// triggered which updates it.
return
} else if (clientMaxAge > 0 && clientSession === null) {
// If the client doesn't have a session then we don't need to call
// the server to check if it does (if they have signed in via another
// tab or window that will come through as a triggeredByStorageEvent
// event and will skip this logic)
return
} else if (
clientMaxAge > 0 &&
currentTime < clientLastSync + clientMaxAge
) {
// If the session freshness is within clientMaxAge then don't request
// it again on this call (avoids too many invokations).
return
}
}
if (clientSession === undefined) {
__NEXTAUTH._clientSession = null
}
// Update clientLastSync before making response to avoid repeated
// invokations that would otherwise be triggered while we are still
// waiting for a response.
__NEXTAUTH._clientLastSync = _now()
// 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,
})
// Save session state internally, just so we can track that we've checked
// if a session exists at least once.
__NEXTAUTH._clientSession = newClientSessionData
setData(newClientSessionData)
setLoading(false)
} catch (error) {
logger.error("CLIENT_USE_SESSION_ERROR", error)
setLoading(false)
}
}
__NEXTAUTH._getSession()
})
return [data, loading]
}
export async function getSession(ctx) {
const session = await _fetchData("session", ctx)
if (ctx?.triggerEvent ?? true) {
broadcast.post({ event: "session", data: { trigger: "getSession" } })
}
return session
}
export async function getCsrfToken(ctx) {
return (await _fetchData("csrf", ctx))?.csrfToken
}
export async function getProviders() {
return await _fetchData("providers")
}
export async function signIn(provider, options = {}, authorizationParams = {}) {
const { callbackUrl = window.location.href, redirect = true } = options
const baseUrl = _apiBaseUrl()
const providers = await getProviders()
if (!providers) {
return window.location.replace(`${baseUrl}/error`)
}
if (!(provider in providers)) {
return window.location.replace(
`${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
)
}
const isCredentials = providers[provider].type === "credentials"
const isEmail = providers[provider].type === "email"
const isSupportingReturn = isCredentials || isEmail
const signInUrl = isCredentials
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
const res = await fetch(_signInUrl, {
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
})
const data = await res.json()
if (redirect || !isSupportingReturn) {
const url = data.url ?? callbackUrl
window.location.replace(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,
}
}
export async function signOut(options = {}) {
const { callbackUrl = window.location.href, redirect = true } = options
const baseUrl = _apiBaseUrl()
const fetchOptions = {
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
csrfToken: await getCsrfToken(),
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.replace(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
}
// 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.
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)
}
}
export function Provider({ children, session, options }) {
setOptions(options)
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 } = {}) {
try {
const baseUrl = await _apiBaseUrl()
const options = req ? { headers: { cookie: req.headers.cookie } } : {}
const res = await fetch(`${baseUrl}/${path}`, options)
const data = await res.json()
if (!res.ok) throw data
return Object.keys(data).length > 0 ? data : null // Return null if data empty
} catch (error) {
logger.error("CLIENT_FETCH_ERROR", path, error)
return null
}
}
function _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")
}
// Return absolute path when called server side
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
}
// 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)
}
/**
* 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("types/internals/client").BroadcastMessage) => void} onReceive
*/
receive(onReceive) {
if (typeof window === "undefined") return
window.addEventListener("storage", async (event) => {
if (event.key !== name) return
/** @type {import("types/internals/client").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() })
)
},
}
}
// Some methods are exported with more than one name. This provides some
// flexibility over how they can be invoked and backwards compatibility
// with earlier releases. These should be removed in a newer release, as it only
// creates problems for bundlers and adds confusion to users. TypeScript declarations
// will provide sufficient help when importing
export {
setOptions as options,
getSession as session,
getProviders as providers,
getCsrfToken as csrfToken,
signIn as signin,
signOut as signout,
}
export default {
getSession,
getCsrfToken,
getProviders,
useSession,
signIn,
signOut,
Provider,
/* Deprecated / unsupported features below this line */
// Use setOptions() set options globally in the app.
setOptions,
// Some methods are exported with more than one name. This provides some
// flexibility over how they can be invoked and backwards compatibility
// with earlier releases.
options: setOptions,
session: getSession,
providers: getProviders,
csrfToken: getCsrfToken,
signin: signIn,
signout: signOut,
}

View File

@@ -216,6 +216,7 @@ a.site {
>div,
form {
display: block;
margin: 0 auto 0.5rem auto;
input[type] {
margin-bottom: 0.5rem;
@@ -227,31 +228,4 @@ a.site {
max-width: 300px;
}
}
.signout {
.message {
margin-bottom: 1.5rem;
}
}
.logo {
margin-top: 100px;
max-width: 300px;
max-height: 150px;
}
.card {
max-width: max-content;
border: 1px solid var(--color-control-border);
border-radius: 5px;
padding: 20px 50px;
margin: 50px auto;
.header {
color: var(--color-primary);
}
}
.section-header {
color: var(--brand-color);
}
}

View File

@@ -1,11 +1,11 @@
// To support serverless targets (which don't work if you try to read in things
// like CSS files at run time) this file is replaced in production builds with
// a function that returns compiled CSS (embedded as a string in the function).
import fs from "fs"
import path from "path"
import fs from 'fs'
import path from 'path'
const pathToCss = path.join(process.cwd(), "/src/css/index.css")
const pathToCss = path.join(process.cwd(), '/dist/css/index.css')
export default function css() {
return fs.readFileSync(pathToCss, "utf8")
export default function css () {
return fs.readFileSync(pathToCss, 'utf8')
}

View File

@@ -1,2 +0,0 @@
export { default } from "./server"
export * from "./server/types"

View File

@@ -1,106 +0,0 @@
import type { IncomingMessage } from "http"
import type { LoggerInstance, Session } from ".."
export interface NextAuthClientConfig {
baseUrl: string
basePath: string
baseUrlServer: string
basePathServer: string
/** Stores last session response */
_session?: Session | null | undefined
/** Used for timestamp since last sycned (in seconds) */
_lastSync: number
/**
* Stores the `SessionProvider`'s session update method to be able to
* trigger session updates from places like `signIn` or `signOut`
*/
_getSession: (...args: any[]) => any
}
export interface CtxOrReq {
req?: IncomingMessage
ctx?: { req: IncomingMessage }
}
/**
* 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.
*/
export async function fetchData<T = any>(
path: string,
__NEXTAUTH: NextAuthClientConfig,
logger: LoggerInstance,
{ ctx, req = ctx?.req }: CtxOrReq = {}
): Promise<T | null> {
try {
const options = req?.headers.cookie
? { headers: { cookie: req.headers.cookie } }
: {}
const res = await fetch(`${apiBaseUrl(__NEXTAUTH)}/${path}`, options)
const data = await res.json()
if (!res.ok) throw data
return Object.keys(data).length > 0 ? data : null // Return null if data empty
} catch (error) {
logger.error("CLIENT_FETCH_ERROR", {
error,
path,
...(req ? { header: req.headers } : {}),
})
return null
}
}
export function apiBaseUrl(__NEXTAUTH: NextAuthClientConfig) {
if (typeof window === "undefined") {
// Return absolute path when called server side
return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}`
}
// Return relative path when called client side
return __NEXTAUTH.basePath
}
/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */
export function now() {
return Math.floor(Date.now() / 1000)
}
export interface BroadcastMessage {
event?: "session"
data?: { trigger?: "signout" | "getSession" }
clientId: string
timestamp: number
}
/**
* 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
*/
export function BroadcastChannel(name = "nextauth.message") {
return {
/** Get notified by other tabs/windows. */
receive(onReceive: (message: BroadcastMessage) => void) {
const handler = (event) => {
if (event.key !== name) return
const message: BroadcastMessage = JSON.parse(event.newValue)
if (message?.event !== "session" || !message?.data) return
onReceive(message)
}
window.addEventListener("storage", handler)
return () => window.removeEventListener("storage", handler)
},
/** Notify other tabs/windows. */
post(message) {
if (typeof window === "undefined") return
localStorage.setItem(
name,
JSON.stringify({ ...message, timestamp: now() })
)
},
}
}

98
src/lib/errors.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* Same as the default `Error`, but it is JSON serializable.
* @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af
*/
export class UnknownError extends Error {
constructor(error) {
// Support passing error or string
super(error?.message ?? error)
this.name = "UnknownError"
if (error instanceof Error) {
this.stack = error.stack
}
}
toJSON() {
return {
name: this.name,
message: this.message,
stack: this.stack,
}
}
}
export class OAuthCallbackError extends UnknownError {
name = "OAuthCallbackError"
}
/**
* 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 {
name = "AccountNotLinkedError"
}
export class CreateUserError extends UnknownError {
name = "CreateUserError"
}
export class GetUserError extends UnknownError {
name = "GetUserError"
}
export class GetUserByEmailError extends UnknownError {
name = "GetUserByEmailError"
}
export class GetUserByIdError extends UnknownError {
name = "GetUserByIdError"
}
export class GetUserByProviderAccountIdError extends UnknownError {
name = "GetUserByProviderAccountIdError"
}
export class UpdateUserError extends UnknownError {
name = "UpdateUserError"
}
export class DeleteUserError extends UnknownError {
name = "DeleteUserError"
}
export class LinkAccountError extends UnknownError {
name = "LinkAccountError"
}
export class UnlinkAccountError extends UnknownError {
name = "UnlinkAccountError"
}
export class CreateSessionError extends UnknownError {
name = "CreateSessionError"
}
export class GetSessionError extends UnknownError {
name = "GetSessionError"
}
export class UpdateSessionError extends UnknownError {
name = "UpdateSessionError"
}
export class DeleteSessionError extends UnknownError {
name = "DeleteSessionError"
}
export class CreateVerificationRequestError extends UnknownError {
name = "CreateVerificationRequestError"
}
export class GetVerificationRequestError extends UnknownError {
name = "GetVerificationRequestError"
}
export class DeleteVerificationRequestError extends UnknownError {
name = "DeleteVerificationRequestError"
}

View File

@@ -1,10 +1,6 @@
import crypto from "crypto"
import jose from "jose"
import logger from "../lib/logger"
import { NextApiRequest } from "next"
import type { JWT, JWTDecodeParams, JWTEncodeParams } from "./types"
export * from "./types"
import logger from "./logger"
// Set default algorithm to use for auto-generated signing key
const DEFAULT_SIGNATURE_ALGORITHM = "HS512"
@@ -32,7 +28,7 @@ export async function encode({
zip: "DEF",
},
encryption = DEFAULT_ENCRYPTION_ENABLED,
}: JWTEncodeParams) {
} = {}) {
// Signing Key
const _signingKey = signingKey
? jose.JWK.asKey(JSON.parse(signingKey))
@@ -69,7 +65,7 @@ export async function decode({
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM],
},
encryption = DEFAULT_ENCRYPTION_ENABLED,
}: JWTDecodeParams): Promise<JWT | null> {
} = {}) {
if (!token) return null
let tokenToVerify = token
@@ -95,26 +91,19 @@ export async function decode({
: getDerivedSigningKey(secret)
// Verify token
return jose.JWT.verify(
tokenToVerify,
_signingKey,
verificationOptions
) as JWT | null
return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions)
}
export type GetTokenParams<R extends boolean = false> = {
req: NextApiRequest
secureCookie?: boolean
cookieName?: string
raw?: R
decode?: typeof decode
secret?: string
} & Omit<JWTDecodeParams, "secret">
/** [Documentation](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken) */
export async function getToken<R extends boolean = false>(
params?: GetTokenParams<R>
): Promise<R extends true ? string : JWT | null> {
/**
* Server-side method to retrieve the JWT from `req`.
* @param {{
* req: NextApiRequest
* secureCookie?: boolean
* cookieName?: string
* raw?: boolean
* }} params
*/
export async function getToken(params) {
const {
req,
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
@@ -128,7 +117,7 @@ export async function getToken<R extends boolean = false>(
: "next-auth.session-token",
raw = false,
decode: _decode = decode,
} = params ?? {}
} = params
if (!req) throw new Error("Must pass `req` to JWT getToken()")
// Try to get token from cookie
@@ -143,15 +132,12 @@ export async function getToken<R extends boolean = false>(
}
if (raw) {
// @ts-expect-error
return token
}
try {
// @ts-expect-error
return await _decode({ token, ...params })
return _decode({ token, ...params })
} catch {
// @ts-expect-error
return null
}
}
@@ -173,7 +159,6 @@ function hkdf(secret, { byteLength, encryptionInfo, digest = "sha256" }) {
)
)
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("futoin-hkdf")(secret, byteLength, {
info: encryptionInfo,
hash: digest,
@@ -215,3 +200,9 @@ function getDerivedEncryptionKey(secret) {
})
return key
}
export default {
encode,
decode,
getToken,
}

81
src/lib/logger.js Normal file
View File

@@ -0,0 +1,81 @@
/** @type {import("types").LoggerInstance} */
const _logger = {
error(code, ...message) {
console.error(
`[next-auth][error][${code.toLowerCase()}]`,
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
...message
)
},
warn(code, ...message) {
console.warn(
`[next-auth][warn][${code.toLowerCase()}]`,
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`,
...message
)
},
debug(code, ...message) {
if (!process?.env?._NEXTAUTH_DEBUG) return
console.log(`[next-auth][debug][${code.toLowerCase()}]`, ...message)
},
}
/**
* Override the built-in logger.
* Any `undefined` level will use the default logger.
* @param {Partial<import("types").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("types").LoggerInstance} logger
* @param {string} basePath
* @return {import("types").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
}
}

View File

@@ -1,104 +0,0 @@
import { UnknownError } from "../server/errors"
/** Makes sure that error is always serializable */
function formatError(o) {
if (o instanceof Error && !(o instanceof UnknownError)) {
return { message: o.message, stack: o.stack, name: o.name }
}
if (o?.error) {
o.error = formatError(o.error)
o.message = o.message ?? o.error.message
}
return o
}
/**
* Override any of the methods, and the rest will use the default logger.
*
* [Documentation](https://next-auth.js.org/configuration/options#logger)
*/
export interface LoggerInstance {
warn: (
code:
| "JWT_AUTO_GENERATED_SIGNING_KEY"
| "JWT_AUTO_GENERATED_ENCRYPTION_KEY"
| "NEXTAUTH_URL"
) => void
error: (
code: string,
/**
* Either an instance of (JSON serializable) Error
* or an object that contains some debug information.
* (Error is still available through `metadata.error`)
*/
metadata: Error | { error: Error; [key: string]: unknown }
) => void
debug: (code: string, metadata: unknown) => void
}
const _logger: LoggerInstance = {
error(code, metadata) {
metadata = formatError(metadata)
console.error(
`[next-auth][error][${code}]`,
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
metadata.message,
metadata
)
},
warn(code) {
console.warn(
`[next-auth][warn][${code}]`,
`\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}`
)
},
debug(code, metadata) {
if (!process?.env?._NEXTAUTH_DEBUG) return
console.log(`[next-auth][debug][${code}]`, metadata)
},
}
/**
* Override the built-in logger.
* Any `undefined` level will use the default logger.
*/
export function setLogger(newLogger: Partial<LoggerInstance> = {}) {
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 */
export function proxyLogger(
logger: LoggerInstance = _logger,
basePath?: string
): LoggerInstance {
try {
if (typeof window === "undefined") {
return logger
}
const clientLogger = {}
for (const level in logger) {
clientLogger[level] = (code, metadata) => {
_logger[level](code, metadata) // Logs to console
if (level === "error") {
metadata = formatError(metadata)
}
metadata.client = true
const url = `${basePath}/_log`
const body = new URLSearchParams({ level, code, ...metadata })
if (navigator.sendBeacon) {
return navigator.sendBeacon(url, body)
}
return fetch(url, { method: "POST", body, keepalive: true })
}
}
return clientLogger as LoggerInstance
} catch {
return _logger
}
}

View File

@@ -1,25 +0,0 @@
// Source: https://stackoverflow.com/a/34749873/5364135
/** Simple object check */
function isObject(item: any): boolean {
return item && typeof item === "object" && !Array.isArray(item)
}
/** Deep merge two objects */
export function merge(target: any, ...sources: any[]) {
if (!sources.length) return target
const source = sources.shift()
if (isObject(target) && isObject(source)) {
for (const key in source) {
if (isObject(source[key])) {
if (!target[key]) Object.assign(target, { [key]: {} })
merge(target[key], source[key])
} else {
Object.assign(target, { [key]: source[key] })
}
}
}
return merge(target, ...sources)
}

View File

@@ -1,29 +1,27 @@
/**
* Simple universal (client/server) function to split host and path.
* 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.
* @todo Use `URL` instead of custom parsing. (Remember: `protocol` is not standard)
* @param {string} url
*/
export default function parseUrl(url?: string) {
export default function parseUrl (url) {
// Default values
const defaultHost = "http://localhost:3000"
const defaultPath = "/api/auth"
const defaultHost = 'http://localhost:3000'
const defaultPath = '/api/auth'
if (!url) {
url = `${defaultHost}${defaultPath}`
}
if (!url) { url = `${defaultHost}${defaultPath}` }
// Default to HTTPS if no protocol explictly specified
const protocol = url.startsWith("http:") ? "http" : "https"
const protocol = url.startsWith('http:') ? 'http' : 'https'
// Normalize URLs by stripping protocol and no trailing slash
url = url.replace(/^https?:\/\//, "").replace(/\/$/, "")
url = url.replace(/^https?:\/\//, '').replace(/\/$/, '')
// Simple split based on first /
const [_host, ..._path] = url.split("/")
const [_host, ..._path] = url.split('/')
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
const basePath = _path.length > 0 ? `/${_path.join("/")}` : defaultPath
const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath
return { baseUrl, basePath }
}

View File

@@ -1,90 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next"
import type {
CallbacksOptions,
CookiesOptions,
EventCallbacks,
LoggerInstance,
PagesOptions,
SessionOptions,
Theme,
Awaitable,
} from ".."
import type {
OAuthConfig,
EmailConfig,
CredentialsConfig,
AuthorizationEndpointHandler,
TokenEndpointHandler,
UserinfoEndpointHandler,
ProviderType,
} from "../providers"
import type { JWTOptions } from "../jwt"
import type { Adapter } from "../adapters"
// Below are types that are only supposed be used by next-auth internally
/** @internal */
export type InternalProvider<T extends ProviderType = any> = (T extends "oauth"
? Omit<OAuthConfig<any>, "authorization" | "token" | "userinfo"> & {
authorization: AuthorizationEndpointHandler
token: TokenEndpointHandler
userinfo: UserinfoEndpointHandler
}
: T extends "email"
? EmailConfig
: T extends "credentials"
? CredentialsConfig
: never) & {
signinUrl: string
callbackUrl: string
}
/** @internal */
export interface InternalOptions<T extends ProviderType = any> {
providers: InternalProvider[]
baseUrl: string
basePath: string
action:
| "providers"
| "session"
| "csrf"
| "signin"
| "signout"
| "callback"
| "verify-request"
| "error"
provider: T extends string
? InternalProvider<T>
: InternalProvider<T> | undefined
csrfToken?: string
csrfTokenVerified?: boolean
secret: string
theme: Theme
debug: boolean
logger: LoggerInstance
session: Required<SessionOptions>
pages: Partial<PagesOptions>
jwt: JWTOptions
events: Partial<EventCallbacks>
adapter?: Adapter
callbacks: CallbacksOptions
cookies: CookiesOptions
callbackUrl: string
}
/** @internal */
export interface NextAuthRequest extends NextApiRequest {
options: InternalOptions
}
/** @internal */
export type NextAuthResponse<T = any> = NextApiResponse<T>
/** @internal */
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
export type NextAuthApiHandler<Result = void, Response = any> = (
req: NextAuthRequest,
res: NextAuthResponse<Response>
) => Awaitable<Result>

View File

@@ -1,20 +1,20 @@
/** @type {import(".").OAuthProvider} */
export default function FortyTwo(options) {
return {
id: "42-school",
name: "42 School",
type: "oauth",
authorization: "https://api.intra.42.fr/oauth/authorize",
token: "https://api.intra.42.fr/oauth/token",
userinfo: "https://api.intra.42.fr/v2/me",
profile(profile) {
return {
id: profile.id,
name: profile.usual_full_name,
email: profile.email,
image: profile.image_url,
}
},
options,
id: '42-school',
name: '42 School',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.intra.42.fr/oauth/token',
authorizationUrl:
'https://api.intra.42.fr/oauth/authorize?response_type=code',
profileUrl: 'https://api.intra.42.fr/v2/me',
profile: (profile) => ({
id: profile.id,
email: profile.email,
image: profile.image_url,
name: profile.usual_full_name,
}),
...options,
}
}

View File

@@ -1,37 +1,34 @@
/** @type {import(".").OAuthProvider} */
export default function Apple(options) {
return {
id: "apple",
name: "Apple",
type: "oauth",
authorization: {
url: "https://appleid.apple.com/auth/authorize",
params: {
scope: "name email",
response_type: "code",
id_token: "",
response_mode: "form_post",
},
},
token: {
url: "https://appleid.apple.com/auth/token",
idToken: true,
},
jwks_endpoint: "https://appleid.apple.com/auth/keys",
version: "2.0",
scope: "name email",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://appleid.apple.com/auth/token",
authorizationUrl:
"https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post",
profileUrl: null,
idToken: true,
profile(profile) {
// The name of the user will only be returned on first login
const name = profile.user
? profile.user.name.firstName + " " + profile.user.name.lastName
: null
// The name of the user will only return on first login
return {
id: profile.sub,
name,
name:
profile.user != null
? profile.user.name.firstName + " " + profile.user.name.lastName
: null,
email: profile.email,
image: null,
}
},
checks: ["none"], // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
options,
clientId: null,
clientSecret: {
teamId: null,
privateKey: null,
keyId: null,
},
protection: "none", // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
...options,
}
}

View File

@@ -1,18 +1,16 @@
/** @type {import(".").OAuthProvider} */
export default function Atlassian(options) {
return {
id: "atlassian",
name: "Atlassian",
type: "oauth",
authorization: {
url: "https://auth.atlassian.com/oauth/authorize",
params: {
audience: "api.atlassian.com",
prompt: "consent",
},
version: "2.0",
params: {
grant_type: "authorization_code",
},
token: "https://auth.atlassian.com/oauth/token",
userinfo: "https://api.atlassian.com/me",
accessTokenUrl: "https://auth.atlassian.com/oauth/token",
authorizationUrl:
"https://auth.atlassian.com/authorize?audience=api.atlassian.com&response_type=code&prompt=consent",
profileUrl: "https://api.atlassian.com/me",
profile(profile) {
return {
id: profile.account_id,
@@ -21,6 +19,6 @@ export default function Atlassian(options) {
image: profile.picture,
}
},
options,
...options,
}
}

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

@@ -0,0 +1,22 @@
export default function Auth0(options) {
return {
id: "auth0",
name: "Auth0",
type: "oauth",
version: "2.0",
params: { grant_type: "authorization_code" },
scope: "openid email profile",
accessTokenUrl: `https://${options.domain}/oauth/token`,
authorizationUrl: `https://${options.domain}/authorize?response_type=code`,
profileUrl: `https://${options.domain}/userinfo`,
profile(profile) {
return {
id: profile.sub,
name: profile.nickname,
email: profile.email,
image: profile.picture,
}
},
...options,
}
}

View File

@@ -1,31 +0,0 @@
import { OAuthConfig, OAuthUserConfig } from "./oauth"
export interface Auth0Profile {
sub: string
nicname: string
email: string
picture: string
}
export default function Auth0<P extends Record<string, any> = Auth0Profile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "auth0",
name: "Auth0",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
type: "oauth",
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.nickname,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

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

View File

@@ -1,41 +0,0 @@
import { OAuthConfig, OAuthUserConfig } from "."
export interface AzureB2CProfile {
exp: number
nbf: number
ver: string
iss: string
sub: string
aud: string
iat: number
auth_time: number
oid: string
country: string
name: string
postalCode: string
emails: string[]
tfp: string
}
export default function AzureADB2C<
P extends Record<string, any> = AzureB2CProfile
>(options: OAuthUserConfig<P> & { primaryUserFlow: string }): OAuthConfig<P> {
const { tenantId, primaryUserFlow } = options
return {
id: "azure-ad-b2c",
name: "Azure Active Directory B2C",
type: "oauth",
wellKnown: `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${primaryUserFlow}/v2.0/.well-known/openid-configuration`,
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.emails[0],
// TODO: Find out how to retrieve the profile picture
image: null,
}
},
options,
}
}

View File

@@ -1,36 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function AzureAD(options) {
const tenant = options.tenantId ?? "common"
return {
id: "azure-ad",
name: "Azure Active Directory",
type: "oauth",
wellKnown: `https://login.microsoftonline.com/${tenant}/v2.0/.well-known/openid-configuration`,
authorization: {
params: {
scope: "User.Read",
},
},
async profile(profile, tokens) {
// https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
const profilePicture = await fetch(
"https://graph.microsoft.com/v1.0/me/photo/$value",
{
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
}
)
const pictureBuffer = await profilePicture.arrayBuffer()
const pictureBase64 = Buffer.from(pictureBuffer).toString("base64")
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: `data:image/jpeg;base64, ${pictureBase64}`,
}
},
options,
}
}

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

@@ -0,0 +1,22 @@
export default function Basecamp(options) {
return {
id: "basecamp",
name: "Basecamp",
type: "oauth",
version: "2.0",
accessTokenUrl:
"https://launchpad.37signals.com/authorization/token?type=web_server",
authorizationUrl:
"https://launchpad.37signals.com/authorization/new?type=web_server",
profileUrl: "https://launchpad.37signals.com/authorization.json",
profile(profile) {
return {
id: profile.identity.id,
name: `${profile.identity.first_name} ${profile.identity.last_name}`,
email: profile.identity.email_address,
image: null,
}
},
...options,
}
}

View File

@@ -1,18 +1,21 @@
/** @type {import(".").OAuthProvider} */
export default function BattleNet(options) {
const { region } = options
const base =
region === "CN"
? "https://www.battlenet.com.cn/oauth"
: `https://${region}.battle.net/oauth`
return {
id: "battlenet",
name: "Battle.net",
type: "oauth",
authorization: `${base}/authorize`,
token: `${base}/token`,
userinfo: "https://us.battle.net/oauth/userinfo",
version: "2.0",
scope: "openid",
params: { grant_type: "authorization_code" },
accessTokenUrl:
region === "CN"
? "https://www.battlenet.com.cn/oauth/token"
: `https://${region}.battle.net/oauth/token`,
authorizationUrl:
region === "CN"
? "https://www.battlenet.com.cn/oauth/authorize?response_type=code"
: `https://${region}.battle.net/oauth/authorize?response_type=code`,
profileUrl: "https://us.battle.net/oauth/userinfo",
profile(profile) {
return {
id: profile.id,
@@ -21,6 +24,6 @@ export default function BattleNet(options) {
image: null,
}
},
options,
...options,
}
}

View File

@@ -1,12 +1,15 @@
/** @type {import(".").OAuthProvider} */
export default function Box(options) {
return {
id: "box",
name: "Box",
type: "oauth",
authorization: "https://account.box.com/api/oauth2/authorize",
token: "https://api.box.com/oauth2/token",
userinfo: "https://api.box.com/2.0/users/me",
version: "2.0",
scope: "",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://api.box.com/oauth2/token",
authorizationUrl:
"https://account.box.com/api/oauth2/authorize?response_type=code",
profileUrl: "https://api.box.com/2.0/users/me",
profile(profile) {
return {
id: profile.id,
@@ -15,6 +18,6 @@ export default function Box(options) {
image: profile.avatar_url,
}
},
options,
...options,
}
}

View File

@@ -1,12 +1,16 @@
/** @type {import(".").OAuthProvider} */
export default function Bungie(options) {
return {
id: "bungie",
name: "Bungie",
type: "oauth",
authorization: "https://www.bungie.net/en/OAuth/Authorize?reauth=true",
token: "https://www.bungie.net/platform/app/oauth/token/",
userinfo:
version: "2.0",
scope: "",
params: { reauth: "true", grant_type: "authorization_code" },
accessTokenUrl: "https://www.bungie.net/platform/app/oauth/token/",
requestTokenUrl: "https://www.bungie.net/platform/app/oauth/token/",
authorizationUrl:
"https://www.bungie.net/en/OAuth/Authorize?response_type=code",
profileUrl:
"https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/",
profile(profile) {
const { bungieNetUser: user } = profile.Response
@@ -14,12 +18,17 @@ export default function Bungie(options) {
return {
id: user.membershipId,
name: user.displayName,
email: null,
image: `https://www.bungie.net${
user.profilePicturePath.startsWith("/") ? "" : "/"
}${user.profilePicturePath}`,
email: null,
}
},
options,
headers: {
"X-API-Key": null,
},
clientId: null,
clientSecret: null,
...options,
}
}

23
src/providers/cognito.js Normal file
View File

@@ -0,0 +1,23 @@
export default function Cognito(options) {
const { domain } = options
return {
id: "cognito",
name: "Cognito",
type: "oauth",
version: "2.0",
scope: "openid profile email",
params: { grant_type: "authorization_code" },
accessTokenUrl: `https://${domain}/oauth2/token`,
authorizationUrl: `https://${domain}/oauth2/authorize?response_type=code`,
profileUrl: `https://${domain}/oauth2/userInfo`,
profile(profile) {
return {
id: profile.sub,
name: profile.username,
email: profile.email,
image: null,
}
},
...options,
}
}

View File

@@ -1,28 +0,0 @@
import { OAuthConfig, OAuthUserConfig } from "."
export interface CognitoProfile {
sub: string
name: string
email: string
picture: string
}
export default function Cognito<P extends Record<string, any> = CognitoProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "cognito",
name: "Cognito",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -1,21 +1,24 @@
/** @type {import(".").OAuthProvider} */
export default function Coinbase(options) {
return {
id: "coinbase",
name: "Coinbase",
type: "oauth",
authorization:
"https://www.coinbase.com/oauth/authorize?scope=wallet:user:email+wallet:user:read",
token: "https://api.coinbase.com/oauth/token",
userinfo: "https://api.coinbase.com/v2/user",
version: "2.0",
scope: "wallet:user:email wallet:user:read",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://api.coinbase.com/oauth/token",
requestTokenUrl: "https://api.coinbase.com/oauth/token",
authorizationUrl:
"https://www.coinbase.com/oauth/authorize?response_type=code",
profileUrl: "https://api.coinbase.com/v2/user",
profile(profile) {
return {
id: profile.data.id,
name: profile.data.name,
email: profile.data.email,
email: profile.data.email,
image: profile.data.avatar_url,
}
},
options,
...options,
}
}

View File

@@ -0,0 +1,10 @@
export default function Credentials(options) {
return {
id: "credentials",
name: "Credentials",
type: "credentials",
authorize: null,
credentials: null,
...options,
}
}

View File

@@ -1,45 +0,0 @@
import { NextApiRequest } from "next"
import { CommonProviderOptions } from "."
import { User, Awaitable } from ".."
export interface CredentialInput {
label?: string
type?: string
value?: string
placeholder?: string
}
export interface CredentialsConfig<
C extends Record<string, CredentialInput> = {}
> extends CommonProviderOptions {
type: "credentials"
credentials: C
authorize: (
credentials: Record<keyof C, string>,
req: NextApiRequest
) => Awaitable<(Omit<User, "id"> | { id?: string }) | null>
}
export type CredentialsProvider = <C extends Record<string, CredentialInput>>(
options: Partial<CredentialsConfig<C>>
) => CredentialsConfig<C>
export type CredentialsProviderType = "Credentials"
type UserCredentialsConfig<C extends Record<string, CredentialInput>> = Partial<
Omit<CredentialsConfig<C>, "options">
> &
Pick<CredentialsConfig<C>, "authorize" | "credentials">
export default function Credentials<
C extends Record<string, CredentialInput> = Record<string, CredentialInput>
>(options: UserCredentialsConfig<C>): CredentialsConfig<C> {
return {
id: "credentials",
name: "Credentials",
type: "credentials",
credentials: {} as any,
authorize: () => null,
options,
}
}

View File

@@ -1,13 +1,15 @@
/** @type {import(".").OAuthProvider} */
export default function Discord(options) {
return {
id: "discord",
name: "Discord",
type: "oauth",
authorization:
"https://discord.com/api/oauth2/authorize?scope=identify+email",
token: "https://discord.com/api/oauth2/token",
userinfo: "https://discord.com/api/users/@me",
version: "2.0",
scope: "identify email",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://discord.com/api/oauth2/token",
authorizationUrl:
"https://discord.com/api/oauth2/authorize?response_type=code&prompt=none",
profileUrl: "https://discord.com/api/users/@me",
profile(profile) {
if (profile.avatar === null) {
const defaultAvatarNumber = parseInt(profile.discriminator) % 5
@@ -19,10 +21,10 @@ export default function Discord(options) {
return {
id: profile.id,
name: profile.username,
email: profile.email,
image: profile.image_url,
email: profile.email,
}
},
options,
...options,
}
}

View File

@@ -15,7 +15,7 @@
* ...
*
* // pages/index
* import { signIn } from "next-auth/react"
* import { signIn } from "next-auth/client"
* ...
* <button onClick={() => signIn("dropbox")}>
* Sign in
@@ -27,25 +27,28 @@
* - [Dropbox Documentation](https://developers.dropbox.com/oauth-guide)
* - [Configuration](https://www.dropbox.com/developers/apps)
*/
/** @type {import(".").OAuthProvider} */
export default function Dropbox(options) {
return {
id: "dropbox",
name: "Dropbox",
type: "oauth",
authorization:
"https://www.dropbox.com/oauth2/authorize?token_access_type=offline&scope=account_info.read",
token: "https://api.dropboxapi.com/oauth2/token",
userinfo: "https://api.dropboxapi.com/2/users/get_current_account",
profile(profile) {
id: 'dropbox',
name: 'Dropbox',
type: 'oauth',
version: '2.0',
scope: 'account_info.read',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.dropboxapi.com/oauth2/token',
authorizationUrl:
'https://www.dropbox.com/oauth2/authorize?token_access_type=offline&response_type=code',
profileUrl: 'https://api.dropboxapi.com/2/users/get_current_account',
profile: (profile) => {
return {
id: profile.account_id,
name: profile.name.display_name,
email: profile.email,
image: profile.profile_photo_url,
email_verified: profile.email_verified
}
},
checks: ["state", "pkce"],
options,
protection: ["state", "pkce"],
...options
}
}

101
src/providers/email.js Normal file
View File

@@ -0,0 +1,101 @@
import nodemailer from "nodemailer"
import logger from "../lib/logger"
export default function Email(options) {
return {
id: "email",
type: "email",
name: "Email",
// Server can be an SMTP connection string or a nodemailer config object
server: {
host: "localhost",
port: 25,
auth: {
user: "",
pass: "",
},
},
from: "NextAuth <no-reply@example.com>",
maxAge: 24 * 60 * 60,
sendVerificationRequest,
...options,
}
}
const sendVerificationRequest = ({
identifier: email,
url,
baseUrl,
provider,
}) => {
return new Promise((resolve, reject) => {
const { server, from } = provider
// Strip protocol from URL and use domain as site name
const site = baseUrl.replace(/^https?:\/\//, "")
nodemailer.createTransport(server).sendMail(
{
to: email,
from,
subject: `Sign in to ${site}`,
text: text({ url, site, email }),
html: html({ url, site, email }),
},
(error) => {
if (error) {
logger.error("SEND_VERIFICATION_EMAIL_ERROR", error)
return reject(new Error("SEND_VERIFICATION_EMAIL_ERROR", error))
}
return resolve()
}
)
})
}
// Email HTML body
const html = ({ url, site }) => {
// Insert invisible space into domains to prevent the
// the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
// like they are supposed to click it to sign in.
const escapedSite = `${site.replace(/\./g, "&#8203;.")}`
// Some simple styling options
const backgroundColor = "#f9f9f9"
const textColor = "#444444"
const mainBackgroundColor = "#ffffff"
const buttonBackgroundColor = "#346df1"
const buttonBorderColor = "#346df1"
const buttonTextColor = "#ffffff"
return `
<body style="background: ${backgroundColor};">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in to <strong>${escapedSite}</strong>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; text-decoration: none;border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`
}
// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
const text = ({ url, site }) => `Sign in to ${site}\n${url}\n\n`

View File

@@ -1,142 +0,0 @@
import { createTransport } from "nodemailer"
import { CommonProviderOptions } from "."
import { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection"
import { Awaitable } from ".."
export interface EmailConfig extends CommonProviderOptions {
type: "email"
// TODO: Make use of https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
server: string | SMTPConnectionOptions
/** @default "NextAuth <no-reply@example.com>" */
from?: string
/**
* How long until the e-mail can be used to log the user in,
* in seconds. Defaults to 1 day
* @default 86400
*/
maxAge?: number
sendVerificationRequest: (params: {
identifier: string
url: string
expires: Date
provider: EmailConfig
token: string
}) => Awaitable<void>
/**
* By default, we are generating a random verification token.
* You can make it predictable or modify it as you like with this method.
* @example
* ```js
* Providers.Email({
* async generateVerificationToken() {
* return "ABC123"
* }
* })
* ```
* [Documentation](https://next-auth.js.org/providers/email#customising-the-verification-token)
*/
generateVerificationToken?: () => Awaitable<string>
/** If defined, it is used to hash the verification token when saving to the database . */
secret?: string
options: EmailUserConfig
}
export type EmailUserConfig = Partial<Omit<EmailConfig, "options">>
export type EmailProvider = (options: EmailUserConfig) => EmailConfig
// TODO: Rename to Token provider
// when started working on https://github.com/nextauthjs/next-auth/discussions/1465
export type EmailProviderType = "Email"
export default function Email(options: EmailUserConfig): EmailConfig {
return {
id: "email",
type: "email",
name: "Email",
// Server can be an SMTP connection string or a nodemailer config object
server: {
host: "localhost",
port: 25,
auth: {
user: "",
pass: "",
},
},
from: "NextAuth <no-reply@example.com>",
maxAge: 24 * 60 * 60,
async sendVerificationRequest({
identifier: email,
url,
provider: { server, from },
}) {
const { host } = new URL(url)
const transport = createTransport(server)
await transport.sendMail({
to: email,
from,
subject: `Sign in to ${host}`,
text: text({ url, host }),
html: html({ url, host, email }),
})
},
options,
}
}
// Email HTML body
function html({ url, host, email }: Record<"url" | "host" | "email", string>) {
// Insert invisible space into domains and email address to prevent both the
// email address and the domain from being turned into a hyperlink by email
// clients like Outlook and Apple mail, as this is confusing because it seems
// like they are supposed to click on their email address to sign in.
const escapedEmail = `${email.replace(/\./g, "&#8203;.")}`
const escapedHost = `${host.replace(/\./g, "&#8203;.")}`
// Some simple styling options
const backgroundColor = "#f9f9f9"
const textColor = "#444444"
const mainBackgroundColor = "#ffffff"
const buttonBackgroundColor = "#346df1"
const buttonBorderColor = "#346df1"
const buttonTextColor = "#ffffff"
return `
<body style="background: ${backgroundColor};">
<table width="100%" border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="padding: 10px 0px 20px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
<strong>${escapedHost}</strong>
</td>
</tr>
</table>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${mainBackgroundColor}; max-width: 600px; margin: auto; border-radius: 10px;">
<tr>
<td align="center" style="padding: 10px 0px 0px 0px; font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
Sign in as <strong>${escapedEmail}</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 20px 0;">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<td align="center" style="border-radius: 5px;" bgcolor="${buttonBackgroundColor}"><a href="${url}" target="_blank" style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${buttonTextColor}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${buttonBorderColor}; display: inline-block; font-weight: bold;">Sign in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${textColor};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</body>
`
}
// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones)
function text({ url, host }: Record<"url" | "host", string>) {
return `Sign in to ${host}\n${url}\n\n`
}

View File

@@ -1,20 +1,22 @@
/** @type {import(".").OAuthProvider} */
export default function EVEOnline(options) {
return {
id: "eveonline",
name: "EVE Online",
type: "oauth",
authorization: "https://login.eveonline.com/oauth/authorize",
token: "https://login.eveonline.com/oauth/token",
userinfo: "https://login.eveonline.com/oauth/verify",
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,
email: null,
image: `https://image.eveonline.com/Character/${profile.CharacterID}_128.jpg`,
email: null,
}
},
options,
...options,
}
}

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

@@ -0,0 +1,22 @@
export default function Facebook(options) {
return {
id: "facebook",
name: "Facebook",
type: "oauth",
version: "2.0",
scope: "email",
accessTokenUrl: "https://graph.facebook.com/oauth/access_token",
authorizationUrl:
"https://www.facebook.com/v7.0/dialog/oauth?response_type=code",
profileUrl: "https://graph.facebook.com/me?fields=email,name,picture",
profile(profile) {
return {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.picture.data.url,
}
},
...options,
}
}

View File

@@ -1,40 +0,0 @@
import { Profile } from ".."
import { OAuthConfig, OAuthUserConfig } from "./oauth"
export interface FacebookProfile extends Profile {
id: string
picture: { data: { url: string } }
}
export default function Facebook<
P extends Record<string, any> = FacebookProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "facebook",
name: "Facebook",
type: "oauth",
authorization: "https://www.facebook.com/v11.0/dialog/oauth?scope=email",
token: "https://graph.facebook.com/oauth/access_token",
userinfo: {
url: "https://graph.facebook.com/me",
// https://developers.facebook.com/docs/graph-api/reference/user/#fields
params: { fields: "id,name,email,picture" },
async request({ tokens, client, provider }) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return await client.userinfo(tokens.access_token!, {
// @ts-expect-error
params: provider.userinfo?.params,
})
},
},
profile(profile: P) {
return {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.picture.data.url,
}
},
options,
}
}

View File

@@ -1,25 +1,28 @@
/** @type {import(".").OAuthProvider} */
export default function FACEIT(options) {
return {
id: "faceit",
name: "FACEIT",
type: "oauth",
authorization: "https://accounts.faceit.com/accounts?redirect_popup=true",
version: "2.0",
params: { grant_type: "authorization_code" },
headers: {
Authorization: `Basic ${Buffer.from(
`${options.clientId}:${options.clientSecret}`
).toString("base64")}`,
},
token: "https://api.faceit.com/auth/v1/oauth/token",
userinfo: "https://api.faceit.com/auth/v1/resources/userinfo",
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: profile.guid,
name: profile.name,
email: profile.email,
image: profile.picture,
id,
name,
email,
image,
}
},
options,
...options,
}
}

View File

@@ -1,31 +1,23 @@
/** @type {import("src/providers").OAuthProvider} */
/** @type {import(".").OAuthProvider} */
export default function Foursquare(options) {
const { apiVersion = "20210801" } = options
const { apiVersion } = options
return {
id: "foursquare",
name: "Foursquare",
type: "oauth",
authorization: "https://foursquare.com/oauth2/authenticate",
token: "https://foursquare.com/oauth2/access_token",
userinfo: {
url: `https://api.foursquare.com/v2/users/self?v=${apiVersion}`,
request({ tokens, client }) {
return client.userinfo(undefined, {
params: { oauth_token: tokens.access_token },
})
},
},
profile({ response: { profile } }) {
version: "2.0",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://foursquare.com/oauth2/access_token",
authorizationUrl:
"https://foursquare.com/oauth2/authenticate?response_type=code",
profileUrl: `https://api.foursquare.com/v2/users/self?v=${apiVersion}`,
profile(profile) {
return {
id: profile.id,
name: `${profile.firstName} ${profile.lastName}`,
image: `${profile.prefix}original${profile.suffix}`,
email: profile.contact.email,
image: profile.photo
? `${profile.photo.prefix}original${profile.photo.suffix}`
: null,
}
},
options,
...options,
}
}

View File

@@ -1,22 +1,20 @@
/** @type {import(".").OAuthProvider} */
export default function Freshbooks(options) {
return {
id: "freshbooks",
name: "Freshbooks",
type: "oauth",
version: "2.0",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://api.freshbooks.com/auth/oauth/token",
authorizationUrl:
"https://auth.freshbooks.com/service/auth/oauth/authorize?response_type=code",
profileUrl: "https://api.freshbooks.com/auth/api/v1/users/me",
async profile(profile) {
return {
id: profile.response.id,
name: `${profile.response.first_name} ${profile.response.last_name}`,
email: profile.response.email,
}
},
...options,
}
id: 'freshbooks',
name: 'Freshbooks',
type: 'oauth',
version: '2.0',
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://api.freshbooks.com/auth/oauth/token',
authorizationUrl: 'https://auth.freshbooks.com/service/auth/oauth/authorize?response_type=code',
profileUrl: 'https://api.freshbooks.com/auth/api/v1/users/me',
async profile(profile) {
return {
id: profile.response.id,
name: `${profile.response.first_name} ${profile.response.last_name}`,
email: profile.response.email,
};
},
...options
};
}

View File

@@ -1,12 +1,19 @@
/** @type {import(".").OAuthProvider} */
export default function FusionAuth(options) {
let authorizationUrl = `https://${options.domain}/oauth2/authorize?response_type=code`
if (options.tenantId) {
authorizationUrl += `&tenantId=${options.tenantId}`
}
return {
id: "fusionauth",
name: "FusionAuth",
type: "oauth",
authorization: `${options.issuer}oauth2/authorize`,
token: `${options.issuer}oauth2/token`,
userinfo: `${options.issuer}oauth2/userinfo`,
version: "2.0",
scope: "openid",
params: { grant_type: "authorization_code" },
accessTokenUrl: `https://${options.domain}/oauth2/token`,
authorizationUrl,
profileUrl: `https://${options.domain}/oauth2/userinfo`,
profile(profile) {
return {
id: profile.sub,
@@ -15,6 +22,6 @@ export default function FusionAuth(options) {
image: profile.picture,
}
},
options,
...options,
}
}

View File

@@ -1,20 +1,21 @@
/** @type {import(".").OAuthProvider} */
export default function GitHub(options) {
return {
id: "github",
name: "GitHub",
type: "oauth",
authorization: "https://github.com/login/oauth/authorize?scope=read:user+user:email",
token: "https://github.com/login/oauth/access_token",
userinfo: "https://api.github.com/user",
version: "2.0",
scope: "user",
accessTokenUrl: "https://github.com/login/oauth/access_token",
authorizationUrl: "https://github.com/login/oauth/authorize",
profileUrl: "https://api.github.com/user",
profile(profile) {
return {
id: profile.id.toString(),
id: profile.id,
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url,
}
},
options,
...options,
}
}

View File

@@ -1,14 +1,14 @@
/** @type {import("src/providers").OAuthProvider} */
/** @type {import(".").OAuthProvider} */
export default function GitLab(options) {
return {
id: "gitlab",
name: "GitLab",
type: "oauth",
authorization: "https://gitlab.com/oauth/authorize?scope=read_user",
token: "https://gitlab.com/oauth/token",
userinfo: "https://gitlab.com/api/v4/user",
checks: ["pkce", "state"],
version: "2.0",
scope: "read_user",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://gitlab.com/oauth/token",
authorizationUrl: "https://gitlab.com/oauth/authorize?response_type=code",
profileUrl: "https://gitlab.com/api/v4/user",
profile(profile) {
return {
id: profile.id,
@@ -17,6 +17,6 @@ export default function GitLab(options) {
image: profile.avatar_url,
}
},
options,
...options,
}
}

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

@@ -0,0 +1,25 @@
export default function Google(options) {
return {
id: "google",
name: "Google",
type: "oauth",
version: "2.0",
scope:
"https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
params: { grant_type: "authorization_code" },
accessTokenUrl: "https://accounts.google.com/o/oauth2/token",
requestTokenUrl: "https://accounts.google.com/o/oauth2/auth",
authorizationUrl:
"https://accounts.google.com/o/oauth2/auth?response_type=code",
profileUrl: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
profile(profile) {
return {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
...options,
}
}

View File

@@ -1,31 +0,0 @@
import { OAuthConfig, OAuthUserConfig } from "./oauth"
export interface GoogleProfile {
sub: string
name: string
email: string
picture: string
}
export default function Google<P extends Record<string, any> = GoogleProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "google",
name: "Google",
type: "oauth",
wellKnown: "https://accounts.google.com/.well-known/openid-configuration",
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -1,21 +1,17 @@
/** @type {import(".").OAuthProvider} */
export default function IdentityServer4(options) {
return {
id: "identity-server4",
name: "IdentityServer4",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid profile email" } },
checks: ["pkce", "state"],
idToken: true,
version: "2.0",
scope: "openid profile email",
params: { grant_type: "authorization_code" },
accessTokenUrl: `https://${options.domain}/connect/token`,
authorizationUrl: `https://${options.domain}/connect/authorize?response_type=code`,
profileUrl: `https://${options.domain}/connect/userinfo`,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
}
return { ...profile, id: profile.sub }
},
options,
...options,
}
}

View File

@@ -1,41 +0,0 @@
import { OAuthConfig, OAuthProvider, OAuthProviderType } from "./oauth"
import { EmailConfig, EmailProvider, EmailProviderType } from "./email"
import {
CredentialsConfig,
CredentialsProvider,
CredentialsProviderType,
} from "./credentials"
export * from "./oauth"
export * from "./email"
export * from "./credentials"
export type ProviderType = "oauth" | "email" | "credentials"
export interface CommonProviderOptions {
id: string
name: string
type: ProviderType
options?: Record<string, unknown>
}
export type Provider = OAuthConfig<any> | EmailConfig | CredentialsConfig
export type BuiltInProviders = Record<OAuthProviderType, OAuthProvider> &
Record<CredentialsProviderType, CredentialsProvider> &
Record<EmailProviderType, EmailProvider>
export type AppProviders = Array<
Provider | ReturnType<BuiltInProviders[keyof BuiltInProviders]>
>
export interface AppProvider extends CommonProviderOptions {
signinUrl: string
callbackUrl: string
}
export type RedirectableProviderType = "email" | "credentials"
export type BuiltInProviderType = RedirectableProviderType | OAuthProviderType

View File

@@ -1,5 +1,5 @@
/**
* @type {import("src/providers").OAuthProvider} options
* @type {import("types/providers").OAuthProvider} options
* @example
*
* ```js
@@ -15,7 +15,7 @@
* ...
*
* // pages/index
* import { signIn } from "next-auth/react"
* import { signIn } from "next-auth/client"
* ...
* <button onClick={() => signIn("instagram")}>
* Sign in
@@ -24,16 +24,18 @@
* ```
* [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)
*/
/** @type {import(".").OAuthProvider} */
export default function Instagram(options) {
return {
id: "instagram",
name: "Instagram",
type: "oauth",
authorization:
"https://api.instagram.com/oauth/authorize?scope=user_profile",
token: "https://api.instagram.com/oauth/access_token",
userinfo:
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 {
@@ -43,6 +45,6 @@ export default function Instagram(options) {
image: null,
}
},
options,
...options,
}
}

View File

@@ -1,12 +1,14 @@
/** @type {import(".").OAuthProvider} */
export default function Kakao(options) {
return {
id: "kakao",
name: "Kakao",
type: "oauth",
authorization: "https://kauth.kakao.com/oauth/authorize",
token: "https://kauth.kakao.com/oauth/token",
userinfo: "https://kapi.kakao.com/v2/user/me",
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,
@@ -15,6 +17,6 @@ export default function Kakao(options) {
image: profile.kakao_account?.profile.profile_image_url,
}
},
options,
...options,
}
}

View File

@@ -1,48 +0,0 @@
import { OAuthConfig, OAuthUserConfig } from "."
export interface KeycloakProfile {
exp: number
iat: number
auth_time: number
jti: string
iss: string
aud: string
sub: string
typ: string
azp: string
session_state: string
at_hash: string
acr: string
sid: string
email_verified: boolean
name: string
preferred_username: string
given_name: string
family_name: string
email: string
picture: string
user: any
}
export default function Keycloak<
P extends Record<string, any> = KeycloakProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "keycloak",
name: "Keycloak",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
type: "oauth",
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state"],
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

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