diff --git a/examples/auth/keystone.ts b/examples/auth/keystone.ts index 1a07f4bee88..1a884ae4d7f 100644 --- a/examples/auth/keystone.ts +++ b/examples/auth/keystone.ts @@ -40,8 +40,16 @@ const { withAuth } = createAuth({ // isEnabled: true, }, }, + sessionStrategy: + // Stateless sessions will store the listKey and itemId of the signed-in user in a cookie + statelessSessions({ + data: 'name isAdmin', + // 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 (should be an environment variable) + secret: sessionSecret, + }), // Populate session.data based on the authed user - sessionData: 'name isAdmin', /* TODO -- complete the UI for these features and enable them passwordResetLink: { sendToken(args) { @@ -65,13 +73,5 @@ export default withAuth( }, lists, ui: {}, - session: - // Stateless sessions will store the listKey and itemId of the signed-in user in a cookie - 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 (should be an environment variable) - secret: sessionSecret, - }), }) ); diff --git a/examples/basic/keystone.ts b/examples/basic/keystone.ts index ccc152b85ab..cb9b6dbba9c 100644 --- a/examples/basic/keystone.ts +++ b/examples/basic/keystone.ts @@ -17,7 +17,12 @@ const auth = createAuth({ isAdmin: true, }, }, - sessionData: 'name isAdmin', + sessionStrategy: statelessSessions({ + data: 'name isAdmin', + // The session secret is used to encrypt cookie data (should be an environment variable) + maxAge: sessionMaxAge, + secret: sessionSecret, + }), }); // TODO -- Create a separate example for access control in the Admin UI @@ -56,12 +61,5 @@ export default auth.withAuth( }, lists, extendGraphqlSchema, - session: statelessSessions({ maxAge: sessionMaxAge, secret: sessionSecret }), - // TODO -- Create a separate example for stored/redis sessions - // session: storedSessions({ - // store: new Map(), - // // store: redisSessionStore({ client: redis.createClient() }), - // secret: sessionSecret, - // }), }) ); diff --git a/examples/custom-session-validation/keystone.ts b/examples/custom-session-validation/keystone.ts index c27326bd386..9451355df39 100644 --- a/examples/custom-session-validation/keystone.ts +++ b/examples/custom-session-validation/keystone.ts @@ -1,8 +1,32 @@ -import { KeystoneConfig, SessionStrategy } from '@keystone-6/core/types'; import { config } from '@keystone-6/core'; import { statelessSessions } from '@keystone-6/auth/session'; +import { SessionStrategy } from '@keystone-6/auth/types'; import { createAuth } from '@keystone-6/auth'; import { lists } from './schema'; +import { Context, TypeInfo } from '.keystone/types'; + +const withTimeData = ( + _sessionStrategy: SessionStrategy> +): SessionStrategy> => { + const { start, ...sessionStrategy } = _sessionStrategy; + return { + ...sessionStrategy, + start: async ({ data, context }) => { + // Add the current time to the session data + const withTimeData = { + ...data, + startTime: new Date(), + }; + // Start the keystone session and include the startTime + return await start({ data: withTimeData, context }); + }, + }; +}; + +const maxSessionAge = 60 * 60 * 8; // 8 hours, in seconds +// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. +// This session object will be made available on the context object used in hooks, access-control, +// resolvers, etc. // createAuth configures signin functionality based on the config below. Note this only implements // authentication, i.e signing in as an item using identity and secret fields in a list. Session @@ -22,79 +46,46 @@ const { withAuth } = createAuth({ fields: ['name', 'email', 'password'], }, // Make passwordChangedAt available on the sesssion data - sessionData: 'id passwordChangedAt', + sessionStrategy: withTimeData( + statelessSessions({ + data: 'id passwordChangedAt', + // The session secret is used to encrypt cookie data (should be an environment variable) + maxAge: maxSessionAge, + secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', + }) + ), }); -const maxSessionAge = 60 * 60 * 8; // 8 hours, in seconds -// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. -// This session object will be made available on the context object used in hooks, access-control, -// resolvers, etc. +async function getSession({ context }: { context: Context }): Promise { + // Get the session from the cookie stored by keystone + const { session } = context; + // If there is no session returned from keystone or there is no startTime on the session return an invalid session + // If session.startTime is null session.data.passwordChangedAt > session.startTime will always be true and therefore + // the session will never be invalid until the maxSessionAge is reached. + if (!session || !session.startTime) return; + //if the password hasn't changed (and isn't missing), then the session is OK + if (session.data.passwordChangedAt === null) return session; + // If passwordChangeAt is undefined, then sessionData is missing the passwordChangedAt field + // Or something is wrong with the session configuration so throw and error + if (session.data.passwordChangedAt === undefined) { + throw new TypeError('passwordChangedAt is not listed in sessionData'); + } + if (session.data.passwordChangedAt > session.startTime) { + return; + } -const withTimeData = ( - _sessionStrategy: SessionStrategy> -): SessionStrategy> => { - const { get, start, ...sessionStrategy } = _sessionStrategy; - return { - ...sessionStrategy, - get: async ({ context }) => { - // Get the session from the cookie stored by keystone - const session = await get({ context }); - // If there is no session returned from keystone or there is no startTime on the session return an invalid session - // If session.startTime is null session.data.passwordChangedAt > session.startTime will always be true and therefore - // the session will never be invalid until the maxSessionAge is reached. - if (!session || !session.startTime) return; - //if the password hasn't changed (and isn't missing), then the session is OK - if (session.data.passwordChangedAt === null) return session; - // If passwordChangeAt is undefined, then sessionData is missing the passwordChangedAt field - // Or something is wrong with the session configuration so throw and error - if (session.data.passwordChangedAt === undefined) { - throw new TypeError('passwordChangedAt is not listed in sessionData'); - } - if (session.data.passwordChangedAt > session.startTime) { - return; - } - - return session; - }, - start: async ({ data, context }) => { - // Add the current time to the session data - const withTimeData = { - ...data, - startTime: new Date(), - }; - // Start the keystone session and include the startTime - return await start({ data: withTimeData, context }); - }, - }; -}; - -const myAuth = (keystoneConfig: KeystoneConfig): KeystoneConfig => { - // Add the session strategy to the config - if (!keystoneConfig.session) throw new TypeError('Missing .session configuration'); - return { - ...keystoneConfig, - session: withTimeData(keystoneConfig.session), - }; -}; - -const session = statelessSessions({ - // The session secret is used to encrypt cookie data (should be an environment variable) - maxAge: maxSessionAge, - secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', -}); + return session; +} // We wrap our config using the withAuth function. This will inject all // the extra config required to add support for authentication in our system. -export default myAuth( - withAuth( - config({ - db: { - provider: 'sqlite', - url: process.env.DATABASE_URL || 'file:./keystone-example.db', - }, - lists, - // We add our session configuration to the system here. - session, - }) - ) +export default withAuth( + config({ + db: { + provider: 'sqlite', + url: process.env.DATABASE_URL || 'file:./keystone-example.db', + }, + lists, + getSession, + }) ); diff --git a/examples/ecommerce/keystone.ts b/examples/ecommerce/keystone.ts index 41c9b8c943e..556233b8872 100644 --- a/examples/ecommerce/keystone.ts +++ b/examples/ecommerce/keystone.ts @@ -20,6 +20,7 @@ const databaseURL = process.env.DATABASE_URL || 'file:./keystone.db'; const sessionConfig = { maxAge: 60 * 60 * 24 * 360, // How long they stay signed in? secret: process.env.COOKIE_SECRET || 'this secret should only be used in testing', + data: `id name email role { ${permissionsList.join(' ')} }`, }; const { withAuth } = createAuth({ @@ -36,7 +37,7 @@ const { withAuth } = createAuth({ await sendPasswordResetEmail(args.token, args.identity); }, }, - sessionData: `id name email role { ${permissionsList.join(' ')} }`, + sessionStrategy: statelessSessions(sessionConfig), }); export default withAuth( @@ -71,6 +72,5 @@ export default withAuth( // Show the UI only for poeple who pass this test isAccessAllowed: ({ session }) => !!session, }, - session: statelessSessions(sessionConfig), }) ); diff --git a/examples/redis-session-store/keystone.ts b/examples/redis-session-store/keystone.ts index 6548648ed44..aa5a9fbf049 100644 --- a/examples/redis-session-store/keystone.ts +++ b/examples/redis-session-store/keystone.ts @@ -4,28 +4,8 @@ import { createAuth } from '@keystone-6/auth'; import { createClient } from '@redis/client'; import { lists } from './schema'; -// createAuth configures signin functionality based on the config below. Note this only implements -// authentication, i.e signing in as an item using identity and secret fields in a list. Session -// management and access control are controlled independently in the main keystone config. -const { withAuth } = createAuth({ - // This is the list that contains items people can sign in as - listKey: 'Person', - // The identity field is typically a username or email address - identityField: 'email', - // The secret field must be a password type field - secretField: 'password', - - // initFirstItem turns on the "First User" experience, which prompts you to create a new user - // when there are no items in the list yet - initFirstItem: { - // These fields are collected in the "Create First User" form - fields: ['name', 'email', 'password'], - }, -}); - const redis = createClient(); - -const session = storedSessions({ +const sessionStrategy = storedSessions({ store: ({ maxAge }) => ({ async get(key) { let result = await redis.get(key); @@ -44,6 +24,26 @@ const session = storedSessions({ secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', }); +// createAuth configures signin functionality based on the config below. Note this only implements +// authentication, i.e signing in as an item using identity and secret fields in a list. Session +// management and access control are controlled independently in the main keystone config. +const { withAuth } = createAuth({ + // This is the list that contains items people can sign in as + listKey: 'Person', + // The identity field is typically a username or email address + identityField: 'email', + // The secret field must be a password type field + secretField: 'password', + + // initFirstItem turns on the "First User" experience, which prompts you to create a new user + // when there are no items in the list yet + initFirstItem: { + // These fields are collected in the "Create First User" form + fields: ['name', 'email', 'password'], + }, + sessionStrategy, +}); + // We wrap our config using the withAuth function. This will inject all // the extra config required to add support for authentication in our system. export default withAuth( @@ -56,7 +56,5 @@ export default withAuth( }, }, lists, - // We add our session configuration to the system here. - session, }) ); diff --git a/examples/roles/keystone.ts b/examples/roles/keystone.ts index d11295c45ba..02a24b30747 100644 --- a/examples/roles/keystone.ts +++ b/examples/roles/keystone.ts @@ -10,6 +10,18 @@ const sessionMaxAge = 60 * 60 * 24 * 30; // 30 days const sessionConfig = { maxAge: sessionMaxAge, secret: sessionSecret, + /* This loads the related role for the current user, including all permissions */ + data: ` + name role { + id + name + canCreateTodos + canManageAllTodos + canSeeOtherPeople + canEditOtherPeople + canManagePeople + canManageRoles + }`, }; const { withAuth } = createAuth({ @@ -36,18 +48,7 @@ const { withAuth } = createAuth({ }, }, }, - /* This loads the related role for the current user, including all permissions */ - sessionData: ` - name role { - id - name - canCreateTodos - canManageAllTodos - canSeeOtherPeople - canEditOtherPeople - canManagePeople - canManageRoles - }`, + sessionStrategy: statelessSessions(sessionConfig), }); export default withAuth( @@ -61,6 +62,5 @@ export default withAuth( /* Everyone who is signed in can access the Admin UI */ isAccessAllowed: isSignedIn, }, - session: statelessSessions(sessionConfig), }) ); diff --git a/examples/testing/keystone.ts b/examples/testing/keystone.ts index f1d71b2381f..f92a7f3fba1 100644 --- a/examples/testing/keystone.ts +++ b/examples/testing/keystone.ts @@ -4,6 +4,14 @@ import { createAuth } from '@keystone-6/auth'; import { lists } from './schema'; import { TypeInfo } from '.keystone/types'; +// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. +// This session object will be made available on the context object used in hooks, access-control, +// resolvers, etc. +const sessionStrategy = statelessSessions({ + // The session secret is used to encrypt cookie data (should be an environment variable) + secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', +}); + // createAuth configures signin functionality based on the config below. Note this only implements // authentication, i.e signing in as an item using identity and secret fields in a list. Session // management and access control are controlled independently in the main keystone config. @@ -21,14 +29,8 @@ const { withAuth } = createAuth({ // These fields are collected in the "Create First User" form fields: ['name', 'email', 'password'], }, -}); - -// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. -// This session object will be made available on the context object used in hooks, access-control, -// resolvers, etc. -const session = statelessSessions({ - // The session secret is used to encrypt cookie data (should be an environment variable) - secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', + // We add our session configuration to the system here. + sessionStrategy, }); // We wrap our config using the withAuth function. This will inject all @@ -40,7 +42,5 @@ export default withAuth( url: process.env.DATABASE_URL || 'file:./keystone-example.db', }, lists, - // We add our session configuration to the system here. - session, }) ); diff --git a/examples/with-auth/keystone.ts b/examples/with-auth/keystone.ts index 246f29ed6e9..9576f898984 100644 --- a/examples/with-auth/keystone.ts +++ b/examples/with-auth/keystone.ts @@ -3,6 +3,14 @@ import { statelessSessions } from '@keystone-6/auth/session'; import { createAuth } from '@keystone-6/auth'; import { lists } from './schema'; +// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. +// This session object will be made available on the context object used in hooks, access-control, +// resolvers, etc. +const sessionStrategy = statelessSessions({ + // The session secret is used to encrypt cookie data (should be an environment variable) + secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', +}); + // createAuth configures signin functionality based on the config below. Note this only implements // authentication, i.e signing in as an item using identity and secret fields in a list. Session // management and access control are controlled independently in the main keystone config. @@ -20,14 +28,7 @@ const { withAuth } = createAuth({ // These fields are collected in the "Create First User" form fields: ['name', 'email', 'password'], }, -}); - -// Stateless sessions will store the listKey and itemId of the signed-in user in a cookie. -// This session object will be made available on the context object used in hooks, access-control, -// resolvers, etc. -const session = statelessSessions({ - // The session secret is used to encrypt cookie data (should be an environment variable) - secret: '-- EXAMPLE COOKIE SECRET; CHANGE ME --', + sessionStrategy, }); // We wrap our config using the withAuth function. This will inject all @@ -39,7 +40,5 @@ export default withAuth( url: process.env.DATABASE_URL || 'file:./keystone-example.db', }, lists, - // We add our session configuration to the system here. - session, }) ); diff --git a/packages/auth/package.json b/packages/auth/package.json index f5ee401cbed..34b6c1f04c5 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -9,6 +9,10 @@ "module": "./dist/keystone-6-auth.esm.js", "default": "./dist/keystone-6-auth.cjs.js" }, + "./types": { + "module": "./types/dist/keystone-6-auth-types.esm.js", + "default": "./types/dist/keystone-6-auth-types.cjs.js" + }, "./session": { "module": "./session/dist/keystone-6-auth-session.esm.js", "default": "./session/dist/keystone-6-auth-session.cjs.js" @@ -49,7 +53,8 @@ "entrypoints": [ "index.ts", "pages/*.tsx", - "session/index.ts" + "session/index.ts", + "types.ts" ] }, "repository": "https://github.com/keystonejs/keystone/tree/main/packages/auth" diff --git a/packages/auth/src/gql/getBaseAuthSchema.ts b/packages/auth/src/gql/getBaseAuthSchema.ts index 010c5ffbff3..7e87cfe6ba5 100644 --- a/packages/auth/src/gql/getBaseAuthSchema.ts +++ b/packages/auth/src/gql/getBaseAuthSchema.ts @@ -1,6 +1,6 @@ import type { BaseItem } from '@keystone-6/core/types'; import { graphql } from '@keystone-6/core'; -import { AuthGqlNames, SecretFieldImpl } from '../types'; +import { AuthGqlNames, SecretFieldImpl, SessionStrategy } from '../types'; import { validateSecret } from '../lib/validateSecret'; @@ -11,6 +11,7 @@ export function getBaseAuthSchema({ gqlNames, secretFieldImpl, base, + sessionStrategy, }: { listKey: string; identityField: I; @@ -18,6 +19,7 @@ export function getBaseAuthSchema({ gqlNames: AuthGqlNames; secretFieldImpl: SecretFieldImpl; base: graphql.BaseSchemaMeta; + sessionStrategy: SessionStrategy; }) { const ItemAuthenticationWithPasswordSuccess = graphql.object<{ sessionToken: string; @@ -62,6 +64,15 @@ export function getBaseAuthSchema({ }), }, mutation: { + endSession: graphql.field({ + type: graphql.nonNull(graphql.Boolean), + async resolve(rootVal, args, context) { + if (sessionStrategy) { + await sessionStrategy.end({ context }); + } + return true; + }, + }), [gqlNames.authenticateItemWithPassword]: graphql.field({ type: AuthenticationResult, args: { @@ -69,7 +80,7 @@ export function getBaseAuthSchema({ [secretField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), }, async resolve(root, { [identityField]: identity, [secretField]: secret }, context) { - if (!context.sessionStrategy) { + if (!context.res) { throw new Error('No session implementation available on context'); } @@ -88,7 +99,8 @@ export function getBaseAuthSchema({ } // Update system state - const sessionToken = (await context.sessionStrategy.start({ + + const sessionToken = (await sessionStrategy.start({ data: { listKey, itemId: result.item.id, diff --git a/packages/auth/src/gql/getInitFirstItemSchema.ts b/packages/auth/src/gql/getInitFirstItemSchema.ts index 23fe1028b0e..5e3fbee8fff 100644 --- a/packages/auth/src/gql/getInitFirstItemSchema.ts +++ b/packages/auth/src/gql/getInitFirstItemSchema.ts @@ -2,7 +2,7 @@ import { graphql } from '@keystone-6/core'; import { BaseItem } from '@keystone-6/core/types'; import { assertInputObjectType, GraphQLInputObjectType, GraphQLSchema } from 'graphql'; -import { AuthGqlNames, InitFirstItemConfig } from '../types'; +import { AuthGqlNames, InitFirstItemConfig, SessionStrategy } from '../types'; export function getInitFirstItemSchema({ listKey, @@ -11,6 +11,7 @@ export function getInitFirstItemSchema({ gqlNames, graphQLSchema, ItemAuthenticationWithPasswordSuccess, + sessionStrategy, }: { listKey: string; fields: InitFirstItemConfig['fields']; @@ -21,6 +22,7 @@ export function getInitFirstItemSchema({ item: BaseItem; sessionToken: string; }>; + sessionStrategy: SessionStrategy; }) { const createInputConfig = assertInputObjectType( graphQLSchema.getType(`${listKey}CreateInput`) @@ -41,10 +43,6 @@ export function getInitFirstItemSchema({ type: graphql.nonNull(ItemAuthenticationWithPasswordSuccess), args: { data: graphql.arg({ type: graphql.nonNull(initialCreateInput) }) }, async resolve(rootVal, { data }, context) { - if (!context.sessionStrategy) { - throw new Error('No session implementation available on context'); - } - const dbItemAPI = context.sudo().db[listKey]; const count = await dbItemAPI.count({}); if (count !== 0) { @@ -56,7 +54,7 @@ export function getInitFirstItemSchema({ // (this is also mostly fine, the chance that people are using things where // the input value can't round-trip like the Upload scalar here is quite low) const item = await dbItemAPI.createOne({ data: { ...data, ...itemData } }); - const sessionToken = (await context.sessionStrategy.start({ + const sessionToken = (await sessionStrategy.start({ data: { listKey, itemId: item.id.toString() }, context, })) as string; diff --git a/packages/auth/src/gql/getMagicAuthLinkSchema.ts b/packages/auth/src/gql/getMagicAuthLinkSchema.ts index 1ecc3600f59..1aa32fbaa03 100644 --- a/packages/auth/src/gql/getMagicAuthLinkSchema.ts +++ b/packages/auth/src/gql/getMagicAuthLinkSchema.ts @@ -1,6 +1,6 @@ import type { BaseItem } from '@keystone-6/core/types'; import { graphql } from '@keystone-6/core'; -import { AuthGqlNames, AuthTokenTypeConfig, SecretFieldImpl } from '../types'; +import { AuthGqlNames, AuthTokenTypeConfig, SecretFieldImpl, SessionStrategy } from '../types'; import { createAuthToken } from '../lib/createAuthToken'; import { validateAuthToken } from '../lib/validateAuthToken'; @@ -20,6 +20,7 @@ export function getMagicAuthLinkSchema({ magicAuthLink, magicAuthTokenSecretFieldImpl, base, + sessionStrategy, }: { listKey: string; identityField: I; @@ -27,6 +28,7 @@ export function getMagicAuthLinkSchema({ magicAuthLink: AuthTokenTypeConfig; magicAuthTokenSecretFieldImpl: SecretFieldImpl; base: graphql.BaseSchemaMeta; + sessionStrategy: SessionStrategy; }) { const RedeemItemMagicAuthTokenFailure = graphql.object<{ code: typeof errorCodes[number]; @@ -90,10 +92,6 @@ export function getMagicAuthLinkSchema({ token: graphql.arg({ type: graphql.nonNull(graphql.String) }), }, async resolve(rootVal, { [identityField]: identity, token }, context) { - if (!context.sessionStrategy) { - throw new Error('No session implementation available on context'); - } - const dbItemAPI = context.sudo().db[listKey]; const tokenType = 'magicAuth'; const result = await validateAuthToken( @@ -117,7 +115,7 @@ export function getMagicAuthLinkSchema({ data: { [`${tokenType}RedeemedAt`]: new Date().toISOString() }, }); - const sessionToken = (await context.sessionStrategy.start({ + const sessionToken = (await sessionStrategy.start({ data: { listKey, itemId: result.item.id.toString(), diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index f277b30365a..49d3d17716f 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -5,12 +5,11 @@ import { KeystoneConfig, KeystoneContext, AdminUIConfig, - SessionStrategy, BaseKeystoneTypeInfo, } from '@keystone-6/core/types'; import { password, timestamp } from '@keystone-6/core/fields'; -import { AuthConfig, AuthGqlNames } from './types'; +import { AuthConfig, AuthGqlNames, SessionStrategy } from './types'; import { getSchemaExtension } from './schema'; import { signinTemplate } from './templates/signin'; import { initTemplate } from './templates/init'; @@ -27,7 +26,7 @@ export function createAuth({ identityField, magicAuthLink, passwordResetLink, - sessionData = 'id', + sessionStrategy, }: AuthConfig) { const gqlNames: AuthGqlNames = { // Core @@ -168,7 +167,7 @@ export function createAuth({ initFirstItem, passwordResetLink, magicAuthLink, - sessionData, + sessionStrategy, }); /** @@ -220,38 +219,39 @@ export function createAuth({ - [ ] We could support additional where input to validate item sessions (e.g an isEnabled boolean) */ const withItemData = ( - _sessionStrategy: SessionStrategy> - ): SessionStrategy<{ listKey: string; itemId: string; data: any }> => { - const { get, ...sessionStrategy } = _sessionStrategy; - return { - ...sessionStrategy, - get: async ({ context }) => { - const session = await get({ context }); - const sudoContext = context.sudo(); - if ( - !session || - !session.listKey || - session.listKey !== listKey || - !session.itemId || - !sudoContext.query[session.listKey] - ) { - return; - } - - try { - const data = await sudoContext.query[listKey].findOne({ - where: { id: session.itemId }, - query: sessionData, - }); - if (!data) return; + sessionStrategy: SessionStrategy>, + getSession: KeystoneConfig['getSession'] + ): ((args: { context: KeystoneContext }) => Promise) => { + const { get } = sessionStrategy; + return async ({ context }) => { + const session = await get({ context }); + const sudoContext = context.sudo(); + if ( + !session || + !session.listKey || + session.listKey !== listKey || + !session.itemId || + !sudoContext.query[session.listKey] + ) { + return; + } + try { + const data = await sudoContext.query[listKey].findOne({ + where: { id: session.itemId }, + query: sessionStrategy.data, + }); + if (!data) return; + if (getSession) { + return getSession({ context: { ...context, session: { ...session, data } } }); + } else { return { ...session, itemId: session.itemId, listKey, data }; - } catch (e) { - // TODO: the assumption is this should only be from an invalid sessionData configuration - // it could be something else though, either way, result is a bad session - return; } - }, + } catch (e) { + // TODO: the assumption is this should only be from an invalid sessionData configuration + // it could be something else though, either way, result is a bad session + return; + } }; }; @@ -297,15 +297,14 @@ export function createAuth({ }; } - if (!keystoneConfig.session) throw new TypeError('Missing .session configuration'); - const session = withItemData(keystoneConfig.session); + const getSession = withItemData(sessionStrategy, keystoneConfig.getSession); const existingExtendGraphQLSchema = keystoneConfig.extendGraphqlSchema; const listConfig = keystoneConfig.lists[listKey]; return { ...keystoneConfig, ui, - session, + getSession, // Add the additional fields to the references lists fields object // TODO: The fields we're adding here shouldn't naively replace existing fields with the same key // Leaving existing fields in place would allow solution devs to customise these field defs (eg. access control, diff --git a/packages/auth/src/schema.ts b/packages/auth/src/schema.ts index f85db95715e..95ee516886c 100644 --- a/packages/auth/src/schema.ts +++ b/packages/auth/src/schema.ts @@ -10,7 +10,13 @@ import { validate, } from 'graphql'; import { graphql } from '@keystone-6/core'; -import { AuthGqlNames, AuthTokenTypeConfig, InitFirstItemConfig, SecretFieldImpl } from './types'; +import { + AuthGqlNames, + AuthTokenTypeConfig, + InitFirstItemConfig, + SecretFieldImpl, + SessionStrategy, +} from './types'; import { getBaseAuthSchema } from './gql/getBaseAuthSchema'; import { getInitFirstItemSchema } from './gql/getInitFirstItemSchema'; import { getPasswordResetSchema } from './gql/getPasswordResetSchema'; @@ -48,7 +54,7 @@ export const getSchemaExtension = ({ initFirstItem, passwordResetLink, magicAuthLink, - sessionData, + sessionStrategy, }: { identityField: string; listKey: string; @@ -57,7 +63,7 @@ export const getSchemaExtension = ({ initFirstItem?: InitFirstItemConfig; passwordResetLink?: AuthTokenTypeConfig; magicAuthLink?: AuthTokenTypeConfig; - sessionData: string; + sessionStrategy: SessionStrategy; }): ExtendGraphqlSchema => graphql.extend(base => { const uniqueWhereInputType = assertInputObjectType( @@ -82,6 +88,7 @@ export const getSchemaExtension = ({ gqlNames, secretFieldImpl: getSecretFieldImpl(base.schema, listKey, secretField), base, + sessionStrategy, }); // technically this will incorrectly error if someone has a schema extension that adds a field to the list output type @@ -94,7 +101,7 @@ export const getSchemaExtension = ({ // this isn't used to get the itemQueryName and we don't know it here pluralGraphQLName: '', }).itemQueryName - }(where: { id: $id }) { ${sessionData} } }`; + }(where: { id: $id }) { ${sessionStrategy.data} } }`; try { ast = parse(query); } catch (err) { @@ -122,6 +129,7 @@ export const getSchemaExtension = ({ gqlNames, graphQLSchema: base.schema, ItemAuthenticationWithPasswordSuccess: baseSchema.ItemAuthenticationWithPasswordSuccess, + sessionStrategy, }), passwordResetLink && getPasswordResetSchema({ @@ -144,6 +152,7 @@ export const getSchemaExtension = ({ gqlNames, magicAuthTokenSecretFieldImpl: getSecretFieldImpl(base.schema, listKey, 'magicAuthToken'), base, + sessionStrategy, }), ].filter((x): x is Exclude => x !== undefined); }); diff --git a/packages/auth/src/session/index.ts b/packages/auth/src/session/index.ts index a68da83924e..b6f6a070ed9 100644 --- a/packages/auth/src/session/index.ts +++ b/packages/auth/src/session/index.ts @@ -2,7 +2,7 @@ import * as cookie from 'cookie'; import Iron from '@hapi/iron'; // uid-safe is what express-session uses so let's just use it import { sync as uid } from 'uid-safe'; -import { SessionStrategy, JSONValue, SessionStoreFunction } from '@keystone-6/core/types'; +import { SessionStrategy, JSONValue, SessionStoreFunction } from '../types'; function generateSessionId() { return uid(24); @@ -13,6 +13,7 @@ const MAX_AGE = 60 * 60 * 8; // 8 hours // should we also accept httpOnly? type StatelessSessionsOptions = { + data?: string; /** * Secret used by https://github.com/hapijs/iron for encapsulating data. Must be at least 32 characters long */ @@ -69,6 +70,7 @@ export function statelessSessions({ ironOptions = Iron.defaults, domain, sameSite = 'lax', + data = 'id', }: StatelessSessionsOptions): SessionStrategy { if (!secret) { throw new Error('You must specify a session secret to use sessions'); @@ -77,6 +79,7 @@ export function statelessSessions({ throw new Error('The session secret must be at least 32 characters long'); } return { + data, async get({ context }) { if (!context?.req) { return; @@ -129,11 +132,13 @@ export function statelessSessions({ export function storedSessions({ store: storeOption, maxAge = MAX_AGE, + data, ...statelessSessionsOptions }: { store: SessionStoreFunction } & StatelessSessionsOptions): SessionStrategy { - let { get, start, end } = statelessSessions({ ...statelessSessionsOptions, maxAge }); + let { get, start, end } = statelessSessions({ ...statelessSessionsOptions, maxAge, data }); let store = storeOption({ maxAge }); return { + data, async get({ context }) { const data = (await get({ context })) as { sessionId: string } | undefined; const sessionId = data?.sessionId; diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index ee1e354afbc..5733165d1ba 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -35,6 +35,43 @@ export type AuthTokenTypeConfig = { tokensValidForMins?: number; }; +export type JSONValue = + | string + | number + | boolean + | null + | readonly JSONValue[] + | { [key: string]: JSONValue }; + +export type SessionStrategy = { + data?: string; + // creates token from data, sets the cookie with token via res, returns token + start: (args: { + data: StoredSessionData | StartSessionData; + context: KeystoneContext; + }) => Promise; + // resets the cookie via res + end: (args: { context: KeystoneContext }) => Promise; + // -- this one is invoked at the start of every request + // reads the token, gets the data, returns it + get: (args: { context: KeystoneContext }) => Promise; +}; + +export type SessionStore = { + get(key: string): undefined | JSONValue | Promise; + // 😞 using any here rather than void to be compatible with Map. note that `| Promise` doesn't actually do anything type wise because it just turns into any, it's just to show intent here + set(key: string, value: JSONValue): any | Promise; + // 😞 | boolean is for compatibility with Map + delete(key: string): void | boolean | Promise; +}; + +export type SessionStoreFunction = (args: { + /** + * The number of seconds that a cookie session be valid for + */ + maxAge: number; +}) => SessionStore; + export type AuthConfig = { /** The key of the list to authenticate users with */ listKey: ListTypeInfo['key']; @@ -49,7 +86,7 @@ export type AuthConfig = { /** "Magic link" functionality */ magicAuthLink?: AuthTokenTypeConfig; /** Session data population */ - sessionData?: string; + sessionStrategy: SessionStrategy; }; export type InitFirstItemConfig = { diff --git a/packages/auth/types/package.json b/packages/auth/types/package.json new file mode 100644 index 00000000000..337875520a6 --- /dev/null +++ b/packages/auth/types/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/keystone-6-auth-types.cjs.js", + "module": "dist/keystone-6-auth-types.esm.js" +} 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/___internal-do-not-use-will-break-in-patch/next-graphql.ts b/packages/core/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts index 8da795cdc39..244e486c112 100644 --- a/packages/core/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts +++ b/packages/core/src/___internal-do-not-use-will-break-in-patch/next-graphql.ts @@ -17,7 +17,6 @@ export function nextGraphQLAPIRoute(keystoneConfig: KeystoneConfig, prismaClient const apolloServer = createApolloServerMicro({ graphQLSchema, context, - sessionStrategy: initializedKeystoneConfig.session, graphqlConfig: initializedKeystoneConfig.graphql, connectionPromise: connect(), }); diff --git a/packages/core/src/admin-ui/templates/index.ts b/packages/core/src/admin-ui/templates/index.ts index 1d4a56714f3..6d82265e376 100644 --- a/packages/core/src/admin-ui/templates/index.ts +++ b/packages/core/src/admin-ui/templates/index.ts @@ -27,7 +27,7 @@ export const writeAdminFiles = ( inputPath: Path.join(pkgDir, 'static', 'favicon.ico'), outputPath: 'public/favicon.ico', }, - { mode: 'write', src: noAccessTemplate(config.session), outputPath: 'pages/no-access.js' }, + { mode: 'write', src: noAccessTemplate(config.getSession), outputPath: 'pages/no-access.js' }, { mode: 'write', src: appTemplate( diff --git a/packages/core/src/admin-ui/templates/no-access.ts b/packages/core/src/admin-ui/templates/no-access.ts index 38302a0f00b..26bccb9ccfb 100644 --- a/packages/core/src/admin-ui/templates/no-access.ts +++ b/packages/core/src/admin-ui/templates/no-access.ts @@ -1,6 +1,6 @@ import type { KeystoneConfig } from '../../types'; -export const noAccessTemplate = (session: KeystoneConfig['session']) => +export const noAccessTemplate = (session: KeystoneConfig['getSession']) => `import { getNoAccessPage } from '@keystone-6/core/___internal-do-not-use-will-break-in-patch/admin-ui/pages/NoAccessPage'; export default getNoAccessPage(${JSON.stringify({ sessionsEnabled: !!session })}) diff --git a/packages/core/src/lib/context/createContext.ts b/packages/core/src/lib/context/createContext.ts index bc581777af5..e57641f6ed8 100644 --- a/packages/core/src/lib/context/createContext.ts +++ b/packages/core/src/lib/context/createContext.ts @@ -78,10 +78,10 @@ export function makeCreateContext({ async function withRequest(req: IncomingMessage, res?: ServerResponse) { contextToReturn.req = req; contextToReturn.res = res; - if (!config.session) { + if (!config.getSession) { return contextToReturn; } - contextToReturn.session = await config.session.get({ context: contextToReturn }); + contextToReturn.session = await config.getSession({ context: contextToReturn }); return createContext({ session: contextToReturn.session, sudo, req, res }); } const dbAPI: KeystoneContext['db'] = {}; @@ -91,7 +91,7 @@ export function makeCreateContext({ query: itemAPI, prisma: prismaClient, graphql: { raw: rawGraphQL, run: runGraphQL, schema }, - sessionStrategy: config.session, + getSession: config.getSession, sudo: () => createContext({ sudo: true, req, res }), exitSudo: () => createContext({ sudo: false, req, res }), withSession: session => { diff --git a/packages/core/src/lib/createGraphQLSchema.ts b/packages/core/src/lib/createGraphQLSchema.ts index cd86a8d0549..853ca02c03f 100644 --- a/packages/core/src/lib/createGraphQLSchema.ts +++ b/packages/core/src/lib/createGraphQLSchema.ts @@ -12,19 +12,7 @@ export function createGraphQLSchema( ) { // Start with the core keystone graphQL schema let graphQLSchema = getGraphQLSchema(lists, { - mutation: config.session - ? { - endSession: graphql.field({ - type: graphql.nonNull(graphql.Boolean), - async resolve(rootVal, args, context) { - if (context.sessionStrategy) { - await context.sessionStrategy.end({ context }); - } - return true; - }, - }), - } - : {}, + mutation: {}, query: { keystone: graphql.field({ type: graphql.nonNull(KeystoneMeta), diff --git a/packages/core/src/lib/server/createAdminUIMiddleware.ts b/packages/core/src/lib/server/createAdminUIMiddleware.ts index 0ee2564e3b6..4b9a3e136c6 100644 --- a/packages/core/src/lib/server/createAdminUIMiddleware.ts +++ b/packages/core/src/lib/server/createAdminUIMiddleware.ts @@ -31,7 +31,7 @@ export function createAdminUIMiddlewareWithNextApp( ) { const handle = nextApp.getRequestHandler(); - const { ui, session } = config; + const { ui, getSession } = config; const publicPages = ui?.publicPages ?? []; return async (req: express.Request, res: express.Response) => { const { pathname } = url.parse(req.url); @@ -43,7 +43,7 @@ export function createAdminUIMiddlewareWithNextApp( const userContext = await context.withRequest(req, res); const isValidSession = ui?.isAccessAllowed ? await ui.isAccessAllowed(userContext) - : session + : getSession ? context.session !== undefined : true; const shouldRedirect = await ui?.pageMiddleware?.({ context, isValidSession }); diff --git a/packages/core/src/lib/server/createApolloServer.ts b/packages/core/src/lib/server/createApolloServer.ts index 79f2b139f1a..028ec7374ce 100644 --- a/packages/core/src/lib/server/createApolloServer.ts +++ b/packages/core/src/lib/server/createApolloServer.ts @@ -7,18 +7,16 @@ import { ApolloServerPluginLandingPageGraphQLPlayground, Config, } from 'apollo-server-core'; -import type { KeystoneContext, GraphQLConfig, SessionStrategy } from '../../types'; +import type { KeystoneContext, GraphQLConfig } from '../../types'; export const createApolloServerMicro = ({ graphQLSchema, context, - sessionStrategy, graphqlConfig, connectionPromise, }: { graphQLSchema: GraphQLSchema; context: KeystoneContext; - sessionStrategy?: SessionStrategy; graphqlConfig?: GraphQLConfig; connectionPromise: Promise; }) => { @@ -33,12 +31,10 @@ export const createApolloServerMicro = ({ export const createApolloServerExpress = ({ graphQLSchema, context, - sessionStrategy, graphqlConfig, }: { graphQLSchema: GraphQLSchema; context: KeystoneContext; - sessionStrategy?: SessionStrategy; graphqlConfig?: GraphQLConfig; }) => { const userContext = async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => diff --git a/packages/core/src/lib/server/createExpressServer.ts b/packages/core/src/lib/server/createExpressServer.ts index 60fb0456435..60eee0898f2 100644 --- a/packages/core/src/lib/server/createExpressServer.ts +++ b/packages/core/src/lib/server/createExpressServer.ts @@ -5,7 +5,7 @@ import { GraphQLSchema } from 'graphql'; // @ts-ignore import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'; import { ApolloServer } from 'apollo-server-express'; -import type { KeystoneConfig, KeystoneContext, SessionStrategy, GraphQLConfig } from '../../types'; +import type { KeystoneConfig, KeystoneContext, GraphQLConfig } from '../../types'; import { createApolloServerExpress } from './createApolloServer'; import { addHealthCheck } from './addHealthCheck'; @@ -24,20 +24,17 @@ const addApolloServer = async ({ config, graphQLSchema, context, - sessionStrategy, graphqlConfig, }: { server: express.Express; config: KeystoneConfig; graphQLSchema: GraphQLSchema; context: KeystoneContext; - sessionStrategy?: SessionStrategy; graphqlConfig?: GraphQLConfig; }) => { const apolloServer = createApolloServerExpress({ graphQLSchema, context, - sessionStrategy, graphqlConfig, }); @@ -112,7 +109,6 @@ export const createExpressServer = async ( config, graphQLSchema, context, - sessionStrategy: config.session, graphqlConfig: config.graphql, }); diff --git a/packages/core/src/types/config/index.ts b/packages/core/src/types/config/index.ts index 6dcf6b5ff28..9ed12ae293e 100644 --- a/packages/core/src/types/config/index.ts +++ b/packages/core/src/types/config/index.ts @@ -8,7 +8,6 @@ import type { Options as BodyParserOptions } from 'body-parser'; import type { AssetMode, BaseKeystoneTypeInfo, KeystoneContext, DatabaseProvider } from '..'; -import { SessionStrategy } from '../session'; import type { MaybePromise } from '../utils'; import type { ListSchemaConfig, @@ -84,7 +83,7 @@ export type KeystoneConfig; ui?: AdminUIConfig; server?: ServerConfig; - session?: SessionStrategy; + getSession?: (args: { context: KeystoneContext }) => Promise; graphql?: GraphQLConfig; extendGraphqlSchema?: ExtendGraphqlSchema; /** An object containing configuration about keystone's various external storages. diff --git a/packages/core/src/types/context.ts b/packages/core/src/types/context.ts index 105c4d3c9ad..9f2e1105afc 100644 --- a/packages/core/src/types/context.ts +++ b/packages/core/src/types/context.ts @@ -4,7 +4,7 @@ import { GraphQLSchema, ExecutionResult, DocumentNode } from 'graphql'; import { TypedDocumentNode } from '@graphql-typed-document-node/core'; import { InitialisedList } from '../lib/core/types-for-lists'; import { BaseListTypeInfo } from './type-info'; -import { GqlNames, BaseKeystoneTypeInfo, SessionStrategy } from '.'; +import { GqlNames, BaseKeystoneTypeInfo } from '.'; export type KeystoneContext = { req?: IncomingMessage; @@ -27,7 +27,7 @@ export type KeystoneContext; }; - sessionStrategy?: SessionStrategy; + getSession?: (args: { context: KeystoneContext }) => Promise; session?: any; }; diff --git a/tests/api-tests/access-control/schema-utils.ts b/tests/api-tests/access-control/schema-utils.ts index 445b457bd5f..10f835fddf4 100644 --- a/tests/api-tests/access-control/schema-utils.ts +++ b/tests/api-tests/access-control/schema-utils.ts @@ -123,7 +123,7 @@ lists.RelatedToAll = list({ const config = apiTestConfig({ lists, - session: statelessSessions({ secret: COOKIE_SECRET }), + getSession: statelessSessions({ secret: COOKIE_SECRET, data: 'id' }).get, ui: { isAccessAllowed: () => true, }, diff --git a/tests/api-tests/access-control/utils.ts b/tests/api-tests/access-control/utils.ts index e3757bb2f63..9c90dc854ee 100644 --- a/tests/api-tests/access-control/utils.ts +++ b/tests/api-tests/access-control/utils.ts @@ -174,13 +174,12 @@ const auth = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id', + sessionStrategy: statelessSessions({ secret: COOKIE_SECRET, data: 'id' }), }); const config = auth.withAuth( apiTestConfig({ lists, - session: statelessSessions({ secret: COOKIE_SECRET }), }) ); diff --git a/tests/api-tests/auth-header.test.ts b/tests/api-tests/auth-header.test.ts index b95dadf80ea..c281aa8f19e 100644 --- a/tests/api-tests/auth-header.test.ts +++ b/tests/api-tests/auth-header.test.ts @@ -23,7 +23,7 @@ function setup(options?: any) { listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id', + sessionStrategy: statelessSessions({ secret: COOKIE_SECRET, data: 'id' }), ...options, }); @@ -48,7 +48,6 @@ function setup(options?: any) { }, }), }, - session: statelessSessions({ secret: COOKIE_SECRET }), }) ), }) @@ -103,7 +102,7 @@ describe('Auth testing', () => { listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id', + sessionStrategy: statelessSessions({ secret: COOKIE_SECRET, data: 'id' }), }); await expect( setupTestEnv({ @@ -119,8 +118,6 @@ describe('Auth testing', () => { }, }), }, - - session: statelessSessions({ secret: COOKIE_SECRET }), }) ), }) diff --git a/tests/api-tests/auth.test.ts b/tests/api-tests/auth.test.ts index b2aa3fc8d55..e9a20848193 100644 --- a/tests/api-tests/auth.test.ts +++ b/tests/api-tests/auth.test.ts @@ -23,7 +23,7 @@ const auth = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', - sessionData: 'id name', + sessionStrategy: statelessSessions({ secret: COOKIE_SECRET, data: 'id name' }), initFirstItem: { fields: ['email', 'password'], itemData: { name: 'First User' } }, magicAuthLink: { sendToken: async ({ identity, token }) => { @@ -59,7 +59,6 @@ const runner = withServer( }, }), }, - session: statelessSessions({ secret: COOKIE_SECRET }), }) ), })