Compare commits

...

8 Commits

Author SHA1 Message Date
Thang Vu
777b7b2f23 chore(release): bump package version(s) [skip ci] 2022-10-09 22:52:53 +07:00
Balázs Orbán
6132c3fa75 fix(ts): match TS types better with implementation (#4953)
* refactor(ts): export `AdapterAccount` from `next-auth/adapters`

* chore: run linter, remove prisma warning

* fix(ts): match TS with implementation closer

* remove unused import

* rename error

* add missing dev dependency

* fix type

* fix type

* fix more types and tests

* remove unused `id`

* skip upstash tests in CI

* revert some changes

* fix type

* revert some change

* revert some change

* revert some change

* revert some changes

* update lock file

* revert line change

* revert some change

* improve adapter & oauth typing

* fix test, revert

* apply review suggestion

* Add test for new rejection logics

* Update assert.test.ts

* fix: Hubspot config

* restore some ts-expect-error

* fix: tests in mirko-orm

* fix: remove redundant id: string

* fix: use ts-expect-errors

* fix: simplify provider type

* fix: normalize user options

* restore ts-expect-errors

Co-authored-by: Thang Vu <hi@thvu.dev>
2022-10-09 21:54:01 +07:00
Usman Sabuwala (Max Programming)
94beef77e6 docs: Remove extra space in code (#5515) 2022-10-09 13:40:38 +02:00
Philipp
490d59dd17 fix(middleware): improve handling of custom Next.js basePath (#5109)
* fix(middleware): improve handling of custom nextjs basePath

* fix(middleware): improve extraction of nextjs base path from req.nextUrl

* adapt to req.nextUrl.basePath

* Fix indent

* Add middleware test for custom-base and simplified code a little bit

* Fix indent

* Add another test

* Rename basePath and nextJsBasePath

* Fix lint error
2022-10-09 11:31:28 +07:00
Thang Vu
26a8c5fc6d chore: lint in apps (#5507) 2022-10-06 22:14:12 +01:00
Colby Fayock
e26ec74720 docs(tutorial): Creating a Custom Adapter (#5506)
* Add new item to tutorials page

* Update tutorials.md

* Apply suggestions from code review

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-10-06 22:12:20 +01:00
Max Peintner
d13997e140 feat(providers): ZITADEL provider (#5479)
* feat: zitadel provider

* Update packages/next-auth/src/providers/zitadel.ts

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

* Update packages/next-auth/src/providers/zitadel.ts

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

Co-authored-by: Balázs Orbán <info@balazsorban.com>
2022-10-06 09:44:21 +02:00
Balázs Orbán
d6efda077d chore(release): bump package version(s) [skip ci] 2022-10-05 19:30:28 +02:00
57 changed files with 633 additions and 322 deletions

View File

@@ -3,10 +3,27 @@ const path = require("path")
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
parserOptions: {
project: [path.resolve(__dirname, "./packages/**/tsconfig.eslint.json")],
},
extends: ["standard-with-typescript", "prettier"],
overrides: [
{
files: ["*.ts", "*.tsx"],
extends: ["standard-with-typescript", "prettier"],
rules: {
camelcase: "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/restrict-template-expressions": "off",
},
parserOptions: {
project: [
path.resolve(__dirname, "./packages/**/tsconfig.eslint.json"),
path.resolve(__dirname, "./apps/**/tsconfig.json"),
],
},
},
],
extends: ["prettier"],
globals: {
localStorage: "readonly",
location: "readonly",
@@ -14,10 +31,6 @@ module.exports = {
},
rules: {
camelcase: "off",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/strict-boolean-expressions": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/restrict-template-expressions": "off",
},
plugins: ["jest"],
env: {

View File

@@ -6,6 +6,7 @@
"scripts": {
"clean": "rm -rf .next",
"dev": "next dev",
"lint": "next lint",
"build": "next build",
"start": "next start",
"email": "fake-smtp-server",

View File

@@ -18,6 +18,7 @@ import Freshbooks from "next-auth/providers/freshbooks"
import GitHub from "next-auth/providers/github"
import Gitlab from "next-auth/providers/gitlab"
import Google from "next-auth/providers/google"
import Hubspot from "next-auth/providers/hubspot"
import IDS4 from "next-auth/providers/identity-server4"
import Instagram from "next-auth/providers/instagram"
import Keycloak from "next-auth/providers/keycloak"
@@ -35,6 +36,7 @@ import Twitter, { TwitterLegacy } from "next-auth/providers/twitter"
import Vk from "next-auth/providers/vk"
import Wikimedia from "next-auth/providers/wikimedia"
import WorkOS from "next-auth/providers/workos"
import Zitadel from "next-auth/providers/zitadel"
// Adapters
import { PrismaClient } from "@prisma/client"
@@ -102,6 +104,7 @@ export const authOptions: NextAuthOptions = {
GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
Gitlab({ clientId: process.env.GITLAB_ID, clientSecret: process.env.GITLAB_SECRET }),
Google({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET }),
Hubspot({ clientId: process.env.HUBSPOT_ID, clientSecret: process.env.HUBSPOT_SECRET }),
IDS4({ clientId: process.env.IDS4_ID, clientSecret: process.env.IDS4_SECRET, issuer: process.env.IDS4_ISSUER }),
Instagram({ clientId: process.env.INSTAGRAM_ID, clientSecret: process.env.INSTAGRAM_SECRET }),
Keycloak({ clientId: process.env.KEYCLOAK_ID, clientSecret: process.env.KEYCLOAK_SECRET, issuer: process.env.KEYCLOAK_ISSUER }),
@@ -120,6 +123,7 @@ export const authOptions: NextAuthOptions = {
Vk({ clientId: process.env.VK_ID, clientSecret: process.env.VK_SECRET }),
Wikimedia({ clientId: process.env.WIKIMEDIA_ID, clientSecret: process.env.WIKIMEDIA_SECRET }),
WorkOS({ clientId: process.env.WORKOS_ID, clientSecret: process.env.WORKOS_SECRET }),
Zitadel({ issuer: process.env.ZITADEL_ISSUER, clientId: process.env.ZITADEL_CLIENT_ID, clientSecret: process.env.ZITADEL_CLIENT_SECRET }),
],
}

View File

@@ -156,7 +156,7 @@ interface OAuthConfig {
*/
id: string
version: string
profile(profile: P, tokens: TokenSet): Awaitable<User & { id: string }>
profile(profile: P, tokens: TokenSet): Awaitable<User>
checks?: ChecksType | ChecksType[]
clientId: string
clientSecret: string

View File

@@ -50,7 +50,7 @@ providers: [
// You can pass any HTML attribute to the <input> tag through the object.
credentials: {
username: { label: "Username", type: "text", placeholder: "jsmith" },
password: { label: "Password", type: "password" }
password: { label: "Password", type: "password" }
},
async authorize(credentials, req) {
// Add logic here to look up the user from the credentials supplied

View File

@@ -0,0 +1,87 @@
---
id: zitadel
title: Zitadel
---
## Documentation
https://docs.zitadel.com/docs/apis/openidoauth/endpoints
## Configuration
https://docs.zitadel.com/docs/guides/integrate/oauth-recommended-flows
The Redirect URIs used when creating the credentials must include your full domain and end in the callback path. For example:
- For production: `https://{YOUR_DOMAIN}/api/auth/callback/zitadel`
- For development: `http://localhost:3000/api/auth/callback/zitadel`
Make sure to enable **dev mode** in ZITADEL console to allow redirects for local development.
## Options
The **ZITADEL Provider** comes with a set of default options:
- [ZITADEL Provider options](https://github.com/nextauthjs/next-auth/blob/main/packages/next-auth/src/providers/zitadel.ts)
You can override any of the options to suit your own use case.
## Example
```js
import ZitadelProvider from "next-auth/providers/zitadel";
...
providers: [
ZitadelProvider({
issuer: process.env.ZITADEL_ISSUER,
clientId: process.env.ZITADEL_CLIENT_ID,
clientSecret: process.env.ZITADEL_CLIENT_SECRET,
})
]
...
```
If you need access to ZITADEL APIs or need additional information, make sure to add the corresponding scopes.
To get the full list of supported claims take a look [here](https://docs.zitadel.com/docs/apis/openidoauth/endpoints).
```js
const options = {
...
providers: [
ZitadelProvider({
clientId: process.env.ZITADEL_CLIENT_ID,
authorization: {
params: {
scope: `openid email profile urn:zitadel:iam:org:project:id:${process.env.ZITADEL_PROJECT_ID}:aud`
}
}
})
],
...
}
```
:::
:::tip
ZITADEL also returns a `email_verified` boolean property in the profile.
You can use this property to restrict access to people with verified accounts.
```js
const options = {
...
callbacks: {
async signIn({ account, profile }) {
if (account.provider === "zitadel") {
return profile.email_verified;
}
return true; // Do different verification for other providers that don't have `email_verified`
},
}
...
}
```
:::

View File

@@ -105,6 +105,11 @@ This tutorial covers:
## Database
#### [Create a NextAuth.js Custom Adapter with HarperDB & Next.js](https://spacejelly.dev/posts/how-to-create-a-nextauth-js-custom-adapter-with-harperdb-next-js/) <svg xmlns="http://www.w3.org/2000/svg" style={{ marginLeft: '5px', marginBottom:'-6px'}} height="20" width="20" fill="none" viewBox="0 0 24 24" stroke="currentColor"><title>External</title> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> </svg>
- Use a custom database in a Custom Adapter for persisted NextAuth.js sessions using HarperDB as an example.
- Video tutorial also available: <https://www.youtube.com/watch?v=pu7xBv7sZ8s>
#### [Using NextAuth.js with Prisma and PlanetScale serverless databases](https://github.com/planetscale/nextjs-planetscale-starter) <svg xmlns="http://www.w3.org/2000/svg" style={{ marginLeft: '5px', marginBottom:'-6px'}} height="20" width="20" fill="none" viewBox="0 0 24 24" stroke="currentColor"><title>External</title> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> </svg>
- How to set up a PlanetScale database to fetch and store user / account data with the Prisma adapter.

View File

@@ -1,7 +1,7 @@
{
"name": "@next-auth/dynamodb-adapter",
"repository": "https://github.com/nextauthjs/next-auth",
"version": "1.0.4",
"version": "1.0.5",
"description": "AWS DynamoDB adapter for next-auth.",
"keywords": [
"next-auth",
@@ -43,4 +43,4 @@
"jest": "^27.4.3",
"next-auth": "workspace:*"
}
}
}

View File

@@ -4,10 +4,10 @@ import type {
BatchWriteCommandInput,
DynamoDBDocument,
} from "@aws-sdk/lib-dynamodb"
import type { Account } from "next-auth"
import type {
Adapter,
AdapterSession,
AdapterAccount,
AdapterUser,
VerificationToken,
} from "next-auth/adapters"
@@ -86,7 +86,7 @@ export function DynamoDBAdapter(
})
if (!data.Items?.length) return null
const accounts = data.Items[0] as Account
const accounts = data.Items[0] as AdapterAccount
const res = await client.get({
TableName,
Key: {
@@ -174,7 +174,7 @@ export function DynamoDBAdapter(
":gsi1sk": `ACCOUNT#${providerAccountId}`,
},
})
const account = format.from<Account>(data.Items?.[0])
const account = format.from<AdapterAccount>(data.Items?.[0])
if (!account) return
await client.delete({
TableName,

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/firebase-adapter",
"version": "1.0.1",
"version": "1.0.2",
"description": "Firebase adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -43,4 +43,4 @@
"jest": "^27.4.3",
"next-auth": "workspace:*"
}
}
}

View File

@@ -15,17 +15,18 @@ import {
where,
connectFirestoreEmulator,
} from "firebase/firestore"
import type { Account } from "next-auth"
import type {
Adapter,
AdapterSession,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken,
} from "next-auth/adapters"
import { getConverter } from "./converter"
type IndexableObject = Record<string, unknown>
export type IndexableObject = Record<string, unknown>
export interface FirestoreAdapterOptions {
emulator?: {
@@ -50,13 +51,13 @@ export function FirestoreAdapter({
}
const Users = collection(db, "users").withConverter(
getConverter<AdapterUser>()
getConverter<AdapterUser & IndexableObject>()
)
const Sessions = collection(db, "sessions").withConverter(
getConverter<AdapterSession & IndexableObject>()
)
const Accounts = collection(db, "accounts").withConverter(
getConverter<Account>()
getConverter<AdapterAccount>()
)
const VerificationTokens = collection(db, "verificationTokens").withConverter(
getConverter<VerificationToken & IndexableObject>({ excludeId: true })

View File

@@ -14,7 +14,7 @@ connectFirestoreEmulator(firestore, 'localhost', 8080);
type IndexableObject = Record<string, unknown>;
const Users = collection(firestore, 'users').withConverter(getConverter<AdapterUser>());
const Users = collection(firestore, 'users').withConverter(getConverter<AdapterUser & IndexableObject>());
const Sessions = collection(firestore, 'sessions').withConverter(getConverter<AdapterSession & IndexableObject>());
const Accounts = collection(firestore, 'accounts').withConverter(getConverter<Account>());
const VerificationTokens = collection(firestore, 'verificationTokens').withConverter(getConverter<VerificationToken & IndexableObject>({ excludeId: true }));

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/mikro-orm-adapter",
"version": "3.0.0",
"version": "3.0.1",
"description": "MikroORM adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -50,4 +50,4 @@
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
}
}

View File

@@ -5,17 +5,16 @@ import {
Unique,
PrimaryKey,
Entity,
Enum,
OneToMany,
Collection,
ManyToOne,
types,
} from "@mikro-orm/core"
import type { DefaultAccount } from "next-auth"
import type {
AdapterSession,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken as AdapterVerificationToken,
} from "next-auth/adapters"
import type { ProviderType } from "next-auth/providers"
@@ -35,7 +34,7 @@ export class User implements RemoveIndex<AdapterUser> {
@Property({ type: types.string, nullable: true })
@Unique()
email?: string
email: string = ""
@Property({ type: types.datetime, nullable: true })
emailVerified: Date | null = null
@@ -44,7 +43,7 @@ export class User implements RemoveIndex<AdapterUser> {
image?: string
@OneToMany({
entity: 'Session',
entity: "Session",
mappedBy: (session: Session) => session.user,
hidden: true,
orphanRemoval: true,
@@ -52,7 +51,7 @@ export class User implements RemoveIndex<AdapterUser> {
sessions = new Collection<Session, object>(this)
@OneToMany({
entity: 'Account',
entity: "Account",
mappedBy: (account: Account) => account.user,
hidden: true,
orphanRemoval: true,
@@ -67,7 +66,7 @@ export class Session implements AdapterSession {
id: string = randomUUID()
@ManyToOne({
entity: 'User',
entity: "User",
hidden: true,
onDelete: "cascade",
})
@@ -76,7 +75,7 @@ export class Session implements AdapterSession {
@Property({ type: types.string, persist: false })
userId!: string
@Property({ type: 'Date' })
@Property({ type: "Date" })
expires!: Date
@Property({ type: types.string })
@@ -86,13 +85,13 @@ export class Session implements AdapterSession {
@Entity()
@Unique({ properties: ["provider", "providerAccountId"] })
export class Account implements RemoveIndex<DefaultAccount> {
export class Account implements RemoveIndex<AdapterAccount> {
@PrimaryKey()
@Property({ type: types.string })
id: string = randomUUID()
@ManyToOne({
entity: 'User',
entity: "User",
hidden: true,
onDelete: "cascade",
})
@@ -139,7 +138,7 @@ export class VerificationToken implements AdapterVerificationToken {
@Property({ type: types.string })
token!: string
@Property({ type: 'Date' })
@Property({ type: "Date" })
expires!: Date
@Property({ type: types.string })

View File

@@ -1,7 +1,4 @@
import { Options, types } from "@mikro-orm/core"
import type { SqliteDriver } from "@mikro-orm/sqlite"
import { MikroORM, wrap } from "@mikro-orm/core"
import { runBasicTests } from "@next-auth/adapter-test"
import { MikroOrmAdapter, defaultEntities } from "../src"
import {
Cascade,
@@ -11,8 +8,12 @@ import {
PrimaryKey,
Property,
Unique,
MikroORM,
wrap,
Options,
types,
} from "@mikro-orm/core"
import { randomUUID } from "@next-auth/adapter-test"
import { randomUUID, runBasicTests } from "@next-auth/adapter-test"
@Entity()
export class User implements defaultEntities.User {
@@ -25,16 +26,16 @@ export class User implements defaultEntities.User {
@Property({ type: types.string, nullable: true })
@Unique()
email?: string
email: string = ""
@Property({ type: 'Date', nullable: true })
@Property({ type: "Date", nullable: true })
emailVerified: Date | null = null
@Property({ type: types.string, nullable: true })
image?: string
@OneToMany({
entity: 'Session',
entity: "Session",
mappedBy: (session: defaultEntities.Session) => session.user,
hidden: true,
orphanRemoval: true,
@@ -43,7 +44,7 @@ export class User implements defaultEntities.User {
sessions = new Collection<defaultEntities.Session>(this)
@OneToMany({
entity: 'Account',
entity: "Account",
mappedBy: (account: defaultEntities.Account) => account.user,
hidden: true,
orphanRemoval: true,

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/mongodb-adapter",
"version": "1.1.0",
"version": "1.1.1",
"description": "mongoDB adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -44,4 +44,4 @@
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
}
}

View File

@@ -3,12 +3,12 @@ import { ObjectId } from "mongodb"
import type {
Adapter,
AdapterSession,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken,
} from "next-auth/adapters"
import type { MongoClient } from "mongodb"
import type { Account } from "next-auth"
export interface MongoDBAdapterOptions {
collections?: {
@@ -56,7 +56,7 @@ export const format = {
else if (key === "id") continue
else newObject[key] = value
}
return newObject as T
return newObject as T & { _id: ObjectId }
},
}
@@ -78,7 +78,7 @@ export function MongoDBAdapter(
const c = { ...defaultCollections, ...collections }
return {
U: _db.collection<AdapterUser>(c.Users),
A: _db.collection<Account>(c.Accounts),
A: _db.collection<AdapterAccount>(c.Accounts),
S: _db.collection<AdapterSession>(c.Sessions),
V: _db.collection<VerificationToken>(c?.VerificationTokens),
}
@@ -128,7 +128,7 @@ export function MongoDBAdapter(
])
},
linkAccount: async (data) => {
const account = to<Account>(data)
const account = to<AdapterAccount>(data)
await (await db).A.insertOne(account)
return account
},
@@ -136,7 +136,7 @@ export function MongoDBAdapter(
const { value: account } = await (
await db
).A.findOneAndDelete(provider_providerAccountId)
return from<Account>(account!)
return from<AdapterAccount>(account!)
},
async getSessionAndUser(sessionToken) {
const session = await (await db).S.findOne({ sessionToken })
@@ -156,7 +156,6 @@ export function MongoDBAdapter(
return from<AdapterSession>(session)
},
async updateSession(data) {
// @ts-expect-error
const { _id, ...session } = to<AdapterSession>(data)
const result = await (

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/neo4j-adapter",
"version": "1.0.4",
"version": "1.0.5",
"description": "neo4j adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -50,4 +50,4 @@
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
}
}

View File

@@ -87,8 +87,6 @@ export function Neo4jAdapter(session: Session): Adapter {
)
},
// @ts-expect-error Property 'id' is missing in type
// We never use `session.id` anywhere in the core, so this is fine.
async createSession(data) {
const { userId, ...s } = format.to(data)
await write(

View File

@@ -38,7 +38,7 @@ runBasicTests({
return format.from(result?.records[0]?.get("u")?.properties)
},
async session(sessionToken: any) {
async session(sessionToken: string) {
const result = await neo4jSession.readTransaction((tx) =>
tx.run(
`MATCH (u:User)-[:HAS_SESSION]->(s:Session)

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env bash
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
NEO4J_USER=neo4j
NEO4J_PASS=password
CONTAINER_NAME=next-auth-neo4j-test-e
@@ -29,7 +28,7 @@ neo4j:4.2.0
# -e NEO4J_ACCEPT_LICENSE_AGREEMENT=yes \
# neo4j:4.2.0-enterprise
echo "Waiting 5 sec for db to start..." && sleep 5
echo "Waiting 10 sec for db to start..." && sleep 10
if $JEST_WATCH; then
# Run jest in watch mode

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/prisma-adapter",
"version": "1.0.4",
"version": "1.0.5",
"description": "Prisma adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -52,4 +52,4 @@
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
}
}

View File

@@ -20,7 +20,6 @@ model User {
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
@@ -35,11 +34,10 @@ model Account {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@id([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
@@ -51,5 +49,5 @@ model VerificationToken {
token String @unique
expires DateTime
@@unique([identifier, token])
@@id([identifier, token])
}

View File

@@ -4,8 +4,7 @@ datasource db {
}
generator client {
provider = "prisma-client-js"
previewFeatures = ["mongoDb"]
provider = "prisma-client-js"
}
model Account {

View File

@@ -10,7 +10,7 @@ generator client {
model User {
id String @id @default(cuid())
name String?
email String? @unique
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
@@ -18,7 +18,6 @@ model User {
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
@@ -33,11 +32,10 @@ model Account {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@id([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
@@ -49,5 +47,5 @@ model VerificationToken {
token String @unique
expires DateTime
@@unique([identifier, token])
@@id([identifier, token])
}

View File

@@ -1,5 +1,5 @@
import type { PrismaClient, Prisma } from "@prisma/client"
import type { Adapter } from "next-auth/adapters"
import type { Adapter, AdapterAccount } from "next-auth/adapters"
export function PrismaAdapter(p: PrismaClient): Adapter {
return {
@@ -15,9 +15,12 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
},
updateUser: ({ id, ...data }) => p.user.update({ where: { id }, data }),
deleteUser: (id) => p.user.delete({ where: { id } }),
linkAccount: (data) => p.account.create({ data }) as any,
linkAccount: (data) =>
p.account.create({ data }) as unknown as AdapterAccount,
unlinkAccount: (provider_providerAccountId) =>
p.account.delete({ where: { provider_providerAccountId } }) as any,
p.account.delete({
where: { provider_providerAccountId },
}) as unknown as AdapterAccount,
async getSessionAndUser(sessionToken) {
const userAndSession = await p.session.findUnique({
where: { sessionToken },
@@ -33,17 +36,18 @@ export function PrismaAdapter(p: PrismaClient): Adapter {
deleteSession: (sessionToken) =>
p.session.delete({ where: { sessionToken } }),
async createVerificationToken(data) {
// @ts-ignore
const { id: _, ...verificationToken } = await p.verificationToken.create({
data,
})
const verificationToken = await p.verificationToken.create({ data })
// @ts-expect-errors // MongoDB needs an ID, but we don't
if (verificationToken.id) delete verificationToken.id
return verificationToken
},
async useVerificationToken(identifier_token) {
try {
// @ts-ignore
const { id: _, ...verificationToken } =
await p.verificationToken.delete({ where: { identifier_token } })
const verificationToken = await p.verificationToken.delete({
where: { identifier_token },
})
// @ts-expect-errors // MongoDB needs an ID, but we don't
if (verificationToken.id) delete verificationToken.id
return verificationToken
} catch (error) {
// If token already used/deleted, just return null

View File

@@ -40,9 +40,9 @@ runBasicTests({
where: { identifier_token },
})
if (!result) return null
// @ts-ignore
const { id: _, ...verificationToken } = result
return verificationToken
// @ts-ignore // MongoDB needs an ID, but we don't
delete result.id
return result
},
},
})

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/sequelize-adapter",
"version": "1.0.5",
"version": "1.0.6",
"description": "Sequelize adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",

View File

@@ -1,7 +1,7 @@
import type { Account as AdapterAccount } from "next-auth"
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken,
} from "next-auth/adapters"

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/typeorm-legacy-adapter",
"version": "2.0.0",
"version": "2.0.1",
"description": "TypeORM (legacy) adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",

View File

@@ -1,6 +1,10 @@
import type { Adapter, AdapterSession, AdapterUser } from "next-auth/adapters"
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
} from "next-auth/adapters"
import { DataSourceOptions, DataSource, EntityManager } from "typeorm"
import type { Account } from "next-auth"
import * as defaultEntities from "./entities"
import { parseDataSourceConfig, updateConnectionEntities } from "./utils"
@@ -87,7 +91,7 @@ export function TypeORMLegacyAdapter(
},
async getUserByAccount(provider_providerAccountId) {
const m = await getManager(c)
const account = await m.findOne<Account & { user: AdapterUser }>(
const account = await m.findOne<AdapterAccount & { user: AdapterUser }>(
"AccountEntity",
{ where: provider_providerAccountId, relations: ["user"] }
)
@@ -115,9 +119,8 @@ export function TypeORMLegacyAdapter(
},
async unlinkAccount(providerAccountId) {
const m = await getManager(c)
await m.delete<Account>("AccountEntity", providerAccountId)
await m.delete<AdapterAccount>("AccountEntity", providerAccountId)
},
// @ts-expect-error
async createSession(data) {
const m = await getManager(c)
const session = await m.save("SessionEntity", data)

View File

@@ -1,6 +1,6 @@
{
"name": "@next-auth/upstash-redis-adapter",
"version": "3.0.2",
"version": "3.0.3",
"description": "Upstash adapter for next-auth. It uses Upstash's connectionless (HTTP based) Redis client.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -49,4 +49,4 @@
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
}
}

View File

@@ -1,7 +1,7 @@
import type { Account as AdapterAccount } from "next-auth"
import type {
Adapter,
AdapterUser,
AdapterAccount,
AdapterSession,
VerificationToken,
} from "next-auth/adapters"
@@ -117,7 +117,6 @@ export function UpstashRedisAdapter(
const id = uuid()
// TypeScript thinks the emailVerified field is missing
// but all fields are copied directly from user, so it's there
// @ts-expect-error
return await setUser(id, { ...user, id })
},
getUser,
@@ -144,10 +143,7 @@ export function UpstashRedisAdapter(
const id = `${account.provider}:${account.providerAccountId}`
return await setAccount(id, { ...account, id })
},
async createSession(session) {
const id = session.sessionToken
return await setSession(id, { ...session, id })
},
createSession: (session) => setSession(session.sessionToken, session),
async getSessionAndUser(sessionToken) {
const session = await getSession(sessionToken)
if (!session) return null

View File

@@ -11,6 +11,14 @@ if (!process.env.UPSTASH_REDIS_URL || !process.env.UPSTASH_REDIS_KEY) {
process.exit(0)
}
if (process.env.CI) {
// TODO: Fix this
test('Skipping UpstashRedisAdapter tests in CI because of "Request failed" errors. Should revisit', () => {
expect(true).toBe(true)
})
process.exit(0)
}
const client = new Redis({
url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_KEY,

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth",
"version": "4.12.2",
"version": "4.13.0",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",
@@ -130,4 +130,4 @@
"engines": {
"node": "^12.19.0 || ^14.15.0 || ^16.13.0"
}
}
}

View File

@@ -2,11 +2,15 @@ import { Account, User, Awaitable } from "."
export interface AdapterUser extends User {
id: string
email: string
emailVerified: Date | null
}
export interface AdapterAccount extends Account {
userId: string
}
export interface AdapterSession {
id: string
/** A randomly generated value that is used to get hold of the session. */
sessionToken: string
/** Used to connect the session to a particular user */
@@ -55,13 +59,30 @@ export interface VerificationToken {
* [Adapters Overview](https://next-auth.js.org/adapters/overview) |
* [Create a custom adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter)
*/
export interface Adapter {
export type Adapter<WithVerificationToken = boolean> = DefaultAdapter &
(WithVerificationToken extends true
? {
createVerificationToken: (
verificationToken: VerificationToken
) => Awaitable<VerificationToken | null | undefined>
/**
* Return verification token from the database
* and delete it so it cannot be used again.
*/
useVerificationToken: (params: {
identifier: string
token: string
}) => Awaitable<VerificationToken | null>
}
: {})
export interface DefaultAdapter {
createUser: (user: Omit<AdapterUser, "id">) => Awaitable<AdapterUser>
getUser: (id: string) => Awaitable<AdapterUser | null>
getUserByEmail: (email: string) => Awaitable<AdapterUser | null>
/** Using the provider id and the id of the user for a specific account, get the user. */
getUserByAccount: (
providerAccountId: Pick<Account, "provider" | "providerAccountId">
providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">
) => Awaitable<AdapterUser | null>
updateUser: (user: Partial<AdapterUser>) => Awaitable<AdapterUser>
/** @todo Implement */
@@ -69,12 +90,12 @@ export interface Adapter {
userId: string
) => Promise<void> | Awaitable<AdapterUser | null | undefined>
linkAccount: (
account: Account
) => Promise<void> | Awaitable<Account | null | undefined>
account: AdapterAccount
) => Promise<void> | Awaitable<AdapterAccount | null | undefined>
/** @todo Implement */
unlinkAccount?: (
providerAccountId: Pick<Account, "provider" | "providerAccountId">
) => Promise<void> | Awaitable<Account | undefined>
providerAccountId: Pick<AdapterAccount, "provider" | "providerAccountId">
) => Promise<void> | Awaitable<AdapterAccount | undefined>
/** Creates a session for the user and returns it. */
createSession: (session: {
sessionToken: string

View File

@@ -1,5 +1,4 @@
import type { EventCallbacks, LoggerInstance } from ".."
import type { Adapter } from "../adapters"
/**
* Same as the default `Error`, but it is JSON serializable.
@@ -58,6 +57,11 @@ export class MissingAdapter extends UnknownError {
code = "EMAIL_REQUIRES_ADAPTER_ERROR"
}
export class MissingAdapterMethods extends UnknownError {
name = "MissingAdapterMethodsError"
code = "MISSING_ADAPTER_METHODS_ERROR"
}
export class UnsupportedStrategy extends UnknownError {
name = "UnsupportedStrategyError"
code = "CALLBACK_CREDENTIALS_JWT_ERROR"
@@ -99,10 +103,10 @@ export function eventsErrorHandler(
}
/** Handles adapter induced errors. */
export function adapterErrorHandler(
adapter: Adapter | undefined,
export function adapterErrorHandler<TAdapter>(
adapter: TAdapter | undefined,
logger: LoggerInstance
): Adapter | undefined {
): TAdapter | undefined {
if (!adapter) return
return Object.keys(adapter).reduce<any>((acc, name) => {

View File

@@ -71,6 +71,7 @@ export async function init({
// and are request-specific.
url,
action,
// @ts-expect-errors
provider,
cookies: {
...cookie.defaultCookies(

View File

@@ -5,6 +5,7 @@ import {
MissingSecret,
UnsupportedStrategy,
InvalidCallbackUrl,
MissingAdapterMethods,
} from "../errors"
import parseUrl from "../../utils/parse-url"
import { defaultCookies } from "./cookie"
@@ -120,8 +121,23 @@ export function assertConfig(params: {
}
}
if (hasEmail && !options.adapter) {
return new MissingAdapter("E-mail login requires an adapter.")
if (hasEmail) {
const { adapter } = options
if (!adapter) {
return new MissingAdapter("E-mail login requires an adapter.")
}
const missingMethods = [
"createVerificationToken",
"useVerificationToken",
"getUserByEmail",
].filter((method) => !adapter[method])
if (missingMethods.length) {
return new MissingAdapterMethods(
`Required adapter methods were missing: ${missingMethods.join(", ")}`
)
}
}
if (!warned) {

View File

@@ -21,11 +21,11 @@ import type { SessionToken } from "./cookie"
*/
export default async function callbackHandler(params: {
sessionToken?: SessionToken
profile: User
account: Account
profile: User | AdapterUser | { email: string }
account: Account | null
options: InternalOptions
}) {
const { sessionToken, profile, account, options } = params
const { sessionToken, profile: _profile, account, options } = params
// Input validation
if (!account?.providerAccountId || !account.type)
throw new Error("Missing or invalid provider account")
@@ -42,9 +42,11 @@ export default async function callbackHandler(params: {
// If no adapter is configured then we don't have a database and cannot
// persist data; in this mode we just return a dummy session object.
if (!adapter) {
return { user: profile, account, session: {} }
return { user: _profile as User, account }
}
const profile = _profile as AdapterUser
const {
createUser,
updateUser,
@@ -84,9 +86,7 @@ export default async function callbackHandler(params: {
if (account.type === "email") {
// If signing in with an email, check if an account with the same email address exists already
const userByEmail = profile.email
? await getUserByEmail(profile.email)
: null
const userByEmail = await getUserByEmail(profile.email)
if (userByEmail) {
// If they are not already signed in as the same user, this flow will
// sign them out of the current session and sign them in as the new user
@@ -101,8 +101,7 @@ export default async function callbackHandler(params: {
user = await updateUser({ id: userByEmail.id, emailVerified: new Date() })
await events.updateUser?.({ user })
} else {
const newUser = { ...profile, emailVerified: new Date() }
delete (newUser as Omit<AdapterUser, "id">).id
const { id: _, ...newUser } = { ...profile, emailVerified: new Date() }
// Create user account if there isn't one for the email address already
user = await createUser(newUser)
await events.createUser?.({ user })
@@ -198,8 +197,7 @@ export default async function callbackHandler(params: {
// If no account matching the same [provider].id or .email exists, we can
// create a new account for the user, link it to the OAuth acccount and
// create a new session for them so they are signed in with it.
const newUser = { ...profile, emailVerified: null }
delete (newUser as Omit<AdapterUser, "id">).id
const { id: _, ...newUser } = { ...profile, emailVerified: null }
user = await createUser(newUser)
await events.createUser?.({ user })
@@ -217,4 +215,6 @@ export default async function callbackHandler(params: {
return { session, user, isNewUser: true }
}
}
throw new Error("Unsupported account type")
}

View File

@@ -0,0 +1,19 @@
import type { InternalOptions } from "../../types"
export default async function getUserFromEmail({
email,
adapter,
withId = false,
}: {
email: string
adapter: InternalOptions<"email">["adapter"]
withId: boolean
}) {
const { getUserByEmail } = adapter
// If is an existing user return a user object (otherwise use placeholder)
return (email ? await getUserByEmail(email) : null) ?? withId
? { id: email, email }
: {
email,
}
}

View File

@@ -36,7 +36,6 @@ export default async function email(
theme,
}),
// Save in database
// @ts-expect-error // verified in `assertConfig`
adapter.createVerificationToken({
identifier,
token: hashToken(token, options),

View File

@@ -39,10 +39,7 @@ export default async function getAuthorizationUrl({
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({
const url = `${provider.authorization?.url}?${new URLSearchParams({
oauth_token: tokens.oauth_token,
oauth_token_secret: tokens.oauth_token_secret,
...tokens.params,
@@ -68,7 +65,7 @@ export default async function getAuthorizationUrl({
authorizationParams.nonce = nonce.value
cookies.push(nonce.cookie)
}
const pkce = await createPKCE(options)
if (pkce) {
authorizationParams.code_challenge = pkce.code_challenge

View File

@@ -7,10 +7,10 @@ import { useNonce } from "./nonce-handler"
import { OAuthCallbackError } from "../../errors"
import type { CallbackParamsType, OpenIDCallbackChecks } from "openid-client"
import type { Account, LoggerInstance, Profile } from "../../.."
import type { LoggerInstance, Profile } from "../../.."
import type { OAuthChecks, OAuthConfig } from "../../../providers"
import type { InternalOptions } from "../../types"
import type { RequestInternal, OutgoingResponse } from "../.."
import type { RequestInternal } from "../.."
import type { Cookie } from "../cookie"
export default async function oAuthCallback(params: {
@@ -19,7 +19,7 @@ export default async function oAuthCallback(params: {
body: RequestInternal["body"]
method: Required<RequestInternal>["method"]
cookies: RequestInternal["cookies"]
}): Promise<GetProfileResult & { cookies?: OutgoingResponse["cookies"] }> {
}) {
const { options, query, body, method, cookies } = params
const { logger, provider } = options
@@ -34,23 +34,19 @@ export default async function oAuthCallback(params: {
logger.debug("OAUTH_CALLBACK_HANDLER_ERROR", { body })
throw error
}
if (provider.version?.startsWith("1.")) {
try {
const client = await oAuth1Client(options)
// Handle OAuth v1.x
const { oauth_token, oauth_verifier } = query ?? {}
// @ts-expect-error
const tokens: TokenSet = await client.getOAuthAccessToken(
oauth_token as string,
// @ts-expect-error
const tokens = (await (client as any).getOAuthAccessToken(
oauth_token,
null,
oauth_verifier
)
// @ts-expect-error
let profile: Profile = await client.get(
(provider as any).profileUrl,
)) as TokenSet
let profile: Profile = await (client as any).get(
provider.profileUrl,
tokens.oauth_token,
tokens.oauth_token_secret
)
@@ -59,7 +55,8 @@ export default async function oAuthCallback(params: {
profile = JSON.parse(profile)
}
return await getProfile({ profile, tokens, provider, logger })
const newProfile = await getProfile({ profile, tokens, provider, logger })
return { ...newProfile, cookies: [] }
} catch (error) {
logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error as Error)
throw error
@@ -82,7 +79,7 @@ export default async function oAuthCallback(params: {
const nonce = await useNonce(cookies?.[options.cookies.nonce.name], options)
if (nonce && provider.idToken) {
(checks as OpenIDCallbackChecks).nonce = nonce.value
;(checks as OpenIDCallbackChecks).nonce = nonce.value
resCookies.push(nonce.cookie)
}
@@ -102,13 +99,10 @@ export default async function oAuthCallback(params: {
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,
@@ -128,9 +122,7 @@ export default async function oAuthCallback(params: {
}
let profile: Profile
// @ts-expect-error
if (provider.userinfo?.request) {
// @ts-expect-error
profile = await provider.userinfo.request({
provider,
tokens,
@@ -140,7 +132,6 @@ export default async function oAuthCallback(params: {
profile = tokens.claims()
} else {
profile = await client.userinfo(tokens, {
// @ts-expect-error
params: provider.userinfo?.params,
})
}
@@ -164,25 +155,22 @@ export interface GetProfileParams {
logger: LoggerInstance
}
export interface GetProfileResult {
// @ts-expect-error
profile: ReturnType<OAuthConfig["profile"]> | null
account: Omit<Account, "userId"> | null
OAuthProfile: Profile
}
/** Returns profile, raw profile and auth provider details */
async function getProfile({
profile: OAuthProfile,
tokens,
provider,
logger,
}: GetProfileParams): Promise<GetProfileResult> {
}: GetProfileParams) {
try {
logger.debug("PROFILE_DATA", { OAuthProfile })
// @ts-expect-error
const profile = await provider.profile(OAuthProfile, tokens)
profile.email = profile.email?.toLowerCase()
if (!profile.id)
throw new TypeError(
`Profile id is missing in ${provider.name} OAuth profile response`
)
// Return profile, raw profile and auth provider details
return {
profile,
@@ -202,11 +190,9 @@ 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 as Error)
return {
profile: null,
account: null,
logger.error("OAUTH_PARSE_PROFILE_ERROR", {
error: error as Error,
OAuthProfile,
}
})
}
}

View File

@@ -22,13 +22,9 @@ export async function openidClient(
} 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,
authorization_endpoint: provider.authorization?.url,
token_endpoint: provider.token?.url,
userinfo_endpoint: provider.userinfo?.url,
})
}

View File

@@ -1,7 +1,11 @@
import { merge } from "../../utils/merge"
import type { InternalProvider } from "../types"
import type { Provider } from "../../providers"
import type {
InternalOAuthConfig,
OAuthConfig,
Provider,
} from "../../providers"
import type { InternalUrl } from "../../utils/parse-url"
/**
@@ -18,52 +22,72 @@ export default function parseProviders(params: {
} {
const { url, providerId } = params
const providers = params.providers.map(({ options, ...rest }) => {
const defaultOptions = normalizeProvider(rest as Provider)
const userOptions = normalizeProvider(options as Provider)
const providers = params.providers.map<InternalProvider>(
({ options: userOptions, ...rest }) => {
if (rest.type === "oauth") {
const normalizedOptions = normalizeOAuthOptions(rest)
const normalizedUserOptions = normalizeOAuthOptions(userOptions, true)
return merge(normalizedOptions, {
...normalizedUserOptions,
signinUrl: `${url}/signin/${normalizedUserOptions?.id ?? rest.id}`,
callbackUrl: `${url}/callback/${
normalizedUserOptions?.id ?? rest.id
}`,
})
}
return merge(rest, {
...userOptions,
signinUrl: `${url}/signin/${userOptions?.id ?? rest.id}`,
callbackUrl: `${url}/callback/${userOptions?.id ?? rest.id}`,
})
}
)
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 }
return {
providers,
provider: providers.find(({ id }) => id === providerId),
}
}
function normalizeProvider(provider?: Provider) {
if (!provider) return
/**
* Transform OAuth options `authorization`, `token` and `profile` strings to `{ url: string; params: Record<string, string> }`
*/
function normalizeOAuthOptions(
oauthOptions?: Partial<OAuthConfig<any>> | Record<string, unknown>,
isUserOptions = false
) {
if (!oauthOptions) return
const normalized: InternalProvider = Object.entries(
provider
).reduce<InternalProvider>((acc, [key, value]) => {
if (
["authorization", "token", "userinfo"].includes(key) &&
typeof value === "string"
) {
const url = new URL(value)
acc[key] = {
url: `${url.origin}${url.pathname}`,
params: Object.fromEntries(url.searchParams ?? []),
const normalized = Object.entries(oauthOptions).reduce<
InternalOAuthConfig<Record<string, unknown>>
>(
(acc, [key, value]) => {
if (
["authorization", "token", "userinfo"].includes(key) &&
typeof value === "string"
) {
const url = new URL(value)
acc[key] = {
url: `${url.origin}${url.pathname}`,
params: Object.fromEntries(url.searchParams ?? []),
}
} else {
acc[key] = value
}
} else {
acc[key] = value
}
return acc
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter, @typescript-eslint/consistent-type-assertions
}, {} as any)
return acc
},
// eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter
{} as any
)
if (normalized.type === "oauth" && !normalized.version?.startsWith("1.")) {
if (!isUserOptions && !normalized.version?.startsWith("1.")) {
// If provider has as an "openid-configuration" well-known endpoint
// or an "openid" scope request, it will also likely be able to receive an `id_token`
// Only do this if this function is not called with user options to avoid overriding in later stage.
normalized.idToken = Boolean(
normalized.idToken ??
normalized.wellKnown?.includes("openid-configuration") ??
// @ts-expect-error
normalized.authorization?.params?.scope?.includes("openid")
)

View File

@@ -1,15 +1,17 @@
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
import { hashToken } from "../lib/utils"
import getUserFromEmail from "../lib/email/getUserFromEmail"
import type { InternalOptions } from "../types"
import type { RequestInternal, OutgoingResponse } from ".."
import type { Cookie, SessionStore } from "../lib/cookie"
import type { User } from "../.."
import type { AdapterSession } from "../../adapters"
/** Handle callbacks from login services */
export default async function callback(params: {
options: InternalOptions<"oauth" | "credentials" | "email">
options: InternalOptions
query: RequestInternal["query"]
method: Required<RequestInternal>["method"]
body: RequestInternal["body"]
@@ -50,7 +52,7 @@ export default async function callback(params: {
cookies: params.cookies,
})
if (oauthCookies) cookies.push(...oauthCookies)
if (oauthCookies.length) cookies.push(...oauthCookies)
try {
// Make it easier to debug when adding a new provider
@@ -68,7 +70,7 @@ export default async function callback(params: {
// Note: In oAuthCallback an error is logged with debug info, so it
// should at least be visible to developers what happened if it is an
// error with the provider.
if (!profile) {
if (!profile || !account || !OAuthProfile) {
return { redirect: `${url}/signin`, cookies }
}
@@ -80,7 +82,6 @@ export default async function callback(params: {
if (adapter) {
const { getUserByAccount } = adapter
const userByAccount = await getUserByAccount({
// @ts-expect-error
providerAccountId: account.providerAccountId,
provider: provider.id,
})
@@ -91,7 +92,6 @@ export default async function callback(params: {
try {
const isAllowed = await callbacks.signIn({
user: userOrProfile,
// @ts-expect-error
account,
profile: OAuthProfile,
})
@@ -110,11 +110,9 @@ export default async function callback(params: {
}
// Sign user in
// @ts-expect-error
const { user, session, isNewUser } = await callbackHandler({
sessionToken: sessionStore.value,
profile,
// @ts-expect-error
account,
options,
})
@@ -129,7 +127,6 @@ export default async function callback(params: {
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
profile: OAuthProfile,
isNewUser,
@@ -150,10 +147,10 @@ export default async function callback(params: {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: session.sessionToken,
value: (session as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: session.expires,
expires: (session as AdapterSession).expires,
},
})
}
@@ -201,14 +198,16 @@ export default async function callback(params: {
}
} else if (provider.type === "email") {
try {
// Verified in `assertConfig`
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { useVerificationToken, getUserByEmail } = adapter!
const token = query?.token as string | undefined
const identifier = query?.email as string | undefined
const token = query?.token
const identifier = query?.email
// If these are missing, the sign-in URL was manually opened without these params or the `sendVerificationRequest` method did not send the link correctly in the email.
if (!token || !identifier) {
return { redirect: `${url}/error?error=configuration`, cookies }
}
const invite = await useVerificationToken?.({
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
const invite = await adapter.useVerificationToken({
identifier,
token: hashToken(token, options),
})
@@ -218,29 +217,23 @@ export default async function callback(params: {
return { redirect: `${url}/error?error=Verification`, cookies }
}
// If it is an existing user, use that, otherwise use a placeholder
const profile = (identifier
? await getUserByEmail(identifier)
: null) ?? {
const profile = await getUserFromEmail({
email: identifier,
}
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
adapter,
})
/** @type {import("src").Account} */
const account = {
providerAccountId: profile.email,
type: "email",
type: "email" as const,
provider: provider.id,
}
// 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 { redirect: `${url}/error?error=AccessDenied`, cookies }
@@ -257,12 +250,9 @@ export default async function callback(params: {
}
// Sign user in
// @ts-expect-error
const { user, session, isNewUser } = await callbackHandler({
sessionToken: sessionStore.value,
// @ts-expect-error
profile,
// @ts-expect-error
account,
options,
})
@@ -277,7 +267,6 @@ export default async function callback(params: {
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
isNewUser,
})
@@ -297,15 +286,14 @@ export default async function callback(params: {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: session.sessionToken,
value: (session as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: session.expires,
expires: (session as AdapterSession).expires,
},
})
}
// @ts-expect-error
await events.signIn?.({ user, account, isNewUser })
// Handle first logins on new accounts

View File

@@ -1,8 +1,9 @@
import getAuthorizationUrl from "../lib/oauth/authorization-url"
import emailSignin from "../lib/email/signin"
import getUserFromEmail from "../lib/email/getUserFromEmail"
import type { RequestInternal, OutgoingResponse } from ".."
import type { InternalOptions } from "../types"
import type { Account, User } from "../.."
import type { Account } from "../.."
/** Handle requests to /api/auth/signin */
export default async function signin(params: {
@@ -11,7 +12,7 @@ export default async function signin(params: {
body: RequestInternal["body"]
}): Promise<OutgoingResponse> {
const { options, query, body } = params
const { url, adapter, callbacks, logger, provider } = options
const { url, callbacks, logger, provider } = options
if (!provider.type) {
return {
@@ -54,14 +55,12 @@ export default async function signin(params: {
return { redirect: `${url}/error?error=EmailSignin` }
}
// 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) ?? {
const user = await getUserFromEmail({
email,
id: email,
}
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
adapter: options.adapter,
withId: true,
})
const account: Account = {
providerAccountId: email,
@@ -72,7 +71,6 @@ export default async function signin(params: {
// Check if user is allowed to sign in
try {
// @ts-expect-error
const signInCallbackResponse = await callbacks.signIn({
user,
account,

View File

@@ -1,11 +1,11 @@
import type { Adapter } from "../adapters"
import type { Adapter, AdapterUser } from "../adapters"
import type {
Provider,
CredentialInput,
ProviderType,
OAuthConfig,
EmailConfig,
CredentialsConfig,
InternalOAuthConfig,
} from "../providers"
import type { TokenSetParameters } from "openid-client"
import type { JWT, JWTOptions } from "../jwt"
@@ -231,7 +231,7 @@ export type TokenSet = TokenSetParameters
* Usually contains information about the provider being used
* and also extends `TokenSet`, which is different tokens returned by OAuth Providers.
*/
export interface DefaultAccount extends Partial<TokenSet> {
export interface Account extends Partial<TokenSet> {
/**
* This value depends on the type of the provider being used to create the account.
* - oauth: The OAuth account's id, returned from the `profile()` callback.
@@ -240,30 +240,23 @@ export interface DefaultAccount extends Partial<TokenSet> {
*/
providerAccountId: string
/** id of the user this account belongs to. */
userId: string
userId?: string
/** id of the provider used for this account */
provider: string
/** Provider's type for this account */
type: ProviderType
}
export interface Account extends Record<string, unknown>, DefaultAccount {}
export interface DefaultProfile {
/** The OAuth profile returned from your provider */
export interface Profile {
sub?: string
name?: string
email?: string
image?: string
}
/** The OAuth profile returned from your provider */
export interface Profile extends Record<string, unknown>, DefaultProfile {}
/** [Documentation](https://next-auth.js.org/configuration/callbacks) */
export interface CallbacksOptions<
P extends Record<string, unknown> = Profile,
A extends Record<string, unknown> = Account
> {
export interface CallbacksOptions<P = Profile, A = Account> {
/**
* Use this callback to control if a user is allowed to sign in.
* Returning true will continue the sign-in flow.
@@ -272,13 +265,13 @@ export interface CallbacksOptions<
* [Documentation](https://next-auth.js.org/configuration/callbacks#sign-in-callback)
*/
signIn: (params: {
user: User
account: A
user: User | { email: string }
account: A | null
/**
* If OAuth provider is used, it contains the full
* OAuth profile returned by your provider.
*/
profile: P & Record<string, unknown>
profile?: P
/**
* If Email provider is used, on the first call, it contains a
* `verificationRequest: true` property to indicate it is being triggered in the verification request flow.
@@ -287,7 +280,7 @@ export interface CallbacksOptions<
* to avoid sending emails to addresses or domains on a blocklist or to only explicitly generate them
* for email address in an allow list.
*/
email: {
email?: {
verificationRequest?: boolean
}
/** If Credentials provider is used, it contains the user credentials */
@@ -341,8 +334,8 @@ export interface CallbacksOptions<
*/
jwt: (params: {
token: JWT
user?: User
account?: A
user?: User | AdapterUser
account?: A | null
profile?: P
isNewUser?: boolean
}) => Awaitable<JWT>
@@ -378,7 +371,7 @@ export interface EventCallbacks {
*/
signIn: (message: {
user: User
account: Account
account: Account | null
profile?: Profile
isNewUser?: boolean
}) => Awaitable<void>
@@ -392,9 +385,9 @@ export interface EventCallbacks {
createUser: (message: { user: User }) => Awaitable<void>
updateUser: (message: { user: User }) => Awaitable<void>
linkAccount: (message: {
user: User
user: User | AdapterUser | { email: string }
account: Account
profile: User
profile: User | AdapterUser | { email: string }
}) => Awaitable<void>
/**
* The message object will contain one of these depending on
@@ -420,7 +413,7 @@ export interface PagesOptions {
export type ISODateString = string
export interface DefaultSession extends Record<string, unknown> {
export interface DefaultSession {
user?: {
name?: string | null
email?: string | null
@@ -438,7 +431,7 @@ export interface DefaultSession extends Record<string, unknown> {
* [`SessionProvider`](https://next-auth.js.org/getting-started/client#sessionprovider) |
* [`session` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback)
*/
export interface Session extends Record<string, unknown>, DefaultSession {}
export interface Session extends DefaultSession {}
export type SessionStrategy = "jwt" | "database"
@@ -494,13 +487,13 @@ export interface DefaultUser {
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) |
* [`profile` OAuth provider callback](https://next-auth.js.org/configuration/providers#using-a-custom-provider)
*/
export interface User extends Record<string, unknown>, DefaultUser {}
export interface User extends DefaultUser {}
// Below are types that are only supposed be used by next-auth internally
/** @internal */
export type InternalProvider<T extends ProviderType = any> = (T extends "oauth"
? OAuthConfig<any>
export type InternalProvider<T = ProviderType> = (T extends "oauth"
? InternalOAuthConfig<any>
: T extends "email"
? EmailConfig
: T extends "credentials"
@@ -522,7 +515,10 @@ export type NextAuthAction =
| "_log"
/** @internal */
export interface InternalOptions<T extends ProviderType = any> {
export interface InternalOptions<
TProviderType = ProviderType,
WithVerificationToken = TProviderType extends "email" ? true : false
> {
providers: InternalProvider[]
/**
* Parsed from `NEXTAUTH_URL` or `x-forwarded-host` on Vercel.
@@ -530,9 +526,7 @@ export interface InternalOptions<T extends ProviderType = any> {
*/
url: InternalUrl
action: NextAuthAction
provider: T extends string
? InternalProvider<T>
: InternalProvider<T> | undefined
provider: InternalProvider<TProviderType>
csrfToken?: string
csrfTokenVerified?: boolean
secret: string
@@ -543,7 +537,9 @@ export interface InternalOptions<T extends ProviderType = any> {
pages: Partial<PagesOptions>
jwt: JWTOptions
events: Partial<EventCallbacks>
adapter?: Adapter
adapter: WithVerificationToken extends true
? Adapter<WithVerificationToken>
: Adapter<WithVerificationToken> | undefined
callbacks: CallbacksOptions
cookies: CookiesOptions
callbackUrl: string

View File

@@ -101,17 +101,17 @@ async function handleMiddleware(
options: NextAuthMiddlewareOptions | undefined,
onSuccess?: (token: JWT | null) => Promise<NextMiddlewareResult>
) {
const { pathname, search, origin } = req.nextUrl
const { pathname, search, origin, basePath } = req.nextUrl
const signInPage = options?.pages?.signIn ?? "/api/auth/signin"
const errorPage = options?.pages?.error ?? "/api/auth/error"
const basePath = parseUrl(process.env.NEXTAUTH_URL).path
const authPath = parseUrl(process.env.NEXTAUTH_URL).path
const publicPaths = ["/_next", "/favicon.ico"]
// Avoid infinite redirects/invalid response
// on paths that never require authentication
if (
pathname.startsWith(basePath) ||
`${basePath}${pathname}`.startsWith(authPath) ||
[signInPage, errorPage].includes(pathname) ||
publicPaths.some((p) => pathname.startsWith(p))
) {
@@ -125,7 +125,7 @@ async function handleMiddleware(
`\nhttps://next-auth.js.org/errors#no_secret`
)
const errorUrl = new URL(errorPage, origin)
const errorUrl = new URL(`${basePath}${errorPage}`, origin)
errorUrl.searchParams.append("error", "Configuration")
return NextResponse.redirect(errorUrl)
@@ -145,8 +145,8 @@ async function handleMiddleware(
if (isAuthorized) return await onSuccess?.(token)
// the user is not logged in, redirect to the sign-in page
const signInUrl = new URL(signInPage, origin)
signInUrl.searchParams.append("callbackUrl", `${pathname}${search}`)
const signInUrl = new URL(`${basePath}${signInPage}`, origin)
signInUrl.searchParams.append("callbackUrl", `${basePath}${pathname}${search}`)
return NextResponse.redirect(signInUrl)
}

View File

@@ -1,28 +1,25 @@
import type { OAuthConfig, OAuthUserConfig } from "."
interface HubSpotProfile extends Record<string, any> {
// TODO: figure out additional fields, for now using
// TODO: figure out additional fields, for now using
// https://legacydocs.hubspot.com/docs/methods/oauth2/get-access-token-information
user: string,
user_id: string,
user: string
user_id: string
hub_domain: string,
hub_id: string,
hub_domain: string
hub_id: string
}
const HubSpotConfig = {
authorizationUrl: "https://app.hubspot.com/oauth/authorize",
tokenUrl: "https://api.hubapi.com/oauth/v1/token",
profileUrl: "https://api.hubapi.com/oauth/v1/access-tokens"
profileUrl: "https://api.hubapi.com/oauth/v1/access-tokens",
}
export default function HubSpot<P extends HubSpotProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
return {
id: "hubspot",
name: "HubSpot",
@@ -36,7 +33,6 @@ export default function HubSpot<P extends HubSpotProfile>(
scope: "oauth",
client_id: options.clientId,
},
},
client: {
token_endpoint_auth_method: "client_secret_post",
@@ -45,33 +41,27 @@ export default function HubSpot<P extends HubSpotProfile>(
userinfo: {
url: HubSpotConfig.profileUrl,
async request(context) {
const url = `${HubSpotConfig.profileUrl}/${context.tokens.access_token}`;
const url = `${HubSpotConfig.profileUrl}/${context.tokens.access_token}`
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
},
method: "GET",
});
})
const userInfo = await response.json();
return { userInfo }
}
return await response.json()
},
},
profile(profile) {
const { userInfo } = profile
return {
id: userInfo.user_id,
name: userInfo.user,
email: userInfo.user,
id: profile.user_id,
name: profile.user,
email: profile.user,
// TODO: get image from profile once it's available
// TODO: get image from profile once it's available
// Details available https://community.hubspot.com/t5/APIs-Integrations/Profile-photo-is-not-retrieved-with-User-API/m-p/325521
image: null
image: null,
}
},
options,

View File

@@ -110,7 +110,7 @@ export interface OAuthConfig<P> extends CommonProviderOptions, PartialIssuer {
userinfo?: string | UserinfoEndpointHandler
type: "oauth"
version?: string
profile?: (profile: P, tokens: TokenSet) => Awaitable<User & { id: string }>
profile: (profile: P, tokens: TokenSet) => Awaitable<User>
checks?: ChecksType | ChecksType[]
client?: Partial<ClientMetadata>
jwks?: { keys: JWK[] }
@@ -147,6 +147,14 @@ export interface OAuthConfig<P> extends CommonProviderOptions, PartialIssuer {
encoding?: string
}
/** @internal */
export interface InternalOAuthConfig<P>
extends Omit<OAuthConfig<P>, "authorization" | "token" | "userinfo"> {
authorization?: AuthorizationEndpointHandler
token?: TokenEndpointHandler
userinfo?: UserinfoEndpointHandler
}
export type OAuthUserConfig<P> = Omit<
Partial<OAuthConfig<P>>,
"options" | "type"

View File

@@ -0,0 +1,51 @@
import type { OAuthConfig, OAuthUserConfig } from "."
export interface ZitadelProfile extends Record<string, any> {
amr: string // Authentication Method References as defined in RFC8176
aud: string // The audience of the token, by default all client id's and the project id are included
auth_time: number // Unix time of the authentication
azp: string // Client id of the client who requested the token
email: string // Email Address of the subject
email_verified: boolean // if the email was verified by ZITADEL
exp: number // Time the token expires (as unix time)
family_name: string // The subjects family name
given_name: string // Given name of the subject
gender: string // Gender of the subject
iat: number // Time of the token was issued at (as unix time)
iss: string // Issuing domain of a token
jti: string // Unique id of the token
locale: string // Language from the subject
name: string // The subjects full name
nbf: number // Time the token must not be used before (as unix time)
picture: string // The subjects profile picture
phone: string // Phone number provided by the user
phone_verified: boolean // if the phonenumber was verified by ZITADEL
preferred_username: string // ZITADEL's login name of the user. Consist of username@primarydomain
sub: string // Subject ID of the user
}
export default function Zitadel<P extends ZitadelProfile>(
options: OAuthUserConfig<P>
): OAuthConfig<P> {
const { issuer } = options
return {
id: "zitadel",
name: "ZITADEL",
type: "oauth",
version: "2",
wellKnown: `${issuer}/.well-known/openid-configuration`,
authorization: { params: { scope: "openid email profile" } },
idToken: true,
checks: ["pkce", "state"],
async profile(profile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
}
},
options,
}
}

View File

@@ -1,5 +1,11 @@
import { InvalidCallbackUrl, MissingSecret } from "../src/core/errors"
import {
InvalidCallbackUrl,
MissingAdapter,
MissingAdapterMethods,
MissingSecret,
} from "../src/core/errors"
import { handler } from "./lib"
import EmailProvider from "../src/providers/email"
it("Show error page if secret is not defined", async () => {
const { res, log } = await handler(
@@ -14,6 +20,48 @@ it("Show error page if secret is not defined", async () => {
expect(log.error).toBeCalledWith("NO_SECRET", expect.any(MissingSecret))
})
it("Show error page if adapter is missing functions when using with email", async () => {
const sendVerificationRequest = jest.fn()
const missingFunctionAdapter: any = {}
const { res, log } = await handler(
{
adapter: missingFunctionAdapter,
providers: [EmailProvider({ sendVerificationRequest })],
secret: "secret",
},
{ prod: true }
)
expect(res.status).toBe(500)
expect(res.html).toMatch(/there is a problem with the server configuration./i)
expect(res.html).toMatch(/check the server logs for more information./i)
expect(log.error).toBeCalledWith(
"MISSING_ADAPTER_METHODS_ERROR",
expect.any(MissingAdapterMethods)
)
})
it("Show error page if adapter is not configured when using with email", async () => {
const sendVerificationRequest = jest.fn()
const { res, log } = await handler(
{
providers: [EmailProvider({ sendVerificationRequest })],
secret: "secret",
},
{ prod: true }
)
expect(res.status).toBe(500)
expect(res.html).toMatch(/there is a problem with the server configuration./i)
expect(res.html).toMatch(/check the server logs for more information./i)
expect(log.error).toBeCalledWith(
"EMAIL_REQUIRES_ADAPTER_ERROR",
expect.any(MissingAdapter)
)
})
it("Should show configuration error page on invalid `callbackUrl`", async () => {
const { res, log } = await handler(
{ providers: [] },

View File

@@ -156,6 +156,7 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => {
expect(signIn).toBeCalledTimes(0)
expect(sendVerificationRequest).toBeCalledTimes(0)
// @ts-expect-error
expect(log.error.mock.calls[0]).toEqual([
"SIGNIN_EMAIL_ERROR",
{ error, providerId: "email" },

View File

@@ -59,10 +59,10 @@ export function createCSRF() {
}
export function mockAdapter(): Adapter {
// @ts-expect-error
const adapter: Adapter = {
createVerificationToken: jest.fn(() => {}),
useVerificationToken: jest.fn(() => {}),
getUserByEmail: jest.fn(() => {}),
}
return adapter;
return adapter
}

View File

@@ -38,3 +38,58 @@ it("should not redirect on public paths", async () => {
const res = await handleMiddleware(req, null as any)
expect(res).toBeUndefined()
})
it("should redirect according to nextUrl basePath", async () => {
const options: NextAuthMiddlewareOptions = {
secret: "secret"
}
const nextUrl: any = {
pathname: "/protected/pathA",
search: "",
origin: "http://127.0.0.1",
basePath: "/custom-base-path",
}
const req: any = { nextUrl, headers: { authorization: "" } }
const handleMiddleware = withAuth(options) as NextMiddleware
const res = await handleMiddleware(req, null as any)
expect(res).toBeDefined()
expect(res.status).toEqual(307)
expect(res.headers.get('location')).toContain("http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA")
})
it("should redirect according to nextUrl basePath", async () => {
// given
const options: NextAuthMiddlewareOptions = {
secret: "secret"
}
const handleMiddleware = withAuth(options) as NextMiddleware
// when
const res = await handleMiddleware({
nextUrl: {
pathname: "/protected/pathA",
search: "",
origin: "http://127.0.0.1",
basePath: "/custom-base-path"
}, headers: { authorization: "" }
} as any, null as any)
// then
expect(res).toBeDefined()
expect(res.status).toEqual(307)
expect(res.headers.get("location")).toContain("http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA")
// and when follow redirect
const resFromRedirectedUrl = await handleMiddleware({
nextUrl: {
pathname: "/api/auth/signin",
search: "callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA",
origin: "http://127.0.0.1",
basePath: "/custom-base-path"
}, headers: { authorization: "" }
} as any, null as any)
// then return sign in page
expect(resFromRedirectedUrl).toBeUndefined()
})