diff --git a/examples/custom-session-api-key/README.md b/examples/custom-session-api-key/README.md new file mode 100644 index 00000000000..33dc8c85195 --- /dev/null +++ b/examples/custom-session-api-key/README.md @@ -0,0 +1,31 @@ +## Feature Example - API Key Session Strategy + +This project demonstrates how to compose session strategies. + +Users can sign in with the standard `@keystone-6/auth` password flow. API requests can also authenticate with example-specific API key headers: + +```shell +Keystone-Example-API-Key-ID: +Keystone-Example-API-Key-Secret: +``` + +The headers are branded for this example so they do not collide with other platform or proxy headers. + +The API key secret is stored in a Keystone `password` field, so Keystone hashes it before writing to the database. The `apiKeyExpiresAt` field controls expiry. + +## Instructions + +To run this project, clone the Keystone repository locally, run `pnpm install` at the root of the repository then navigate to this directory and run: + +```shell +pnpm dev +``` + +This will start the Admin UI at [localhost:3000](http://localhost:3000). + +Create a user, generate an API key secret, set an expiry, then make GraphQL requests with: + +```shell +Keystone-Example-API-Key-ID: +Keystone-Example-API-Key-Secret: +``` diff --git a/examples/custom-session-api-key/api-key-field/index.ts b/examples/custom-session-api-key/api-key-field/index.ts new file mode 100644 index 00000000000..c2a7f0d2fe2 --- /dev/null +++ b/examples/custom-session-api-key/api-key-field/index.ts @@ -0,0 +1,20 @@ +import { password as corePassword } from '@keystone-6/core/fields' +import type { BaseListTypeInfo, FieldTypeFunc } from '@keystone-6/core/types' +import type { PasswordFieldConfig } from '@keystone-6/core/fields/types/password' + +export function password( + config: PasswordFieldConfig = {} +): FieldTypeFunc { + return corePassword(config) +} + +export function apiKey( + config: PasswordFieldConfig = {} +): FieldTypeFunc { + const field = corePassword(config) + + return meta => ({ + ...field(meta), + views: './api-key-field/views', + }) +} diff --git a/examples/custom-session-api-key/api-key-field/views.tsx b/examples/custom-session-api-key/api-key-field/views.tsx new file mode 100644 index 00000000000..286e8164e95 --- /dev/null +++ b/examples/custom-session-api-key/api-key-field/views.tsx @@ -0,0 +1,122 @@ +import { useId, useState } from 'react' +import { Cell, controller } from '@keystone-6/core/fields/types/password/views' +import { ActionButton, ToggleButton } from '@keystar/ui/button' +import { FieldLabel, FieldMessage } from '@keystar/ui/field' +import { Icon } from '@keystar/ui/icon' +import { eyeIcon } from '@keystar/ui/icon/icons/eyeIcon' +import { rotateCwIcon } from '@keystar/ui/icon/icons/rotateCwIcon' +import { Flex, VStack } from '@keystar/ui/layout' +import { css, tokenSchema } from '@keystar/ui/style' +import { TextField } from '@keystar/ui/text-field' +import { Tooltip, TooltipTrigger } from '@keystar/ui/tooltip' +import { Text } from '@keystar/ui/typography' +import type { FieldProps } from '@keystone-6/core/types' + +export { Cell, controller } + +function InlineCode(props: { children: string }) { + return ( + + {props.children} + + ) +} + +function validate(value: FieldProps['value'], field: ReturnType) { + if (value.kind === 'initial') return + if (value.value.length < field.validation.length.min) { + return `${field.label} must be at least ${field.validation.length.min} characters long` + } + if (field.validation.length.max !== null && value.value.length > field.validation.length.max) { + return `${field.label} must be no longer than ${field.validation.length.max} characters` + } + if (field.validation.match && !field.validation.match.regex.test(value.value)) { + return field.validation.match.explanation + } +} + +function generateSecret() { + const bytes = new Uint8Array(32) + crypto.getRandomValues(bytes) + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('') +} + +export function Field(props: FieldProps) { + const { autoFocus, field, forceValidation, onChange, value } = props + const [secureTextEntry, setSecureTextEntry] = useState(true) + const [touched, setTouched] = useState(false) + const labelId = useId() + const itemId = typeof props.itemValue.id === 'string' ? props.itemValue.id : '' + + const editingValue = value.kind === 'editing' ? value.value : '' + const displayValue = secureTextEntry ? editingValue.replace(/./g, '•') : editingValue + const secretPreview = editingValue ? editingValue.replace(/./g, '*') : '' + const validationMessage = forceValidation || touched ? validate(value, field) : undefined + + const generateApiKeySecret = () => { + const secret = generateSecret() + setSecureTextEntry(false) + onChange?.({ kind: 'editing', isSet: value.isSet, value: secret, confirm: secret }) + } + + return ( + + + {field.label} + + + + Send Keystone-Example-API-Key-ID: {itemId} + + + Send Keystone-Example-API-Key-Secret: {secretPreview} + + + + setTouched(true)} + placeholder="API key secret" + value={displayValue} + flex + /> + {!!editingValue && ( + + setSecureTextEntry(bool => !bool)} + > + + + Show API key secret + + )} + {onChange && ( + + + + + Generate API key secret + + )} + + {!!editingValue && ( + + Copy this API key secret now. It cannot be accessed again after saving. + + )} + {!!validationMessage && {validationMessage}} + + ) +} diff --git a/examples/custom-session-api-key/keystone.ts b/examples/custom-session-api-key/keystone.ts new file mode 100644 index 00000000000..fac72856fe5 --- /dev/null +++ b/examples/custom-session-api-key/keystone.ts @@ -0,0 +1,133 @@ +import { config } from '@keystone-6/core' +import { statelessSessions } from '@keystone-6/core/session' +import { createAuth } from '@keystone-6/auth' +import { getPasswordFieldKDF } from '@keystone-6/core/fields/types/password' +import type { SessionStrategy } from '@keystone-6/core/types' +import { type Session, lists } from './schema' +import type { Context, 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 + listKey: 'User', + + // an identity field, typically a username or an email address + identityField: 'name', + + // a secret field must be a password field type + secretField: 'password', + + // initFirstItem enables the "First User" experience, this will add an interface form + // adding a new User item if the database is empty + // + // WARNING: do not use initFirstItem in production + // see https://keystonejs.com/docs/config/auth#init-first-item for more + initFirstItem: { + // the following fields are used by the "Create First User" form + fields: ['name', 'password'], + + // the following fields are configured by default for this item + itemData: { + // isAdmin is true, so the admin can pass isAccessAllowed (see below) + isAdmin: true, + }, + }, + + // add isAdmin to the session data + sessionData: 'isAdmin', +}) + +const cookieSessionStrategy = 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 getHeaderValue(header: string | string[] | undefined) { + if (Array.isArray(header)) return + return header +} + +function getApiKeyCredentials(headers: Context['req']['headers']) { + const itemId = getHeaderValue(headers['keystone-example-api-key-id']) + const secret = getHeaderValue(headers['keystone-example-api-key-secret']) + if (!itemId) return + if (!secret) return + + return { itemId, secret } +} + +const apiKeySessionStrategy = { + async get({ context }: { context: Context }): Promise { + if (!context.req) return + + const credentials = getApiKeyCredentials(context.req.headers) + if (!credentials) return + + const user = await context.sudo().prisma.user.findUnique({ + where: { id: credentials.itemId }, + select: { id: true, apiKey: true, apiKeyExpiresAt: true, isAdmin: true }, + }) + if (!user?.apiKey) return + if (!user.apiKeyExpiresAt) return + if (new Date(user.apiKeyExpiresAt) <= new Date()) return + + const kdf = getPasswordFieldKDF(context.graphql.schema, 'User', 'apiKey') + if (!kdf) return + if (!(await kdf.compare(credentials.secret, user.apiKey))) return + + return { + itemId: user.id, + data: { + isAdmin: user.isAdmin, + }, + } + }, + async start() {}, + async end() {}, +} satisfies SessionStrategy + +const composedSessionStrategy = { + async get({ context }: { context: Context }) { + const apiKeySession = await apiKeySessionStrategy.get({ context }) + if (apiKeySession) return apiKeySession + + return cookieSessionStrategy.get({ context }) + }, + start: cookieSessionStrategy.start, + end: cookieSessionStrategy.end, +} satisfies SessionStrategy + +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, + ui: { + // only admins can view the AdminUI + isAccessAllowed: context => { + return context.session?.data?.isAdmin ?? false + }, + }, + // you can find out more at https://keystonejs.com/docs/apis/session#session-api + session: composedSessionStrategy, + }) +) diff --git a/examples/custom-session-api-key/package.json b/examples/custom-session-api-key/package.json new file mode 100644 index 00000000000..ff19572cb14 --- /dev/null +++ b/examples/custom-session-api-key/package.json @@ -0,0 +1,25 @@ +{ + "name": "@keystone-6/example-custom-session-api-key", + "version": null, + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "start": "keystone start", + "build": "keystone build", + "check": "keystone postinstall" + }, + "dependencies": { + "@keystone-6/auth": "workspace:^", + "@keystone-6/core": "workspace:^", + "@keystar/ui": "^0.7.22", + "@prisma/client": "catalog:", + "next": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "prisma": "catalog:", + "typescript": "catalog:" + } +} diff --git a/examples/custom-session-api-key/schema.graphql b/examples/custom-session-api-key/schema.graphql new file mode 100644 index 00000000000..8dba4fc11c3 --- /dev/null +++ b/examples/custom-session-api-key/schema.graphql @@ -0,0 +1,290 @@ +# This file is automatically generated by Keystone, do not modify it manually. +# Modify your Keystone config when you want to change this. + +type User { + id: ID! + name: String + password: PasswordState + apiKey: PasswordState + apiKeyExpiresAt: DateTime + isAdmin: Boolean +} + +type PasswordState { + isSet: Boolean! +} + +scalar DateTime @specifiedBy(url: "https://datatracker.ietf.org/doc/html/rfc3339#section-5.6") + +input UserWhereUniqueInput { + id: ID +} + +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] + id: IDFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input UserOrderByInput { + id: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input UserUpdateInput { + name: String + password: String + apiKey: String + apiKeyExpiresAt: DateTime + isAdmin: Boolean +} + +input UserUpdateArgs { + where: UserWhereUniqueInput! + data: UserUpdateInput! +} + +input UserCreateInput { + name: String + password: String +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type Mutation { + createUser(data: UserCreateInput!): User + createUsers(data: [UserCreateInput!]!): [User] + updateUser(where: UserWhereUniqueInput!, data: UserUpdateInput!): User + updateUsers(data: [UserUpdateArgs!]!): [User] + deleteUser(where: UserWhereUniqueInput!): User + deleteUsers(where: [UserWhereUniqueInput!]!): [User] + endSession: Boolean! + authenticateUserWithPassword(name: String!, password: String!): UserAuthenticationWithPasswordResult + createInitialUser(data: CreateInitialUserInput!): UserAuthenticationWithPasswordSuccess! +} + +union UserAuthenticationWithPasswordResult = UserAuthenticationWithPasswordSuccess | UserAuthenticationWithPasswordFailure + +type UserAuthenticationWithPasswordSuccess { + sessionToken: String! + item: User! +} + +type UserAuthenticationWithPasswordFailure { + message: String! +} + +input CreateInitialUserInput { + name: String + password: String +} + +type Query { + user(where: UserWhereUniqueInput!): User + users(where: UserWhereInput! = {}, orderBy: [UserOrderByInput!]! = [], take: Int, skip: Int! = 0, cursor: UserWhereUniqueInput): [User!] + usersCount(where: UserWhereInput! = {}): Int + keystone: KeystoneMeta! + authenticatedItem: User +} + +type KeystoneMeta { + adminMeta: KeystoneAdminMeta! +} + +type KeystoneAdminMeta { + lists: [KeystoneAdminUIListMeta!]! + list(key: String!, itemId: ID): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + label: String! + singular: String! + plural: String! + path: String! + labelField: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + actions: [KeystoneAdminUIActionMeta!]! + graphql: KeystoneAdminUIGraphQL! + pageSize: Int! + initialColumns: [String!]! + initialSearchFields: [String!]! + initialSort: KeystoneAdminUISort + initialFilter: JSON + hiddenFilter: JSON + isSingleton: Boolean! + hideNavigation: Boolean! + hideCreate: Boolean! + hideDelete: Boolean! +} + +type KeystoneAdminUIFieldMeta { + key: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + itemView: KeystoneAdminUIFieldMetaItemView + listView: KeystoneAdminUIFieldMetaListView! + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: JSON! + isRequired: JSON! +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: JSON! + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition! + isRequired: JSON! +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +type KeystoneAdminUIActionMeta { + key: String! + label: String! + icon: String + messages: KeystoneAdminUIActionMetaMessages! + graphql: KeystoneAdminUIActionMetaGraphQL + itemView: KeystoneAdminUIActionMetaItemView + listView: KeystoneAdminUIActionMetaListView! +} + +type KeystoneAdminUIActionMetaMessages { + promptTitle: String! + promptTitleMany: String + prompt: String! + promptMany: String + promptConfirmLabel: String! + promptConfirmLabelMany: String + fail: String! + failMany: String + success: String! + successMany: String +} + +type KeystoneAdminUIActionMetaGraphQL { + arguments: [KeystoneAdminUIActionMetaGraphQLArgument!]! + names: KeystoneAdminUIActionMetaGraphQLNames! +} + +type KeystoneAdminUIActionMetaGraphQLArgument { + name: String! + type: String! + source: JSON +} + +type KeystoneAdminUIActionMetaGraphQLNames { + one: String! + many: String +} + +type KeystoneAdminUIActionMetaItemView { + actionMode: JSON! + navigation: KeystoneAdminUIActionMetaItemViewNavigation! + hidePrompt: Boolean! + hideToast: Boolean! +} + +enum KeystoneAdminUIActionMetaItemViewNavigation { + follow + refetch + return +} + +type KeystoneAdminUIActionMetaListView { + actionMode: JSON! +} + +type KeystoneAdminUIGraphQL { + names: KeystoneAdminUIGraphQLNames! +} + +type KeystoneAdminUIGraphQLNames { + outputTypeName: String! + whereInputName: String! + whereUniqueInputName: String! + createInputName: String! + createMutationName: String! + createManyMutationName: String! + relateToOneForCreateInputName: String! + relateToManyForCreateInputName: String! + itemQueryName: String! + listOrderName: String! + listQueryCountName: String! + listQueryName: String! + updateInputName: String! + updateMutationName: String! + updateManyInputName: String! + updateManyMutationName: String! + relateToOneForUpdateInputName: String! + relateToManyForUpdateInputName: String! + deleteMutationName: String! + deleteManyMutationName: String! +} + +type KeystoneAdminUISort { + field: String! + direction: KeystoneAdminUISortDirection! +} + +enum KeystoneAdminUISortDirection { + ASC + DESC +} diff --git a/examples/custom-session-api-key/schema.prisma b/examples/custom-session-api-key/schema.prisma new file mode 100644 index 00000000000..00fc6c720bc --- /dev/null +++ b/examples/custom-session-api-key/schema.prisma @@ -0,0 +1,22 @@ +// This file is automatically generated by Keystone, do not modify it manually. +// Modify your Keystone config when you want to change this. + +datasource sqlite { + url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") + provider = "sqlite" +} + +generator client { + provider = "prisma-client-js" + output = "node_modules/myprisma" +} + +model User { + id String @id @default(cuid()) + name String @unique @default("") + password String + apiKey String? + apiKeyExpiresAt DateTime? + isAdmin Boolean @default(false) +} diff --git a/examples/custom-session-api-key/schema.ts b/examples/custom-session-api-key/schema.ts new file mode 100644 index 00000000000..b2416a4a23d --- /dev/null +++ b/examples/custom-session-api-key/schema.ts @@ -0,0 +1,224 @@ +import { group, list } from '@keystone-6/core' +import { allowAll, denyAll } from '@keystone-6/core/access' +import { text, checkbox, timestamp } from '@keystone-6/core/fields' +import { apiKey, password } from './api-key-field' +import type { 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 + +export type Session = { + itemId: string + data: { + isAdmin: boolean + } +} + +function hasSession({ session }: { session?: Session }) { + return Boolean(session) +} + +function isAdminOrSameUser({ session, item }: { session?: Session; item: Lists.User.Item | null }) { + // you need to have a session to do this + if (!session) return false + + // admins can do anything + if (session.data.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 +} + +function isAdminOrSameUserFilter({ session }: { session?: Session }) { + // you need to have a session to do this + if (!session) return false + + // admins can see everything + if (session.data?.isAdmin) return {} + + // only yourself + return { + id: { + equals: session.itemId, + }, + } +} + +function isAdmin({ session }: { session?: Session }) { + // you need to have a session to do this + if (!session) return false + + // admins can do anything + if (session.data.isAdmin) return true + + // otherwise, no + return false +} + +export const lists = { + User: list({ + access: { + operation: { + create: allowAll, + query: hasSession, + + // what a user can update is limited by + // the access.filter.* and access.item.* access controls + update: hasSession, + + // only admins can delete users + delete: isAdmin, + }, + filter: { + query: isAdminOrSameUserFilter, + update: isAdminOrSameUserFilter, + }, + item: { + // this is redundant as ^filter.update should stop unauthorised updates + // we include it anyway as a demonstration + update: isAdminOrSameUser, + }, + }, + ui: { + // only show deletion options for admins + hideDelete: args => !isAdmin(args), + listView: { + // the default columns that will be displayed in the list view + initialColumns: ['name', 'isAdmin'], + }, + }, + fields: { + // the user's name, used as the identity field for authentication + // should not be publicly visible + // + // we use isIndexed to enforce names are unique + // that may not be suitable for your application + name: text({ + access: { + // only the respective user, or an admin can read this field + read: isAdminOrSameUser, + + // only admins can update this field + update: isAdmin, + }, + isFilterable: false, + isOrderable: false, + isIndexed: 'unique', + validation: { + isRequired: true, + }, + }), + + // the user's password, used as the secret field for authentication + // should not be publicly visible + password: password({ + access: { + read: denyAll, + update: isAdminOrSameUser, + }, + validation: { + isRequired: true, + }, + ui: { + itemView: { + // don't show this field if it isn't relevant + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + listView: { + // UI-only. Field access above is the security boundary. + fieldMode: 'hidden', + }, + }, + }), + + ...group({ + label: 'API key', + fields: { + // the API key secret is hashed by the password field before it is stored + apiKey: apiKey({ + label: 'API key secret', + access: { + read: isAdminOrSameUser, + create: isAdmin, + update: isAdminOrSameUser, + }, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + validation: { + length: { + min: 32, + }, + }, + ui: { + itemView: { + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + listView: { + fieldMode: 'hidden', + }, + }, + }), + + apiKeyExpiresAt: timestamp({ + label: 'API key expires at', + access: { + read: isAdminOrSameUser, + create: isAdmin, + update: isAdmin, + }, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + isOrderable: false, + ui: { + itemView: { + fieldMode: args => (isAdminOrSameUser(args) ? 'edit' : 'hidden'), + }, + }, + }), + }, + }), + + // a flag to indicate if this user is an admin + // should not be publicly visible + isAdmin: checkbox({ + access: { + // only the respective user, or an admin can read this field + read: isAdminOrSameUser, + + // only admins can create, or update this field + create: isAdmin, + update: isAdmin, + }, + defaultValue: false, + graphql: { + omit: { + create: true, + }, + }, + isFilterable: false, + isOrderable: false, + ui: { + // only admins can edit this field + createView: { + fieldMode: args => (isAdmin(args) ? 'edit' : 'hidden'), + }, + itemView: { + fieldMode: args => (isAdmin(args) ? 'edit' : 'read'), + }, + }, + }), + }, + }), +} satisfies Lists diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83f4ef1540a..5539d72913c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -668,6 +668,34 @@ importers: specifier: 'catalog:' version: 6.0.3 + examples/custom-session-api-key: + dependencies: + '@keystone-6/auth': + specifier: workspace:^ + version: link:../../packages/auth + '@keystone-6/core': + specifier: workspace:^ + version: link:../../packages/core + '@prisma/client': + specifier: 'catalog:' + version: 6.19.3(prisma@6.19.3(magicast@0.3.5)(typescript@6.0.3))(typescript@6.0.3) + next: + specifier: 'catalog:' + version: 16.2.10(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: 'catalog:' + version: 19.2.5 + react-dom: + specifier: 'catalog:' + version: 19.2.5(react@19.2.5) + devDependencies: + prisma: + specifier: 'catalog:' + version: 6.19.3(magicast@0.3.5)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + examples/custom-session-invalidation: dependencies: '@keystone-6/auth':