@twreporter-academy/keystone-totp
v0.0.2
Published
TOTP (authenticator-app) two-factor authentication for [Keystone 6](https://keystonejs.com/), shaped like `@keystone-6/auth`: `createTotp()` returns `{ withTotp, fields }` and you wrap your config.
Readme
@twreporter-academy/keystone-totp
TOTP (authenticator-app) two-factor authentication for Keystone 6, shaped like @keystone-6/auth: createTotp() returns { withTotp, fields } and you wrap your config.
Install
yarn add @twreporter-academy/keystone-totp
# peer: @keystone-6/core ^6, react ^18Wire it up
1. Create the plugin (pass your app's Keystone imports)
GraphQL object types must come from the same @keystone-6/core / graphql instance as your lists. Always pass keystone — graphql plus the field constructors this plugin uses to stamp TOTP columns onto that list:
// auth/totp.ts
import { graphql } from '@keystone-6/core'
import { integer, text, timestamp, virtual } from '@keystone-6/core/fields'
import { createCrypto, createTotp } from '@twreporter-academy/keystone-totp'
export const totp = createTotp({
listKey: 'User',
identityField: 'email', // used as authenticator label
issuer: 'My CMS',
// Local AES for keystone dev; Cloud Run injects KMS via the same crypto shape.
crypto: createCrypto(process.env.SESSION_SECRET!),
canManageTotp: (session, targetUserId) =>
session?.data?.role === 'admin' || session?.itemId === targetUserId,
// optional: override field access (defaults keep totpSecret/totpLastStep/
// totpFailedAttempts/totpLockedUntil unwritable via CRUD — disable/reset is
// clearTotp + canManageTotp only)
// access: { totpSecret: { update: () => false } },
keystone: {
graphql,
// Host-app constructors (not field configs). Duplicate @keystone-6/core copies
// break GraphQL types. Spread as totp.fields in step 2:
// text → totpSecret, integer → totpLastStep / totpFailedAttempts,
// timestamp → totpLockedUntil, virtual → totpControls
fields: { text, integer, timestamp, virtual },
},
// optional: labels (English defaults), pagePath ('/two-factor')
})2. Spread fields onto the auth list
import { list } from '@keystone-6/core'
import { password, text } from '@keystone-6/core/fields'
import { totp } from '../auth/totp'
export const User = list({
fields: {
email: text({ isIndexed: 'unique', validation: { isRequired: true } }),
password: password({ validation: { isRequired: true } }),
...totp.fields, // totpSecret, totpLastStep, totpFailedAttempts, totpLockedUntil, totpControls
},
})3. Wrap config outside withAuth
import { createAuth } from '@keystone-6/auth'
import { config } from '@keystone-6/core'
import { statelessSessions } from '@keystone-6/core/session'
import { totp } from './auth/totp'
const { withAuth } = createAuth({
listKey: 'User',
identityField: 'email',
secretField: 'password',
sessionData: 'id email',
})
export default totp.withTotp(
withAuth(
config({
lists: { User },
session: statelessSessions({ secret: process.env.SESSION_SECRET! }),
ui: {
// Allow password-only (pending TOTP) sessions into the Admin shell
// so /two-factor can load. pageMiddleware then enforces TOTP.
isAccessAllowed: (context) => !!context.session?.data,
},
})
)
)withTotp injects:
- session
startwrapper (totpOk) - GraphQL:
totpSession,beginTotpEnrollment,confirmTotpEnrollment,verifyTotp,clearTotp /two-factorAdmin page viagetAdditionalFilespublicPages+pageMiddlewareredirects for pending sessions
4. Client gate in custom Navigation
Keystone's sign-in page does router.push('/'), which skips pageMiddleware. If you customize Admin Navigation, render:
import { TotpPendingGate } from '@twreporter-academy/keystone-totp/admin/TotpPendingGate'
function Navigation(props) {
return (
<NavigationContainer {...props}>
<TotpPendingGate />
{/* ... */}
</NavigationContainer>
)
}5. Gate list access on session.totpOk
access: {
operation: {
query: ({ session }) => Boolean(session?.totpOk),
// ...
},
}Behaviour notes
- Enroll: no
totpSecret→/two-factorshows QR; confirm stores the encrypted secret. Admin status follows whether a secret is stored. - Verify: secret present → enter code. Password login never sets
totpOk. - Replay: a successfully used code cannot be reused in the same timestep (
totpLastStep); a second verify of the same code fails withinvalidCode. - Lockout: five consecutive wrong codes set
totpLockedUntilfor 15 minutes (shared across sessions). Expired locks and successful verifies clear the counter;clearTotpclears it too. - Disable:
clearTotponly (needscanManageTotp). WipestotpSecret,totpLastStep, and lockout fields together. List updates cannot write those fields (fieldaccessdefaults + optional overrides viacreateTotp({ access })). - Encryption: inject
crypto: { encryptTotpSecret, decryptTotpSecret }(async).createCrypto(secret)is the local AES-256-GCM helper for development; production hosts typically pass GCP KMS (or similar) instead. Rotating a local AES secret invalidates stored TOTP secrets; KMS key versions continue to decrypt older ciphertext.
Reference
This monorepo's CMS (packages/cms) is the reference consumer: see auth/totp-plugin.ts, keystone.ts, and lists/system-user.ts.
