From a959ef8133951aee9dc0b23f7fe9926931b3309f Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:27:28 +1000 Subject: [PATCH 01/14] remove magicAuthLink and passwordResetLink functionality --- packages/auth/LICENSE | 2 +- .../auth/src/gql/getMagicAuthLinkSchema.ts | 145 ----------------- .../auth/src/gql/getPasswordResetSchema.ts | 153 ------------------ packages/auth/src/index.ts | 57 +------ packages/auth/src/schema.ts | 42 +---- packages/auth/src/types.ts | 16 -- 6 files changed, 13 insertions(+), 402 deletions(-) delete mode 100644 packages/auth/src/gql/getMagicAuthLinkSchema.ts delete mode 100644 packages/auth/src/gql/getPasswordResetSchema.ts diff --git a/packages/auth/LICENSE b/packages/auth/LICENSE index 8f562c46477..8f0e5cd3917 100644 --- a/packages/auth/LICENSE +++ b/packages/auth/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Thinkmill Labs Pty Ltd +Copyright (c) 2024 Thinkmill Labs Pty Ltd Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/auth/src/gql/getMagicAuthLinkSchema.ts b/packages/auth/src/gql/getMagicAuthLinkSchema.ts deleted file mode 100644 index b114ebd8f0b..00000000000 --- a/packages/auth/src/gql/getMagicAuthLinkSchema.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { type BaseItem, type KeystoneContext } from '@keystone-6/core/types' -import { graphql } from '@keystone-6/core' -import type { AuthGqlNames, AuthTokenTypeConfig, SecretFieldImpl } from '../types' - -import { createAuthToken } from '../lib/createAuthToken' -import { validateAuthToken } from '../lib/validateAuthToken' -import { getAuthTokenErrorMessage } from '../lib/getErrorMessage' - -const errorCodes = ['FAILURE', 'TOKEN_EXPIRED', 'TOKEN_REDEEMED'] as const - -const MagicLinkRedemptionErrorCode = graphql.enum({ - name: 'MagicLinkRedemptionErrorCode', - values: graphql.enumValues(errorCodes), -}) - -export function getMagicAuthLinkSchema ({ - listKey, - identityField, - gqlNames, - magicAuthLink, - magicAuthTokenSecretFieldImpl, - base, -}: { - listKey: string - identityField: I - gqlNames: AuthGqlNames - magicAuthLink: AuthTokenTypeConfig - magicAuthTokenSecretFieldImpl: SecretFieldImpl - base: graphql.BaseSchemaMeta - // TODO: type required by pnpm :( -}): graphql.Extension { - const RedeemItemMagicAuthTokenFailure = graphql.object<{ - code:(typeof errorCodes)[number] - message: string - }>()({ - name: gqlNames.RedeemItemMagicAuthTokenFailure, - fields: { - code: graphql.field({ type: graphql.nonNull(MagicLinkRedemptionErrorCode) }), - message: graphql.field({ type: graphql.nonNull(graphql.String) }), - }, - }) - const RedeemItemMagicAuthTokenSuccess = graphql.object<{ token: string, item: BaseItem }>()({ - name: gqlNames.RedeemItemMagicAuthTokenSuccess, - fields: { - token: graphql.field({ type: graphql.nonNull(graphql.String) }), - item: graphql.field({ type: graphql.nonNull(base.object(listKey)) }), - }, - }) - const RedeemItemMagicAuthTokenResult = graphql.union({ - name: gqlNames.RedeemItemMagicAuthTokenResult, - types: [RedeemItemMagicAuthTokenSuccess, RedeemItemMagicAuthTokenFailure], - resolveType (val) { - return 'token' in val - ? gqlNames.RedeemItemMagicAuthTokenSuccess - : gqlNames.RedeemItemMagicAuthTokenFailure - }, - }) - return { - mutation: { - [gqlNames.sendItemMagicAuthLink]: graphql.field({ - type: graphql.nonNull(graphql.Boolean), - args: { [identityField]: graphql.arg({ type: graphql.nonNull(graphql.String) }) }, - async resolve (rootVal, { [identityField]: identity }, context: KeystoneContext) { - const dbItemAPI = context.sudo().db[listKey] - const tokenType = 'magicAuth' - - const result = await createAuthToken(identityField, identity, dbItemAPI) - - // Update system state - if (result.success) { - // Save the token and related info back to the item - const { token, itemId } = result - await dbItemAPI.updateOne({ - where: { id: `${itemId}` }, - data: { - [`${tokenType}Token`]: token, - [`${tokenType}IssuedAt`]: new Date().toISOString(), - [`${tokenType}RedeemedAt`]: null, - }, - }) - - await magicAuthLink.sendToken({ itemId, identity, token, context }) - } - return true - }, - }), - [gqlNames.redeemItemMagicAuthToken]: graphql.field({ - type: graphql.nonNull(RedeemItemMagicAuthTokenResult), - args: { - [identityField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), - token: graphql.arg({ type: graphql.nonNull(graphql.String) }), - }, - - async resolve (rootVal, { [identityField]: identity, token }, context: KeystoneContext) { - 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( - listKey, - magicAuthTokenSecretFieldImpl, - tokenType, - identityField, - identity, - magicAuthLink.tokensValidForMins, - token, - dbItemAPI - ) - - if (!result.success) { - return { - code: result.code, - message: getAuthTokenErrorMessage({ code: result.code }) - } - } - - // Update system state - // Save the token and related info back to the item - await dbItemAPI.updateOne({ - where: { id: result.item.id }, - data: { [`${tokenType}RedeemedAt`]: new Date().toISOString() }, - }) - - const sessionToken = (await context.sessionStrategy.start({ - data: { - listKey, - itemId: result.item.id.toString(), - }, - context, - })) - - // return Failure if sessionStrategy.start() is incompatible - if (typeof sessionToken !== 'string' || sessionToken.length === 0) { - return { code: 'FAILURE', message: 'Failed to start session.' } - } - - return { - token: sessionToken, - item: result.item - } - }, - }), - }, - } -} diff --git a/packages/auth/src/gql/getPasswordResetSchema.ts b/packages/auth/src/gql/getPasswordResetSchema.ts deleted file mode 100644 index 01f73fb4f78..00000000000 --- a/packages/auth/src/gql/getPasswordResetSchema.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { type KeystoneContext } from '@keystone-6/core/types' -import { graphql } from '@keystone-6/core' -import type { AuthGqlNames, AuthTokenTypeConfig, SecretFieldImpl } from '../types' - -import { createAuthToken } from '../lib/createAuthToken' -import { validateAuthToken } from '../lib/validateAuthToken' -import { getAuthTokenErrorMessage } from '../lib/getErrorMessage' - -const errorCodes = ['FAILURE', 'TOKEN_EXPIRED', 'TOKEN_REDEEMED'] as const - -const PasswordResetRedemptionErrorCode = graphql.enum({ - name: 'PasswordResetRedemptionErrorCode', - values: graphql.enumValues(errorCodes), -}) - -export function getPasswordResetSchema ({ - listKey, - identityField, - secretField, - gqlNames, - passwordResetLink, - passwordResetTokenSecretFieldImpl, -}: { - listKey: string - identityField: I - secretField: S - gqlNames: AuthGqlNames - passwordResetLink: AuthTokenTypeConfig - passwordResetTokenSecretFieldImpl: SecretFieldImpl - // TODO: return type required by pnpm :( -}): graphql.Extension { - const getResult = (name: string) => - graphql.object<{ code:(typeof errorCodes)[number], message: string }>()({ - name, - fields: { - code: graphql.field({ type: graphql.nonNull(PasswordResetRedemptionErrorCode) }), - message: graphql.field({ type: graphql.nonNull(graphql.String) }), - }, - }) - - const ValidateItemPasswordResetTokenResult = getResult(gqlNames.ValidateItemPasswordResetTokenResult) - const RedeemItemPasswordResetTokenResult = getResult(gqlNames.RedeemItemPasswordResetTokenResult) - return { - mutation: { - [gqlNames.sendItemPasswordResetLink]: graphql.field({ - type: graphql.nonNull(graphql.Boolean), - args: { [identityField]: graphql.arg({ type: graphql.nonNull(graphql.String) }) }, - async resolve (rootVal, { [identityField]: identity }, context: KeystoneContext) { - const dbItemAPI = context.sudo().db[listKey] - const tokenType = 'passwordReset' - - const result = await createAuthToken(identityField, identity, dbItemAPI) - - // Update system state - if (result.success) { - // Save the token and related info back to the item - const { token, itemId } = result - await dbItemAPI.updateOne({ - where: { id: `${itemId}` }, - data: { - [`${tokenType}Token`]: token, - [`${tokenType}IssuedAt`]: new Date().toISOString(), - [`${tokenType}RedeemedAt`]: null, - }, - }) - - await passwordResetLink.sendToken({ itemId, identity, token, context }) - } - return true - }, - }), - [gqlNames.redeemItemPasswordResetToken]: graphql.field({ - type: RedeemItemPasswordResetTokenResult, - args: { - [identityField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), - token: graphql.arg({ type: graphql.nonNull(graphql.String) }), - [secretField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), - }, - async resolve ( - rootVal, - { [identityField]: identity, token, [secretField]: secret }, - context: KeystoneContext - ) { - const dbItemAPI = context.sudo().db[listKey] - const tokenType = 'passwordReset' - const result = await validateAuthToken( - listKey, - passwordResetTokenSecretFieldImpl, - tokenType, - identityField, - identity, - passwordResetLink.tokensValidForMins, - token, - dbItemAPI - ) - - if (!result.success) { - return { code: result.code, message: getAuthTokenErrorMessage({ code: result.code }) } - } - - // Update system state - const itemId = result.item.id - // Save the token and related info back to the item - await dbItemAPI.updateOne({ - where: { id: itemId }, - data: { [`${tokenType}RedeemedAt`]: new Date().toISOString() }, - }) - - // Save the provided secret. Do this as a separate step as password validation - // may fail, in which case we still want to mark the token as redeemed - // (NB: Is this *really* what we want? -TL) - await dbItemAPI.updateOne({ - where: { id: itemId }, - data: { [secretField]: secret }, - }) - - return null - }, - }), - }, - query: { - [gqlNames.validateItemPasswordResetToken]: graphql.field({ - type: ValidateItemPasswordResetTokenResult, - args: { - [identityField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), - token: graphql.arg({ type: graphql.nonNull(graphql.String) }), - }, - async resolve (rootVal, { [identityField]: identity, token }, context: KeystoneContext) { - const dbItemAPI = context.sudo().db[listKey] - const result = await validateAuthToken( - listKey, - passwordResetTokenSecretFieldImpl, - 'passwordReset', - identityField, - identity, - passwordResetLink.tokensValidForMins, - token, - dbItemAPI - ) - - if (!result.success) { - return { - code: result.code, - message: getAuthTokenErrorMessage({ code: result.code }) - } - } - - return null - }, - }), - }, - } -} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index cf992f91520..0d5cac210a2 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -6,9 +6,11 @@ import type { BaseKeystoneTypeInfo, KeystoneConfig, } from '@keystone-6/core/types' -import { password, timestamp } from '@keystone-6/core/fields' +import { + type AuthConfig, + type AuthGqlNames +} from './types' -import type { AuthConfig, AuthGqlNames } from './types' import { getSchemaExtension } from './schema' import configTemplate from './templates/config' import signinTemplate from './templates/signin' @@ -30,8 +32,6 @@ export function createAuth ({ secretField, initFirstItem, identityField, - magicAuthLink, - passwordResetLink, sessionData = 'id', }: AuthConfig) { const authGqlNames: AuthGqlNames = { @@ -43,52 +43,6 @@ export function createAuth ({ // Initial data CreateInitialInput: `CreateInitial${listKey}Input`, createInitialItem: `createInitial${listKey}`, - // Password reset - sendItemPasswordResetLink: `send${listKey}PasswordResetLink`, - SendItemPasswordResetLinkResult: `Send${listKey}PasswordResetLinkResult`, - validateItemPasswordResetToken: `validate${listKey}PasswordResetToken`, - ValidateItemPasswordResetTokenResult: `Validate${listKey}PasswordResetTokenResult`, - redeemItemPasswordResetToken: `redeem${listKey}PasswordResetToken`, - RedeemItemPasswordResetTokenResult: `Redeem${listKey}PasswordResetTokenResult`, - // Magic auth - sendItemMagicAuthLink: `send${listKey}MagicAuthLink`, - SendItemMagicAuthLinkResult: `Send${listKey}MagicAuthLinkResult`, - redeemItemMagicAuthToken: `redeem${listKey}MagicAuthToken`, - RedeemItemMagicAuthTokenResult: `Redeem${listKey}MagicAuthTokenResult`, - RedeemItemMagicAuthTokenSuccess: `Redeem${listKey}MagicAuthTokenSuccess`, - RedeemItemMagicAuthTokenFailure: `Redeem${listKey}MagicAuthTokenFailure`, - } - - /** - * fields - * - * Fields added to the auth list. - */ - const fieldConfig = { - access: () => false, - ui: { - createView: { fieldMode: 'hidden' }, - itemView: { fieldMode: 'hidden' }, - listView: { fieldMode: 'hidden' }, - }, - } as const - - const authFields = { - ...(passwordResetLink - ? { - passwordResetToken: password({ ...fieldConfig }), - passwordResetIssuedAt: timestamp({ ...fieldConfig }), - passwordResetRedeemedAt: timestamp({ ...fieldConfig }), - } - : null), - - ...(magicAuthLink - ? { - magicAuthToken: password({ ...fieldConfig }), - magicAuthIssuedAt: timestamp({ ...fieldConfig }), - magicAuthRedeemedAt: timestamp({ ...fieldConfig }), - } - : null), } /** @@ -145,8 +99,6 @@ export function createAuth ({ secretField, gqlNames: authGqlNames, initFirstItem, - passwordResetLink, - magicAuthLink, sessionData, }) @@ -320,7 +272,6 @@ export function createAuth ({ ...authListConfig, fields: { ...authListConfig.fields, - ...authFields, }, }, }, diff --git a/packages/auth/src/schema.ts b/packages/auth/src/schema.ts index a7b70ad82db..3c717b53dbb 100644 --- a/packages/auth/src/schema.ts +++ b/packages/auth/src/schema.ts @@ -1,8 +1,6 @@ -import { getGqlNames } from '@keystone-6/core/types' - import { - assertObjectType, type GraphQLSchema, + assertObjectType, assertInputObjectType, GraphQLString, GraphQLID, @@ -10,16 +8,16 @@ import { validate, } from 'graphql' import { graphql } from '@keystone-6/core' -import type { - AuthGqlNames, - AuthTokenTypeConfig, - InitFirstItemConfig, - SecretFieldImpl, +import { getGqlNames } from '@keystone-6/core/types' + +import { + type AuthGqlNames, + type AuthTokenTypeConfig, + type InitFirstItemConfig, + type SecretFieldImpl, } from './types' import { getBaseAuthSchema } from './gql/getBaseAuthSchema' import { getInitFirstItemSchema } from './gql/getInitFirstItemSchema' -import { getPasswordResetSchema } from './gql/getPasswordResetSchema' -import { getMagicAuthLinkSchema } from './gql/getMagicAuthLinkSchema' function assertSecretFieldImpl ( impl: any, @@ -51,8 +49,6 @@ export const getSchemaExtension = ({ secretField, gqlNames, initFirstItem, - passwordResetLink, - magicAuthLink, sessionData, }: { identityField: string @@ -120,27 +116,5 @@ export const getSchemaExtension = ({ graphQLSchema: base.schema, ItemAuthenticationWithPasswordSuccess: baseSchema.ItemAuthenticationWithPasswordSuccess, }), - passwordResetLink && - getPasswordResetSchema({ - identityField, - listKey, - secretField, - passwordResetLink, - gqlNames, - passwordResetTokenSecretFieldImpl: getSecretFieldImpl( - base.schema, - listKey, - 'passwordResetToken' - ), - }), - magicAuthLink && - getMagicAuthLinkSchema({ - identityField, - listKey, - magicAuthLink, - gqlNames, - magicAuthTokenSecretFieldImpl: getSecretFieldImpl(base.schema, listKey, 'magicAuthToken'), - base, - }), ].filter((x): x is Exclude => x !== undefined) }) diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 747faff962c..21a8e0b4ce7 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -7,18 +7,6 @@ export type AuthGqlNames = { ItemAuthenticationWithPasswordResult: string ItemAuthenticationWithPasswordSuccess: string ItemAuthenticationWithPasswordFailure: string - sendItemPasswordResetLink: string - SendItemPasswordResetLinkResult: string - validateItemPasswordResetToken: string - ValidateItemPasswordResetTokenResult: string - redeemItemPasswordResetToken: string - RedeemItemPasswordResetTokenResult: string - sendItemMagicAuthLink: string - SendItemMagicAuthLinkResult: string - redeemItemMagicAuthToken: string - RedeemItemMagicAuthTokenResult: string - RedeemItemMagicAuthTokenSuccess: string - RedeemItemMagicAuthTokenFailure: string } export type SendTokenFn = (args: { @@ -44,10 +32,6 @@ export type AuthConfig = { secretField: ListTypeInfo['fields'] /** The initial user/db seeding functionality */ initFirstItem?: InitFirstItemConfig - /** Password reset link functionality */ - passwordResetLink?: AuthTokenTypeConfig - /** "Magic link" functionality */ - magicAuthLink?: AuthTokenTypeConfig /** Session data population */ sessionData?: string } From 65e4b04a6568c899408cb9261eeb86420da83def Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:41:44 +1000 Subject: [PATCH 02/14] remove related functionality from docs/ --- docs/content/docs/config/auth.md | 259 +-------- tests/api-tests/auth.test.ts | 929 ------------------------------- 2 files changed, 9 insertions(+), 1179 deletions(-) diff --git a/docs/content/docs/config/auth.md b/docs/content/docs/config/auth.md index 606cb0cdc1c..034f9a81d9a 100644 --- a/docs/content/docs/config/auth.md +++ b/docs/content/docs/config/auth.md @@ -9,9 +9,9 @@ Additional options to this function provide support for creating an initial item For examples of how to use authentication in your system please see the [authentication guide](../guides/auth-and-access-control). ```typescript -import { config, list } from '@keystone-6/core'; -import { text, password, checkbox } from '@keystone-6/core/fields'; -import { createAuth } from '@keystone-6/auth'; +import { config, list } from '@keystone-6/core' +import { text, password, checkbox } from '@keystone-6/core/fields' +import { createAuth } from '@keystone-6/auth' const { withAuth } = createAuth({ // Required options @@ -26,15 +26,7 @@ const { withAuth } = createAuth({ itemData: { isAdmin: true }, skipKeystoneWelcome: false, }, - passwordResetLink: { - sendToken: async ({ itemId, identity, token, context }) => { /* ... */ }, - tokensValidForMins: 60, - }, - magicAuthLink: { - sendToken: async ({ itemId, identity, token, context }) => { /* ... */ }, - tokensValidForMins: 60, - }, -}); +}) export default withAuth( config({ @@ -49,7 +41,7 @@ export default withAuth( session: { /* ... */ }, }, }) -); +) ``` The function `createAuth` returns a function `withAuth` which should be used to wrap your `config()`. @@ -65,13 +57,13 @@ The core functionality of the authentication system provides a GraphQL mutation - `secretField`: The name of the field to use as a secret. This field must be a `password()` field type. ```typescript -import { createAuth } from '@keystone-6/auth'; +import { createAuth } from '@keystone-6/auth' const { withAuth } = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', -}); +}) ``` #### GraphQL API {% #graphql-api %} @@ -157,14 +149,14 @@ Configuring `sessionData` will add an `session.data` based on the `itemId`, popu The value is a GraphQL query string which indicates which fields should be populated on the `session.data` object ```typescript -import { createAuth } from '@keystone-6/auth'; +import { createAuth } from '@keystone-6/auth' const { withAuth } = createAuth({ listKey: 'User', identityField: 'email', secretField: 'password', sessionData: 'id name isAdmin', -}); +}) ``` ### initFirstItem @@ -222,239 +214,6 @@ This mutation is used by the Admin UI's initial user screen and should generally The initial user screen is added at `/init`, and users are redirected here if there is no active session and no users in the system. -### passwordResetLink - -This option adds support for sending password reset links to users. -The mutation `sendUserPasswordResetLink` allows you to send a reset token to a user. -The mutation `redeemUserPasswordResetToken` lets the user reset their password by redeeming the token. -You need to provide a `sendToken` function which can be used by `sendUserPasswordResetLink` to send the generated token to the user. -It is expected that you will use these mutations as part of a password reset workflow within your frontend application. - -#### Options {% #password-reset-link-options %} - -- `sendToken`: This function is invoked by the `sendUserPasswordResetLink` mutation. - It should use an appropriate mechanism (e.g email, Twitter, Slack, carrier pigeon) to provide the user with the password reset token. - It should include an appropriate way to submit the token to the `redeemUserPasswordResetToken` mutation (e.g. a link to a password reset form). - The following arguments are provided to `sendToken`: - - `itemId`: The ID of the user requesting the password reset. - - `identity`: The identity value provided to the `sendUserPasswordResetLink` mutation. - - `token`: The token the user must supply to use `redeemUserPasswordResetToken`. - - `context`: A [`KeystoneContext`](../context/overview) object. -- `tokensValidForMins` (default: `10`, max: `24 * 60` (1 day), min: `0.16` (10 seconds)): The length of time, in minutes, that the token is valid for. - -```typescript -import { createAuth } from '@keystone-6/auth'; - -const { withAuth } = createAuth({ - listKey: 'User', - identityField: 'email', - secretField: 'password', - - passwordResetLink: { - sendToken: async ({ itemId, identity, token, context }) => { /* ... */ }, - tokensValidForMins: 60, - }, -}); -``` - -#### Additional fields - -Enabling `passwordResetLink` will add the following fields to the configuration of the list `listKey`. - -```typescript -const fieldConfig = { - access: () => false, - ui: { - createView: { fieldMode: 'hidden' }, - itemView: { fieldMode: 'hidden' }, - listView: { fieldMode: 'hidden' }, - }, -} as const; -const fields = { - passwordResetToken: password(fieldConfig), - passwordResetIssuedAt: timestamp(fieldConfig), - passwordResetRedeemedAt: timestamp(fieldConfig), -}; -``` - -- `passwordResetToken` stores the token generated by `sendUserPasswordResetLink`. -- `passwordResetIssuedAt` records the time that the token was generated. -- `passwordResetRedeemedAt` records the time that the token was redeemed. - -#### GraphQL API {% #password-reset-graphql-api %} - -Enabling `passwordResetLink` will add the following elements to the GraphQL API. - -```graphql -type Mutation { - sendUserPasswordResetLink(email: String!): Boolean - redeemUserPasswordResetToken(email: String!, token: String!, password: String!): RedeemUserPasswordResetTokenResult -} - -type Query { - validateUserPasswordResetToken(email: String!, token: String!): ValidateUserPasswordResetTokenResult -} - -type ValidateUserPasswordResetTokenResult { - code: PasswordResetRedemptionErrorCode! - message: String! -} - -type RedeemUserPasswordResetTokenResult { - code: PasswordResetRedemptionErrorCode! - message: String! -} - -enum PasswordResetRedemptionErrorCode { - FAILURE - TOKEN_EXPIRED - TOKEN_REDEEMED -} -``` - -##### sendUserPasswordResetLink - -This mutation verifies that the supplied identity exists and, if it does, generates a new token and calls `sendToken()`. -The token and the the current time are stored in the fields `passwordResetToken` and `passwordResetIssuedAt` respectively. -The argument name for this function is the value of `identityField`. -This mutation always returns `null`. - -##### redeemUserPasswordResetToken - -This mutation validates the provided token and then resets the user's password. -The argument names for this function are the values of `identityField` and `secretField`. -This mutation returns `null` on success. - -If the provided `identity` and `token` do not match then the value `{ code: FAILURE, message: 'Auth token redemption failed.'}` is returned. - -If the `identity` and `token` values match, but the value of `passwordResetRedeemedAt` on the item is not `null` then the value `{ code: TOKEN_REDEEMED, message: 'Auth tokens are single use and the auth token provided has already been redeemed.' }` is returned. - -If the `identity` and `token` values match and the token has not already been redeemed then the value of `passwordResetIssuedAt` is compared against `tokensValidForMins`. -If the token has expired the value `{ code: TOKEN_EXPIRED, message: 'The auth token provided has expired.' }` is returned. - -If the token is valid, then the value of `passwordResetRedeemedAt` will be set to the current time, and then the new password value will be saved. -The password will be validated before being saved. -If the password is invalid the token will still be considered as redeemed and the user must restart the password reset flow. -A `ValidationFailureError` will be returned as an `error` if the password is invalid. - -If the password is successfully saved then the mutation will return `null`. - -##### validateUserPasswordResetToken - -This query performs all the same validation steps as `redeemUserPasswordResetToken`, but does not update the password or `passwordResetRedeemedAt` field. -The return values are the same as `redeemUserPasswordResetToken`. - -### magicAuthLink - -This option adds support for sending a one-time authentication link to users. -One-time authentication links allow a user to start an authenticated session without needing to know their password. -The mutation `sendUserMagicAuthLink` allows you to send a one-time authentication link token to a user. -The mutation `redeemUserMagicAuthToken` lets the user start an authenticated session by redeeming the token. -You need to provide a `sendToken` function which can be used by `sendUserMagicAuthLink` to send the generated token to the user. -It is expected that you will use these mutations as part of a one-time authentication workflow within your frontend application. - -#### Options {% #magic-auth-link-options %} - -- `sendToken`: This function is invoked by the `sendUserMagicAuthLink` mutation. - It should use an appropriate mechanism (e.g email, Twitter, Slack, carrier pigeon) to provide the user with their one-time authentication token. - It should include an appropriate way to submit the token to the `redeemUserMagicAuthToken` mutation (e.g. a link to a route which executes the mutation on their behalf). - The following arguments are provided to `sendToken`: - - `itemId`: The ID of the user requesting the one-time authentication link. - - `identity`: The identity value provided to the `sendUserMagicAuthLink` mutation. - - `token`: The token the user must supply to use `redeemUserMagicAuthToken`. - - `context`: A [`KeystoneContext`](../context/overview) object. -- `tokensValidForMins` (default: `10`, max: `24 * 60` (1 day), min: `0.16` (10 seconds)): The length of time, in minutes, that the token is valid for. - -```typescript -import { createAuth } from '@keystone-6/auth'; - -const { withAuth } = createAuth({ - listKey: 'User', - identityField: 'email', - secretField: 'password', - - magicAuthLink: { - sendToken: async ({ itemId, identity, token, context }) => { /* ... */ }, - tokensValidForMins: 60, - }, -}); -``` - -#### Additional fields {% #magic-auth-link-additional-fields %} - -Enabling `magicAuthLink` will add the following fields to the configuration of the list `listKey`. - -```typescript -const fieldConfig = { - access: () => false, - ui: { - createView: { fieldMode: 'hidden' }, - itemView: { fieldMode: 'hidden' }, - listView: { fieldMode: 'hidden' }, - }, -} as const; -const fields = { - magicAuthToken: password(fieldConfig), - magicAuthIssuedAt: timestamp(fieldConfig), - magicAuthRedeemedAt: timestamp(fieldConfig), -}; -``` - -- `magicAuthToken` stores the token generated by `sendUserMagicAuthLink`. -- `magicAuthIssuedAt` records the time that the token was generated. -- `magicAuthRedeemedAt` records the time that the token was redeemed. - -#### GraphQL API - -Enabling `magicAuthLink` will add the following elements to the GraphQL API. - -```graphql -type Mutation { - sendUserMagicAuthLink(email: String!): Boolean - redeemUserMagicAuthToken(email: String!, token: String!): RedeemUserMagicAuthTokenResult! -} - -union RedeemUserMagicAuthTokenResult = RedeemUserMagicAuthTokenSuccess | RedeemUserMagicAuthTokenFailure - -type RedeemUserMagicAuthTokenSuccess { - token: String! - item: User! -} - -type RedeemUserMagicAuthTokenFailure { - code: MagicLinkRedemptionErrorCode! - message: String! -} - -enum MagicLinkRedemptionErrorCode { - FAILURE - TOKEN_EXPIRED - TOKEN_REDEEMED -} -``` - -##### sendUserMagicAuthLink - -This mutation verifies that the supplied identity exists and, if it does, generates a new token and calls `sendToken()`. -The token and the the current time are stored in the fields `magicAuthToken` and `magicAuthIssuedAt` respectively. -The argument name for this function is the value of `identityField`. -This mutation always returns `null`. - -##### redeemUserMagicAuthToken - -This mutation validates the provided token and then starts an authenticated session as the identified user. -The argument name for this function is the value of `identityField`. - -If the provided `identity` and `token` do not match then the value `{ code: FAILURE, message: 'Auth token redemption failed.'}` is returned. - -If the `identity` and `token` values match, but the value of `magicAuthRedeemedAt` on the item is not `null` then the value `{ code: TOKEN_REDEEMED, message: 'Auth tokens are single use and the auth token provided has already been redeemed.' }` is returned. - -If the `identity` and `token` values match and the token has not already been redeemed then the value of `magicAuthRedeemedAt` is compared against `tokensValidForMins`. -If the token has expired the value `{ code: TOKEN_EXPIRED, message: 'The auth token provided has expired.' }` is returned. - -If the token is valid then the session handler will start a new session and return the encoded session cookie data as `sessionToken`. -The authenticated item will be returned as `item`. - ## Related resources {% related-content %} diff --git a/tests/api-tests/auth.test.ts b/tests/api-tests/auth.test.ts index 95a27d6048d..423b91dcdb5 100644 --- a/tests/api-tests/auth.test.ts +++ b/tests/api-tests/auth.test.ts @@ -14,8 +14,6 @@ const initialData = { ], } -let MAGIC_TOKEN: string - const auth = createAuth({ listKey: 'User', identityField: 'email', @@ -25,20 +23,6 @@ const auth = createAuth({ fields: ['email', 'password'], itemData: { name: 'First User' } }, - magicAuthLink: { - sendToken: async ({ identity, token }) => { - if (identity === 'bad@keystonejs.com') throw new Error('Error in sendToken') - MAGIC_TOKEN = token - }, - tokensValidForMins: 60, - }, - passwordResetLink: { - sendToken: async ({ identity, token }) => { - if (identity === 'bad@keystonejs.com') throw new Error('Error in sendToken') - MAGIC_TOKEN = token - }, - tokensValidForMins: 60, - }, }) const runner = setupTestRunner({ @@ -241,919 +225,6 @@ describe('Auth testing', () => { }) ) }) - - describe('getMagicAuthLink', () => { - test( - 'sendItemMagicAuthLink - Success', - runner(async ({ context, gqlSuper }) => { - await seed(context, initialData) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ sendUserMagicAuthLink: true }) - - // Verify that token fields cant be read. - let user = await context.query.User.findOne({ - where: { email: 'boris@keystonejs.com' }, - query: 'magicAuthToken { isSet } magicAuthIssuedAt magicAuthRedeemedAt', - }) - expect(user).toEqual({ - magicAuthToken: null, - magicAuthIssuedAt: null, - magicAuthRedeemedAt: null, - }) - - // Verify that token fields have been updated - user = await context.sudo().query.User.findOne({ - where: { email: 'boris@keystonejs.com' }, - query: 'magicAuthToken { isSet } magicAuthIssuedAt magicAuthRedeemedAt', - }) - expect(user).toEqual({ - magicAuthToken: { isSet: true }, - magicAuthIssuedAt: expect.any(String), - magicAuthRedeemedAt: null, - }) - }) - ) - test( - 'sendItemMagicAuthLink - Failure - missing user', - runner(async ({ context, gqlSuper }) => { - await seed(context, initialData) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'bores@keystonejs.com' }, - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ sendUserMagicAuthLink: true }) - }) - ) - test( - 'sendItemMagicAuthLink - Failure - Error in sendToken', - runner(async ({ context, gqlSuper }) => { - await seed(context, initialData) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'bad@keystonejs.com' }, - }) - expect(body.data).toEqual(null) - expectInternalServerError(body.errors, [ - { path: ['sendUserMagicAuthLink'], message: 'Error in sendToken' }, - ]) - - // Verify that token fields have been updated - const user = await context.sudo().query.User.findOne({ - where: { email: 'bad@keystonejs.com' }, - query: 'magicAuthToken { isSet } magicAuthIssuedAt magicAuthRedeemedAt', - }) - expect(user).toEqual({ - magicAuthToken: { isSet: true }, - magicAuthIssuedAt: expect.any(String), - magicAuthRedeemedAt: null, - }) - }) - ) - - test( - 'redeemItemMagicAuthToken - Success', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - // verify we get back a token for the expected user - - const user = await context.sudo().query.User.findOne({ - where: { email: 'boris@keystonejs.com' }, - query: 'id magicAuthToken { isSet } magicAuthIssuedAt magicAuthRedeemedAt', - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserMagicAuthToken: { token: expect.any(String), item: { id: user.id } }, - }) - - // verify that we've set a redemption time - expect(user).toEqual({ - id: user.id, - magicAuthToken: { isSet: true }, - magicAuthIssuedAt: expect.any(String), - magicAuthRedeemedAt: expect.any(String), - }) - }) - ) - test( - 'redeemItemMagicAuthToken - Failure - bad token', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) { - code message - } - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'boris@keystonejs.com', token: 'BAD TOKEN' }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserMagicAuthToken: { - code: 'FAILURE', - message: 'Auth token redemption failed.', - }, - }) - }) - ) - test( - 'redeemItemMagicAuthToken - Failure - non-existent user', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'missing@keystonejs.com', token: 'BAD TOKEN' }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserMagicAuthToken: { - code: 'FAILURE', - message: 'Auth token redemption failed.', - }, - }) - }) - ) - test( - 'redeemItemMagicAuthToken - Failure - already redeemed', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Redeem once - await gql({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - // Redeem twice - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserMagicAuthToken: { - code: 'TOKEN_REDEEMED', - message: - 'Auth tokens are single use and the auth token provided has already been redeemed.', - }, - }) - }) - ) - test( - 'redeemItemMagicAuthToken - Failure - Token expired', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Set the "issued at" date to 59 minutes ago - let user = await context.sudo().query.User.updateOne({ - where: { email: 'boris@keystonejs.com' }, - data: { magicAuthIssuedAt: new Date(Number(new Date()) - 59 * 60 * 1000).toISOString() }, - }) - { - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserMagicAuthToken: { token: expect.any(String), item: { id: user.id } }, - }) - } - - // Send another token - await gql({ - query: ` - mutation($email: String!) { - sendUserMagicAuthLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Set the "issued at" date to 61 minutes ago - user = await context.sudo().query.User.updateOne({ - where: { email: 'boris@keystonejs.com' }, - data: { magicAuthIssuedAt: new Date(Number(new Date()) - 61 * 60 * 1000).toISOString() }, - }) - { - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!) { - redeemUserMagicAuthToken(email: $email, token: $token) { - ... on RedeemUserMagicAuthTokenSuccess { - token item { id } - } - ... on RedeemUserMagicAuthTokenFailure { - code message - } - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserMagicAuthToken: { - code: 'TOKEN_EXPIRED', - message: 'The auth token provided has expired.', - }, - }) - } - }) - ) - }) - - describe('getPasswordReset', () => { - test( - 'sendItemPasswordResetLink - Success', - runner(async ({ context, gqlSuper }) => { - await seed(context, initialData) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ sendUserPasswordResetLink: true }) - - // Verify that token fields cant be read. - let user = await context.query.User.findOne({ - where: { email: 'boris@keystonejs.com' }, - query: 'passwordResetToken { isSet } passwordResetIssuedAt passwordResetRedeemedAt', - }) - expect(user).toEqual({ - passwordResetToken: null, - passwordResetIssuedAt: null, - passwordResetRedeemedAt: null, - }) - - // Verify that token fields have been updated - user = await context.sudo().query.User.findOne({ - where: { email: 'boris@keystonejs.com' }, - query: 'passwordResetToken { isSet } passwordResetIssuedAt passwordResetRedeemedAt', - }) - expect(user).toEqual({ - passwordResetToken: { isSet: true }, - passwordResetIssuedAt: expect.any(String), - passwordResetRedeemedAt: null, - }) - }) - ) - - test( - 'sendItemPasswordResetLink - Failure - missing user', - runner(async ({ context, gqlSuper }) => { - await seed(context, initialData) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'bores@keystonejs.com' }, - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ sendUserPasswordResetLink: true }) - }) - ) - test( - 'sendItemPasswordResetLink - Failure - Error in sendToken', - runner(async ({ context, gqlSuper }) => { - await seed(context, initialData) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'bad@keystonejs.com' }, - }) - expect(body.data).toEqual(null) - expectInternalServerError(body.errors, [ - { path: ['sendUserPasswordResetLink'], message: 'Error in sendToken' }, - ]) - - // Verify that token fields have been updated - const user = await context.sudo().query.User.findOne({ - where: { email: 'bad@keystonejs.com' }, - query: 'passwordResetToken { isSet } passwordResetIssuedAt passwordResetRedeemedAt', - }) - expect(user).toEqual({ - passwordResetToken: { isSet: true }, - passwordResetIssuedAt: expect.any(String), - passwordResetRedeemedAt: null, - }) - }) - ) - - test( - 'redeemItemPasswordToken - Success', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: MAGIC_TOKEN, - password: 'NEW PASSWORD', - }, - }) - // Veryify we get back a token for the expected user. - - const user = await context.sudo().query.User.findOne({ - where: { email: 'boris@keystonejs.com' }, - query: - 'id passwordResetToken { isSet } passwordResetIssuedAt passwordResetRedeemedAt password { isSet }', - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ redeemUserPasswordResetToken: null }) - // Verify that we've set a redemption time and a password hash - expect(user).toEqual({ - id: user.id, - passwordResetToken: { isSet: true }, - passwordResetIssuedAt: expect.any(String), - passwordResetRedeemedAt: expect.any(String), - password: { isSet: true }, - }) - }) - ) - test( - 'redeemItemPasswordToken - Failure - bad token', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: 'BAD TOKEN', - password: 'NEW PASSWORD', - }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserPasswordResetToken: { - code: 'FAILURE', - message: 'Auth token redemption failed.', - }, - }) - }) - ) - test( - 'redeemItemPasswordToken - Failure - non-existent user', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'missing@keystonejs.com', - token: 'BAD TOKEN', - password: 'NEW PASSWORD', - }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserPasswordResetToken: { - code: 'FAILURE', - message: 'Auth token redemption failed.', - }, - }) - }) - ) - test( - 'redeemItemPasswordToken - Failure - already redeemed', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Redeem once - await gql({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: MAGIC_TOKEN, - password: 'NEW PASSWORD', - }, - }) - // Redeem twice - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: MAGIC_TOKEN, - password: 'NEW PASSWORD', - }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserPasswordResetToken: { - code: 'TOKEN_REDEEMED', - message: - 'Auth tokens are single use and the auth token provided has already been redeemed.', - }, - }) - }) - ) - test( - 'redeemItemPasswordToken - Failure - Token expired', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Set the "issued at" date to 59 minutes ago - await context.sudo().query.User.updateOne({ - where: { email: 'boris@keystonejs.com' }, - data: { - passwordResetIssuedAt: new Date(Number(new Date()) - 59 * 60 * 1000).toISOString(), - }, - }) - { - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: MAGIC_TOKEN, - password: 'NEW PASSWORD', - }, - }) - - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ redeemUserPasswordResetToken: null }) - } - - // Send another token - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Set the "issued at" date to 61 minutes ago - await context.sudo().query.User.updateOne({ - where: { email: 'boris@keystonejs.com' }, - data: { - passwordResetIssuedAt: new Date(Number(new Date()) - 61 * 60 * 1000).toISOString(), - }, - }) - { - const { body } = await gqlSuper({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: MAGIC_TOKEN, - password: 'NEW PASSWORD', - }, - }) - - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - redeemUserPasswordResetToken: { - code: 'TOKEN_EXPIRED', - message: 'The auth token provided has expired.', - }, - }) - } - }) - ) - - test( - 'validateItemPasswordToken - Success', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - query($email: String!, $token: String!) { - validateUserPasswordResetToken(email: $email, token: $token) { - code message - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ validateUserPasswordResetToken: null }) - }) - ) - test( - 'validateItemPasswordToken - Failure - bad token', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - query($email: String!, $token: String!) { - validateUserPasswordResetToken(email: $email, token: $token) { - code message - } - } - `, - variables: { email: 'boris@keystonejs.com', token: 'BAD TOKEN' }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - validateUserPasswordResetToken: { - code: 'FAILURE', - message: 'Auth token redemption failed.', - }, - }) - }) - ) - test( - 'validateItemPasswordToken - Failure - non-existent user', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - - const { body } = await gqlSuper({ - query: ` - query($email: String!, $token: String!) { - validateUserPasswordResetToken(email: $email, token: $token) { - code message - } - } - `, - variables: { email: 'missing@keystonejs.com', token: 'BAD TOKEN' }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - validateUserPasswordResetToken: { - code: 'FAILURE', - message: 'Auth token redemption failed.', - }, - }) - }) - ) - test( - 'validateItemPasswordToken - Failure - already redeemed', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Redeem once - await gql({ - query: ` - mutation($email: String!, $token: String!, $password: String!) { - redeemUserPasswordResetToken(email: $email, token: $token, password: $password) { - code message - } - } - `, - variables: { - email: 'boris@keystonejs.com', - token: MAGIC_TOKEN, - password: 'NEW PASSWORD', - }, - }) - // Redeem twice - const { body } = await gqlSuper({ - query: ` - query($email: String!, $token: String!) { - validateUserPasswordResetToken(email: $email, token: $token) { - code message - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - // Generic failure message - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - validateUserPasswordResetToken: { - code: 'TOKEN_REDEEMED', - message: - 'Auth tokens are single use and the auth token provided has already been redeemed.', - }, - }) - }) - ) - test( - 'validateItemPasswordToken - Failure - Token expired', - runner(async ({ context, gql, gqlSuper }) => { - await seed(context, initialData) - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Set the "issued at" date to 59 minutes ago - await context.sudo().query.User.updateOne({ - where: { email: 'boris@keystonejs.com' }, - data: { - passwordResetIssuedAt: new Date(Number(new Date()) - 59 * 60 * 1000).toISOString(), - }, - }) - { - const { body } = await gqlSuper({ - query: ` - query($email: String!, $token: String!) { - validateUserPasswordResetToken(email: $email, token: $token) { - code message - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ validateUserPasswordResetToken: null }) - } - - // Send another token - await gql({ - query: ` - mutation($email: String!) { - sendUserPasswordResetLink(email: $email) - } - `, - variables: { email: 'boris@keystonejs.com' }, - }) - // Set the "issued at" date to 61 minutes ago - await context.sudo().query.User.updateOne({ - where: { email: 'boris@keystonejs.com' }, - data: { - passwordResetIssuedAt: new Date(Number(new Date()) - 61 * 60 * 1000).toISOString(), - }, - }) - { - const { body } = await gqlSuper({ - query: ` - query($email: String!, $token: String!) { - validateUserPasswordResetToken(email: $email, token: $token) { - code message - } - } - `, - variables: { email: 'boris@keystonejs.com', token: MAGIC_TOKEN }, - }) - - expect(body.errors).toBe(undefined) - expect(body.data).toEqual({ - validateUserPasswordResetToken: { - code: 'TOKEN_EXPIRED', - message: 'The auth token provided has expired.', - }, - }) - } - }) - ) - }) }) test( From 84e488ba5538a23878c0e3fa8781922cbd9cbff7 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 13:30:05 +1000 Subject: [PATCH 03/14] add changeset --- .changeset/rm-auth-magic.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/rm-auth-magic.md diff --git a/.changeset/rm-auth-magic.md b/.changeset/rm-auth-magic.md new file mode 100644 index 00000000000..05440609e48 --- /dev/null +++ b/.changeset/rm-auth-magic.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/auth': major +--- + +Remove `magicAuthLink` and `passwordResetLink` functionality from `@keystone-6/auth` From 57fd5ee01a0e0fdc6c3afbb74aa455bdad666c67 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 12:45:35 +1000 Subject: [PATCH 04/14] tidy up --- packages/auth/src/lib/createAuthToken.ts | 20 ------- packages/auth/src/lib/emailHeuristics.ts | 15 ----- packages/auth/src/lib/getErrorMessage.ts | 12 ---- packages/auth/src/lib/validateAuthToken.ts | 64 ---------------------- 4 files changed, 111 deletions(-) delete mode 100644 packages/auth/src/lib/createAuthToken.ts delete mode 100644 packages/auth/src/lib/emailHeuristics.ts delete mode 100644 packages/auth/src/lib/getErrorMessage.ts delete mode 100644 packages/auth/src/lib/validateAuthToken.ts diff --git a/packages/auth/src/lib/createAuthToken.ts b/packages/auth/src/lib/createAuthToken.ts deleted file mode 100644 index 79affab5240..00000000000 --- a/packages/auth/src/lib/createAuthToken.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { randomBytes } from 'crypto' -import type { KeystoneDbAPI } from '@keystone-6/core/types' - -export async function createAuthToken ( - identityField: string, - identity: string, - dbItemAPI: KeystoneDbAPI[string] -): Promise< - { success: false } | { success: true, itemId: string | number | bigint, token: string } -> { - // FIXME : identity lookups may leak information due to timing attacks - const item = await dbItemAPI.findOne({ where: { [identityField]: identity } }) - if (!item) return { success: false } - - return { - success: true, - itemId: item.id, - token: randomBytes(16).toString('base64url').slice(0, 20), // (128 / Math.log2(64)) < 20 - } -} diff --git a/packages/auth/src/lib/emailHeuristics.ts b/packages/auth/src/lib/emailHeuristics.ts deleted file mode 100644 index 55d01f483aa..00000000000 --- a/packages/auth/src/lib/emailHeuristics.ts +++ /dev/null @@ -1,15 +0,0 @@ -const emailKeysToGuess = ['email', 'username'] - -export const guessEmailFromValue = (value: any) => { - for (const key of emailKeysToGuess) { - if (value[key] && typeof value[key].value === 'string') { - return value[key].value - } - } -} - -// email validation regex from https://html.spec.whatwg.org/multipage/input.html#email-state-(type=email) -export const validEmail = (email: string) => - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( - email - ) diff --git a/packages/auth/src/lib/getErrorMessage.ts b/packages/auth/src/lib/getErrorMessage.ts deleted file mode 100644 index 51ebde0a0f2..00000000000 --- a/packages/auth/src/lib/getErrorMessage.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type AuthTokenRedemptionErrorCode } from '../types' - -export function getAuthTokenErrorMessage ({ code }: { code: AuthTokenRedemptionErrorCode }): string { - switch (code) { - case 'FAILURE': - return 'Auth token redemption failed.' - case 'TOKEN_EXPIRED': - return 'The auth token provided has expired.' - case 'TOKEN_REDEEMED': - return 'Auth tokens are single use and the auth token provided has already been redeemed.' - } -} diff --git a/packages/auth/src/lib/validateAuthToken.ts b/packages/auth/src/lib/validateAuthToken.ts deleted file mode 100644 index d2d2cb2e11a..00000000000 --- a/packages/auth/src/lib/validateAuthToken.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { KeystoneDbAPI } from '@keystone-6/core/types' -import { type AuthTokenRedemptionErrorCode, type SecretFieldImpl } from '../types' -import { validateSecret } from './validateSecret' - -// The tokensValidForMins config is from userland so could be anything; make it sane -function sanitiseValidForMinsConfig (input: any): number { - const parsed = Number.parseFloat(input) - // > 10 seconds, < 24 hrs, default 10 mins - return parsed ? Math.max(1 / 6, Math.min(parsed, 60 * 24)) : 10 -} - -export async function validateAuthToken ( - listKey: string, - secretFieldImpl: SecretFieldImpl, - tokenType: 'passwordReset' | 'magicAuth', - identityField: string, - identity: string, - tokenValidMins: number | undefined, - token: string, - dbItemAPI: KeystoneDbAPI[string] -): Promise< - | { success: false, code: AuthTokenRedemptionErrorCode } - | { success: true, item: { id: any, [prop: string]: any } } -> { - const result = await validateSecret( - secretFieldImpl, - identityField, - identity, - `${tokenType}Token`, - token, - dbItemAPI - ) - if (!result.success) { - // Could be due to: - // - Missing identity - // - Missing secret - // - Secret mismatch. - return { success: false, code: 'FAILURE' } - } - - // Now that we know the identity and token are valid, we can always return 'helpful' errors and stop worrying about protecting identities. - const { item } = result - const fieldKeys = { issuedAt: `${tokenType}IssuedAt`, redeemedAt: `${tokenType}RedeemedAt` } - - // Check that the token has not been redeemed already - if (item[fieldKeys.redeemedAt]) { - return { success: false, code: 'TOKEN_REDEEMED' } - } - - // Check that the token has not expired - if (!item[fieldKeys.issuedAt] || typeof item[fieldKeys.issuedAt].getTime !== 'function') { - throw new Error( - `Error redeeming authToken: field ${listKey}.${fieldKeys.issuedAt} isn't a valid Date object.` - ) - } - const elapsedMins = (Date.now() - item[fieldKeys.issuedAt].getTime()) / (1000 * 60) - const validForMins = sanitiseValidForMinsConfig(tokenValidMins) - if (elapsedMins > validForMins) { - return { success: false, code: 'TOKEN_EXPIRED' } - } - - // Authenticated! - return { success: true, item } -} From 68f5219f4ceef79510d319b09988404951424982 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 13:36:34 +1000 Subject: [PATCH 05/14] inline validateSecret --- packages/auth/src/gql/getBaseAuthSchema.ts | 30 +++++++++---------- .../auth/src/gql/getInitFirstItemSchema.ts | 11 +++++-- packages/auth/src/lib/validateSecret.ts | 23 -------------- 3 files changed, 22 insertions(+), 42 deletions(-) delete mode 100644 packages/auth/src/lib/validateSecret.ts diff --git a/packages/auth/src/gql/getBaseAuthSchema.ts b/packages/auth/src/gql/getBaseAuthSchema.ts index d50ef287251..2e4bf4adbdc 100644 --- a/packages/auth/src/gql/getBaseAuthSchema.ts +++ b/packages/auth/src/gql/getBaseAuthSchema.ts @@ -8,8 +8,6 @@ import type { SecretFieldImpl, } from '../types' -import { validateSecret } from '../lib/validateSecret' - export function getBaseAuthSchema ({ listKey, identityField, @@ -24,7 +22,6 @@ export function getBaseAuthSchema ({ gqlNames: AuthGqlNames secretFieldImpl: SecretFieldImpl base: graphql.BaseSchemaMeta - // TODO: return type required by pnpm :( }): { extension: graphql.Extension @@ -91,24 +88,22 @@ export function getBaseAuthSchema ({ async resolve (root, { [identityField]: identity, [secretField]: secret }, context: KeystoneContext) { if (!context.sessionStrategy) throw new Error('No session implementation available on context') - const dbItemAPI = context.sudo().db[listKey] - const result = await validateSecret( - secretFieldImpl, - identityField, - identity, - secretField, - secret, - dbItemAPI - ) + const item = await context.sudo().db[listKey].findOne({ + where: { [identityField]: identity } + }) - if (!result.success) { + if ((typeof item?.[secretField] !== 'string')) { + await secretFieldImpl.generateHash('simulated-password-to-counter-timing-attack') return { code: 'FAILURE', message: 'Authentication failed.' } } + const equal = await secretFieldImpl.compare(secret, item[secretField]) + if (!equal) return { code: 'FAILURE', message: 'Authentication failed.' } + // Update system state const sessionToken = await context.sessionStrategy.start({ data: { - itemId: result.item.id, + itemId: item.id, }, context, }) @@ -120,12 +115,15 @@ export function getBaseAuthSchema ({ return { sessionToken, - item: result.item + item } }, }), }, } - return { extension, ItemAuthenticationWithPasswordSuccess } + return { + extension, + ItemAuthenticationWithPasswordSuccess + } } diff --git a/packages/auth/src/gql/getInitFirstItemSchema.ts b/packages/auth/src/gql/getInitFirstItemSchema.ts index 5db6cfdb0e4..be6905c44bb 100644 --- a/packages/auth/src/gql/getInitFirstItemSchema.ts +++ b/packages/auth/src/gql/getInitFirstItemSchema.ts @@ -1,8 +1,13 @@ -import { type BaseItem, type KeystoneContext } from '@keystone-6/core/types' +import { + type BaseItem, + type KeystoneContext, +} from '@keystone-6/core/types' import { graphql } from '@keystone-6/core' import { assertInputObjectType, GraphQLInputObjectType, type GraphQLSchema } from 'graphql' - -import type { AuthGqlNames, InitFirstItemConfig } from '../types' +import { + type AuthGqlNames, + type InitFirstItemConfig, +} from '../types' export function getInitFirstItemSchema ({ listKey, diff --git a/packages/auth/src/lib/validateSecret.ts b/packages/auth/src/lib/validateSecret.ts deleted file mode 100644 index b2f796dd4a4..00000000000 --- a/packages/auth/src/lib/validateSecret.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KeystoneDbAPI } from '@keystone-6/core/types' -import type { SecretFieldImpl } from '../types' - -export async function validateSecret ( - secretFieldImpl: SecretFieldImpl, - identityField: string, - identity: string, - secretField: string, - secret: string, - dbItemAPI: KeystoneDbAPI[string] -): Promise<{ success: false } | { success: true, item: { id: any, [prop: string]: any } }> { - const item = await dbItemAPI.findOne({ where: { [identityField]: identity } }) - if (!item || !item[secretField]) { - // See "Identity Protection" in the README as to why this is a thing - await secretFieldImpl.generateHash('simulated-password-to-counter-timing-attack') - return { success: false } - } else if (await secretFieldImpl.compare(secret, item[secretField])) { - // Authenticated! - return { success: true, item } - } else { - return { success: false } - } -} From 894cd16da4a538f45025c587688548635d6cf6a1 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:05:26 +1000 Subject: [PATCH 06/14] unify authentication failure errors --- packages/auth/src/gql/getBaseAuthSchema.ts | 20 ++++++---- .../auth/src/gql/getInitFirstItemSchema.ts | 37 ++++++++++++------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/packages/auth/src/gql/getBaseAuthSchema.ts b/packages/auth/src/gql/getBaseAuthSchema.ts index 2e4bf4adbdc..80ff473e6e2 100644 --- a/packages/auth/src/gql/getBaseAuthSchema.ts +++ b/packages/auth/src/gql/getBaseAuthSchema.ts @@ -8,6 +8,11 @@ import type { SecretFieldImpl, } from '../types' +const AUTHENTICATION_FAILURE = { + code: 'FAILURE', + message: 'Authentication failed.' +} as const + export function getBaseAuthSchema ({ listKey, identityField, @@ -85,8 +90,11 @@ export function getBaseAuthSchema ({ [identityField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), [secretField]: graphql.arg({ type: graphql.nonNull(graphql.String) }), }, - async resolve (root, { [identityField]: identity, [secretField]: secret }, context: KeystoneContext) { - if (!context.sessionStrategy) throw new Error('No session implementation available on context') + async resolve (root, { + [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 } @@ -94,13 +102,12 @@ export function getBaseAuthSchema ({ if ((typeof item?.[secretField] !== 'string')) { await secretFieldImpl.generateHash('simulated-password-to-counter-timing-attack') - return { code: 'FAILURE', message: 'Authentication failed.' } + return AUTHENTICATION_FAILURE } const equal = await secretFieldImpl.compare(secret, item[secretField]) - if (!equal) return { code: 'FAILURE', message: 'Authentication failed.' } + if (!equal) return AUTHENTICATION_FAILURE - // Update system state const sessionToken = await context.sessionStrategy.start({ data: { itemId: item.id, @@ -108,9 +115,8 @@ export function getBaseAuthSchema ({ context, }) - // return Failure if sessionStrategy.start() is incompatible if (typeof sessionToken !== 'string' || sessionToken.length === 0) { - return { code: 'FAILURE', message: 'Failed to start session.' } + return AUTHENTICATION_FAILURE } return { diff --git a/packages/auth/src/gql/getInitFirstItemSchema.ts b/packages/auth/src/gql/getInitFirstItemSchema.ts index be6905c44bb..ab9d96e75ca 100644 --- a/packages/auth/src/gql/getInitFirstItemSchema.ts +++ b/packages/auth/src/gql/getInitFirstItemSchema.ts @@ -9,10 +9,12 @@ import { type InitFirstItemConfig, } from '../types' +const AUTHENTICATION_FAILURE = 'Authentication failed.' as const + export function getInitFirstItemSchema ({ listKey, fields, - itemData, + itemData: defaultItemData, gqlNames, graphQLSchema, ItemAuthenticationWithPasswordSuccess, @@ -48,34 +50,41 @@ export function getInitFirstItemSchema ({ type: graphql.nonNull(ItemAuthenticationWithPasswordSuccess), args: { data: graphql.arg({ type: graphql.nonNull(initialCreateInput) }) }, async resolve (rootVal, { data }, context: KeystoneContext) { - if (!context.sessionStrategy) { - throw new Error('No session implementation available on context') - } + if (!context.sessionStrategy) throw new Error('No session strategy on context') const sudoContext = context.sudo() // should approximate hasInitFirstItemConditions const count = await sudoContext.db[listKey].count() - if (count !== 0) { - throw new Error('Initial items can only be created when no items exist in that list') - } + if (count !== 0) throw AUTHENTICATION_FAILURE // Update system state // this is strictly speaking incorrect. the db API will do GraphQL coercion on a value which has already been coerced // (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 sudoContext.db[listKey].createOne({ data: { ...data, ...itemData } }) - const sessionToken = (await context.sessionStrategy.start({ - data: { listKey, itemId: item.id.toString() }, + const item = await sudoContext.db[listKey].createOne({ + data: { + ...defaultItemData, + ...data + } + }) + + const sessionToken = await context.sessionStrategy.start({ + data: { + listKey, + itemId: item.id, + }, context, - })) + }) - // return Failure if sessionStrategy.start() is incompatible if (typeof sessionToken !== 'string' || sessionToken.length === 0) { - throw new Error('Failed to start session') + throw AUTHENTICATION_FAILURE } - return { item, sessionToken } + return { + sessionToken, + item + } }, }), }, From 26029aab4ccc75006e3af19ab5a057ecebc0ed4d Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:16:28 +1000 Subject: [PATCH 07/14] rename itemData internally to defaultItemData --- packages/auth/src/gql/getInitFirstItemSchema.ts | 8 +++----- packages/auth/src/schema.ts | 6 ++---- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/auth/src/gql/getInitFirstItemSchema.ts b/packages/auth/src/gql/getInitFirstItemSchema.ts index ab9d96e75ca..8f037cc8f75 100644 --- a/packages/auth/src/gql/getInitFirstItemSchema.ts +++ b/packages/auth/src/gql/getInitFirstItemSchema.ts @@ -14,14 +14,14 @@ const AUTHENTICATION_FAILURE = 'Authentication failed.' as const export function getInitFirstItemSchema ({ listKey, fields, - itemData: defaultItemData, + defaultItemData, gqlNames, graphQLSchema, ItemAuthenticationWithPasswordSuccess, }: { listKey: string fields: InitFirstItemConfig['fields'] - itemData: InitFirstItemConfig['itemData'] + defaultItemData: InitFirstItemConfig['itemData'] gqlNames: AuthGqlNames graphQLSchema: GraphQLSchema ItemAuthenticationWithPasswordSuccess: graphql.ObjectType<{ @@ -37,9 +37,7 @@ export function getInitFirstItemSchema ({ const initialCreateInput = graphql.wrap.inputObject( new GraphQLInputObjectType({ ...createInputConfig, - fields: Object.fromEntries( - Object.entries(createInputConfig.fields).filter(([fieldKey]) => fieldsSet.has(fieldKey)) - ), + fields: Object.fromEntries(Object.entries(createInputConfig.fields).filter(([fieldKey]) => fieldsSet.has(fieldKey))), name: gqlNames.CreateInitialInput, }) ) diff --git a/packages/auth/src/schema.ts b/packages/auth/src/schema.ts index 3c717b53dbb..6e11eafe710 100644 --- a/packages/auth/src/schema.ts +++ b/packages/auth/src/schema.ts @@ -61,9 +61,7 @@ export const getSchemaExtension = ({ sessionData: string }) => graphql.extend(base => { - const uniqueWhereInputType = assertInputObjectType( - base.schema.getType(`${listKey}WhereUniqueInput`) - ) + const uniqueWhereInputType = assertInputObjectType(base.schema.getType(`${listKey}WhereUniqueInput`)) const identityFieldOnUniqueWhere = uniqueWhereInputType.getFields()[identityField] if ( base.schema.extensions.sudo && @@ -111,7 +109,7 @@ export const getSchemaExtension = ({ getInitFirstItemSchema({ listKey, fields: initFirstItem.fields, - itemData: initFirstItem.itemData, + defaultItemData: initFirstItem.itemData, gqlNames, graphQLSchema: base.schema, ItemAuthenticationWithPasswordSuccess: baseSchema.ItemAuthenticationWithPasswordSuccess, From 6d2c0d0572797b7ec0e7fe70f28e2e4968f489ed Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:05:56 +1000 Subject: [PATCH 08/14] prefer satisfies --- packages/core/src/session.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index e8a3d30b0fe..6db19fc9771 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -68,7 +68,7 @@ export function statelessSessions ({ ironOptions = Iron.defaults, domain, sameSite = 'lax', -}: StatelessSessionsOptions = {}): SessionStrategy { +}: StatelessSessionsOptions = {}) { // atleast 192-bit in base64 if (secret.length < 32) { throw new Error('The session secret must be at least 32 characters long') @@ -123,7 +123,7 @@ export function statelessSessions ({ return sealedData }, - } + } satisfies SessionStrategy } export function storedSessions ({ @@ -132,7 +132,7 @@ export function storedSessions ({ ...statelessSessionsOptions }: { store: SessionStoreFunction -} & StatelessSessionsOptions): SessionStrategy { +} & StatelessSessionsOptions) { const stateless = statelessSessions({ ...statelessSessionsOptions, maxAge }) const store = storeFn({ maxAge }) @@ -155,5 +155,5 @@ export function storedSessions ({ await store.delete(sessionId) await stateless.end({ context }) }, - } + } satisfies SessionStrategy } From a921f3ef87fb1313a1f123f0c9f84764ece829c1 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 30 Jul 2024 15:27:53 +1000 Subject: [PATCH 09/14] dont show why an initFirstItem request failed --- tests/api-tests/auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api-tests/auth.test.ts b/tests/api-tests/auth.test.ts index 423b91dcdb5..d5107c9437a 100644 --- a/tests/api-tests/auth.test.ts +++ b/tests/api-tests/auth.test.ts @@ -190,7 +190,7 @@ describe('Auth testing', () => { expectInternalServerError(body.errors, [ { path: ['createInitialUser'], - message: 'Initial items can only be created when no items exist in that list', + message: 'Unexpected error value: \"Authentication failed.\"', }, ]) expect(body.data).toEqual(null) From 0789563a9e81655b409b466557bc7f456ac639de Mon Sep 17 00:00:00 2001 From: Josh Calder <8251494+borisno2@users.noreply.github.com> Date: Sun, 4 Aug 2024 16:00:34 +1000 Subject: [PATCH 10/14] add magicAuthLink example --- examples/auth-magic-link/keystone.ts | 66 +++++ examples/auth-magic-link/package.json | 21 ++ examples/auth-magic-link/sandbox.config.json | 7 + examples/auth-magic-link/schema.graphql | 266 ++++++++++++++++++ examples/auth-magic-link/schema.prisma | 21 ++ examples/auth-magic-link/schema.ts | 178 ++++++++++++ examples/auth/keystone.ts | 9 +- examples/auth/schema.ts | 8 +- examples/reuse/schema.ts | 10 +- packages/auth/src/gql/getBaseAuthSchema.ts | 9 +- packages/auth/src/schema.ts | 10 +- .../core/src/fields/types/password/index.ts | 18 +- pnpm-lock.yaml | 19 ++ 13 files changed, 604 insertions(+), 38 deletions(-) create mode 100644 examples/auth-magic-link/keystone.ts create mode 100644 examples/auth-magic-link/package.json create mode 100644 examples/auth-magic-link/sandbox.config.json create mode 100644 examples/auth-magic-link/schema.graphql create mode 100644 examples/auth-magic-link/schema.prisma create mode 100644 examples/auth-magic-link/schema.ts diff --git a/examples/auth-magic-link/keystone.ts b/examples/auth-magic-link/keystone.ts new file mode 100644 index 00000000000..d375c318b3d --- /dev/null +++ b/examples/auth-magic-link/keystone.ts @@ -0,0 +1,66 @@ +import { config } from '@keystone-6/core' +import { statelessSessions } from '@keystone-6/core/session' +import { createAuth } from '@keystone-6/auth' +import { + type Session, + lists, + extendGraphqlSchema, +} 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 + 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'], + }, +}) + +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, + 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/package.json b/examples/auth-magic-link/package.json new file mode 100644 index 00000000000..cb096a6a024 --- /dev/null +++ b/examples/auth-magic-link/package.json @@ -0,0 +1,21 @@ +{ + "name": "@keystone-6/example-auth-magic-link", + "version": null, + "private": true, + "license": "MIT", + "scripts": { + "dev": "keystone dev", + "start": "keystone start", + "build": "keystone build", + "postinstall": "keystone postinstall" + }, + "dependencies": { + "@keystone-6/auth": "^8.1.0", + "@keystone-6/core": "^6.3.1", + "@prisma/client": "5.19.0" + }, + "devDependencies": { + "prisma": "5.19.0", + "typescript": "^5.5.0" + } +} diff --git a/examples/auth-magic-link/sandbox.config.json b/examples/auth-magic-link/sandbox.config.json new file mode 100644 index 00000000000..7a34682ee45 --- /dev/null +++ b/examples/auth-magic-link/sandbox.config.json @@ -0,0 +1,7 @@ +{ + "template": "node", + "container": { + "startScript": "keystone dev", + "node": "16" + } +} diff --git a/examples/auth-magic-link/schema.graphql b/examples/auth-magic-link/schema.graphql new file mode 100644 index 00000000000..9511790ff05 --- /dev/null +++ b/examples/auth-magic-link/schema.graphql @@ -0,0 +1,266 @@ +# 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 +} + +type PasswordState { + isSet: Boolean! +} + +input UserWhereUniqueInput { + id: ID + name: String +} + +input UserWhereInput { + AND: [UserWhereInput!] + OR: [UserWhereInput!] + NOT: [UserWhereInput!] + id: IDFilter + name: StringFilter +} + +input IDFilter { + equals: ID + in: [ID!] + notIn: [ID!] + lt: ID + lte: ID + gt: ID + gte: ID + not: IDFilter +} + +input StringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input NestedStringFilter { + equals: String + in: [String!] + notIn: [String!] + lt: String + lte: String + gt: String + gte: String + contains: String + startsWith: String + endsWith: String + not: NestedStringFilter +} + +input UserOrderByInput { + id: OrderDirection + name: OrderDirection +} + +enum OrderDirection { + asc + desc +} + +input UserUpdateInput { + name: String + password: String +} + +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! + requestAuthToken(userId: String!): Boolean! + redeemAuthToken(userId: String!, token: String!): Boolean! +} + +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!): KeystoneAdminUIListMeta +} + +type KeystoneAdminUIListMeta { + key: String! + path: String! + description: String + label: String! + labelField: String! + singular: String! + plural: String! + fields: [KeystoneAdminUIFieldMeta!]! + groups: [KeystoneAdminUIFieldGroupMeta!]! + graphql: KeystoneAdminUIGraphQL! + pageSize: Int! + initialColumns: [String!]! + initialSearchFields: [String!]! + initialSort: KeystoneAdminUISort + isSingleton: Boolean! + hideNavigation: Boolean! + hideCreate: Boolean! + hideDelete: Boolean! +} + +type KeystoneAdminUIFieldMeta { + path: String! + label: String! + description: String + isOrderable: Boolean! + isFilterable: Boolean! + isNonNull: [KeystoneAdminUIFieldMetaIsNonNull!] + fieldMeta: JSON + viewsIndex: Int! + customViewsIndex: Int + createView: KeystoneAdminUIFieldMetaCreateView! + listView: KeystoneAdminUIFieldMetaListView! + itemView(id: ID): KeystoneAdminUIFieldMetaItemView + search: QueryMode +} + +enum KeystoneAdminUIFieldMetaIsNonNull { + read + create + update +} + +type KeystoneAdminUIFieldMetaCreateView { + fieldMode: KeystoneAdminUIFieldMetaCreateViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaCreateViewFieldMode { + edit + hidden +} + +type KeystoneAdminUIFieldMetaListView { + fieldMode: KeystoneAdminUIFieldMetaListViewFieldMode! +} + +enum KeystoneAdminUIFieldMetaListViewFieldMode { + read + hidden +} + +type KeystoneAdminUIFieldMetaItemView { + fieldMode: KeystoneAdminUIFieldMetaItemViewFieldMode + fieldPosition: KeystoneAdminUIFieldMetaItemViewFieldPosition +} + +enum KeystoneAdminUIFieldMetaItemViewFieldMode { + edit + read + hidden +} + +enum KeystoneAdminUIFieldMetaItemViewFieldPosition { + form + sidebar +} + +enum QueryMode { + default + insensitive +} + +type KeystoneAdminUIFieldGroupMeta { + label: String! + description: String + fields: [KeystoneAdminUIFieldMeta!]! +} + +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/auth-magic-link/schema.prisma b/examples/auth-magic-link/schema.prisma new file mode 100644 index 00000000000..ad2c476aef7 --- /dev/null +++ b/examples/auth-magic-link/schema.prisma @@ -0,0 +1,21 @@ +// 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 + oneTimeToken String? + oneTimeTokenCreatedAt DateTime? +} diff --git a/examples/auth-magic-link/schema.ts b/examples/auth-magic-link/schema.ts new file mode 100644 index 00000000000..488622f53ed --- /dev/null +++ b/examples/auth-magic-link/schema.ts @@ -0,0 +1,178 @@ +import { graphql, list } from '@keystone-6/core' +import { allowAll, denyAll } from '@keystone-6/core/access' +import { password, text, timestamp } from '@keystone-6/core/fields' +import type { Lists, Context } from '.keystone/types' + +import { randomBytes } from 'node:crypto' + +export type Session = { + itemId: string +} + +function hasSession ({ session }: { session?: Session }) { + return Boolean(session) +} + +function isSameUserFilter ({ session }: { session?: Session }) { + // you need to have a session to do this + if (!session) return false + + // only yourself + return { + id: { + equals: session.itemId, + }, + } +} + +const hiddenField = { + access: denyAll, + graphql: { + omit: true, + }, + ui: { + createView: { + fieldMode: () => 'hidden' as const, + }, + itemView: { + fieldMode: () => 'hidden' as const, + }, + listView: { + fieldMode: () => 'hidden' as const, + }, + }, +} + +export const lists = { + User: list({ + access: { + operation: { + query: allowAll, + create: allowAll, + + // what a user can update is limited by + // the access.filter.* access controls + update: hasSession, + delete: hasSession, + }, + filter: { + update: isSameUserFilter, + }, + }, + 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({ + isIndexed: 'unique', + validation: { + isRequired: true, + }, + }), + password: password({ + validation: { + isRequired: true + } + }), + oneTimeToken: password({ ...hiddenField, }), + oneTimeTokenCreatedAt: timestamp({ ...hiddenField, }), + }, + }), +} satisfies Lists + +export const extendGraphqlSchema = graphql.extend(base => { + return { + mutation: { + // https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html + requestAuthToken: graphql.field({ + type: graphql.nonNull(graphql.Boolean), // always true + args: { userId: graphql.arg({ type: graphql.nonNull(graphql.String) }) }, + + async resolve (args, { userId }, context: Context) { + // out of band + ;(async function () { + const ott = randomBytes(16).toString('base64url') + const sudoContext = context.sudo() + + const user = await sudoContext.db.User.findOne({ where: { id: userId } }) + if (!user) return + + await sudoContext.db.User.updateOne({ + where: { id: userId }, + data: { + oneTimeToken: ott, + oneTimeTokenCreatedAt: new Date(), + }, + }) + + // you could send this one time token as is + // or embedded in a "magic link" (or similar) + console.log(`your code is ${ott}`) + }()) + + // always return true, lest we leak information + return true + }, + }), + + redeemAuthToken: graphql.field({ + type: graphql.nonNull(graphql.Boolean), + args: { + userId: graphql.arg({ type: graphql.nonNull(graphql.String) }), + token: graphql.arg({ type: graphql.nonNull(graphql.String) }), + }, + + async resolve (args, { userId, token }, context: 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() + const user = await sudoContext.db.User.findOne({ where: { id: userId } }) + const { + oneTimeToken, + oneTimeTokenCreatedAt, + } = user ?? {} + const expiry = (oneTimeTokenCreatedAt?.getTime() ?? 0) + 300000 /* 5 minutes */ + + if (!oneTimeToken) { + await kdf.generateHash('simulated-password-to-counter-timing-attack') + return false + } + + const result = await kdf.compare(token, oneTimeToken) + if (Date.now() > expiry) return false + + // out of band + ;(async function () { + if (!user) return + if (!result) return + + // reset + await sudoContext.db.User.updateOne({ + where: { id: user.id }, + data: { + oneTimeToken: null, + oneTimeTokenCreatedAt: null, + }, + }) + }()) + + if (result) { + await context.sessionStrategy.start({ + context, + data: { + listkey: 'User', + itemId: userId, + }, + }) + } + + return result + }, + }), + }, + } +}) diff --git a/examples/auth/keystone.ts b/examples/auth/keystone.ts index ee96e919f1e..c55238d2b07 100644 --- a/examples/auth/keystone.ts +++ b/examples/auth/keystone.ts @@ -1,8 +1,11 @@ import { config } from '@keystone-6/core' import { statelessSessions } from '@keystone-6/core/session' import { createAuth } from '@keystone-6/auth' -import { type Session, lists } from './schema' -import { type TypeInfo } from '.keystone/types' +import { + type Session, + lists +} 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 @@ -51,7 +54,7 @@ export default withAuth>( config({ db: { provider: 'sqlite', - url: process.env.DATABASE_URL || 'file:./keystone-example.db', + 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', diff --git a/examples/auth/schema.ts b/examples/auth/schema.ts index 1031ad4463b..2715ea3f411 100644 --- a/examples/auth/schema.ts +++ b/examples/auth/schema.ts @@ -39,7 +39,7 @@ function isAdminOrSameUserFilter ({ session }: { session?: Session }) { // admins can see everything if (session.data?.isAdmin) return {} - // the authenticated user can only see themselves + // only yourself return { id: { equals: session.itemId, @@ -65,11 +65,11 @@ export const lists = { create: allowAll, query: allowAll, - // only allow users to update _anything_, but what they can update is limited by + // what a user can update is limited by // the access.filter.* and access.item.* access controls update: hasSession, - // only allow admins to delete users + // only admins can delete users delete: isAdmin, }, filter: { @@ -94,7 +94,7 @@ export const lists = { // should not be publicly visible // // we use isIndexed to enforce names are unique - // that may not suitable for your application + // that may not be suitable for your application name: text({ access: { // only the respective user, or an admin can read this field diff --git a/examples/reuse/schema.ts b/examples/reuse/schema.ts index 6cc07c52e6f..1d4642e242c 100644 --- a/examples/reuse/schema.ts +++ b/examples/reuse/schema.ts @@ -6,7 +6,7 @@ import { checkbox, text, timestamp } from '@keystone-6/core/fields' import type { Lists, TypeInfo } from '.keystone/types' -const readOnly = { +const readOnlyField = { access: { read: allowAll, create: denyAll, @@ -94,25 +94,25 @@ function trackingAtHooks< function trackingFields () { return { createdBy: text({ - ...readOnly, + ...readOnlyField, hooks: { ...trackingByHooks(true), }, }), createdAt: timestamp({ - ...readOnly, + ...readOnlyField, hooks: { ...trackingAtHooks(true), }, }), updatedBy: text({ - ...readOnly, + ...readOnlyField, hooks: { ...trackingByHooks(), }, }), updatedAt: timestamp({ - ...readOnly, + ...readOnlyField, hooks: { ...trackingAtHooks(), }, diff --git a/packages/auth/src/gql/getBaseAuthSchema.ts b/packages/auth/src/gql/getBaseAuthSchema.ts index 80ff473e6e2..0b3ce1a5671 100644 --- a/packages/auth/src/gql/getBaseAuthSchema.ts +++ b/packages/auth/src/gql/getBaseAuthSchema.ts @@ -27,14 +27,7 @@ export function getBaseAuthSchema ({ gqlNames: AuthGqlNames secretFieldImpl: SecretFieldImpl base: graphql.BaseSchemaMeta - // TODO: return type required by pnpm :( -}): { - extension: graphql.Extension - ItemAuthenticationWithPasswordSuccess: graphql.ObjectType<{ - sessionToken: string - item: BaseItem - }> -} { +}) { const ItemAuthenticationWithPasswordSuccess = graphql.object<{ sessionToken: string item: BaseItem diff --git a/packages/auth/src/schema.ts b/packages/auth/src/schema.ts index 6e11eafe710..4ddf3e74d1d 100644 --- a/packages/auth/src/schema.ts +++ b/packages/auth/src/schema.ts @@ -10,11 +10,11 @@ import { import { graphql } from '@keystone-6/core' import { getGqlNames } from '@keystone-6/core/types' -import { - type AuthGqlNames, - type AuthTokenTypeConfig, - type InitFirstItemConfig, - type SecretFieldImpl, +import type { + AuthGqlNames, + AuthTokenTypeConfig, + InitFirstItemConfig, + SecretFieldImpl, } from './types' import { getBaseAuthSchema } from './gql/getBaseAuthSchema' import { getInitFirstItemSchema } from './gql/getInitFirstItemSchema' diff --git a/packages/core/src/fields/types/password/index.ts b/packages/core/src/fields/types/password/index.ts index 3c189aa4900..02f392c72cc 100644 --- a/packages/core/src/fields/types/password/index.ts +++ b/packages/core/src/fields/types/password/index.ts @@ -55,8 +55,8 @@ const bcryptHashRegex = /^\$2[aby]?\$\d{1,2}\$[./A-Za-z0-9]{53}$/ export function password (config: PasswordFieldConfig = {}): FieldTypeFunc { const { - bcrypt = bcryptjs, - workFactor = 10, + bcrypt = bcryptjs, // TODO: rename to kdf in breaking change + workFactor = 10, // TODO: remove in breaking change, use a custom KDF validation = {}, } = config const { @@ -145,23 +145,15 @@ export function password (config: Passwo : { arg: graphql.arg({ type: PasswordFilter }), resolve (val) { - if (val === null) { - throw userInputError('Password filters cannot be set to null') - } - if (val.isSet) { - return { - not: null, - } - } + if (val === null) throw userInputError('Password filters cannot be set to null') + if (val.isSet) return { not: null } return null }, }, create: { arg: graphql.arg({ type: graphql.String }), resolve (val) { - if (val === undefined) { - return null - } + if (val === undefined) return null return inputResolver(val) }, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98c61c6a7b3..bbc7af3a4b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,25 @@ importers: specifier: ^5.5.0 version: 5.7.3 + examples/auth-magic-link: + dependencies: + '@keystone-6/auth': + specifier: ^8.1.0 + version: link:../../packages/auth + '@keystone-6/core': + specifier: ^6.3.1 + version: link:../../packages/core + '@prisma/client': + specifier: 5.19.0 + version: 5.19.0(prisma@5.19.0) + devDependencies: + prisma: + specifier: 5.19.0 + version: 5.19.0 + typescript: + specifier: ^5.5.0 + version: 5.7.3 + examples/better-list-search: dependencies: '@keystone-6/core': From 032782896fbdaecfa02f735c0fa180c499aadf4f Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Tue, 4 Feb 2025 17:34:21 +1100 Subject: [PATCH 11/14] fix password field cell components when null --- .../src/fields/types/password/views/index.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/core/src/fields/types/password/views/index.tsx b/packages/core/src/fields/types/password/views/index.tsx index 6d808602a57..704014e0a85 100644 --- a/packages/core/src/fields/types/password/views/index.tsx +++ b/packages/core/src/fields/types/password/views/index.tsx @@ -197,7 +197,7 @@ export function Field (props: FieldProps) { } export const Cell: CellComponent = ({ value }) => { - return value.isSet + return value !== null ? (
@@ -249,11 +249,11 @@ type Value = confirm: string } -type PasswordController = FieldController & { validation: Validation } - -export const controller = ( +export function controller ( config: FieldControllerConfig -): PasswordController => { +): FieldController & { + validation: Validation +} { const validation: Validation = { ...config.fieldMeta.validation, match: @@ -288,9 +288,22 @@ export const controller = ( ? undefined : { Filter (props) { - const { autoFocus, context, typeLabel, onChange, value, type, ...otherProps } = props + const { + autoFocus, + context, + typeLabel, + onChange, + value, + type, + ...otherProps + } = props return ( - + {typeLabel} set ) From 17b26c0aeaa9dc95322b51a77315d0c6da48de8d Mon Sep 17 00:00:00 2001 From: Daniel Cousens Date: Tue, 4 Feb 2025 18:08:38 +1100 Subject: [PATCH 12/14] add explanation commentary --- examples/auth-magic-link/schema.ts | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/examples/auth-magic-link/schema.ts b/examples/auth-magic-link/schema.ts index 488622f53ed..503153765b6 100644 --- a/examples/auth-magic-link/schema.ts +++ b/examples/auth-magic-link/schema.ts @@ -82,16 +82,33 @@ export const lists = { }), } satisfies Lists +// 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 +// +// This example should follow the OWASP guidance for the flow of a password reset, as the outcome is the same (a user is authenticated out of band). +// +// The `requestAuthToken` mutation always returns `true` to prevent user enumeration. +// We ensure that authentication tokens are randomly generated, short-lived (e.g. 5 minutes expiry), and hashed in the database (using the Keystone password field). +// The out of band delivery code and database lookup & update is run asynchronously to mitigate timing attacks. +// The delivery of the one-time-token is out of band (e.g. by email or SMS), the exact implementation of ths is left up to you, with console.log used for this example. +// +// The `requestAuthToken` mutation returns `true` only if the token is equal and not passed the expiry. +// The user provided token is hashed as part of the comparison, and an approximately constant-time approach is used to mitigate timing attacks. +// We ensure that authentication tokens are randomly generated, short-lived (e.g. 5 minutes expiry), and hashed in the database (using the Keystone password field). +// +// References +// https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html + export const extendGraphqlSchema = graphql.extend(base => { return { mutation: { - // https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html requestAuthToken: graphql.field({ type: graphql.nonNull(graphql.Boolean), // always true args: { userId: graphql.arg({ type: graphql.nonNull(graphql.String) }) }, async resolve (args, { userId }, context: Context) { - // out of band + // run asynchronously to mitigate timing attacks ;(async function () { const ott = randomBytes(16).toString('base64url') const sudoContext = context.sudo() @@ -109,7 +126,7 @@ export const extendGraphqlSchema = graphql.extend(base => { // you could send this one time token as is // or embedded in a "magic link" (or similar) - console.log(`your code is ${ott}`) + console.log(`DEBUGGING: the one time token for ${user.id} is ${ott}`) }()) // always return true, lest we leak information @@ -128,7 +145,6 @@ export const extendGraphqlSchema = graphql.extend(base => { 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() const user = await sudoContext.db.User.findOne({ where: { id: userId } }) const { @@ -142,6 +158,7 @@ export const extendGraphqlSchema = graphql.extend(base => { return false } + // TODO: could the expiry be checked before the hashing operation? const result = await kdf.compare(token, oneTimeToken) if (Date.now() > expiry) return false From c49a876bc4c4f903ceb99e451390054a7512aa48 Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:17:41 +1100 Subject: [PATCH 13/14] prefer import type --- packages/auth/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 0d5cac210a2..de5078ae3be 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -6,9 +6,9 @@ import type { BaseKeystoneTypeInfo, KeystoneConfig, } from '@keystone-6/core/types' -import { - type AuthConfig, - type AuthGqlNames +import type { + AuthConfig, + AuthGqlNames } from './types' import { getSchemaExtension } from './schema' From dee5ceab9569d66947c508ec2c1608c84220f69e Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 4 Feb 2025 19:21:46 +1100 Subject: [PATCH 14/14] update comments for timing attack reduction --- examples/auth-magic-link/schema.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/examples/auth-magic-link/schema.ts b/examples/auth-magic-link/schema.ts index 503153765b6..20cc5e2f46a 100644 --- a/examples/auth-magic-link/schema.ts +++ b/examples/auth-magic-link/schema.ts @@ -90,11 +90,11 @@ export const lists = { // // The `requestAuthToken` mutation always returns `true` to prevent user enumeration. // We ensure that authentication tokens are randomly generated, short-lived (e.g. 5 minutes expiry), and hashed in the database (using the Keystone password field). -// The out of band delivery code and database lookup & update is run asynchronously to mitigate timing attacks. +// The out of band delivery code and database lookup & update is run asynchronously to reduce timing attacks. // The delivery of the one-time-token is out of band (e.g. by email or SMS), the exact implementation of ths is left up to you, with console.log used for this example. // // The `requestAuthToken` mutation returns `true` only if the token is equal and not passed the expiry. -// The user provided token is hashed as part of the comparison, and an approximately constant-time approach is used to mitigate timing attacks. +// The user provided token is hashed as part of the comparison, and an approximately constant-time approach is used to reduce timing attacks. // We ensure that authentication tokens are randomly generated, short-lived (e.g. 5 minutes expiry), and hashed in the database (using the Keystone password field). // // References @@ -108,7 +108,7 @@ export const extendGraphqlSchema = graphql.extend(base => { args: { userId: graphql.arg({ type: graphql.nonNull(graphql.String) }) }, async resolve (args, { userId }, context: Context) { - // run asynchronously to mitigate timing attacks + // run asynchronously to reduce timing attacks ;(async function () { const ott = randomBytes(16).toString('base64url') const sudoContext = context.sudo() @@ -158,15 +158,11 @@ export const extendGraphqlSchema = graphql.extend(base => { return false } - // TODO: could the expiry be checked before the hashing operation? + // TODO: could the expiry be checked before the hashing operation? timing? const result = await kdf.compare(token, oneTimeToken) if (Date.now() > expiry) return false - // out of band - ;(async function () { - if (!user) return - if (!result) return - + if (result) { // reset await sudoContext.db.User.updateOne({ where: { id: user.id }, @@ -175,9 +171,7 @@ export const extendGraphqlSchema = graphql.extend(base => { oneTimeTokenCreatedAt: null, }, }) - }()) - if (result) { await context.sessionStrategy.start({ context, data: {