All files / Bandstand/src/lib auth.ts

98.16% Statements 107/109
69.44% Branches 25/36
100% Functions 7/7
98.16% Lines 107/109

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 1361x 1x   1x 1x 1x 1x                         12x 12x 12x 12x 24x 24x 24x   12x 12x 12x     12x   3x 3x 3x 3x 3x   1x 1x 1x 1x 1x 1x 1x 1x 1x 12x 12x 12x 12x 12x   11x 11x 11x 12x 12x 10x 10x 10x 1x 1x 1x 1x 12x 12x 12x 1x   1x 1x 1x 1x 1x 1x 1x 4x 1x 1x   1x   1x 1x 3x   3x     3x 3x 2x 3x 3x 1x 2x   2x 2x 1x 1x 2x 2x 2x 2x 2x 2x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 1x 1x 2x 2x 2x 2x 2x 1x 1x  
import CredentialsProvider from 'next-auth/providers/credentials'
import GoogleProvider from 'next-auth/providers/google'
import type {NextAuthOptions} from 'next-auth'
import {prisma} from './db'
import {verifyPassword} from './password'
import {clearFailures, isThrottled, recordFailure} from './login-throttle'
import {avatarUrl} from './avatars'
 
/**
 * Email + password sign-in, plus "Sign in with Google" for members who have a
 * Google account. Either way the person must already be a member (an admin
 * adds them on /members): Google only proves who they are, it never creates
 * an account. Not everyone in the band has Google, hence the passwords.
 *
 * Sessions are JWTs (the credentials provider requires it). Each token
 * carries the user's sessionVersion at sign-in; resetting or changing a
 * password bumps it, which ends every older session on its next request.
 */
 
function clientIp(
  headers: Record<string, string | string[] | undefined> | undefined,
) {
  const h = (k: string) => {
    const v = headers?.[k]
    return Array.isArray(v) ? v[0] : v
  }
  // Behind Cloudflare and Traefik: the original client is the first hop
  return (
    h('cf-connecting-ip') ??
    h('x-forwarded-for')?.split(',')[0].trim() ??
    'unknown'
  )
}
 
function findMember(email: string) {
  return prisma.user.findFirst({
    where: {email: {equals: email.trim(), mode: 'insensitive'}},
  })
}
 
export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      name: 'Password',
      credentials: {
        email: {label: 'Email', type: 'email'},
        password: {label: 'Password', type: 'password'},
      },
      async authorize(credentials, req) {
        const email = credentials?.email?.trim().toLowerCase() ?? ''
        const password = credentials?.password ?? ''
        if (!email || !password) return null
        const ip = clientIp(req?.headers as Record<string, string> | undefined)
        if (isThrottled(email, ip)) throw new Error('throttled')
 
        const user = await prisma.user.findFirst({
          where: {email: {equals: email, mode: 'insensitive'}},
        })
        const ok = await verifyPassword(password, user?.passwordHash)
        if (!user || !ok) {
          recordFailure(email, ip)
          return null
        }
        clearFailures(email)
        return {
          id: user.id,
          email: user.email,
          name: user.displayName ?? user.name,
        }
      },
    }),
    // Offered only when the OAuth client is configured
    ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
      ? [
          GoogleProvider({
            clientId: process.env.GOOGLE_CLIENT_ID,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET,
          }),
        ]
      : []),
  ],
  pages: {signIn: '/login', error: '/login'},
  // Long-lived: people sign in once on the phone or iPad they read charts on
  session: {strategy: 'jwt', maxAge: 180 * 24 * 60 * 60},
 
  callbacks: {
    async signIn({account, profile}) {
      if (account?.provider !== 'google') return true
      // Google: only a verified email that belongs to a band member
      const p = profile as
        | {email?: string; email_verified?: boolean}
        | undefined
      if (!p?.email || p.email_verified === false)
        return '/login?error=NotMember'
      const member = await findMember(p.email)
      return member ? true : '/login?error=NotMember'
    },
    async jwt({token, user, account}) {
      if (user) {
        // Credentials return our user id; Google returns Google's, so map by email
        const u =
          account?.provider === 'google'
            ? await findMember(user.email ?? '')
            : await prisma.user.findUnique({where: {id: user.id}})
        if (!u) return token
        token.uid = u.id
        token.sv = u.sessionVersion
      }
      return token
    },
    async session({session, token}) {
      const u = token.uid
        ? await prisma.user.findUnique({
            where: {id: token.uid as string},
            select: {
              email: true,
              name: true,
              displayName: true,
              sessionVersion: true,
              id: true,
              avatarAt: true,
            },
          })
        : null
      // Deleted user, or password changed since this token was issued
      if (!u || u.sessionVersion !== token.sv) return {expires: session.expires}
      session.user = {
        email: u.email,
        name: u.displayName ?? u.name,
        image: avatarUrl(u),
      }
      return session
    },
  },
}