From 2618091162dfc90a5e464749ee6e5c4d3a857953 Mon Sep 17 00:00:00 2001 From: Pratik Karki Date: Fri, 25 Jul 2025 15:24:34 +0545 Subject: [PATCH] Fix local authentication flow when cognito isn't used Signed-off-by: Pratik Karki --- package.json | 1 + pnpm-lock.yaml | 16 + src/runtime/auth/cognito.ts | 561 +++++++++++++-------- src/runtime/modules/auth.ts | 42 +- test/runtime/local-auth-validation.test.ts | 131 +++++ 5 files changed, 534 insertions(+), 217 deletions(-) create mode 100644 test/runtime/local-auth-validation.test.ts diff --git a/package.json b/package.json index 68da342b..a7657f54 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "@codingame/esbuild-import-meta-url-plugin": "~1.0.2", "@eslint/js": "^9.26.0", "@types/cookie-parser": "^1.4.9", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^18.19.110", "@types/vscode": "^1.100.0", "@typescript-eslint/eslint-plugin": "~8.32.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8607cc9c..5e176f04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: '@types/cookie-parser': specifier: ^1.4.9 version: 1.4.9(@types/express@5.0.3) + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 '@types/node': specifier: ^18.19.110 version: 18.19.115 @@ -1693,6 +1696,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + '@types/lodash-es@4.17.12': resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} @@ -1702,6 +1708,9 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@18.19.115': resolution: {integrity: sha512-kNrFiTgG4a9JAn1LMQeLOv3MvXIPokzXziohMrMsvpYgLpdEt/mMiVYc4sGKtDfyxM5gIDF4VgrPRyCw4fHOYg==} @@ -7389,6 +7398,11 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 18.19.115 + '@types/lodash-es@4.17.12': dependencies: '@types/lodash': 4.17.20 @@ -7397,6 +7411,8 @@ snapshots: '@types/mime@1.3.5': {} + '@types/ms@2.1.0': {} + '@types/node@18.19.115': dependencies: undici-types: 5.26.5 diff --git a/src/runtime/auth/cognito.ts b/src/runtime/auth/cognito.ts index b1c58012..320efc2d 100644 --- a/src/runtime/auth/cognito.ts +++ b/src/runtime/auth/cognito.ts @@ -6,7 +6,7 @@ import { SignUpCallback, UserInfo, } from './interface.js'; -import { ensureUser, ensureUserSession, findUser, findUserByEmail } from '../modules/auth.js'; +import { ensureUser, ensureUserSession, findUser, findUserByEmail, updateUser } from '../modules/auth.js'; import { logger } from '../logger.js'; import { sleepMilliseconds } from '../util.js'; import { Instance } from '../module.js'; @@ -62,6 +62,7 @@ const defaultConfig = isNodeEnv .set('ClientId', process.env.COGNITO_CLIENT_ID) : new Map(); + // Helper function to parse Cognito error and throw appropriate custom error function handleCognitoError(err: any, context: string): never { // Log error details for debugging (sanitize sensitive information) @@ -239,6 +240,7 @@ function handleCognitoError(err: any, context: string): never { } } + // Helper function to sanitize error messages to prevent sensitive information exposure function sanitizeErrorMessage(message: string): string { if (!message) return ''; @@ -266,6 +268,7 @@ export function getHttpStatusForError(error: Error): number { if (error instanceof ExpiredCodeError) return 400; if (error instanceof CodeMismatchError) return 400; + // Check error message for additional context if (error.message) { if ( @@ -283,17 +286,88 @@ export function getHttpStatusForError(error: Error): number { return 500; // Internal server error for unknown errors } + +import { scryptSync, randomBytes, timingSafeEqual, createHmac } from 'crypto'; + +const SALT_LENGTH = 32; +const KEY_LENGTH = 64; +const TOKEN_SECRET = process.env.JWT_SECRET || 'default-secret-change-in-production'; +const TOKEN_EXPIRY_HOURS = 24; + +function hashPassword(password: string): string { + const salt = randomBytes(SALT_LENGTH); + const hash = scryptSync(password, salt, KEY_LENGTH); + return `${salt.toString('hex')}:${hash.toString('hex')}`; +} + +function verifyPassword(password: string, hashedPassword: string): boolean { + const [saltHex, hashHex] = hashedPassword.split(':'); + if (!saltHex || !hashHex) return false; + + const salt = Buffer.from(saltHex, 'hex'); + const hash = Buffer.from(hashHex, 'hex'); + const derivedHash = scryptSync(password, salt, KEY_LENGTH); + + return timingSafeEqual(hash, derivedHash); +} + +function generateLocalToken(userId: string, email: string): string { + const payload = { + sub: userId, + email: email, + type: 'local', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + (TOKEN_EXPIRY_HOURS * 3600) + }; + + const payloadStr = JSON.stringify(payload); + const signature = createHmac('sha256', TOKEN_SECRET).update(payloadStr).digest('hex'); + + return `${Buffer.from(payloadStr).toString('base64')}.${signature}`; +} + +function verifyLocalToken(token: string): { userId: string; email: string } { + try { + const [payloadB64, signature] = token.split('.'); + if (!payloadB64 || !signature) { + throw new Error('Invalid token format'); + } + + const payloadStr = Buffer.from(payloadB64, 'base64').toString(); + const expectedSignature = createHmac('sha256', TOKEN_SECRET).update(payloadStr).digest('hex'); + + if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'))) { + throw new Error('Invalid token signature'); + } + + const payload = JSON.parse(payloadStr); + + if (payload.type !== 'local') { + throw new Error('Invalid token type'); + } + + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Token expired'); + } + + return { userId: payload.sub, email: payload.email }; + } catch { + throw new UnauthorisedError('Invalid or expired token'); + } +} + export class CognitoAuth implements AgentlangAuth { config: Map; userPool: any; constructor(config?: Map) { this.config = config ? config : defaultConfig; const upid = this.config.get('UserPoolId'); - if (upid) + if (upid && isNodeEnv) { this.userPool = new CognitoUserPool({ UserPoolId: upid, ClientId: this.fetchClientId(), }); + } } fetchUserPoolId(): string { @@ -309,7 +383,7 @@ export class CognitoAuth implements AgentlangAuth { if (id) { return id; } - throw new Error(`${k} is not set`); + throw new Error(`${k} is not set - Cognito is not configured`); } async signUp( @@ -319,60 +393,87 @@ export class CognitoAuth implements AgentlangAuth { env: Environment, cb: SignUpCallback ): Promise { - const client = new CognitoIdentityProviderClient({ - region: process.env.AWS_REGION || 'us-west-2', - credentials: fromEnv(), - }); - const userAttrs = [ - { - Name: 'email', - Value: username, - }, - { - Name: 'name', - Value: username, - }, - ]; - if (userData) { - userData.forEach((v: string, k: string) => { - userAttrs.push({ Name: k, Value: v }); + const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; + + if (cognitoConfigured) { + const client = new CognitoIdentityProviderClient({ + region: process.env.AWS_REGION || 'us-west-2', + credentials: fromEnv(), }); - } - const input = { - ClientId: this.config.get('ClientId'), - Username: username, - Password: password, - UserAttributes: userAttrs, - ValidationData: userAttrs, - }; - const command = new SignUpCommand(input); - try { - logger.debug(`Attempting signup for user: ${username}`); - const response = await client.send(command); - - if (response.$metadata.httpStatusCode == 200) { - logger.info(`Signup successful for user: ${username}`); - const user = await ensureUser(username, '', '', env); - const userInfo: UserInfo = { - username: username, - id: user.id, - systemUserInfo: response.UserSub, - }; - cb(userInfo); - } else { - logger.error(`Signup failed with HTTP status ${response.$metadata.httpStatusCode}`, { - username: username, - statusCode: response.$metadata.httpStatusCode, + const userAttrs = [ + { + Name: 'email', + Value: username, + }, + { + Name: 'name', + Value: username, + }, + ]; + if (userData) { + userData.forEach((v: string, k: string) => { + userAttrs.push({ Name: k, Value: v }); }); - throw new BadRequestError(`Signup failed with status ${response.$metadata.httpStatusCode}`); } - } catch (err: any) { - if (err instanceof BadRequestError) throw err; - logger.error(`Signup error for user ${username}:`, { - errorName: err.name, - errorMessage: sanitizeErrorMessage(err.message), - }); - handleCognitoError(err, 'signUp'); + const input = { + ClientId: this.config.get('ClientId'), + Username: username, + Password: password, + UserAttributes: userAttrs, + ValidationData: userAttrs, + }; + const command = new SignUpCommand(input); + try { + logger.debug(`Attempting Cognito signup for user: ${username}`); + const response = await client.send(command); + + if (response.$metadata.httpStatusCode == 200) { + logger.info(`Cognito signup successful for user: ${username}`); + const user = await ensureUser(username, '', '', env); + const userId = user && user.lookup ? user.lookup('id') : crypto.randomUUID(); + const userInfo: UserInfo = { + username: username, + id: userId, + systemUserInfo: response.UserSub, + }; + cb(userInfo); + } else { + logger.error(`Cognito signup failed with HTTP status ${response.$metadata.httpStatusCode}`, { + username: username, + statusCode: response.$metadata.httpStatusCode, + }); + throw new BadRequestError(`Signup failed with status ${response.$metadata.httpStatusCode}`); + } + } catch (err: any) { + if (err instanceof BadRequestError) throw err; + logger.error(`Cognito signup error for user ${username}:`, { + errorName: err.name, + errorMessage: sanitizeErrorMessage(err.message), + }); + handleCognitoError(err, 'signUp'); + } + } else { + logger.debug(`Cognito not configured, using local signup for user: ${username}`); + + const existingUser = await findUserByEmail(username, env); + if (existingUser) { + throw new BadRequestError('An account with this email already exists.'); + } + + const hashedPassword = hashPassword(password); + const firstName = userData?.get('firstName') || ''; + const lastName = userData?.get('lastName') || ''; + + const user = await ensureUser(username, firstName, lastName, env, hashedPassword); + const userId = user && user.lookup ? user.lookup('id') : crypto.randomUUID(); + const userInfo: UserInfo = { + username: username, + id: userId, + systemUserInfo: { type: 'local' }, + }; + + logger.info(`Local signup successful for user: ${username}`); + cb(userInfo); } } @@ -464,83 +565,48 @@ export class CognitoAuth implements AgentlangAuth { throw new UnauthorisedError('Login failed. Please try again.'); } } else { - // Cognito not configured, fall back to local authentication - let localUser = await findUserByEmail(username, env); + logger.debug(`Cognito not configured, using local authentication for user: ${username}`); + + const localUser = await findUserByEmail(username, env); if (!localUser) { - logger.warn(`User ${username} not found in local store`); - localUser = await ensureUser(username, '', '', env); + logger.warn(`User ${username} not found in local store during login`); + throw new UserNotFoundError('User account not found. Please check your email or sign up.'); } - const user = new CognitoUser({ - Username: username, - Pool: this.fetchUserPool(), - }); - const authDetails = new AuthenticationDetails({ - Username: username, - Password: password, - }); - let result: any; - let authError: any; - user.authenticateUser(authDetails, { - onSuccess: (session: any) => { - result = session; - }, - onFailure: (err: any) => { - logger.debug(`Cognito authentication failed for user ${username}:`, { - errorName: err.name, - errorMessage: sanitizeErrorMessage(err.message), - }); - authError = err; - }, - mfaRequired: (challengeName: any, _challengeParameters: any) => { - logger.info(`MFA required for user ${username}: ${challengeName}`); - authError = new Error('MFA authentication required'); - }, - newPasswordRequired: (_userAttributes: any, _requiredAttributes: any) => { - logger.info(`New password required for user ${username}`); - authError = new PasswordResetRequiredError( - 'New password required. Please reset your password.' - ); - }, - }); - while (result == undefined && authError == undefined) { - await sleepMilliseconds(100); - } - if (authError) { - if (authError instanceof PasswordResetRequiredError) { - throw authError; - } - logger.error(`Login failed for user ${username}:`, { - errorName: authError.name, - errorMessage: sanitizeErrorMessage(authError.message), - }); - handleCognitoError(authError, 'login'); + + const storedPasswordHash = localUser.lookup('passwordHash'); + if (!storedPasswordHash) { + logger.warn(`User ${username} has no password hash - account may be Cognito-only`); + throw new UnauthorisedError('Invalid credentials. Please check your email and password.'); } - if (result) { - const userid = localUser.lookup('id'); - const idToken = result.getIdToken().getJwtToken(); - const accessToken = result.getAccessToken().getJwtToken(); - const refreshToken = result.getRefreshToken().getToken(); - const localSess: Instance = await ensureUserSession( - userid, - idToken, - accessToken, - refreshToken, - env - ); - const sessInfo: SessionInfo = { - sessionId: localSess.lookup('id'), - userId: userid, - authToken: idToken, - idToken: idToken, - accessToken: accessToken, - refreshToken: refreshToken, - systemSesionInfo: result, - }; - cb(sessInfo); - } else { - logger.error(`Login failed for ${username} - no result received`); - throw new UnauthorisedError('Login failed. Please try again.'); + + if (!verifyPassword(password, storedPasswordHash)) { + logger.warn(`Invalid password attempt for user ${username}`); + throw new UnauthorisedError('Invalid credentials. Please check your email and password.'); } + + const userid = localUser.lookup('id'); + const authToken = generateLocalToken(userid, username); + + const localSess: Instance = await ensureUserSession( + userid, + authToken, + '', + '', + env + ); + + const sessInfo: SessionInfo = { + sessionId: localSess.lookup('id'), + userId: userid, + authToken: authToken, + idToken: authToken, + accessToken: undefined, + refreshToken: undefined, + systemSesionInfo: { type: 'local', userId: userid }, + }; + + logger.info(`Local authentication successful for user: ${username}`); + cb(sessInfo); } } @@ -552,43 +618,57 @@ export class CognitoAuth implements AgentlangAuth { if (cb) cb(true); return; } - const user = new CognitoUser({ - Username: localUser.lookup('email'), - Pool: this.fetchUserPool(), - }); - let done = false; - let logoutError: any; + // Check if Cognito is configured and we have Cognito tokens + const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; + const hasCognitoTokens = sessionInfo.idToken && sessionInfo.accessToken && sessionInfo.refreshToken; - const session = new CognitoUserSession({ - IdToken: new CognitoIdToken({ IdToken: sessionInfo.idToken }), - AccessToken: new CognitoAccessToken({ AccessToken: sessionInfo.accessToken }), - RefreshToken: new CognitoRefreshToken({ RefreshToken: sessionInfo.refreshToken }), - }); - user.setSignInUserSession(session); - user.globalSignOut({ - onSuccess: function () { - done = true; - }, - onFailure: function (err: any) { - done = true; - logger.error(`Cognito signOut error for user ${sessionInfo.userId}:`, { - errorName: err.name, - errorMessage: sanitizeErrorMessage(err.message), - }); - logoutError = err; - }, - }); + if (cognitoConfigured && hasCognitoTokens) { + // Perform Cognito logout + const user = new CognitoUser({ + Username: localUser.lookup('email'), + Pool: this.fetchUserPool(), + }); - while (!done) { - await sleepMilliseconds(100); - } - if (logoutError) { - logger.error( - `Error during Cognito logout for user ${sessionInfo.userId}: ${logoutError.message}` - ); - // Continue with local session cleanup even if Cognito logout fails + let done = false; + let logoutError: any; + + const session = new CognitoUserSession({ + IdToken: new CognitoIdToken({ IdToken: sessionInfo.idToken }), + AccessToken: new CognitoAccessToken({ AccessToken: sessionInfo.accessToken }), + RefreshToken: new CognitoRefreshToken({ RefreshToken: sessionInfo.refreshToken }), + }); + user.setSignInUserSession(session); + user.globalSignOut({ + onSuccess: function () { + done = true; + }, + onFailure: function (err: any) { + done = true; + logger.error(`Cognito signOut error for user ${sessionInfo.userId}:`, { + errorName: err.name, + errorMessage: sanitizeErrorMessage(err.message), + }); + logoutError = err; + }, + }); + + while (!done) { + await sleepMilliseconds(100); + } + if (logoutError) { + logger.error( + `Error during Cognito logout for user ${sessionInfo.userId}: ${logoutError.message}` + ); + // Continue with local session cleanup even if Cognito logout fails + } + logger.debug(`Successfully logged out user ${sessionInfo.userId} from Cognito`); + } else { + // Local authentication logout - just invalidate the local session + logger.debug(`Performing local logout for user ${sessionInfo.userId}`); } + + // Always perform local session cleanup logger.debug(`Successfully logged out user ${sessionInfo.userId}`); if (cb) cb(true); } catch (err: any) { @@ -609,79 +689,134 @@ export class CognitoAuth implements AgentlangAuth { logger.warn(`User ${sessionInfo.userId} not found for password-change`); return false; } + const email = localUser.lookup('email'); - const user = new CognitoUser({ - Username: email, - Pool: this.fetchUserPool(), - }); - const session = new CognitoUserSession({ - IdToken: new CognitoIdToken({ IdToken: sessionInfo.idToken }), - AccessToken: new CognitoAccessToken({ AccessToken: sessionInfo.accessToken }), - RefreshToken: new CognitoRefreshToken({ RefreshToken: sessionInfo.refreshToken }), - }); - user.setSignInUserSession(session); - let done = false; - let cpErr: any = undefined; - user.changePassword(oldPassword, newPassword, (err: any, _: any) => { - if (err) { - done = true; - cpErr = err; - } else { - done = true; + + // Check if Cognito is configured and we have Cognito tokens + const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; + const hasCognitoTokens = sessionInfo.idToken && sessionInfo.accessToken && sessionInfo.refreshToken; + + if (cognitoConfigured && hasCognitoTokens) { + // Use Cognito password change + const user = new CognitoUser({ + Username: email, + Pool: this.fetchUserPool(), + }); + const session = new CognitoUserSession({ + IdToken: new CognitoIdToken({ IdToken: sessionInfo.idToken }), + AccessToken: new CognitoAccessToken({ AccessToken: sessionInfo.accessToken }), + RefreshToken: new CognitoRefreshToken({ RefreshToken: sessionInfo.refreshToken }), + }); + user.setSignInUserSession(session); + let done = false; + let cpErr: any = undefined; + user.changePassword(oldPassword, newPassword, (err: any, _: any) => { + if (err) { + done = true; + cpErr = err; + } else { + done = true; + } + }); + + while (!done) { + await sleepMilliseconds(100); } - }); - while (!done) { - await sleepMilliseconds(100); - } + if (cpErr) { + logger.warn(`Failed to change the password for ${email} - ${cpErr.message}`); + return false; + } + return true; + } else { + const storedPasswordHash = localUser.lookup('passwordHash'); + if (!storedPasswordHash) { + logger.warn(`User ${email} has no password hash for local password change`); + return false; + } - if (cpErr) { - logger.warn(`Failed to change the password for ${email} - ${cpErr.message}`); - return false; + if (!verifyPassword(oldPassword, storedPasswordHash)) { + logger.warn(`Invalid old password for user ${email} during password change`); + return false; + } + + const newPasswordHash = hashPassword(newPassword); + + try { + await updateUser( + sessionInfo.userId, + undefined, + undefined, + undefined, + newPasswordHash, + env + ); + logger.info(`Password changed successfully for user ${email}`); + return true; + } catch (error) { + logger.error(`Failed to update password for user ${email}: ${error}`); + return false; + } } - return true; } private fetchUserPool() { if (this.userPool) { return this.userPool; } - throw new Error('UserPool not initialized'); + throw new Error('UserPool not initialized - Cognito is not configured'); } async verifyToken(token: string): Promise { - try { - const verifier = CognitoJwtVerifier.create({ - userPoolId: this.fetchUserPoolId(), - tokenUse: 'id', - clientId: this.fetchClientId(), - }); + // Check if Cognito is configured + const cognitoConfigured = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; + + if (cognitoConfigured) { + // Use Cognito JWT verification + try { + const verifier = CognitoJwtVerifier.create({ + userPoolId: this.fetchUserPoolId(), + tokenUse: 'id', + clientId: this.fetchClientId(), + }); - const payload = await verifier.verify(token); - logger.debug(`Decoded JWT for ${payload.email}`); - } catch (err: any) { - logger.error(`Token verification failed:`, { - errorName: err.name, - errorMessage: sanitizeErrorMessage(err.message), - }); + const payload = await verifier.verify(token); + logger.debug(`Decoded JWT for ${payload.email}`); + } catch (err: any) { + logger.error(`Token verification failed:`, { + errorName: err.name, + errorMessage: sanitizeErrorMessage(err.message), + }); + + // Handle specific token verification errors + if (err.message && err.message.includes('expired')) { + throw new UnauthorisedError('Token has expired. Please login again.'); + } + if (err.message && err.message.includes('invalid')) { + throw new UnauthorisedError('Invalid token format.'); + } + if (err.message && err.message.includes('not before')) { + throw new UnauthorisedError('Token is not yet valid.'); + } + if (err.message && err.message.includes('audience')) { + throw new UnauthorisedError('Token audience mismatch.'); + } - // Handle specific token verification errors - if (err.message && err.message.includes('expired')) { - throw new UnauthorisedError('Token has expired. Please login again.'); + throw new UnauthorisedError( + `Token verification failed: ${sanitizeErrorMessage(err.message) || 'Invalid token'}` + ); } - if (err.message && err.message.includes('invalid')) { + } else { + if (!token || typeof token !== 'string') { throw new UnauthorisedError('Invalid token format.'); } - if (err.message && err.message.includes('not before')) { - throw new UnauthorisedError('Token is not yet valid.'); - } - if (err.message && err.message.includes('audience')) { - throw new UnauthorisedError('Token audience mismatch.'); + + try { + const decoded = verifyLocalToken(token); + logger.debug(`Local JWT verified for user: ${decoded.userId}`); + } catch (error) { + throw error; } - - throw new UnauthorisedError( - `Token verification failed: ${sanitizeErrorMessage(err.message) || 'Invalid token'}` - ); } } diff --git a/src/runtime/modules/auth.ts b/src/runtime/modules/auth.ts index 0fd5efe8..39f79b84 100644 --- a/src/runtime/modules/auth.ts +++ b/src/runtime/modules/auth.ts @@ -36,6 +36,7 @@ entity User { email Email @unique @indexed, firstName String, lastName String, + passwordHash String @optional, @rbac [(allow: [read, delete, update, create], where: auth.user = this.id)], @after {delete AfterDeleteUser} } @@ -48,7 +49,16 @@ workflow CreateUser { {User {id CreateUser.id, email CreateUser.email, firstName CreateUser.firstName, - lastName CreateUser.lastName}} + lastName CreateUser.lastName, + passwordHash? CreateUser.passwordHash}} +} + +workflow UpdateUser { + {User {id? UpdateUser.id, + email? UpdateUser.email, + firstName? UpdateUser.firstName, + lastName? UpdateUser.lastName, + passwordHash? UpdateUser.passwordHash}} } workflow FindUser { @@ -196,7 +206,8 @@ export async function createUser( email: string, firstName: string, lastName: string, - env: Environment + env: Environment, + passwordHash?: string ): Promise { return await evalEvent( 'CreateUser', @@ -205,6 +216,28 @@ export async function createUser( email: email, firstName: firstName, lastName: lastName, + passwordHash: passwordHash, + }, + env + ); +} + +export async function updateUser( + id: string, + email?: string, + firstName?: string, + lastName?: string, + passwordHash?: string, + env?: Environment +): Promise { + return await evalEvent( + 'UpdateUser', + { + id: id, + email: email, + firstName: firstName, + lastName: lastName, + passwordHash: passwordHash, }, env ); @@ -234,13 +267,14 @@ export async function ensureUser( email: string, firstName: string, lastName: string, - env: Environment + env: Environment, + passwordHash?: string ) { const user = await findUserByEmail(email, env); if (user) { return user; } - return await createUser(crypto.randomUUID(), email, firstName, lastName, env); + return await createUser(crypto.randomUUID(), email, firstName, lastName, env, passwordHash); } export async function ensureUserSession( diff --git a/test/runtime/local-auth-validation.test.ts b/test/runtime/local-auth-validation.test.ts new file mode 100644 index 00000000..6b9cefc0 --- /dev/null +++ b/test/runtime/local-auth-validation.test.ts @@ -0,0 +1,131 @@ +import { assert, describe, test } from 'vitest'; + +describe('Local Authentication Validation', () => { + test('Password hashing and token generation work correctly', async () => { + // Test the core crypto functions that power local authentication + const { scryptSync, randomBytes, timingSafeEqual, createHmac } = await import('crypto'); + + // Test password hashing + const SALT_LENGTH = 32; + const KEY_LENGTH = 64; + + function hashPassword(password: string): string { + const salt = randomBytes(SALT_LENGTH); + const hash = scryptSync(password, salt, KEY_LENGTH); + return `${salt.toString('hex')}:${hash.toString('hex')}`; + } + + function verifyPassword(password: string, hashedPassword: string): boolean { + const [saltHex, hashHex] = hashedPassword.split(':'); + if (!saltHex || !hashHex) return false; + + const salt = Buffer.from(saltHex, 'hex'); + const hash = Buffer.from(hashHex, 'hex'); + const derivedHash = scryptSync(password, salt, KEY_LENGTH); + + return timingSafeEqual(hash, derivedHash); + } + + // Test token generation + const TOKEN_SECRET = 'test-secret'; + + function generateLocalToken(userId: string, email: string): string { + const payload = { + sub: userId, + email: email, + type: 'local', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + (24 * 3600) + }; + + const payloadStr = JSON.stringify(payload); + const signature = createHmac('sha256', TOKEN_SECRET).update(payloadStr).digest('hex'); + + return `${Buffer.from(payloadStr).toString('base64')}.${signature}`; + } + + function verifyLocalToken(token: string): { userId: string; email: string } { + const [payloadB64, signature] = token.split('.'); + if (!payloadB64 || !signature) { + throw new Error('Invalid token format'); + } + + const payloadStr = Buffer.from(payloadB64, 'base64').toString(); + const expectedSignature = createHmac('sha256', TOKEN_SECRET).update(payloadStr).digest('hex'); + + if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex'))) { + throw new Error('Invalid token signature'); + } + + const payload = JSON.parse(payloadStr); + + if (payload.type !== 'local') { + throw new Error('Invalid token type'); + } + + if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Token expired'); + } + + return { userId: payload.sub, email: payload.email }; + } + + // Test 1: Password hashing and verification + const testPassword = 'TestPassword123!'; + const hashedPassword = hashPassword(testPassword); + + assert(hashedPassword.includes(':'), 'Hashed password should contain salt separator'); + assert(verifyPassword(testPassword, hashedPassword), 'Password verification should succeed'); + assert(!verifyPassword('WrongPassword', hashedPassword), 'Wrong password should be rejected'); + + // Test 2: Token generation and verification + const testUserId = 'test-user-id'; + const testEmail = 'test@example.com'; + + const token = generateLocalToken(testUserId, testEmail); + assert(token.includes('.'), 'Token should contain signature separator'); + + const decoded = verifyLocalToken(token); + assert(decoded.userId === testUserId, 'Decoded user ID should match'); + assert(decoded.email === testEmail, 'Decoded email should match'); + + // Test 3: Invalid token rejection + try { + verifyLocalToken('invalid.token'); + assert(false, 'Should have rejected invalid token'); + } catch (error: any) { + assert(error.message, 'Should have error message'); + // The error could be about invalid format, signature, or other issues + } + + // Test 4: Cognito configuration detection + const originalUserPoolId = process.env.COGNITO_USER_POOL_ID; + const originalClientId = process.env.COGNITO_CLIENT_ID; + + // Test without Cognito + delete process.env.COGNITO_USER_POOL_ID; + delete process.env.COGNITO_CLIENT_ID; + + const cognitoConfigured1 = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; + assert(!cognitoConfigured1, 'Should detect Cognito as not configured'); + + // Test with Cognito + process.env.COGNITO_USER_POOL_ID = 'test-pool'; + process.env.COGNITO_CLIENT_ID = 'test-client'; + + const cognitoConfigured2 = process.env.COGNITO_USER_POOL_ID && process.env.COGNITO_CLIENT_ID; + assert(cognitoConfigured2, 'Should detect Cognito as configured'); + + // Restore original values + if (originalUserPoolId) { + process.env.COGNITO_USER_POOL_ID = originalUserPoolId; + } else { + delete process.env.COGNITO_USER_POOL_ID; + } + if (originalClientId) { + process.env.COGNITO_CLIENT_ID = originalClientId; + } else { + delete process.env.COGNITO_CLIENT_ID; + } + }); +}); \ No newline at end of file