Compare commits

...

181 Commits

Author SHA1 Message Date
Balázs Orbán
f3be5e87f6 feat(middleware): introduce withAuth Next.js method (#3657)
* feat(middleware): introduce Middleware API to Next.js

* chore(app): upgrade Next.js in dev app

* chore(dev): add Middleware protected page to dev app

* chore(middleware): add `next/middleware` to `exports`

* fix(middleware): bail out redirect on custom pages

* fix(middleware): allow one-line export

* chore(middleware): simplify code

* fix(middleware): redirect back to page after succesful login

* feat(middleware): re-export `withAuth` as `default`

* chore: export middleware from `next-auth/middleware`

* chore: add `middleware` files to npm

* feat(middleware): handle chaining, fix some bugs

* chore(dev): showcase different middlewares

* chore(middleware): remove `@ts-expect-error` comments

* chore: update build clean script

* fix: bail out when NextAuth.js paths

* refactor: be more explicit about `initConfig` result

* refactor: simplify

* refactor: use `callbacks` similarily to `NextAuthOptions`

* refactor: use `nextauth` namespace when setting `token` on `req`

* refactor: don't allow passing `secret`

* addressing review
2022-02-03 18:07:26 +01:00
Dinil Fernando
844c9b147c feat(providers): add Trakt provider (#3771)
* added trakt provider

* fixed incorrect auth url

* Update src/providers/trakt.ts

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

* Update src/providers/trakt.ts

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

* Update trakt.ts

Co-authored-by: caidenwilson <caidenwilson@protonmail.com>
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-02-03 15:27:05 +01:00
Balázs Orbán
c9e16fb71e fix(core): only show Twitter OAuth 2 warning once 2022-02-02 16:06:14 +01:00
Balázs Orbán
a7d34f97c8 fix(providers): properly warn when using Twitter OAuth 2 (#3784)
* fix(providers): properly warn when using Twitter OAuth 2

* refactor(providers): move Twitter OAuth2 warning to `assert`

* fix: use proper warning code

* refactor: only set boolean
2022-02-02 12:37:17 +01:00
Balázs Orbán
f20d6790c8 feat(core): detect NEXTAUTH_SECRET (#3783)
* feat(core): detect `NEXTAUTH_SECRET` env variable

* chore(dev): use detected `NEXTAUTH_SECRET` in dev app
2022-02-02 02:08:56 +01:00
Norbert Szabó
53baf6d67d feat(ts): strongly type sign-in and error page errors (#3740)
* feat: added types for sign in errors

* feat: adding type to error prop

* chore: added documentation links to types
2022-02-02 02:08:44 +01:00
dependabot[bot]
255c822dfb chore(deps): bump node-fetch from 2.6.6 to 2.6.7 (#3777)
Bumps [node-fetch](https://github.com/node-fetch/node-fetch) from 2.6.6 to 2.6.7.
- [Release notes](https://github.com/node-fetch/node-fetch/releases)
- [Commits](https://github.com/node-fetch/node-fetch/compare/v2.6.6...v2.6.7)

---
updated-dependencies:
- dependency-name: node-fetch
  dependency-type: indirect
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-01 08:18:26 +01:00
inshatan
31c03c96d1 typo in redirect url for response with error (#3758) 2022-02-01 08:18:09 +01:00
dependabot[bot]
74df39a678 chore(deps-dev): bump next from 12.0.7 to 12.0.9 (#3764)
Bumps [next](https://github.com/vercel/next.js) from 12.0.7 to 12.0.9.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v12.0.7...v12.0.9)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:development
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-01 08:16:25 +01:00
Balázs Orbán
714d80a4f5 Update package.json 2022-01-25 18:39:58 +01:00
Balázs Orbán
3d5c669a05 Update bug_report.yaml 2022-01-25 18:37:30 +01:00
Balázs Orbán
29977f108f Update bug_report.yaml 2022-01-25 18:36:56 +01:00
Balázs Orbán
7d2e16a6bb Update bug_report.yaml 2022-01-25 18:35:54 +01:00
Seiji Takahashi
af157dac07 feat(react): add refetchOnWindowFocus option to SessionProvider (#3730) 2022-01-25 18:06:37 +01:00
Alex Johansson
1bf56a218e fix: Allow React 18 as peer dependency (#3728)
Avoid peer dependency warning when using React 18
2022-01-25 18:02:53 +01:00
David Chalifoux
4824f8c02a fix(providers): Check for valid profile picture response before converting to base64 (#3656)
* Fix: Add OpenID to authorization scope

* Fix: Check for valid profile picture response before converting to base64

* Update src/providers/azure-ad.ts

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

* Confirm that profile photo was returned

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-01-20 02:09:04 +01:00
PAKKU-Chan
a4d831d1b9 feat(providers): add authentik provider (#3625)
* Added authentik provider

* Removed idToken
2022-01-19 01:38:46 +01:00
David Chalifoux
59985264a2 fix(providers): use openid scopes by default (#3651) 2022-01-17 04:28:15 +01:00
Iftekhar Rifat
c844296982 fix: pass csrf & callbackUrl cookies in session api (#3607) 2022-01-17 00:41:16 +01:00
Jon Bellah
d1aa2a1a8e fix(ts): match GoogleProfile interface with Google docs (#3643) 2022-01-17 00:40:23 +01:00
Balázs Orbán
8139126f29 fix(core): detect Vercel without NEXTAUTH_URL (#3649)
* fix(core): detect Vercel without `NEXTAUTH_URL`

* chore(ts): use `any`

* chore: use `process.env.VERCEL` to detect Vercel
2022-01-17 00:37:30 +01:00
Laxmikanta Nayak
aa0e8200b3 docs: Updated the wrong link to providers list in readme (#3616)
The link to providers list was 404 so updated to the correct link in document.
2022-01-15 04:44:31 +01:00
Thang Vu
82447f8e3e fix: display inline errors when using custom error page. (#3576) 2022-01-10 11:57:27 +01:00
Balázs Orbán
a0b3814c81 feat: better out-of-the-box id_token detection (#3514)
* feat: better out-of-the-box `id_token` detection

* fix: check for `scope` on correct endpoint

* chore: simplify internal typing
2022-01-10 11:50:45 +01:00
Balázs Orbán
90c7d535c0 feat(providers): add support to Twitter OAuth 2.0 (#3446)
* feat(providers): add support to Twitter OAuth 2.0

* docs: add docs comment

* chore: cleanup

* chore: remove comments

* chore: give warning for OAuth 2 for now
2022-01-10 11:49:43 +01:00
Tetedeiench
0510c9b1ba feat(providers): add Patreon provider (#3581)
* Added patreon provider - tested and working

* Update src/providers/patreon.js

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

* Update src/providers/patreon.js

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

* Update src/providers/patreon.js

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

* Update src/providers/patreon.js

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

* Switched to TS, restore .env.local, restore package.json as per comments on the PR

* chore: ran Prettier

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-01-10 11:48:11 +01:00
Changsoon Bok
49e4af17e2 fix(providers): refactor naver provider profile (#3500)
* fix(providers): refactor naver provider profile

fix(providers): refactor naver provider profile

* fix(providers): convert typescript - naver provider profile

fix(providers): convert typescript - naver provider profile

* chore(providers): use nested interface for consistency

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-01-10 11:35:53 +01:00
Changsoon Bok
db65afe5ab fix(providers): fix url and auth method for Kakao provider (#3501)
* fix(providers): fix url and auth method for Kakao provider

* fix(providers): convert typescript - Kakao provider

fix(providers): convert typescript - Kakao provider

* chore(providers): use nested interface for consistency

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-01-10 11:31:55 +01:00
Changsoon Bok
36ca1f99e3 docs: update contributing guide link (#3595) 2022-01-10 11:22:58 +01:00
Thang Vu
9bec96784f chore(dev): add postinstall in app to remove next-auth (#3575) 2022-01-08 00:43:38 +01:00
Thang Vu
227ff2259f chore: add eslintIgnore in package.json (#3548)
* fix: add eslintIgnore in package.json

* Let eslint runs in app, config + js files

* Add a separate tsconfig.eslint.json file

We want to run the lint command on `app`, `src` and `config`, but at the same time want `tsc` to compile files in `src` only. A separate `tsconfig.eslint.json` is a suitable solution to satisfy both `eslint` and `tsc`: 04d1f3e549/packages/parser/README.md
2022-01-05 04:02:03 +01:00
Yves Fridelance
c71cb8457d fix(oauth): set httpOptions before issuer discovery (#3537)
* Update client.ts

Set custom.setHttpOptionsDefaults before Issuer.discover(.wellKnown). This allow discover the .wellKnown endpoint behind a proxy

* chore: address code review

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-01-02 00:04:23 +01:00
krautwigundrüben
a09a75be53 fix(providers): make Strava work again (#3520)
* Update strava.js

Auth with Strava was throwing errors before, this works.

* Update strava.js

changed according to commenters' suggestions

* chore: run linter

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-01-02 00:00:33 +01:00
Balázs Orbán
c4936991e5 chore(app): upgrade dev app dependencies 2021-12-31 00:41:59 +01:00
Thang Vu
e2add6a597 chore(dev): fix start email script (#3541) 2021-12-30 22:42:26 +01:00
Adam Kaczmarek
0e8be0c7d2 docs: fix OpenCollective link in README.md (#3494) 2021-12-22 00:42:21 +01:00
Ivan Esteban
d1d2d977fe fix(providers): use idToken by default in Cognito provider (#3448) 2021-12-18 02:21:20 +01:00
Kirankumar Ambati
48749d7320 fix(pages): remove default placeholder for credentials provider (#3451)
* fix #3449: removed default placeholder for credentials provider

* fix: formatting
2021-12-18 02:10:05 +01:00
Drew Miller
87d0beb70c fix(jwt): use authorization header as fallback (#3453)
If the `req` sent to `getToken` doesn't have the relevant cookies, use
the Bearer token in the Authorization header as a fallback.

Fixes #3452
2021-12-16 13:37:03 +01:00
Balázs Orbán
978e2eeb08 chore(dev): minor fixes on dev app 2021-12-11 21:19:12 +01:00
Balázs Orbán
8ab057ea33 chore(deps): ugprade dependencies (#3415) 2021-12-11 21:17:22 +01:00
Bogdan Soare
2c269a6a81 fix(providers): use id_token by default on Okta provider (#3418) 2021-12-11 12:52:40 +01:00
Alessandro Cuppari
8b9a109255 fix(providers): refactor FusionAuth to v4 (#3376)
* feat: updated fusionauth provider

* Updated fusionauth profile interface docstring

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

* Refactored openid well know logic

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

* Removed jwks endpoint property

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-12-09 21:48:01 +01:00
Etienne Martin
ac35d9f739 docs: Fix README.md typo (#3412) 2021-12-09 16:53:17 +01:00
Balázs Orbán
30a0fc6bc0 fix: properly handle callback URL fallback (#3402)
* fix: don't default to localhost on `host`

* fall back to `host` for `callbackUrl`

* use parsed host

* remove unnecessary type cast
2021-12-08 18:20:33 +01:00
Balázs Orbán
b0f6175cec chore(deps): upgrade next dev dependency 2021-12-08 17:50:25 +01:00
Balázs Orbán
1c7fe57edb fix: default to VERCEL_URL for callbackUrl 2021-12-08 17:43:49 +01:00
Balázs Orbán
59797bbdef fix: use VERCEL_URL by default for secureCookie (#3399) 2021-12-08 17:22:57 +01:00
Paul Büchner
9eb78a9de9 chore: fix typo in comment (#3388) 2021-12-08 03:07:26 +01:00
Balázs Orbán
2670bbb28f docs: match docs page wording for SECURITY.md 2021-12-06 21:05:41 +01:00
DmitryScaletta
0431c2a334 fix(ts): improve types for encode/decode functions (#3346)
* fix: improve types for encode/decode functions

* fix: use Awaitable type for encode/decode functions
2021-12-04 02:09:48 +01:00
Rraji Abdelbari
5ac688cc18 fix(providers): convert 42 School profile id to string (#3351) 2021-12-04 02:08:48 +01:00
Anthony Ringoet
8ea75f0c1c fix(ts): typo in Auth0Profile interface (#3347) 2021-12-04 02:06:23 +01:00
dnikomon
4dcdb62dca fix: remove nextauth from authorization params (#3332)
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-12-02 19:43:42 +01:00
Nico Domino
1f4b7d8089 chore: add opencollective to package.json (#3333) 2021-12-02 12:50:14 +01:00
Balázs Orbán
fedb84872d docs: add top contributors to package.json 2021-12-01 16:44:20 +01:00
Balázs Orbán
c0dddfb77f docs: upgrade README 2021-12-01 16:40:21 +01:00
Balázs Orbán
50fe115df6 Release v4 2021-12-01 16:32:35 +01:00
Jameel Khan
cc17ddf8aa fix: Fallback to --color-text when no color-brand (#3313) 2021-12-01 15:01:11 +01:00
Balázs Orbán
8644e553ed Merge branch 'main' into beta 2021-11-30 19:20:56 +01:00
Nisala Kalupahana
d1d0db43ea feat(providers): ensure GitHub provider always gives an email (#3302)
* Ensure that GitHub provider always gives an email

* Update src/providers/github.js

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

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-11-29 23:58:49 +01:00
Balázs Orbán
b01f6805d3 chore(providers): TS improvements (#3295) 2021-11-28 17:52:56 +01:00
Balázs Orbán
c44b860b9e feat(providers): refactor Apple provider (#2875)
* chore: remove legacy code

* fix(providers): refactor Apple provider

* chore(dev): add Apple provider

* docs(providers): add `generateClientSecret` to JSDoc

* fix(providers): use `jose@4`

* fix(providers): use seconds since epoch, correct sign

* chore(providers): move secret generator into a script
2021-11-28 17:52:24 +01:00
Khánh Hoàng
22f74d7c4d fix(providers): correct authorization url for Atlassian (#2999)
* fix(provider): correct authorization for Atlassian

* feat(providers): use wellKnown for better configuration

* fix(atlassian): switch back to raw config

* fix(providers): pass generic to `OAuthUserConfig`

Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-11-28 15:49:24 +01:00
Balázs Orbán
2570168660 fix: add custom error message when session required (#3288) 2021-11-28 15:38:02 +01:00
Balázs Orbán
187a1474f5 feat(oauth): expose httpOptions (#3287) 2021-11-26 23:40:58 +01:00
Kevin McKernan
4dc76749f2 fix(providers): Rewrite EVEOnline in TS, fix default scopes (#2759)
* refactor EVEOnlineProvider into typescript, fix default scopes

* Update src/providers/eveonline.ts

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

* update to new OIDC SSO endpoints

* set idToken: true

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-11-26 19:20:40 +01:00
Torben
35ee608d59 feat(providers): add Osu! provider (#3234) 2021-11-20 14:49:51 +01:00
Estevan Jantsk
0f132de115 feat(providers): add Pipedrive provider (#3011)
* Add Pipedrive as a provider

* convert pipedrive provider to ts

* remove others interface

* refactor(pipedrive): run prettier

Co-authored-by: Lluis Agusti <hi@llu.lu>
2021-11-17 23:07:29 +01:00
Balázs Orbán
31426b9435 fix(providers): match filename with 42 Provider's id (#3225) 2021-11-17 23:03:56 +01:00
Balázs Orbán
64b2a2c43b fix: assert action when req.query isn't available (#3222)
* fix: assert `action` if `req.query` unavailable

* refactor: make `method` externally optional
2021-11-17 22:47:12 +01:00
Balázs Orbán
7beb3ff03b refactor(providers): cleanup 42 (#3221) 2021-11-17 10:15:59 +01:00
Richard van der Dys
432876c011 fix(providers): refactor Zoom
* Added support for zoom in beta

* Converted to typescript

* rename

* Now reflects response from Zoom

* chore: Prettier

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-11-16 23:37:03 +01:00
Balázs Orbán
15d1fab4c8 fix: correct assertion when Credentials only (#3217) 2021-11-16 23:16:12 +01:00
Rraji Abdelbari
5e803cd34c refactor(providers): convert 42 to TypeScript (#3211) 2021-11-16 21:57:53 +01:00
Balázs Orbán
76bf524e8e feat: make missing secret an error (#3143)
BREAKING CHANGE:

It is now required to set a `secret` in production.
2021-11-15 18:45:56 +01:00
Balázs Orbán
f9e0ef8d18 feat: introduce chunking when session cookie becomes too big (#3101)
If the expected cookie size would exceed the 4096 bytes most browsers allow, we split up the cookie value and put the content into multiple cookies, then assemble it upon reading it back. This eliminates the need for a database or user-land solutions in case the user wants to save more data or is constrained by their IdP for certain fields.
2021-11-15 10:30:26 +01:00
Rraji Abdelbari
38cefdd548 fix(providers): set 42 default scope (#3189)
With no scope defined, it sets `openid` by default, which is an invalid 42 scope.

Co-authored-by: Alaa Zorkane <alaazorkane@gmail.com>

Co-authored-by: Alaa Zorkane <alaazorkane@gmail.com>
2021-11-13 11:14:29 +01:00
Balázs Orbán
b871b47d8b fix: allow configuring http timeout (#3188) 2021-11-12 12:58:08 +01:00
Balázs Orbán
043b252940 refactor: decouple CSRF-state (#3142)
* refactor: decouple csrf token from state

* refactor: simplify pkce-handler
2021-11-11 22:30:19 +01:00
Balázs Orbán
e9ac11b4b2 fix: respect host in getServerSession (#3179) 2021-11-11 11:27:14 +01:00
Balázs Orbán
ba39efb256 feat: rename session strategy (#3144)
BREAKING CHANGE:

The `session.jwt: boolean` option has been renamed to `session.strategy: "jwt" | "database"`. The goal is to make the user's options more intuitive:

1. No adapter, `strategy: "jwt"`: This is the default. The session is saved in a cookie and never persisted anywhere.
2. With Adapter, `strategy: "database"`: If an Adapter is defined, this will be the implicit setting. No user config is needed.
3. With Adapter, `strategy: "jwt"`: The user can explicitly instruct `next-auth` to use JWT even if a database is available. This can result in faster lookups in compromise of lowered security. Read more about: https://next-auth.js.org/faq#json-web-tokens

Example:

```diff
session: {
-  jwt: true,
+ strategy: "jwt",
}
```
2021-11-07 21:06:10 +01:00
Balázs Orbán
6502b63e9c feat: allow relative redirects (#3140) 2021-11-07 17:40:13 +01:00
Balázs Orbán
0d7d8da2d9 fix: use error query param if set (#3141) 2021-11-07 17:37:09 +01:00
Mathis Møller
f998bf2768 refactor: strict types (#2802)
* WIP strict types

* wip types

* wip strict types

* More strict typing

* Removing strict false
Fix last types

* Fix typo

* Make TS happy

* Fix tests

* Fixes to types

* Make files align with strict mode
2021-11-04 20:01:45 +01:00
Kovacs Nicolas
78fa33312f docs(readme): opencollective domain (#3066)
I had 502 using `opencollective.org` for some time, also, the correct domain looks like `opencollective.com`
2021-11-04 08:16:30 +01:00
Nico Domino
533ed949b3 feat: Clerk to README supporters
Added Clerk to supporters section!
2021-11-03 22:53:38 +01:00
Balázs Orbán
1597369d30 fix: correctly transpile all client-side submodules (#3100) 2021-11-03 18:31:02 +01:00
Balázs Orbán
41819882be fix(oauth): allow 10 sec clock tolerance (#3071) 2021-10-31 14:57:25 +01:00
Srijan Sharma
b66afcc5cc fix: normalize URL before parsing (#3077) 2021-10-31 10:35:02 +01:00
Filip Skokan
da991de8a4 fix: bump openid-client (#3063)
fixes #3052
2021-10-29 14:10:14 +02:00
Balázs Orbán
1d9b7b82b9 feat(react): preserve history on client-side navigation (#2980)
* feat(react): preserve history on client-side navigation

* chore(deps): upgrade jest

* test(client): use absolute URL since `whatwg-*` refusing relative URLs
2021-10-29 12:55:53 +02:00
Filip Skokan
c089ede3af refactor: use universal modules in next-auth/jwt (#3062) 2021-10-29 12:45:47 +02:00
Thang Vu
5725931406 fix(providers): add default id_token_signed_response_alg to LINE (#3059)
* Add default value for client in Line Provider

* Migrate to TypeScript
2021-10-29 10:33:25 +02:00
Haye
c8b7e2e3cb fix: uuid import (#3056) 2021-10-28 22:33:13 +02:00
Filip Skokan
72408ab7d7 feat: update jose and openid-client (#3039)
Updates the `jose` and `openid-client` packages.

BREAKING CHANGE:

The `jwt` option has been simplified and the NextAuth.js issued JWT is now encrypted by default.

If you want to override the defaults, you can still use the `encode` and `decode` functions. These are advanced options and they should only be used if you know what you are doing.

The default secret generation has been removed in this PR, which will be added back in a separate one. Remember, that is only for developer convenience, it is **highly** recommended to always create your own secret for production.
2021-10-27 22:09:46 +02:00
Balázs Orbán
eb33c9db1d refactor: decouple Next.js from core (#2857)
* refactor: decouple Next.js from core (WIP)

* refactor: use `base` instead of `baseUrl`+`basePath`

* fix: signout route

* refactor(ts): convert files to TS

* fix: imports

* refactor: convert callback route

* fix: add `next` files to package

* chore(dev): alias npm email

* refactor: do not merge req with user options

* refactor: rename userOptions to options

* refactor: use native `URL` in `parseUrl`

* refactor: move Next.js specific code to `next` module

* refactor(ts): return `OutgoingResponse` on all routes

* fix: change `base` to `url`

* feat: introduce `getServerSession`

* refactor: move main logic to `handler` file

* chore(dev): showcase `getServerSession`

* feat: extract `sessionToken` from Authorization header

* fix: pass headers to getServerSession

* refactor: rename `server` to `core`

* refactor: re-export `next-auth/next` in `next-auth`

* fix: add `core` to npm package

* fix: re-export default method

* feat: return `body`+`header` instead of `json`,`text`

* feat: pass `NEXTAUTH_URL` as a variable to core

* refactor: simplify Next.js wrapper

* feat: export `client/_utils`

* fix(ts): suppress TS errors
2021-10-27 16:11:58 +02:00
Tania
932d05da70 docs: mention other repos in readme and issue forms (#2989)
* Update bug_report.yaml

Add information about distributing issues to the correct repo

* fix yaml syntax

* remove new line

* improve content

* Import content

* remove one emoji

* Update feature_request.yaml

* Update README.md
2021-10-22 09:40:18 +02:00
Balázs Orbán
58a98b667d fix(providers): resize default AzureAD profile picture (#2910)
* Update azure-ad.js

* fix: default azure AD profile photo size

Co-authored-by: ndom91 <yo@ndo.dev>
2021-10-10 18:47:51 +02:00
Thang Vu
129d161115 fix(providers): Refactor Line Provider (#2917)
* feat(providers): Refactor Line Provider

* Use static wellKnown + retrieve email

* Remove issuer
2021-10-08 16:09:38 +02:00
Nico Domino
19e326e8e2 fix: conditionally render theme logo on builtin pages (#2916)
* fix: conditionally render theme logo

* fix: add dispaly to render span

* fix: theme-logo img display
2021-10-08 04:43:53 +02:00
Balázs Orbán
a0b9577267 chore(deps): upgrade dependencies (#2900) 2021-10-06 01:42:29 +02:00
Balázs Orbán
dfff2e692f fix: correctly set authorization url for OAuth1 (#2884) 2021-10-03 15:26:14 +02:00
Balázs Orbán
5149a5d865 chore: trigger CI 2021-10-03 13:29:49 +02:00
Gegham Zakaryan
0707ba663b fix(signin): Set default input type to text to fix CSS (#2881) 2021-10-03 01:01:34 +02:00
Gegham Zakaryan
c5bd99d92a feat(signin): Support passing any argument to credentials input (#2876)
Spreading the object into the input tag allows developers to specify any attribute for the input tag used in the builtin sign-in page, such as 'autocomplete', 'autofocus', etc.

Removed the hardcoded attributes which just set the defaults of the 'input' tag and won't cause any behavior change in case they are absence from the object.

Signed-off-by: Gegham Zakaryan <zakaryan.2004@outlook.com>
2021-10-01 23:25:35 +02:00
Balázs Orbán
72d4c5bfe1 feat(providers): refactor Azure B2C provider (#2862)
* fix(providers): refactor AzureB2C provider

* chore(dev): add Azure B2C to the dev app

* chore(providers): remove unnecessary config
2021-09-29 23:43:42 +02:00
Kiran Jd
f6350354f0 fix(provider): refactor Okta provider (#2856)
* fix(provider): refactor Okta provider

* fix(providers): convert Okta to TS

* fix: typo

* fix(okta): adds picture to profile

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

* fix(provider): refactor Okta provider

fix(providers): convert Okta to TS

fix: typo

* fix: resolves merge conflicts

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2021-09-28 20:59:57 +02:00
Balázs Orbán
50e6a64832 feat(providers): expose openid-client options client and jwks (#2717) 2021-09-28 17:32:47 +02:00
Balázs Orbán
9e1eab088a fix(providers): convert to TS, add picture (#2851) 2021-09-28 14:47:33 +02:00
Balázs Orbán
f0551b07b8 feat(providers): refactor Slack provider (#2848)
* chore(dev): add SlackProvider to dev app

* feat(providers): refactor Slack provider

* chore(dev): remove unwanted provider
2021-09-28 14:47:18 +02:00
Balázs Orbán
c2fd58d72a chore: remove unused www reference 2021-09-28 01:53:09 +02:00
Balázs Orbán
b052d4cfc1 fix(providers): make string endpoint handlers overrideable (#2842)
* chore: remove `console.log`

* chore(ts): improve `InternalProvider` type

* refactor(ts): convert some files to TypeScript

* fix(providers): make string endpoint handlers overrideable
2021-09-26 22:02:21 +02:00
Balázs Orbán
506672676a feat(providers): refactor Cognito provider (#2829)
* chore(dev): add CognitoProvider to dev app

* feat(log): log `error_description` in OAuth callback

* fix(providers): migrate Cognito to v4

* docs: mention superblog.ai for infra support

* fix: return profile picture for Cognito

* fix(ts): add picture to CognitoProfile
2021-09-25 14:14:56 +02:00
Nico Domino
ffa2b1bd6b fix: use default export map syntax for ESM/CJS (#2830) 2021-09-25 14:01:28 +02:00
Balázs Orbán
1d52600f41 fix(provider): refactor LinkedIn provider (#2821) 2021-09-24 11:27:57 +02:00
Balázs Orbán
9693277222 chore(app): add SpotifyProvider to dev app 2021-09-23 00:43:28 +02:00
Balázs Orbán
19a33f3131 chore(dev): add AzureADProvider to dev app 2021-09-22 23:55:49 +02:00
Nico Domino
424bd04eff fix(providers): refactor Azure AD provider to support v4 (#2818) 2021-09-22 23:35:13 +02:00
ndom91
a177bbb68c fix: login logo height 2021-09-22 22:21:45 +02:00
Jussi Räsänen
04fc3fd6bc fix(provider): remove extra brace from jwks_uri (#2813) 2021-09-22 16:17:43 +02:00
Nico Domino
cabcdc967f feat: built-in page theme updates (#2788)
Add some very minimal customization to the built-in pages so people might not immediately need to replace them. This way they can customize some things with their brand color and add their company/project logo. We explicitly **do not** want to go overboard styling this page. This is not an authentication component library or Next.js app template!

Example:
```js
export default NextAuth({
  providers: [...],
  jwt: {...},
  theme: {
    colorScheme: 'auto',
    brandColor: '#67b246',
    logo: 'https://company.com/assets/logo.png'
  }
})
```
2021-09-20 00:48:36 +02:00
Balázs Orbán
a2c4046772 fix(ts): add defaults to OAuthConfig generics 2021-09-15 10:24:26 +02:00
Balázs Orbán
ea3f0d6911 refactor(ts): move Twitch to TypeScript 2021-09-15 10:19:35 +02:00
Mathis Møller
819e97e6d2 fix: respect id from user options in signinUrl and callbackUrl id (#2698) 2021-09-08 20:31:24 +02:00
Gianluca
e8a58a01b6 docs(contributing): fixed numeration type (#2624)
There was a numeration type error in the "For  contributors" section
2021-08-29 11:30:05 +02:00
Nico Domino
91de463a5e docs(providers): add tip about async provider code (#2443) 2021-08-26 23:45:07 +02:00
Nico Domino
4a9d871698 docs(www): add more algolia no-result terms (#2442) 2021-08-26 23:41:49 +02:00
Alex Vilchis
c2119b15de chore(docs): fix dependency name (#2607) 2021-08-26 19:42:20 +02:00
Alex Vilchis
0ce15c4a18 docs: Fix grammar (#2602) 2021-08-25 19:48:14 +02: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
142 changed files with 9165 additions and 14354 deletions

View File

@@ -10,7 +10,18 @@ body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report! Please provide the following information:
Thanks for taking the time to fill out this bug report!
### Important :exclamation:
Please help us maintain this project more efficiently!
Is this your first time contributing? See this video https://www.youtube.com/watch?v=cuoNzXFLitc
**Providing incorrect/insufficient information or skipping steps to reproduce the bug may result in closing the issue or converting to discussion without further explanation.**
Before creating the issue make sure you shouldn't be creating it in one the below repos instead:
- Docs related: https://github.com/nextauthjs/docs
- Adapter related: https://github.com/nextauthjs/adapters
If you are in the correct repo, then proceed by providing the following information:
- type: textarea
id: description
attributes:
@@ -44,8 +55,6 @@ body:
We encourage you to use one of the templates set up on **CodeSandbox** to reproduce your issue:
- [`next-auth-example`](https://codesandbox.io/s/next-auth-example-1kktb)
- [`next-auth-typescript-example`](https://codesandbox.io/s/next-auth-typescript-example-se32w)
🚧 _If you don't provide any way to reproduce the bug, the issue is at risk of being closed._
- type: textarea
id: logs

View File

@@ -9,8 +9,14 @@ body:
- type: markdown
attributes:
value: |
Thank you very much for reaching out to us regarding the awesome feature that you believe should be included in the NextAuth.js library. Please provide the following information:
Thank you very much for reaching out to us regarding the awesome feature that you believe should be included in the NextAuth.js library.
### Important :exclamation:
Please help us maintain this project more efficiently! Before creating the issue make sure you shouldn't be creating it in one the below repos instead:
- Docs related: https://github.com/nextauthjs/docs
- Adapter related: https://github.com/nextauthjs/adapters
If you are in the correct repo, then proceed by providing the following information:
- type: textarea
id: description
attributes:
@@ -65,4 +71,3 @@ body:
attributes:
value: |
It takes a lot of work 🏋🏻‍♀️ maintaining a library like `next-auth`; any contribution is more than welcome 💚

10
.gitignore vendored
View File

@@ -29,12 +29,15 @@ node_modules
/client
/css
/lib
/server
/core
/jwt
/react
/adapters.d.ts
/index.d.ts
/index.js
/next
/middleware.d.ts
/middleware.js
# Development app
app/src/css
@@ -42,6 +45,8 @@ app/package-lock.json
app/yarn.lock
app/prisma/migrations
app/prisma/dev.db*
app/dist
app/next-auth
# VS
/.vs/slnx.sqlite-journal
@@ -49,6 +54,9 @@ app/prisma/dev.db*
/.vs
.vscode
# Jetbrains
.idea
# GitHub Actions runner
/actions-runner
/_work

View File

@@ -32,6 +32,11 @@ NextAuth.js is a complete open source authentication solution for [Next.js](http
It is designed from the ground up to support Next.js and Serverless.
This is the core repo for NextAuth.js. Check the repos below if you are interested in additional information:
- Docs related: https://github.com/nextauthjs/docs
- Adapter related: https://github.com/nextauthjs/adapters
## Getting Started
```
@@ -49,7 +54,7 @@ See [next-auth.js.org](https://next-auth.js.org) for more information and docume
### Flexible and easy to use
- Designed to work with any OAuth service, it supports OAuth 1.0, 1.0A and 2.0
- Built-in support for [many popular sign-in services](https://next-auth.js.org/configuration/providers)
- Built-in support for [many popular sign-in services](https://next-auth.js.org/providers/overview)
- Supports email / passwordless authentication
- Supports stateless authentication with any backend (Active Directory, LDAP, etc)
- Supports both JSON Web Tokens and database sessions
@@ -81,7 +86,8 @@ Advanced options allow you to define your own routines to handle controlling wha
### TypeScript
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.
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.
## Example
@@ -90,21 +96,24 @@ NextAuth.js comes with built-in types. For more information and usage, check out
```javascript
// pages/api/auth/[...nextauth].js
import NextAuth from "next-auth"
import Providers from "next-auth/providers"
import AppleProvider from "next-auth/providers/apple"
import GoogleProvider from "next-auth/providers/google"
import EmailProvider from "next-auth/providers/email"
export default NextAuth({
secret: process.env.SECRET,
providers: [
// OAuth authentication providers
Providers.Apple({
AppleProvider({
clientId: process.env.APPLE_ID,
clientSecret: process.env.APPLE_SECRET,
}),
Providers.Google({
GoogleProvider({
clientId: process.env.GOOGLE_ID,
clientSecret: process.env.GOOGLE_SECRET,
}),
// Sign in with passwordless email link
Providers.Email({
EmailProvider({
server: process.env.MAIL_SERVER,
from: "<no-reply@example.com>",
}),
@@ -140,7 +149,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.
Use the `<SessionProvider>` to allow 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"
@@ -170,7 +179,7 @@ export default function App({
### Support
We're happy to announce we've recently created an [OpenCollective](https://opencollective.org/nextauth) for individuals and companies looking to contribute financially to the project!
We're happy to announce we've recently created an [OpenCollective](https://opencollective.com/nextauth) for individuals and companies looking to contribute financially to the project!
<!--sponsors start-->
<table>
@@ -190,6 +199,13 @@ We're happy to announce we've recently created an [OpenCollective](https://openc
<div>Prisma</div><br />
<sub>🥉 Bronze Financial Sponsor</sub>
</td>
<td align="center" valign="top">
<a href="https://clerk.dev" target="_blank">
<img width="128px" src="https://avatars.githubusercontent.com/u/49538330?s=200&v=4" alt="Prisma Logo" />
</a><br />
<div>Clerk</div><br />
<sub>🥉 Bronze Financial Sponsor</sub>
</td>
<td align="center" valign="top">
<a href="https://checklyhq.com" target="_blank">
<img width="128px" src="https://avatars.githubusercontent.com/u/25982255?v=4" alt="Checkly Logo" />
@@ -197,6 +213,13 @@ 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>
@@ -205,7 +228,8 @@ We're happy to announce we've recently created an [OpenCollective](https://openc
## Contributing
We're open to all community contributions! If you'd like to contribute in any way, please first read our [Contributing Guide](https://github.com/nextauthjs/next-auth/blob/canary/CONTRIBUTING.md).
We're open to all community contributions! If you'd like to contribute in any way, please first read
our [Contributing Guide](https://github.com/nextauthjs/next-auth/blob/main/CONTRIBUTING.md).
## License

View File

@@ -2,12 +2,6 @@
NextAuth.js practices responsible disclosure.
## Supported Versions
Security updates are only released for the current version.
Old releases are not maintained and do not receive updates.
## Reporting a Vulnerability
We request that you contact us directly to report serious issues that might impact the security of sites using NextAuth.js.
@@ -19,6 +13,12 @@ If you contact us regarding a serious issue:
- We will disclose the issue (and credit you, with your consent) once a fix to resolve the issue has been released.
- If 90 days has elapsed and we still don't have a fix, we will disclose the issue publicly.
Currently, the best way to report an issue is by contacting us via email at me@iaincollins.com or info@balazsorban.com and yo@ndo.dev.
The best way to report an issue is by contacting us via email at info@balazsorban.com or me@iaincollins.com and yo@ndo.dev, or raise a public issue requesting someone get in touch with you via whatever means you prefer for more details. (Please do not disclose sensitive details publicly at this stage.)
For less serious issues (e.g. RFC compliance for unsupported flows or potential issues that may cause a problem future or default behaviour / options) it is appropriate to submit these these publically as bug reports or feature requests or to raise a question to open a discussion around them.
> For less serious issues (e.g. RFC compliance for unsupported flows or potential issues that may cause a problem in the future) it is appropriate to submit these these publically as bug reports or feature requests or to raise a question to open a discussion around them.
## Supported Versions
Security updates are only released for the current version.
Old releases are not maintained and do not receive updates.

View File

@@ -7,7 +7,7 @@ NEXTAUTH_URL=http://localhost:3000
# https://generate-secret.vercel.app/32 to generate a secret.
# Note: Changing a secret may invalidate existing sessions
# and/or verification tokens.
SECRET=secret
NEXTAUTH_SECRET=secret
AUTH0_ID=
AUTH0_SECRET=
@@ -30,6 +30,12 @@ TWITCH_SECRET=
TWITTER_ID=
TWITTER_SECRET=
LINE_ID=
LINE_SECRET=
TRAKT_ID=
TRAKT_SECRET=
# Example configuration for a Gmail account (will need SMTP enabled)
EMAIL_SERVER=smtps://user@gmail.com:password@smtp.gmail.com:465
EMAIL_FROM=user@gmail.com

View File

@@ -39,15 +39,13 @@ export default function Header() {
{session && (
<>
{session.user.image && (
<span
style={{ backgroundImage: `url(${session.user.image})` }}
className={styles.avatar}
/>
<img src={session.user.image} className={styles.avatar} />
)}
<span className={styles.signedInText}>
<small>Signed in as</small>
<br />
<strong>{session.user.email || session.user.name}</strong>
<strong>{session.user.email} </strong>
{session.user.name ? `(${session.user.name})` : null}
</span>
<a
href="/api/auth/signout"
@@ -105,6 +103,11 @@ export default function Header() {
<a>Email</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href="/middleware-protected">
<a>Middleware protected</a>
</Link>
</li>
</ul>
</nav>
</header>

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

@@ -1,5 +1,4 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited

View File

@@ -2,6 +2,10 @@ const path = require("path")
module.exports = {
webpack(config) {
config.experiments = {
...config.experiments,
topLevelAwait: true,
}
config.resolve = {
...config.resolve,
alias: {
@@ -18,6 +22,9 @@ module.exports = {
return config
},
typescript: {
ignoreBuildErrors: true,
},
experimental: {
externalDir: true,
},

View File

@@ -5,28 +5,31 @@
"private": true,
"scripts": {
"clean": "rm -rf .next",
"postinstall": "rm -rf node_modules/next-auth",
"dev": "npm-run-all --parallel dev:next watch:css copy:css ",
"dev:next": "next dev",
"build": "next build",
"copy:css": "cpx \"../css/**/*\" src/css --watch",
"watch:css": "cd .. && npm run watch:css",
"start": "next start",
"start:email": "npx fake-smtp-server"
"email": "npx fake-smtp-server",
"start:email": "npm run email"
},
"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",
"@next-auth/fauna-adapter": "^1.0.1",
"@next-auth/prisma-adapter": "^1.0.1",
"@prisma/client": "^3.7.0",
"fake-smtp-server": "^0.8.0",
"faunadb": "^4.3.0",
"next": "^11.1.0",
"nodemailer": "^6.6.3",
"faunadb": "^4.4.1",
"next": "^12.0.8",
"nodemailer": "^6.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"cpx": "^1.5.0",
"npm-run-all": "^4.1.5",
"prisma": "^2.29.1"
"prisma": "^3.7.0"
}
}

View File

@@ -1,12 +1,9 @@
import { SessionProvider } from "next-auth/react"
import "./styles.css"
export default function App({
Component,
pageProps: { session, ...pageProps },
}) {
export default function App({ Component, pageProps }) {
return (
<SessionProvider session={session}>
<SessionProvider session={pageProps.session}>
<Component {...pageProps} />
</SessionProvider>
)

View File

@@ -1,9 +1,11 @@
import NextAuth from "next-auth"
import EmailProvider from "next-auth/providers/email"
import NextAuth, { NextAuthOptions } 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 TwitterProvider, {
TwitterLegacy as TwitterLegacyProvider,
} 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"
@@ -17,6 +19,16 @@ 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 OsuProvider from "next-auth/providers/osu"
import AppleProvider from "next-auth/providers/apple"
import PatreonProvider from "next-auth/providers/patreon"
import TraktProvider from "next-auth/providers/trakt"
// import { PrismaAdapter } from "@next-auth/prisma-adapter"
// import { PrismaClient } from "@prisma/client"
@@ -31,29 +43,28 @@ import DiscordProvider from "next-auth/providers/discord"
// domain: process.env.FAUNA_DOMAIN,
// })
// const adapter = FaunaAdapter(client)
export default NextAuth({
export const authOptions: NextAuthOptions = {
// 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 },
},
}),
// 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") {
async authorize(credentials) {
if (credentials.password === "pw") {
return {
name: "Fill Murray",
email: "bill@fillmurray.com",
@@ -64,11 +75,17 @@ export default NextAuth({
},
}),
// OAuth 1
// TwitterLegacyProvider({
// clientId: process.env.TWITTER_LEGACY_ID,
// clientSecret: process.env.TWITTER_LEGACY_SECRET,
// }),
// OAuth 2 / OIDC
TwitterProvider({
// Opt-in to the new Twitter API for now. Should be default in the future.
version: "2.0",
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
}),
// OAuth 2 / OIDC
GitHubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
@@ -132,11 +149,59 @@ export default NextAuth({
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,
profilePhotoSize: 48,
}),
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,
}),
OsuProvider({
clientId: process.env.OSU_CLIENT_ID,
clientSecret: process.env.OSU_CLIENT_SECRET,
}),
AppleProvider({
clientId: process.env.APPLE_ID,
clientSecret: process.env.APPLE_SECRET,
}),
PatreonProvider({
clientId: process.env.PATREON_ID,
clientSecret: process.env.PATREON_SECRET,
}),
TraktProvider({
clientId: process.env.TRAKT_ID,
clientSecret: process.env.TRAKT_SECRET,
}),
],
jwt: {
encryption: true,
secret: process.env.SECRET,
},
debug: true,
theme: "auto",
})
theme: {
colorScheme: "auto",
logo: "https://next-auth.js.org/img/logo/logo-sm.png",
brandColor: "#1786fb",
},
}
export default NextAuth(authOptions)

View File

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

View File

@@ -0,0 +1,44 @@
export { default } from "next-auth/middleware"
// Other ways to use this middleware
// import withAuth from "next-auth/middleware"
// import { withAuth } from "next-auth/middleware"
// export function middleware(req, ev) {
// return withAuth(req)
// }
// export function middleware(req, ev) {
// return withAuth(req, ev)
// }
// export function middleware(req, ev) {
// return withAuth(req, {
// callbacks: {
// authorized: ({ token }) => !!token,
// },
// })
// }
// export default withAuth(function middleware(req, ev) {
// console.log(req.nextauth.token)
// })
// export default withAuth(
// function middleware(req, ev) {
// console.log(req, ev)
// return undefined // NOTE: `NextMiddleware` should allow returning `void`
// },
// {
// callbacks: {
// authorized: ({ token }) => token.name === "Balázs Orbán",
// }
// }
// )
// export default withAuth({
// callbacks: {
// authorized: ({ token }) => !!token,
// },
// })

View File

@@ -0,0 +1,9 @@
import Layout from "components/layout"
export default function Page() {
return (
<Layout>
<h1>Page protected by Middleware</h1>
</Layout>
)
}

View File

@@ -1,5 +1,6 @@
// This is an example of how to protect content using server rendering
import { getSession } from "next-auth/react"
import { getServerSession } from "next-auth/next"
import { authOptions } from "./api/auth/[...nextauth]"
import Layout from "../components/layout"
import AccessDenied from "../components/access-denied"
@@ -25,7 +26,7 @@ export default function Page({ content, session }) {
}
export async function getServerSideProps(context) {
const session = await getSession(context)
const session = await getServerSession(context, authOptions)
let content = null
if (session) {

View File

@@ -4,7 +4,7 @@ body {
max-width: 680px;
margin: 0 auto;
background: #fff;
color: #333;
color: var(--color-text);
}
li,

View File

@@ -16,6 +16,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {

View File

@@ -31,14 +31,19 @@ module.exports = (api) => {
comments: false,
overrides: [
{
test: ["../src/react/index.tsx"],
test: [
"../src/react/index.tsx",
"../src/lib/logger.ts",
"../src/core/errors.ts",
"../src/client/**",
],
presets: [
["@babel/preset-env", { targets: { ie: 11 } }],
["@babel/preset-react", { runtime: "automatic" }],
],
},
{
test: ["../src/server/pages/*.tsx"],
test: ["../src/core/pages/*.tsx"],
presets: ["preact"],
plugins: [
[

View File

@@ -12,4 +12,8 @@ module.exports = {
testMatch: ["**/*.test.js"],
coverageDirectory: "../coverage",
testEnvironment: "jsdom",
watchPlugins: [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname",
],
}

16173
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,11 @@
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",
"author": "Iain Collins <me@iaincollins.com>",
"contributors": [
"Balázs Orbán <info@balazsorban.com>",
"Nico Domino <yo@ndo.dev>",
"Lluis Agusti <hi@llu.lu>"
],
"main": "index.js",
"module": "index.js",
"types": "index.d.ts",
@@ -21,22 +26,18 @@
"nextauth"
],
"exports": {
".": {
"import": "./index.js"
},
"./jwt": {
"import": "./jwt/index.js"
},
"./react": {
"import": "./react/index.js"
},
"./providers/*": {
"import": "./providers/*.js"
}
".": "./index.js",
"./jwt": "./jwt/index.js",
"./react": "./react/index.js",
"./core": "./core/index.js",
"./next": "./next/index.js",
"./middleware": "./middleware.js",
"./client/_utils": "./client/_utils.js",
"./providers/*": "./providers/*.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",
"clean": "rm -rf client css lib providers core jwt react next index.d.ts index.js adapters.d.ts middleware.d.ts middleware.js",
"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",
@@ -55,26 +56,32 @@
"css",
"jwt",
"react",
"next",
"client",
"providers",
"server",
"core",
"index.d.ts",
"index.js",
"adapters.d.ts"
"adapters.d.ts",
"middleware.d.ts",
"middleware.js"
],
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.14.6",
"futoin-hkdf": "^1.3.3",
"jose": "^1.27.2",
"@babel/runtime": "^7.16.3",
"@panva/hkdf": "^1.0.1",
"cookie": "^0.4.1",
"jose": "^4.3.7",
"oauth": "^0.9.15",
"openid-client": "^4.7.4",
"preact": "^10.5.13",
"preact-render-to-string": "^5.1.19"
"openid-client": "^5.1.0",
"preact": "^10.6.3",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"nodemailer": "^6.6.2",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"nodemailer": "^6.6.5",
"react": "^17.0.2 || ^18.0.0-0",
"react-dom": "^17.0.2 || ^18.0.0-0"
},
"peerDependenciesMeta": {
"nodemailer": {
@@ -82,58 +89,60 @@
}
},
"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",
"@testing-library/user-event": "^13.1.9",
"@actions/core": "^1.6.0",
"@babel/cli": "^7.16.0",
"@babel/core": "^7.16.0",
"@babel/plugin-proposal-optional-catch-binding": "^7.16.0",
"@babel/plugin-transform-runtime": "^7.16.4",
"@babel/preset-env": "^7.16.4",
"@babel/preset-react": "^7.16.0",
"@babel/preset-typescript": "^7.16.0",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^13.5.0",
"@types/node": "^16.11.12",
"@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",
"@types/react": "^17.0.37",
"@types/react-dom": "^17.0.11",
"@typescript-eslint/parser": "^4.33.0",
"autoprefixer": "^10.4.0",
"babel-jest": "^27.4.2",
"babel-plugin-jsx-pragmatic": "^1.0.2",
"babel-preset-preact": "^2.0.0",
"conventional-changelog-conventionalcommits": "4.6.0",
"cssnano": "^5.0.6",
"eslint": "^7.29.0",
"conventional-changelog-conventionalcommits": "4.6.1",
"cssnano": "^5.0.12",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard-with-typescript": "^20.0.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-jest": "^24.3.6",
"eslint-config-standard-with-typescript": "^21.0.1",
"eslint-plugin-jest": "^25.3.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0",
"fs-extra": "^10.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",
"husky": "^7.0.4",
"jest": "^27.4.3",
"jest-watch-typeahead": "^1.0.0",
"msw": "^0.36.3",
"next": "12.0.9",
"postcss-cli": "^9.0.2",
"postcss-nested": "^5.0.6",
"prettier": "2.4.1",
"pretty-quick": "^3.1.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.3.5",
"typescript": "^4.5.2",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": "^12.19.0 || ^14.15.0 || ^16.13.0"
},
"prettier": {
"semi": false
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json"
"project": "./tsconfig.eslint.json"
},
"extends": [
"standard-with-typescript",
@@ -145,7 +154,7 @@
"types",
".next",
"dist",
"/server",
"/core",
"/react.js"
],
"globals": {
@@ -177,6 +186,11 @@
}
]
},
"eslintIgnore": [
"./*.d.ts",
"**/tests",
"**/__tests__"
],
"release": {
"branches": [
"+([0-9])?(.{+([0-9]),x}).x",
@@ -207,6 +221,10 @@
{
"type": "github",
"url": "https://github.com/sponsors/balazsorban44"
},
{
"type": "opencollective",
"url": "https://opencollective.com/nextauth"
}
]
}

View File

@@ -65,26 +65,28 @@ export const mockSignOutResponse = {
}
export const server = setupServer(
rest.post("/api/auth/signout", (req, res, ctx) =>
rest.post("http://localhost/api/auth/signout", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSignOutResponse))
),
rest.get("/api/auth/session", (req, res, ctx) =>
rest.get("http://localhost/api/auth/session", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockSession))
),
rest.get("/api/auth/csrf", (req, res, ctx) =>
rest.get("http://localhost/api/auth/csrf", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockCSRFToken))
),
rest.get("/api/auth/providers", (req, res, ctx) =>
rest.get("http://localhost/api/auth/providers", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockProviders))
),
rest.post("/api/auth/signin/github", (req, res, ctx) =>
rest.post("http://localhost/api/auth/signin/github", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockGithubResponse))
),
rest.post("/api/auth/callback/credentials", (req, res, ctx) =>
rest.post("http://localhost/api/auth/callback/credentials", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockCredentialsResponse))
),
rest.post("/api/auth/signin/email", (req, res, ctx) =>
rest.post("http://localhost/api/auth/signin/email", (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockEmailResponse))
),
rest.post("/api/auth/_log", (req, res, ctx) => res(ctx.status(200)))
rest.post("http://localhost/api/auth/_log", (req, res, ctx) =>
res(ctx.status(200))
)
)

View File

@@ -27,12 +27,22 @@ jest.mock("../../lib/logger", () => ({
beforeAll(() => {
server.listen()
let _href = window.location.href
// Allows to mutate `window.location`...
delete window.location
window.location = {
...location,
replace: jest.fn(),
reload: jest.fn(),
}
Object.defineProperty(window.location, "href", {
get: () => _href,
// whatwg-fetch or whatwg-url does not seem to work with relative URLs
set: (href) => {
_href = href.startsWith("/") ? `http://localhost${href}` : href
return _href
},
})
})
beforeEach(() => {
@@ -59,9 +69,10 @@ test.each`
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
`/api/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
expect(window.location.href).toBe(
`http://localhost/api/auth/signin?${new URLSearchParams({
callbackUrl,
})}`
)
})
}
@@ -76,14 +87,14 @@ test.each`
async ({ provider }) => {
render(<SignInFlow providerId={provider} />)
const callbackUrl = window.location.href
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
`/api/auth/signin?callbackUrl=${encodeURIComponent(
window.location.href
)}`
expect(window.location.href).toBe(
`http://localhost/api/auth/signin?${new URLSearchParams({
callbackUrl,
})}`
)
})
}
@@ -101,8 +112,7 @@ test.each`
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrl)
expect(window.location.href).toBe(mockUrl)
})
}
)
@@ -119,8 +129,7 @@ test("redirection can't be stopped using an oauth provider", async () => {
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockGithubResponse.url)
expect(window.location.href).toBe(mockGithubResponse.url)
})
})
@@ -136,9 +145,7 @@ test("redirection can be stopped using the 'credentials' provider", async () =>
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).not.toHaveBeenCalledWith(
mockCredentialsResponse.url
)
expect(window.location.href).not.toBe(mockCredentialsResponse.url)
expect(screen.getByTestId("signin-result").textContent).not.toBe(
"no response"
@@ -165,9 +172,7 @@ test("redirection can be stopped using the 'email' provider", async () => {
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).not.toHaveBeenCalledWith(
mockEmailResponse.url
)
expect(window.location.href).not.toBe(mockEmailResponse.url)
expect(screen.getByTestId("signin-result").textContent).not.toBe(
"no response"
@@ -190,7 +195,7 @@ test("if callback URL contains a hash we force a window reload when re-directing
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
server.use(
rest.post("/api/auth/signin/email", (req, res, ctx) => {
rest.post("http://localhost/api/auth/signin/email", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -206,8 +211,7 @@ test("if callback URL contains a hash we force a window reload when re-directing
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
expect(window.location.href).toBe(mockUrlWithHash)
// the browser will not refresh the page if the redirect URL contains a hash, hence we force it on the client, see #1289
expect(window.location.reload).toHaveBeenCalledTimes(1)
})
@@ -218,7 +222,7 @@ test("params are propagated to the signin URL when supplied", async () => {
const authParams = "foo=bar&bar=foo"
server.use(
rest.post("/api/auth/signin/github", (req, res, ctx) => {
rest.post("http://localhost/api/auth/signin/github", (req, res, ctx) => {
matchedParams = req.url.search
return res(ctx.status(200), ctx.json(mockGithubResponse))
})
@@ -237,7 +241,7 @@ test("when it fails to fetch the providers, it redirected back to signin page",
const errorMsg = "Error when retrieving providers"
server.use(
rest.get("/api/auth/providers", (req, res, ctx) =>
rest.get("http://localhost/api/auth/providers", (req, res, ctx) =>
res(ctx.status(500), ctx.json(errorMsg))
)
)
@@ -247,7 +251,7 @@ test("when it fails to fetch the providers, it redirected back to signin page",
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledWith(`/api/auth/error`)
expect(window.location.href).toBe(`http://localhost/api/auth/error`)
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", {
@@ -268,10 +272,7 @@ function SignInFlow({
async function handleSignIn() {
const result = await signIn(
providerId,
{
callbackUrl,
redirect,
},
{ callbackUrl, redirect },
authorizationParams
)

View File

@@ -10,11 +10,11 @@ const { location } = window
beforeAll(() => {
server.listen()
// Allows to mutate `window.location`...
delete window.location
window.location = {
...location,
replace: jest.fn(),
reload: jest.fn(),
href: location.href,
}
})
@@ -37,7 +37,7 @@ const callbackUrl = "https://redirects/to"
test("by default it redirects to the current URL if the server did not provide one", async () => {
server.use(
rest.post("/api/auth/signout", (req, res, ctx) =>
rest.post("http://localhost/api/auth/signout", (req, res, ctx) =>
res(ctx.status(200), ctx.json({ ...mockSignOutResponse, url: undefined }))
)
)
@@ -47,8 +47,7 @@ test("by default it redirects to the current URL if the server did not provide o
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(window.location.href)
expect(window.location.href).toBe(window.location.href)
})
})
@@ -58,10 +57,7 @@ test("it redirects to the URL allowed by the server", async () => {
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.replace).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(
mockSignOutResponse.url
)
expect(window.location.href).toBe(mockSignOutResponse.url)
})
})
@@ -69,7 +65,7 @@ test("if url contains a hash during redirection a page reload happens", async ()
const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz"
server.use(
rest.post("/api/auth/signout", (req, res, ctx) => {
rest.post("http://localhost/api/auth/signout", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
@@ -85,8 +81,7 @@ test("if url contains a hash during redirection a page reload happens", async ()
userEvent.click(screen.getByRole("button"))
await waitFor(() => {
expect(window.location.reload).toHaveBeenCalledTimes(1)
expect(window.location.replace).toHaveBeenCalledWith(mockUrlWithHash)
expect(window.location.href).toBe(mockUrlWithHash)
})
})

View File

@@ -5,24 +5,32 @@ import { SessionProvider, useSession, signOut } from "../../react"
import { server, mockSession } from "./helpers/mocks"
const origConsoleError = console.error
const origLocation = window.location
const locationReplace = jest.fn()
const { location } = window
let _href = window.location.href
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`...
// Allows to mutate `window.location`...
delete window.location
window.location = { ...origLocation, replace: locationReplace }
window.location = {}
Object.defineProperty(window.location, "href", {
get: () => _href,
// whatwg-fetch or whatwg-url does not seem to work with relative URLs
set: (href) => {
_href = href.startsWith("/") ? `http://localhost${href}` : href
return _href
},
})
server.listen()
})
afterEach(() => {
server.resetHandlers()
locationReplace.mockClear()
_href = "http://localhost/"
// clear the internal session cache...
signOut({ redirect: false })
@@ -30,7 +38,7 @@ afterEach(() => {
afterAll(() => {
console.error = origConsoleError
window.location = origLocation
window.location = location
server.close()
})
@@ -67,7 +75,7 @@ test("when session is fetched, `data` will contain the session data and `status`
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) =>
rest.get(`http://localhost/api/auth/session`, (_, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
@@ -84,11 +92,12 @@ test("when it fails to fetch the session, `data` will be null and `status` will
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) =>
rest.get(`http://localhost/api/auth/session`, (req, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
const callbackUrl = window.location.href
const { result } = renderHook(() => useSession({ required: true }), {
wrapper: SessionProvider,
})
@@ -98,25 +107,17 @@ test("it'll redirect to sign-in page if the session is required and the user is
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()
)
expect(window.location.href).toBe(
`http://localhost/api/auth/signin?${new URLSearchParams({
error: "SessionRequired",
callbackUrl,
})}`
)
})
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) =>
rest.get(`http://localhost/api/auth/session`, (_, res, ctx) =>
res(ctx.status(401), ctx.json({}))
)
)
@@ -135,8 +136,5 @@ test("will call custom redirect logic if supplied when the user could not authen
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)
})

View File

@@ -45,7 +45,7 @@ export async function fetchData<T = any>(
return Object.keys(data).length > 0 ? data : null // Return null if data empty
} catch (error) {
logger.error("CLIENT_FETCH_ERROR", {
error,
error: error as Error,
path,
...(req ? { header: req.headers } : {}),
})
@@ -84,9 +84,9 @@ export function BroadcastChannel(name = "nextauth.message") {
return {
/** Get notified by other tabs/windows. */
receive(onReceive: (message: BroadcastMessage) => void) {
const handler = (event) => {
const handler = (event: StorageEvent) => {
if (event.key !== name) return
const message: BroadcastMessage = JSON.parse(event.newValue)
const message: BroadcastMessage = JSON.parse(event.newValue ?? "{}")
if (message?.event !== "session" || !message?.data) return
onReceive(message)
@@ -95,7 +95,7 @@ export function BroadcastChannel(name = "nextauth.message") {
return () => window.removeEventListener("storage", handler)
},
/** Notify other tabs/windows. */
post(message) {
post(message: Record<string, unknown>) {
if (typeof window === "undefined") return
localStorage.setItem(
name,

View File

@@ -1,15 +1,17 @@
import { EventCallbacks, LoggerInstance } from ".."
import { Adapter } from "../adapters"
import type { EventCallbacks, LoggerInstance } from ".."
import type { Adapter } from "../adapters"
/**
* 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) {
code: string
constructor(error: Error | string) {
// Support passing error or string
super(error?.message ?? error)
super((error as Error)?.message ?? error)
this.name = "UnknownError"
this.code = (error as any).code
if (error instanceof Error) {
this.stack = error.stack
}
@@ -36,6 +38,31 @@ export class AccountNotLinkedError extends UnknownError {
name = "AccountNotLinkedError"
}
export class MissingAPIRoute extends UnknownError {
name = "MissingAPIRouteError"
code = "MISSING_NEXTAUTH_API_ROUTE_ERROR"
}
export class MissingSecret extends UnknownError {
name = "MissingSecretError"
code = "NO_SECRET"
}
export class MissingAuthorize extends UnknownError {
name = "MissingAuthorizeError"
code = "CALLBACK_CREDENTIALS_HANDLER_ERROR"
}
export class MissingAdapter extends UnknownError {
name = "MissingAdapterError"
code = "EMAIL_REQUIRES_ADAPTER_ERROR"
}
export class UnsupportedStrategy extends UnknownError {
name = "UnsupportedStrategyError"
code = "CALLBACK_CREDENTIALS_JWT_ERROR"
}
type Method = (...args: any[]) => Promise<any>
export function upperSnake(s: string) {
@@ -56,10 +83,10 @@ export function eventsErrorHandler(
return Object.keys(methods).reduce<any>((acc, name) => {
acc[name] = async (...args: any[]) => {
try {
const method: Method = methods[name]
const method: Method = methods[name as keyof Method]
return await method(...args)
} catch (e) {
logger.error(`${upperSnake(name)}_EVENT_ERROR`, e)
logger.error(`${upperSnake(name)}_EVENT_ERROR`, e as Error)
}
}
return acc
@@ -77,11 +104,11 @@ export function adapterErrorHandler(
acc[name] = async (...args: any[]) => {
try {
logger.debug(`adapter_${name}`, { args })
const method: Method = adapter[name as any]
const method: Method = adapter[name as keyof Method]
return await method(...args)
} catch (error) {
logger.error(`adapter_error_${name}`, error)
const e = new UnknownError(error)
logger.error(`adapter_error_${name}`, error as Error)
const e = new UnknownError(error as Error)
e.name = `${capitalize(name)}Error`
throw e
}

236
src/core/index.ts Normal file
View File

@@ -0,0 +1,236 @@
import logger, { setLogger } from "../lib/logger"
import * as routes from "./routes"
import renderPage from "./pages"
import { init } from "./init"
import { assertConfig } from "./lib/assert"
import { SessionStore } from "./lib/cookie"
import type { NextAuthOptions } from "./types"
import type { NextAuthAction } from "../lib/types"
import type { Cookie } from "./lib/cookie"
import type { ErrorType } from "./pages/error"
export interface IncomingRequest {
/** @default "http://localhost:3000" */
host?: string
method?: string
cookies?: Record<string, string>
headers?: Record<string, any>
query?: Record<string, any>
body?: Record<string, any>
action: NextAuthAction
providerId?: string
error?: string
}
export interface NextAuthHeader {
key: string
value: string
}
export interface OutgoingResponse<
Body extends string | Record<string, any> | any[] = any
> {
status?: number
headers?: NextAuthHeader[]
body?: Body
redirect?: string
cookies?: Cookie[]
}
export interface NextAuthHandlerParams {
req: IncomingRequest
options: NextAuthOptions
}
export async function NextAuthHandler<
Body extends string | Record<string, any> | any[]
>(params: NextAuthHandlerParams): Promise<OutgoingResponse<Body>> {
const { options: userOptions, req } = params
setLogger(userOptions.logger, userOptions.debug)
const assertionResult = assertConfig(params)
if (typeof assertionResult === "string") {
logger.warn(assertionResult)
} else if (assertionResult instanceof Error) {
// Bail out early if there's an error in the user config
const { pages, theme } = userOptions
logger.error(assertionResult.code, assertionResult)
if (pages?.error) {
return {
redirect: `${pages.error}?error=Configuration`,
}
}
const render = renderPage({ theme })
return render.error({ error: "configuration" })
}
const { action, providerId, error, method = "GET" } = req
const { options, cookies } = await init({
userOptions,
action,
providerId,
host: req.host,
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
csrfToken: req.body?.csrfToken,
cookies: req.cookies,
isPost: method === "POST",
})
const sessionStore = new SessionStore(
options.cookies.sessionToken,
req,
options.logger
)
if (method === "GET") {
const render = renderPage({ ...options, query: req.query, cookies })
const { pages } = options
switch (action) {
case "providers":
return (await routes.providers(options.providers)) as any
case "session": {
const session = await routes.session({ options, sessionStore })
if (session.cookies) cookies.push(...session.cookies)
return { ...session, cookies } as any
}
case "csrf":
return {
headers: [{ key: "Content-Type", value: "application/json" }],
body: { csrfToken: options.csrfToken } as any,
cookies,
}
case "signin":
if (pages.signIn) {
let signinUrl = `${pages.signIn}${
pages.signIn.includes("?") ? "&" : "?"
}callbackUrl=${options.callbackUrl}`
if (error) signinUrl = `${signinUrl}&error=${error}`
return { redirect: signinUrl, cookies }
}
return render.signin()
case "signout":
if (pages.signOut) return { redirect: pages.signOut, cookies }
return render.signout()
case "callback":
if (options.provider) {
const callback = await routes.callback({
body: req.body,
query: req.query,
headers: req.headers,
cookies: req.cookies,
method,
options,
sessionStore,
})
if (callback.cookies) cookies.push(...callback.cookies)
return { ...callback, cookies }
}
break
case "verify-request":
if (pages.verifyRequest) {
return { redirect: pages.verifyRequest, cookies }
}
return render.verifyRequest()
case "error":
// These error messages are displayed in line on the sign in page
if (
[
"Signin",
"OAuthSignin",
"OAuthCallback",
"OAuthCreateAccount",
"EmailCreateAccount",
"Callback",
"OAuthAccountNotLinked",
"EmailSignin",
"CredentialsSignin",
"SessionRequired",
].includes(error as string)
) {
return { redirect: `${options.url}/signin?error=${error}`, cookies }
}
if (pages.error) {
return {
redirect: `${pages.error}${
pages.error.includes("?") ? "&" : "?"
}error=${error}`,
cookies,
}
}
return render.error({ error: error as ErrorType })
default:
}
} else if (method === "POST") {
switch (action) {
case "signin":
// Verified CSRF Token required for all sign in routes
if (options.csrfTokenVerified && options.provider) {
const signin = await routes.signin({
query: req.query,
body: req.body,
options,
})
if (signin.cookies) cookies.push(...signin.cookies)
return { ...signin, cookies }
}
return { redirect: `${options.url}/signin?csrf=true`, cookies }
case "signout":
// Verified CSRF Token required for signout
if (options.csrfTokenVerified) {
const signout = await routes.signout({ options, sessionStore })
if (signout.cookies) cookies.push(...signout.cookies)
return { ...signout, cookies }
}
return { redirect: `${options.url}/signout?csrf=true`, cookies }
case "callback":
if (options.provider) {
// Verified CSRF Token required for credentials providers only
if (
options.provider.type === "credentials" &&
!options.csrfTokenVerified
) {
return { redirect: `${options.url}/signin?csrf=true`, cookies }
}
const callback = await routes.callback({
body: req.body,
query: req.query,
headers: req.headers,
cookies: req.cookies,
method,
options,
sessionStore,
})
if (callback.cookies) cookies.push(...callback.cookies)
return { ...callback, cookies }
}
break
case "_log":
if (userOptions.logger) {
try {
const { code, level, ...metadata } = req.body ?? {}
logger[level](code, metadata)
} catch (error) {
// If logging itself failed...
logger.error("LOGGER_ERROR", error as Error)
}
}
return {}
default:
}
}
return {
status: 400,
body: `Error: Action ${action} with HTTP ${method} is not supported by NextAuth.js` as any,
}
}

147
src/core/init.ts Normal file
View File

@@ -0,0 +1,147 @@
import { NextAuthOptions } from ".."
import logger from "../lib/logger"
import parseUrl from "../lib/parse-url"
import { InternalOptions } from "../lib/types"
import { adapterErrorHandler, eventsErrorHandler } from "./errors"
import parseProviders from "./lib/providers"
import createSecret from "./lib/utils"
import * as cookie from "./lib/cookie"
import * as jwt from "../jwt"
import { defaultCallbacks } from "./lib/default-callbacks"
import { createCSRFToken } from "./lib/csrf-token"
import { createCallbackUrl } from "./lib/callback-url"
import { IncomingRequest } from "."
interface InitParams {
host?: string
userOptions: NextAuthOptions
providerId?: string
action: InternalOptions["action"]
/** Callback URL value extracted from the incoming request. */
callbackUrl?: string
/** CSRF token value extracted from the incoming request. From body if POST, from query if GET */
csrfToken?: string
/** Is the incoming request a POST request? */
isPost: boolean
cookies: IncomingRequest["cookies"]
}
/** Initialize all internal options and cookies. */
export async function init({
userOptions,
providerId,
action,
host,
cookies: reqCookies,
callbackUrl: reqCallbackUrl,
csrfToken: reqCsrfToken,
isPost,
}: InitParams): Promise<{
options: InternalOptions
cookies: cookie.Cookie[]
}> {
const url = parseUrl(host)
const secret = createSecret({ userOptions, url })
const { providers, provider } = parseProviders({
providers: userOptions.providers,
url,
providerId,
})
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
// User provided options are overriden by other options,
// except for the options with special handling above
const options: InternalOptions = {
debug: false,
pages: {},
theme: {
colorScheme: "auto",
logo: "",
brandColor: "",
},
// Custom options override defaults
...userOptions,
// These computed settings can have values in userOptions but we override them
// and are request-specific.
url,
action,
provider,
cookies: {
...cookie.defaultCookies(
userOptions.useSecureCookies ?? url.base.startsWith("https://")
),
// Allow user cookie options to override any cookie settings above
...userOptions.cookies,
},
secret,
providers,
// Session options
session: {
// If no adapter specified, force use of JSON Web Tokens (stateless)
strategy: userOptions.adapter ? "database" : "jwt",
maxAge,
updateAge: 24 * 60 * 60,
...userOptions.session,
},
// JWT options
jwt: {
secret, // Use application secret if no keys specified
maxAge, // same as session maxAge,
encode: jwt.encode,
decode: jwt.decode,
...userOptions.jwt,
},
// Event messages
events: eventsErrorHandler(userOptions.events ?? {}, logger),
adapter: adapterErrorHandler(userOptions.adapter, logger),
// Callback functions
callbacks: { ...defaultCallbacks, ...userOptions.callbacks },
logger,
callbackUrl: url.origin,
}
// Init cookies
const cookies: cookie.Cookie[] = []
const {
csrfToken,
cookie: csrfCookie,
csrfTokenVerified,
} = createCSRFToken({
options,
cookieValue: reqCookies?.[options.cookies.csrfToken.name],
isPost,
bodyValue: reqCsrfToken,
})
options.csrfToken = csrfToken
options.csrfTokenVerified = csrfTokenVerified
if (csrfCookie) {
cookies.push({
name: options.cookies.csrfToken.name,
value: csrfCookie,
options: options.cookies.csrfToken.options,
})
}
const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({
options,
cookieValue: reqCookies?.[options.cookies.callbackUrl.name],
paramValue: reqCallbackUrl,
})
options.callbackUrl = callbackUrl
if (callbackUrlCookie) {
cookies.push({
name: options.cookies.callbackUrl.name,
value: callbackUrlCookie,
options: options.cookies.callbackUrl.options,
})
}
return { options, cookies }
}

87
src/core/lib/assert.ts Normal file
View File

@@ -0,0 +1,87 @@
import {
MissingAdapter,
MissingAPIRoute,
MissingAuthorize,
MissingSecret,
UnsupportedStrategy,
} from "../errors"
import type { NextAuthHandlerParams } from ".."
import type { WarningCode } from "../../lib/logger"
type ConfigError =
| MissingAPIRoute
| MissingSecret
| UnsupportedStrategy
| MissingAuthorize
| MissingAdapter
let twitterWarned = false
/**
* Verify that the user configured `next-auth` correctly.
* Good place to mention deprecations as well.
*
* REVIEW: Make some of these and corresponding docs less Next.js specific?
*/
export function assertConfig(
params: NextAuthHandlerParams
): ConfigError | WarningCode | undefined {
const { options, req } = params
// req.query isn't defined when asserting `getServerSession` for example
if (!req.query?.nextauth && !req.action) {
return new MissingAPIRoute(
"Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly."
)
}
if (!options.secret) {
if (process.env.NODE_ENV === "production") {
return new MissingSecret("Please define a `secret` in production.")
} else {
return "NO_SECRET"
}
}
if (!req.host) return "NEXTAUTH_URL"
let hasCredentials, hasEmail
let hasTwitterProvider
for (const provider of options.providers) {
if (provider.type === "credentials") hasCredentials = true
else if (provider.type === "email") hasEmail = true
else if (provider.id === "twitter") hasTwitterProvider = true
}
if (hasCredentials) {
const dbStrategy = options.session?.strategy === "database"
const onlyCredentials = !options.providers.some(
(p) => p.type !== "credentials"
)
if (dbStrategy && onlyCredentials) {
return new UnsupportedStrategy(
"Signin in with credentials only supported if JWT strategy is enabled"
)
}
const credentialsNoAuthorize = options.providers.some(
(p) => p.type === "credentials" && !p.authorize
)
if (credentialsNoAuthorize) {
return new MissingAuthorize(
"Must define an authorize() handler to use credentials authentication provider"
)
}
}
if (hasEmail && !options.adapter) {
return new MissingAdapter("E-mail login requires an adapter.")
}
if (!twitterWarned && hasTwitterProvider) {
twitterWarned = true
return "TWITTER_OAUTH_2_BETA"
}
}

View File

@@ -19,12 +19,13 @@ import { SessionToken } from "./cookie"
* done prior to this handler being called to avoid additonal complexity in this
* handler.
*/
export default async function callbackHandler(
sessionToken: SessionToken,
profile: User,
account: Account,
export default async function callbackHandler(params: {
sessionToken?: SessionToken
profile: User
account: Account
options: InternalOptions
) {
}) {
const { sessionToken, profile, account, options } = params
// Input validation
if (!account?.providerAccountId || !account.type)
throw new Error("Missing or invalid provider account")
@@ -35,7 +36,7 @@ export default async function callbackHandler(
adapter,
jwt,
events,
session: { jwt: useJwtSession },
session: { strategy: sessionStrategy },
} = options
// If no adapter is configured then we don't have a database and cannot
@@ -60,6 +61,8 @@ export default async function callbackHandler(
let user: AdapterUser | null = null
let isNewUser = false
const useJwtSession = sessionStrategy === "jwt"
if (sessionToken) {
if (useJwtSession) {
try {

View File

@@ -0,0 +1,42 @@
import { InternalOptions } from "../../lib/types"
interface CreateCallbackUrlParams {
options: InternalOptions
/** Try reading value from request body (POST) then from query param (GET) */
paramValue?: string
cookieValue?: string
}
/**
* Get callback URL based on query param / cookie + validation,
* and add it to `req.options.callbackUrl`.
*/
export async function createCallbackUrl({
options,
paramValue,
cookieValue,
}: CreateCallbackUrlParams) {
const { url, callbacks } = options
let callbackUrl = url.origin
if (paramValue) {
// If callbackUrl form field or query parameter is passed try to use it if allowed
callbackUrl = await callbacks.redirect({
url: paramValue,
baseUrl: url.origin,
})
} else if (cookieValue) {
// If no callbackUrl specified, try using the value from the cookie if allowed
callbackUrl = await callbacks.redirect({
url: cookieValue,
baseUrl: url.origin,
})
}
return {
callbackUrl,
// Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow
callbackUrlCookie: callbackUrl !== cookieValue ? callbackUrl : undefined,
}
}

212
src/core/lib/cookie.ts Normal file
View File

@@ -0,0 +1,212 @@
import type { IncomingHttpHeaders } from "http"
import type { CookiesOptions } from "../.."
import type { CookieOption, LoggerInstance, SessionStrategy } from "../types"
// Uncomment to recalculate the estimated size
// of an empty session cookie
// import { serialize } from "cookie"
// console.log(
// "Cookie estimated to be ",
// serialize(`__Secure.next-auth.session-token.0`, "", {
// expires: new Date(),
// httpOnly: true,
// maxAge: Number.MAX_SAFE_INTEGER,
// path: "/",
// sameSite: "strict",
// secure: true,
// domain: "example.com",
// }).length,
// " bytes"
// )
const ALLOWED_COOKIE_SIZE = 4096
// Based on commented out section above
const ESTIMATED_EMPTY_COOKIE_SIZE = 163
const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE
// REVIEW: Is there any way to defer two types of strings?
/** Stringified form of `JWT`. Extract the content with `jwt.decode` */
export type JWTString = string
export type SetCookieOptions = Partial<CookieOption["options"]> & {
expires?: Date | string
encode?: (val: unknown) => string
}
/**
* If `options.session.strategy` is set to `jwt`, this is a stringified `JWT`.
* In case of `strategy: "database"`, this is the `sessionToken` of the session in the database.
*/
export type SessionToken<T extends SessionStrategy = "jwt"> = T extends "jwt"
? JWTString
: string
/**
* Use secure cookies if the site uses HTTPS
* This being conditional allows cookies to work non-HTTPS development URLs
* Honour secure cookie option, which sets 'secure' and also adds '__Secure-'
* prefix, but enable them by default if the site URL is HTTPS; but not for
* non-HTTPS URLs like http://localhost which are used in development).
* For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/
*
* @TODO Review cookie settings (names, options)
*/
export function defaultCookies(useSecureCookies: boolean): CookiesOptions {
const cookiePrefix = useSecureCookies ? "__Secure-" : ""
return {
// default cookie options
sessionToken: {
name: `${cookiePrefix}next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
},
callbackUrl: {
name: `${cookiePrefix}next-auth.callback-url`,
options: {
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
},
csrfToken: {
// Default to __Host- for CSRF token for additional protection if using useSecureCookies
// NB: The `__Host-` prefix is stricter than the `__Secure-` prefix.
name: `${useSecureCookies ? "__Host-" : ""}next-auth.csrf-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
},
pkceCodeVerifier: {
name: `${cookiePrefix}next-auth.pkce.code_verifier`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
},
state: {
name: `${cookiePrefix}next-auth.state`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: useSecureCookies,
},
},
}
}
export interface Cookie extends CookieOption {
value: string
}
type Chunks = Record<string, string>
export class SessionStore {
#chunks: Chunks = {}
#option: CookieOption
#logger: LoggerInstance | Console
constructor(
option: CookieOption,
req: {
cookies?: Record<string, string>
headers?: Record<string, string> | IncomingHttpHeaders
},
logger: LoggerInstance | Console
) {
this.#logger = logger
this.#option = option
if (!req) return
for (const name in req.cookies) {
if (name.startsWith(option.name)) {
this.#chunks[name] = req.cookies[name]
}
}
}
get value() {
return Object.values(this.#chunks)?.join("")
}
/** Given a cookie, return a list of cookies, chunked to fit the allowed cookie size. */
#chunk(cookie: Cookie): Cookie[] {
const chunkCount = Math.ceil(cookie.value.length / CHUNK_SIZE)
if (chunkCount === 1) {
this.#chunks[cookie.name] = cookie.value
return [cookie]
}
const cookies: Cookie[] = []
for (let i = 0; i < chunkCount; i++) {
const name = `${cookie.name}.${i}`
const value = cookie.value.substr(i * CHUNK_SIZE, CHUNK_SIZE)
cookies.push({ ...cookie, name, value })
this.#chunks[name] = value
}
this.#logger.debug("CHUNKING_SESSION_COOKIE", {
message: `Session cookie exceeds allowed ${ALLOWED_COOKIE_SIZE} bytes.`,
emptyCookieSize: ESTIMATED_EMPTY_COOKIE_SIZE,
valueSize: cookie.value.length,
chunks: cookies.map((c) => c.value.length + ESTIMATED_EMPTY_COOKIE_SIZE),
})
return cookies
}
/** Returns cleaned cookie chunks. */
#clean(): Record<string, Cookie> {
const cleanedChunks: Record<string, Cookie> = {}
for (const name in this.#chunks) {
delete this.#chunks?.[name]
cleanedChunks[name] = {
name,
value: "",
options: { ...this.#option.options, maxAge: 0 },
}
}
return cleanedChunks
}
/**
* Given a cookie value, return new cookies, chunked, to fit the allowed cookie size.
* If the cookie has changed from chunked to unchunked or vice versa,
* it deletes the old cookies as well.
*/
chunk(value: string, options: Partial<Cookie["options"]>): Cookie[] {
// Assume all cookies should be cleaned by default
const cookies: Record<string, Cookie> = this.#clean()
// Calculate new chunks
const chunked = this.#chunk({
name: this.#option.name,
value,
options: { ...this.#option.options, ...options },
})
// Update stored chunks / cookies
for (const chunk of chunked) {
cookies[chunk.name] = chunk
}
return Object.values(cookies)
}
/** Returns a list of cookies that should be cleaned. */
clean(): Cookie[] {
return Object.values(this.#clean())
}
}

View File

@@ -1,6 +1,12 @@
import { createHash, randomBytes } from "crypto"
import { NextAuthRequest, NextAuthResponse } from "../../lib/types"
import * as cookie from "./cookie"
import { InternalOptions } from "../../lib/types"
interface CreateCSRFTokenParams {
options: InternalOptions
cookieValue?: string
isPost: boolean
bodyValue?: string
}
/**
* Ensure CSRF Token cookie is set for any subsequent requests.
@@ -16,41 +22,33 @@ import * as cookie from "./cookie"
* https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie
* https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf
*/
export default function csrfTokenHandler(
req: NextAuthRequest,
res: NextAuthResponse
) {
const { cookies, secret } = req.options
if (cookies.csrfToken.name in req.cookies) {
const [csrfToken, csrfTokenHash] =
req.cookies[cookies.csrfToken.name].split("|")
export function createCSRFToken({
options,
cookieValue,
isPost,
bodyValue,
}: CreateCSRFTokenParams) {
if (cookieValue) {
const [csrfToken, csrfTokenHash] = cookieValue.split("|")
const expectedCsrfTokenHash = createHash("sha256")
.update(`${csrfToken}${secret}`)
.update(`${csrfToken}${options.secret}`)
.digest("hex")
if (csrfTokenHash === expectedCsrfTokenHash) {
// If hash matches then we trust the CSRF token value
// If this is a POST request and the CSRF Token in the POST request matches
// the cookie we have already verified is the one we have set, then the token is verified!
const csrfTokenVerified =
req.method === "POST" && csrfToken === req.body.csrfToken
req.options.csrfToken = csrfToken
req.options.csrfTokenVerified = csrfTokenVerified
return
const csrfTokenVerified = isPost && csrfToken === bodyValue
return { csrfTokenVerified, csrfToken }
}
}
// If no csrfToken from cookie - because it's not been set yet,
// or because the hash doesn't match (e.g. because it's been modifed or because the secret has changed)
// create a new token.
// New CSRF token
const csrfToken = randomBytes(32).toString("hex")
const csrfTokenHash = createHash("sha256")
.update(`${csrfToken}${secret}`)
.update(`${csrfToken}${options.secret}`)
.digest("hex")
const csrfTokenCookie = `${csrfToken}|${csrfTokenHash}`
cookie.set(
res,
cookies.csrfToken.name,
csrfTokenCookie,
cookies.csrfToken.options
)
req.options.csrfToken = csrfToken
const cookie = `${csrfToken}|${csrfTokenHash}`
return { cookie, csrfToken }
}

View File

@@ -6,6 +6,7 @@ export const defaultCallbacks: CallbacksOptions = {
},
redirect({ url, baseUrl }) {
if (url.startsWith(baseUrl)) return url
else if (url.startsWith("/")) return new URL(url, baseUrl).toString()
return baseUrl
},
session({ session }) {

View File

@@ -1,6 +1,5 @@
import { randomBytes } from "crypto"
import { EmailConfig } from "src/providers"
import { InternalOptions, InternalProvider } from "src/lib/types"
import { InternalOptions } from "../../../lib/types"
import { hashToken } from "../utils"
/**
@@ -9,9 +8,9 @@ import { hashToken } from "../utils"
*/
export default async function email(
identifier: string,
options: InternalOptions<EmailConfig & InternalProvider>
options: InternalOptions<"email">
) {
const { baseUrl, basePath, adapter, provider, logger, callbackUrl } = options
const { url, adapter, provider, logger, callbackUrl } = options
// Generate token
const token =
@@ -33,7 +32,7 @@ export default async function email(
// Generate a link with email, unhashed token and callback url
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
const url = `${baseUrl}${basePath}/callback/${provider.id}?${params}`
const _url = `${url}/callback/${provider.id}?${params}`
try {
// Send to user
@@ -41,14 +40,14 @@ export default async function email(
identifier,
token,
expires,
url,
url: _url,
provider,
})
} catch (error) {
logger.error("SEND_VERIFICATION_EMAIL_ERROR", {
identifier,
url,
error,
error: error as Error,
})
throw new Error("SEND_VERIFICATION_EMAIL_ERROR")
}

View File

@@ -0,0 +1,79 @@
import { openidClient } from "./client"
import { oAuth1Client } from "./client-legacy"
import { createState } from "./state-handler"
import { createPKCE } from "./pkce-handler"
import type { AuthorizationParameters } from "openid-client"
import type { InternalOptions } from "../../../lib/types"
import type { IncomingRequest } from "../.."
import type { Cookie } from "../cookie"
/**
*
* Generates an authorization/request token URL.
*
* [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/) | [OAuth 1](https://oauth.net/core/1.0a/#auth_step2)
*/
export default async function getAuthorizationUrl(params: {
options: InternalOptions<"oauth">
query: IncomingRequest["query"]
}) {
const { options, query } = params
const { logger, provider } = options
try {
let params: any = {}
if (typeof provider.authorization === "string") {
const parsedUrl = new URL(provider.authorization)
const parsedParams = Object.fromEntries(parsedUrl.searchParams.entries())
params = { ...params, ...parsedParams }
} else {
params = { ...params, ...provider.authorization?.params }
}
params = { ...params, ...query }
// Handle OAuth v1.x
if (provider.version?.startsWith("1.")) {
const client = oAuth1Client(options)
const tokens = (await client.getOAuthRequestToken(params)) as any
const url = `${
// @ts-expect-error
provider.authorization?.url ?? provider.authorization
}?${new URLSearchParams({
oauth_token: tokens.oauth_token,
oauth_token_secret: tokens.oauth_token_secret,
...tokens.params,
})}`
logger.debug("GET_AUTHORIZATION_URL", { url })
return { redirect: url }
}
const client = await openidClient(options)
const authorizationParams: AuthorizationParameters = params
const cookies: Cookie[] = []
const state = await createState(options)
if (state) {
authorizationParams.state = state.value
cookies.push(state.cookie)
}
const pkce = await createPKCE(options)
if (pkce) {
authorizationParams.code_challenge = pkce.code_challenge
authorizationParams.code_challenge_method = pkce.code_challenge_method
cookies.push(pkce.cookie)
}
const url = client.authorizationUrl(authorizationParams)
logger.debug("GET_AUTHORIZATION_URL", { url, cookies })
return { redirect: url, cookies }
} catch (error) {
logger.error("GET_AUTHORIZATION_URL_ERROR", error as Error)
throw error
}
}

View File

@@ -1,27 +1,34 @@
import { TokenSet } from "openid-client"
import { openidClient } from "./client"
import { oAuth1Client } from "./client-legacy"
import { getState } from "./state-handler"
import { useState } from "./state-handler"
import { usePKCECodeVerifier } from "./pkce-handler"
import { OAuthCallbackError } from "../../errors"
import { TokenSet } from "openid-client"
import { Account, LoggerInstance, Profile } from "src"
import { OAuthChecks, OAuthConfig } from "src/providers"
export default async function oAuthCallback(
req,
res
): Promise<GetProfileResult> {
const { logger } = req.options
import type { CallbackParamsType } from "openid-client"
import type { Account, LoggerInstance, Profile } from "../../.."
import type { OAuthChecks, OAuthConfig } from "../../../providers"
import type { InternalOptions } from "../../../lib/types"
import type { IncomingRequest, OutgoingResponse } from "../.."
import type { Cookie } from "../cookie"
/** @type {import("src/providers").OAuthConfig} */
const provider = req.options.provider
export default async function oAuthCallback(params: {
options: InternalOptions<"oauth">
query: IncomingRequest["query"]
body: IncomingRequest["body"]
method: Required<IncomingRequest>["method"]
cookies: IncomingRequest["cookies"]
}): Promise<GetProfileResult & { cookies?: OutgoingResponse["cookies"] }> {
const { options, query, body, method, cookies } = params
const { logger, provider } = options
const errorMessage = req.body.error ?? req.query.error
const errorMessage = body?.error ?? query?.error
if (errorMessage) {
const error = new Error(errorMessage)
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", {
error,
body: req.body,
error_description: query?.error_description,
body,
providerId: provider.id,
})
throw error
@@ -29,19 +36,19 @@ export default async function oAuthCallback(
if (provider.version?.startsWith("1.")) {
try {
const client = await oAuth1Client(req.options)
const client = await oAuth1Client(options)
// Handle OAuth v1.x
const { oauth_token, oauth_verifier } = req.query
const { oauth_token, oauth_verifier } = query ?? {}
// @ts-expect-error
const tokens: TokenSet = await client.getOAuthAccessToken(
oauth_token,
oauth_token as string,
// @ts-expect-error
null,
oauth_verifier
)
// @ts-expect-error
let profile: Profile = await client.get(
provider.profileUrl,
(provider as any).profileUrl,
tokens.oauth_token,
tokens.oauth_token_secret
)
@@ -52,24 +59,49 @@ export default async function oAuthCallback(
return await getProfile({ profile, tokens, provider, logger })
} catch (error) {
logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error)
logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error as Error)
throw error
}
}
try {
const client = await openidClient(req.options)
const client = await openidClient(options)
/** @type {import("openid-client").TokenSet} */
let tokens
let tokens: TokenSet
const checks: OAuthChecks = {
code_verifier: await usePKCECodeVerifier(req, res),
state: getState(req),
const checks: OAuthChecks = {}
const resCookies: Cookie[] = []
const state = await useState(cookies?.[options.cookies.state.name], options)
if (state) {
checks.state = state.value
resCookies.push(state.cookie)
}
const params = { ...client.callbackParams(req), ...provider.token?.params }
const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name]
const pkce = await usePKCECodeVerifier(codeVerifier, options)
if (pkce) {
checks.code_verifier = pkce.codeVerifier
resCookies.push(pkce.cookie)
}
const params: CallbackParamsType = {
...client.callbackParams({
url: `http://n?${new URLSearchParams(query)}`,
// TODO: Ask to allow object to be passed upstream:
// https://github.com/panva/node-openid-client/blob/3ae206dfc78c02134aa87a07f693052c637cab84/types/index.d.ts#L439
// @ts-expect-error
body,
method,
}),
// @ts-expect-error
...provider.token?.params,
}
// @ts-expect-error
if (provider.token?.request) {
// @ts-expect-error
const response = await provider.token.request({
provider,
params,
@@ -89,7 +121,9 @@ export default async function oAuthCallback(
}
let profile: Profile
// @ts-expect-error
if (provider.userinfo?.request) {
// @ts-expect-error
profile = await provider.userinfo.request({
provider,
tokens,
@@ -99,25 +133,31 @@ export default async function oAuthCallback(
profile = tokens.claims()
} else {
profile = await client.userinfo(tokens, {
// @ts-expect-error
params: provider.userinfo?.params,
})
}
// If a user object is supplied (e.g. Apple provider) add it to the profile object
// TODO: Remove/extract to Apple provider?
profile.user = JSON.parse(req.body.user ?? req.query.user ?? null)
return await getProfile({ profile, provider, tokens, logger })
const profileResult = await getProfile({
profile,
provider,
tokens,
logger,
})
return { ...profileResult, cookies: resCookies }
} catch (error) {
logger.error("OAUTH_CALLBACK_ERROR", { error, providerId: provider.id })
throw new OAuthCallbackError(error)
logger.error("OAUTH_CALLBACK_ERROR", {
error: error as Error,
providerId: provider.id,
})
throw new OAuthCallbackError(error as Error)
}
}
export interface GetProfileParams {
profile: Profile
tokens: TokenSet
provider: OAuthConfig
provider: OAuthConfig<any>
logger: LoggerInstance
}
@@ -159,7 +199,10 @@ async function getProfile({
// all providers, so we return an empty object; the user should then be
// redirected back to the sign up page. We log the error to help developers
// who might be trying to debug this when configuring a new provider.
logger.error("OAUTH_PARSE_PROFILE_ERROR", { error, OAuthProfile })
logger.error("OAUTH_PARSE_PROFILE_ERROR", {
error: error as Error,
OAuthProfile,
})
return {
profile: null,
account: null,

View File

@@ -2,29 +2,29 @@
// We have the intentions to provide only minor fixes for this in the future.
import { OAuth } from "oauth"
import { InternalOptions } from "src/lib/types"
/**
* Client supporting OAuth 1.x
* @param {import("src/lib/types").InternalOptions} options
*/
export function oAuth1Client(options) {
/** @type {import("src/providers").OAuthConfig} */
export function oAuth1Client(options: InternalOptions<"oauth">) {
const provider = options.provider
const oauth1Client = new OAuth(
provider.requestTokenUrl,
provider.accessTokenUrl,
provider.clientId,
provider.clientSecret,
provider.version || "1.0",
provider.requestTokenUrl as string,
provider.accessTokenUrl as string,
provider.clientId as string,
provider.clientSecret as string,
provider.version ?? "1.0",
provider.callbackUrl,
provider.encoding || "HMAC-SHA1"
provider.encoding ?? "HMAC-SHA1"
)
// Promisify get() for OAuth1
const originalGet = oauth1Client.get.bind(oauth1Client)
oauth1Client.get = (...args) => {
return new Promise((resolve, reject) => {
// @ts-expect-error
oauth1Client.get = async (...args) => {
return await new Promise((resolve, reject) => {
originalGet(...args, (error, result) => {
if (error) {
return reject(error)
@@ -36,15 +36,15 @@ export function oAuth1Client(options) {
// Promisify getOAuth1AccessToken() for OAuth1
const originalGetOAuth1AccessToken =
oauth1Client.getOAuthAccessToken.bind(oauth1Client)
oauth1Client.getOAuthAccessToken = (...args) => {
return new Promise((resolve, reject) => {
oauth1Client.getOAuthAccessToken = async (...args: any[]) => {
return await new Promise((resolve, reject) => {
originalGetOAuth1AccessToken(
...args,
(error, oauth_token, oauth_token_secret) => {
(error: any, oauth_token: any, oauth_token_secret: any) => {
if (error) {
return reject(error)
}
resolve({ oauth_token, oauth_token_secret })
resolve({ oauth_token, oauth_token_secret } as any)
}
)
})
@@ -52,15 +52,15 @@ export function oAuth1Client(options) {
const originalGetOAuthRequestToken =
oauth1Client.getOAuthRequestToken.bind(oauth1Client)
oauth1Client.getOAuthRequestToken = (params = {}) => {
return new Promise((resolve, reject) => {
oauth1Client.getOAuthRequestToken = async (params = {}) => {
return await new Promise((resolve, reject) => {
originalGetOAuthRequestToken(
params,
(error, oauth_token, oauth_token_secret, params) => {
if (error) {
return reject(error)
}
resolve({ oauth_token, oauth_token_secret, params })
resolve({ oauth_token, oauth_token_secret, params } as any)
}
)
})

View File

@@ -0,0 +1,50 @@
import { Issuer, Client, custom } from "openid-client"
import { InternalOptions } from "src/lib/types"
/**
* NOTE: We can add auto discovery of the provider's endpoint
* that requires only one endpoint to be specified by the user.
* Check out `Issuer.discover`
*
* Client supporting OAuth 2.x and OIDC
*/
export async function openidClient(
options: InternalOptions<"oauth">
): Promise<Client> {
const provider = options.provider
if (provider.httpOptions) custom.setHttpOptionsDefaults(provider.httpOptions)
let issuer: Issuer
if (provider.wellKnown) {
issuer = await Issuer.discover(provider.wellKnown)
} else {
issuer = new Issuer({
issuer: provider.issuer as string,
authorization_endpoint:
// @ts-expect-error
provider.authorization?.url ?? provider.authorization,
// @ts-expect-error
token_endpoint: provider.token?.url ?? provider.token,
// @ts-expect-error
userinfo_endpoint: provider.userinfo?.url ?? provider.userinfo,
})
}
const client = new issuer.Client(
{
client_id: provider.clientId as string,
client_secret: provider.clientSecret as string,
redirect_uris: [provider.callbackUrl],
...provider.client,
},
provider.jwks
)
// allow a 10 second skew
// See https://github.com/nextauthjs/next-auth/issues/3032
// and https://github.com/nextauthjs/next-auth/issues/3067
client[custom.clock_tolerance] = 10
return client
}

View File

@@ -0,0 +1,84 @@
import * as jwt from "../../../jwt"
import { generators } from "openid-client"
import type { InternalOptions } from "src/lib/types"
import type { Cookie } from "../cookie"
const PKCE_CODE_CHALLENGE_METHOD = "S256"
const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds
/**
* Returns `code_challenge` and `code_challenge_method`
* and saves them in a cookie.
*/
export async function createPKCE(options: InternalOptions<"oauth">): Promise<
| undefined
| {
code_challenge: string
code_challenge_method: "S256"
cookie: Cookie
}
> {
const { cookies, logger, provider } = options
if (!provider.checks?.includes("pkce")) {
// Provider does not support PKCE, return nothing.
return
}
const code_verifier = generators.codeVerifier()
const code_challenge = generators.codeChallenge(code_verifier)
const expires = new Date()
expires.setTime(expires.getTime() + PKCE_MAX_AGE * 1000)
// Encrypt code_verifier and save it to an encrypted cookie
const encryptedCodeVerifier = await jwt.encode({
...options.jwt,
maxAge: PKCE_MAX_AGE,
token: { code_verifier },
})
logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", {
code_challenge,
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
code_verifier,
PKCE_MAX_AGE,
})
return {
code_challenge,
code_challenge_method: PKCE_CODE_CHALLENGE_METHOD,
cookie: {
name: cookies.pkceCodeVerifier.name,
value: encryptedCodeVerifier,
options: { ...cookies.pkceCodeVerifier.options, expires },
},
}
}
/**
* Returns code_verifier if provider uses PKCE,
* and clears the container cookie afterwards.
*/
export async function usePKCECodeVerifier(
codeVerifier: string | undefined,
options: InternalOptions<"oauth">
): Promise<{ codeVerifier: string; cookie: Cookie } | undefined> {
const { cookies, provider } = options
if (!provider?.checks?.includes("pkce") || !codeVerifier) {
return
}
const pkce = (await jwt.decode({
...options.jwt,
token: codeVerifier,
})) as any
return {
codeVerifier: pkce?.code_verifier ?? undefined,
cookie: {
name: cookies.pkceCodeVerifier.name,
value: "",
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
},
}
}

View File

@@ -0,0 +1,63 @@
import { generators } from "openid-client"
import type { InternalOptions } from "src/lib/types"
import type { Cookie } from "../cookie"
const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds
/** Returns state if the provider supports it */
export async function createState(
options: InternalOptions<"oauth">
): Promise<{ cookie: Cookie; value: string } | undefined> {
const { logger, provider, jwt, cookies } = options
if (!provider.checks?.includes("state")) {
// Provider does not support state, return nothing
return
}
const state = generators.state()
const encodedState = await jwt.encode({
...jwt,
maxAge: STATE_MAX_AGE,
token: { state },
})
logger.debug("CREATE_STATE", { state, maxAge: STATE_MAX_AGE })
const expires = new Date()
expires.setTime(expires.getTime() + STATE_MAX_AGE * 1000)
return {
value: state,
cookie: {
name: cookies.state.name,
value: encodedState,
options: { ...cookies.state.options, expires },
},
}
}
/**
* Returns state from if the provider supports states,
* and clears the container cookie afterwards.
*/
export async function useState(
state: string | undefined,
options: InternalOptions<"oauth">
): Promise<{ value: string; cookie: Cookie } | undefined> {
const { cookies, provider, jwt } = options
if (!provider.checks?.includes("state") || !state) return
const value = (await jwt.decode({ ...options.jwt, token: state })) as any
return {
value: value?.state ?? undefined,
cookie: {
name: cookies.state.name,
value: "",
options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 },
},
}
}

73
src/core/lib/providers.ts Normal file
View File

@@ -0,0 +1,73 @@
import { merge } from "../../lib/merge"
import type { InternalProvider } from "../../lib/types"
import type { Provider } from "../../providers"
import type { InternalUrl } from "../../lib/parse-url"
/**
* Adds `signinUrl` and `callbackUrl` to each provider
* and deep merge user-defined options.
*/
export default function parseProviders(params: {
providers: Provider[]
url: InternalUrl
providerId?: string
}): {
providers: InternalProvider[]
provider?: InternalProvider
} {
const { url, providerId } = params
const providers = params.providers.map(({ options, ...rest }) => {
const defaultOptions = normalizeProvider(rest as Provider)
const userOptions = normalizeProvider(options as Provider)
return merge(defaultOptions, {
...userOptions,
signinUrl: `${url}/signin/${userOptions?.id ?? rest.id}`,
callbackUrl: `${url}/callback/${userOptions?.id ?? rest.id}`,
})
})
const provider = providers.find(({ id }) => id === providerId)
return { providers, provider }
}
function normalizeProvider(provider?: Provider) {
if (!provider) return
const normalized: InternalProvider = Object.entries(
provider
).reduce<InternalProvider>((acc, [key, value]) => {
if (
["authorization", "token", "userinfo"].includes(key) &&
typeof value === "string"
) {
const url = new URL(value)
acc[key] = {
url: `${url.origin}${url.pathname}`,
params: Object.fromEntries(url.searchParams ?? []),
}
} else {
acc[key] = value
}
return acc
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions
}, {} as any)
if (normalized.type === "oauth" && !normalized.version?.startsWith("1.")) {
// If provider has as an "openid-configuration" well-known endpoint
// or an "openid" scope request, it will also likely be able to receive an `id_token`
normalized.idToken = Boolean(
normalized.idToken ??
normalized.wellKnown?.includes("openid-configuration") ??
// @ts-expect-error
normalized.authorization?.params?.scope?.includes("openid")
)
if (!normalized.checks) normalized.checks = ["state"]
}
return normalized
}

View File

@@ -1,21 +1,18 @@
import { createHash } from "crypto"
import { NextAuthOptions } from "../.."
import { EmailConfig } from "../../providers"
import { InternalOptions, InternalProvider } from "../../lib/types"
import { InternalOptions } from "../../lib/types"
import { InternalUrl } from "../../lib/parse-url"
/**
* Takes a number in seconds and returns the date in the future.
* Optionally takes a second date parameter. In that case
* the date in the future will be calculated from that date instead of now.
*/
export function fromDate(time, date = Date.now()) {
export function fromDate(time: number, date = Date.now()) {
return new Date(date + time * 1000)
}
export function hashToken(
token: string,
options: InternalOptions<EmailConfig & InternalProvider>
) {
export function hashToken(token: string, options: InternalOptions<"email">) {
const { provider, secret } = options
return (
createHash("sha256")
@@ -28,28 +25,18 @@ export function hashToken(
/**
* Secret used salt cookies and tokens (e.g. for CSRF protection).
* If no secret option is specified then it creates one on the fly
* based on options passed here. A options contains unique data, such as
* OAuth provider secrets and database credentials it should be sufficent.
*/
export default function createSecret({
userOptions,
basePath,
baseUrl,
}: {
* based on options passed here. If options contains unique data, such as
* OAuth provider secrets and database credentials it should be sufficent. If no secret provided in production, we throw an error. */
export default function createSecret(params: {
userOptions: NextAuthOptions
basePath: string
baseUrl: string
url: InternalUrl
}) {
const { userOptions, url } = params
return (
userOptions.secret ??
createHash("sha256")
.update(
JSON.stringify({
baseUrl,
basePath,
...userOptions,
})
)
.update(JSON.stringify({ ...url, ...userOptions }))
.digest("hex")
)
}

114
src/core/pages/error.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
/**
* The following errors are passed as error query parameters to the default or overridden error page.
*
* [Documentation](https://next-auth.js.org/configuration/pages#error-page) */
export type ErrorType =
| "default"
| "configuration"
| "accessdenied"
| "verification"
export interface ErrorProps {
url?: InternalUrl
theme?: Theme
error?: ErrorType
}
interface ErrorView {
status: number
heading: string
message: JSX.Element
signin?: JSX.Element
}
/** Renders an error page. */
export default function ErrorPage(props: ErrorProps) {
const { url, error = "default", theme } = props
const signinPageUrl = `${url}/signin`
const errors: Record<ErrorType, ErrorView> = {
default: {
status: 200,
heading: "Error",
message: (
<p>
<a className="site" href={url?.origin}>
{url?.host}
</a>
</p>
),
},
configuration: {
status: 500,
heading: "Server error",
message: (
<div>
<p>There is a problem with the server configuration.</p>
<p>Check the server logs for more information.</p>
</div>
),
},
accessdenied: {
status: 403,
heading: "Access Denied",
message: (
<div>
<p>You do not have permission to sign in.</p>
<p>
<a className="button" href={signinPageUrl}>
Sign in
</a>
</p>
</div>
),
},
verification: {
status: 403,
heading: "Unable to sign in",
message: (
<div>
<p>The sign in link is no longer valid.</p>
<p>It may have been used already or it may have expired.</p>
</div>
),
signin: (
<p>
<a className="button" href={signinPageUrl}>
Sign in
</a>
</p>
),
},
}
const { status, heading, message, signin } =
errors[error.toLowerCase()] ?? errors.default
return {
status,
html: (
<div className="error">
{theme?.brandColor && (
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme?.brandColor}
}
`,
}}
/>
)}
{theme?.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
<h1>{heading}</h1>
<div className="message">{message}</div>
{signin}
</div>
</div>
),
}
}

79
src/core/pages/index.ts Normal file
View File

@@ -0,0 +1,79 @@
import renderToString from "preact-render-to-string"
import SigninPage from "./signin"
import SignoutPage from "./signout"
import VerifyRequestPage from "./verify-request"
import ErrorPage from "./error"
import css from "../../css"
import type { InternalOptions } from "../../lib/types"
import type { IncomingRequest, OutgoingResponse } from ".."
import type { Cookie } from "../lib/cookie"
import type { ErrorType } from "./error"
type RenderPageParams = {
query?: IncomingRequest["query"]
cookies?: Cookie[]
} & Partial<
Pick<
InternalOptions,
"url" | "callbackUrl" | "csrfToken" | "providers" | "theme"
>
>
/**
* Unless the user defines their [own pages](https://next-auth.js.org/configuration/pages),
* we render a set of default ones, using Preact SSR.
*/
export default function renderPage(params: RenderPageParams) {
const { url, theme, query, cookies } = params
function send({ html, title, status }: any): OutgoingResponse {
return {
cookies,
status,
headers: [{ key: "Content-Type", value: "text/html" }],
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css()}</style><title>${title}</title></head><body class="__next-auth-theme-${
theme?.colorScheme ?? "auto"
}"><div class="page">${renderToString(html)}</div></body></html>`,
}
}
return {
signin(props?: any) {
return send({
html: SigninPage({
csrfToken: params.csrfToken,
providers: params.providers,
callbackUrl: params.callbackUrl,
theme,
...query,
...props,
}),
title: "Sign In",
})
},
signout(props?: any) {
return send({
html: SignoutPage({
csrfToken: params.csrfToken,
url,
theme,
...props,
}),
title: "Sign Out",
})
},
verifyRequest(props?: any) {
return send({
html: VerifyRequestPage({ url, theme, ...props }),
title: "Verify Request",
})
},
error(props?: { error?: ErrorType }) {
return send({
...ErrorPage({ url, theme, ...props }),
title: "Error",
})
},
}
}

168
src/core/pages/signin.tsx Normal file
View File

@@ -0,0 +1,168 @@
import { Theme } from "../.."
import { InternalProvider } from "../../lib/types"
/**
* The following errors are passed as error query parameters to the default or overridden sign-in page.
*
* [Documentation](https://next-auth.js.org/configuration/pages#sign-in-page) */
export type SignInErrorTypes =
| "Signin"
| "OAuthSignin"
| "OAuthCallback"
| "OAuthCreateAccount"
| "EmailCreateAccount"
| "Callback"
| "OAuthAccountNotLinked"
| "EmailSignin"
| "CredentialsSignin"
| "SessionRequired"
| "default"
export interface SignInServerPageParams {
csrfToken: string
providers: InternalProvider[]
callbackUrl: string
email: string
error: SignInErrorTypes
theme: Theme
}
export default function SigninPage(props: SignInServerPageParams) {
const {
csrfToken,
providers,
callbackUrl,
theme,
email,
error: errorType,
} = props
// We only want to render providers
const providersToRender = providers.filter((provider) => {
if (provider.type === "oauth" || provider.type === "email") {
// Always render oauth and email type providers
return true
} else if (provider.type === "credentials" && provider.credentials) {
// Only render credentials type provider if credentials are defined
return true
}
// Don't render other provider types
return false
})
if (typeof document !== "undefined" && theme.brandColor) {
document.documentElement.style.setProperty(
"--brand-color",
theme.brandColor
)
}
const errors: Record<SignInErrorTypes, string> = {
Signin: "Try signing in with a different account.",
OAuthSignin: "Try signing in with a different account.",
OAuthCallback: "Try signing in with a different account.",
OAuthCreateAccount: "Try signing in with a different account.",
EmailCreateAccount: "Try signing in with a different account.",
Callback: "Try signing in with a different account.",
OAuthAccountNotLinked:
"To confirm your identity, sign in with the same account you used originally.",
EmailSignin: "The e-mail could not be sent.",
CredentialsSignin:
"Sign in failed. Check the details you provided are correct.",
SessionRequired: "Please sign in to access this page.",
default: "Unable to sign in.",
}
const error = errorType && (errors[errorType] ?? errors.default)
return (
<div className="signin">
{theme.brandColor && (
<style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/>
)}
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
{error && (
<div className="error">
<p>{error}</p>
</div>
)}
{providersToRender.map((provider, i: number) => (
<div key={provider.id} className="provider">
{provider.type === "oauth" && (
<form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
{callbackUrl && (
<input type="hidden" name="callbackUrl" value={callbackUrl} />
)}
<button type="submit" className="button">
Sign in with {provider.name}
</button>
</form>
)}
{(provider.type === "email" || provider.type === "credentials") &&
i > 0 &&
providersToRender[i - 1].type !== "email" &&
providersToRender[i - 1].type !== "credentials" && <hr />}
{provider.type === "email" && (
<form action={provider.signinUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<label
className="section-header"
htmlFor={`input-email-for-${provider.id}-provider`}
>
Email
</label>
<input
id={`input-email-for-${provider.id}-provider`}
autoFocus
type="text"
name="email"
value={email}
placeholder="email@example.com"
/>
<button type="submit">Sign in with {provider.name}</button>
</form>
)}
{provider.type === "credentials" && (
<form action={provider.callbackUrl} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
{Object.keys(provider.credentials).map((credential) => {
return (
<div key={`input-group-${provider.id}`}>
<label
className="section-header"
htmlFor={`input-${credential}-for-${provider.id}-provider`}
>
{provider.credentials[credential].label ?? credential}
</label>
<input
name={credential}
id={`input-${credential}-for-${provider.id}-provider`}
type={provider.credentials[credential].type ?? "text"}
placeholder={
provider.credentials[credential].placeholder ?? ""
}
{...provider.credentials[credential]}
/>
</div>
)
})}
<button type="submit">Sign in with {provider.name}</button>
</form>
)}
{(provider.type === "email" || provider.type === "credentials") &&
i + 1 < providersToRender.length && <hr />}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
export interface SignoutProps {
url: InternalUrl
csrfToken: string
theme: Theme
}
export default function SignoutPage(props: SignoutProps) {
const { url, csrfToken, theme } = props
return (
<div className="signout">
{ theme.brandColor && <style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/> }
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
<h1>Signout</h1>
<p>Are you sure you want to sign out?</p>
<form action={`${url}/signout`} method="POST">
<input type="hidden" name="csrfToken" value={csrfToken} />
<button type="submit">Sign out</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
interface VerifyRequestPageProps {
url: InternalUrl
theme: Theme
}
export default function VerifyRequestPage(props: VerifyRequestPageProps) {
const { url, theme } = props
return (
<div className="verify-request">
{ theme.brandColor && <style
dangerouslySetInnerHTML={{
__html: `
:root {
--brand-color: ${theme.brandColor}
}
`,
}}
/> }
{theme.logo && <img src={theme.logo} alt="Logo" className="logo" />}
<div className="card">
<h1>Check your email</h1>
<p>A sign in link has been sent to your email address.</p>
<p>
<a className="site" href={url.origin}>
{url.host}
</a>
</p>
</div>
</div>
)
}

View File

@@ -1,33 +1,57 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
import * as cookie from "../lib/cookie"
import { hashToken } from "../lib/utils"
/**
* Handle callbacks from login services
* @type {import("src/lib/types").NextAuthApiHandler}
*/
export default async function callback(req, res) {
import type { InternalOptions } from "../../lib/types"
import type { IncomingRequest, OutgoingResponse } from ".."
import type { Cookie, SessionStore } from "../lib/cookie"
import type { User } from "../.."
/** Handle callbacks from login services */
export default async function callback(params: {
options: InternalOptions<"oauth" | "credentials" | "email">
query: IncomingRequest["query"]
method: Required<IncomingRequest>["method"]
body: IncomingRequest["body"]
headers: IncomingRequest["headers"]
cookies: IncomingRequest["cookies"]
sessionStore: SessionStore
}): Promise<OutgoingResponse> {
const { options, query, body, method, headers, sessionStore } = params
const {
provider,
adapter,
baseUrl,
basePath,
cookies,
url,
callbackUrl,
pages,
jwt,
events,
callbacks,
session: { jwt: useJwtSession, maxAge: sessionMaxAge },
session: { strategy: sessionStrategy, maxAge: sessionMaxAge },
logger,
} = req.options
} = options
const sessionToken = req.cookies?.[cookies.sessionToken.name] ?? null
const cookies: Cookie[] = []
const useJwtSession = sessionStrategy === "jwt"
if (provider.type === "oauth") {
try {
const { profile, account, OAuthProfile } = await oAuthCallback(req, res)
const {
profile,
account,
OAuthProfile,
cookies: oauthCookies,
} = await oAuthCallback({
query,
body,
method,
options,
cookies: params.cookies,
})
if (oauthCookies) cookies.push(...oauthCookies)
try {
// Make it easier to debug when adding a new provider
logger.debug("OAUTH_CALLBACK_RESPONSE", {
@@ -45,7 +69,7 @@ export default async function callback(req, res) {
// should at least be visible to developers what happened if it is an
// error with the provider.
if (!profile) {
return res.redirect(`${baseUrl}${basePath}/signin`)
return { redirect: `${url}/signin`, cookies }
}
// Check if user is allowed to sign in
@@ -56,6 +80,7 @@ export default async function callback(req, res) {
if (adapter) {
const { getUserByAccount } = adapter
const userByAccount = await getUserByAccount({
// @ts-expect-error
providerAccountId: account.providerAccountId,
provider: provider.id,
})
@@ -66,31 +91,33 @@ export default async function callback(req, res) {
try {
const isAllowed = await callbacks.signIn({
user: userOrProfile,
// @ts-expect-error
account,
profile: OAuthProfile,
})
if (!isAllowed) {
return res.redirect(
`${baseUrl}${basePath}/error?error=AccessDenied`
)
return { redirect: `${url}/error?error=AccessDenied`, cookies }
} else if (typeof isAllowed === "string") {
return res.redirect(isAllowed)
return { redirect: isAllowed, cookies }
}
} catch (error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(
error.message
)}`
)
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message
)}`,
cookies,
}
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(
sessionToken,
// @ts-expect-error
const { user, session, isNewUser } = await callbackHandler({
sessionToken: sessionStore.value,
profile,
// @ts-expect-error
account,
req.options
)
options,
})
if (useJwtSession) {
const defaultToken = {
@@ -102,90 +129,90 @@ export default async function callback(req, res) {
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
profile: OAuthProfile,
isNewUser,
})
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token })
// Encode token
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: cookieExpires.toISOString(),
...cookies.sessionToken.options,
const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
} else {
// Save Session Token in cookie
cookie.set(res, cookies.sessionToken.name, session.sessionToken, {
expires: session.expires,
...cookies.sessionToken.options,
cookies.push({
name: options.cookies.sessionToken.name,
value: session.sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: session.expires,
},
})
}
// @ts-expect-error
await events.signIn?.({ user, account, profile, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return res.redirect(
`${pages.newUser}${
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`
)
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
cookies,
}
}
// Callback URL is already verified at this point, so safe to use if specified
return res.redirect(callbackUrl || baseUrl)
return { redirect: callbackUrl, cookies }
} catch (error) {
if (error.name === "AccountNotLinkedError") {
if ((error as Error).name === "AccountNotLinkedError") {
// If the email on the account is already linked, but not with this OAuth account
return res.redirect(
`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`
)
} else if (error.name === "CreateUserError") {
return res.redirect(
`${baseUrl}${basePath}/error?error=OAuthCreateAccount`
)
return {
redirect: `${url}/error?error=OAuthAccountNotLinked`,
cookies,
}
} else if ((error as Error).name === "CreateUserError") {
return { redirect: `${url}/error?error=OAuthCreateAccount`, cookies }
}
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error as Error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} catch (error) {
if (error.name === "OAuthCallbackError") {
logger.error("CALLBACK_OAUTH_ERROR", error)
return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCallback`)
if ((error as Error).name === "OAuthCallbackError") {
logger.error("CALLBACK_OAUTH_ERROR", error as Error)
return { redirect: `${url}/error?error=OAuthCallback`, cookies }
}
logger.error("OAUTH_CALLBACK_ERROR", error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
logger.error("OAUTH_CALLBACK_ERROR", error as Error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} else if (provider.type === "email") {
try {
if (!adapter) {
logger.error(
"EMAIL_REQUIRES_ADAPTER_ERROR",
new Error("E-mail login requires an adapter but it was undefined")
)
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
// Verified in `assertConfig`
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { useVerificationToken, getUserByEmail } = adapter!
const { useVerificationToken, getUserByEmail } = adapter
const token = query?.token
const identifier = query?.email
const token = req.query.token
const identifier = req.query.email
const invite = await useVerificationToken({
const invite = await useVerificationToken?.({
identifier,
token: hashToken(token, req.options),
token: hashToken(token, options),
})
const invalidInvite = !invite || invite.expires.valueOf() < Date.now()
if (invalidInvite) {
return res.redirect(`${baseUrl}${basePath}/error?error=Verification`)
return { redirect: `${url}/error?error=Verification`, cookies }
}
// If it is an existing user, use that, otherwise use a placeholder
@@ -205,30 +232,37 @@ export default async function callback(req, res) {
// Check if user is allowed to sign in
try {
const signInCallbackResponse = await callbacks.signIn({
// @ts-expect-error
user: profile,
// @ts-expect-error
account,
// @ts-expect-error
email: { email: identifier },
})
if (!signInCallbackResponse) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
return { redirect: `${url}/error?error=AccessDenied`, cookies }
} else if (typeof signInCallbackResponse === "string") {
return res.redirect(signInCallbackResponse)
return { redirect: signInCallbackResponse, cookies }
}
} catch (error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(
error.message
)}`
)
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message
)}`,
cookies,
}
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler(
sessionToken,
// @ts-expect-error
const { user, session, isNewUser } = await callbackHandler({
sessionToken: sessionStore.value,
// @ts-expect-error
profile,
// @ts-expect-error
account,
req.options
)
options,
})
if (useJwtSession) {
const defaultToken = {
@@ -240,99 +274,86 @@ export default async function callback(req, res) {
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
isNewUser,
})
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token })
// Encode token
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: cookieExpires.toISOString(),
...cookies.sessionToken.options,
const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
} else {
// Save Session Token in cookie
cookie.set(res, cookies.sessionToken.name, session.sessionToken, {
expires: session.expires,
...cookies.sessionToken.options,
cookies.push({
name: options.cookies.sessionToken.name,
value: session.sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: session.expires,
},
})
}
// @ts-expect-error
await events.signIn?.({ user, account, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return res.redirect(
`${pages.newUser}${
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`
)
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
cookies,
}
}
// Callback URL is already verified at this point, so safe to use if specified
return res.redirect(callbackUrl || baseUrl)
return { redirect: callbackUrl, cookies }
} catch (error) {
if (error.name === "CreateUserError") {
return res.redirect(
`${baseUrl}${basePath}/error?error=EmailCreateAccount`
)
if ((error as Error).name === "CreateUserError") {
return { redirect: `${url}/error?error=EmailCreateAccount`, cookies }
}
logger.error("CALLBACK_EMAIL_ERROR", error)
return res.redirect(`${baseUrl}${basePath}/error?error=Callback`)
}
} else if (provider.type === "credentials" && req.method === "POST") {
if (!useJwtSession) {
logger.error(
"CALLBACK_CREDENTIALS_JWT_ERROR",
new Error(
"Signin in with credentials is only supported if JSON Web Tokens are enabled"
)
)
return res
.status(500)
.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
logger.error("CALLBACK_EMAIL_ERROR", error as Error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} else if (provider.type === "credentials" && method === "POST") {
const credentials = body
if (!provider.authorize) {
logger.error(
"CALLBACK_CREDENTIALS_HANDLER_ERROR",
new Error(
"Must define an authorize() handler to use credentials authentication provider"
)
)
return res
.status(500)
.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}
const credentials = req.body
let user
let user: User
try {
user = await provider.authorize(credentials, {
...req,
options: {},
cookies: {},
})
user = (await provider.authorize(credentials, {
query,
body,
headers,
method,
})) as User
if (!user) {
return res.status(401).redirect(
`${baseUrl}${basePath}/error?${new URLSearchParams({
return {
status: 401,
redirect: `${url}/error?${new URLSearchParams({
error: "CredentialsSignin",
provider: provider.id,
})}`
)
})}`,
cookies,
}
}
} catch (error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(error.message)}`
)
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message
)}`,
cookies,
}
}
/** @type {import("src").Account} */
@@ -345,20 +366,26 @@ export default async function callback(req, res) {
try {
const isAllowed = await callbacks.signIn({
user,
// @ts-expect-error
account,
credentials,
})
if (!isAllowed) {
return res
.status(403)
.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
return {
status: 403,
redirect: `${url}/error?error=AccessDenied`,
cookies,
}
} else if (typeof isAllowed === "string") {
return res.redirect(isAllowed)
return { redirect: isAllowed, cookies }
}
} catch (error) {
return res.redirect(
`${baseUrl}${basePath}/error?error=${encodeURIComponent(error.message)}`
)
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message
)}`,
cookies,
}
}
const defaultToken = {
@@ -371,27 +398,32 @@ export default async function callback(req, res) {
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
isNewUser: false,
})
// Sign and encrypt token
const newEncodedJwt = await jwt.encode({ ...jwt, token })
// Encode token
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
cookie.set(res, cookies.sessionToken.name, newEncodedJwt, {
expires: cookieExpires.toISOString(),
...cookies.sessionToken.options,
const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
// @ts-expect-error
await events.signIn?.({ user, account })
return res.redirect(callbackUrl || baseUrl)
return { redirect: callbackUrl, cookies }
}
return {
status: 500,
body: `Error: Callback for provider type ${provider.type} not supported`,
cookies,
}
return res
.status(500)
.end(`Error: Callback for provider type ${provider.type} not supported`)
}

View File

@@ -0,0 +1,30 @@
import { OutgoingResponse } from ".."
import { InternalProvider } from "../../lib/types"
export interface PublicProvider {
id: string
name: string
type: string
signinUrl: string
callbackUrl: string
}
/**
* Return a JSON object with a list of all OAuth providers currently configured
* and their signin and callback URLs. This makes it possible to automatically
* generate buttons for all providers when rendering client side.
*/
export default function providers(
providers: InternalProvider[]
): OutgoingResponse<Record<string, PublicProvider>> {
return {
headers: [{ key: "Content-Type", value: "application/json" }],
body: providers.reduce<Record<string, PublicProvider>>(
(acc, { id, name, type, signinUrl, callbackUrl }) => {
acc[id] = { id, name, type, signinUrl, callbackUrl }
return acc
},
{}
),
}
}

View File

@@ -1,34 +1,56 @@
import * as cookie from "../lib/cookie"
import { fromDate } from "../lib/utils"
import type { Adapter } from "../../adapters"
import type { InternalOptions } from "../../lib/types"
import type { OutgoingResponse } from ".."
import type { Session } from "../.."
import type { SessionStore } from "../lib/cookie"
interface SessionParams {
options: InternalOptions
sessionStore: SessionStore
}
/**
* Return a session object (without any private fields)
* for Single Page App clients
* @param {import("src/lib/types").NextAuthRequest} req
* @param {import("src/lib/types").NextAuthResponse} res
*/
export default async function session(req, res) {
const { cookies, adapter, jwt, events, callbacks, logger } = req.options
const useJwtSession = req.options.session.jwt
const sessionMaxAge = req.options.session.maxAge
const sessionToken = req.cookies[cookies.sessionToken.name]
if (!sessionToken) {
return res.json({})
export default async function session(
params: SessionParams
): Promise<OutgoingResponse<Session | {}>> {
const { options, sessionStore } = params
const {
adapter,
jwt,
events,
callbacks,
logger,
session: { strategy: sessionStrategy, maxAge: sessionMaxAge },
} = options
const response: OutgoingResponse<Session | {}> = {
body: {},
headers: [{ key: "Content-Type", value: "application/json" }],
cookies: [],
}
let response = {}
if (useJwtSession) {
try {
// Decrypt and verify token
const decodedToken = await jwt.decode({ ...jwt, token: sessionToken })
const sessionToken = sessionStore.value
if (!sessionToken) return response
if (sessionStrategy === "jwt") {
try {
const decodedToken = await jwt.decode({
...jwt,
token: sessionToken,
})
// Generate new session expiry date
const newExpires = fromDate(sessionMaxAge)
// By default, only exposes a limited subset of information to the client
// as needed for presentation purposes (e.g. "you are logged in as...").
const defaultSession = {
const session = {
user: {
name: decodedToken?.name,
email: decodedToken?.email,
@@ -37,37 +59,39 @@ export default async function session(req, res) {
expires: newExpires.toISOString(),
}
// Pass Session and JSON Web Token through to the session callback
// @ts-expect-error
const token = await callbacks.jwt({ token: decodedToken })
const session = await callbacks.session({
session: defaultSession,
token,
})
// @ts-expect-error
const newSession = await callbacks.session({ session, token })
// Return session payload as response
response = session
response.body = newSession
// Refresh JWT expiry by re-signing it, with an updated expiry date
const newToken = await jwt.encode({ ...jwt, token })
const newToken = await jwt.encode({
...jwt,
token,
maxAge: options.session.maxAge,
})
// Set cookie, to also update expiry date on cookie
cookie.set(res, cookies.sessionToken.name, newToken, {
const sessionCookies = sessionStore.chunk(newToken, {
expires: newExpires,
...cookies.sessionToken.options,
})
await events.session?.({ session, token })
response.cookies?.push(...sessionCookies)
await events.session?.({ session: newSession, token })
} catch (error) {
// If JWT not verifiable, make sure the cookie for it is removed and return empty object
logger.error("JWT_SESSION_ERROR", error)
cookie.set(res, cookies.sessionToken.name, "", {
...cookies.sessionToken.options,
maxAge: 0,
})
logger.error("JWT_SESSION_ERROR", error as Error)
response.cookies?.push(...sessionStore.clean())
}
} else {
try {
const { getSessionAndUser, deleteSession, updateSession } = adapter
const { getSessionAndUser, deleteSession, updateSession } =
adapter as Adapter
let userAndSession = await getSessionAndUser(sessionToken)
// If session has expired, clean up the database
@@ -82,7 +106,7 @@ export default async function session(req, res) {
if (userAndSession) {
const { user, session } = userAndSession
const sessionUpdateAge = req.options.session.updateAge
const sessionUpdateAge = options.session.updateAge
// Calculate last updated date to throttle write updates to database
// Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge
// e.g. ({expiry date} - 30 days) + 1 hour
@@ -99,6 +123,7 @@ export default async function session(req, res) {
}
// Pass Session through to the session callback
// @ts-expect-error
const sessionPayload = await callbacks.session({
// By default, only exposes a limited subset of information to the client
// as needed for presentation purposes (e.g. "you are logged in as...").
@@ -114,27 +139,29 @@ export default async function session(req, res) {
})
// Return session payload as response
response = sessionPayload
response.body = sessionPayload
// Set cookie again to update expiry
cookie.set(res, cookies.sessionToken.name, sessionToken, {
expires: newExpires,
...cookies.sessionToken.options,
response.cookies?.push({
name: options.cookies.sessionToken.name,
value: sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: newExpires,
},
})
// @ts-expect-error
await events.session?.({ session: sessionPayload })
} else if (sessionToken) {
// If sessionToken was found set but it's not valid for a session then
// If `sessionToken` was found set but it's not valid for a session then
// remove the sessionToken cookie from browser.
cookie.set(res, cookies.sessionToken.name, "", {
...cookies.sessionToken.options,
maxAge: 0,
})
response.cookies?.push(...sessionStore.clean())
}
} catch (error) {
logger.error("SESSION_ERROR", error)
logger.error("SESSION_ERROR", error as Error)
}
}
res.json(response)
return response
}

92
src/core/routes/signin.ts Normal file
View File

@@ -0,0 +1,92 @@
import getAuthorizationUrl from "../lib/oauth/authorization-url"
import emailSignin from "../lib/email/signin"
import { IncomingRequest, OutgoingResponse } from ".."
import { InternalOptions } from "../../lib/types"
import { Account, User } from "../.."
/** Handle requests to /api/auth/signin */
export default async function signin(params: {
options: InternalOptions<"oauth" | "email">
query: IncomingRequest["query"]
body: IncomingRequest["body"]
}): Promise<OutgoingResponse> {
const { options, query, body } = params
const { url, adapter, callbacks, logger, provider } = options
if (!provider.type) {
return {
status: 500,
// @ts-expect-error
text: `Error: Type not specified for ${provider.name}`,
}
}
if (provider.type === "oauth") {
try {
const response = await getAuthorizationUrl({ options, query })
return response
} catch (error) {
logger.error("SIGNIN_OAUTH_ERROR", { error: error as Error, provider })
return { redirect: `${url}/error?error=OAuthSignin` }
}
} else if (provider.type === "email") {
// Note: Technically the part of the email address local mailbox element
// (everything before the @ symbol) should be treated as 'case sensitive'
// according to RFC 2821, but in practice this causes more problems than
// it solves. We treat email addresses as all lower case. If anyone
// complains about this we can make strict RFC 2821 compliance an option.
const email = body?.email?.toLowerCase() ?? null
// Verified in `assertConfig`
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { getUserByEmail } = adapter!
// If is an existing user return a user object (otherwise use placeholder)
const user: User = (email ? await getUserByEmail(email) : null) ?? {
email,
id: email,
}
const account: Account = {
providerAccountId: email,
userId: email,
type: "email",
provider: provider.id,
}
// Check if user is allowed to sign in
try {
// @ts-expect-error
const signInCallbackResponse = await callbacks.signIn({
user,
account,
email: { verificationRequest: true },
})
if (!signInCallbackResponse) {
return { redirect: `${url}/error?error=AccessDenied` }
} else if (typeof signInCallbackResponse === "string") {
return { redirect: signInCallbackResponse }
}
} catch (error) {
return {
redirect: `${url}/error?${new URLSearchParams({
error: error as string,
})}`,
}
}
try {
await emailSignin(email, options)
} catch (error) {
logger.error("SIGNIN_EMAIL_ERROR", error as Error)
return { redirect: `${url}/error?error=EmailSignin` }
}
const params = new URLSearchParams({
provider: provider.id,
type: provider.type,
})
return { redirect: `${url}/verify-request?${params}` }
}
return { redirect: `${url}/signin` }
}

View File

@@ -0,0 +1,45 @@
import type { Adapter } from "../../adapters"
import type { InternalOptions } from "../../lib/types"
import type { OutgoingResponse } from ".."
import type { SessionStore } from "../lib/cookie"
/** Handle requests to /api/auth/signout */
export default async function signout(params: {
options: InternalOptions
sessionStore: SessionStore
}): Promise<OutgoingResponse> {
const { options, sessionStore } = params
const { adapter, events, jwt, callbackUrl, logger, session } = options
const sessionToken = sessionStore?.value
if (!sessionToken) {
return { redirect: callbackUrl }
}
if (session.strategy === "jwt") {
// Dispatch signout event
try {
const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken })
// @ts-expect-error
await events.signOut?.({ token: decodedJwt })
} catch (error) {
// Do nothing if decoding the JWT fails
logger.error("SIGNOUT_ERROR", error)
}
} else {
try {
const session = await (adapter as Adapter).deleteSession(sessionToken)
// Dispatch signout event
// @ts-expect-error
await events.signOut?.({ session })
} catch (error) {
// If error, log it but continue
logger.error("SIGNOUT_ERROR", error as Error)
}
}
// Remove Session Token
const sessionCookies = sessionStore.clean()
return { redirect: callbackUrl, cookies: sessionCookies }
}

View File

@@ -1,8 +1,9 @@
import { Adapter } from "../adapters"
import { Provider, CredentialInput, ProviderType } from "../providers"
import { TokenSetParameters } from "openid-client"
import { JWT, JWTOptions } from "../jwt"
import { LoggerInstance } from "../lib/logger"
import type { Adapter } from "../adapters"
import type { Provider, CredentialInput, ProviderType } from "../providers"
import type { TokenSetParameters } from "openid-client"
import type { JWT, JWTOptions } from "../jwt"
import type { LoggerInstance } from "../lib/logger"
import type { CookieSerializeOptions } from "cookie"
export type Awaitable<T> = T | PromiseLike<T>
@@ -26,9 +27,10 @@ export interface NextAuthOptions {
providers: Provider[]
/**
* A random string used to hash tokens, sign cookies and generate cryptographic keys.
* If not specified is uses a hash of all configuration options, including Client ID / Secrets for entropy.
* The default behavior is volatile, and **it is strongly recommended** you explicitly specify a value
* to avoid invalidating end user sessions when configuration changes are deployed.
* If not specified, it falls back to `jwt.secret` or `NEXTAUTH_SECRET` from environment vairables.
* Otherwise it will use a hash of all configuration options, including Client ID / Secrets for entropy.
*
* NOTE: The last behavior is extrmely volatile, and will throw an error in production.
* * **Default value**: `string` (SHA hash of the "options" object)
* * **Required**: No - **but strongly recommended**!
*
@@ -201,7 +203,11 @@ export interface NextAuthOptions {
* [Documentation](https://next-auth.js.org/configuration/options#theme) |
* [Pages](https://next-auth.js.org/configuration/pages)
*/
export type Theme = "auto" | "dark" | "light"
export interface Theme {
colorScheme: "auto" | "dark" | "light"
logo?: string
brandColor?: string
}
/**
* Different tokens returned by OAuth Providers.
@@ -274,7 +280,7 @@ export interface CallbacksOptions<
verificationRequest?: boolean
}
/** If Credentials provider is used, it contains the user credentials */
credentials: Record<string, CredentialInput>
credentials?: Record<string, CredentialInput>
}) => Awaitable<string | boolean>
/**
* This callback is called anytime the user is redirected to a callback URL (e.g. on signin or signout).
@@ -293,7 +299,7 @@ export interface CallbacksOptions<
* This callback is called whenever a session is checked.
* (Eg.: invoking the `/api/session` endpoint, using `useSession` or `getSession`)
*
* By default, only a subset (email, name, imgage)
* By default, only a subset (email, name, image)
* of the token is returned for increased security.
*
* If you want to make something available you added to the token through the `jwt` callback,
@@ -334,14 +340,7 @@ export interface CallbacksOptions<
/** [Documentation](https://next-auth.js.org/configuration/options#cookies) */
export interface CookieOption {
name: string
options: {
httpOnly?: boolean
sameSite: true | "strict" | "lax" | "none"
path?: string
secure: boolean
maxAge?: number
domain?: string
}
options: CookieSerializeOptions
}
/** [Documentation](https://next-auth.js.org/configuration/options#cookies) */
@@ -350,6 +349,7 @@ export interface CookiesOptions {
callbackUrl: CookieOption
csrfToken: CookieOption
pkceCodeVerifier: CookieOption
state: CookieOption
}
/**
@@ -424,9 +424,23 @@ export interface DefaultSession extends Record<string, unknown> {
*/
export interface Session extends Record<string, unknown>, DefaultSession {}
export type SessionStrategy = "jwt" | "database"
/** [Documentation](https://next-auth.js.org/configuration/options#session) */
export interface SessionOptions {
jwt: boolean
/**
* Choose how you want to save the user session.
* The default is `"jwt"`, an encrypted JWT (JWE) in the session cookie.
*
* If you use an `adapter` however, we default it to `"database"` instead.
* You can still force a JWT session by explicitly defining `"jwt"`.
*
* When using `"database"`, the session cookie will only contain a `sessionToken` value,
* which is used to look up the session in the database.
*
* [Documentation](https://next-auth.js.org/configuration/options#session) | [Adapter](https://next-auth.js.org/configuration/options#adapter) | [About JSON Web Tokens](https://next-auth.js.org/faq#json-web-tokens)
*/
strategy: SessionStrategy
/**
* Relative time from now in seconds when to expire the session
* @default 2592000 // 30 days
@@ -458,13 +472,3 @@ export interface DefaultUser {
* [`profile` OAuth provider callback](https://next-auth.js.org/configuration/providers#using-a-custom-provider)
*/
export interface User extends Record<string, unknown>, DefaultUser {}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface ProcessEnv {
NEXTAUTH_URL?: string
VERCEL_URL?: string
}
}
}

View File

@@ -1,6 +1,6 @@
:root {
--border-width: 1px;
--border-radius: .3rem;
--border-radius: 0.3rem;
--color-error: #c94b4b;
--color-info: #157efb;
--color-info-text: #fff;
@@ -43,7 +43,9 @@ body {
background-color: var(--color-background);
margin: 0;
padding: 0;
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
font-family: -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans,
sans-serif, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
}
h1 {
@@ -54,7 +56,7 @@ h1 {
}
p {
color: var(--color-text)
color: var(--color-text);
}
form {
@@ -67,19 +69,19 @@ label {
text-align: left;
margin-bottom: 0.25rem;
display: block;
color: #666;
color: var(--color-text);
}
input[type] {
box-sizing: border-box;
display: block;
width: 100%;
padding: .5rem 1rem;
padding: 0.5rem 1rem;
border: var(--border-width) solid var(--color-control-border);
background: var(--color-background);
font-size: 1rem;
border-radius: var(--border-radius);
box-shadow: inset 0 .1rem .2rem rgba(0, 0, 0, .2);
box-shadow: inset 0 0.1rem 0.2rem rgba(0, 0, 0, 0.2);
color: var(--color-text);
&:focus {
@@ -107,15 +109,17 @@ a.button {
button,
a.button {
margin: 0 0 .75rem 0;
padding: .75rem 1rem;
margin: 0 0 0.75rem 0;
padding: 0.75rem 1rem;
border: var(--border-width) solid var(--color-control-border);
color: var(--color-primary);
background-color: var(--color-background);
font-size: 1rem;
border-radius: var(--border-radius);
transition: all .1s ease-in-out;
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .05);
transition: all 0.1s ease-in-out;
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
inset 0 0.1rem 0.2rem var(--color-background),
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.05);
font-weight: 500;
position: relative;
@@ -124,7 +128,9 @@ a.button {
}
&:active {
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, .15), inset 0 .1rem .2rem var(--color-background), inset 0 -.1rem .1rem rgba(0, 0, 0, .1);
box-shadow: 0 0.15rem 0.3rem rgba(0, 0, 0, 0.15),
inset 0 0.1rem 0.2rem var(--color-background),
inset 0 -0.1rem 0.1rem rgba(0, 0, 0, 0.1);
background-color: var(--color-button-active-background);
border-color: var(--color-button-active-border);
cursor: pointer;
@@ -146,13 +152,12 @@ a.site {
position: absolute;
width: 100%;
height: 100%;
display: table;
display: grid;
place-items: center;
margin: 0;
padding: 0;
>div {
display: table-cell;
vertical-align: middle;
> div {
text-align: center;
padding: 0.5rem;
}
@@ -163,7 +168,7 @@ a.site {
display: inline-block;
padding-left: 2rem;
padding-right: 2rem;
margin-top: .5rem;
margin-top: 0.5rem;
}
.message {
@@ -172,7 +177,6 @@ a.site {
}
.signin {
button,
a.button,
input[type="text"] {
@@ -192,9 +196,9 @@ a.site {
content: "or";
background: var(--color-background);
color: #888;
padding: 0 .4rem;
padding: 0 0.4rem;
position: relative;
top: -.6rem;
top: -0.6rem;
}
}
@@ -213,10 +217,9 @@ a.site {
}
}
>div,
> div,
form {
display: block;
margin: 0 auto 0.5rem auto;
input[type] {
margin-bottom: 0.5rem;
@@ -228,4 +231,32 @@ a.site {
max-width: 300px;
}
}
}
.signout {
.message {
margin-bottom: 1.5rem;
}
}
.logo {
display: inline-block;
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, var(--color-text));
}

View File

@@ -1,2 +1,6 @@
export { default } from "./server"
export * from "./server/types"
export * from "./core/types"
export type { IncomingRequest, OutgoingResponse } from "./core"
export * from "./next"
export { default } from "./next"

View File

@@ -1,217 +1,121 @@
import crypto from "crypto"
import jose from "jose"
import logger from "../lib/logger"
import { NextApiRequest } from "next"
import type { JWT, JWTDecodeParams, JWTEncodeParams } from "./types"
import { EncryptJWT, jwtDecrypt } from "jose"
import hkdf from "@panva/hkdf"
import { v4 as uuid } from "uuid"
import { SessionStore } from "../core/lib/cookie"
import type { NextApiRequest } from "next"
import type { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "./types"
import type { LoggerInstance } from ".."
export * from "./types"
// Set default algorithm to use for auto-generated signing key
const DEFAULT_SIGNATURE_ALGORITHM = "HS512"
// Set default algorithm for auto-generated symmetric encryption key
const DEFAULT_ENCRYPTION_ALGORITHM = "A256GCM"
// Use encryption or not by default
const DEFAULT_ENCRYPTION_ENABLED = false
const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days
export async function encode({
token = {},
maxAge = DEFAULT_MAX_AGE,
secret,
signingKey,
signingOptions = {
expiresIn: `${maxAge}s`,
},
encryptionKey,
encryptionOptions = {
alg: "dir",
enc: DEFAULT_ENCRYPTION_ALGORITHM,
zip: "DEF",
},
encryption = DEFAULT_ENCRYPTION_ENABLED,
}: JWTEncodeParams) {
// Signing Key
const _signingKey = signingKey
? jose.JWK.asKey(JSON.parse(signingKey))
: getDerivedSigningKey(secret)
const now = () => (Date.now() / 1000) | 0
// Sign token
const signedToken = jose.JWT.sign(token, _signingKey, signingOptions)
if (encryption) {
// Encryption Key
const _encryptionKey = encryptionKey
? jose.JWK.asKey(JSON.parse(encryptionKey))
: getDerivedEncryptionKey(secret)
// Encrypt token
return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions)
}
return signedToken
/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */
export async function encode(params: JWTEncodeParams) {
const { token = {}, secret, maxAge = DEFAULT_MAX_AGE } = params
const encryptionSecret = await getDerivedEncryptionKey(secret)
return await new EncryptJWT(token)
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuedAt()
.setExpirationTime(now() + maxAge)
.setJti(uuid())
.encrypt(encryptionSecret)
}
export async function decode({
secret,
token,
maxAge = DEFAULT_MAX_AGE,
signingKey,
verificationKey = signingKey, // Optional (defaults to encryptionKey)
verificationOptions = {
maxTokenAge: `${maxAge}s`,
algorithms: [DEFAULT_SIGNATURE_ALGORITHM],
},
encryptionKey,
decryptionKey = encryptionKey, // Optional (defaults to encryptionKey)
decryptionOptions = {
algorithms: [DEFAULT_ENCRYPTION_ALGORITHM],
},
encryption = DEFAULT_ENCRYPTION_ENABLED,
}: JWTDecodeParams): Promise<JWT | null> {
/** Decodes a NextAuth.js issued JWT. */
export async function decode(params: JWTDecodeParams): Promise<JWT | null> {
const { token, secret } = params
if (!token) return null
let tokenToVerify = token
if (encryption) {
// Encryption Key
const _encryptionKey = decryptionKey
? jose.JWK.asKey(JSON.parse(decryptionKey))
: getDerivedEncryptionKey(secret)
// Decrypt token
const decryptedToken = jose.JWE.decrypt(
token,
_encryptionKey,
decryptionOptions
)
tokenToVerify = decryptedToken.toString("utf8")
}
// Signing Key
const _signingKey = verificationKey
? jose.JWK.asKey(JSON.parse(verificationKey))
: getDerivedSigningKey(secret)
// Verify token
return jose.JWT.verify(
tokenToVerify,
_signingKey,
verificationOptions
) as JWT | null
const encryptionSecret = await getDerivedEncryptionKey(secret)
const { payload } = await jwtDecrypt(token, encryptionSecret, {
clockTolerance: 15,
})
return payload
}
export type GetTokenParams<R extends boolean = false> = {
req: NextApiRequest
export interface GetTokenParams<R extends boolean = false> {
/** The request containing the JWT either in the cookies or in the `Authorization` header. */
req: NextApiRequest | Pick<NextApiRequest, "cookies" | "headers">
/**
* Use secure prefix for cookie name, unless URL in `NEXTAUTH_URL` is http://
* or not set (e.g. development or test instance) case use unprefixed name
*/
secureCookie?: boolean
/** If the JWT is in the cookie, what name `getToken()` should look for. */
cookieName?: string
/**
* `getToken()` will return the raw JWT if this is set to `true`
* @default false
*/
raw?: R
decode?: typeof decode
/**
* The same `secret` used in the `NextAuth` configuration.
* Defaults to the `NEXTAUTH_SECRET` environment variable.
*/
secret?: string
} & Omit<JWTDecodeParams, "secret">
decode?: JWTOptions["decode"]
logger?: LoggerInstance | Console
}
/** [Documentation](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken) */
/**
* Takes a NextAuth.js request (`req`) and returns either the NextAuth.js issued JWT's payload,
* or the raw JWT string. We look for the JWT in the either the cookies, or the `Authorization` header.
* [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> {
const {
req,
// Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http://
// or not set (e.g. development or test instance) case use unprefixed name
secureCookie = !(
!process.env.NEXTAUTH_URL ||
process.env.NEXTAUTH_URL.startsWith("http://")
),
secureCookie = process.env.NEXTAUTH_URL?.startsWith("https://") ??
!!process.env.VERCEL,
cookieName = secureCookie
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
raw = false,
raw,
decode: _decode = decode,
logger = console,
secret = process.env.NEXTAUTH_SECRET,
} = params ?? {}
if (!req) throw new Error("Must pass `req` to JWT getToken()")
// Try to get token from cookie
let token = req.cookies[cookieName]
const sessionStore = new SessionStore(
{ name: cookieName, options: { secure: secureCookie } },
{ cookies: req.cookies, headers: req.headers },
logger
)
let token = sessionStore.value
// If cookie not found in cookie look for bearer token in authorization header.
// This allows clients that pass through tokens in headers rather than as
// cookies to use this helper function.
if (!token && req.headers.authorization?.split(" ")[0] === "Bearer") {
const urlEncodedToken = req.headers.authorization.split(" ")[1]
token = decodeURIComponent(urlEncodedToken)
}
if (raw) {
// @ts-expect-error
return token
}
// @ts-expect-error
if (!token) return null
// @ts-expect-error
if (raw) return token
try {
// @ts-expect-error
return await _decode({ token, ...params })
return await _decode({ token, secret })
} catch {
// @ts-expect-error
return null
}
}
// Generate warning (but only once at startup) when auto-generated keys are used
let DERIVED_SIGNING_KEY_WARNING = false
let DERIVED_ENCRYPTION_KEY_WARNING = false
// Do the better hkdf of Node.js one added in `v15.0.0` and Third Party one
function hkdf(secret, { byteLength, encryptionInfo, digest = "sha256" }) {
if (crypto.hkdfSync) {
return Buffer.from(
crypto.hkdfSync(
digest,
secret,
Buffer.alloc(0),
encryptionInfo,
byteLength
)
)
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require("futoin-hkdf")(secret, byteLength, {
info: encryptionInfo,
hash: digest,
})
}
function getDerivedSigningKey(secret) {
if (!DERIVED_SIGNING_KEY_WARNING) {
logger.warn("JWT_AUTO_GENERATED_SIGNING_KEY")
DERIVED_SIGNING_KEY_WARNING = true
}
const buffer = hkdf(secret, {
byteLength: 64,
encryptionInfo: "NextAuth.js Generated Signing Key",
})
const key = jose.JWK.asKey(buffer, {
alg: DEFAULT_SIGNATURE_ALGORITHM,
use: "sig",
kid: "nextauth-auto-generated-signing-key",
})
return key
}
function getDerivedEncryptionKey(secret) {
if (!DERIVED_ENCRYPTION_KEY_WARNING) {
logger.warn("JWT_AUTO_GENERATED_ENCRYPTION_KEY")
DERIVED_ENCRYPTION_KEY_WARNING = true
}
const buffer = hkdf(secret, {
byteLength: 32,
encryptionInfo: "NextAuth.js Generated Encryption Key",
})
const key = jose.JWK.asKey(buffer, {
alg: DEFAULT_ENCRYPTION_ALGORITHM,
use: "enc",
kid: "nextauth-auto-generated-encryption-key",
})
return key
async function getDerivedEncryptionKey(secret: string | Buffer) {
return await hkdf(
"sha256",
secret,
"",
"NextAuth.js Generated Encryption Key",
32
)
}

View File

@@ -1,5 +1,4 @@
import type { JWT as JoseJWT, JWE } from "jose"
import { decode, encode } from "."
import type { Awaitable } from ".."
export interface DefaultJWT extends Record<string, unknown> {
name?: string | null
@@ -16,36 +15,40 @@ export interface DefaultJWT extends Record<string, unknown> {
export interface JWT extends Record<string, unknown>, DefaultJWT {}
export interface JWTEncodeParams {
/** The JWT payload. */
token?: JWT
maxAge?: number
/** The secret used to encode the NextAuth.js issued JWT. */
secret: string | Buffer
signingKey?: string
signingOptions?: JoseJWT.SignOptions
encryptionKey?: string
encryptionOptions?: object
encryption?: boolean
/**
* The maximum age of the NextAuth.js issued JWT in seconds.
* @default 30 * 24 * 30 * 60 // 30 days
*/
maxAge?: number
}
export interface JWTDecodeParams {
/** The NextAuth.js issued JWT to be decoded */
token?: string
maxAge?: number
/** The secret used to decode the NextAuth.js issued JWT. */
secret: string | Buffer
signingKey?: string
verificationKey?: string
verificationOptions?: JoseJWT.VerifyOptions<false>
encryptionKey?: string
decryptionKey?: string
decryptionOptions?: JWE.DecryptOptions<false>
encryption?: boolean
}
export interface JWTOptions {
/**
* The secret used to encode/decode the NextAuth.js issued JWT.
* @deprecated Set the `NEXTAUTH_SECRET` environment vairable or
* use the top-level `secret` option instead
*/
secret: string
/**
* The maximum age of the NextAuth.js issued JWT in seconds.
* @default 30 * 24 * 30 * 60 // 30 days
*/
maxAge: number
encryption?: boolean
signingKey?: string
encryptionKey?: string
encode: typeof encode
decode: typeof decode
verificationOptions?: JoseJWT.VerifyOptions<false>
/** Override this method to control the NextAuth.js issued JWT encoding. */
encode: (params: JWTEncodeParams) => Awaitable<string>
/** Override this method to control the NextAuth.js issued JWT decoding. */
decode: (params: JWTDecodeParams) => Awaitable<JWT | null>
}
export type Secret = string | Buffer

View File

@@ -1,29 +1,33 @@
import { UnknownError } from "../server/errors"
import { UnknownError } from "../core/errors"
// TODO: better typing
/** Makes sure that error is always serializable */
function formatError(o) {
function formatError(o: unknown): unknown {
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)
if (hasErrorProperty(o)) {
o.error = formatError(o.error) as Error
o.message = o.message ?? o.error.message
}
return o
}
function hasErrorProperty(
x: unknown
): x is { error: Error; [key: string]: unknown } {
return !!(x as any)?.error
}
export type WarningCode = "NEXTAUTH_URL" | "NO_SECRET" | "TWITTER_OAUTH_2_BETA"
/**
* 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
export interface LoggerInstance extends Record<string, Function> {
warn: (code: WarningCode) => void
error: (
code: string,
/**
@@ -38,7 +42,7 @@ export interface LoggerInstance {
const _logger: LoggerInstance = {
error(code, metadata) {
metadata = formatError(metadata)
metadata = formatError(metadata) as Error
console.error(
`[next-auth][error][${code}]`,
`\nhttps://next-auth.js.org/errors#${code.toLowerCase()}`,
@@ -53,16 +57,21 @@ const _logger: LoggerInstance = {
)
},
debug(code, metadata) {
if (!process?.env?._NEXTAUTH_DEBUG) return
console.log(`[next-auth][debug][${code}]`, metadata)
},
}
/**
* Override the built-in logger.
* Override the built-in logger with user's implementation.
* Any `undefined` level will use the default logger.
*/
export function setLogger(newLogger: Partial<LoggerInstance> = {}) {
export function setLogger(
newLogger: Partial<LoggerInstance> = {},
debug?: boolean
) {
// Turn off debug logging if `debug` isn't set to `true`
if (!debug) _logger.debug = () => {}
if (newLogger.error) _logger.error = newLogger.error
if (newLogger.warn) _logger.warn = newLogger.warn
if (newLogger.debug) _logger.debug = newLogger.debug
@@ -80,15 +89,15 @@ export function proxyLogger(
return logger
}
const clientLogger = {}
const clientLogger: Record<string, unknown> = {}
for (const level in logger) {
clientLogger[level] = (code, metadata) => {
clientLogger[level] = (code: string, metadata: Error) => {
_logger[level](code, metadata) // Logs to console
if (level === "error") {
metadata = formatError(metadata)
metadata = formatError(metadata) as Error
}
metadata.client = true
;(metadata as any).client = true
const url = `${basePath}/_log`
const body = new URLSearchParams({ level, code, ...metadata })
if (navigator.sendBeacon) {
@@ -97,7 +106,7 @@ export function proxyLogger(
return fetch(url, { method: "POST", body, keepalive: true })
}
}
return clientLogger as LoggerInstance
return clientLogger as unknown as LoggerInstance
} catch {
return _logger
}

View File

@@ -6,7 +6,7 @@ function isObject(item: any): boolean {
}
/** Deep merge two objects */
export function merge(target: any, ...sources: any[]) {
export function merge(target: any, ...sources: any[]): any {
if (!sources.length) return target
const source = sources.shift()

View File

@@ -1,29 +1,36 @@
/**
* 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)
*/
export default function parseUrl(url?: string) {
// Default values
const defaultHost = "http://localhost:3000"
const defaultPath = "/api/auth"
export interface InternalUrl {
/** @default "http://localhost:3000" */
origin: string
/** @default "localhost:3000" */
host: string
/** @default "/api/auth" */
path: string
/** @default "http://localhost:3000/api/auth" */
base: string
/** @default "http://localhost:3000/api/auth" */
toString: () => string
}
if (!url) {
url = `${defaultHost}${defaultPath}`
/** Returns an `URL` like object to make requests/redirects from server-side */
export default function parseUrl(url?: string): InternalUrl {
const defaultUrl = new URL("http://localhost:3000/api/auth")
if (url && !url.startsWith("http")) {
url = `https://${url}`
}
// Default to HTTPS if no protocol explictly specified
const protocol = url.startsWith("http:") ? "http" : "https"
const _url = new URL(url ?? defaultUrl)
const path = (_url.pathname === "/" ? defaultUrl.pathname : _url.pathname)
// Remove trailing slash
.replace(/\/$/, "")
// Normalize URLs by stripping protocol and no trailing slash
url = url.replace(/^https?:\/\//, "").replace(/\/$/, "")
const base = `${_url.origin}${path}`
// Simple split based on first /
const [_host, ..._path] = url.split("/")
const baseUrl = _host ? `${protocol}://${_host}` : defaultHost
const basePath = _path.length > 0 ? `/${_path.join("/")}` : defaultPath
return { baseUrl, basePath }
return {
origin: _url.origin,
host: _url.host,
path,
base,
toString: () => base,
}
}

View File

@@ -11,35 +11,53 @@ import type {
Awaitable,
} from ".."
import type { Provider } from "../providers"
import type {
OAuthConfig,
EmailConfig,
CredentialsConfig,
ProviderType,
} from "../providers"
import type { JWTOptions } from "../jwt"
import type { Adapter } from "../adapters"
import { InternalUrl } from "./parse-url"
// Below are types that are only supposed be used by next-auth internally
/** @internal */
export type InternalProvider = Provider & {
export type InternalProvider<T extends ProviderType = any> = (T extends "oauth"
? OAuthConfig<any>
: T extends "email"
? EmailConfig
: T extends "credentials"
? CredentialsConfig
: never) & {
signinUrl: string
callbackUrl: string
}
export type NextAuthAction =
| "providers"
| "session"
| "csrf"
| "signin"
| "signout"
| "callback"
| "verify-request"
| "error"
| "_log"
/** @internal */
export interface InternalOptions<
P extends InternalProvider = InternalProvider
> {
export interface InternalOptions<T extends ProviderType = any> {
providers: InternalProvider[]
baseUrl: string
basePath: string
action:
| "providers"
| "session"
| "csrf"
| "signin"
| "signout"
| "callback"
| "verify-request"
| "error"
provider: P
/**
* Parsed from `NEXTAUTH_URL` or `x-forwarded-host` on Vercel.
* @default "http://localhost:3000/api/auth"
*/
url: InternalUrl
action: NextAuthAction
provider: T extends string
? InternalProvider<T>
: InternalProvider<T> | undefined
csrfToken?: string
csrfTokenVerified?: boolean
secret: string

2
src/middleware.ts Normal file
View File

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

118
src/next/index.ts Normal file
View File

@@ -0,0 +1,118 @@
import { NextAuthHandler } from "../core"
import { setCookie, detectHost } from "./utils"
import type {
GetServerSidePropsContext,
NextApiRequest,
NextApiResponse,
} from "next"
import type { NextAuthOptions, Session } from ".."
import type {
NextAuthAction,
NextAuthRequest,
NextAuthResponse,
} from "../lib/types"
async function NextAuthNextHandler(
req: NextApiRequest,
res: NextApiResponse,
options: NextAuthOptions
) {
const { nextauth, ...query } = req.query
options.secret =
options.secret ?? options.jwt?.secret ?? process.env.NEXTAUTH_SECRET
const handler = await NextAuthHandler({
req: {
host: detectHost(req.headers["x-forwarded-host"]),
body: req.body,
query,
cookies: req.cookies,
headers: req.headers,
method: req.method,
action: nextauth?.[0] as NextAuthAction,
providerId: nextauth?.[1],
error: (req.query.error as string | undefined) ?? nextauth?.[1],
},
options,
})
res.status(handler.status ?? 200)
handler.cookies?.forEach((cookie) => setCookie(res, cookie))
handler.headers?.forEach((h) => res.setHeader(h.key, h.value))
if (handler.redirect) {
// If the request expects a return URL, send it as JSON
// instead of doing an actual redirect.
if (req.body?.json !== "true") {
// Could chain. .end() when lowest target is Node 14
// https://github.com/nodejs/node/issues/33148
res.status(302).setHeader("Location", handler.redirect)
return res.end()
}
return res.json({ url: handler.redirect })
}
return res.send(handler.body)
}
function NextAuth(options: NextAuthOptions): any
function NextAuth(
req: NextApiRequest,
res: NextApiResponse,
options: NextAuthOptions
): any
/** Tha main entry point to next-auth */
function NextAuth(
...args:
| [NextAuthOptions]
| [NextApiRequest, NextApiResponse, NextAuthOptions]
) {
if (args.length === 1) {
return async (req: NextAuthRequest, res: NextAuthResponse) =>
await NextAuthNextHandler(req, res, args[0])
}
return NextAuthNextHandler(args[0], args[1], args[2])
}
export default NextAuth
export async function getServerSession(
context:
| GetServerSidePropsContext
| { req: NextApiRequest; res: NextApiResponse },
options: NextAuthOptions
): Promise<Session | null> {
const session = await NextAuthHandler<Session | {}>({
options,
req: {
host: detectHost(context.req.headers["x-forwarded-host"]),
action: "session",
method: "GET",
cookies: context.req.cookies,
headers: context.req.headers,
},
})
const { body, cookies } = session
cookies?.forEach((cookie) => setCookie(context.res, cookie))
if (body && Object.keys(body).length) return body as Session
return null
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface ProcessEnv {
NEXTAUTH_URL?: string
VERCEL?: "1"
}
}
}

141
src/next/middleware.ts Normal file
View File

@@ -0,0 +1,141 @@
import type { NextMiddleware, NextFetchEvent } from "next/server"
import type { Awaitable, NextAuthOptions } from ".."
import type { JWT } from "../jwt"
import { NextResponse, NextRequest } from "next/server"
import { getToken } from "../jwt"
import parseUrl from "../lib/parse-url"
type AuthorizedCallback = (params: {
token: JWT | null
req: NextRequest
}) => Awaitable<boolean>
export interface NextAuthMiddlewareOptions {
/**
* Where to redirect the user in case of an error if they weren't logged in.
* Similar to `pages` in `NextAuth`.
*
* ---
* [Documentation](https://next-auth.js.org/configuration/pages)
*/
pages?: NextAuthOptions["pages"]
callbacks?: {
/**
* Callback that receives the user's JWT payload
* and returns `true` to allow the user to continue.
*
* This is similar to the `signIn` callback in `NextAuthOptions`.
*
* If it returns `false`, the user is redirected to the sign-in page instead
*
* The default is to let the user continue if they have a valid JWT (basic authentication).
*
* How to restrict a page and all of it's subpages for admins-only:
* @example
*
* ```js
* // `pages/admin/_middleware.js`
* import { withAuth } from "next-auth/middleware"
*
* export default withAuth({
* callbacks: {
* authorized: ({ token }) => token?.user.isAdmin
* }
* })
* ```
*
* ---
* [Documentation](https://next-auth.js.org/getting-started/nextjs/middleware#api) | [`signIn` callback](configuration/callbacks#sign-in-callback)
*/
authorized?: AuthorizedCallback
}
}
async function handleMiddleware(
req: NextRequest,
options: NextAuthMiddlewareOptions | undefined,
onSuccess?: (token: JWT | null) => Promise<any>
) {
const signInPage = options?.pages?.signIn ?? "/api/auth/signin"
const errorPage = options?.pages?.error ?? "/api/auth/error"
const basePath = parseUrl(process.env.NEXTAUTH_URL).path
// Avoid infinite redirect loop
if (
req.nextUrl.pathname.startsWith(basePath) ||
[signInPage, errorPage].includes(req.nextUrl.pathname)
) {
return
}
if (!process.env.NEXTAUTH_SECRET) {
console.error(
`[next-auth][error][NO_SECRET]`,
`\nhttps://next-auth.js.org/errors#no_secret`
)
return {
redirect: NextResponse.redirect(`${errorPage}?error=Configuration`),
}
}
const token = await getToken({ req: req as any })
const isAuthorized =
(await options?.callbacks?.authorized?.({ req, token })) ?? !!token
// the user is authorized, let the middleware handle the rest
if (isAuthorized) return await onSuccess?.(token)
// the user is not logged in, re-direct to the sign-in page
return NextResponse.redirect(
`${signInPage}?${new URLSearchParams({ callbackUrl: req.url })}`
)
}
export type WithAuthArgs =
| [NextRequest]
| [NextRequest, NextFetchEvent]
| [NextRequest, NextAuthMiddlewareOptions]
| [NextMiddleware]
| [NextMiddleware, NextAuthMiddlewareOptions]
| [NextAuthMiddlewareOptions]
/**
* Middleware that checks if the user is authenticated/authorized.
* If if they aren't, they will be redirected to the login page.
* Otherwise, continue.
*
* @example
*
* ```js
* // `pages/_middleware.js`
* export { default } from "next-auth/middleware"
* ```
*
* ---
* [Documentation](https://next-auth.js.org/getting-started/middleware)
*/
export function withAuth(...args: WithAuthArgs) {
if (args[0] instanceof NextRequest) {
// @ts-expect-error
return handleMiddleware(...args)
}
if (typeof args[0] === "function") {
const middleware = args[0]
const options = args[1] as NextAuthMiddlewareOptions | undefined
return async (...args: Parameters<NextMiddleware>) =>
await handleMiddleware(args[0], options, async (token) => {
;(args[0] as any).nextauth = { token }
return await middleware(...args)
})
}
const options = args[0]
return async (...args: Parameters<NextMiddleware>) =>
await handleMiddleware(args[0], options)
}
export default withAuth

23
src/next/utils.ts Normal file
View File

@@ -0,0 +1,23 @@
import { serialize } from "cookie"
import { Cookie } from "../core/lib/cookie"
export function setCookie(res, cookie: Cookie) {
// Preserve any existing cookies that have already been set in the same session
let setCookieHeader = res.getHeader("Set-Cookie") ?? []
// If not an array (i.e. a string with a single cookie) convert it into an array
if (!Array.isArray(setCookieHeader)) {
setCookieHeader = [setCookieHeader]
}
const { name, value, options } = cookie
const cookieHeader = serialize(name, value, options)
setCookieHeader.push(cookieHeader)
res.setHeader("Set-Cookie", setCookieHeader)
}
/** Extract the host from the environment */
export function detectHost(forwardedHost: any) {
// If we detect a Vercel environment, we can trust the host
if (process.env.VERCEL) return forwardedHost
// If `NEXTAUTH_URL` is `undefined` we fall back to "http://localhost:3000"
return process.env.NEXTAUTH_URL
}

179
src/providers/42-school.ts Normal file
View File

@@ -0,0 +1,179 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface UserData {
id: number
email: string
login: string
first_name: string
last_name: string
usual_full_name: null | string
usual_first_name: null | string
url: string
phone: "hidden" | string | null
displayname: string
image_url: string | null
"staff?": boolean
correction_point: number
pool_month: string | null
pool_year: string | null
location: string | null
wallet: number
anonymize_date: string
created_at: string
updated_at: string | null
alumni: boolean
"is_launched?": boolean
}
export interface CursusUser {
grade: string | null
level: number
skills: Array<{ id: number; name: string; level: number }>
blackholed_at: string | null
id: number
begin_at: string | null
end_at: string | null
cursus_id: number
has_coalition: boolean
created_at: string
updated_at: string | null
user: UserData
cursus: { id: number; created_at: string; name: string; slug: string }
}
export interface ProjectUser {
id: number
occurrence: number
final_mark: number | null
status: "in_progress" | "finished"
"validated?": boolean | null
current_team_id: number
project: {
id: number
name: string
slug: string
parent_id: number | null
}
cursus_ids: number[]
marked_at: string | null
marked: boolean
retriable_at: string | null
created_at: string
updated_at: string | null
}
export interface Achievement {
id: number
name: string
description: string
tier: "none" | "easy" | "medium" | "hard" | "challenge"
kind: "scolarity" | "project" | "pedagogy" | "scolarity"
visible: boolean
image: string | null
nbr_of_success: number | null
users_url: string
}
export interface LanguagesUser {
id: number
language_id: number
user_id: number
position: number
created_at: string
}
export interface TitlesUser {
id: number
user_id: number
title_id: number
selected: boolean
created_at: string
updated_at: string | null
}
export interface ExpertisesUser {
id: number
expertise_id: number
interested: boolean
value: number
contact_me: boolean
created_at: string
user_id: number
}
export interface Campus {
id: number
name: string
time_zone: string
language: {
id: number
name: string
identifier: string
created_at: string
updated_at: string | null
}
users_count: number
vogsphere_id: number
country: string
address: string
zip: string
city: string
website: string
facebook: string
twitter: string
active: boolean
email_extension: string
default_hidden_phone: boolean
}
export interface CampusUser {
id: number
user_id: number
campus_id: number
is_primary: boolean
created_at: string
updated_at: string | null
}
export interface FortyTwoProfile extends UserData {
groups: Array<{ id: string; name: string }>
cursus_users: CursusUser[]
projects_users: ProjectUser[]
languages_users: LanguagesUser[]
achievements: Achievement[]
titles: Array<{ id: string; name: string }>
titles_users: TitlesUser[]
partnerships: any[]
patroned: any[]
patroning: any[]
expertises_users: ExpertisesUser[]
roles: Array<{ id: string; name: string }>
campus: Campus[]
campus_users: CampusUser[]
user: any | null
}
export default function FortyTwo<
P extends Record<string, any> = FortyTwoProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "42-school",
name: "42 School",
type: "oauth",
authorization: {
url: "https://api.intra.42.fr/oauth/authorize",
params: { scope: "public" },
},
token: "https://api.intra.42.fr/oauth/token",
userinfo: "https://api.intra.42.fr/v2/me",
profile(profile) {
return {
id: profile.id.toString(),
name: profile.usual_full_name,
email: profile.email,
image: profile.image_url,
}
},
options,
}
}

View File

@@ -1,20 +0,0 @@
/** @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,
}
}

View File

@@ -1,37 +0,0 @@
/** @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",
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
return {
id: profile.sub,
name,
email: profile.email,
image: null,
}
},
checks: ["none"], // REVIEW: Apple does not support state, as far as I know. Can we use "pkce" then?
options,
}
}

122
src/providers/apple.ts Normal file
View File

@@ -0,0 +1,122 @@
import { OAuthConfig, OAuthUserConfig } from "."
/**
* See more at:
* [Retrieve the User's Information from Apple ID Servers
](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773)
*/
export interface AppleProfile {
/**
* The issuer registered claim identifies the principal that issued the identity token.
* Since Apple generates the token, the value is `https://appleid.apple.com`.
*/
iss: "https://appleid.apple.com"
/**
* The audience registered claim identifies the recipient for which the identity token is intended.
* Since the token is meant for your application, the value is the `client_id` from your developer account.
*/
aud: string
/**
* The issued at registered claim indicates the time at which Apple issued the identity token,
* in terms of the number of seconds since Epoch, in UTC.
*/
iat: number
/**
* The expiration time registered identifies the time on or after which the identity token expires,
* in terms of number of seconds since Epoch, in UTC.
* The value must be greater than the current date/time when verifying the token.
*/
exp: number
/**
* The subject registered claim identifies the principal that's the subject of the identity token.
* Since this token is meant for your application, the value is the unique identifier for the user.
*/
sub: string
/**
* A String value used to associate a client session and the identity token.
* This value mitigates replay attacks and is present only if passed during the authorization request.
*/
nonce: string
/**
* A Boolean value that indicates whether the transaction is on a nonce-supported platform.
* If you sent a nonce in the authorization request but don't see the nonce claim in the identity token,
* check this claim to determine how to proceed.
* If this claim returns true, you should treat nonce as mandatory and fail the transaction;
* otherwise, you can proceed treating the nonce as options.
*/
nonce_supported: boolean
/**
* A String value representing the user's email address.
* The email address is either the user's real email address or the proxy address,
* depending on their status private email relay service.
*/
email: string
/**
* A String or Boolean value that indicates whether the service has verified the email.
* The value of this claim is always true, because the servers only return verified email addresses.
* The value can either be a String (`"true"`) or a Boolean (`true`).
*/
email_verified: "true" | true
/**
* A String or Boolean value that indicates whether the email shared by the user is the proxy address.
* The value can either be a String (`"true"` or `"false"`) or a Boolean (`true` or `false`).
*/
is_private_email: boolean | "true" | "false"
/**
* An Integer value that indicates whether the user appears to be a real person.
* Use the value of this claim to mitigate fraud. The possible values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal).
* For more information, see [`ASUserDetectionStatus`](https://developer.apple.com/documentation/authenticationservices/asuserdetectionstatus).
* This claim is present only on iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14 and later;
* the claim isn't present or supported for web-based apps.
*/
real_user_status: 0 | 1 | 2
/**
* A String value representing the transfer identifier used to migrate users to your team.
* This claim is present only during the 60-day transfer period after an you transfer an app.
* For more information, see [Bringing New Apps and Users into Your Team](https://developer.apple.com/documentation/sign_in_with_apple/bringing_new_apps_and_users_into_your_team).
*/
transfer_sub: string
at_hash: string
auth_time: number
}
export default function Apple<P extends Record<string, any> = AppleProfile>(
options: Omit<OAuthUserConfig<P>, "clientSecret"> & {
/**
* Apple requires the client secret to be a JWT. You can generate one using the following script:
* https://bal.so/apple-gen-secret
*
* Read more: [Creating the Client Secret
](https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
*/
clientSecret: string
}
): OAuthConfig<P> {
return {
id: "apple",
name: "Apple",
type: "oauth",
wellKnown: "https://appleid.apple.com/.well-known/openid-configuration",
authorization: {
params: { scope: "name email", response_mode: "form_post" },
},
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: null,
}
},
checks: ["pkce"],
options,
}
}

View File

@@ -1,11 +1,21 @@
/** @type {import(".").OAuthProvider} */
export default function Atlassian(options) {
import type { OAuthConfig, OAuthUserConfig } from "."
interface AtlassianProfile {
account_id: string
name: string
email: string
picture: string
}
export default function Atlassian<P extends AtlassianProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "atlassian",
name: "Atlassian",
type: "oauth",
authorization: {
url: "https://auth.atlassian.com/oauth/authorize",
url: "https://auth.atlassian.com/authorize",
params: {
audience: "api.atlassian.com",
prompt: "consent",

View File

@@ -1,6 +1,15 @@
import { OAuthConfig, OAuthUserConfig } from "./oauth"
import type { OAuthConfig, OAuthUserConfig } from "."
export default function Auth0(options: OAuthUserConfig): OAuthConfig {
export interface Auth0Profile {
sub: string
nickname: 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",
@@ -9,7 +18,7 @@ export default function Auth0(options: OAuthUserConfig): OAuthConfig {
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state"],
idToken: true,
profile(profile: any) {
profile(profile) {
return {
id: profile.sub,
name: profile.nickname,

View File

@@ -0,0 +1,44 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface AuthentikProfile {
iss: string,
sub: string,
aud: string,
exp: number,
iat: number,
auth_time: number,
acr: string,
c_hash: string,
nonce: string,
at_hash: string,
email: string,
email_verified: boolean,
name: string,
given_name: string,
family_name: string,
preferred_username: string,
nickname: string,
groups: string[]
}
export default function Authentik<
P extends Record<string, any> = AuthentikProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "authentik",
name: "Authentik",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
type: "oauth",
authorization: { params: { scope: "openid email profile" } },
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -1,44 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function AzureADB2C(options) {
const { tenantName, primaryUserFlow } = options
return {
id: "azure-ad-b2c",
name: "Azure Active Directory B2C",
type: "oauth",
authorization: {
url: `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${primaryUserFlow}/oauth2/v2.0/authorize`,
params: {
response_type: "code id_token",
response_mode: "query",
},
},
token: {
url: `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${primaryUserFlow}/oauth2/v2.0/token`,
idToken: true,
},
jwks_uri: `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${primaryUserFlow}}/discovery/v2.0/keys`,
profile(profile) {
let name = ""
if (profile.name) {
// B2C "Display Name"
name = profile.name
} else if (profile.given_name && profile.family_name) {
// B2C "Given Name" & "Surname"
name = `${profile.given_name} ${profile.family_name}`
} else if (profile.given_name) {
// B2C "Given Name"
name = `${profile.given_name}`
}
return {
id: profile.oid,
name,
email: profile.emails[0],
image: null,
}
},
options,
}
}

View File

@@ -0,0 +1,46 @@
import type { 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
tenantId: 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,22 +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",
authorization: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize?response_mode=query`,
token: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
userinfo: "https://graph.microsoft.com/v1.0/me/",
profile(profile) {
return {
id: profile.id,
name: profile.displayName,
email: profile.userPrincipalName,
image: null,
}
},
options,
}
}

65
src/providers/azure-ad.ts Normal file
View File

@@ -0,0 +1,65 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface AzureADProfile {
sub: string
nicname: string
email: string
picture: string
}
export default function AzureAD<P extends Record<string, any> = AzureADProfile>(
options: OAuthUserConfig<P> & {
/**
* https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
* @default 48
*/
profilePhotoSize?: 48 | 64 | 96 | 120 | 240 | 360 | 432 | 504 | 648
/** @default "common" */
tenantId?: string
}
): OAuthConfig<P> {
const tenant = options.tenantId ?? "common"
const profilePhotoSize = options.profilePhotoSize ?? 48
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: "openid profile email",
},
},
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/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
{
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
}
)
// Confirm that profile photo was returned
if (profilePicture.ok) {
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}`,
}
} else {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
}
}
},
options,
}
}

View File

@@ -1,20 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function Cognito(options) {
return {
id: "cognito",
name: "Cognito",
type: "oauth",
authorization: `${options.issuer}oauth2/authorize?scope=openid+profile+email`,
token: `${options.issuer}oauth2/token`,
userinfo: `${options.issuer}oauth2/userInfo`,
profile(profile) {
return {
id: profile.sub,
name: profile.username,
email: profile.email,
image: null,
}
},
options,
}
}

29
src/providers/cognito.ts Normal file
View File

@@ -0,0 +1,29 @@
import type { 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`,
idToken: true,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -1,6 +1,6 @@
import { NextApiRequest } from "next"
import { CommonProviderOptions } from "."
import { User, Awaitable } from ".."
import type { IncomingRequest } from "../core"
import type { CommonProviderOptions } from "."
import type { User, Awaitable } from ".."
export interface CredentialInput {
label?: string
@@ -10,13 +10,13 @@ export interface CredentialInput {
}
export interface CredentialsConfig<
C extends Record<string, CredentialInput> = {}
C extends Record<string, CredentialInput> = Record<string, CredentialInput>
> extends CommonProviderOptions {
type: "credentials"
credentials: C
authorize: (
credentials: Record<keyof C, string>,
req: NextApiRequest
credentials: Record<keyof C, string> | undefined,
req: Pick<IncomingRequest, "body" | "query" | "headers" | "method">
) => Awaitable<(Omit<User, "id"> | { id?: string }) | null>
}

View File

@@ -1,5 +1,5 @@
/**
* @param {import("../server").Provider} options
* @param {import("../core").Provider} options
* @example
*
* ```js

View File

@@ -1,8 +1,8 @@
import { createTransport } from "nodemailer"
import { CommonProviderOptions } from "."
import { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection"
import { Awaitable } from ".."
import type { CommonProviderOptions } from "."
import type { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection"
import type { Awaitable } from ".."
export interface EmailConfig extends CommonProviderOptions {
type: "email"
@@ -72,7 +72,6 @@ export default function Email(options: EmailUserConfig): EmailConfig {
provider: { server, from },
}) {
const { host } = new URL(url)
console.log(server)
const transport = createTransport(server)
await transport.sendMail({
to: email,

View File

@@ -1,20 +0,0 @@
/** @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",
profile(profile) {
return {
id: profile.CharacterID,
name: profile.CharacterName,
email: null,
image: `https://image.eveonline.com/Character/${profile.CharacterID}_128.jpg`,
}
},
options,
}
}

View File

@@ -0,0 +1,38 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface EVEOnlineProfile {
CharacterID: number
CharacterName: string
ExpiresOn: string
Scopes: string
TokenType: string
CharacterOwnerHash: string
IntellectualProperty: string
}
export default function EVEOnline<
P extends Record<string, any> = EVEOnlineProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "eveonline",
name: "EVE Online",
type: "oauth",
wellKnown:
"https://login.eveonline.com/.well-known/oauth-authorization-server",
authorization: {
params: {
scope: "publicData",
},
},
idToken: true,
profile(profile) {
return {
id: profile.CharacterID,
name: profile.CharacterName,
email: null,
image: `https://image.eveonline.com/Character/${profile.CharacterID}_128.jpg`,
}
},
options,
}
}

View File

@@ -1,7 +1,6 @@
import { Profile } from ".."
import { OAuthConfig, OAuthUserConfig } from "./oauth"
import type { OAuthConfig, OAuthUserConfig } from "."
export interface FacebookProfile extends Profile {
export interface FacebookProfile {
id: string
picture: { data: { url: string } }
}

View File

@@ -1,3 +1,6 @@
import { get } from 'https'
import { once } from 'events'
/** @type {import("src/providers").OAuthProvider} */
/** @type {import(".").OAuthProvider} */
export default function Foursquare(options) {
@@ -9,11 +12,29 @@ export default function Foursquare(options) {
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 },
})
async request({ tokens }) {
const url = new URL('https://api.foursquare.com/v2/users/self');
url.searchParams.append('v', apiVersion);
url.searchParams.append('oauth_token', tokens.access_token);
const req = get(url, { timeout: 3500 });
const [response] = await Promise.race([once(req, 'response'), once(req, 'timeout')])
// timeout reached
if (!response) {
req.destroy()
throw new Error('HTTP Request Timed Out')
}
if (response.statusCode !== 200) {
throw new Error('Expected 200 OK from the userinfo endpoint')
}
const parts = []
for await (const part of response) {
parts.push(part)
}
return JSON.parse(Buffer.concat(parts))
},
},
profile({ response: { profile } }) {

View File

@@ -1,20 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function FusionAuth(options) {
return {
id: "fusionauth",
name: "FusionAuth",
type: "oauth",
authorization: `${options.issuer}oauth2/authorize`,
token: `${options.issuer}oauth2/token`,
userinfo: `${options.issuer}oauth2/userinfo`,
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -0,0 +1,52 @@
import { OAuthConfig, OAuthUserConfig } from "./oauth"
/** This is the default openid signature returned from FusionAuth
* it can be customized using [lambda functions](https://fusionauth.io/docs/v1/tech/lambdas)
*/
export interface FusionAuthProfile {
aud: string
exp: number
iat: number
iss: string
sub: string
jti: string
authenticationType: string
email: string
email_verified: boolean
preferred_username: string
at_hash: string
c_hash: string
scope: string
sid: string
}
export default function FusionAuth<
P extends Record<string, any> = FusionAuthProfile
>(
// tenantId only needed if there is more than one tenant configured on the server
options: OAuthUserConfig<P> & { tenantId?: string }
): OAuthConfig<P> {
return {
id: "fusionauth",
name: "FusionAuth",
type: "oauth",
wellKnown: options?.tenantId
? `${options.issuer}/.well-known/openid-configuration?tenantId=${options.tenantId}`
: `${options.issuer}/.well-known/openid-configuration`,
authorization: {
params: {
scope: "openid offline_access",
...(options?.tenantId && { tenantId: options.tenantId }),
},
},
checks: ["pkce", "state"],
profile(profile) {
return {
id: profile.sub,
email: profile.email,
name: profile?.preferred_username,
}
},
options,
}
}

View File

@@ -6,7 +6,31 @@ export default function GitHub(options) {
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",
userinfo: {
url: "https://api.github.com/user",
async request({ client, tokens }) {
// Get base profile
const profile = await client.userinfo(tokens)
// If user has email hidden, get their primary email from the GitHub API
if (!profile.email) {
const emails = await (
await fetch("https://api.github.com/user/emails", {
headers: { Authorization: `token ${tokens.access_token}` },
})
).json()
if (emails?.length > 0) {
// Get primary email
profile.email = emails.find(email => email.primary)?.email;
// And if for some reason it doesn't exist, just use the first
if (!profile.email) profile.email = emails[0].email;
}
}
return profile
},
},
profile(profile) {
return {
id: profile.id.toString(),

View File

@@ -1,9 +1,21 @@
import { Profile } from ".."
import { OAuthConfig, OAuthUserConfig } from "./oauth"
import type { OAuthConfig, OAuthUserConfig } from "."
export interface GoogleProfile extends Profile {
sub: string
export interface GoogleProfile {
aud: string
azp: string
email: string
email_verified: boolean
exp: number
family_name: string
given_name: string
hd: string
iat: number
iss: string
jti: string
name: string
nbf: number
picture: string
sub: string
}
export default function Google<P extends Record<string, any> = GoogleProfile>(
@@ -17,7 +29,7 @@ export default function Google<P extends Record<string, any> = GoogleProfile>(
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
profile(profile: P) {
profile(profile) {
return {
id: profile.sub,
name: profile.name,

View File

@@ -1,8 +1,8 @@
import { OAuthConfig, OAuthProvider, OAuthProviderType } from "./oauth"
import type { OAuthConfig, OAuthProvider, OAuthProviderType } from "./oauth"
import { EmailConfig, EmailProvider, EmailProviderType } from "./email"
import type { EmailConfig, EmailProvider, EmailProviderType } from "./email"
import {
import type {
CredentialsConfig,
CredentialsProvider,
CredentialsProviderType,
@@ -21,7 +21,7 @@ export interface CommonProviderOptions {
options?: Record<string, unknown>
}
export type Provider = OAuthConfig | EmailConfig | CredentialsConfig
export type Provider = OAuthConfig<any> | EmailConfig | CredentialsConfig
export type BuiltInProviders = Record<OAuthProviderType, OAuthProvider> &
Record<CredentialsProviderType, CredentialsProvider> &

View File

@@ -1,20 +0,0 @@
/** @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",
profile(profile) {
return {
id: profile.id,
name: profile.kakao_account?.profile.nickname,
email: profile.kakao_account?.email,
image: profile.kakao_account?.profile.profile_image_url,
}
},
options,
}
}

92
src/providers/kakao.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export type DateTime = string
export type Gender = "female" | "male"
export type AgeRange =
| "1-9"
| "10-14"
| "15-19"
| "20-29"
| "30-39"
| "40-49"
| "50-59"
| "60-69"
| "70-79"
| "80-89"
| "90-"
/**
* https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
* type from : https://gist.github.com/ziponia/cdce1ebd88f979b2a6f3f53416b56a77
*/
export interface KakaoProfile {
id: number
has_signed_up?: boolean
connected_at?: DateTime
synched_at?: DateTime
properties?: {
id?: string
status?: string
registered_at?: DateTime
msg_blocked?: boolean
nickname?: string
profile_image?: string
thumbnail_image?: string
}
kakao_account?: {
profile_needs_agreement?: boolean
profile_nickname_needs_agreement?: boolean
profile_image_needs_agreement?: boolean
profile?: {
nickname?: string
thumbnail_image_url?: string
profile_image_url?: string
is_default_image?: boolean
}
name_needs_agreement?: boolean
name?: string
email_needs_agreement?: boolean
is_email_valid?: boolean
is_email_verified?: boolean
email?: string
age_range_needs_agreement?: boolean
age_range?: AgeRange
birthyear_needs_agreement?: boolean
birthyear?: string
birthday_needs_agreement?: boolean
birthday?: string
birthday_type?: string
gender_needs_agreement?: boolean
gender?: Gender
phone_number_needs_agreement?: boolean
phone_number?: string
ci_needs_agreement?: boolean
ci?: string
ci_authenticated_at?: DateTime
}
}
export default function Kakao<P extends Record<string, any> = KakaoProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "kakao",
name: "Kakao",
type: "oauth",
authorization: "https://kauth.kakao.com/oauth/authorize?scope",
token: "https://kauth.kakao.com/oauth/token",
userinfo: "https://kapi.kakao.com/v2/user/me",
client: {
token_endpoint_auth_method: "client_secret_post",
},
profile(profile) {
return {
id: profile.id,
name: profile.kakao_account?.profile.nickname,
email: profile.kakao_account?.email,
image: profile.kakao_account?.profile.profile_image_url,
}
},
options,
}
}

View File

@@ -1,21 +0,0 @@
/** @type {import("src/providers").OAuthProvider} */
export default function Keycloak(options) {
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: null,
}
},
options,
}
}

48
src/providers/keycloak.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { 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,
}
}

View File

@@ -1,21 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function LINE(options) {
return {
id: "line",
name: "LINE",
type: "oauth",
authorization:
"https://access.line.me/oauth2/v2.1/authorize?scope=openid+profile",
token: "https://api.line.me/oauth2/v2.1/token",
userinfo: "https://api.line.me/v2/profile",
profile(profile) {
return {
id: profile.userId,
name: profile.displayName,
email: null,
image: profile.pictureUrl,
}
},
options,
}
}

38
src/providers/line.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface LineProfile {
iss: string
sub: string
aud: string
exp: number
iat: number
amr: string[]
name: string
picture: string
user: any
}
export default function LINE<P extends Record<string, any> = LineProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "line",
name: "LINE",
type: "oauth",
authorization: { params: { scope: "openid profile" } },
idToken: true,
wellKnown: "https://access.line.me/.well-known/openid-configuration",
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
client: {
id_token_signed_response_alg: "HS256",
},
options,
}
}

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