Compare commits

...

128 Commits

Author SHA1 Message Date
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
123 changed files with 5869 additions and 8005 deletions

View File

@@ -10,7 +10,13 @@ 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! 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:

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 💚

8
.gitignore vendored
View File

@@ -29,12 +29,13 @@ node_modules
/client
/css
/lib
/server
/core
/jwt
/react
/adapters.d.ts
/index.d.ts
/index.js
/next
# Development app
app/src/css
@@ -42,6 +43,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 +52,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
```
@@ -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>",
}),
@@ -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/canary/CONTRIBUTING.md).
## License

View File

@@ -30,6 +30,9 @@ TWITCH_SECRET=
TWITTER_ID=
TWITTER_SECRET=
LINE_ID=
LINE_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"

View File

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

View File

@@ -7,10 +7,12 @@
"clean": "rm -rf .next",
"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": "email"
},
"license": "ISC",
"dependencies": {

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,4 +1,4 @@
import NextAuth from "next-auth"
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"
@@ -17,6 +17,14 @@ 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 { PrismaAdapter } from "@next-auth/prisma-adapter"
// import { PrismaClient } from "@prisma/client"
@@ -31,29 +39,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",
@@ -132,11 +139,52 @@ 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,
}),
],
jwt: {
encryption: true,
secret: process.env.SECRET,
},
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

@@ -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

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

7441
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,17 @@
"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",
"./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",
"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,24 +55,28 @@
"css",
"jwt",
"react",
"next",
"client",
"providers",
"server",
"core",
"index.d.ts",
"index.js",
"adapters.d.ts"
],
"license": "ISC",
"dependencies": {
"@babel/runtime": "^7.14.6",
"futoin-hkdf": "^1.3.3",
"jose": "^1.27.2",
"@babel/runtime": "^7.15.4",
"@panva/hkdf": "^1.0.0",
"cookie": "^0.4.1",
"jose": "^4.1.2",
"oauth": "^0.9.15",
"openid-client": "^4.7.4",
"preact": "^10.5.13",
"preact-render-to-string": "^5.1.19"
"openid-client": "^5.0.2",
"preact": "^10.5.14",
"preact-render-to-string": "^5.1.19",
"uuid": "^8.3.2"
},
"peerDependencies": {
"nodemailer": "^6.6.2",
"nodemailer": "^6.6.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
@@ -82,51 +86,56 @@
}
},
"devDependencies": {
"@actions/core": "^1.4.0",
"@babel/cli": "^7.14.5",
"@babel/core": "^7.14.6",
"@actions/core": "^1.6.0",
"@babel/cli": "^7.15.7",
"@babel/core": "^7.15.5",
"@babel/plugin-proposal-optional-catch-binding": "^7.14.5",
"@babel/plugin-transform-runtime": "^7.14.5",
"@babel/preset-env": "^7.14.7",
"@babel/plugin-transform-runtime": "^7.15.0",
"@babel/preset-env": "^7.15.6",
"@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",
"@testing-library/react": "^12.1.2",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^13.2.1",
"@types/node": "^16.11.6",
"@types/nodemailer": "^6.4.4",
"@types/oauth": "^0.9.1",
"@types/react": "^17.0.19",
"@types/react": "^17.0.27",
"@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",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.33.0",
"autoprefixer": "^10.3.7",
"babel-jest": "^27.3.0",
"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.8",
"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-import": "^2.24.2",
"eslint-plugin-jest": "^25.2.2",
"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",
"husky": "^7.0.2",
"jest": "^27.3.0",
"jest-watch-typeahead": "^1.0.0",
"msw": "^0.35.0",
"next": "v11.1.3-canary.0",
"postcss-cli": "^8.3.1",
"postcss-nested": "^5.0.5",
"prettier": "^2.3.1",
"postcss-cli": "^9.0.1",
"postcss-nested": "^5.0.6",
"prettier": "^2.4.1",
"pretty-quick": "^3.1.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"typescript": "^4.3.5",
"typescript": "^4.4.3",
"whatwg-fetch": "^3.6.2"
},
"engines": {
"node": "^12.19.0 || ^14.15.0 || ^16.13.0"
},
"prettier": {
"semi": false
},
@@ -135,17 +144,14 @@
"parserOptions": {
"project": "./tsconfig.json"
},
"extends": [
"standard-with-typescript",
"prettier"
],
"extends": ["standard-with-typescript", "prettier"],
"ignorePatterns": [
"node_modules",
"next-env.d.ts",
"types",
".next",
"dist",
"/server",
"/core",
"/react.js"
],
"globals": {
@@ -162,18 +168,12 @@
},
"overrides": [
{
"files": [
"./**/*test.js"
],
"files": ["./**/*test.js"],
"env": {
"jest/globals": true
},
"extends": [
"plugin:jest/recommended"
],
"plugins": [
"jest"
]
"extends": ["plugin:jest/recommended"],
"plugins": ["jest"]
}
]
},
@@ -207,6 +207,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
}

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

@@ -0,0 +1,233 @@
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":
return (await routes.session({ options, sessionStore })) 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":
if (pages.error) {
return {
redirect: `${pages.error}${
pages.error.includes("?") ? "&" : "?"
}error=${error}`,
cookies,
}
}
// 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 }
}
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: process.env.NEXTAUTH_URL ?? "http://localhost:3000",
}
// 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 }
}

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

@@ -0,0 +1,78 @@
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
/**
* 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
options.providers.forEach(({ type }) => {
if (type === "credentials") hasCredentials = true
else if (type === "email") hasEmail = 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.")
}
}

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,33 @@
import { CallbackParamsType, 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 { 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 +35,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 +58,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 +120,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 +132,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 +198,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
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
if (provider.httpOptions) custom.setHttpOptionsDefaults(provider.httpOptions)
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 },
},
}
}

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

@@ -0,0 +1,68 @@
import { InternalProvider } from "../../lib/types"
import { Provider } from "../../providers"
import { merge } from "../../lib/merge"
import { 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 normalizedProvider: InternalProvider = Object.entries(
provider
).reduce<InternalProvider>((acc, [key, value]) => {
if (
["authorization", "token", "userinfo"].includes(key) &&
typeof value === "string"
) {
const url = new URL(value)
;(acc as any)[key] = {
url: `${url.origin}${url.pathname}`,
params: Object.fromEntries(url.searchParams ?? []),
}
} else {
acc[key as keyof InternalProvider] = value
}
return acc
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions
}, {} as InternalProvider)
// Checks only work on OAuth 2.x + OIDC providers
if (
provider.type === "oauth" &&
!provider.version?.startsWith("1.") &&
!provider.checks
) {
;(normalizedProvider as InternalProvider<"oauth">).checks = ["state"]
}
return normalizedProvider
}

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

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

@@ -0,0 +1,108 @@
import { Theme } from "../.."
import { InternalUrl } from "../../lib/parse-url"
export interface ErrorProps {
url?: InternalUrl
theme?: Theme
error?: string
}
interface ErrorView {
status: number
heading: string
message: JSX.Element
signin?: JSX.Element
}
export type ErrorType =
| "default"
| "configuration"
| "accessdenied"
| "verification"
/** 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",
})
},
}
}

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

@@ -0,0 +1,151 @@
import { Theme } from "../.."
import { InternalProvider } from "../../lib/types"
export interface SignInServerPageParams {
csrfToken: string
providers: InternalProvider[]
callbackUrl: string
email: string
error: string
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<string, 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: "Check your email inbox.",
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 ??
"Password"
}
{...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>
@@ -201,7 +202,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 +279,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).
@@ -334,14 +339,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 +348,7 @@ export interface CookiesOptions {
callbackUrl: CookieOption
csrfToken: CookieOption
pkceCodeVerifier: CookieOption
state: CookieOption
}
/**
@@ -424,9 +423,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 +471,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,124 +1,75 @@
import crypto from "crypto"
import jose from "jose"
import logger from "../lib/logger"
import { NextApiRequest } from "next"
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 } 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
const now = () => (Date.now() / 1000) | 0
/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */
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,
maxAge = DEFAULT_MAX_AGE,
}: JWTEncodeParams) {
// Signing Key
const _signingKey = signingKey
? jose.JWK.asKey(JSON.parse(signingKey))
: getDerivedSigningKey(secret)
// 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
const encryptionSecret = await getDerivedEncryptionKey(secret)
return await new EncryptJWT(token)
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuedAt()
.setExpirationTime(now() + maxAge)
.setJti(uuid())
.encrypt(encryptionSecret)
}
/** Decodes a NextAuth.js issued JWT. */
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,
secret,
}: JWTDecodeParams): Promise<JWT | null> {
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> = {
export interface GetTokenParams<R extends boolean = false> {
/** The request containing the JWT either in the cookies or in the `Authorization` header. */
req: NextApiRequest
/**
* 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
secret: string
decode?: typeof decode
secret?: string
} & Omit<JWTDecodeParams, "secret">
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://")
@@ -126,26 +77,25 @@ export async function getToken<R extends boolean = false>(
cookieName = secureCookie
? "__Secure-next-auth.session-token"
: "next-auth.session-token",
raw = false,
raw,
decode: _decode = decode,
logger = console,
} = 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
)
// 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)
}
const token = sessionStore.value
// @ts-expect-error
if (!token) return null
if (raw) {
// @ts-expect-error
return token
}
// @ts-expect-error
if (raw) return token
try {
// @ts-expect-error
@@ -156,62 +106,12 @@ export async function getToken<R extends boolean = false>(
}
}
// 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,4 +1,3 @@
import type { JWT as JoseJWT, JWE } from "jose"
import { decode, encode } from "."
export interface DefaultJWT extends Record<string, unknown> {
@@ -16,36 +15,36 @@ 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. */
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
/** Override this method to control the NextAuth.js issued JWT encoding. */
encode: typeof encode
/** Override this method to control the NextAuth.js issued JWT decoding. */
decode: typeof decode
verificationOptions?: JoseJWT.VerifyOptions<false>
}
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"
/**
* 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,35 @@
/**
* 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}`
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 `VERCEL_URL`.
* @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

15
src/next/cookie.ts Normal file
View File

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

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

@@ -0,0 +1,114 @@
import { NextAuthHandler } from "../core"
import { setCookie } from "./cookie"
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
const handler = await NextAuthHandler({
req: {
host: (process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL) as string,
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: (process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL) as string,
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_URL?: string
}
}
}

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,
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
nicname: string
email: string
picture: string
}
export default function Auth0<P extends Record<string, any> = Auth0Profile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "auth0",
name: "Auth0",
@@ -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

@@ -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,
}
}

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

@@ -0,0 +1,55 @@
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: "User.Read",
},
},
async profile(profile, tokens) {
// https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
const profilePicture = await fetch(
`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
{
headers: {
Authorization: `Bearer ${tokens.access_token}`,
},
}
)
const pictureBuffer = await profilePicture.arrayBuffer()
const pictureBase64 = Buffer.from(pictureBuffer).toString("base64")
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: `data:image/jpeg;base64, ${pictureBase64}`,
}
},
options,
}
}

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,
}
}

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

@@ -0,0 +1,28 @@
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`,
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

@@ -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,8 +1,9 @@
import { Profile } from ".."
import { OAuthConfig, OAuthUserConfig } from "./oauth"
import type { OAuthConfig, OAuthUserConfig } from "."
export interface GoogleProfile extends Profile {
export interface GoogleProfile {
sub: string
name: string
email: string
picture: string
}
@@ -17,7 +18,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,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,
}
}

View File

@@ -1,22 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function LinkedIn(options) {
return {
id: "linkedin",
name: "LinkedIn",
type: "oauth",
authorization:
"https://www.linkedin.com/oauth/v2/authorization?scope=r_liteprofile",
token: "https://www.linkedin.com/oauth/v2/accessToken",
userinfo:
"https://api.linkedin.com/v2/me?projection=(id,localizedFirstName,localizedLastName)",
profile(profile) {
return {
id: profile.id,
name: `${profile.localizedFirstName} ${profile.localizedLastName}`,
email: null,
image: null,
}
},
options,
}
}

57
src/providers/linkedin.ts Normal file
View File

@@ -0,0 +1,57 @@
import type { OAuthConfig, OAuthUserConfig } from "."
interface Identifier {
identifier: string
}
interface Element {
identifiers?: Identifier[]
}
export interface LinkedInProfile {
id: string
localizedFirstName: string
localizedLastName: string
profilePicture: {
"displayImage~": {
elements?: Element[]
}
}
}
export default function LinkedIn<
P extends Record<string, any> = LinkedInProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "linkedin",
name: "LinkedIn",
type: "oauth",
authorization: {
url: "https://www.linkedin.com/oauth/v2/authorization",
params: { scope: "r_liteprofile r_emailaddress" },
},
token: "https://www.linkedin.com/oauth/v2/accessToken",
userinfo: {
url: "https://api.linkedin.com/v2/me",
params: {
projection: `(id,localizedFirstName,localizedLastName,profilePicture(displayImage~digitalmediaAsset:playableStreams))`,
},
},
async profile(profile, tokens) {
const emailResponse = await fetch(
"https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))",
{ headers: { Authorization: `Bearer ${tokens.access_token}` } }
)
const emailData = await emailResponse.json()
return {
id: profile.id,
name: `${profile.localizedFirstName} ${profile.localizedLastName}`,
email: emailData?.elements?.[0]?.["handle~"]?.emailAddress,
image:
profile.profilePicture?.["displayImage~"]?.elements?.[0]
?.identifiers?.[0]?.identifier,
}
},
options,
}
}

View File

@@ -1,14 +1,19 @@
import { CommonProviderOptions } from "../providers"
import { Profile, TokenSet, User, Awaitable } from ".."
import type { CommonProviderOptions } from "../providers"
import type { Profile, TokenSet, User, Awaitable } from ".."
import {
import type {
AuthorizationParameters,
CallbackParamsType,
Client,
Issuer,
ClientMetadata,
IssuerMetadata,
OAuthCallbackChecks,
OpenIDCallbackChecks,
HttpOptions,
} from "openid-client"
import type { JWK } from "jose"
type Client = InstanceType<Issuer["Client"]>
export type { OAuthProviderType } from "./oauth-types"
@@ -20,12 +25,12 @@ type PartialIssuer = Partial<Pick<IssuerMetadata, "jwks_endpoint" | "issuer">>
type UrlParams = Record<string, unknown>
type EndpointRequest<C, R> = (
type EndpointRequest<C, R, P> = (
context: C & {
/** `openid-client` Client */
client: Client
/** Provider is passed for convenience, ans also contains the `callbackUrl`. */
provider: OAuthConfig & {
provider: OAuthConfig<P> & {
signinUrl: string
callbackUrl: string
}
@@ -46,17 +51,45 @@ interface AdvancedEndpointHandler<P extends UrlParams, C, R> {
* - ⚠ **This is an advanced option.**
* You should **try to avoid using advanced options** unless you are very comfortable using them.
*/
request?: EndpointRequest<C, R>
request?: EndpointRequest<C, R, P>
}
/** Either an URL (containing all the parameters) or an object with more granular control. */
type EndpointHandler<P extends UrlParams, C = any, R = any> =
| string
| AdvancedEndpointHandler<P, C, R>
export type EndpointHandler<
P extends UrlParams,
C = any,
R = any
> = AdvancedEndpointHandler<P, C, R>
export interface OAuthConfig<P extends Record<string, any> = {}>
extends CommonProviderOptions,
PartialIssuer {
export type AuthorizationEndpointHandler =
EndpointHandler<AuthorizationParameters>
export type TokenEndpointHandler = EndpointHandler<
UrlParams,
{
/**
* Parameters extracted from the request to the `/api/auth/callback/:providerId` endpoint.
* Contains params like `state`.
*/
params: CallbackParamsType
/**
* When using this custom flow, make sure to do all the necessary security checks.
* Thist object contains parameters you have to match against the request to make sure it is valid.
*/
checks: OAuthChecks
},
{
tokens: TokenSet
}
>
export type UserinfoEndpointHandler = EndpointHandler<
UrlParams,
{ tokens: TokenSet },
Profile
>
export interface OAuthConfig<P> extends CommonProviderOptions, PartialIssuer {
/**
* OpenID Connect (OIDC) compliant providers can configure
* this instead of `authorize`/`token`/`userinfo` options
@@ -72,47 +105,17 @@ export interface OAuthConfig<P extends Record<string, any> = {}>
*
* [Authorization endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1)
*/
authorization?: EndpointHandler<AuthorizationParameters>
/**
* Endpoint that returns OAuth 2/OIDC tokens and information about them.
* This includes `access_token`, `id_token`, `refresh_token`, etc.
*
* [Token endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.2)
*/
token?: EndpointHandler<
UrlParams,
{
/**
* Parameters extracted from the request to the `/api/auth/callback/:providerId` endpoint.
* Contains params like `state`.
*/
params: CallbackParamsType
/**
* When using this custom flow, make sure to do all the necessary security checks.
* Thist object contains parameters you have to match against the request to make sure it is valid.
*/
checks: OAuthChecks
},
{ tokens: TokenSet }
>
/**
* When using an OAuth 2 provider, the user information must be requested
* through an additional request from the userinfo endpoint.
*
* [Userinfo endpoint](https://www.oauth.com/oauth2-servers/signing-in-with-google/verifying-the-user-info)
*/
userinfo?: EndpointHandler<UrlParams, { tokens: TokenSet }, Profile>
authorization?: string | AuthorizationEndpointHandler
token?: string | TokenEndpointHandler
userinfo?: string | UserinfoEndpointHandler
type: "oauth"
version?: string
accessTokenUrl?: string
requestTokenUrl?: string
profile?: (profile: P, tokens: TokenSet) => Awaitable<User & { id: string }>
checks?: ChecksType | ChecksType[]
client?: Partial<ClientMetadata>
jwks?: { keys: JWK[] }
clientId?: string
clientSecret?:
| string
// TODO: only allow for Apple
| Record<"appleId" | "teamId" | "privateKey" | "keyId", string>
clientSecret?: string
/**
* If set to `true`, the user information will be extracted
* from the `id_token` claims, instead of
@@ -127,20 +130,29 @@ export interface OAuthConfig<P extends Record<string, any> = {}>
region?: string
// TODO: only allow for some
issuer?: string
// TODO: only allow for Azure Active Directory B2C and FusionAuth
tenantId?: string
/** Read more at: https://github.com/panva/node-openid-client/tree/main/docs#customizing-http-requests */
httpOptions?: HttpOptions
/**
* The options provided by the user.
* We will perform a deep-merge of these values
* with the default configuration.
*/
options?: OAuthUserConfig<P>
// These are kept around for backwards compatibility with OAuth 1.x
accessTokenUrl?: string
requestTokenUrl?: string
profileUrl?: string
encoding?: string
}
export type OAuthUserConfig<P = {}> = Omit<
export type OAuthUserConfig<P> = Omit<
Partial<OAuthConfig<P>>,
"options" | "type"
> &
Required<Pick<OAuthConfig<P>, "clientId" | "clientSecret">>
export type OAuthProvider = (options: Partial<OAuthConfig>) => OAuthConfig
export type OAuthProvider = (
options: Partial<OAuthConfig<any>>
) => OAuthConfig<any>

View File

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

56
src/providers/okta.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface OktaProfile {
iss: string
ver: string
sub: string
aud: string
iat: string
exp: string
jti: string
auth_time: string
amr: string
idp: string
nonce: string
name: string
nickname: string
preferred_username: string
given_name: string
middle_name: string
family_name: string
email: string
email_verified: string
profile: string
zoneinfo: string
locale: string
address: string
phone_number: string
picture: string
website: string
gender: string
birthdate: string
updated_at: string
at_hash: string
c_hash: string
}
export default function Okta<P extends Record<string, any> = OktaProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "okta",
name: "Okta",
type: "oauth",
wellKnown: `${options.issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } },
profile(profile) {
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

77
src/providers/osu.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface OsuUserCompact {
avatar_url: string
country_code: string
default_group: string
id: string
is_active: boolean
is_bot: boolean
is_deleted: boolean
is_online: boolean
is_supporter: boolean
last_visit: Date | null
pm_friends_only: boolean
profile_colour: string | null
username: string
}
export interface OsuProfile extends OsuUserCompact {
discord: string | null
has_supported: boolean
interests: string | null
join_date: Date
kudosu: {
available: number
total: number
}
location: string | null
max_blocks: number
max_friends: number
occupation: string | null
playmode: string
playstyle: string[]
post_count: number
profile_order: string[]
title: string | null
title_url: string | null
twitter: string | null
website: string | null
country: {
code: string
name: string
}
cover: {
custom_url: string | null
url: string
id: number | null
}
is_restricted: boolean
}
export default function Osu<P extends Record<string, any> = OsuProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "osu",
name: "Osu!",
type: "oauth",
token: "https://osu.ppy.sh/oauth/token",
authorization: {
url: "https://osu.ppy.sh/oauth/authorize",
params: {
scope: "identify",
},
},
userinfo: "https://osu.ppy.sh/api/v2/me",
profile(profile) {
return {
id: profile.id,
email: null,
name: profile.username,
image: profile.avatar_url,
}
},
options,
}
}

View File

@@ -0,0 +1,59 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface PipedriveProfile {
success: boolean
data: {
id: number
name: string
default_currency?: string
locale?: string
lang?: number
email: string
phone?: string
activated?: boolean
last_login?: Date
created?: Date
modified?: Date
signup_flow_variation?: string
has_created_company?: boolean
is_admin?: number
active_flag?: boolean
timezone_name?: string
timezone_offset?: string
role_id?: number
icon_url?: string
is_you?: boolean
company_id?: number
company_name?: string
company_domain?: string
company_country?: string
company_industry?: string
language?: {
language_code?: string
country_code?: string
}
}
}
export default function Pipedrive<
P extends Record<string, any> = PipedriveProfile
>(options: OAuthUserConfig<P>): OAuthConfig<P> {
return {
id: "pipedrive",
name: "Pipedrive",
type: "oauth",
version: "2.0",
authorization: "https://oauth.pipedrive.com/oauth/authorize",
token: "https://oauth.pipedrive.com/oauth/token",
userinfo: "https://api.pipedrive.com/users/me",
profile: ({ data: profile }) => {
return {
id: profile.id,
name: profile.name,
email: profile.email,
image: profile.icon_url,
}
},
options,
}
}

View File

@@ -1,25 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function Slack(options) {
return {
id: "slack",
name: "Slack",
type: "oauth",
authorization: {
url: "https://slack.com/oauth/v2/authorize",
params: {
user_scope: "identity.basic,identity.email,identity.avatar",
},
},
token: "https://slack.com/api/oauth.v2.access",
userinfo: "https://slack.com/api/users.identity",
profile(profile) {
return {
id: profile.user.id,
name: profile.user.name,
email: profile.user.email,
image: profile.user.image_512,
}
},
options,
}
}

54
src/providers/slack.ts Normal file
View File

@@ -0,0 +1,54 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface SlackProfile {
ok: boolean
sub: string
"https://slack.com/user_id": string
"https://slack.com/team_id": string
email: string
email_verified: boolean
date_email_verified: number
name: string
picture: string
given_name: string
family_name: string
locale: string
"https://slack.com/team_name": string
"https://slack.com/team_domain": string
"https://slack.com/user_image_24": string
"https://slack.com/user_image_32": string
"https://slack.com/user_image_48": string
"https://slack.com/user_image_72": string
"https://slack.com/user_image_192": string
"https://slack.com/user_image_512": string
"https://slack.com/user_image_1024": string
"https://slack.com/team_image_34": string
"https://slack.com/team_image_44": string
"https://slack.com/team_image_68": string
"https://slack.com/team_image_88": string
"https://slack.com/team_image_102": string
"https://slack.com/team_image_132": string
"https://slack.com/team_image_230": string
"https://slack.com/team_image_default": boolean
}
export default function Slack<P extends Record<string, any> = SlackProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "slack",
name: "Slack",
type: "oauth",
wellKnown: "https://slack.com/.well-known/openid-configuration",
authorization: { params: { scope: "openid profile email" } },
profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -1,5 +1,18 @@
/** @type {import(".").OAuthProvider} */
export default function Spotify(options) {
import type { OAuthConfig, OAuthUserConfig } from "."
export interface SpotifyImage {
url: string
}
export interface SpotifyProfile {
id: string
display_name: string
email: string
images: SpotifyImage[]
}
export default function Spotify<P extends Record<string, any> = SpotifyProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "spotify",
name: "Spotify",

View File

@@ -1,5 +1,15 @@
/** @type {import(".").OAuthProvider} */
export default function Twitch(options) {
import type { OAuthConfig, OAuthUserConfig } from "."
export interface TwitchProfile {
sub: string
preferred_username: string
email: string
picture: string
}
export default function Twitch<P extends Record<string, any> = TwitchProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
wellKnown: "https://id.twitch.tv/oauth2/.well-known/openid-configuration",
id: "twitch",
@@ -8,9 +18,13 @@ export default function Twitch(options) {
authorization: {
params: {
scope: "openid user:read:email",
claims: JSON.stringify({
id_token: { email: null, picture: null, preferred_username: null },
}),
claims: {
id_token: {
email: null,
picture: null,
preferred_username: null,
},
},
},
},
idToken: true,

View File

@@ -1,26 +0,0 @@
/** @type {import(".").OAuthProvider} */
export default function Twitter(options) {
return {
id: "twitter",
name: "Twitter",
type: "oauth",
version: "1.0A",
authorization: "https://api.twitter.com/oauth/authenticate",
accessTokenUrl: "https://api.twitter.com/oauth/access_token",
requestTokenUrl: "https://api.twitter.com/oauth/request_token",
profileUrl:
"https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true",
profile(profile) {
return {
id: profile.id_str,
name: profile.name,
email: profile.email,
image: profile.profile_image_url_https.replace(
/_normal\.(jpg|png|gif)$/,
".$1"
),
}
},
options,
}
}

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