diff --git a/examples/actions/schema.ts b/examples/actions/schema.ts index 33263c0cac0..46ae91ef5d0 100644 --- a/examples/actions/schema.ts +++ b/examples/actions/schema.ts @@ -28,7 +28,7 @@ export const lists = { actions: { vote: { access: ({ context }) => { - const ua = context.req?.headers['user-agent'] ?? '' + const ua = context.req?.headers.get('user-agent') ?? '' // only allow voting from Chrome browsers return ua.includes('Chrome') }, diff --git a/examples/auth-magic-link/keystone.ts b/examples/auth-magic-link/keystone.ts index c1c33816a1b..a17e50f7576 100644 --- a/examples/auth-magic-link/keystone.ts +++ b/examples/auth-magic-link/keystone.ts @@ -1,21 +1,12 @@ import { config } from '@keystone-6/core' -import { statelessSessions } from '@keystone-6/core/session' import { createAuth } from '@keystone-6/auth' -import { lists, extendGraphqlSchema } from './schema' +import { lists, extendGraphqlSchema, sessionStrategy } from './schema' import type { TypeInfo } from '.keystone/types' // WARNING: this example is for demonstration purposes only // as with each of our examples, it has not been vetted // or tested for any particular usage -// WARNING: you need to change this -const sessionSecret = '-- DEV COOKIE SECRET; CHANGE ME --' - -// statelessSessions uses cookies for session tracking -// these cookies have an expiry, in seconds -// we use an expiry of one hour for this example -const sessionMaxAge = 60 * 60 - // withAuth is a function we can use to wrap our base configuration const { withAuth } = createAuth({ // this is the list that contains our users @@ -36,6 +27,11 @@ const { withAuth } = createAuth({ // the following fields are used by the "Create First User" form fields: ['name', 'password'], }, + sessionStrategy, + getSession: ({ context, data }) => + context.query.User.findOne({ + where: { id: data.itemId }, + }), }) export default withAuth( @@ -51,12 +47,5 @@ export default withAuth( graphql: { extendGraphqlSchema, }, - // you can find out more at https://keystonejs.com/docs/apis/session#session-api - session: statelessSessions({ - // the maxAge option controls how long session cookies are valid for before they expire - maxAge: sessionMaxAge, - // the session secret is used to encrypt cookie data - secret: sessionSecret, - }), }) ) diff --git a/examples/auth-magic-link/schema.ts b/examples/auth-magic-link/schema.ts index 67a3dda3ccb..2b33178311f 100644 --- a/examples/auth-magic-link/schema.ts +++ b/examples/auth-magic-link/schema.ts @@ -4,14 +4,14 @@ import { password, text, timestamp } from '@keystone-6/core/fields' import type { Lists, Context, Session } from '.keystone/types' import { randomBytes } from 'node:crypto' +import { statelessSessions } from '@keystone-6/auth' const g = gWithContext() type g = gWithContext.infer declare module '.keystone/types' { interface Session { - itemId: string - listKey: string + id: string } } @@ -19,6 +19,21 @@ function hasSession({ session }: { session?: Session }) { return Boolean(session) } +// WARNING: you need to change this +const sessionSecret = '-- DEV COOKIE SECRET; CHANGE ME --' + +// statelessSessions uses cookies for session tracking +// these cookies have an expiry, in seconds +// we use an expiry of one hour for this example +const sessionMaxAge = 60 * 60 + +export const sessionStrategy = statelessSessions({ + // the maxAge option controls how long session cookies are valid for before they expire + maxAge: sessionMaxAge, + // the session secret is used to encrypt cookie data + secret: sessionSecret, +}) + function isSameUserFilter({ session }: { session?: Session }) { // you need to have a session to do this if (!session) return false @@ -26,7 +41,7 @@ function isSameUserFilter({ session }: { session?: Session }) { // only yourself return { id: { - equals: session.itemId, + equals: session.id, }, } } @@ -148,9 +163,6 @@ export const extendGraphqlSchema = g.extend(base => { }, async resolve(args, { userId, token }, context) { - if (!context.sessionStrategy) - throw new Error('No session implementation available on context') - const kdf = (base.schema.getType('User') as any).getFields()?.password.extensions ?.keystoneSecretField const sudoContext = context.sudo() @@ -176,10 +188,9 @@ export const extendGraphqlSchema = g.extend(base => { }, }) - await context.sessionStrategy.start({ + await sessionStrategy.start({ context, data: { - listKey: 'User', itemId: userId, }, }) diff --git a/examples/auth/keystone.ts b/examples/auth/keystone.ts index 73ce4c6b3d2..9a66e8dcf63 100644 --- a/examples/auth/keystone.ts +++ b/examples/auth/keystone.ts @@ -1,6 +1,5 @@ import { config } from '@keystone-6/core' -import { statelessSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, statelessSessions } from '@keystone-6/auth' import { lists } from './schema' import type { TypeInfo } from '.keystone/types' @@ -42,9 +41,19 @@ const { withAuth } = createAuth({ isAdmin: true, }, }, - - // add isAdmin to the session data - sessionData: 'isAdmin', + sessionStrategy: statelessSessions({ + // the maxAge option controls how long session cookies are valid for before they expire + maxAge: sessionMaxAge, + // the session secret is used to encrypt cookie data + secret: sessionSecret, + }), + async getSession({ context, data }) { + const user = await context.db.User.findOne({ + where: { id: data.itemId }, + }) + if (!user) return + return { user } + }, }) export default withAuth( @@ -60,15 +69,8 @@ export default withAuth( ui: { // only admins can view the AdminUI isAccessAllowed: context => { - return context.session?.data?.isAdmin ?? false + return context.session?.user.isAdmin ?? false }, }, - // you can find out more at https://keystonejs.com/docs/apis/session#session-api - session: statelessSessions({ - // the maxAge option controls how long session cookies are valid for before they expire - maxAge: sessionMaxAge, - // the session secret is used to encrypt cookie data - secret: sessionSecret, - }), }) ) diff --git a/examples/auth/schema.ts b/examples/auth/schema.ts index 52d817d0909..8fff816a63d 100644 --- a/examples/auth/schema.ts +++ b/examples/auth/schema.ts @@ -8,10 +8,7 @@ import type { Lists, Session } from '.keystone/types' // or tested for any particular usage declare module '.keystone/types' { interface Session { - itemId: string - data: { - isAdmin: boolean - } + user: Lists.User.Item } } @@ -24,13 +21,13 @@ function isAdminOrSameUser({ session, item }: { session?: Session; item: Lists.U if (!session) return false // admins can do anything - if (session.data.isAdmin) return true + if (session.user.isAdmin) return true // no item? then no if (!item) return false // the authenticated user needs to be equal to the user we are updating - return session.itemId === item.id + return session.user.id === item.id } function isAdminOrSameUserFilter({ session }: { session?: Session }) { @@ -38,12 +35,12 @@ function isAdminOrSameUserFilter({ session }: { session?: Session }) { if (!session) return false // admins can see everything - if (session.data?.isAdmin) return {} + if (session.user.isAdmin) return {} // only yourself return { id: { - equals: session.itemId, + equals: session.user.id, }, } } @@ -53,7 +50,7 @@ function isAdmin({ session }: { session?: Session }) { if (!session) return false // admins can do anything - if (session.data.isAdmin) return true + if (session.user.isAdmin) return true // otherwise, no return false diff --git a/examples/custom-session-invalidation/keystone.ts b/examples/custom-session-invalidation/keystone.ts index 698f278c783..afe1f692ff8 100644 --- a/examples/custom-session-invalidation/keystone.ts +++ b/examples/custom-session-invalidation/keystone.ts @@ -1,15 +1,15 @@ import { config } from '@keystone-6/core' -import { statelessSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, SessionStrategy, statelessSessions } from '@keystone-6/auth' import { lists } from './schema' -import type { Config, Context, TypeInfo, Session } from '.keystone/types' +import type { TypeInfo, Lists } from '.keystone/types' +import type { BaseKeystoneTypeInfo } from '@keystone-6/core/types' // WARNING: this example is for demonstration purposes only // as with each of our examples, it has not been vetted // or tested for any particular usage // withAuth is a function we can use to wrap our base configuration -const { withAuth } = createAuth({ +const { withAuth } = createAuth({ // this is the list that contains our users listKey: 'User', @@ -29,55 +29,57 @@ const { withAuth } = createAuth({ fields: ['name', 'password'], }, - sessionData: 'passwordChangedAt', + sessionStrategy: withSessionStartedAt(statelessSessions()), + async getSession({ context, data }) { + const user = await context.db.User.findOne({ + where: { id: data.itemId }, + }) + if (!user) return + if (user.passwordChangedAt && user.passwordChangedAt > new Date(data.startedAt)) { + return + } + return { user } + }, }) -function withSessionInvalidation(config: Config): Config { - const existingSessionStrategy = config.session! - +function withSessionStartedAt( + existingSessionStrategy: SessionStrategy< + T & { startedAt: number }, + T & { startedAt: number }, + TypeInfo + > +): SessionStrategy { return { - ...config, - session: { - ...existingSessionStrategy, - async get({ context }: { context: Context }): Promise { - const session = await existingSessionStrategy.get({ context }) - if (!session) return - - // has the password changed since the session started? - if (new Date(session.data.passwordChangedAt) > new Date(session.startedAt)) { - // invalidate the session if password changed - await existingSessionStrategy.end({ context }) - return - } - - return session - }, - async start({ context, data }: { context: Context; data: Session }) { - return await existingSessionStrategy.start({ - context, - data: { - ...data, - startedAt: Date.now(), - }, - }) - }, + async start({ context, data }) { + await existingSessionStrategy.start({ + context, + data: { ...data, startedAt: Date.now() }, + }) + }, + async get({ context }) { + const session = await existingSessionStrategy.get({ context }) + if ( + !session || + typeof session !== 'object' || + !('startedAt' in session) || + typeof session.startedAt !== 'number' + ) + return + return { ...session, startedAt: session.startedAt } }, + end: existingSessionStrategy.end, } } -export default withSessionInvalidation( - withAuth( - config({ - db: { - provider: 'sqlite', - url: process.env.DATABASE_URL || 'file:./keystone-example.db', +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', - // WARNING: this is only needed for our monorepo examples, dont do this - prismaClientPath: 'node_modules/myprisma', - }, - lists, - // you can find out more at https://keystonejs.com/docs/apis/session#session-api - session: statelessSessions(), - }) - ) + // WARNING: this is only needed for our monorepo examples, dont do this + prismaClientPath: 'node_modules/myprisma', + }, + lists, + }) ) diff --git a/examples/custom-session-invalidation/schema.ts b/examples/custom-session-invalidation/schema.ts index a9c0288352b..34c78aace1f 100644 --- a/examples/custom-session-invalidation/schema.ts +++ b/examples/custom-session-invalidation/schema.ts @@ -10,12 +10,7 @@ import type { Lists, Session } from '.keystone/types' // needs to be compatible with withAuth declare module '.keystone/types' { interface Session { - listKey: string - itemId: string - data: { - passwordChangedAt: string - } - startedAt: number + user: Lists.User.Item } } @@ -30,7 +25,7 @@ function isSameUserFilter({ session }: { session?: Session }) { // the authenticated user can only see themselves return { id: { - equals: session.itemId, + equals: session.user.id, }, } } diff --git a/examples/custom-session-jwt/keystone.ts b/examples/custom-session-jwt/keystone.ts index 7c83edfac2b..0925f94681e 100644 --- a/examples/custom-session-jwt/keystone.ts +++ b/examples/custom-session-jwt/keystone.ts @@ -48,32 +48,23 @@ async function jwtVerify(token: string): Promise { }) } -const jwtSessionStrategy = { - async get({ context }: { context: Context }): Promise { - if (!context.req) return +async function jwtSessionStrategy({ context }: { context: Context }): Promise { + if (!context.req) return - const { cookie = '' } = context.req.headers - const [cookieName, jwt] = cookie.split('=') - if (cookieName !== 'user') return + const cookie = context.req.headers.get('cookie') ?? '' + const [cookieName, jwt] = cookie.split('=') + if (cookieName !== 'user') return - const jwtResult = await jwtVerify(jwt) - if (!jwtResult) return + const jwtResult = await jwtVerify(jwt) + if (!jwtResult) return - const { id } = jwtResult - const who = await context.sudo().db.User.findOne({ where: { id } }) - if (!who) return - return { - id, - admin: who.admin, - } - }, - - // we don't need these unless we want to support the functions - // context.sessionStrategy.start - // context.sessionStrategy.end - // - async start() {}, - async end() {}, + const { id } = jwtResult + const who = await context.sudo().db.User.findOne({ where: { id } }) + if (!who) return + return { + id, + admin: who.admin, + } } export default config({ diff --git a/examples/custom-session-next-auth/session.ts b/examples/custom-session-next-auth/session.ts index 05cf6fa9dea..3c37fd892ab 100644 --- a/examples/custom-session-next-auth/session.ts +++ b/examples/custom-session-next-auth/session.ts @@ -46,38 +46,32 @@ declare module '.keystone/types' { } } -export const nextAuthSessionStrategy = { - async get({ context }: { context: Context }) { - const { req, res } = context - const { headers } = req ?? {} - if (!headers?.cookie || !res) return +export async function nextAuthSessionStrategy({ context }: { context: Context }) { + if (!context.req || !context.res || !context.req.nodeReq) return + const cookie = context.req.headers.get('cookie') + if (!cookie) return - // next-auth needs a different cookies structure - const cookies: Record = {} - for (const part of headers.cookie.split(';')) { - const [key, value] = part.trim().split('=') - cookies[key] = decodeURIComponent(value) - } - - const nextAuthSession = await getServerSession( - { headers, cookies } as any, - res, - nextAuthOptions - ) - if (!nextAuthSession) return + // next-auth needs a different cookies structure + const cookies: Record = {} + for (const part of cookie.split(';')) { + const [key, value] = part.trim().split('=') + cookies[key] = decodeURIComponent(value) + } - const { authId } = nextAuthSession.keystone - if (!authId) return + const nextAuthSession = await getServerSession( + { headers: context.req.nodeReq.headers, cookies } as any, + { getHeader() {}, setCookie() {}, setHeader() {} } as any, + nextAuthOptions + ) + if (!nextAuthSession) return - const author = await context.sudo().db.Author.findOne({ - where: { authId }, - }) - if (!author) return + const { authId } = nextAuthSession.keystone + if (!authId) return - return { id: author.id } - }, + const author = await context.sudo().db.Author.findOne({ + where: { authId }, + }) + if (!author) return - // we don't need these as next-auth handle start and end for us - async start() {}, - async end() {}, + return { id: author.id } } diff --git a/examples/custom-session-passport/auth.ts b/examples/custom-session-passport/auth.ts index cea6643c270..8f9b95d8d9b 100644 --- a/examples/custom-session-passport/auth.ts +++ b/examples/custom-session-passport/auth.ts @@ -1,5 +1,5 @@ import { Router } from 'express' -import { statelessSessions } from '@keystone-6/core/session' +import expressSession from 'express-session' import { type KeystoneContext } from '@keystone-6/core/types' import { Passport } from 'passport' @@ -7,23 +7,22 @@ import { type VerifyCallback } from 'passport-oauth2' import { Strategy, type StrategyOptions, type Profile } from 'passport-github2' import { type Author } from 'myprisma' -import type { Session, TypeInfo } from '.keystone/types' +import type { Context, TypeInfo } from '.keystone/types' declare module '.keystone/types' { export interface Session extends Author {} } -export const session = statelessSessions({ - maxAge: 60 * 60 * 24 * 30, - secret: process.env.SESSION_SECRET!, -}) declare global { namespace Express { - // Augment the global user added by Passport to be the same as the Prisma Author interface User extends Author {} } } +export async function session({ context }: { context: Context }) { + return (context.req?.nodeReq as any)?.user as Author +} + const options: StrategyOptions = { // see https://github.com/settings/applications/new clientID: process.env.GITHUB_CLIENT_ID!, @@ -33,7 +32,7 @@ const options: StrategyOptions = { export function passportMiddleware(commonContext: KeystoneContext): Router { const router = Router() - const instance = new Passport() + const passport = new Passport() const strategy = new Strategy( options, async (_a: string, _r: string, profile: Profile, done: VerifyCallback) => { @@ -47,35 +46,41 @@ export function passportMiddleware(commonContext: KeystoneContext): Ro } ) - instance.use(strategy) - const middleware = instance.authenticate('github', { - session: false, // dont use express-session + passport.serializeUser((user, done) => { + done(null, user.id) }) - - router.get('/auth/github', middleware) - router.get('/auth/github/callback', middleware, async (req, res) => { - if (!req.user) { - res.status(401).send('Authentication failed') + passport.deserializeUser(async (user, done) => { + if (typeof user !== 'string') { + done(null) return } - - const context = await commonContext.withRequest(req, res) - - // starts the session, and sets the cookie on context.res - await context.sessionStrategy?.start({ - context, - data: req.user, + const author = await commonContext.prisma.author.findUnique({ + where: { id: user }, }) + if (!author) { + return done(null) + } + return done(null, author) + }) - res.redirect('/auth/session') + passport.use(strategy) + router.use( + expressSession({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }) + ) + router.use(passport.authenticate('session')) + const middleware = passport.authenticate('github', { + successRedirect: '/auth/session', }) + router.get('/auth/github', middleware) + router.get('/auth/github/callback', middleware) + // show the current session object // WARNING: this is for demonstration purposes only, probably dont do this router.get('/auth/session', async (req, res) => { - const context = await commonContext.withRequest(req, res) - const session = await context.sessionStrategy?.get({ context }) - + const context = await commonContext.withNodeRequest(req) + const session = context.session + console.log('session', req.session) res.setHeader('Content-Type', 'application/json') res.send(JSON.stringify(session)) res.end() diff --git a/examples/custom-session-passport/package.json b/examples/custom-session-passport/package.json index 64e4f272fca..15db71a3cbf 100644 --- a/examples/custom-session-passport/package.json +++ b/examples/custom-session-passport/package.json @@ -14,11 +14,13 @@ "@prisma/client": "6.5.0", "dotenv": "^16.0.0", "express": "^4.19.2", + "express-session": "^1.18.1", "passport": "^0.7.0", "passport-github2": "^0.1.12" }, "devDependencies": { "@types/express": "^4.17.14", + "@types/express-session": "^1.18.1", "@types/passport": "^1.0.16", "@types/passport-github2": "^1.2.9", "@types/passport-oauth2": "^1.4.16", diff --git a/examples/custom-session-redis/keystone.ts b/examples/custom-session-redis/keystone.ts index dc37c3954e0..a70751fbf99 100644 --- a/examples/custom-session-redis/keystone.ts +++ b/examples/custom-session-redis/keystone.ts @@ -1,16 +1,15 @@ import { config } from '@keystone-6/core' -import { storedSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, storedSessions } from '@keystone-6/auth' import { createClient } from '@redis/client' import { lists } from './schema' -import type { TypeInfo, Session } from '.keystone/types' +import type { TypeInfo, Lists } from '.keystone/types' // WARNING: this example is for demonstration purposes only // as with each of our examples, it has not been vetted // or tested for any particular usage // withAuth is a function we can use to wrap our base configuration -const { withAuth } = createAuth({ +const { withAuth } = createAuth({ // this is the list that contains our users listKey: 'User', @@ -29,11 +28,17 @@ const { withAuth } = createAuth({ // the following fields are used by the "Create First User" form fields: ['name', 'password'], }, + sessionStrategy: redisSessionStrategy(), + getSession: async ({ context, data }) => { + const user = await context.db.User.findOne({ where: { id: data.itemId } }) + if (!user) return + return { itemId: user.id } + }, }) const redis = createClient() -function redisSessionStrategy() { +function redisSessionStrategy() { // you can find out more at https://keystonejs.com/docs/apis/session#session-api return storedSessions({ store: ({ maxAge }) => ({ @@ -69,6 +74,5 @@ export default withAuth( prismaClientPath: 'node_modules/myprisma', }, lists, - session: redisSessionStrategy(), }) ) diff --git a/examples/custom-session-redis/schema.ts b/examples/custom-session-redis/schema.ts index 6e5a5a4d77c..c81d607b4ab 100644 --- a/examples/custom-session-redis/schema.ts +++ b/examples/custom-session-redis/schema.ts @@ -10,11 +10,7 @@ import type { Lists, Session } from '.keystone/types' // needs to be compatible with withAuth declare module '.keystone/types' { interface Session { - listKey: string itemId: string - data: { - something: string - } } } diff --git a/examples/custom-session/keystone.ts b/examples/custom-session/keystone.ts index 4a78bc0203d..053b72e4832 100644 --- a/examples/custom-session/keystone.ts +++ b/examples/custom-session/keystone.ts @@ -2,33 +2,28 @@ import { config } from '@keystone-6/core' import { lists } from './schema' import type { Context, TypeInfo, Session } from '.keystone/types' -const sillySessionStrategy = { - async get({ context }: { context: Context }): Promise { - if (!context.req) return +async function sillySessionStrategy({ + context, +}: { + context: Context +}): Promise { + if (!context.req) return - // WARNING: for demonstrative purposes only, this has no authentication - // use `Cookie:user=clh9v6pcn0000sbhm9u0j6in0` for Alice (admin) - // use `Cookie:user=clh9v762w0002sbhmhhyc0340` for Bob - // - // in practice, you should use authentication for your sessions, such as OAuth or JWT - const { cookie = '' } = context.req.headers - const [cookieName, id] = cookie.split('=') - if (cookieName !== 'user') return - - const who = await context.sudo().db.User.findOne({ where: { id } }) - if (!who) return - return { - id, - admin: who.admin, - } - }, - - // we don't need these unless we want to support the functions - // context.sessionStrategy.start - // context.sessionStrategy.end + // WARNING: for demonstrative purposes only, this has no authentication + // use `Cookie:user=clh9v6pcn0000sbhm9u0j6in0` for Alice (admin) + // use `Cookie:user=clh9v762w0002sbhmhhyc0340` for Bob // - async start() {}, - async end() {}, + // in practice, you should use authentication for your sessions, such as OAuth or JWT + const cookie = context.req.headers.get('cookie') ?? '' + const [cookieName, id] = cookie.split('=') + if (cookieName !== 'user') return + + const who = await context.sudo().db.User.findOne({ where: { id } }) + if (!who) return + return { + id, + admin: who.admin, + } } export default config({ diff --git a/examples/extend-express-app/keystone.ts b/examples/extend-express-app/keystone.ts index 96b584128e0..dbbe227900a 100644 --- a/examples/extend-express-app/keystone.ts +++ b/examples/extend-express-app/keystone.ts @@ -26,7 +26,7 @@ export default config({ // http://localhost:3000/rest/posts?draft=1 // app.get('/rest/posts', async (req, res) => { - const context = await commonContext.withRequest(req, res) + const context = await commonContext.withNodeRequest(req) // if (!context.session) return res.status(401).end() const isDraft = req.query?.draft === '1' @@ -56,7 +56,7 @@ export default config({ // this example HTTP GET handler retrieves a post in the database for your context // returning it as JSON - const context = await commonContext.withRequest(req, res) + const context = await commonContext.withNodeRequest(req) // if (!context.session) return res.status(401).end() const task = await context.query.Post.findOne({ diff --git a/examples/framework-nextjs-app-directory/keystone.db b/examples/framework-nextjs-app-directory/keystone.db index 7083cc1a240..adf8acd2406 100644 Binary files a/examples/framework-nextjs-app-directory/keystone.db and b/examples/framework-nextjs-app-directory/keystone.db differ diff --git a/examples/framework-nextjs-app-directory/keystone.ts b/examples/framework-nextjs-app-directory/keystone.ts index bdcebbbc7c8..6d02b1ee28c 100644 --- a/examples/framework-nextjs-app-directory/keystone.ts +++ b/examples/framework-nextjs-app-directory/keystone.ts @@ -1,18 +1,42 @@ import { config } from '@keystone-6/core' +import { createAuth, statelessSessions } from '@keystone-6/auth' import { lists } from './src/keystone/schema' import { seedDemoData } from './src/keystone/seed' -import type { Context } from '.keystone/types' +import type { Context, Lists } from '.keystone/types' -export default config({ - db: { - provider: 'sqlite', - url: `file:${process.cwd()}/keystone.db`, // next.js requires an absolute path for sqlite - onConnect: async (context: Context) => { - await seedDemoData(context) - }, +declare module '.keystone/types' { + interface Session { + user: Lists.User.Item + } +} - // WARNING: this is only needed for our monorepo examples, dont do this - prismaClientPath: 'node_modules/myprisma', +const { withAuth } = createAuth({ + listKey: 'User', + identityField: 'email', + secretField: 'password', + sessionStrategy: statelessSessions(), + getSession: async ({ data, context }) => { + const user = await context.db.User.findOne({ where: { id: data.itemId } }) + if (!user) return + return { user } }, - lists, }) + +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: `file:${process.cwd()}/keystone.db`, // next.js requires an absolute path for sqlite + onConnect: async (context: Context) => { + await seedDemoData(context) + }, + + // WARNING: this is only needed for our monorepo examples, dont do this + prismaClientPath: 'node_modules/myprisma', + }, + lists, + ui: { + isAccessAllowed: ({ session }) => session !== undefined, + }, + }) +) diff --git a/examples/framework-nextjs-app-directory/package.json b/examples/framework-nextjs-app-directory/package.json index bda2df82ed8..b35f8934b7a 100644 --- a/examples/framework-nextjs-app-directory/package.json +++ b/examples/framework-nextjs-app-directory/package.json @@ -22,7 +22,8 @@ "@prisma/client": "6.5.0", "graphql": "^16.8.1", "graphql-request": "^5.0.0", - "graphql-yoga": "^3.1.0", + "@apollo/server": "^4.11.3", + "@as-integrations/next": "^3.2.0", "next": "^15.5.2", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/examples/framework-nextjs-app-directory/schema.graphql b/examples/framework-nextjs-app-directory/schema.graphql index f102cd09bcb..5cb224c8b52 100644 --- a/examples/framework-nextjs-app-directory/schema.graphql +++ b/examples/framework-nextjs-app-directory/schema.graphql @@ -4,10 +4,16 @@ type User { id: ID! name: String + email: String + password: PasswordState about: User_about_Document createdAt: DateTime } +type PasswordState { + isSet: Boolean! +} + type User_about_Document { document(hydrateRelationships: Boolean! = false): JSON! } @@ -16,6 +22,7 @@ scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339 input UserWhereUniqueInput { id: ID + email: String } input UserWhereInput { @@ -24,6 +31,8 @@ input UserWhereInput { NOT: [UserWhereInput!] id: IDFilter name: StringFilter + email: StringFilter + password: PasswordFilter createdAt: DateTimeNullableFilter } @@ -66,6 +75,10 @@ input NestedStringFilter { not: NestedStringFilter } +input PasswordFilter { + isSet: Boolean! +} + input DateTimeNullableFilter { equals: DateTime in: [DateTime!] @@ -80,6 +93,7 @@ input DateTimeNullableFilter { input UserOrderByInput { id: OrderDirection name: OrderDirection + email: OrderDirection createdAt: OrderDirection } @@ -90,6 +104,8 @@ enum OrderDirection { input UserUpdateInput { name: String + email: String + password: String about: JSON createdAt: DateTime } @@ -101,6 +117,8 @@ input UserUpdateArgs { input UserCreateInput { name: String + email: String + password: String about: JSON createdAt: DateTime } @@ -117,6 +135,19 @@ type Mutation { updateUsers(data: [UserUpdateArgs!]!): [User] deleteUser(where: UserWhereUniqueInput!): User deleteUsers(where: [UserWhereUniqueInput!]!): [User] + endSession: Boolean! + authenticateUserWithPassword(email: String!, password: String!): UserAuthenticationWithPasswordResult +} + +union UserAuthenticationWithPasswordResult = UserAuthenticationWithPasswordSuccess | UserAuthenticationWithPasswordFailure + +type UserAuthenticationWithPasswordSuccess { + sessionToken: String! + item: User! +} + +type UserAuthenticationWithPasswordFailure { + message: String! } type Query { @@ -124,6 +155,7 @@ type Query { users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] usersCount(where: UserWhereInput! = {}): Int keystone: KeystoneMeta! + authenticatedItem: User } type KeystoneMeta { diff --git a/examples/framework-nextjs-app-directory/schema.prisma b/examples/framework-nextjs-app-directory/schema.prisma index 4a1df607ced..0b5f7e0fcb9 100644 --- a/examples/framework-nextjs-app-directory/schema.prisma +++ b/examples/framework-nextjs-app-directory/schema.prisma @@ -15,6 +15,8 @@ generator client { model User { id String @id @default(cuid()) name String @default("") + email String @unique @default("") + password String? about Json createdAt DateTime? @default(now()) } diff --git a/examples/framework-nextjs-app-directory/src/app/api/graphql/route.ts b/examples/framework-nextjs-app-directory/src/app/api/graphql/route.ts new file mode 100644 index 00000000000..7e6b6dafaba --- /dev/null +++ b/examples/framework-nextjs-app-directory/src/app/api/graphql/route.ts @@ -0,0 +1,39 @@ +import { ApolloServer } from '@apollo/server' +import { startServerAndCreateNextHandler } from '@as-integrations/next' +import { keystoneContext } from '../../../keystone/context' +import { NextRequest } from 'next/server' + +const server = new ApolloServer({ + schema: keystoneContext.graphql.schema, +}) + +const _handler = startServerAndCreateNextHandler(server, { + context: async (req: NextRequest) => { + const resHeaders = new Headers() + requestsToResponseHeaders.set(req, resHeaders) + const context = await keystoneContext.withRequest( + { headers: req.headers }, + { headers: resHeaders } + ) + return context + }, +}) + +const requestsToResponseHeaders = new WeakMap() + +const handler = async (req: NextRequest) => { + const res = await _handler(req) + const headers = requestsToResponseHeaders.get(req) + if (headers) { + for (const [key, value] of headers.entries()) { + if (key === 'set-cookie') { + res.headers.append(key, value) + } else { + res.headers.set(key, value) + } + } + } + return res +} + +export { handler as GET, handler as POST } diff --git a/examples/framework-nextjs-app-directory/src/app/page.tsx b/examples/framework-nextjs-app-directory/src/app/page.tsx index d75096c0b3a..5c35edbc100 100644 --- a/examples/framework-nextjs-app-directory/src/app/page.tsx +++ b/examples/framework-nextjs-app-directory/src/app/page.tsx @@ -1,12 +1,11 @@ +import { headers } from 'next/headers' import { keystoneContext } from '../keystone/context' import { DocumentRender } from './DocumentRender' export default async function HomePage() { - // WARNING: this does nothing for now - // you will probably use getServerSession from 'next/auth' - // https://next-auth.js.org/configuration/nextjs#in-app-directory - const session = {} - const users = await keystoneContext.withSession(session).query.User.findMany({ + const users = await ( + await keystoneContext.withRequest({ headers: new Headers(await headers()) }) + ).query.User.findMany({ query: 'id name about { document }', }) diff --git a/examples/framework-nextjs-app-directory/src/keystone/schema.ts b/examples/framework-nextjs-app-directory/src/keystone/schema.ts index 2ee6405f484..e64274ac484 100644 --- a/examples/framework-nextjs-app-directory/src/keystone/schema.ts +++ b/examples/framework-nextjs-app-directory/src/keystone/schema.ts @@ -1,6 +1,6 @@ import { list } from '@keystone-6/core' import { allowAll } from '@keystone-6/core/access' -import { text, timestamp } from '@keystone-6/core/fields' +import { password, text, timestamp } from '@keystone-6/core/fields' import { document } from '@keystone-6/fields-document' import { type Lists } from '.keystone/types' @@ -15,6 +15,11 @@ export const lists = { fields: { name: text({ validation: { isRequired: true } }), + email: text({ + validation: { isRequired: true }, + isIndexed: 'unique', + }), + password: password(), about: document({ formatting: true, dividers: true, diff --git a/examples/framework-nextjs-app-directory/src/pages/api/graphql.ts b/examples/framework-nextjs-app-directory/src/pages/api/graphql.ts deleted file mode 100644 index 031f9bb77d7..00000000000 --- a/examples/framework-nextjs-app-directory/src/pages/api/graphql.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createYoga } from 'graphql-yoga' -import { type NextApiRequest, type NextApiResponse } from 'next' -import { keystoneContext } from '../../keystone/context' - -/* - An example of how to setup your own yoga graphql server - using the generated Keystone GraphQL schema. -*/ -export const config = { - api: { - // Disable body parsing (required for file uploads) - bodyParser: false, - }, -} - -// Use Keystone API to create GraphQL handler -export default createYoga<{ - req: NextApiRequest - res: NextApiResponse -}>({ - graphqlEndpoint: '/api/graphql', - schema: keystoneContext.graphql.schema, - /* - `keystoneContext` object doesn't have user's session information. - You need an authenticated context to CRUD data behind access control. - keystoneContext.withRequest(req, res) automatically unwraps the session cookie - in the request object and gives you a `context` object with session info - and an elevated sudo context to bypass access control if needed (context.sudo()). - */ - context: ({ req, res }) => keystoneContext.withRequest(req, res), -}) diff --git a/examples/framework-nextjs-pages-directory/src/pages/api/graphql.ts b/examples/framework-nextjs-pages-directory/src/pages/api/graphql.ts index a3ddba64706..24ac35cf110 100644 --- a/examples/framework-nextjs-pages-directory/src/pages/api/graphql.ts +++ b/examples/framework-nextjs-pages-directory/src/pages/api/graphql.ts @@ -27,5 +27,28 @@ export default createYoga<{ in the request object and gives you a `context` object with session info and an elevated sudo context to bypass access control if needed (context.sudo()). */ - context: ({ req, res }) => keystoneContext.withRequest(req, res), + context: ({ request }) => { + const responseHeaders = new Headers() + requestToResponseHeaders.set(request, responseHeaders) + return keystoneContext.withRequest({ headers: request.headers }, { headers: responseHeaders }) + }, + plugins: [ + { + onResponse({ response, request }) { + const headers = requestToResponseHeaders.get(request) + if (headers) { + for (const [key, value] of headers.entries()) { + if (key === 'set-cookie') { + response.headers.append(key, value) + } else { + response.headers.set(key, value) + } + } + } + }, + }, + ], + fetchAPI: globalThis, }) + +const requestToResponseHeaders = new WeakMap() diff --git a/examples/framework-nextjs-pages-directory/src/pages/index.tsx b/examples/framework-nextjs-pages-directory/src/pages/index.tsx index 3ff29a133ad..6c84809f552 100644 --- a/examples/framework-nextjs-pages-directory/src/pages/index.tsx +++ b/examples/framework-nextjs-pages-directory/src/pages/index.tsx @@ -66,8 +66,14 @@ const Home: NextPage = ({ users }: InferGetServerSidePropsType { - const context = await keystoneContext.withRequest(req, res) +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const context = await keystoneContext.withRequest({ + headers: new Headers( + Object.entries(req.headers).flatMap(([key, value]): [string, string][] => + value ? (Array.isArray(value) ? value.map(inner => [key, inner]) : [[key, value]]) : [] + ) + ), + }) const users = await context.query.User.findMany({ query: 'id name about', }) diff --git a/examples/hooks/schema.ts b/examples/hooks/schema.ts index 1a94ce4e0c9..23ad2e673e3 100644 --- a/examples/hooks/schema.ts +++ b/examples/hooks/schema.ts @@ -103,12 +103,12 @@ export const lists = { resolveInput: { create: ({ context, resolvedData }) => { //resolvedData.createdAt = new Date(); // see createdAt field hook - resolvedData.createdBy = `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` + resolvedData.createdBy = `${context.req?.nodeReq?.socket.remoteAddress} (${context.req?.headers.get('user-agent')})` return resolvedData }, update: ({ context, resolvedData }) => { //resolvedData.updatedAt = new Date(); // see updatedAt field hook - resolvedData.updatedBy = `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` + resolvedData.updatedBy = `${context.req?.nodeReq?.socket.remoteAddress} (${context.req?.headers.get('user-agent')})` return resolvedData }, }, diff --git a/examples/logging/keystone.ts b/examples/logging/keystone.ts index 00cfc85dd39..953944349ad 100644 --- a/examples/logging/keystone.ts +++ b/examples/logging/keystone.ts @@ -44,7 +44,7 @@ export default config({ pino.logger.info( { req: requestContext.contextValue.req - ? { id: requestContext.contextValue.req?.id } + ? { id: requestContext.contextValue.req?.nodeReq?.id } : undefined, responseTime: Date.now() - start, graphql: { diff --git a/examples/reuse/schema.ts b/examples/reuse/schema.ts index e3b1af40aef..3055fbf863a 100644 --- a/examples/reuse/schema.ts +++ b/examples/reuse/schema.ts @@ -47,7 +47,7 @@ function trackingFields() { hooks: { resolveInput: { async create({ context }) { - return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` + return `${context.req?.nodeReq?.socket.remoteAddress} (${context.req?.headers.get('user-agent')})` }, async update() { return undefined @@ -77,7 +77,7 @@ function trackingFields() { // TODO: refined types for the return types // FIXME: CommonFieldConfig need not always be generalised - return `${context.req?.socket.remoteAddress} (${context.req?.headers['user-agent']})` + return `${context.req?.nodeReq?.socket.remoteAddress} (${context.req?.headers.get('user-agent')})` }, }, }), diff --git a/examples/testing/keystone.ts b/examples/testing/keystone.ts index eb8ac308bdf..f23e117145d 100644 --- a/examples/testing/keystone.ts +++ b/examples/testing/keystone.ts @@ -1,6 +1,5 @@ import { config } from '@keystone-6/core' -import { statelessSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, statelessSessions } from '@keystone-6/auth' import { lists } from './schema' import type { TypeInfo } from '.keystone/types' @@ -28,6 +27,13 @@ const { withAuth } = createAuth({ // the following fields are used by the "Create First User" form fields: ['name', 'password'], }, + sessionStrategy: statelessSessions(), + getSession: ({ context, data }) => { + return context.query.User.findOne({ + where: { id: data.itemId }, + query: 'id name', + }) + }, }) export default withAuth( @@ -41,6 +47,5 @@ export default withAuth( }, lists, // you can find out more at https://keystonejs.com/docs/apis/session#session-api - session: statelessSessions(), }) ) diff --git a/examples/usecase-blog-moderated/keystone.ts b/examples/usecase-blog-moderated/keystone.ts index f2732213f0f..e057a9c7177 100644 --- a/examples/usecase-blog-moderated/keystone.ts +++ b/examples/usecase-blog-moderated/keystone.ts @@ -2,36 +2,31 @@ import { config } from '@keystone-6/core' import { lists } from './schema' import type { Context, TypeInfo, Session } from '.keystone/types' -const sillySessionStrategy = { - async get({ context }: { context: Context }): Promise { - if (!context.req) return +async function sillySessionStrategy({ + context, +}: { + context: Context +}): Promise { + if (!context.req) return - // WARNING: for demonstrative purposes only, this has no authentication - // use `Cookie:user=clh9v6pcn0000sbhm9u0j6in0` for Alice (admin) - // use `Cookie:user=clh9v762w0002sbhmhhyc0340` for Bob (moderator) - // use `Cookie:user=clh9v7ahs0004sbhmpx30w85n` for Eve (contributor) - // - // in practice, you should use authentication for your sessions, such as OAuth or JWT - const { cookie = '' } = context.req.headers - const [cookieName, id] = cookie.split('=') - if (cookieName !== 'user') return - - const who = await context.sudo().db.User.findOne({ where: { id } }) - if (!who) return - return { - id, - admin: who.admin, - moderator: who.moderatorId ? { id: who.moderatorId } : null, - contributor: who.contributorId ? { id: who.contributorId } : null, - } - }, - - // we don't need these unless we want to support the functions - // context.sessionStrategy.start - // context.sessionStrategy.end + // WARNING: for demonstrative purposes only, this has no authentication + // use `Cookie:user=clh9v6pcn0000sbhm9u0j6in0` for Alice (admin) + // use `Cookie:user=clh9v762w0002sbhmhhyc0340` for Bob (moderator) + // use `Cookie:user=clh9v7ahs0004sbhmpx30w85n` for Eve (contributor) // - async start() {}, - async end() {}, + // in practice, you should use authentication for your sessions, such as OAuth or JWT + const cookie = context.req.headers.get('cookie') ?? '' + const [cookieName, id] = cookie.split('=') + if (cookieName !== 'user') return + + const who = await context.sudo().db.User.findOne({ where: { id } }) + if (!who) return + return { + id, + admin: who.admin, + moderator: who.moderatorId ? { id: who.moderatorId } : null, + contributor: who.contributorId ? { id: who.contributorId } : null, + } } export default config({ diff --git a/examples/usecase-roles/keystone.ts b/examples/usecase-roles/keystone.ts index d1330f218ab..d1deef20438 100644 --- a/examples/usecase-roles/keystone.ts +++ b/examples/usecase-roles/keystone.ts @@ -1,6 +1,5 @@ import { config } from '@keystone-6/core' -import { statelessSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, statelessSessions } from '@keystone-6/auth' import { lists } from './schema' // WARNING: this example is for demonstration purposes only @@ -47,8 +46,11 @@ const { withAuth } = createAuth({ }, }, }, - - sessionData: ` + sessionStrategy: statelessSessions<{ itemId: string }>(), + getSession: ({ context, data }) => + context.query.User.findOne({ + where: { id: data.itemId }, + query: `id name role { id @@ -61,6 +63,7 @@ const { withAuth } = createAuth({ canManageRoles canUseAdminUI }`, + }), }) export default withAuth( @@ -78,7 +81,5 @@ export default withAuth( return session?.data.role?.canUseAdminUI ?? false }, }, - // you can find out more at https://keystonejs.com/docs/apis/session#session-api - session: statelessSessions(), }) ) diff --git a/packages/auth/package.json b/packages/auth/package.json index 7af9272e90e..0fbe8cc56d8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -32,7 +32,9 @@ "@keystar/ui": "^0.7.16", "fast-deep-equal": "^3.1.3", "graphql": "^16.8.1", - "next": "^15.5.2" + "next": "^15.5.2", + "cookie": "^1.0.0", + "@hapi/iron": "^7.0.0" }, "devDependencies": { "@keystone-6/core": "workspace:^", diff --git a/packages/auth/src/gql/getBaseAuthSchema.ts b/packages/auth/src/gql/getBaseAuthSchema.ts index 0f1218bd79d..226756191ff 100644 --- a/packages/auth/src/gql/getBaseAuthSchema.ts +++ b/packages/auth/src/gql/getBaseAuthSchema.ts @@ -3,24 +3,29 @@ import { g } from '@keystone-6/core' import { getPasswordFieldKDF } from '@keystone-6/core/fields/types/password' import type { AuthGqlNames } from '../types' import type { BaseSchemaMeta } from '@keystone-6/core/graphql-ts' +import type { SessionStrategy } from '../session' const AUTHENTICATION_FAILURE = { code: 'FAILURE', message: 'Authentication failed.', } as const +export const sessionToItemId = new WeakMap() + export function getBaseAuthSchema({ authGqlNames, listKey, identityField, secretField, base, + sessionStrategy, }: { authGqlNames: AuthGqlNames listKey: string identityField: I secretField: S base: BaseSchemaMeta + sessionStrategy: SessionStrategy<{ itemId: string }, unknown> }) { const kdf = getPasswordFieldKDF(base.schema, listKey, secretField) if (!kdf) { @@ -58,13 +63,15 @@ export function getBaseAuthSchema({ type: base.object(listKey), resolve(rootVal, args, context: KeystoneContext) { const { session } = context - if (!session?.itemId) return null + if (!session) return null - return context.db[listKey].findOne({ - where: { - id: session.itemId, - }, - }) + let id + try { + id = sessionToItemId.get(session) + } catch {} + if (!id) return null + + return context.db[listKey].findOne({ where: { id } }) }, }), }, @@ -72,7 +79,7 @@ export function getBaseAuthSchema({ endSession: g.field({ type: g.nonNull(g.Boolean), async resolve(rootVal, args, context) { - await context.sessionStrategy?.end({ context }) + await sessionStrategy.end({ context }) return true }, }), @@ -87,8 +94,6 @@ export function getBaseAuthSchema({ { [identityField]: identity, [secretField]: secret }, context: KeystoneContext ) { - if (!context.sessionStrategy) throw new Error('No session strategy on context') - const item = await context.sudo().db[listKey].findOne({ where: { [identityField]: identity }, }) @@ -101,9 +106,9 @@ export function getBaseAuthSchema({ const equal = await kdf.compare(secret, item[secretField]) if (!equal) return AUTHENTICATION_FAILURE - const sessionToken = await context.sessionStrategy.start({ + const sessionToken = await sessionStrategy.start({ data: { - itemId: item.id, + itemId: item.id.toString(), }, context, }) diff --git a/packages/auth/src/gql/getInitFirstItemSchema.ts b/packages/auth/src/gql/getInitFirstItemSchema.ts index 6a93631a8f2..b945d405282 100644 --- a/packages/auth/src/gql/getInitFirstItemSchema.ts +++ b/packages/auth/src/gql/getInitFirstItemSchema.ts @@ -3,6 +3,7 @@ import { g } from '@keystone-6/core' import { assertInputObjectType, GraphQLInputObjectType, type GraphQLSchema } from 'graphql' import { type AuthGqlNames, type InitFirstItemConfig } from '../types' import type { Extension } from '@keystone-6/core/graphql-ts' +import type { SessionStrategy } from '../session' const AUTHENTICATION_FAILURE = 'Authentication failed.' as const @@ -13,6 +14,7 @@ export function getInitFirstItemSchema({ defaultItemData, graphQLSchema, ItemAuthenticationWithPasswordSuccess, + sessionStrategy, }: { authGqlNames: AuthGqlNames listKey: string @@ -25,6 +27,7 @@ export function getInitFirstItemSchema({ sessionToken: string }> > + sessionStrategy: SessionStrategy<{ itemId: string }, unknown> // TODO: return type required by pnpm :( }): Extension { const createInputConfig = assertInputObjectType( @@ -45,8 +48,6 @@ export function getInitFirstItemSchema({ type: g.nonNull(ItemAuthenticationWithPasswordSuccess), args: { data: g.arg({ type: g.nonNull(initialCreateInput) }) }, async resolve(rootVal, { data }, context: KeystoneContext) { - if (!context.sessionStrategy) throw new Error('No session strategy on context') - const sudoContext = context.sudo() // should approximate hasInitFirstItemConditions @@ -64,10 +65,9 @@ export function getInitFirstItemSchema({ }, }) - const sessionToken = await context.sessionStrategy.start({ + const sessionToken = await sessionStrategy.start({ data: { - listKey, - itemId: item.id, + itemId: item.id.toString(), }, context, }) diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 6e2c8c2e875..0a2687a2ada 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -2,7 +2,6 @@ import type { AdminFileToWrite, BaseListTypeInfo, KeystoneContext, - SessionStrategy, BaseKeystoneTypeInfo, KeystoneConfig, } from '@keystone-6/core/types' @@ -12,6 +11,7 @@ import { getSchemaExtension } from './schema' import configTemplate from './templates/config' import signinTemplate from './templates/signin' import initTemplate from './templates/init' +import { sessionToItemId } from './gql/getBaseAuthSchema' export type AuthSession = { itemId: string | number // TODO: use ListTypeInfo @@ -35,18 +35,23 @@ function getAuthGqlNames(singular: string): AuthGqlNames { } // TODO: use TypeInfo and listKey for types + /** * createAuth function * * Generates config for Keystone to implement standard auth features. */ -export function createAuth({ +export function createAuth< + ListTypeInfo extends BaseListTypeInfo & { all: { session: object } }, + SessionStrategySession extends { itemId: string } = { itemId: string }, +>({ listKey, secretField, initFirstItem, identityField, - sessionData = 'id', -}: AuthConfig) { + sessionStrategy, + getSession, +}: AuthConfig) { /** * getAdditionalFiles * @@ -116,43 +121,6 @@ export function createAuth({ } } - // this strategy wraps the existing session strategy, - // and injects the requested session.data before returning - function authSessionStrategy( - _sessionStrategy: SessionStrategy - ): SessionStrategy { - const { get, ...sessionStrategy } = _sessionStrategy - return { - ...sessionStrategy, - get: async ({ context }) => { - const session = await get({ context }) - const sudoContext = context.sudo() - if (!session?.itemId) return - - // TODO: replace with SessionSecret: HMAC({ listKey, identityField, secretField }, SessionSecretVar) - // if (session.listKey !== listKey) return null - - try { - const data = await sudoContext.query[listKey].findOne({ - where: { id: session.itemId }, - query: sessionData, - }) - if (!data) return - - return { - ...session, - itemId: session.itemId, - data, - } - } catch (e) { - console.error(e) - // WARNING: this is probably an invalid configuration - return - } - }, - } - } - async function hasInitFirstItemConditions( context: KeystoneContext ) { @@ -170,13 +138,14 @@ export function createAuth({ context, wasAccessAllowed, basePath, + url, }: { context: KeystoneContext wasAccessAllowed: boolean basePath: string + url: string }): Promise<{ kind: 'redirect'; to: string } | void> { - const { req } = context - const { pathname } = new URL(req!.url!, 'http://_') + const { pathname } = new URL(url, 'http://_') // redirect to init if initFirstItem conditions are met if (pathname !== `${basePath}/init` && (await hasInitFirstItemConditions(context))) { @@ -242,8 +211,6 @@ export function createAuth({ } } - if (!config.session) throw new TypeError('Missing .session configuration') - const { graphql } = config const { extendGraphqlSchema = defaultExtendGraphqlSchema } = graphql ?? {} const listConfig = config.lists[listKey] @@ -260,7 +227,7 @@ export function createAuth({ identityField, secretField, initFirstItem, - sessionData, + sessionStrategy, }) return { @@ -272,7 +239,17 @@ export function createAuth({ }, }, ui, - session: authSessionStrategy(config.session), + async session(args) { + const session = await sessionStrategy.get(args) + if (!session) return + const innerSession = getSession({ data: session, context: args.context }) + if (!innerSession) return + if (typeof innerSession !== 'object') { + throw new Error('getSession must return an object') + } + sessionToItemId.set(innerSession, session.itemId) + return innerSession + }, lists: { ...config.lists, [listKey]: { @@ -289,3 +266,11 @@ export function createAuth({ withAuth, } } + +export { + type SessionStrategy, + statelessSessions, + storedSessions, + type SessionStore, + type SessionStoreFunction, +} from './session' diff --git a/packages/auth/src/schema.ts b/packages/auth/src/schema.ts index f1ce6d338ea..9a8696943a9 100644 --- a/packages/auth/src/schema.ts +++ b/packages/auth/src/schema.ts @@ -1,9 +1,10 @@ -import { assertInputObjectType, GraphQLString, GraphQLID, parse, validate } from 'graphql' +import { assertInputObjectType, GraphQLString, GraphQLID } from 'graphql' import { g } from '@keystone-6/core' -import type { AuthGqlNames, AuthTokenTypeConfig, InitFirstItemConfig } from './types' +import type { AuthGqlNames, InitFirstItemConfig } from './types' import { getBaseAuthSchema } from './gql/getBaseAuthSchema' import { getInitFirstItemSchema } from './gql/getInitFirstItemSchema' +import type { SessionStrategy } from './session' export const getSchemaExtension = ({ authGqlNames, @@ -11,16 +12,14 @@ export const getSchemaExtension = ({ identityField, secretField, initFirstItem, - sessionData, + sessionStrategy, }: { authGqlNames: AuthGqlNames listKey: string identityField: string secretField: string initFirstItem?: InitFirstItemConfig - passwordResetLink?: AuthTokenTypeConfig - magicAuthLink?: AuthTokenTypeConfig - sessionData: string + sessionStrategy: SessionStrategy<{ itemId: string }, unknown> }) => g.extend(base => { const uniqueWhereInputType = assertInputObjectType( @@ -41,34 +40,14 @@ export const getSchemaExtension = ({ } const baseSchema = getBaseAuthSchema({ - authGqlNames: authGqlNames, + authGqlNames, identityField, listKey, secretField, base, + sessionStrategy, }) - // technically this will incorrectly error if someone has a schema extension that adds a field to the list output type - // and then wants to fetch that field with `sessionData` but it's extremely unlikely someone will do that since if - // they want to add a GraphQL field, they'll probably use a virtual field - const query = `query($id: ID!) { ${authGqlNames.itemQueryName}(where: { id: $id }) { ${sessionData} } }` - - let ast - try { - ast = parse(query) - } catch (err) { - throw new Error( - `The query to get session data has a syntax error, the sessionData option in your createAuth usage is likely incorrect\n${err}` - ) - } - - const errors = validate(base.schema, ast) - if (errors.length) { - throw new Error( - `The query to get session data has validation errors, the sessionData option in your createAuth usage is likely incorrect\n${errors.join('\n')}` - ) - } - return [ baseSchema.extension, initFirstItem && @@ -79,6 +58,7 @@ export const getSchemaExtension = ({ defaultItemData: initFirstItem.itemData, graphQLSchema: base.schema, ItemAuthenticationWithPasswordSuccess: baseSchema.ItemAuthenticationWithPasswordSuccess, + sessionStrategy, }), - ].filter((x): x is Exclude => x !== undefined) + ].filter(x => x !== undefined) }) diff --git a/packages/core/src/session.ts b/packages/auth/src/session.ts similarity index 75% rename from packages/core/src/session.ts rename to packages/auth/src/session.ts index 92bc2fe777b..053a7843bbb 100644 --- a/packages/core/src/session.ts +++ b/packages/auth/src/session.ts @@ -1,7 +1,32 @@ import { randomBytes } from 'node:crypto' import * as cookie from 'cookie' import Iron from '@hapi/iron' -import type { SessionStrategy, SessionStoreFunction } from '../types' +import type { BaseKeystoneTypeInfo, KeystoneContext, MaybePromise } from '@keystone-6/core/types' + +export type SessionStrategy< + StartSession, + GetSession, + TypeInfo extends BaseKeystoneTypeInfo = BaseKeystoneTypeInfo, +> = { + get: (args: { context: KeystoneContext }) => Promise + start: (args: { context: KeystoneContext; data: StartSession }) => Promise + end: (args: { context: KeystoneContext }) => Promise +} + +/** @deprecated */ +export type SessionStore = { + get(key: string): MaybePromise + set(key: string, value: Session): void | Promise + delete(key: string): void | Promise +} + +/** @deprecated */ +export type SessionStoreFunction = (args: { + /** + * The number of seconds that a cookie session be valid for + */ + maxAge: number +}) => SessionStore // TODO: should we also accept httpOnly? type StatelessSessionsOptions = { @@ -59,7 +84,7 @@ type StatelessSessionsOptions = { sameSite?: true | false | 'lax' | 'strict' | 'none' } -export function statelessSessions({ +export function statelessSessions({ secret = randomBytes(32).toString('base64url'), maxAge = 60 * 60 * 8, // 8 hours, cookieName = 'keystonejs-session', @@ -68,7 +93,7 @@ export function statelessSessions({ ironOptions = Iron.defaults, domain, sameSite = 'lax', -}: StatelessSessionsOptions = {}) { +}: StatelessSessionsOptions = {}): SessionStrategy { // atleast 192-bit in base64 if (secret.length < 32) { throw new Error('The session secret must be at least 32 characters long') @@ -78,8 +103,8 @@ export function statelessSessions({ async get({ context }) { if (!context?.req) return - const cookies = cookie.parse(context.req.headers.cookie || '') - const bearer = context.req.headers.authorization?.replace('Bearer ', '') + const cookies = cookie.parse(context.req.headers.get('cookie') || '') + const bearer = context.req.headers.get('authorization')?.replace('Bearer ', '') const token = bearer || cookies[cookieName] if (!token) return try { @@ -91,7 +116,7 @@ export function statelessSessions({ async end({ context }) { if (!context?.res) return - context.res.setHeader( + context.res.headers.append( 'Set-Cookie', cookie.serialize(cookieName, '', { maxAge: 0, @@ -108,7 +133,7 @@ export function statelessSessions({ if (!context?.res) return const sealedData = await Iron.seal(data, secret, { ...ironOptions, ttl: maxAge * 1000 }) - context.res.setHeader( + context.res.headers.append( 'Set-Cookie', cookie.serialize(cookieName, sealedData, { maxAge, @@ -123,16 +148,16 @@ export function statelessSessions({ return sealedData }, - } satisfies SessionStrategy + } } -export function storedSessions({ +export function storedSessions({ store: storeFn, maxAge = 60 * 60 * 8, // 8 hours ...statelessSessionsOptions }: { store: SessionStoreFunction -} & StatelessSessionsOptions) { +} & StatelessSessionsOptions): SessionStrategy { const stateless = statelessSessions({ ...statelessSessionsOptions, maxAge }) const store = storeFn({ maxAge }) @@ -155,5 +180,5 @@ export function storedSessions({ await store.delete(sessionId) await stateless.end({ context }) }, - } satisfies SessionStrategy + } } diff --git a/packages/auth/src/templates/init.ts b/packages/auth/src/templates/init.ts index 15ddb04dca9..c2aa7b05688 100644 --- a/packages/auth/src/templates/init.ts +++ b/packages/auth/src/templates/init.ts @@ -8,7 +8,7 @@ export default function ({ }: { authGqlNames: AuthGqlNames listKey: string - initFirstItem: NonNullable['initFirstItem']> + initFirstItem: NonNullable['initFirstItem']> }) { return `import makeInitPage from '@keystone-6/auth/pages/InitPage' diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 1ed7e1ac7a4..fcec41eb008 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -1,4 +1,5 @@ import type { BaseListTypeInfo, KeystoneContext } from '@keystone-6/core/types' +import type { SessionStrategy } from './session' export type AuthGqlNames = { itemQueryName: string @@ -27,17 +28,22 @@ export type AuthTokenTypeConfig = { tokensValidForMins?: number } -export type AuthConfig = { +export type AuthConfig = { /** The key of the list to authenticate users with */ listKey: ListTypeInfo['key'] /** The path of the field the identity is stored in; must be text-ish */ identityField: ListTypeInfo['fields'] /** The path of the field the secret is stored in; must be password-ish */ secretField: ListTypeInfo['fields'] + /** How Keystone Auth should store/access auth information in headers/cookies */ + sessionStrategy: SessionStrategy<{ itemId: string }, SessionStrategySession, ListTypeInfo['all']> + /** Session hydration */ + getSession: (args: { + data: SessionStrategySession + context: KeystoneContext + }) => Promise /** The initial user/db seeding functionality */ initFirstItem?: InitFirstItemConfig - /** Session data population */ - sessionData?: string } export type InitFirstItemConfig = { diff --git a/packages/core/package.json b/packages/core/package.json index c5c7ebd1f35..8040e77e099 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,11 +21,6 @@ "module": "./context/dist/keystone-6-core-context.esm.js", "default": "./context/dist/keystone-6-core-context.cjs.js" }, - "./session": { - "types": "./session/dist/keystone-6-core-session.cjs.js", - "module": "./session/dist/keystone-6-core-session.esm.js", - "default": "./session/dist/keystone-6-core-session.cjs.js" - }, "./testing": { "types": "./testing/dist/keystone-6-core-testing.cjs.js", "module": "./testing/dist/keystone-6-core-testing.esm.js", @@ -240,7 +235,6 @@ "@graphql-ts/extend": "^2.0.0", "@graphql-ts/schema": "^1.0.0", "@graphql-typed-document-node/core": "^3.1.2", - "@hapi/iron": "^7.0.0", "@internationalized/date": "^3.9.0", "@keystar/ui": "^0.7.19", "@nodelib/fs.walk": "^3.0.0", @@ -261,7 +255,6 @@ "ci-info": "^4.0.0", "clipboard-copy": "^4.0.1", "conf": "^10.2.0", - "cookie": "^1.0.0", "cors": "^2.8.5", "dataloader": "^2.1.0", "date-fns": "^4.0.0", @@ -309,7 +302,6 @@ "___internal-do-not-use-will-break-in-patch/admin-ui/id-field-view.tsx", "context.ts", "testing.ts", - "session.ts", "graphql-ts.ts", "scripts/index.ts", "scripts/cli.ts", diff --git a/packages/core/session/package.json b/packages/core/session/package.json deleted file mode 100644 index a5b8d27f9f1..00000000000 --- a/packages/core/session/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "main": "dist/keystone-6-core-session.cjs.js", - "module": "dist/keystone-6-core-session.esm.js" -} diff --git a/packages/core/src/lib/context/createContext.ts b/packages/core/src/lib/context/createContext.ts index 16615a3afe6..7b5d75b1772 100644 --- a/packages/core/src/lib/context/createContext.ts +++ b/packages/core/src/lib/context/createContext.ts @@ -1,9 +1,9 @@ -import type { IncomingMessage, ServerResponse } from 'http' import { type ExecutionResult, type GraphQLSchema, graphql, print } from 'graphql' import type { KeystoneContext, KeystoneGraphQLAPI, KeystoneConfig } from '../../types' import { type InitialisedList } from '../core/initialise-lists' import { getDbFactory, getQueryFactory } from './api' +import type { OutgoingMessage } from 'http' export function createContext({ config, @@ -53,8 +53,8 @@ export function createContext({ prisma: any session?: unknown sudo: boolean - req?: IncomingMessage - res?: ServerResponse + req?: KeystoneContext['req'] + res?: KeystoneContext['res'] }) => { const schema = sudo ? graphQLSchemaSudo : graphQLSchema const rawGraphQL: KeystoneGraphQLAPI['raw'] = async ({ query, variables }) => { @@ -97,9 +97,25 @@ export function createContext({ req, res, - sessionStrategy: config.session, + // sessionStrategy: config.session, ...(session ? { session } : {}), - + withNodeRequest: async (req, res) => { + return context.withRequest( + { + headers: new Headers( + Object.entries(req.headers).flatMap(([key, value]): [string, string][] => + value + ? Array.isArray(value) + ? value.map(inner => [key, inner]) + : [[key, value]] + : [] + ) + ), + nodeReq: req, + }, + res ? { headers: new HeadersWithSettingOnNodeResponse(res) } : undefined + ) + }, withRequest: async (newReq, newRes) => { const newContext = construct({ prisma, @@ -109,7 +125,7 @@ export function createContext({ res: newRes, }) return newContext.withSession( - (await config.session?.get({ context: newContext })) ?? undefined + (await config.session?.({ context: newContext })) ?? undefined ) }, @@ -142,3 +158,23 @@ export function createContext({ sudo: false, }) } + +class HeadersWithSettingOnNodeResponse extends Headers { + #propogateTo: OutgoingMessage + constructor(propogateTo: OutgoingMessage, init?: HeadersInit) { + super(init) + this.#propogateTo = propogateTo + } + append(name: string, value: string): void { + super.append(name, value) + this.#propogateTo.appendHeader(name, value) + } + delete(name: string): void { + super.delete(name) + this.#propogateTo.removeHeader(name) + } + set(name: string, value: string): void { + super.set(name, value) + this.#propogateTo.setHeader(name, value) + } +} diff --git a/packages/core/src/lib/express.ts b/packages/core/src/lib/express.ts index 3ef65128d16..5b30afe7b60 100644 --- a/packages/core/src/lib/express.ts +++ b/packages/core/src/lib/express.ts @@ -100,7 +100,7 @@ export async function createExpressServer( }, expressMiddleware(apolloServer, { context: async ({ req, res }) => { - return await context.withRequest(req, res) + return await context.withNodeRequest(req, res) }, }) ) diff --git a/packages/core/src/lib/middleware.ts b/packages/core/src/lib/middleware.ts index 63a4ecef664..15ab9de05c0 100644 --- a/packages/core/src/lib/middleware.ts +++ b/packages/core/src/lib/middleware.ts @@ -30,9 +30,10 @@ export function createAdminUIMiddlewareWithNextApp( try { // do nothing if this is a public page const isPublicPage = publicPages.includes(pathname!) - const context = await commonContext.withRequest(req, res) + const context = await commonContext.withNodeRequest(req) const wasAccessAllowed = isPublicPage ? true : await isAccessAllowed(context) const shouldRedirect = await pageMiddleware?.({ + url: req.url, context, wasAccessAllowed, basePath, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 5cabb90de4f..341f907cbe5 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -8,7 +8,6 @@ import type { IdFieldConfig, KeystoneConfig, KeystoneConfigPre, - KeystoneContext, ListConfig, MaybeItemFunctionWithFilter, MaybeSessionFunction, @@ -73,11 +72,6 @@ function listsWithDefaults(config: KeystoneConfigPre, defaultIdField: IdFieldCon ]) satisfies KeystoneConfig['lists'] } -function defaultIsAccessAllowed({ session, sessionStrategy }: KeystoneContext) { - if (!sessionStrategy) return true - return session !== undefined -} - async function noop() {} function identity(x: T) { return x @@ -152,7 +146,9 @@ export function config( ui: { ...config.ui, basePath: config.ui?.basePath ?? '', - isAccessAllowed: config.ui?.isAccessAllowed ?? defaultIsAccessAllowed, + isAccessAllowed: + config.ui?.isAccessAllowed ?? + (config.session ? ({ session }) => session !== undefined : () => true), isDisabled: config.ui?.isDisabled ?? false, getAdditionalFiles: config.ui?.getAdditionalFiles ?? (() => []), pageMiddleware: config.ui?.pageMiddleware ?? noop, diff --git a/packages/core/src/types/config/index.ts b/packages/core/src/types/config/index.ts index 4f831ad4c66..cfb7b3a0f6d 100644 --- a/packages/core/src/types/config/index.ts +++ b/packages/core/src/types/config/index.ts @@ -8,7 +8,6 @@ import type express from 'express' import type { GraphQLSchema } from 'graphql' import type { BaseKeystoneTypeInfo, DatabaseProvider, KeystoneContext } from '..' -import type { SessionStrategy } from '../session' import type { MaybePromise } from '../utils' import type { FieldAccessControl, ListAccessControl } from './access-control' import type { BaseFields } from './fields' @@ -145,7 +144,9 @@ export type KeystoneConfigPre + session?: (args: { + context: KeystoneContext + }) => Promise /** Telemetry boolean to disable telemetry for this project */ telemetry?: boolean @@ -169,6 +170,7 @@ export type KeystoneConfigPre wasAccessAllowed: boolean + url: string basePath: string }) => MaybePromise<{ kind: 'redirect'; to: string } | void> } diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index e48788b607a..efda9ca28a5 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -1,20 +1,26 @@ -import type { IncomingMessage, ServerResponse } from 'http' import type { DocumentNode, ExecutionResult, GraphQLSchema } from 'graphql' import type { TypedDocumentNode } from '@graphql-typed-document-node/core' import type { InitialisedList } from '../lib/core/initialise-lists' -import type { SessionStrategy } from './session' import type { BaseKeystoneTypeInfo, BaseListTypeInfo } from './type-info' import type { MaybePromise } from './utils' +import type { IncomingMessage, OutgoingMessage } from 'node:http' export type KeystoneContext = { - req?: IncomingMessage - res?: ServerResponse + req?: { headers: Headers; nodeReq?: IncomingMessage } + res?: { headers: Headers } db: KeystoneDbAPI query: KeystoneListsAPI graphql: KeystoneGraphQLAPI sudo: () => KeystoneContext withSession: (session?: TypeInfo['session']) => KeystoneContext - withRequest: (req: IncomingMessage, res?: ServerResponse) => Promise> + withNodeRequest: ( + req: IncomingMessage, + res?: OutgoingMessage + ) => Promise> + withRequest: ( + req: NonNullable['req']>, + res?: KeystoneContext['res'] + ) => Promise> prisma: TypeInfo['prisma'] transaction: ( f: (context: KeystoneContext) => MaybePromise, @@ -27,7 +33,6 @@ export type KeystoneContext Promise - sessionStrategy?: SessionStrategy session?: TypeInfo['session'] /** diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 99992bf0e8a..1674859b379 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,7 +1,6 @@ export * from './core' export * from './config' export * from './utils' -export * from './session' export * from './admin-meta' export * from './context' export * from './next-fields' diff --git a/packages/core/src/types/session.ts b/packages/core/src/types/session.ts deleted file mode 100644 index a91b81987b7..00000000000 --- a/packages/core/src/types/session.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { MaybePromise } from './utils' -import type { BaseKeystoneTypeInfo, KeystoneContext } from '.' - -export type SessionStrategy< - Session, - TypeInfo extends BaseKeystoneTypeInfo = BaseKeystoneTypeInfo, -> = { - get: (args: { context: KeystoneContext }) => Promise - start: (args: { context: KeystoneContext; data: Session }) => Promise - end: (args: { context: KeystoneContext }) => Promise -} - -/** @deprecated */ -export type SessionStore = { - get(key: string): MaybePromise - set(key: string, value: Session): void | Promise - delete(key: string): void | Promise -} - -/** @deprecated */ -export type SessionStoreFunction = (args: { - /** - * The number of seconds that a cookie session be valid for - */ - maxAge: number -}) => SessionStore diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 190f9c912a2..eee0b8c4c06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,9 @@ importers: express: specifier: ^4.19.2 version: 4.21.2 + express-session: + specifier: ^1.18.1 + version: 1.18.2 passport: specifier: ^0.7.0 version: 0.7.0 @@ -677,6 +680,9 @@ importers: '@types/express': specifier: ^4.17.14 version: 4.17.23 + '@types/express-session': + specifier: ^1.18.1 + version: 1.18.2 '@types/passport': specifier: ^1.0.16 version: 1.0.17 @@ -1086,6 +1092,12 @@ importers: examples/framework-nextjs-app-directory: dependencies: + '@apollo/server': + specifier: ^4.11.3 + version: 4.12.2(graphql@16.11.0) + '@as-integrations/next': + specifier: ^3.2.0 + version: 3.2.0(@apollo/server@4.12.2(graphql@16.11.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)) '@keystone-6/auth': specifier: ^8.1.0 version: link:../../packages/auth @@ -1110,9 +1122,6 @@ importers: graphql-request: specifier: ^5.0.0 version: 5.2.0(graphql@16.11.0) - graphql-yoga: - specifier: ^3.1.0 - version: 3.9.1(graphql@16.11.0) next: specifier: ^15.5.2 version: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -1793,9 +1802,15 @@ importers: '@babel/runtime': specifier: ^7.24.7 version: 7.28.3 + '@hapi/iron': + specifier: ^7.0.0 + version: 7.0.1 '@keystar/ui': specifier: ^0.7.16 version: 0.7.19(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + cookie: + specifier: ^1.0.0 + version: 1.0.2 fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -1867,9 +1882,6 @@ importers: '@graphql-typed-document-node/core': specifier: ^3.1.2 version: 3.2.0(graphql@16.11.0) - '@hapi/iron': - specifier: ^7.0.0 - version: 7.0.1 '@internationalized/date': specifier: ^3.9.0 version: 3.9.0 @@ -1930,9 +1942,6 @@ importers: conf: specifier: ^10.2.0 version: 10.2.0 - cookie: - specifier: ^1.0.0 - version: 1.0.2 cors: specifier: ^2.8.5 version: 2.8.5 @@ -2626,6 +2635,13 @@ packages: peerDependencies: graphql: '*' + '@as-integrations/next@3.2.0': + resolution: {integrity: sha512-JTVtRwHdOQTixIacmvfdUukSqNytEHfgvg+K9P8cW7JeF4SCPXat+i9abSII3/cbR6/GQwFZ6gq+c4R0nmSzMg==} + engines: {node: '>=18'} + peerDependencies: + '@apollo/server': ^4.0.0 + next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + '@astrojs/compiler@2.12.2': resolution: {integrity: sha512-w2zfvhjNCkNMmMMOn5b0J8+OmUaBL1o40ipMvqcG6NRpdC+lKxmTi48DT8Xw0SzJ3AfmeFLB45zXZXtmbsjcgw==} @@ -6381,6 +6397,9 @@ packages: '@types/express-serve-static-core@4.19.6': resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + '@types/express-session@1.18.2': + resolution: {integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==} + '@types/express@4.17.23': resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} @@ -7541,6 +7560,9 @@ packages: cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -8194,6 +8216,10 @@ packages: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + express-session@1.18.2: + resolution: {integrity: sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==} + engines: {node: '>= 0.8.0'} + express@4.21.2: resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} engines: {node: '>= 0.10.0'} @@ -11055,6 +11081,10 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -12169,6 +12199,10 @@ packages: ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} @@ -12996,6 +13030,11 @@ snapshots: transitivePeerDependencies: - encoding + '@as-integrations/next@3.2.0(@apollo/server@4.12.2(graphql@16.11.0))(next@15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))': + dependencies: + '@apollo/server': 4.12.2(graphql@16.11.0) + next: 15.5.2(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@astrojs/compiler@2.12.2': {} '@astrojs/internal-helpers@0.4.1': {} @@ -18355,6 +18394,10 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.5 + '@types/express-session@1.18.2': + dependencies: + '@types/express': 4.17.23 + '@types/express@4.17.23': dependencies: '@types/body-parser': 1.19.6 @@ -19831,6 +19874,8 @@ snapshots: cookie-signature@1.0.6: {} + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.4.2: {} @@ -20561,6 +20606,19 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 + express-session@1.18.2: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + express@4.21.2: dependencies: accepts: 1.3.8 @@ -24309,6 +24367,8 @@ snapshots: quick-lru@5.1.1: {} + random-bytes@1.0.0: {} + range-parser@1.2.1: {} raw-body@2.5.2: @@ -25631,6 +25691,10 @@ snapshots: ufo@1.6.1: {} + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + uid2@0.0.4: {} unc-path-regex@0.1.2: {} diff --git a/tests/api-tests/auth-header.test.ts b/tests/api-tests/auth-header.test.ts index 55e7aecc337..28f02a054a8 100644 --- a/tests/api-tests/auth-header.test.ts +++ b/tests/api-tests/auth-header.test.ts @@ -1,7 +1,6 @@ import { list } from '@keystone-6/core' import { text, timestamp, password } from '@keystone-6/core/fields' -import { statelessSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, statelessSessions } from '@keystone-6/auth' import { setupTestRunner, setupTestEnv } from '@keystone-6/api-tests/test-runner' import { allowAll } from '@keystone-6/core/access' import { expectAccessDenied, seed } from './utils' @@ -13,13 +12,19 @@ const initialData = { ], } -function setup(options?: any) { +function setup() { const { withAuth } = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id', - ...options, + sessionStrategy: statelessSessions(), + async getSession(args) { + const user = await args.context.sudo().db.User.findOne({ where: { id: args.data.itemId } }) + if (!user) { + return + } + return user + }, }) return setupTestRunner({ @@ -41,7 +46,6 @@ function setup(options?: any) { }, }), }, - session: statelessSessions(), } as any) as any, serve: true, }) @@ -95,7 +99,8 @@ describe('Auth testing', () => { listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id', + sessionStrategy: statelessSessions(), + getSession: ({ context, data }) => context.query.User.findOne({ where: { id: data.itemId } }), }) await expect( setupTestEnv( @@ -110,8 +115,6 @@ describe('Auth testing', () => { }, }), }, - - session: statelessSessions(), } as any) as any ) ).rejects.toMatchInlineSnapshot( @@ -222,35 +225,5 @@ describe('Auth testing', () => { } }) ) - - test('Starting up fails if there sessionData configuration has a syntax error', async () => { - await expect( - setup({ - sessionData: 'id {', - })(async () => {}) - ).rejects.toMatchInlineSnapshot(` - [Error: The query to get session data has a syntax error, the sessionData option in your createAuth usage is likely incorrect - Syntax Error: Expected Name, found "}". - - GraphQL request:1:51 - 1 | query($id: ID!) { user(where: { id: $id }) { id { } } - | ^] - `) - }) - - test('Starting up fails if there sessionData configuration has a validation error', async () => { - await expect( - setup({ - sessionData: 'id foo', // foo does not exist - })(async () => {}) - ).rejects.toMatchInlineSnapshot(` - [Error: The query to get session data has validation errors, the sessionData option in your createAuth usage is likely incorrect - Cannot query field "foo" on type "User". - - GraphQL request:1:49 - 1 | query($id: ID!) { user(where: { id: $id }) { id foo } } - | ^] - `) - }) }) }) diff --git a/tests/api-tests/auth.test.ts b/tests/api-tests/auth.test.ts index 756ac7f77a5..9699be00431 100644 --- a/tests/api-tests/auth.test.ts +++ b/tests/api-tests/auth.test.ts @@ -1,7 +1,6 @@ import { text, password } from '@keystone-6/core/fields' import { list } from '@keystone-6/core' -import { statelessSessions } from '@keystone-6/core/session' -import { createAuth } from '@keystone-6/auth' +import { createAuth, statelessSessions } from '@keystone-6/auth' import { setupTestRunner } from '@keystone-6/api-tests/test-runner' import { allowAll } from '@keystone-6/core/access' import { expectInternalServerError, expectValidationError, seed } from './utils' @@ -18,7 +17,9 @@ const auth = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id name', + sessionStrategy: statelessSessions(), + getSession: ({ context, data }) => + context.query.User.findOne({ where: { id: data.itemId }, query: 'id name' }), initFirstItem: { fields: ['email', 'password'], itemData: { name: 'First User' }, @@ -40,7 +41,6 @@ const runner = setupTestRunner({ }, }), }, - session: statelessSessions(), }, wrap: config => auth.withAuth(config), })