UNPKG

keystone-6-oauth

Version:

Keystone6 Plugin that enables social logins such as Google, Twitter, Github, Facebook and others.

359 lines (324 loc) 10.8 kB
import { AdminFileToWrite, BaseListTypeInfo, KeystoneConfig, AdminUIConfig, BaseKeystoneTypeInfo, SessionStrategy, KeystoneContext, } from '@keystone-6/core/types'; import * as cookie from 'cookie'; import type { NextApiRequest } from 'next'; import { Session } from 'next-auth'; import { getSession } from 'next-auth/react'; import { getToken, JWT } from 'next-auth/jwt'; import { Provider } from 'next-auth/providers'; import { nextConfigTemplate } from './templates/next-config'; import { authTemplate } from './templates/auth'; import { getSchemaExtension } from './schema'; import { AuthConfig, KeystoneOAuthConfig, AuthSessionStrategy } from './types'; import url from 'url'; /** * createAuth function * * Generates config for Keystone to implement standard auth features. */ export type { NextAuthProviders, KeystoneOAuthConfig, KeystoneOAuthCallbacks, KeystoneOAuthOnSignIn, KeystoneOAuthOnSignUp } from './types'; export function createAuth<GeneratedListTypes extends BaseListTypeInfo>({ autoCreate, context, cookies, identityField, listKey, keystonePath, onSignIn, onSignUp, pages, providers, sessionData, sessionSecret, }: AuthConfig<GeneratedListTypes>) { // The protectIdentities flag is currently under review to see whether it should be // part of the createAuth API (in which case its use cases need to be documented and tested) // or whether always being true is what we want, in which case we can refactor our code // to match this. -TL const customPath = !keystonePath || keystonePath === '/' ? '' : keystonePath; /** * pageMiddleware * * Should be added to the ui.pageMiddleware stack. * * Redirects: * - from the signin or init pages to the index when a valid session is present * - to the init page when initFirstItem is configured, and there are no user in the database * - to the signin page when no valid session is present */ // TODO: [TYPES] Check pageMiddleware const authMiddleware: AdminUIConfig<BaseKeystoneTypeInfo>['pageMiddleware'] = async ({ context, isValidSession }) => { const { req, session } = context; const pathname = url.parse(req?.url!).pathname!; if (isValidSession) { if (customPath !== '' && pathname === '/') { return { kind: 'redirect', to: `${customPath}` }; } return null; } if (!session && !pathname.includes(`${customPath}/api/auth/`)) { return { kind: 'redirect', to: pages?.signIn || `${customPath}/api/auth/signin`, }; } }; /** * authGetAdditionalFiles * * This function adds files to be generated into the Admin UI build. Must be added to the * ui.getAdditionalFiles config. * * The sign-in page is always included, and the init page is included when initFirstItem is set */ const authGetAdditionalFiles = () => { const filesToWrite: AdminFileToWrite[] = [ { mode: 'write', outputPath: 'pages/api/auth/[...nextauth].js', src: authTemplate({ autoCreate, identityField, listKey, sessionData, sessionSecret, }), }, { mode: 'write', outputPath: 'next.config.js', src: nextConfigTemplate({ keystonePath: customPath }), }, ]; return filesToWrite; }; /** * publicAuthPages * * Must be added to the ui.publicPages config */ const authPublicPages = [ `${customPath}/api/__keystone_api_build`, `${customPath}/api/auth/csrf`, `${customPath}/api/auth/signin`, `${customPath}/api/auth/callback`, `${customPath}/api/auth/session`, `${customPath}/api/auth/providers`, `${customPath}/api/auth/signout`, `${customPath}/api/auth/error`, ]; // TODO: [TYPES] Add Provider // @ts-ignore function addPages(provider: Provider) { const name = provider.id; authPublicPages.push(`${customPath}/api/auth/signin/${name}`); authPublicPages.push(`${customPath}/api/auth/callback/${name}`); } providers.map(addPages); /** * extendGraphqlSchema * * Must be added to the extendGraphqlSchema config. Can be composed. */ const extendGraphqlSchema = getSchemaExtension({ identityField, listKey, }); /** * validateConfig * * Validates the provided auth config; optional step when integrating auth */ const validateConfig = (keystoneConfig: KeystoneConfig) => { const listConfig = keystoneConfig.lists[listKey]; if (listConfig === undefined) { const msg = `A createAuth() invocation specifies the list "${listKey}" but no list with that key has been defined.`; throw new Error(msg); } // TODO: Check for String-like typing for identityField? How? // TODO: Validate that the identifyField is unique. // TODO: If this field isn't required, what happens if I try to log in as `null`? const identityFieldConfig = listConfig.fields[identityField]; if (identityFieldConfig === undefined) { const identityFieldName = JSON.stringify(identityField); const msg = `A createAuth() invocation for the "${listKey}" list specifies ${identityFieldName} as its identityField but no field with that key exists on the list.`; throw new Error(msg); } }; /** * withItemData * * Automatically injects a session.data value with the authenticated item */ /* TODO: - [ ] We could support additional where input to validate item sessions (e.g an isEnabled boolean) */ const withItemData = ( _sessionStrategy: AuthSessionStrategy<Record<string, any>> ): AuthSessionStrategy<{ listKey: string; itemId: string; data: any }> => { const { get, end, ...sessionStrategy } = _sessionStrategy; return { ...sessionStrategy, end: async ({ context }) => { await end({ context }); const TOKEN_NAME = process.env.NODE_ENV === 'development' ? 'next-auth.session-token' : '__Secure-next-auth.session-token'; const { req, res } = context; if (!req || !res) return; res.setHeader( 'Set-Cookie', cookie.serialize(TOKEN_NAME, '', { // TODO: Update parse to URL domain: url.parse(req.url as string).hostname as string, expires: new Date(), httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', }) ); }, // TODO: [TYPES] Add get typing // @ts-ignore get: async ({ context }) => { const { req } = context; const pathname = url.parse(req?.url!).pathname!; // TODO let nextSession: JWT | Session & { listKey: string; itemId: string; data: any; } | null = null; if (!req) return; if (pathname.includes('/api/auth')) { return null; } const sudoContext = context.sudo(); if (req.headers?.authorization?.split(' ')[0] === 'Bearer') { nextSession = (await getToken({ req: req as NextApiRequest, secret: sessionSecret, })); } else { nextSession = (await getSession({ req })) as any; // TODO: [TYPES] Review nextSession } if ( !nextSession || !nextSession.listKey || nextSession.listKey !== listKey || !nextSession.itemId || !sudoContext.query[listKey] || !nextSession.itemId ) { return null; } const reqWithUser = req as any; reqWithUser.user = { data: nextSession.data, itemId: nextSession.itemId, listKey: nextSession.listKey, }; const userSession = await get({ context }); return { ...userSession, ...nextSession, data: nextSession.data, itemId: nextSession.itemId, listKey: nextSession.listKey, }; }, }; }; function defaultIsAccessAllowed({ session }: KeystoneContext) { return session !== undefined; } /** * withAuth * * Automatically extends config with the correct auth functionality. This is the easiest way to * configure auth for keystone; you should probably use it unless you want to extend or replace * the way auth is set up with custom functionality. * * It validates the auth config against the provided keystone config, and preserves existing * config by composing existing extendGraphqlSchema functions and ui config. */ const withAuth = (keystoneConfig: KeystoneConfig): KeystoneOAuthConfig => { validateConfig(keystoneConfig); let { ui } = keystoneConfig; if (!ui?.isDisabled) { const { getAdditionalFiles = [], isAccessAllowed = defaultIsAccessAllowed, pageMiddleware, publicPages = [], } = ui || {}; ui = { ...ui, publicPages: [...publicPages, ...authPublicPages], isAccessAllowed: async (context: KeystoneContext) => { const pathname = url.parse(context.req?.url!).pathname!; if ( pathname.startsWith(`${customPath}/_next`) || pathname.startsWith(`${customPath}/__next`) || pathname.startsWith(`${customPath}/api/auth/`) || (pages?.signIn && pathname.includes(pages?.signIn)) || (pages?.error && pathname.includes(pages?.error)) || (pages?.signOut && pathname.includes(pages?.signOut)) ) { return true; } return await isAccessAllowed(context); }, getAdditionalFiles: [...getAdditionalFiles, authGetAdditionalFiles], pageMiddleware: async args => { if (!authMiddleware) throw new Error('Missing authMiddleware'); const shouldRedirect = await authMiddleware(args); if (shouldRedirect) return shouldRedirect; return pageMiddleware?.(args); }, } } if (!keystoneConfig.session) throw new TypeError('Missing .session configuration'); const session = withItemData( keystoneConfig.session ) as SessionStrategy<any>; const existingExtendGraphQLSchema = keystoneConfig.extendGraphqlSchema; return { ...keystoneConfig, context, cookies, extendGraphqlSchema: existingExtendGraphQLSchema // TODO: [TYPES] Add schema ? (schema:any) => existingExtendGraphQLSchema(extendGraphqlSchema(schema)) : extendGraphqlSchema, lists: { ...keystoneConfig.lists, }, onSignIn, onSignUp, pages, providers, session, ui, }; }; return { withAuth }; }