diff --git a/packages/auth-providers/dbAuth/api/package.json b/packages/auth-providers/dbAuth/api/package.json index c751ed312c62..b42dac3d53f1 100644 --- a/packages/auth-providers/dbAuth/api/package.json +++ b/packages/auth-providers/dbAuth/api/package.json @@ -25,7 +25,6 @@ "@babel/runtime-corejs3": "7.22.15", "base64url": "3.0.1", "core-js": "3.32.2", - "crypto-js": "4.1.1", "md5": "2.3.0", "uuid": "9.0.0" }, @@ -34,7 +33,6 @@ "@babel/core": "^7.22.20", "@redwoodjs/api": "6.3.2", "@simplewebauthn/server": "7.3.1", - "@types/crypto-js": "4.1.1", "@types/md5": "2.3.2", "@types/uuid": "9.0.2", "jest": "29.7.0", diff --git a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts index 097f57d7b21d..42900cb53915 100644 --- a/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts +++ b/packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts @@ -13,7 +13,6 @@ import type { } from '@simplewebauthn/typescript-types' import type { APIGatewayProxyEvent, Context as LambdaContext } from 'aws-lambda' import base64url from 'base64url' -import CryptoJS from 'crypto-js' import md5 from 'md5' import { v4 as uuidv4 } from 'uuid' @@ -23,11 +22,15 @@ import { createCorsContext, normalizeRequest } from '@redwoodjs/api' import * as DbAuthError from './errors' import { decryptSession, + encryptSession, extractCookie, getSession, hashPassword, + legacyHashPassword, + isLegacySession, hashToken, webAuthnSession, + extractHashingOptions, } from './shared' type SetCookieHeader = { 'set-cookie': string } @@ -545,11 +548,15 @@ export class DbAuthHandler< async getToken() { try { const user = await this._getCurrentUser() + let headers = {} - // need to return *something* for our existing Authorization header stuff - // to work, so return the user's ID in case we can use it for something - // in the future - return [user[this.options.authFields.id]] + // if the session was encrypted with the old algorithm, re-encrypt it + // with the new one + if (isLegacySession(this.cookie)) { + headers = this._loginResponse(user)[1] + } + + return [user[this.options.authFields.id], headers] } catch (e: any) { if (e instanceof DbAuthError.NotLoggedInError) { return this._logoutResponse() @@ -612,12 +619,16 @@ export class DbAuthHandler< } let user = await this._findUserByToken(resetToken as string) - const [hashedPassword] = hashPassword(password, user.salt) + const [hashedPassword] = hashPassword(password, { + salt: user.salt, + }) + const [legacyHashedPassword] = legacyHashPassword(password, user.salt) if ( - !(this.options.resetPassword as ResetPasswordFlowOptions) + (!(this.options.resetPassword as ResetPasswordFlowOptions) .allowReusedPassword && - user.hashedPassword === hashedPassword + user.hashedPassword === hashedPassword) || + user.hashedPassword === legacyHashedPassword ) { throw new DbAuthError.ReusedPasswordError( ( @@ -1120,11 +1131,6 @@ export class DbAuthHandler< return meta } - // encrypts a string with the SESSION_SECRET - _encrypt(data: string) { - return CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET as string) - } - // returns the set-cookie header to be returned in the request (effectively // creates the session) _createSessionHeader( @@ -1132,9 +1138,9 @@ export class DbAuthHandler< csrfToken: string ): SetCookieHeader { const session = JSON.stringify(data) + ';' + csrfToken - const encrypted = this._encrypt(session) + const encrypted = encryptSession(session) const cookie = [ - `session=${encrypted.toString()}`, + `session=${encrypted}`, ...this._cookieAttributes({ expires: this.sessionExpiresDate }), ].join(';') @@ -1245,19 +1251,56 @@ export class DbAuthHandler< ) } - // is password correct? - const [hashedPassword, _salt] = hashPassword( - password, - user[this.options.authFields.salt] + await this._verifyPassword(user, password) + return user + } + + // extracts scrypt strength options from hashed password (if present) and + // compares the hashed plain text password just submitted using those options + // with the one in the database. Falls back to the legacy CryptoJS algorihtm + // if no options are present. + async _verifyPassword(user: Record, password: string) { + const options = extractHashingOptions( + user[this.options.authFields.hashedPassword] as string ) - if (hashedPassword === user[this.options.authFields.hashedPassword]) { - return user + + if (Object.keys(options).length) { + // hashed using the node:crypto algorithm + const [hashedPassword] = hashPassword(password, { + salt: user[this.options.authFields.salt] as string, + options, + }) + + if (hashedPassword === user[this.options.authFields.hashedPassword]) { + return user + } } else { - throw new DbAuthError.IncorrectPasswordError( - username, - (this.options.login as LoginFlowOptions)?.errors?.incorrectPassword + // fallback to old CryptoJS hashing + const [legacyHashedPassword] = legacyHashPassword( + password, + user[this.options.authFields.salt] as string ) + + if ( + legacyHashedPassword === user[this.options.authFields.hashedPassword] + ) { + const [newHashedPassword] = hashPassword(password, { + salt: user[this.options.authFields.salt] as string, + }) + + // update user's hash to the new algorithm + await this.dbAccessor.update({ + where: { id: user.id }, + data: { [this.options.authFields.hashedPassword]: newHashedPassword }, + }) + return user + } } + + throw new DbAuthError.IncorrectPasswordError( + user[this.options.authFields.username] as string, + (this.options.login as LoginFlowOptions)?.errors?.incorrectPassword + ) } // gets the user from the database and returns only its ID @@ -1316,8 +1359,7 @@ export class DbAuthHandler< ) } - // if we get here everything is good, call the app's signup handler and let - // them worry about scrubbing data and saving to the DB + // if we get here everything is good, call the app's signup handler const [hashedPassword, salt] = hashPassword(password) const newUser = await (this.options.signup as SignupFlowOptions).handler({ username, @@ -1371,9 +1413,7 @@ export class DbAuthHandler< ] { const sessionData = { id: user[this.options.authFields.id] } - // TODO: this needs to go into graphql somewhere so that each request makes - // a new CSRF token and sets it in both the encrypted session and the - // csrf-token header + // TODO: this needs to go into graphql somewhere so that each request makes a new CSRF token and sets it in both the encrypted session and the csrf-token header const csrfToken = DbAuthHandler.CSRF_TOKEN return [ diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js index 4843990ca2bc..a7bf8fb8c567 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js +++ b/packages/auth-providers/dbAuth/api/src/__tests__/DbAuthHandler.test.js @@ -1,4 +1,4 @@ -import CryptoJS from 'crypto-js' +import crypto from 'node:crypto' import { DbAuthHandler } from '../DbAuthHandler' import * as dbAuthError from '../errors' @@ -79,17 +79,19 @@ const db = new DbMock(['user', 'userCredential']) const UUID_REGEX = /\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/ -const SET_SESSION_REGEX = /^session=[a-zA-Z0-9+=/]+;/ +const SET_SESSION_REGEX = /^session=[a-zA-Z0-9+=/]|[a-zA-Z0-9+=/]+;/ const UTC_DATE_REGEX = /\w{3}, \d{2} \w{3} \d{4} [\d:]{8} GMT/ const LOGOUT_COOKIE = 'session=;Expires=Thu, 01 Jan 1970 00:00:00 GMT' +const SESSION_SECRET = '540d03ebb00b441f8f7442cbc39958ad' const createDbUser = async (attributes = {}) => { return await db.user.create({ data: { email: 'rob@redwoodjs.com', + // default hashedPassword is from `node:crypto` hashedPassword: - '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba', - salt: '2ef27f4073c603ba8b7807c6de6d6a89', + '230847bea5154b6c7d281d09593ad1be26fa03a93c04a73bcc2b608c073a8213|16384|8|1', + salt: 'ba8b7807c6de6d6a892ef27f4073c603', ...attributes, }, }) @@ -104,7 +106,16 @@ const expectLoggedInResponse = (response) => { } const encryptToCookie = (data) => { - return `session=${CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET)}` + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + SESSION_SECRET.substring(0, 32), + iv + ) + let encryptedSession = cipher.update(data, 'utf-8', 'base64') + encryptedSession += cipher.final('base64') + + return `session=${encryptedSession}|${iv.toString('base64')}` } let event, context, options @@ -114,7 +125,7 @@ describe('dbAuth', () => { // hide deprecation warnings during test jest.spyOn(console, 'warn').mockImplementation(() => {}) // encryption key so results are consistent regardless of settings in .env - process.env.SESSION_SECRET = 'nREjs1HPS7cFia6tQHK70EWGtfhOgbqJQKsHQz3S' + process.env.SESSION_SECRET = SESSION_SECRET delete process.env.DBAUTH_COOKIE_DOMAIN event = { @@ -563,7 +574,7 @@ describe('dbAuth', () => { event = { headers: { cookie: - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx', + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==', }, } const dbAuth = new DbAuthHandler(event, context, options) @@ -596,7 +607,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'logout' }) event.httpMethod = 'GET' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) const response = await dbAuth.invoke() @@ -607,7 +618,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'foobar' }) event.httpMethod = 'POST' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) const response = await dbAuth.invoke() @@ -618,7 +629,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'logout' }) event.httpMethod = 'POST' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) dbAuth.logout = jest.fn(() => { throw Error('Logout error') @@ -656,7 +667,7 @@ describe('dbAuth', () => { event.body = JSON.stringify({ method: 'logout' }) event.httpMethod = 'POST' event.headers.cookie = - 'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx' + 'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' const dbAuth = new DbAuthHandler(event, context, options) dbAuth.logout = jest.fn(() => ['body', { foo: 'bar' }]) const response = await dbAuth.invoke() @@ -1577,6 +1588,27 @@ describe('dbAuth', () => { expect(response[0]).toEqual('{"error":"User not found"}') }) + + it('re-encrypts the session cookie if using the legacy algorithm', async () => { + await createDbUser({ id: 7 }) + event = { + headers: { + // legacy session with { id: 7 } for userID + cookie: 'session=U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=', + }, + } + process.env.SESSION_SECRET = + 'QKxN2vFSHAf94XYynK8LUALfDuDSdFowG6evfkFX8uszh4YZqhTiqEdshrhWbwbw' + + const dbAuth = new DbAuthHandler(event, context, options) + const [userId, headers] = await dbAuth.getToken() + + expect(userId).toEqual(7) + expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) + + // set session back to default + process.env.SESSION_SECRET = SESSION_SECRET + }) }) describe('When a developer has set GraphiQL headers to mock a session cookie', () => { @@ -2148,11 +2180,11 @@ describe('dbAuth', () => { `Expires=${dbAuth.sessionExpiresDate}` ) // can't really match on the session value since it will change on every render, - // due to CSRF token generation but we can check that it contains a only the - // characters that would be returned by the hash function + // due to CSRF token generation but we can check that it contains only the + // characters that would be returned by the encrypt function expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX) // and we can check that it's a certain number of characters - expect(headers['set-cookie'].split(';')[0].length).toEqual(72) + expect(headers['set-cookie'].split(';')[0].length).toEqual(77) }) }) @@ -2315,6 +2347,38 @@ describe('dbAuth', () => { expect(user.id).toEqual(dbUser.id) }) + + it('returns the user if password is hashed with legacy algorithm', async () => { + const dbUser = await createDbUser({ + // CryptoJS hashed password + hashedPassword: + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba', + salt: '2ef27f4073c603ba8b7807c6de6d6a89', + }) + const dbAuth = new DbAuthHandler(event, context, options) + const user = await dbAuth._verifyUser(dbUser.email, 'password') + + expect(user.id).toEqual(dbUser.id) + }) + + it('updates the user hashPassword to the new algorithm', async () => { + const dbUser = await createDbUser({ + // CryptoJS hashed password + hashedPassword: + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba', + salt: '2ef27f4073c603ba8b7807c6de6d6a89', + }) + const dbAuth = new DbAuthHandler(event, context, options) + await dbAuth._verifyUser(dbUser.email, 'password') + const user = await db.user.findFirst({ where: { id: dbUser.id } }) + + // password now hashed by node:crypto + expect(user.hashedPassword).toEqual( + 'f20d69d478fa1afc85057384e21bd457a76b23b23e2a94f5bd982976f700a552|16384|8|1' + ) + // salt should remain the same + expect(user.salt).toEqual('2ef27f4073c603ba8b7807c6de6d6a89') + }) }) describe('_getCurrentUser()', () => { diff --git a/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.js b/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.js index 2dfeae6a407e..a4fb0c6fadce 100644 --- a/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.js +++ b/packages/auth-providers/dbAuth/api/src/__tests__/shared.test.js @@ -1,19 +1,31 @@ -import CryptoJS from 'crypto-js' +import crypto from 'node:crypto' import * as error from '../errors' import { extractCookie, getSession, hashPassword, + isLegacySession, + legacyHashPassword, decryptSession, dbAuthSession, webAuthnSession, + extractHashingOptions, } from '../shared' -process.env.SESSION_SECRET = 'nREjs1HPS7cFia6tQHK70EWGtfhOgbqJQKsHQz3S' +const SESSION_SECRET = '540d03ebb00b441f8f7442cbc39958ad' const encrypt = (data) => { - return CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET).toString() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + SESSION_SECRET.substring(0, 32), + iv + ) + let encryptedSession = cipher.update(data, 'utf-8', 'base64') + encryptedSession += cipher.final('base64') + + return `${encryptedSession}|${iv.toString('base64')}` } describe('getSession()', () => { @@ -40,7 +52,27 @@ describe('getSession()', () => { }) }) +describe('isLegacySession()', () => { + it('returns `true` if the session cookie appears to be encrypted with CryptoJS', () => { + expect( + isLegacySession('U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=') + ).toEqual(true) + }) + + it('returns `false` if the session cookie appears to be encrypted with node:crypto', () => { + expect( + isLegacySession( + 'ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==' + ) + ).toEqual(false) + }) +}) + describe('decryptSession()', () => { + beforeEach(() => { + process.env.SESSION_SECRET = SESSION_SECRET + }) + it('returns an empty array if no session', () => { expect(decryptSession()).toEqual([]) }) @@ -63,9 +95,23 @@ describe('decryptSession()', () => { expect(decryptSession(text)).toEqual([first, second]) }) + + it('decrypts a session cookie that was created with the legacy CryptoJS algorithm', () => { + process.env.SESSION_SECRET = + 'QKxN2vFSHAf94XYynK8LUALfDuDSdFowG6evfkFX8uszh4YZqhTiqEdshrhWbwbw' + const [json] = decryptSession( + 'U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=' + ) + + expect(json).toEqual({ id: 7 }) + }) }) describe('dbAuthSession()', () => { + beforeEach(() => { + process.env.SESSION_SECRET = SESSION_SECRET + }) + it('returns null if no cookies', () => { const event = { headers: {} } @@ -102,7 +148,52 @@ describe('webAuthnSession', () => { describe('hashPassword', () => { it('hashes a password with a given salt and returns both', () => { - const [hash, salt] = hashPassword( + const [hash, salt] = hashPassword('password', { + salt: 'ba8b7807c6de6d6a892ef27f4073c603', + }) + + expect(hash).toEqual( + '230847bea5154b6c7d281d09593ad1be26fa03a93c04a73bcc2b608c073a8213|16384|8|1' + ) + expect(salt).toEqual('ba8b7807c6de6d6a892ef27f4073c603') + }) + + it('hashes a password with a generated salt if none provided', () => { + const [hash, salt] = hashPassword('password') + + expect(hash).toMatch(/^[a-f0-9]+|16384|8|1$/) + expect(hash.length).toEqual(74) + expect(salt).toMatch(/^[a-f0-9]+$/) + expect(salt.length).toEqual(64) + }) + + it('normalizes strings so utf-8 variants hash to the same output', () => { + const salt = crypto.randomBytes(32).toString('hex') + const [hash1] = hashPassword('\u0041\u006d\u00e9\u006c\u0069\u0065', { + salt, + }) // Amélie + const [hash2] = hashPassword('\u0041\u006d\u0065\u0301\u006c\u0069\u0065', { + salt, + }) // Amélie but separate e and accent codepoints + + expect(hash1).toEqual(hash2) + }) + + it('encodes the scrypt difficulty options into the hash', () => { + const [hash] = hashPassword('password', { + options: { cost: 8192, blockSize: 16, parallelization: 2 }, + }) + const [_hash, cost, blockSize, parallelization] = hash.split('|') + + expect(cost).toEqual('8192') + expect(blockSize).toEqual('16') + expect(parallelization).toEqual('2') + }) +}) + +describe('legacyHashPassword', () => { + it('hashes a password with CryptoJS given a salt and returns both', () => { + const [hash, salt] = legacyHashPassword( 'password', '2ef27f4073c603ba8b7807c6de6d6a89' ) @@ -114,41 +205,62 @@ describe('hashPassword', () => { }) it('hashes a password with a generated salt if none provided', () => { - const [hash, salt] = hashPassword('password') + const [hash, salt] = legacyHashPassword('password') expect(hash).toMatch(/^[a-f0-9]+$/) expect(hash.length).toEqual(64) expect(salt).toMatch(/^[a-f0-9]+$/) - expect(salt.length).toEqual(32) + expect(salt.length).toEqual(64) }) +}) + +describe('session cookie extraction', () => { + let event - describe('session cookie extraction', () => { - let event + const encryptToCookie = (data) => { + return `session=${encrypt(data)}` + } - const encryptToCookie = (data) => { - return `session=${CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET)}` + beforeEach(() => { + event = { + queryStringParameters: {}, + path: '/.redwood/functions/auth', + headers: {}, } + }) - beforeEach(() => { - event = { - queryStringParameters: {}, - path: '/.redwood/functions/auth', - headers: {}, - } - }) + it('extracts from the event', () => { + const cookie = encryptToCookie( + JSON.stringify({ id: 9999999999 }) + ';' + 'token' + ) - it('extracts from the event', () => { - const cookie = encryptToCookie( - JSON.stringify({ id: 9999999999 }) + ';' + 'token' - ) + event = { + headers: { + cookie, + }, + } - event = { - headers: { - cookie, - }, - } + expect(extractCookie(event)).toEqual(cookie) + }) - expect(extractCookie(event)).toEqual(cookie) + it('extract cookie handles non-JSON event body', () => { + event.body = '' + + expect(extractCookie(event)).toBeUndefined() + }) + + describe('when in development', () => { + const curNodeEnv = process.env.NODE_ENV + + beforeAll(() => { + // Session cookie from graphiQLHeaders only extracted in dev + process.env.NODE_ENV = 'development' + }) + + afterAll(() => { + process.env.NODE_ENV = curNodeEnv + event = {} + expect(process.env.NODE_ENV).toBe('test') }) it('extract cookie handles non-JSON event body', () => { @@ -157,69 +269,81 @@ describe('hashPassword', () => { expect(extractCookie(event)).toBeUndefined() }) - describe('when in development', () => { - const curNodeEnv = process.env.NODE_ENV + it('extracts GraphiQL cookie from the header extensions', () => { + const dbUserId = 42 - beforeAll(() => { - // Session cookie from graphiQLHeaders only extracted in dev - process.env.NODE_ENV = 'development' + const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) + event.body = JSON.stringify({ + extensions: { + headers: { + 'auth-provider': 'dbAuth', + cookie, + authorization: 'Bearer ' + dbUserId, + }, + }, }) - afterAll(() => { - process.env.NODE_ENV = curNodeEnv - event = {} - expect(process.env.NODE_ENV).toBe('test') - }) + expect(extractCookie(event)).toEqual(cookie) + }) + + it('overwrites cookie with event header GraphiQL when in dev', () => { + const sessionCookie = encryptToCookie( + JSON.stringify({ id: 9999999999 }) + ';' + 'token' + ) - it('extract cookie handles non-JSON event body', () => { - event.body = '' + event = { + headers: { + cookie: sessionCookie, + }, + } - expect(extractCookie(event)).toBeUndefined() - }) + const dbUserId = 42 - it('extracts GraphiQL cookie from the header extensions', () => { - const dbUserId = 42 - - const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) - event.body = JSON.stringify({ - extensions: { - headers: { - 'auth-provider': 'dbAuth', - cookie, - authorization: 'Bearer ' + dbUserId, - }, + const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) + event.body = JSON.stringify({ + extensions: { + headers: { + 'auth-provider': 'dbAuth', + cookie, + authorization: 'Bearer ' + dbUserId, }, - }) - - expect(extractCookie(event)).toEqual(cookie) + }, }) - it('overwrites cookie with event header GraphiQL when in dev', () => { - const sessionCookie = encryptToCookie( - JSON.stringify({ id: 9999999999 }) + ';' + 'token' - ) + expect(extractCookie(event)).toEqual(cookie) + }) + }) +}) - event = { - headers: { - cookie: sessionCookie, - }, - } - - const dbUserId = 42 - - const cookie = encryptToCookie(JSON.stringify({ id: dbUserId })) - event.body = JSON.stringify({ - extensions: { - headers: { - 'auth-provider': 'dbAuth', - cookie, - authorization: 'Bearer ' + dbUserId, - }, - }, - }) +describe('extractHashingOptions()', () => { + it('returns an empty object if no options', () => { + expect(extractHashingOptions('')).toEqual({}) + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba' + ) + ).toEqual({}) + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba|1' + ) + ).toEqual({}) + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba|1|2' + ) + ).toEqual({}) + }) - expect(extractCookie(event)).toEqual(cookie) - }) + it('returns an object with scrypt options', () => { + expect( + extractHashingOptions( + '0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba|16384|8|1' + ) + ).toEqual({ + cost: 16384, + blockSize: 8, + parallelization: 1, }) }) }) diff --git a/packages/auth-providers/dbAuth/api/src/shared.ts b/packages/auth-providers/dbAuth/api/src/shared.ts index adf707d495e3..217e82657944 100644 --- a/packages/auth-providers/dbAuth/api/src/shared.ts +++ b/packages/auth-providers/dbAuth/api/src/shared.ts @@ -1,8 +1,25 @@ +import crypto from 'node:crypto' + import type { APIGatewayProxyEvent } from 'aws-lambda' -import CryptoJS from 'crypto-js' import * as DbAuthError from './errors' +type ScryptOptions = { + cost?: number + blockSize?: number + parallelization?: number + N?: number + r?: number + p?: number + maxmem?: number +} + +const DEFAULT_SCRYPT_OPTIONS: ScryptOptions = { + cost: 2 ** 14, + blockSize: 8, + parallelization: 1, +} + // Extracts the cookie from an event, handling lower and upper case header names. const eventHeadersCookie = (event: APIGatewayProxyEvent) => { return event.headers.cookie || event.headers.Cookie @@ -27,23 +44,71 @@ const eventGraphiQLHeadersCookie = (event: APIGatewayProxyEvent) => { return } +// decrypts session text using old CryptoJS algorithm (using node:crypto library) +const legacyDecryptSession = (encryptedText: string) => { + const cypher = Buffer.from(encryptedText, 'base64') + const salt = cypher.slice(8, 16) + const password = Buffer.concat([ + Buffer.from(process.env.SESSION_SECRET as string, 'binary'), + salt, + ]) + const md5Hashes = [] + let digest = password + for (let i = 0; i < 3; i++) { + md5Hashes[i] = crypto.createHash('md5').update(digest).digest() + digest = Buffer.concat([md5Hashes[i], password]) + } + const key = Buffer.concat([md5Hashes[0], md5Hashes[1]]) + const iv = md5Hashes[2] + const contents = cypher.slice(16) + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + + return decipher.update(contents) + decipher.final('utf-8') +} + // Extracts the session cookie from an event, handling both // development environment GraphiQL headers and production environment headers. export const extractCookie = (event: APIGatewayProxyEvent) => { return eventGraphiQLHeadersCookie(event) || eventHeadersCookie(event) } +// whether this encrypted session was made with the old CryptoJS algorithm +export const isLegacySession = (text: string | undefined) => { + if (!text) { + return false + } + + const [_encryptedText, iv] = text.split('|') + return !iv +} + // decrypts the session cookie and returns an array: [data, csrf] export const decryptSession = (text: string | null) => { if (!text || text.trim() === '') { return [] } + let decoded + // if cookie contains a pipe then it was encrypted using the `node:crypto` + // algorithm (first element is the ecrypted data, second is the initialization vector) + // otherwise fall back to using the older CryptoJS algorithm + const [encryptedText, iv] = text.split('|') + try { - const decoded = CryptoJS.AES.decrypt( - text, - process.env.SESSION_SECRET as string - ).toString(CryptoJS.enc.Utf8) + if (iv) { + // decrypt using the `node:crypto` algorithm + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + (process.env.SESSION_SECRET as string).substring(0, 32), + Buffer.from(iv, 'base64') + ) + decoded = + decipher.update(encryptedText, 'base64', 'utf-8') + + decipher.final('utf-8') + } else { + decoded = legacyDecryptSession(text) + } + const [data, csrf] = decoded.split(';') const json = JSON.parse(data) @@ -53,6 +118,19 @@ export const decryptSession = (text: string | null) => { } } +export const encryptSession = (dataString: string) => { + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv( + 'aes-256-cbc', + (process.env.SESSION_SECRET as string).substring(0, 32), + iv + ) + let encryptedData = cipher.update(dataString, 'utf-8', 'base64') + encryptedData += cipher.final('base64') + + return `${encryptedData}|${iv.toString('base64')}` +} + // returns the actual value of the session cookie export const getSession = (text?: string) => { if (typeof text === 'undefined' || text === null) { @@ -68,7 +146,7 @@ export const getSession = (text?: string) => { return null } - return sessionCookie.split('=')[1].trim() + return sessionCookie.replace('session=', '').trim() } // Convenience function to get session, decrypt, and return session data all @@ -101,16 +179,51 @@ export const webAuthnSession = (event: APIGatewayProxyEvent) => { } export const hashToken = (token: string) => { - return CryptoJS.SHA256(token).toString(CryptoJS.enc.Hex) + return crypto.createHash('sha256').update(token).digest('hex') } // hashes a password using either the given `salt` argument, or creates a new // salt and hashes using that. Either way, returns an array with [hash, salt] -export const hashPassword = (text: string, salt?: string) => { - const useSalt = salt || CryptoJS.lib.WordArray.random(128 / 8).toString() +// normalizes the string in case it contains unicode characters: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize +// TODO: Add validation that the options are valid values for the scrypt algorithm +export const hashPassword = ( + text: string, + { + salt = crypto.randomBytes(32).toString('hex'), + options = DEFAULT_SCRYPT_OPTIONS, + }: { salt?: string; options?: ScryptOptions } = {} +) => { + const encryptedString = crypto + .scryptSync(text.normalize('NFC'), salt, 32, options) + .toString('hex') + const optionsToString = [ + options.cost, + options.blockSize, + options.parallelization, + ] + return [`${encryptedString}|${optionsToString.join('|')}`, salt] +} +// uses the old algorithm from CryptoJS: +// CryptoJS.PBKDF2(password, salt, { keySize: 8 }).toString() +export const legacyHashPassword = (text: string, salt?: string) => { + const useSalt = salt || crypto.randomBytes(32).toString('hex') return [ - CryptoJS.PBKDF2(text, useSalt, { keySize: 256 / 32 }).toString(), + crypto.pbkdf2Sync(text, useSalt, 1, 32, 'SHA1').toString('hex'), useSalt, ] } + +export const extractHashingOptions = (text: string): ScryptOptions => { + const [_hash, ...options] = text.split('|') + + if (options.length === 3) { + return { + cost: parseInt(options[0]), + blockSize: parseInt(options[1]), + parallelization: parseInt(options[2]), + } + } else { + return {} + } +} diff --git a/packages/auth-providers/dbAuth/setup/package.json b/packages/auth-providers/dbAuth/setup/package.json index 64541362e54d..87b78c04e35e 100644 --- a/packages/auth-providers/dbAuth/setup/package.json +++ b/packages/auth-providers/dbAuth/setup/package.json @@ -27,14 +27,12 @@ "@simplewebauthn/browser": "7.2.0", "core-js": "3.32.2", "prompts": "2.4.2", - "secure-random-password": "0.2.3", "terminal-link": "2.1.1" }, "devDependencies": { "@babel/cli": "7.22.15", "@babel/core": "^7.22.20", "@simplewebauthn/typescript-types": "7.0.0", - "@types/secure-random-password": "0.2.1", "@types/yargs": "17.0.24", "jest": "29.7.0", "typescript": "5.2.2" diff --git a/packages/auth-providers/dbAuth/setup/src/setupData.ts b/packages/auth-providers/dbAuth/setup/src/setupData.ts index 284ee1548927..82bdf2d88dab 100644 --- a/packages/auth-providers/dbAuth/setup/src/setupData.ts +++ b/packages/auth-providers/dbAuth/setup/src/setupData.ts @@ -1,7 +1,6 @@ +import crypto from 'node:crypto' import path from 'path' -import password from 'secure-random-password' - import { getPaths, colors, addEnvVarTask } from '@redwoodjs/cli-helpers' export const libPath = getPaths().api.lib.replace(getPaths().base, '') @@ -10,10 +9,7 @@ export const functionsPath = getPaths().api.functions.replace( '' ) -const secret = password.randomPassword({ - length: 64, - characters: [password.lower, password.upper, password.digits], -}) +const secret = crypto.randomBytes(32).toString('base64') export const extraTask = addEnvVarTask( 'SESSION_SECRET', diff --git a/packages/cli/package.json b/packages/cli/package.json index 9f468c0531d3..b98bf7243e30 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -45,7 +45,6 @@ "@redwoodjs/project-config": "6.3.2", "@redwoodjs/structure": "6.3.2", "@redwoodjs/telemetry": "6.3.2", - "@types/secure-random-password": "0.2.1", "boxen": "5.1.2", "camelcase": "6.3.0", "chalk": "4.1.2", @@ -54,7 +53,6 @@ "configstore": "3.1.5", "core-js": "3.32.2", "cross-env": "7.0.3", - "crypto-js": "4.1.1", "decamelize": "5.0.1", "dotenv-defaults": "5.0.2", "enquirer": "2.4.1", @@ -74,7 +72,6 @@ "prisma": "5.3.1", "prompts": "2.4.2", "rimraf": "5.0.1", - "secure-random-password": "0.2.3", "semver": "7.5.3", "string-env-interpolation": "1.0.1", "systeminformation": "5.21.7", @@ -86,7 +83,6 @@ "devDependencies": { "@babel/cli": "7.22.15", "@babel/core": "^7.22.20", - "@types/crypto-js": "4.1.1", "jest": "29.7.0", "typescript": "5.2.2" }, diff --git a/packages/cli/src/commands/generate/secret/__tests__/secret.test.js b/packages/cli/src/commands/generate/secret/__tests__/secret.test.js index be2680dbede0..417f52f9cadd 100644 --- a/packages/cli/src/commands/generate/secret/__tests__/secret.test.js +++ b/packages/cli/src/commands/generate/secret/__tests__/secret.test.js @@ -1,18 +1,27 @@ import yargs from 'yargs' -import { generateSecret, handler, builder } from './../secret.js' +import { + DEFAULT_LENGTH, + generateSecret, + handler, + builder, +} from './../secret.js' describe('generateSecret', () => { - it('contains only uppercase letters, lowercase letters, and digits', () => { + it('contains base64-encoded string', () => { const secret = generateSecret() + const buffer = Buffer.alloc(DEFAULT_LENGTH) + const stringLength = buffer.toString('base64').length - expect(secret).toMatch(/^[A-Za-z0-9]{64}$/) + expect(secret).toMatch(new RegExp(`^[A-Za-z0-9+/=]{${stringLength}}$`)) }) it('can optionally accept a length', () => { const secret = generateSecret(16) + const buffer = Buffer.alloc(16) - expect(secret.length).toEqual(16) + // however long a 16-byte buffer is when base64-encoded (24 characters) + expect(secret.length).toEqual(buffer.toString('base64').length) }) it('prints nothing but the secret when setting the --raw flag', () => { @@ -26,7 +35,7 @@ describe('generateSecret', () => { console.info = (...args) => (output += args.join(' ') + '\n') process.stdout.write = (str) => (output += str) - const { raw, length } = yargs + const { raw } = yargs .command('secret', false, builder, handler) .parse('secret --raw') @@ -35,6 +44,6 @@ describe('generateSecret', () => { process.stdout.write = realWrite expect(raw).toBeTruthy() - expect(output).toMatch(new RegExp(`^[A-Za-z0-9]{${length}}\n$`)) + expect(output).toMatch(new RegExp(`^[A-Za-z0-9+/=]+\n$`)) }) }) diff --git a/packages/cli/src/commands/generate/secret/secret.js b/packages/cli/src/commands/generate/secret/secret.js index d8cf9514e229..a012efe5e227 100644 --- a/packages/cli/src/commands/generate/secret/secret.js +++ b/packages/cli/src/commands/generate/secret/secret.js @@ -1,15 +1,13 @@ -import password from 'secure-random-password' +import crypto from 'node:crypto' + import terminalLink from 'terminal-link' import { recordTelemetryAttributes } from '@redwoodjs/cli-helpers' -const DEFAULT_LENGTH = 64 +export const DEFAULT_LENGTH = 32 export const generateSecret = (length = DEFAULT_LENGTH) => { - return password.randomPassword({ - length, - characters: [password.lower, password.upper, password.digits], - }) + return crypto.randomBytes(length).toString('base64') } export const command = 'secret' diff --git a/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js b/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js index 4354c56aadd5..49f8250fa3d7 100644 --- a/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js +++ b/packages/cli/src/commands/setup/graphiql/__tests__/graphiqlHandler.test.js @@ -53,18 +53,18 @@ describe('Graphiql generator tests', () => { expect(processExitSpy).toHaveBeenCalledWith(1) }) - it('throws an error if auth provider is dbAuth and no user id is provided', () => { + it('throws an error if auth provider is dbAuth and no user id is provided', async () => { try { - graphiqlHelpers.generatePayload('dbAuth') + await graphiqlHelpers.generatePayload('dbAuth') } catch (e) { expect(e.message).toBe('Require an unique id to generate session cookie') } }) - it('throws an error if auth provider is dbAuth and no supabase env is set', () => { - process.env.SESSION_SECRET = null + it('throws an error if auth provider is dbAuth and no supabase env is set', async () => { + delete process.env.SESSION_SECRET try { - graphiqlHelpers.generatePayload('dbAuth', 'user-id-123') + await graphiqlHelpers.generatePayload('dbAuth', 'user-id-123') } catch (e) { expect(e.message).toBe( 'dbAuth requires a SESSION_SECRET environment variable that is used to encrypt session cookies. Use `yarn rw g secret` to create one, then add to your `.env` file. DO NOT check this variable in your version control system!!' @@ -75,7 +75,11 @@ describe('Graphiql generator tests', () => { it('returns a payload if a token is provided', async () => { const provider = 'supabase' const token = 'mock-token' - const response = graphiqlHelpers.generatePayload(provider, null, token) + const response = await graphiqlHelpers.generatePayload( + provider, + null, + token + ) expect(response).toEqual({ 'auth-provider': provider, authorization: `Bearer ${token}`, diff --git a/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js b/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js index 6b1aaeca11a1..c2f0c148f71c 100644 --- a/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js +++ b/packages/cli/src/commands/setup/graphiql/graphiqlHandler.js @@ -68,8 +68,8 @@ export const handler = async ({ provider, id, token, expiry, view }) => { [ { title: 'Generating graphiql header...', - task: () => { - payload = generatePayload(provider, id, token, expiry) + task: async () => { + payload = await generatePayload(provider, id, token, expiry) }, }, { diff --git a/packages/cli/src/commands/setup/graphiql/supportedProviders.js b/packages/cli/src/commands/setup/graphiql/supportedProviders.js index b02c48f42498..fdb50cafa9b5 100644 --- a/packages/cli/src/commands/setup/graphiql/supportedProviders.js +++ b/packages/cli/src/commands/setup/graphiql/supportedProviders.js @@ -1,4 +1,3 @@ -import CryptoJS from 'crypto-js' import { v4 as uuidv4 } from 'uuid' // tests if id, which is always a string from cli, is actually a number or uuid @@ -10,7 +9,7 @@ const getExpiryTime = (expiry) => { return expiry ? Date.now() + expiry * 60 * 1000 : Date.now() + 3600 * 1000 } -const getDBAuthHeader = (userId) => { +const getDBAuthHeader = async (userId) => { if (!userId) { throw new Error('Require an unique id to generate session cookie') } @@ -20,11 +19,11 @@ const getDBAuthHeader = (userId) => { 'dbAuth requires a SESSION_SECRET environment variable that is used to encrypt session cookies. Use `yarn rw g secret` to create one, then add to your `.env` file. DO NOT check this variable in your version control system!!' ) } + + const { encryptSession } = await import('@redwoodjs/auth-dbauth-api') + const id = isNumeric(userId) ? parseInt(userId) : userId - const cookie = CryptoJS.AES.encrypt( - JSON.stringify({ id }) + ';' + uuidv4(), - process.env.SESSION_SECRET - ).toString() + const cookie = encryptSession(JSON.stringify({ id }) + ';' + uuidv4()) return { 'auth-provider': 'dbAuth', diff --git a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts b/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts index bbd89198ed3e..771fefca29a1 100644 --- a/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts +++ b/packages/studio/api/lib/authProviderEncoders/dbAuthEncoder.ts @@ -1,4 +1,3 @@ -import CryptoJS from 'crypto-js' import { v4 as uuidv4 } from 'uuid' import { SESSION_SECRET } from '../envars' @@ -18,11 +17,10 @@ export const getDBAuthHeader = async (userId?: string) => { ) } + const { encryptSession } = await import('@redwoodjs/auth-dbauth-api') + const id = isNumeric(userId) ? parseInt(userId) : userId - const cookie = CryptoJS.AES.encrypt( - JSON.stringify({ id }) + ';' + uuidv4(), - SESSION_SECRET - ).toString() + const cookie = encryptSession(JSON.stringify({ id }) + ';' + uuidv4()) return { authProvider: 'dbAuth', diff --git a/packages/studio/package.json b/packages/studio/package.json index dfcdb3102458..a721770d62d5 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -32,7 +32,6 @@ "ansi-colors": "4.1.3", "chokidar": "3.5.3", "core-js": "3.32.2", - "crypto-js": "4.1.1", "dotenv": "16.3.1", "fast-json-parse": "1.0.3", "fastify": "4.23.2", @@ -67,7 +66,6 @@ "@tailwindcss/forms": "0.5.3", "@tremor/react": "3.4.1", "@types/aws-lambda": "8.10.119", - "@types/crypto-js": "4.1.1", "@types/jsonwebtoken": "9.0.2", "@types/lodash": "4.14.195", "@types/mailparser": "^3", diff --git a/tasks/smoke-tests/auth/tests/authChecks.spec.ts b/tasks/smoke-tests/auth/tests/authChecks.spec.ts index d5b8fa1c5632..c6179147c91a 100644 --- a/tasks/smoke-tests/auth/tests/authChecks.spec.ts +++ b/tasks/smoke-tests/auth/tests/authChecks.spec.ts @@ -2,12 +2,16 @@ import { test, expect } from '@playwright/test' import { loginAsTestUser, signUpTestUser } from '../../common' -// Signs up a user before these tests +const testUser = { + email: 'testuser@bazinga.com', + password: 'test123', + fullName: 'Test User', +} test.beforeAll(async ({ browser }) => { const page = await browser.newPage() - await signUpTestUser({ page }) + await signUpTestUser({ page, ...testUser }) await page.close() }) @@ -20,7 +24,7 @@ test('useAuth hook, auth redirects checks', async ({ page }) => { `http://localhost:8910/login?redirectTo=/profile` ) - await loginAsTestUser({ page }) + await loginAsTestUser({ page, ...testUser }) await page.goto('/profile') @@ -41,14 +45,19 @@ test('useAuth hook, auth redirects checks', async ({ page }) => { 'Is Adminfalse' ) - // Log Out await page.goto('/') - await page.click('text=Log Out') - await expect(await page.locator('text=Login')).toBeTruthy() + await page.getByText('Log Out').click() + await expect(page.getByText('Log In')).toBeVisible() }) +const post = { + title: 'Hello world! Soft kittens are the best.', + body: 'Bazinga, bazinga, bazinga', + authorId: '2', +} + test('requireAuth graphql checks', async ({ page }) => { - // Create posts + // Try to create a post as an anonymous user. await createNewPost({ page }) await expect( @@ -59,48 +68,36 @@ test('requireAuth graphql checks', async ({ page }) => { await page.goto('/') - await expect( - await page - .locator('article:has-text("Hello world! Soft kittens are the best.")') - .count() - ).toBe(0) + await expect(page.getByText(post.title)).not.toBeVisible() - await loginAsTestUser({ - page, - }) + // Now log in and try again. + await loginAsTestUser({ page, ...testUser }) await createNewPost({ page }) await page.goto('/') - await expect( - await page - .locator('article:has-text("Hello world! Soft kittens are the best.")') - .first() - ).not.toBeEmpty() + + await expect(page.getByText(post.title)).toBeVisible() + + // Delete the post to keep this test idempotent. + // Clicking "Delete" opens a confirmation dialog that we havee to accept. + await page.goto('/posts') + + page.once('dialog', (dialog) => dialog.accept()) + + await page + .getByRole('row') + .filter({ has: page.getByText(post.title) }) + .getByRole('button', { name: 'Delete' }) + .click() }) async function createNewPost({ page }) { await page.goto('/posts/new') - await page.locator('input[name="title"]').click() - await page - .locator('input[name="title"]') - .fill('Hello world! Soft kittens are the best.') - await page.locator('input[name="title"]').press('Tab') - await page.locator('input[name="body"]').fill('Bazinga, bazinga, bazinga') - await page.locator('input[name="authorId"]').fill('2') - - const permissionError = page - .locator('.rw-form-error-title') - .locator(`text=You don't have permission to do that`) - - // Either wait for success and redirect - // Or get the error - await Promise.all([ - Promise.race([ - page.waitForURL('**/'), - permissionError.waitFor({ timeout: 5000 }), - ]), - await page.click('text=SAVE'), - ]) + await page.getByLabel('Title').fill(post.title) + await page.getByLabel('Body').fill(post.body) + await page.getByLabel('Author id').fill(post.authorId) + + await page.getByRole('button', { name: 'Save' }).click() } diff --git a/tasks/smoke-tests/common.ts b/tasks/smoke-tests/common.ts index 0857780114ee..b7ca0b781eae 100644 --- a/tasks/smoke-tests/common.ts +++ b/tasks/smoke-tests/common.ts @@ -58,31 +58,21 @@ export const signUpTestUser = async ({ }: AuthUtilsParams) => { await page.goto('/signup') - await page.locator('input[name="username"]').click() - // Fill input[name="username"] - await page.locator('input[name="username"]').fill(email) - // Press Tab - await page.locator('input[name="username"]').press('Tab') - // Fill input[name="password"] - await page.locator('input[name="password"]').fill(password) - await page.locator('input[name="full-name"]').click() - await page.locator('input[name="full-name"]').fill(fullName) - - const alreadyRegisteredErr = page.locator( - `text=Username \`${email}\` already in use` - ) - - // Either wait for signup to succeed and redirect - // Or get the username already registered error, either way is fine! - await Promise.all([ - Promise.race([ - page.waitForURL('**/'), - alreadyRegisteredErr.waitFor({ timeout: 5000 }), - ]), - page.locator('text=Sign Up').click(), + await page.getByLabel('Username').fill(email) + await page.getByLabel('Password').fill(password) + await page.getByLabel('Full Name').fill(fullName) + + await page.getByRole('button', { name: 'Sign Up' }).click() + + // Wait for either... + // - signup to succeed and redirect to the home page + // - an error message to appear in a toast + await Promise.race([ + page.waitForURL('/'), + expect( + page.getByText(`Username \`${email}\` already in use`) + ).toBeVisible(), ]) - - console.log(`Signup successful for ${email}!`) } export const loginAsTestUser = async ({ @@ -92,18 +82,10 @@ export const loginAsTestUser = async ({ }: AuthUtilsParams) => { await page.goto('/login') - // Click input[name="username"] - await page.locator('input[name="username"]').click() - // Fill input[name="username"] - await page.locator('input[name="username"]').fill(email) - // Click input[name="password"] - await page.locator('input[name="password"]').click() - // Fill input[name="password"] - await page.locator('input[name="password"]').fill(password) - - // Click button:has-text("Login") - await Promise.all([ - page.waitForURL('**/'), - page.locator('button:has-text("Login")').click(), - ]) + await page.getByLabel('Username').fill(email) + await page.getByLabel('Password').fill(password) + + await page.getByRole('button', { name: 'Login' }).click() + + await page.waitForURL('/') } diff --git a/yarn.lock b/yarn.lock index 7dcfeb625afd..0ac796e9a69d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8059,12 +8059,10 @@ __metadata: "@babel/runtime-corejs3": 7.22.15 "@redwoodjs/api": 6.3.2 "@simplewebauthn/server": 7.3.1 - "@types/crypto-js": 4.1.1 "@types/md5": 2.3.2 "@types/uuid": 9.0.2 base64url: 3.0.1 core-js: 3.32.2 - crypto-js: 4.1.1 jest: 29.7.0 md5: 2.3.0 typescript: 5.2.2 @@ -8082,12 +8080,10 @@ __metadata: "@redwoodjs/cli-helpers": 6.3.2 "@simplewebauthn/browser": 7.2.0 "@simplewebauthn/typescript-types": 7.0.0 - "@types/secure-random-password": 0.2.1 "@types/yargs": 17.0.24 core-js: 3.32.2 jest: 29.7.0 prompts: 2.4.2 - secure-random-password: 0.2.3 terminal-link: 2.1.1 typescript: 5.2.2 languageName: unknown @@ -8465,8 +8461,6 @@ __metadata: "@redwoodjs/project-config": 6.3.2 "@redwoodjs/structure": 6.3.2 "@redwoodjs/telemetry": 6.3.2 - "@types/crypto-js": 4.1.1 - "@types/secure-random-password": 0.2.1 boxen: 5.1.2 camelcase: 6.3.0 chalk: 4.1.2 @@ -8475,7 +8469,6 @@ __metadata: configstore: 3.1.5 core-js: 3.32.2 cross-env: 7.0.3 - crypto-js: 4.1.1 decamelize: 5.0.1 dotenv-defaults: 5.0.2 enquirer: 2.4.1 @@ -8496,7 +8489,6 @@ __metadata: prisma: 5.3.1 prompts: 2.4.2 rimraf: 5.0.1 - secure-random-password: 0.2.3 semver: 7.5.3 string-env-interpolation: 1.0.1 systeminformation: 5.21.7 @@ -9059,7 +9051,6 @@ __metadata: "@tailwindcss/forms": 0.5.3 "@tremor/react": 3.4.1 "@types/aws-lambda": 8.10.119 - "@types/crypto-js": 4.1.1 "@types/jsonwebtoken": 9.0.2 "@types/lodash": 4.14.195 "@types/mailparser": ^3 @@ -9078,7 +9069,6 @@ __metadata: buffer: 6.0.3 chokidar: 3.5.3 core-js: 3.32.2 - crypto-js: 4.1.1 dotenv: 16.3.1 fast-json-parse: 1.0.3 fastify: 4.23.2 @@ -11283,13 +11273,6 @@ __metadata: languageName: node linkType: hard -"@types/crypto-js@npm:4.1.1": - version: 4.1.1 - resolution: "@types/crypto-js@npm:4.1.1" - checksum: e53b712c5d3b72d19c67a06c8bbaaafd989d78f71a2168f1376c8fb84d5744e5166b58d79528a124645e13f13fe4d2c97ee8f03d649ef913e93ca6b8cee41370 - languageName: node - linkType: hard - "@types/d3-array@npm:^3.0.3": version: 3.0.5 resolution: "@types/d3-array@npm:3.0.5" @@ -12142,13 +12125,6 @@ __metadata: languageName: node linkType: hard -"@types/secure-random-password@npm:0.2.1": - version: 0.2.1 - resolution: "@types/secure-random-password@npm:0.2.1" - checksum: 87f0528b7ccb907706b0cc77160c6771279508de3852213f2c4f28a83af482b016cf3e0b414f62d2e8b3713f1733ad787e3bd40c201463ac822c117856a7886a - languageName: node - linkType: hard - "@types/semver@npm:^7.3.12, @types/semver@npm:^7.3.4": version: 7.5.0 resolution: "@types/semver@npm:7.5.0" @@ -16786,13 +16762,6 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:4.1.1": - version: 4.1.1 - resolution: "crypto-js@npm:4.1.1" - checksum: 50cc66a35f2738171d9a6d80c85ba7d00cb6440b756db035ba9ccd03032c0a803029a62969ecd4c844106c980af87687c64b204dd967989379c4f354fb482d37 - languageName: node - linkType: hard - "crypto-random-string@npm:^1.0.0": version: 1.0.0 resolution: "crypto-random-string@npm:1.0.0" @@ -31637,22 +31606,6 @@ __metadata: languageName: node linkType: hard -"secure-random-password@npm:0.2.3": - version: 0.2.3 - resolution: "secure-random-password@npm:0.2.3" - dependencies: - secure-random: ^1.1.2 - checksum: ced04529b96724b921b82b7527e1ba2f1299c3f327f941076e9edaea66445118b67a6b7f6edfe3b705a240e595de49144b2b796116c994cb52eb52efdd3d51b5 - languageName: node - linkType: hard - -"secure-random@npm:^1.1.2": - version: 1.1.2 - resolution: "secure-random@npm:1.1.2" - checksum: 612934cd5b1ea217d5e248a16ff2752411474997ede1f460ff37fe3214eedfd66ef6a5936ff76b3a5df3d057a8d2d4ed48298f5500bf837beb911522caac7f5c - languageName: node - linkType: hard - "selderee@npm:^0.10.0": version: 0.10.0 resolution: "selderee@npm:0.10.0"