Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions examples/custom-session-api-key/README.md
Original file line number Diff line number Diff line change
@@ -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: <user-id>
Keystone-Example-API-Key-Secret: <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: <user-id>
Keystone-Example-API-Key-Secret: <api-key-secret>
```
20 changes: 20 additions & 0 deletions examples/custom-session-api-key/api-key-field/index.ts
Original file line number Diff line number Diff line change
@@ -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<ListTypeInfo extends BaseListTypeInfo>(
config: PasswordFieldConfig<ListTypeInfo> = {}
): FieldTypeFunc<ListTypeInfo> {
return corePassword(config)
}

export function apiKey<ListTypeInfo extends BaseListTypeInfo>(
config: PasswordFieldConfig<ListTypeInfo> = {}
): FieldTypeFunc<ListTypeInfo> {
const field = corePassword(config)

return meta => ({
...field(meta),
views: './api-key-field/views',
})
}
122 changes: 122 additions & 0 deletions examples/custom-session-api-key/api-key-field/views.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<code
className={css({
backgroundColor: tokenSchema.color.alias.backgroundHovered,
borderRadius: tokenSchema.size.radius.xsmall,
color: tokenSchema.color.foreground.neutralEmphasis,
fontFamily: tokenSchema.typography.fontFamily.code,
paddingInline: tokenSchema.size.space.xsmall,
})}
>
{props.children}
</code>
)
}

function validate(value: FieldProps<typeof controller>['value'], field: ReturnType<typeof controller>) {
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<typeof controller>) {
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 : '<user-id>'

const editingValue = value.kind === 'editing' ? value.value : ''
const displayValue = secureTextEntry ? editingValue.replace(/./g, '•') : editingValue
const secretPreview = editingValue ? editingValue.replace(/./g, '*') : '<api-key-secret>'
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 (
<VStack role="group" aria-labelledby={labelId} gap="medium" minWidth={0}>
<FieldLabel elementType="span" id={labelId}>
{field.label}
</FieldLabel>
<VStack gap="small">
<Text size="regular" color="neutralSecondary">
Send <InlineCode>Keystone-Example-API-Key-ID: {itemId}</InlineCode>
</Text>
<Text size="regular" color="neutralSecondary">
Send <InlineCode>Keystone-Example-API-Key-Secret: {secretPreview}</InlineCode>
</Text>
</VStack>
<Flex gap="regular" alignItems="end">
<TextField
autoFocus={autoFocus}
aria-label={field.label}
isReadOnly
onBlur={() => setTouched(true)}
placeholder="API key secret"
value={displayValue}
flex
/>
{!!editingValue && (
<TooltipTrigger placement="top end">
<ToggleButton
aria-label="Show API key secret"
isSelected={!secureTextEntry}
onPress={() => setSecureTextEntry(bool => !bool)}
>
<Icon src={eyeIcon} />
</ToggleButton>
<Tooltip>Show API key secret</Tooltip>
</TooltipTrigger>
)}
{onChange && (
<TooltipTrigger placement="top end">
<ActionButton aria-label="Generate API key secret" onPress={generateApiKeySecret}>
<Icon src={rotateCwIcon} />
</ActionButton>
<Tooltip>Generate API key secret</Tooltip>
</TooltipTrigger>
)}
</Flex>
{!!editingValue && (
<Text size="small" color="critical">
Copy this API key secret now. It cannot be accessed again after saving.
</Text>
)}
{!!validationMessage && <FieldMessage>{validationMessage}</FieldMessage>}
</VStack>
)
}
133 changes: 133 additions & 0 deletions examples/custom-session-api-key/keystone.ts
Original file line number Diff line number Diff line change
@@ -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<Session>({
// 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<Session | undefined> {
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<Session, TypeInfo>

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<Session, TypeInfo>

export default withAuth<TypeInfo<Session>>(
config<TypeInfo>({
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,
})
)
25 changes: 25 additions & 0 deletions examples/custom-session-api-key/package.json
Original file line number Diff line number Diff line change
@@ -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:"
}
}
Loading
Loading