mirror of
https://github.com/SrIzan10/next-auth.git
synced 2026-05-01 10:55:20 +00:00
Compare commits
128 Commits
v4.0.0-bet
...
v4.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dcdb62dca | ||
|
|
1f4b7d8089 | ||
|
|
fedb84872d | ||
|
|
c0dddfb77f | ||
|
|
50fe115df6 | ||
|
|
cc17ddf8aa | ||
|
|
8644e553ed | ||
|
|
d1d0db43ea | ||
|
|
b01f6805d3 | ||
|
|
c44b860b9e | ||
|
|
22f74d7c4d | ||
|
|
2570168660 | ||
|
|
187a1474f5 | ||
|
|
4dc76749f2 | ||
|
|
35ee608d59 | ||
|
|
0f132de115 | ||
|
|
31426b9435 | ||
|
|
64b2a2c43b | ||
|
|
7beb3ff03b | ||
|
|
432876c011 | ||
|
|
15d1fab4c8 | ||
|
|
5e803cd34c | ||
|
|
76bf524e8e | ||
|
|
f9e0ef8d18 | ||
|
|
38cefdd548 | ||
|
|
b871b47d8b | ||
|
|
043b252940 | ||
|
|
e9ac11b4b2 | ||
|
|
ba39efb256 | ||
|
|
6502b63e9c | ||
|
|
0d7d8da2d9 | ||
|
|
f998bf2768 | ||
|
|
78fa33312f | ||
|
|
533ed949b3 | ||
|
|
1597369d30 | ||
|
|
41819882be | ||
|
|
b66afcc5cc | ||
|
|
da991de8a4 | ||
|
|
1d9b7b82b9 | ||
|
|
c089ede3af | ||
|
|
5725931406 | ||
|
|
c8b7e2e3cb | ||
|
|
72408ab7d7 | ||
|
|
eb33c9db1d | ||
|
|
932d05da70 | ||
|
|
58a98b667d | ||
|
|
129d161115 | ||
|
|
19e326e8e2 | ||
|
|
a0b9577267 | ||
|
|
dfff2e692f | ||
|
|
5149a5d865 | ||
|
|
0707ba663b | ||
|
|
c5bd99d92a | ||
|
|
72d4c5bfe1 | ||
|
|
f6350354f0 | ||
|
|
50e6a64832 | ||
|
|
9e1eab088a | ||
|
|
f0551b07b8 | ||
|
|
c2fd58d72a | ||
|
|
b052d4cfc1 | ||
|
|
506672676a | ||
|
|
ffa2b1bd6b | ||
|
|
1d52600f41 | ||
|
|
9693277222 | ||
|
|
19a33f3131 | ||
|
|
424bd04eff | ||
|
|
a177bbb68c | ||
|
|
04fc3fd6bc | ||
|
|
cabcdc967f | ||
|
|
a2c4046772 | ||
|
|
ea3f0d6911 | ||
|
|
819e97e6d2 | ||
|
|
e8a58a01b6 | ||
|
|
91de463a5e | ||
|
|
4a9d871698 | ||
|
|
c2119b15de | ||
|
|
0ce15c4a18 | ||
|
|
ead715219a | ||
|
|
8faa7553dd | ||
|
|
90a6a0084b | ||
|
|
cb844a2436 | ||
|
|
74558d6cc2 | ||
|
|
d03125a77b | ||
|
|
66d16f8bf4 | ||
|
|
be74dd0e7e | ||
|
|
9bf867ddcf | ||
|
|
0f460c22da | ||
|
|
887cb00877 | ||
|
|
75ca097ff7 | ||
|
|
bcb9383aec | ||
|
|
b953963101 | ||
|
|
4649f1968b | ||
|
|
45f4a69a4e | ||
|
|
2155c93a3c | ||
|
|
d5958571a4 | ||
|
|
ebecaa6a4b | ||
|
|
1c5173a818 | ||
|
|
35ce332cc6 | ||
|
|
ec295287f1 | ||
|
|
46978ac02f | ||
|
|
f546e550dd | ||
|
|
ac5b4db0f2 | ||
|
|
8bbffdd08c | ||
|
|
a22a0a36fd | ||
|
|
797272afe1 | ||
|
|
13e56bcf2f | ||
|
|
b0f7f87c04 | ||
|
|
9c0851c0f9 | ||
|
|
f5b3c29ab1 | ||
|
|
b4f2a0106a | ||
|
|
9c095b0532 | ||
|
|
0475964a0f | ||
|
|
ad6c13cdc9 | ||
|
|
591aa7cc7e | ||
|
|
9abb392b4e | ||
|
|
b89ae87fb1 | ||
|
|
3687d17724 | ||
|
|
b04ff82fb9 | ||
|
|
c11915ba9c | ||
|
|
24ee459f97 | ||
|
|
ac4851d238 | ||
|
|
84094b0ee7 | ||
|
|
f09ab4a04f | ||
|
|
067364381b | ||
|
|
6ee36b6842 | ||
|
|
5a89ab69d3 | ||
|
|
665445818e | ||
|
|
67cf2a11bb |
8
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
8
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -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:
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
11
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
36
README.md
36
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,7 @@ body {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
li,
|
||||
|
||||
@@ -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: [
|
||||
[
|
||||
|
||||
@@ -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
7441
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
126
package.json
126
package.json
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
@@ -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
233
src/core/index.ts
Normal 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
147
src/core/init.ts
Normal 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
78
src/core/lib/assert.ts
Normal 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.")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
42
src/core/lib/callback-url.ts
Normal file
42
src/core/lib/callback-url.ts
Normal 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
212
src/core/lib/cookie.ts
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }) {
|
||||
@@ -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")
|
||||
}
|
||||
79
src/core/lib/oauth/authorization-url.ts
Normal file
79
src/core/lib/oauth/authorization-url.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
50
src/core/lib/oauth/client.ts
Normal file
50
src/core/lib/oauth/client.ts
Normal 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
|
||||
}
|
||||
84
src/core/lib/oauth/pkce-handler.ts
Normal file
84
src/core/lib/oauth/pkce-handler.ts
Normal 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 },
|
||||
},
|
||||
}
|
||||
}
|
||||
63
src/core/lib/oauth/state-handler.ts
Normal file
63
src/core/lib/oauth/state-handler.ts
Normal 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
68
src/core/lib/providers.ts
Normal 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
|
||||
}
|
||||
@@ -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
108
src/core/pages/error.tsx
Normal 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
79
src/core/pages/index.ts
Normal 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
151
src/core/pages/signin.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/core/pages/signout.tsx
Normal file
35
src/core/pages/signout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
src/core/pages/verify-request.tsx
Normal file
35
src/core/pages/verify-request.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
30
src/core/routes/providers.ts
Normal file
30
src/core/routes/providers.ts
Normal 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
|
||||
},
|
||||
{}
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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
92
src/core/routes/signin.ts
Normal 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` }
|
||||
}
|
||||
45
src/core/routes/signout.ts
Normal file
45
src/core/routes/signout.ts
Normal 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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
228
src/jwt/index.ts
228
src/jwt/index.ts
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
15
src/next/cookie.ts
Normal 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
114
src/next/index.ts
Normal 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
179
src/providers/42-school.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
122
src/providers/apple.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
46
src/providers/azure-ad-b2c.ts
Normal file
46
src/providers/azure-ad-b2c.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
55
src/providers/azure-ad.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
28
src/providers/cognito.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @param {import("../server").Provider} options
|
||||
* @param {import("../core").Provider} options
|
||||
* @example
|
||||
*
|
||||
* ```js
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
38
src/providers/eveonline.ts
Normal file
38
src/providers/eveonline.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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 } }) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> &
|
||||
|
||||
@@ -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
48
src/providers/keycloak.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
38
src/providers/line.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
57
src/providers/linkedin.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
56
src/providers/okta.ts
Normal 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
77
src/providers/osu.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
59
src/providers/pipedrive.ts
Normal file
59
src/providers/pipedrive.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
54
src/providers/slack.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
@@ -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
Reference in New Issue
Block a user