All files / auth pre-signup.js

98.03% Statements 50/51
84.61% Branches 22/26
100% Functions 5/5
98.03% Lines 50/51

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 136 137 138 139 140 141 142                              1x   1x   1x 16x   16x 16x   16x 1x 1x         15x 9x 9x 9x   9x 9x     9x       6x 1x 1x 1x   5x       6x       16x 16x 13x     3x   4x     3x       3x 3x 3x   3x 4x 2x     2x                   9x     9x     9x 9x 9x   9x 9x               8x 6x 6x     2x 2x   2x                               2x       1x      
/**
 * Pre-Signup Lambda Trigger
 *
 * Handles three cases:
 * 1. Social login (OIDC/OAuth via Google): auto-confirms user (IdP has already verified
 *    their identity) and links the external provider to any existing Cognito account with
 *    the same email to prevent duplicate accounts.
 * 2. E2E test accounts (e2e-test-*@browsway.com): auto-confirms for automated testing.
 * 3. Regular users: standard email confirmation required.
 */
 
const {
  CognitoIdentityProviderClient,
  ListUsersCommand,
  AdminLinkProviderForUserCommand
} = require('@aws-sdk/client-cognito-identity-provider')
 
const cognitoClient = new CognitoIdentityProviderClient({})
 
exports.handler = async (event) => {
  console.log('Pre-Signup event:', JSON.stringify(event, null, 2))
 
  const { triggerSource } = event
  const email = event.request.userAttributes.email
 
  if (shouldBlockNonProdSignup(email)) {
    console.log(`Blocked signup outside allowlist in non-production: ${email}`)
    throw new Error('Sign-up is restricted in this environment')
  }
 
  // Social login: auto-confirm (user was already verified by the external OIDC provider)
  // and attempt to link to an existing account with the same email to prevent duplicates.
  if (triggerSource === 'PreSignUp_ExternalProvider') {
    console.log(`Social sign-in detected (${event.userName}) - auto-confirming`)
    event.response.autoConfirmUser = true
    event.response.autoVerifyEmail = true
 
    Eif (email) {
      await linkToExistingAccount(event)
    }
 
    return event
  }
 
  // Auto-confirm E2E test accounts only
  if (email && /^e2e-test-.+@browsway\.com$/.test(email)) {
    console.log(`E2E test email detected: ${email} - auto-confirming user`)
    event.response.autoConfirmUser = true
    event.response.autoVerifyEmail = true
  } else {
    console.log(`Regular email: ${email} - requiring email confirmation`)
    // Default behavior: user must confirm via email
  }
 
  return event
}
 
function shouldBlockNonProdSignup(email) {
  const enforcementEnabled = process.env.ENFORCE_NON_PROD_SIGNUP_ALLOWLIST === 'true'
  if (!enforcementEnabled || !email) {
    return false
  }
 
  const allowlist = (process.env.NON_PROD_SIGNUP_ALLOWLIST || '')
    .split(',')
    .map((entry) => entry.trim().toLowerCase())
    .filter(Boolean)
 
  Iif (allowlist.length === 0) {
    return false
  }
 
  const normalizedEmail = email.toLowerCase()
  const atIndex = normalizedEmail.lastIndexOf('@')
  const emailDomain = atIndex > -1 ? normalizedEmail.slice(atIndex + 1) : ''
 
  return !allowlist.some((entry) => {
    if (entry.includes('@')) {
      return normalizedEmail === entry
    }
 
    return emailDomain === entry
  })
}
 
/**
 * Links an external provider identity to an existing Cognito user with the same email.
 * This prevents duplicate accounts when a user signs up with email/password first,
 * then later signs in with Google or Apple using the same email address.
 */
async function linkToExistingAccount(event) {
  const email = event.request.userAttributes.email
  // event.userPoolId is always present in Cognito trigger payloads — avoids a CDK
  // circular dependency that would arise from passing it as an environment variable.
  const userPoolId = event.userPoolId
 
  // event.userName format: "Google_1234567890" or "SignInWithApple_abc123"
  const separatorIndex = event.userName.indexOf('_')
  const providerName = event.userName.substring(0, separatorIndex)
  const providerUserId = event.userName.substring(separatorIndex + 1)
 
  try {
    const listResult = await cognitoClient.send(
      new ListUsersCommand({
        UserPoolId: userPoolId,
        Filter: `email = "${email}"`,
        Limit: 1
      })
    )
 
    if (!listResult.Users || listResult.Users.length === 0) {
      console.log(`No existing user found for email ${email} – new social signup`)
      return
    }
 
    const existingUser = listResult.Users[0]
    console.log(`Linking ${providerName} provider to existing user ${existingUser.Username}`)
 
    await cognitoClient.send(
      new AdminLinkProviderForUserCommand({
        UserPoolId: userPoolId,
        DestinationUser: {
          ProviderName: 'Cognito',
          ProviderAttributeName: 'Cognito_Subject',
          ProviderAttributeValue: existingUser.Username
        },
        SourceUser: {
          ProviderName: providerName,
          ProviderAttributeName: 'Cognito_Subject',
          ProviderAttributeValue: providerUserId
        }
      })
    )
 
    console.log(`Successfully linked ${providerName} to user ${existingUser.Username}`)
  } catch (error) {
    // Log but do not throw: if linking fails the sign-in still proceeds and creates a
    // new account. The user can merge accounts manually if needed.
    console.error('Account linking failed (non-blocking):', error)
  }
}