diff --git a/packages/wabe/generated/schema.graphql b/packages/wabe/generated/schema.graphql index d3e830a0..09b37f64 100644 --- a/packages/wabe/generated/schema.graphql +++ b/packages/wabe/generated/schema.graphql @@ -50,6 +50,7 @@ type User { role: Role sessions: _SessionConnection secondFA: UserSecondFA + pendingChallenges: [UserPendingAuthenticationChallenge] } """ @@ -130,6 +131,12 @@ type UserSecondFA { provider: SecondaryFactor! } +type UserPendingAuthenticationChallenge { + token: String! + provider: String! + expiresAt: Date! +} + """ User class """ @@ -148,6 +155,7 @@ input UserInput { role: RolePointerInput sessions: _SessionRelationInput secondFA: UserSecondFAInput + pendingChallenges: [UserPendingAuthenticationChallengeInput] } input UserACLObjectInput { @@ -208,6 +216,12 @@ input UserSecondFAInput { provider: SecondaryFactor! } +input UserPendingAuthenticationChallengeInput { + token: String! + provider: String! + expiresAt: Date! +} + """ Input to link an object to a pointer User """ @@ -235,6 +249,7 @@ input UserCreateFieldsInput { role: RolePointerInput sessions: _SessionRelationInput secondFA: UserSecondFACreateFieldsInput + pendingChallenges: [UserPendingAuthenticationChallengeCreateFieldsInput] } input UserACLObjectCreateFieldsInput { @@ -295,6 +310,12 @@ input UserSecondFACreateFieldsInput { provider: SecondaryFactor } +input UserPendingAuthenticationChallengeCreateFieldsInput { + token: String + provider: String + expiresAt: Date +} + """ Input to add a relation to the class User """ @@ -802,6 +823,7 @@ input UserWhereInput { role: RoleWhereInput sessions: _SessionRelationWhereInput secondFA: UserSecondFAWhereInput + pendingChallenges: [UserPendingAuthenticationChallengeWhereInput] OR: [UserWhereInput] AND: [UserWhereInput] } @@ -1056,6 +1078,14 @@ input UserSecondFAWhereInput { AND: [UserSecondFAWhereInput] } +input UserPendingAuthenticationChallengeWhereInput { + token: StringWhereInput + provider: StringWhereInput + expiresAt: DateWhereInput + OR: [UserPendingAuthenticationChallengeWhereInput] + AND: [UserPendingAuthenticationChallengeWhereInput] +} + enum UserOrder { name_ASC name_DESC @@ -1085,6 +1115,8 @@ enum UserOrder { sessions_DESC secondFA_ASC secondFA_DESC + pendingChallenges_ASC + pendingChallenges_DESC } type PostConnection { @@ -1410,6 +1442,7 @@ input UserUpdateFieldsInput { role: RolePointerInput sessions: _SessionRelationInput secondFA: UserSecondFAUpdateFieldsInput + pendingChallenges: [UserPendingAuthenticationChallengeUpdateFieldsInput] } input UserACLObjectUpdateFieldsInput { @@ -1470,6 +1503,12 @@ input UserSecondFAUpdateFieldsInput { provider: SecondaryFactor } +input UserPendingAuthenticationChallengeUpdateFieldsInput { + token: String + provider: String + expiresAt: Date +} + input UpdateUsersInput { fields: UserUpdateFieldsInput where: UserWhereInput @@ -1842,6 +1881,7 @@ input SendEmailInput { type SignInWithOutput { user: User + challengeToken: String accessToken: String refreshToken: String srp: SignInWithOutputSRPOutputSignInWith @@ -1956,6 +1996,7 @@ type VerifyChallengeOutputSRPOutputVerifyChallenge { } input VerifyChallengeInput { + challengeToken: String secondFA: VerifyChallengeSecondaryFactorAuthenticationInput } diff --git a/packages/wabe/generated/wabe.ts b/packages/wabe/generated/wabe.ts index bd955c4c..28f46e71 100644 --- a/packages/wabe/generated/wabe.ts +++ b/packages/wabe/generated/wabe.ts @@ -74,6 +74,12 @@ export type SecondFA = { provider: SecondaryFactor } +export type PendingAuthenticationChallenge = { + token: string + provider: string + expiresAt: Date +} + export type User = { id: string name?: string @@ -90,6 +96,7 @@ export type User = { role?: Role sessions?: Array<_Session> secondFA?: SecondFA + pendingChallenges?: Array } export type Experience = { @@ -163,6 +170,7 @@ export type WhereUser = { role?: Role sessions?: Array<_Session> secondFA?: SecondFA + pendingChallenges?: Array } export type WherePost = { @@ -375,6 +383,7 @@ export type MutationRefreshArgs = { } export type VerifyChallengeInput = { + challengeToken?: string secondFA?: VerifyChallengeSecondFA } diff --git a/packages/wabe/src/authentication/cookies.ts b/packages/wabe/src/authentication/cookies.ts new file mode 100644 index 00000000..fb6be413 --- /dev/null +++ b/packages/wabe/src/authentication/cookies.ts @@ -0,0 +1,10 @@ +import type { WabeConfig, WabeTypes } from '../server' + +export const getSessionCookieSameSite = (config: WabeConfig) => { + const frontDomain = config.authentication?.frontDomain + const backDomain = config.authentication?.backDomain + + if (frontDomain && backDomain && frontDomain !== backDomain) return 'None' + + return 'Strict' +} diff --git a/packages/wabe/src/authentication/interface.ts b/packages/wabe/src/authentication/interface.ts index ca40db1b..eb18a4c0 100644 --- a/packages/wabe/src/authentication/interface.ts +++ b/packages/wabe/src/authentication/interface.ts @@ -116,6 +116,27 @@ export interface SessionConfig { jwtTokenFields?: SelectType } +export interface AuthenticationRateLimitConfig { + /** + * Enable this rate limiter. Enabled by default in production. + */ + enabled?: boolean + maxAttempts?: number + windowMs?: number + blockDurationMs?: number +} + +export interface AuthenticationSecurityConfig { + signInRateLimit?: AuthenticationRateLimitConfig + signUpRateLimit?: AuthenticationRateLimitConfig + verifyChallengeRateLimit?: AuthenticationRateLimitConfig + mfaChallengeTtlMs?: number + /** + * Require a valid challenge token during verifyChallenge in production. + */ + requireMfaChallengeInProduction?: boolean +} + export interface AuthenticationConfig { session?: SessionConfig roles?: RoleConfig @@ -127,6 +148,7 @@ export interface AuthenticationConfig { customAuthenticationMethods?: CustomAuthenticationMethods[] sessionHandler?: (context: WobeCustomContext) => void | Promise disableSignUp?: boolean + security?: AuthenticationSecurityConfig } export interface CreateTokenFromAuthorizationCodeOptions { diff --git a/packages/wabe/src/authentication/providers/EmailOTP.ts b/packages/wabe/src/authentication/providers/EmailOTP.ts index 86b963b6..6306d5cc 100644 --- a/packages/wabe/src/authentication/providers/EmailOTP.ts +++ b/packages/wabe/src/authentication/providers/EmailOTP.ts @@ -7,6 +7,7 @@ import type { SecondaryProviderInterface, } from '../interface' import { OTP } from '../OTP' +import { clearRateLimit, isRateLimited, registerRateLimitFailure } from '../security' const DUMMY_USER_ID = '00000000-0000-0000-0000-000000000000' @@ -45,6 +46,11 @@ export class EmailOTP implements SecondaryProviderInterface) { + const normalizedEmail = input.email.trim().toLowerCase() + const rateLimitKey = `emailOtp:${normalizedEmail}` + + if (isRateLimited(context, 'verifyChallenge', rateLimitKey)) return null + const users = await context.wabe.controllers.database.getObjects({ className: 'User', where: { @@ -77,7 +83,12 @@ export class EmailOTP implements SecondaryProviderInterface { afterEach(() => { mockGetObjects.mockClear() + mockCount.mockClear() mockCreateObject.mockClear() spyArgonPasswordVerify.mockClear() spyBunPasswordHash.mockClear() @@ -136,6 +137,92 @@ describe('Email password', () => { expect(spyArgonPasswordVerify).toHaveBeenCalledTimes(1) }) + it('should rate limit signIn attempts in production', async () => { + mockGetObjects.mockResolvedValue([]) + + const context = { + wabe: { + ...controllers, + config: { + isProduction: true, + authentication: { + security: { + signInRateLimit: { + enabled: true, + maxAttempts: 2, + windowMs: 60_000, + blockDurationMs: 60_000, + }, + }, + }, + }, + }, + } as any + + const input = { + email: 'ratelimit-email-password@test.fr', + password: 'password', + } + + await expect(emailPassword.onSignIn({ context, input })).rejects.toThrow( + 'Invalid authentication credentials', + ) + await expect(emailPassword.onSignIn({ context, input })).rejects.toThrow( + 'Invalid authentication credentials', + ) + + const callsBeforeBlockedAttempt = mockGetObjects.mock.calls.length + + await expect(emailPassword.onSignIn({ context, input })).rejects.toThrow( + 'Invalid authentication credentials', + ) + + expect(mockGetObjects.mock.calls.length).toBe(callsBeforeBlockedAttempt) + }) + + it('should rate limit signUp attempts in production', async () => { + mockCount.mockResolvedValue(1) + + const context = { + wabe: { + ...controllers, + config: { + isProduction: true, + authentication: { + security: { + signUpRateLimit: { + enabled: true, + maxAttempts: 2, + windowMs: 60_000, + blockDurationMs: 60_000, + }, + }, + }, + }, + }, + } as any + + const input = { + email: 'ratelimit-signup-email-password@test.fr', + password: 'password', + } + + await expect(emailPassword.onSignUp({ context, input })).rejects.toThrow( + 'Not authorized to create user', + ) + await expect(emailPassword.onSignUp({ context, input })).rejects.toThrow( + 'Not authorized to create user', + ) + + const callsBeforeBlockedAttempt = mockCount.mock.calls.length + + await expect(emailPassword.onSignUp({ context, input })).rejects.toThrow( + 'Not authorized to create user', + ) + + expect(mockCount.mock.calls.length).toBe(callsBeforeBlockedAttempt) + }) + it('should not update authentication data if there is no user found', () => { mockGetObjects.mockResolvedValue([]) diff --git a/packages/wabe/src/authentication/providers/EmailPassword.ts b/packages/wabe/src/authentication/providers/EmailPassword.ts index ebd37bdf..738f5ecb 100644 --- a/packages/wabe/src/authentication/providers/EmailPassword.ts +++ b/packages/wabe/src/authentication/providers/EmailPassword.ts @@ -5,6 +5,7 @@ import type { } from '../interface' import { contextWithRoot, verifyArgon2 } from '../../utils/export' import type { DevWabeTypes } from '../../utils/helper' +import { clearRateLimit, isRateLimited, registerRateLimitFailure } from '../security' type EmailPasswordInterface = { password: string @@ -20,6 +21,12 @@ export class EmailPassword implements ProviderInterface) { + const normalizedEmail = input.email.trim().toLowerCase() + const rateLimitKey = `emailPassword:${normalizedEmail}` + + if (isRateLimited(context, 'signIn', rateLimitKey)) + throw new Error('Invalid authentication credentials') + const users = await context.wabe.controllers.database.getObjects({ className: 'User', where: { @@ -51,8 +58,12 @@ export class EmailPassword implements ProviderInterface) { + const normalizedEmail = input.email.trim().toLowerCase() + const rateLimitKey = `emailPassword:${normalizedEmail}` + + if (isRateLimited(context, 'signUp', rateLimitKey)) + throw new Error('Not authorized to create user') + const users = await context.wabe.controllers.database.count({ className: 'User', where: { @@ -76,7 +93,12 @@ export class EmailPassword implements ProviderInterface 0) throw new Error('Not authorized to create user') + if (users > 0) { + registerRateLimitFailure(context, 'signUp', rateLimitKey) + throw new Error('Not authorized to create user') + } + + clearRateLimit(context, 'signUp', rateLimitKey) return { authenticationDataToSave: { diff --git a/packages/wabe/src/authentication/providers/EmailPasswordSRP.ts b/packages/wabe/src/authentication/providers/EmailPasswordSRP.ts index 94782dd0..246f9867 100644 --- a/packages/wabe/src/authentication/providers/EmailPasswordSRP.ts +++ b/packages/wabe/src/authentication/providers/EmailPasswordSRP.ts @@ -7,6 +7,7 @@ import type { import { contextWithRoot } from '../../utils/export' import type { DevWabeTypes } from '../../utils/helper' import { createSRPServer, type Ephemeral, type Session } from 'js-srp6a' +import { clearRateLimit, isRateLimited, registerRateLimitFailure } from '../security' // 🛡 Valeurs factices pour mitigation des timing attacks const DUMMY_SALT = 'deadbeefdeadbeefdeadbeefdeadbeef' @@ -90,6 +91,12 @@ export class EmailPasswordSRP implements ProviderInterface< input, context, }: AuthenticationEventsOptions) { + const normalizedEmail = input.email.trim().toLowerCase() + const rateLimitKey = `emailPasswordSrp:${normalizedEmail}` + + if (isRateLimited(context, 'signUp', rateLimitKey)) + throw new Error('Not authorized to create user') + const users = await context.wabe.controllers.database.count({ className: 'User', where: { @@ -98,7 +105,12 @@ export class EmailPasswordSRP implements ProviderInterface< context: contextWithRoot(context), }) - if (users > 0) throw new Error('Not authorized to create user') + if (users > 0) { + registerRateLimitFailure(context, 'signUp', rateLimitKey) + throw new Error('Not authorized to create user') + } + + clearRateLimit(context, 'signUp', rateLimitKey) return { authenticationDataToSave: { diff --git a/packages/wabe/src/authentication/providers/PhonePassword.test.ts b/packages/wabe/src/authentication/providers/PhonePassword.test.ts index 725def16..75bda050 100644 --- a/packages/wabe/src/authentication/providers/PhonePassword.test.ts +++ b/packages/wabe/src/authentication/providers/PhonePassword.test.ts @@ -4,6 +4,7 @@ import * as crypto from '../../utils/crypto' import { PhonePassword } from './PhonePassword' describe('Phone password', () => { + const mockGetObject = mock(() => Promise.resolve(null)) as any const mockGetObjects = mock(() => Promise.resolve([])) const mockCount = mock(() => Promise.resolve(0)) as any const mockCreateObject = mock(() => Promise.resolve({ id: 'userId' })) as any @@ -14,6 +15,7 @@ describe('Phone password', () => { const controllers = { controllers: { database: { + getObject: mockGetObject, getObjects: mockGetObjects, createObject: mockCreateObject, count: mockCount, @@ -22,7 +24,9 @@ describe('Phone password', () => { } as any afterEach(() => { + mockGetObject.mockClear() mockGetObjects.mockClear() + mockCount.mockClear() mockCreateObject.mockClear() spyArgonPasswordVerify.mockClear() spyBunPasswordHash.mockClear() @@ -136,6 +140,49 @@ describe('Phone password', () => { expect(spyArgonPasswordVerify).toHaveBeenCalledTimes(1) }) + it('should rate limit signUp attempts in production', async () => { + mockCount.mockResolvedValue(1) + + const context = { + wabe: { + ...controllers, + config: { + isProduction: true, + authentication: { + security: { + signUpRateLimit: { + enabled: true, + maxAttempts: 2, + windowMs: 60_000, + blockDurationMs: 60_000, + }, + }, + }, + }, + }, + } as any + + const input = { + phone: 'ratelimit-signup-phone-password', + password: 'password', + } + + await expect(phonePassword.onSignUp({ context, input })).rejects.toThrow( + 'Not authorized to create user', + ) + await expect(phonePassword.onSignUp({ context, input })).rejects.toThrow( + 'Not authorized to create user', + ) + + const callsBeforeBlockedAttempt = mockCount.mock.calls.length + + await expect(phonePassword.onSignUp({ context, input })).rejects.toThrow( + 'Not authorized to create user', + ) + + expect(mockCount.mock.calls.length).toBe(callsBeforeBlockedAttempt) + }) + it('should not update authentication data if there is no user found', () => { mockGetObjects.mockResolvedValue([]) @@ -154,11 +201,9 @@ describe('Phone password', () => { }) it('should update authentication data if the userId match with an user', async () => { - mockGetObjects.mockResolvedValue([ - { - id: 'id', - }, - ] as any) + mockGetObject.mockResolvedValue({ + id: 'id', + }) spyBunPasswordHash.mockResolvedValueOnce('$argon2id$hashedPassword') diff --git a/packages/wabe/src/authentication/providers/PhonePassword.ts b/packages/wabe/src/authentication/providers/PhonePassword.ts index fc12b767..053bf1d0 100644 --- a/packages/wabe/src/authentication/providers/PhonePassword.ts +++ b/packages/wabe/src/authentication/providers/PhonePassword.ts @@ -5,6 +5,7 @@ import type { } from '../interface' import { contextWithRoot, verifyArgon2 } from '../../utils/export' import type { DevWabeTypes } from '../../utils/helper' +import { clearRateLimit, isRateLimited, registerRateLimitFailure } from '../security' const DUMMY_PASSWORD_HASH = '$argon2id$v=19$m=65536,t=2,p=1$wHZB9xRS/Mbo7L3SL9e935Ag5K+T2EuT/XgB8akwZgo$SPf8EZ4T1HYkuIll4v2hSzNCH7woX3VrZJo3yWg5u8U' @@ -20,12 +21,18 @@ export class PhonePassword implements ProviderInterface) { + const normalizedPhone = input.phone.trim().toLowerCase() + const rateLimitKey = `phonePassword:${normalizedPhone}` + + if (isRateLimited(context, 'signIn', rateLimitKey)) + throw new Error('Invalid authentication credentials') + const users = await context.wabe.controllers.database.getObjects({ className: 'User', where: { authentication: { phonePassword: { - phone: { equalTo: input.phone }, + phone: { equalTo: normalizedPhone }, }, }, }, @@ -51,8 +58,16 @@ export class PhonePassword implements ProviderInterface) { + const normalizedPhone = input.phone.trim().toLowerCase() + const rateLimitKey = `phonePassword:${normalizedPhone}` + + if (isRateLimited(context, 'signUp', rateLimitKey)) + throw new Error('Not authorized to create user') + const users = await context.wabe.controllers.database.count({ className: 'User', where: { authentication: { phonePassword: { - phone: { equalTo: input.phone }, + phone: { equalTo: normalizedPhone }, }, }, }, context: contextWithRoot(context), }) - if (users > 0) throw new Error('Not authorized to create user') + if (users > 0) { + registerRateLimitFailure(context, 'signUp', rateLimitKey) + throw new Error('Not authorized to create user') + } + + clearRateLimit(context, 'signUp', rateLimitKey) return { authenticationDataToSave: { - phone: input.phone, + phone: normalizedPhone, password: input.password, }, } @@ -90,24 +116,19 @@ export class PhonePassword implements ProviderInterface) { - const users = await context.wabe.controllers.database.getObjects({ + const normalizedPhone = input.phone.trim().toLowerCase() + const user = await context.wabe.controllers.database.getObject({ className: 'User', - where: { - id: { - equalTo: userId, - }, - }, - context, + id: userId, + context: contextWithRoot(context), select: { authentication: true }, }) - if (users.length === 0) throw new Error('User not found') - - const user = users[0] + if (!user) throw new Error('User not found') return { authenticationDataToSave: { - phone: input.phone ?? user?.authentication?.phonePassword?.phone, + phone: normalizedPhone ?? user?.authentication?.phonePassword?.phone, password: input.password ? input.password : user?.authentication?.phonePassword?.password, }, } diff --git a/packages/wabe/src/authentication/providers/QRCodeOTP.ts b/packages/wabe/src/authentication/providers/QRCodeOTP.ts index 214c5671..84c42e1f 100644 --- a/packages/wabe/src/authentication/providers/QRCodeOTP.ts +++ b/packages/wabe/src/authentication/providers/QRCodeOTP.ts @@ -2,6 +2,7 @@ import { contextWithRoot } from '../..' import type { DevWabeTypes } from '../../utils/helper' import type { OnVerifyChallengeOptions, SecondaryProviderInterface } from '../interface' import { OTP } from '../OTP' +import { clearRateLimit, isRateLimited, registerRateLimitFailure } from '../security' const DUMMY_USER_ID = '00000000-0000-0000-0000-000000000000' @@ -19,6 +20,11 @@ export class QRCodeOTP implements SecondaryProviderInterface) { + const normalizedEmail = input.email.trim().toLowerCase() + const rateLimitKey = `qrCodeOtp:${normalizedEmail}` + + if (isRateLimited(context, 'verifyChallenge', rateLimitKey)) return null + const users = await context.wabe.controllers.database.getObjects({ className: 'User', where: { @@ -51,7 +57,12 @@ export class QRCodeOTP implements SecondaryProviderInterface { ) const mockCreateObject = mock(() => Promise.resolve({})) + const mockGetObject = mock(() => Promise.resolve({ pendingChallenges: [] })) + const mockUpdateObject = mock(() => Promise.resolve({})) const mockOnSendChallenge = mock(() => Promise.resolve()) const mockOnVerifyChallenge = mock(() => Promise.resolve(true)) const context = { wabe: { + controllers: { + database: { + getObject: mockGetObject, + updateObject: mockUpdateObject, + }, + }, config: { authentication: { session: { @@ -68,6 +76,8 @@ describe('SignInWith', () => { afterEach(() => { mockCreateObject.mockClear() + mockGetObject.mockClear() + mockUpdateObject.mockClear() mockOnLogin.mockClear() mockOnSignUp.mockClear() }) @@ -121,6 +131,8 @@ describe('SignInWith', () => { expect(res).toEqual({ accessToken: null, refreshToken: null, + srp: null, + challengeToken: expect.any(String), user: { id: 'id', email: 'email@test.fr', @@ -231,6 +243,7 @@ describe('SignInWith', () => { expect(res).toEqual({ accessToken: 'accessToken', refreshToken: 'refreshToken', + challengeToken: null, user: { id: 'id', }, diff --git a/packages/wabe/src/authentication/resolvers/signInWithResolver.ts b/packages/wabe/src/authentication/resolvers/signInWithResolver.ts index f40b37de..4c57c8a8 100644 --- a/packages/wabe/src/authentication/resolvers/signInWithResolver.ts +++ b/packages/wabe/src/authentication/resolvers/signInWithResolver.ts @@ -1,6 +1,8 @@ import type { SignInWithInput } from '../../../generated/wabe' import type { WabeContext } from '../../server/interface' import type { DevWabeTypes } from '../../utils/helper' +import { getSessionCookieSameSite } from '../cookies' +import { createMfaChallenge } from '../security' import { Session } from '../Session' import type { ProviderInterface, SecondaryProviderInterface } from '../interface' import { getAuthenticationMethod } from '../utils' @@ -52,7 +54,18 @@ export const signInWithResolver = async ( user, }) - return { accessToken: null, refreshToken: null, user } + const challengeToken = await createMfaChallenge(context, { + userId, + provider: secondFAObject.provider, + }) + + return { + accessToken: null, + refreshToken: null, + user, + challengeToken, + srp: null, + } } const session = new Session() @@ -62,11 +75,12 @@ export const signInWithResolver = async ( if (context.wabe.config.authentication?.session?.cookieSession) { const accessTokenExpiresAt = session.getAccessTokenExpireAt(context.wabe.config) const refreshTokenExpiresAt = session.getRefreshTokenExpireAt(context.wabe.config) + const sameSite = getSessionCookieSameSite(context.wabe.config) context.response?.setCookie('refreshToken', refreshToken, { httpOnly: true, path: '/', - sameSite: 'Strict', + sameSite, secure: true, expires: refreshTokenExpiresAt, }) @@ -74,7 +88,7 @@ export const signInWithResolver = async ( context.response?.setCookie('accessToken', accessToken, { httpOnly: true, path: '/', - sameSite: 'Strict', + sameSite, secure: true, expires: accessTokenExpiresAt, }) @@ -82,11 +96,11 @@ export const signInWithResolver = async ( context.response?.setCookie('csrfToken', csrfToken, { httpOnly: true, path: '/', - sameSite: 'Strict', + sameSite, secure: true, expires: accessTokenExpiresAt, }) } - return { accessToken, refreshToken, user, srp } + return { accessToken, refreshToken, user, srp, challengeToken: null } } diff --git a/packages/wabe/src/authentication/resolvers/signUpWithResolver.ts b/packages/wabe/src/authentication/resolvers/signUpWithResolver.ts index 59c6b0b1..e4720427 100644 --- a/packages/wabe/src/authentication/resolvers/signUpWithResolver.ts +++ b/packages/wabe/src/authentication/resolvers/signUpWithResolver.ts @@ -1,5 +1,6 @@ import type { SignUpWithInput } from '../../../generated/wabe' import type { WabeContext } from '../../server/interface' +import { getSessionCookieSameSite } from '../cookies' import { Session } from '../Session' // 0 - Get the authentication method @@ -36,10 +37,12 @@ export const signUpWithResolver = async ( const { accessToken, refreshToken, csrfToken } = await session.create(createdUserId, context) if (context.wabe.config.authentication?.session?.cookieSession) { + const sameSite = getSessionCookieSameSite(context.wabe.config) + context.response?.setCookie('refreshToken', refreshToken, { httpOnly: true, path: '/', - sameSite: 'Strict', + sameSite, secure: true, expires: session.getRefreshTokenExpireAt(context.wabe.config), }) @@ -47,7 +50,7 @@ export const signUpWithResolver = async ( context.response?.setCookie('accessToken', accessToken, { httpOnly: true, path: '/', - sameSite: 'Strict', + sameSite, secure: true, expires: session.getAccessTokenExpireAt(context.wabe.config), }) @@ -55,7 +58,7 @@ export const signUpWithResolver = async ( context.response?.setCookie('csrfToken', csrfToken, { httpOnly: true, path: '/', - sameSite: 'Strict', + sameSite, secure: true, expires: session.getAccessTokenExpireAt(context.wabe.config), }) diff --git a/packages/wabe/src/authentication/resolvers/verifyChallenge.test.ts b/packages/wabe/src/authentication/resolvers/verifyChallenge.test.ts index e4236db4..83b3d20b 100644 --- a/packages/wabe/src/authentication/resolvers/verifyChallenge.test.ts +++ b/packages/wabe/src/authentication/resolvers/verifyChallenge.test.ts @@ -2,9 +2,16 @@ import { describe, expect, it, beforeEach, mock, spyOn } from 'bun:test' import { verifyChallengeResolver } from './verifyChallenge' import type { WabeContext } from '../../server/interface' import { Session } from '../Session' +import { createMfaChallenge } from '../security' describe('verifyChallenge', () => { const mockOnVerifyChallenge = mock(() => Promise.resolve(true)) + let pendingChallenges: Array<{ token: string; provider: string; expiresAt: Date }> = [] + const mockGetObject = mock(() => Promise.resolve({ pendingChallenges })) + const mockUpdateObject = mock((options: any) => { + pendingChallenges = options?.data?.pendingChallenges || [] + return Promise.resolve({}) + }) const context: WabeContext = { sessionId: 'sessionId', @@ -12,6 +19,12 @@ describe('verifyChallenge', () => { id: 'userId', } as any, wabe: { + controllers: { + database: { + getObject: mockGetObject, + updateObject: mockUpdateObject, + }, + }, config: { authentication: { customAuthenticationMethods: [ @@ -36,6 +49,9 @@ describe('verifyChallenge', () => { beforeEach(() => { mockOnVerifyChallenge.mockClear() + mockGetObject.mockClear() + mockUpdateObject.mockClear() + pendingChallenges = [] }) it('should throw an error if no one factor is provided', () => { @@ -130,4 +146,85 @@ describe('verifyChallenge', () => { spyCreateSession.mockRestore() }) + + it('should require challenge token in production', () => { + mockOnVerifyChallenge.mockResolvedValue({ userId: 'userId' } as never) + + const productionContext = { + ...context, + wabe: { + ...context.wabe, + config: { + ...context.wabe.config, + isProduction: true, + }, + }, + } as WabeContext + + expect( + verifyChallengeResolver( + undefined, + { + input: { + secondFA: { + // @ts-expect-error + fakeOtp: { + code: '123456', + }, + }, + }, + }, + productionContext, + ), + ).rejects.toThrow('Invalid challenge') + }) + + it('should validate challenge token in production', async () => { + const spyCreateSession = spyOn(Session.prototype, 'create').mockResolvedValue({ + accessToken: 'accessToken', + refreshToken: 'refreshToken', + sessionId: 'sessionId', + csrfToken: 'csrfToken', + }) + mockOnVerifyChallenge.mockResolvedValue({ userId: 'userId' } as never) + + const productionContext = { + ...context, + wabe: { + ...context.wabe, + config: { + ...context.wabe.config, + isProduction: true, + }, + }, + } as WabeContext + + const challengeToken = await createMfaChallenge(productionContext, { + userId: 'userId', + provider: 'fakeOtp', + }) + + expect( + await verifyChallengeResolver( + undefined, + { + input: { + challengeToken, + secondFA: { + // @ts-expect-error + fakeOtp: { + code: '123456', + }, + }, + }, + }, + productionContext, + ), + ).toEqual({ + accessToken: 'accessToken', + srp: undefined, + }) + + spyCreateSession.mockRestore() + }) }) diff --git a/packages/wabe/src/authentication/resolvers/verifyChallenge.ts b/packages/wabe/src/authentication/resolvers/verifyChallenge.ts index 8443d42b..39b5c6ca 100644 --- a/packages/wabe/src/authentication/resolvers/verifyChallenge.ts +++ b/packages/wabe/src/authentication/resolvers/verifyChallenge.ts @@ -1,6 +1,8 @@ import type { VerifyChallengeInput } from '../../../generated/wabe' import type { WabeContext } from '../../server/interface' import type { DevWabeTypes } from '../../utils/helper' +import { getSessionCookieSameSite } from '../cookies' +import { consumeMfaChallenge, shouldRequireMfaChallenge } from '../security' import { Session } from '../Session' import type { SecondaryProviderInterface } from '../interface' import { getAuthenticationMethod } from '../utils' @@ -33,6 +35,19 @@ export const verifyChallengeResolver = async ( if (!result?.userId) throw new Error('Invalid challenge') + if (shouldRequireMfaChallenge(context)) { + const challengeToken = input.challengeToken + if (!challengeToken) throw new Error('Invalid challenge') + + const isValidChallenge = await consumeMfaChallenge(context, { + challengeToken, + userId: result.userId, + provider: name, + }) + + if (!isValidChallenge) throw new Error('Invalid challenge') + } + const session = new Session() const { accessToken, refreshToken } = await session.create(result.userId, context) @@ -40,11 +55,12 @@ export const verifyChallengeResolver = async ( if (context.wabe.config.authentication?.session?.cookieSession) { const accessTokenExpiresAt = session.getAccessTokenExpireAt(context.wabe.config) const refreshTokenExpiresAt = session.getRefreshTokenExpireAt(context.wabe.config) + const sameSite = getSessionCookieSameSite(context.wabe.config) context.response?.setCookie('refreshToken', refreshToken, { httpOnly: true, path: '/', - sameSite: 'None', + sameSite, secure: true, expires: refreshTokenExpiresAt, }) @@ -52,7 +68,7 @@ export const verifyChallengeResolver = async ( context.response?.setCookie('accessToken', accessToken, { httpOnly: true, path: '/', - sameSite: 'None', + sameSite, secure: true, expires: accessTokenExpiresAt, }) diff --git a/packages/wabe/src/authentication/security.ts b/packages/wabe/src/authentication/security.ts new file mode 100644 index 00000000..307efde0 --- /dev/null +++ b/packages/wabe/src/authentication/security.ts @@ -0,0 +1,278 @@ +import crypto from 'node:crypto' +import type { WabeContext } from '../server/interface' +import type { WabeTypes } from '../server' +import { contextWithRoot, getDatabaseController } from '../utils/export' +import { DevWabeTypes } from 'src/utils/helper' + +type RateLimitScope = 'signIn' | 'signUp' | 'verifyChallenge' + +type RateLimitOptions = { + enabled: boolean + maxAttempts: number + windowMs: number + blockDurationMs: number +} + +type RateLimitState = { + attempts: number + windowStartedAt: number + blockedUntil: number +} + +type PendingChallenge = { + token: string + provider: string + expiresAt: number +} + +const DEFAULT_SIGN_IN_RATE_LIMIT = { + maxAttempts: 10, + windowMs: 10 * 60 * 1000, + blockDurationMs: 15 * 60 * 1000, +} + +const DEFAULT_SIGN_UP_RATE_LIMIT = { + maxAttempts: 10, + windowMs: 10 * 60 * 1000, + blockDurationMs: 15 * 60 * 1000, +} + +const DEFAULT_VERIFY_CHALLENGE_RATE_LIMIT = { + maxAttempts: 10, + windowMs: 10 * 60 * 1000, + blockDurationMs: 15 * 60 * 1000, +} + +const DEFAULT_MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000 + +const rateLimitStorage = new Map() + +const getRateLimitOptions = ( + context: WabeContext, + scope: RateLimitScope, +): RateLimitOptions => { + const wabeConfig = context.wabe.config + const securityConfig = wabeConfig?.authentication?.security + const scopeConfigMap = { + signIn: securityConfig?.signInRateLimit, + signUp: securityConfig?.signUpRateLimit, + verifyChallenge: securityConfig?.verifyChallengeRateLimit, + } + const defaultsMap = { + signIn: DEFAULT_SIGN_IN_RATE_LIMIT, + signUp: DEFAULT_SIGN_UP_RATE_LIMIT, + verifyChallenge: DEFAULT_VERIFY_CHALLENGE_RATE_LIMIT, + } + const scopeConfig = scopeConfigMap[scope] + const defaults = defaultsMap[scope] + + return { + enabled: scopeConfig?.enabled ?? !!wabeConfig?.isProduction, + maxAttempts: scopeConfig?.maxAttempts ?? defaults.maxAttempts, + windowMs: scopeConfig?.windowMs ?? defaults.windowMs, + blockDurationMs: scopeConfig?.blockDurationMs ?? defaults.blockDurationMs, + } +} + +const getRateLimitKey = (scope: RateLimitScope, key: string) => + `${scope}:${key.trim().toLowerCase()}` + +export const isRateLimited = ( + context: WabeContext, + scope: RateLimitScope, + key: string, +): boolean => { + const options = getRateLimitOptions(context, scope) + + if (!options.enabled) return false + + const now = Date.now() + const storageKey = getRateLimitKey(scope, key) + const state = rateLimitStorage.get(storageKey) + + if (!state) return false + + if (state.blockedUntil <= now) { + if (state.windowStartedAt + options.windowMs <= now) { + rateLimitStorage.delete(storageKey) + } + return false + } + + return true +} + +export const registerRateLimitFailure = ( + context: WabeContext, + scope: RateLimitScope, + key: string, +) => { + const options = getRateLimitOptions(context, scope) + + if (!options.enabled) return + + const now = Date.now() + const storageKey = getRateLimitKey(scope, key) + const currentState = rateLimitStorage.get(storageKey) + const hasExpiredBlock = + !!currentState && currentState.blockedUntil > 0 && currentState.blockedUntil <= now + const shouldResetWindow = + !currentState || currentState.windowStartedAt + options.windowMs <= now || hasExpiredBlock + + const state: RateLimitState = shouldResetWindow + ? { + attempts: 0, + windowStartedAt: now, + blockedUntil: 0, + } + : currentState + + state.attempts += 1 + + if (state.attempts >= options.maxAttempts) { + state.attempts = 0 + state.windowStartedAt = now + state.blockedUntil = now + options.blockDurationMs + } + + rateLimitStorage.set(storageKey, state) +} + +export const clearRateLimit = ( + context: WabeContext, + scope: RateLimitScope, + key: string, +) => { + const options = getRateLimitOptions(context, scope) + + if (!options.enabled) return + + rateLimitStorage.delete(getRateLimitKey(scope, key)) +} + +const getMfaChallengeTTL = (context: WabeContext) => + context.wabe.config?.authentication?.security?.mfaChallengeTtlMs || DEFAULT_MFA_CHALLENGE_TTL_MS + +const parsePendingChallenges = (input: unknown): PendingChallenge[] => { + if (!Array.isArray(input)) return [] + + return input.reduce((acc, item) => { + if (!item || typeof item !== 'object') return acc + const challenge = item as Record + const token = typeof challenge.token === 'string' ? challenge.token : null + const provider = typeof challenge.provider === 'string' ? challenge.provider : null + const expiresAtRaw = challenge.expiresAt + const expiresAt = new Date(expiresAtRaw as string | Date).getTime() + + if (!token || !provider || Number.isNaN(expiresAt)) return acc + + acc.push({ + token, + provider: provider.toLowerCase(), + expiresAt, + }) + return acc + }, [] as PendingChallenge[]) +} + +const pruneExpiredChallenges = (challenges: PendingChallenge[]) => { + const now = Date.now() + return challenges.filter((challenge) => challenge.expiresAt > now) +} + +const getUserPendingChallenges = async ( + context: WabeContext, + userId: string, +): Promise => { + try { + const user = await getDatabaseController(context).getObject({ + className: 'User', + id: userId, + context: contextWithRoot(context), + select: { + pendingChallenges: true, + }, + }) + + return parsePendingChallenges(user?.pendingChallenges) + } catch { + return null + } +} + +const saveUserPendingChallenges = async ( + context: WabeContext, + userId: string, + challenges: PendingChallenge[], +) => + getDatabaseController(context).updateObject({ + className: 'User', + id: userId, + context: contextWithRoot(context), + data: { + pendingChallenges: challenges.map((challenge) => ({ + token: challenge.token, + provider: challenge.provider, + expiresAt: new Date(challenge.expiresAt), + })), + }, + select: {}, + }) + +export const createMfaChallenge = async ( + context: WabeContext, + { userId, provider }: { userId: string; provider: string }, +): Promise => { + const token = crypto.randomUUID() + const expiresAt = Date.now() + getMfaChallengeTTL(context) + + const currentChallenges = (await getUserPendingChallenges(context, userId)) || [] + const nextChallenges = [ + ...pruneExpiredChallenges(currentChallenges), + { + token, + provider: provider.toLowerCase(), + expiresAt, + }, + ] + + await saveUserPendingChallenges(context, userId, nextChallenges) + + return token +} + +export const consumeMfaChallenge = async ( + context: WabeContext, + { + challengeToken, + userId, + provider, + }: { + challengeToken: string + userId: string + provider: string + }, +): Promise => { + const currentChallenges = await getUserPendingChallenges(context, userId) + if (!currentChallenges) return false + + const activeChallenges = pruneExpiredChallenges(currentChallenges) + const normalizedProvider = provider.toLowerCase() + const isValid = activeChallenges.some( + (challenge) => challenge.token === challengeToken && challenge.provider === normalizedProvider, + ) + const remainingChallenges = activeChallenges.filter( + (challenge) => + !(challenge.token === challengeToken && challenge.provider === normalizedProvider), + ) + + if (remainingChallenges.length !== currentChallenges.length || isValid) { + await saveUserPendingChallenges(context, userId, remainingChallenges) + } + + return isValid +} + +export const shouldRequireMfaChallenge = (context: WabeContext) => + context.wabe.config?.authentication?.security?.requireMfaChallengeInProduction ?? + !!context.wabe.config?.isProduction diff --git a/packages/wabe/src/file/hookUploadFile.ts b/packages/wabe/src/file/hookUploadFile.ts index ed5a0bee..ea855473 100644 --- a/packages/wabe/src/file/hookUploadFile.ts +++ b/packages/wabe/src/file/hookUploadFile.ts @@ -1,4 +1,5 @@ import type { HookObject } from '../hooks/HookObject' +import { secureUploadedFile } from './security' const handleFile = async (hookObject: HookObject) => { const newData = hookObject.getNewData() @@ -30,13 +31,14 @@ const handleFile = async (hookObject: HookObject) => { if (!hookObject.context.wabe.controllers.file) throw new Error('No file adapter found') - const fileToUpload = (await beforeUpload?.(file, hookObject.context)) || file + const fileFromBeforeUpload = (await beforeUpload?.(file, hookObject.context)) || file + const fileToUpload = await secureUploadedFile(fileFromBeforeUpload, hookObject.context) // We upload the file and set the name of the file in the newData await hookObject.context.wabe.controllers.file?.uploadFile(fileToUpload) hookObject.upsertNewData(keyName, { - name: file.name, + name: fileToUpload.name, isPresignedUrl: true, }) }), diff --git a/packages/wabe/src/file/index.test.ts b/packages/wabe/src/file/index.test.ts index 75903bef..a52a85e6 100644 --- a/packages/wabe/src/file/index.test.ts +++ b/packages/wabe/src/file/index.test.ts @@ -930,3 +930,102 @@ describe('File upload', () => { expect(await res.text()).toEqual('this is the content') }) }) + +describe('File upload security in production', () => { + let wabe: Wabe + + beforeAll(async () => { + const setup = await setupTests( + [ + { + name: 'TestSecurityFile', + fields: { + file: { type: 'File' }, + }, + permissions: { + read: { requireAuthentication: false }, + create: { requireAuthentication: false }, + update: { requireAuthentication: false }, + delete: { requireAuthentication: false }, + }, + }, + ], + { isProduction: true }, + ) + + wabe = setup.wabe + }) + + afterAll(async () => { + await closeTests(wabe) + }) + + afterEach(async () => { + await wabe.controllers.database.deleteObjects({ + // @ts-expect-error + className: 'TestSecurityFile', + context: { + isRoot: true, + wabe, + }, + where: {}, + select: {}, + }) + }) + + it('should randomize uploaded file name in production', async () => { + await wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'TestSecurityFile', + context: { + isRoot: true, + wabe, + }, + data: { + // @ts-expect-error + file: { + file: new File(['hello'], 'report.txt', { type: 'text/plain' }), + }, + }, + select: {}, + }) + + const result = await wabe.controllers.database.getObjects({ + // @ts-expect-error + className: 'TestSecurityFile', + context: { + isRoot: true, + wabe, + }, + where: {}, + // @ts-expect-error + select: { file: true, id: true }, + }) + + const storedName = (result[0] as any)?.file?.name as string + + expect(storedName).toBeString() + expect(storedName).not.toEqual('report.txt') + expect(storedName.endsWith('.txt')).toBe(true) + }) + + it('should reject file type not allowed in production', async () => { + expect( + wabe.controllers.database.createObject({ + // @ts-expect-error + className: 'TestSecurityFile', + context: { + isRoot: true, + wabe, + }, + data: { + // @ts-expect-error + file: { + file: new File(['alert(1)'], 'script.js', { type: 'application/javascript' }), + }, + }, + select: {}, + }), + ).rejects.toThrow('File extension is not allowed') + }) +}) diff --git a/packages/wabe/src/file/interface.ts b/packages/wabe/src/file/interface.ts index 313d8dc2..9e7f4c31 100644 --- a/packages/wabe/src/file/interface.ts +++ b/packages/wabe/src/file/interface.ts @@ -1,5 +1,28 @@ import type { WabeContext, WabeTypes } from 'src/server' +export type FileUploadSecurityConfig = { + /** + * Enable upload validation rules. Enabled by default in production. + */ + enabled?: boolean + /** + * Maximum allowed file size in bytes. + */ + maxFileSizeBytes?: number + /** + * Allowlist of MIME types accepted by uploads. + */ + allowedMimeTypes?: string[] + /** + * Allowlist of file extensions accepted by uploads (without dot). + */ + allowedExtensions?: string[] + /** + * Randomize uploaded file names (enabled by default in production). + */ + randomizeFileName?: boolean +} + /** * The file config contains the adapter to use to upload file * @param adapter: FileAdapter @@ -11,6 +34,7 @@ export type FileConfig = { urlCacheInSeconds?: number devDirectory?: string beforeUpload?: (file: File, context: WabeContext) => Promise | File + security?: FileUploadSecurityConfig } export interface ReadFileOptions { diff --git a/packages/wabe/src/file/security.ts b/packages/wabe/src/file/security.ts new file mode 100644 index 00000000..4911fbf6 --- /dev/null +++ b/packages/wabe/src/file/security.ts @@ -0,0 +1,156 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import type { WabeContext, WabeTypes } from 'src/server' +import type { FileUploadSecurityConfig } from './interface' + +const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 + +const DEFAULT_ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/json', + 'text/csv', +] + +const DEFAULT_ALLOWED_EXTENSIONS = [ + 'jpg', + 'jpeg', + 'png', + 'gif', + 'webp', + 'pdf', + 'txt', + 'json', + 'csv', +] + +const MIME_SIGNATURES: Array<{ + mimeType: string + bytes: number[] + offset?: number +}> = [ + { + mimeType: 'image/png', + bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], + }, + { mimeType: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] }, + { mimeType: 'image/gif', bytes: [0x47, 0x49, 0x46, 0x38] }, + { mimeType: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46] }, + { mimeType: 'application/pdf', bytes: [0x25, 0x50, 0x44, 0x46, 0x2d] }, +] + +const MIME_TO_EXTENSIONS: Record = { + 'image/jpeg': ['jpg', 'jpeg'], + 'image/png': ['png'], + 'image/gif': ['gif'], + 'image/webp': ['webp'], + 'application/pdf': ['pdf'], + 'text/plain': ['txt'], + 'application/json': ['json'], + 'text/csv': ['csv'], +} + +const normalizeMimeType = (mimeType: string) => + mimeType.trim().toLowerCase().split(';')[0]?.trim() || '' + +const normalizeExtension = (fileName: string) => + path.extname(fileName).replace('.', '').trim().toLowerCase() + +const hasSignature = (fileHeader: Uint8Array, bytes: number[], offset = 0) => + bytes.every((value, index) => fileHeader[offset + index] === value) + +const detectMimeTypeFromContent = async (file: File) => { + const header = new Uint8Array(await file.slice(0, 16).arrayBuffer()) + + for (const signature of MIME_SIGNATURES) { + if (hasSignature(header, signature.bytes, signature.offset)) { + if ( + signature.mimeType === 'image/webp' && + !(header[8] === 0x57 && header[9] === 0x45 && header[10] === 0x42 && header[11] === 0x50) + ) { + continue + } + + return signature.mimeType + } + } + + return null +} + +const getUploadSecurityConfig = (context: WabeContext) => { + const security = context.wabe.config.file?.security + const enabled = security?.enabled ?? context.wabe.config.isProduction + const maxFileSizeBytes = security?.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES + const allowedMimeTypes = (security?.allowedMimeTypes || DEFAULT_ALLOWED_MIME_TYPES).map( + normalizeMimeType, + ) + const allowedExtensions = (security?.allowedExtensions || DEFAULT_ALLOWED_EXTENSIONS).map( + (value) => value.trim().toLowerCase(), + ) + const randomizeFileName = security?.randomizeFileName ?? context.wabe.config.isProduction + + return { + enabled, + maxFileSizeBytes, + allowedMimeTypes, + allowedExtensions, + randomizeFileName, + } +} + +const createRandomizedFile = async (file: File, extension: string) => { + const uniqueName = `${crypto.randomUUID()}.${extension}` + const content = await file.arrayBuffer() + + return new File([content], uniqueName, { + type: file.type, + lastModified: Date.now(), + }) +} + +export const secureUploadedFile = async ( + file: File, + context: WabeContext, +): Promise => { + const { enabled, maxFileSizeBytes, allowedMimeTypes, allowedExtensions, randomizeFileName } = + getUploadSecurityConfig(context) + + if (!enabled) return file + + if (file.size > maxFileSizeBytes) throw new Error('File exceeds maximum allowed size') + + const extension = normalizeExtension(file.name) + if (!extension || !allowedExtensions.includes(extension)) + throw new Error('File extension is not allowed') + + const mimeType = normalizeMimeType(file.type || '') + if (!mimeType || !allowedMimeTypes.includes(mimeType)) + throw new Error('File MIME type is not allowed') + + const detectedMimeType = await detectMimeTypeFromContent(file) + + if (detectedMimeType && detectedMimeType !== mimeType) + throw new Error('File content does not match MIME type') + + if (detectedMimeType && !allowedMimeTypes.includes(detectedMimeType)) + throw new Error('File content type is not allowed') + + const allowedExtensionsForMime = MIME_TO_EXTENSIONS[mimeType] + if (allowedExtensionsForMime && !allowedExtensionsForMime.includes(extension)) + throw new Error('File extension does not match MIME type') + + if (!randomizeFileName) return file + + return createRandomizedFile(file, extension) +} + +export const getUploadSecurityConfigForTests = ( + context: WabeContext, +): ReturnType => getUploadSecurityConfig(context) + +export type { FileUploadSecurityConfig } diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 037b7ad6..85c9d362 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -375,6 +375,9 @@ export class Schema { type: 'Pointer', class: 'User', }, + challengeToken: { + type: 'String', + }, accessToken: { type: 'String', }, @@ -485,6 +488,9 @@ export class Schema { }, args: { input: { + challengeToken: { + type: 'String', + }, secondFA: secondaryFactorAuthenticationInputObject, }, }, @@ -672,6 +678,31 @@ export class Schema { }, }, }, + pendingChallenges: { + type: 'Array', + typeValue: 'Object', + object: { + name: 'PendingAuthenticationChallenge', + fields: { + token: { + type: 'String', + required: true, + }, + provider: { + type: 'String', + required: true, + }, + expiresAt: { + type: 'Date', + required: true, + }, + }, + }, + protected: { + authorizedRoles: ['rootOnly'], + protectedOperations: ['create', 'read', 'update'], + }, + }, } return { diff --git a/packages/wabe/src/server/defaultSessionHandler.ts b/packages/wabe/src/server/defaultSessionHandler.ts index 48c23835..48a0a8e7 100644 --- a/packages/wabe/src/server/defaultSessionHandler.ts +++ b/packages/wabe/src/server/defaultSessionHandler.ts @@ -1,4 +1,5 @@ import type { Wabe, WobeCustomContext } from '.' +import { getSessionCookieSameSite } from '../authentication/cookies' import { Session } from '../authentication/Session' import { getCookieInRequestHeaders, isValidRootKey } from '../utils' import type { DevWabeTypes } from '../utils/helper' @@ -82,11 +83,13 @@ export const defaultSessionHandler = newRefreshToken && newAccessToken !== accessToken ) { + const sameSite = getSessionCookieSameSite(wabe.config) + ctx.res.setCookie('accessToken', newAccessToken, { httpOnly: true, path: '/', expires: session.getAccessTokenExpireAt(wabe.config), - sameSite: 'None', + sameSite, secure: true, }) @@ -94,7 +97,7 @@ export const defaultSessionHandler = httpOnly: true, path: '/', expires: session.getRefreshTokenExpireAt(wabe.config), - sameSite: 'None', + sameSite, secure: true, }) } diff --git a/packages/wabe/src/server/index.test.ts b/packages/wabe/src/server/index.test.ts index f6e477a3..5b228c22 100644 --- a/packages/wabe/src/server/index.test.ts +++ b/packages/wabe/src/server/index.test.ts @@ -228,6 +228,38 @@ describe('Server', () => { await wabe.close() }) + it('should disable /bucket route in production by default', async () => { + const databaseId = uuid() + const port = await getPort() + const wabe = new Wabe({ + isProduction: true, + rootKey: 'eIUbb9abFa8PJGRfRwgiGSCU0fGnLErph2QYjigDRjLsbyNA3fZJ8Npd0FJNzxAc', + database: { + // @ts-expect-error + adapter: await getDatabaseAdapter(databaseId), + }, + port, + security: { + disableCSRFProtection: true, + }, + schema: { + classes: [ + { + name: 'Collection1', + fields: { name: { type: 'String' } }, + }, + ], + }, + }) + + await wabe.start() + + const res = await fetch(`http://127.0.0.1:${port}/bucket/test.txt`) + expect(res.status).toBe(404) + + await wabe.close() + }) + it('should setup the root key in context if the root key is correct', async () => { const databaseId = uuid() diff --git a/packages/wabe/src/server/index.ts b/packages/wabe/src/server/index.ts index c7f718ce..f89ecb52 100644 --- a/packages/wabe/src/server/index.ts +++ b/packages/wabe/src/server/index.ts @@ -181,8 +181,13 @@ export class Wabe { } loadRoutes() { + const enableBucketRoute = !this.config.isProduction + const wabeRoutes = [ - ...defaultRoutes(this.config.file?.devDirectory || `${__dirname}/../../bucket`), + ...defaultRoutes({ + devDirectory: this.config.file?.devDirectory || `${__dirname}/../../bucket`, + enableBucketRoute, + }), ...(this.config.routes || []), ] diff --git a/packages/wabe/src/server/routes/authHandler.ts b/packages/wabe/src/server/routes/authHandler.ts index 1f82e787..05b4fa20 100644 --- a/packages/wabe/src/server/routes/authHandler.ts +++ b/packages/wabe/src/server/routes/authHandler.ts @@ -4,6 +4,7 @@ import { ProviderEnum } from '../../authentication/interface' import { getGraphqlClient } from '../../utils/helper' import { gql } from 'graphql-request' import { Google } from '../../authentication/oauth' +import { getSessionCookieSameSite } from '../../authentication/cookies' import { generateRandomValues } from '../../authentication/oauth/utils' import { GitHub } from '../../authentication/oauth/GitHub' @@ -61,6 +62,7 @@ export const oauthHandlerCallback = async (context: Context, wabeContext: WabeCo const { accessToken, refreshToken } = signInWith const isCookieSession = !!wabeContext.wabe.config.authentication?.session?.cookieSession + const sameSite = getSessionCookieSameSite(wabeContext.wabe.config) context.res.setCookie('accessToken', accessToken, { // If cookie session we put httpOnly to true, otherwise the front will need to get it @@ -70,7 +72,7 @@ export const oauthHandlerCallback = async (context: Context, wabeContext: WabeCo maxAge: (wabeContext.wabe.config.authentication?.session?.accessTokenExpiresInMs || 60 * 15 * 1000) / 1000, // 15 minutes in seconds - sameSite: 'None', + sameSite, secure: true, }) @@ -82,7 +84,7 @@ export const oauthHandlerCallback = async (context: Context, wabeContext: WabeCo maxAge: (wabeContext.wabe.config.authentication?.session?.accessTokenExpiresInMs || 60 * 15 * 1000) / 1000, // 15 minutes in seconds - sameSite: 'None', + sameSite, secure: true, }) diff --git a/packages/wabe/src/server/routes/index.ts b/packages/wabe/src/server/routes/index.ts index 3dd4d632..44b0806d 100644 --- a/packages/wabe/src/server/routes/index.ts +++ b/packages/wabe/src/server/routes/index.ts @@ -9,7 +9,13 @@ export interface WabeRoute { handler: WobeHandler> } -export const defaultRoutes = (devDirectory: string): WabeRoute[] => { +export const defaultRoutes = ({ + devDirectory, + enableBucketRoute, +}: { + devDirectory: string + enableBucketRoute: boolean +}): WabeRoute[] => { const routes: WabeRoute[] = [ { method: 'GET', @@ -28,12 +34,15 @@ export const defaultRoutes = (devDirectory: string): WabeRoute[] => { path: '/auth/oauth/callback', handler: (context) => oauthHandlerCallback(context, context.wabe), }, - { + ] + + if (enableBucketRoute) { + routes.push({ method: 'GET', path: '/bucket/:filename', handler: uploadDirectory({ directory: devDirectory }), - }, - ] + }) + } return routes } diff --git a/packages/wabe/src/utils/database.ts b/packages/wabe/src/utils/database.ts new file mode 100644 index 00000000..e888dca2 --- /dev/null +++ b/packages/wabe/src/utils/database.ts @@ -0,0 +1,8 @@ +import type { WabeTypes } from '../server' +import type { WabeContext } from '../server/interface' + +export const getDatabaseController = (context: WabeContext) => { + const databaseController = context.wabe.controllers?.database + if (!databaseController) throw new Error('No database controller found') + return databaseController +} diff --git a/packages/wabe/src/utils/export.ts b/packages/wabe/src/utils/export.ts index af6283f3..664e6314 100644 --- a/packages/wabe/src/utils/export.ts +++ b/packages/wabe/src/utils/export.ts @@ -9,3 +9,4 @@ export const notEmpty = (value: T | null | undefined): value is T => value !== null && value !== undefined export * from './crypto' +export * from './database'