payloadcms/payload

Custom auth strategy breaks admin login page

Closed this issue · 2 comments

Describe the Bug

I want to replace disable Payload local strategy entirely to add the functionality to login using email and mobile number simultaneously. But when I login to admin, I get a blank page of only Payload logo:

Image

User Collection

import { authStrategy } from '@/collection/authStrategy'
import type { CollectionConfig } from 'payload'

export const Users: CollectionConfig = {
  slug: 'users',
  auth: {
    disableLocalStrategy: true,
    strategies: [authStrategy],
  },
  fields: [
    {
      name: 'email',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'hash',
      type: 'text',
      required: false,
    },
  ],
}

@/collection/authStrategy

import crypto from 'crypto'
import jwt from 'jsonwebtoken'
import {
  AuthStrategy,
  AuthStrategyFunctionArgs,
  AuthStrategyResult,
  parseCookies,
  type Payload,
  User,
} from 'payload'

export async function getMe({
  headers,
  payload,
}: {
  headers: Request['headers']
  payload: Payload
}): Promise<User | null> {
  const cookie = parseCookies(headers)
  const token = cookie.get(`${payload.config.cookiePrefix}-token`)

  if (!token) return null

  let jwtUser: jwt.JwtPayload | string
  try {
    jwtUser = jwt.verify(
      token,
      crypto.createHash('sha256').update(payload.config.secret).digest('hex').slice(0, 32),
      {
        algorithms: ['HS256'],
      },
    )
  } catch (e) {
    if (e instanceof jwt.TokenExpiredError) return null
    throw e
  }
  if (typeof jwtUser === 'string' || typeof jwtUser.email !== 'string') return null

  const usersQuery = await payload.find({
    collection: 'users',
    where: { email: { equals: jwtUser.email } },
    depth: 10,
  })

  return {
    collection: 'User',
    ...usersQuery.docs[0],
  }
}

async function authenticate({
  headers,
  payload,
}: AuthStrategyFunctionArgs): Promise<AuthStrategyResult> {
  const me = await getMe({ headers, payload })

  if (!me) {
    return { user: null }
  }

  return {
    user: me,
  }
}

export const authStrategy: AuthStrategy = {
  name: 'risaman',
  authenticate,
}

/app/(payload)/api/users/login/route.ts

import { handleLogin } from '@/app/(payload)/api/users/login/handleLogin'
import { respond } from '@/respond'
import { PayloadRequest } from 'payload'
import { z } from 'zod'

export async function handleLogin({ email, password }: { email: string; password: string }) {
  const payload = await getPayloadHMR({ config })
  const data = await payload.find({
    collection: 'users',
    where: {
      email: { equals: email },
    },
    depth: 10,
  })

  const user = data.docs[0]

  const isValid = await bcrypt.compare(password, user.hash || '')
  if (!isValid) {
    return [
      null,
      Response.json(
        {
          message: 'Not passed',
        },
        {
          status: 401,
        },
      ),
    ]
  }

  const collectionConfig = payload.collections['users'].config
  const payloadConfig = payload.config
  const fieldsToSign = getFieldsToSign({
    collectionConfig,
    email: user.email,
    user: {
      collection: 'users',
      ...user,
    },
  })
  const token = jwt.sign(fieldsToSign, payload.secret, {
    expiresIn: collectionConfig.auth.tokenExpiration,
  })

  const cookie = generatePayloadCookie({
    collectionAuthConfig: collectionConfig.auth,
    cookiePrefix: payloadConfig.cookiePrefix,
    token,
  })

  return [
    user,
    Response.json(
      {
        message: 'Passed',
      },
      {
        headers: {
          'set-cookie': cookie,
        },
      },
    ),
  ]
}

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(7),
})

type LoginPayload = z.infer<typeof schema>

async function readData(request: PayloadRequest): Promise<LoginPayload> {
  if (!request.formData) {
    throw new Error('Insufficient data')
  }

  const payload = (await request.formData()).get('_payload')
  const fields = typeof payload === 'string' ? JSON.parse(payload) : null
  return schema.parse(fields)
}

export async function POST(request: PayloadRequest) {
  let payload: LoginPayload

  try {
    payload = await readData(request)
  } catch (error) {
    const response = {
      message: 'Validation failed',
      ...(error instanceof z.ZodError ? { errors: error.format() } : {}),
    }
    return respond(response, 400)
  }

  const [user, response] = await handleLogin(payload)

  return response
}

Link to the code that reproduces this issue

https://github.com/bmamouri/payload-auth-replacement

Reproduction Steps

I have a minimum reproduction repo

Which area(s) are affected? (Select all that apply)

area: core

Environment Info

Node.js v18.20.4

Binaries:
Node: 18.20.4
npm: 10.7.0
Yarn: N/A
pnpm: 9.12.1
Relevant Packages:
payload: 3.0.0-beta.123
next: 15.0.0
@payloadcms/db-postgres: 3.0.0-beta.123
@payloadcms/graphql: 3.0.0-beta.123
@payloadcms/next/utilities: 3.0.0-beta.123
@payloadcms/ui/shared: 3.0.0-beta.123
react: 19.0.0-rc-603e6108-20241029
react-dom: 19.0.0-rc-603e6108-20241029
Operating System:
Platform: darwin
Arch: arm64
Version: Darwin Kernel Version 24.1.0: Thu Oct 10 21:05:14 PDT 2024; root:xnu-11215.41.3~2/RELEASE_ARM64_T8103
Available memory (MB): 16384
Available CPU cores: 8

I have just realised that this is by design: https://github.com/payloadcms/payload/blob/v3.0.0-beta.123/packages/next/src/views/Login/index.tsx#L86

It seems when you set disableLocalStrategy, you would need to create your own login form.

This issue has been automatically locked.
Please open a new issue if this issue persists with any additional detail.