Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/wabe/generated/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ type User {
role: Role
sessions: _SessionConnection
secondFA: UserSecondFA
pendingChallenges: [UserPendingAuthenticationChallenge]
}

"""
Expand Down Expand Up @@ -130,6 +131,12 @@ type UserSecondFA {
provider: SecondaryFactor!
}

type UserPendingAuthenticationChallenge {
token: String!
provider: String!
expiresAt: Date!
}

"""
User class
"""
Expand All @@ -148,6 +155,7 @@ input UserInput {
role: RolePointerInput
sessions: _SessionRelationInput
secondFA: UserSecondFAInput
pendingChallenges: [UserPendingAuthenticationChallengeInput]
}

input UserACLObjectInput {
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -235,6 +249,7 @@ input UserCreateFieldsInput {
role: RolePointerInput
sessions: _SessionRelationInput
secondFA: UserSecondFACreateFieldsInput
pendingChallenges: [UserPendingAuthenticationChallengeCreateFieldsInput]
}

input UserACLObjectCreateFieldsInput {
Expand Down Expand Up @@ -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
"""
Expand Down Expand Up @@ -802,6 +823,7 @@ input UserWhereInput {
role: RoleWhereInput
sessions: _SessionRelationWhereInput
secondFA: UserSecondFAWhereInput
pendingChallenges: [UserPendingAuthenticationChallengeWhereInput]
OR: [UserWhereInput]
AND: [UserWhereInput]
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1085,6 +1115,8 @@ enum UserOrder {
sessions_DESC
secondFA_ASC
secondFA_DESC
pendingChallenges_ASC
pendingChallenges_DESC
}

type PostConnection {
Expand Down Expand Up @@ -1410,6 +1442,7 @@ input UserUpdateFieldsInput {
role: RolePointerInput
sessions: _SessionRelationInput
secondFA: UserSecondFAUpdateFieldsInput
pendingChallenges: [UserPendingAuthenticationChallengeUpdateFieldsInput]
}

input UserACLObjectUpdateFieldsInput {
Expand Down Expand Up @@ -1470,6 +1503,12 @@ input UserSecondFAUpdateFieldsInput {
provider: SecondaryFactor
}

input UserPendingAuthenticationChallengeUpdateFieldsInput {
token: String
provider: String
expiresAt: Date
}

input UpdateUsersInput {
fields: UserUpdateFieldsInput
where: UserWhereInput
Expand Down Expand Up @@ -1842,6 +1881,7 @@ input SendEmailInput {

type SignInWithOutput {
user: User
challengeToken: String
accessToken: String
refreshToken: String
srp: SignInWithOutputSRPOutputSignInWith
Expand Down Expand Up @@ -1956,6 +1996,7 @@ type VerifyChallengeOutputSRPOutputVerifyChallenge {
}

input VerifyChallengeInput {
challengeToken: String
secondFA: VerifyChallengeSecondaryFactorAuthenticationInput
}

Expand Down
9 changes: 9 additions & 0 deletions packages/wabe/generated/wabe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -90,6 +96,7 @@ export type User = {
role?: Role
sessions?: Array<_Session>
secondFA?: SecondFA
pendingChallenges?: Array<PendingAuthenticationChallenge>
}

export type Experience = {
Expand Down Expand Up @@ -163,6 +170,7 @@ export type WhereUser = {
role?: Role
sessions?: Array<_Session>
secondFA?: SecondFA
pendingChallenges?: Array<PendingAuthenticationChallenge>
}

export type WherePost = {
Expand Down Expand Up @@ -375,6 +383,7 @@ export type MutationRefreshArgs = {
}

export type VerifyChallengeInput = {
challengeToken?: string
secondFA?: VerifyChallengeSecondFA
}

Expand Down
10 changes: 10 additions & 0 deletions packages/wabe/src/authentication/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { WabeConfig, WabeTypes } from '../server'

export const getSessionCookieSameSite = <T extends WabeTypes>(config: WabeConfig<T>) => {
const frontDomain = config.authentication?.frontDomain
const backDomain = config.authentication?.backDomain

if (frontDomain && backDomain && frontDomain !== backDomain) return 'None'

return 'Strict'
}
22 changes: 22 additions & 0 deletions packages/wabe/src/authentication/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,27 @@ export interface SessionConfig<T extends WabeTypes> {
jwtTokenFields?: SelectType<T, 'User', keyof T['types']['User']>
}

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<T extends WabeTypes> {
session?: SessionConfig<T>
roles?: RoleConfig
Expand All @@ -127,6 +148,7 @@ export interface AuthenticationConfig<T extends WabeTypes> {
customAuthenticationMethods?: CustomAuthenticationMethods<T>[]
sessionHandler?: (context: WobeCustomContext<T>) => void | Promise<void>
disableSignUp?: boolean
security?: AuthenticationSecurityConfig
}

export interface CreateTokenFromAuthorizationCodeOptions {
Expand Down
13 changes: 12 additions & 1 deletion packages/wabe/src/authentication/providers/EmailOTP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -45,6 +46,11 @@ export class EmailOTP implements SecondaryProviderInterface<DevWabeTypes, EmailO
context,
input,
}: OnVerifyChallengeOptions<DevWabeTypes, EmailOTPInterface>) {
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: {
Expand Down Expand Up @@ -77,7 +83,12 @@ export class EmailOTP implements SecondaryProviderInterface<DevWabeTypes, EmailO

const isOtpValid = otpClass.verify(input.otp, userId)

if (realUser && (isOtpValid || isDevBypass)) return { userId: realUser.id }
if (realUser && (isOtpValid || isDevBypass)) {
clearRateLimit(context, 'verifyChallenge', rateLimitKey)
return { userId: realUser.id }
}

registerRateLimitFailure(context, 'verifyChallenge', rateLimitKey)

return null
}
Expand Down
87 changes: 87 additions & 0 deletions packages/wabe/src/authentication/providers/EmailPassword.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('Email password', () => {

afterEach(() => {
mockGetObjects.mockClear()
mockCount.mockClear()
mockCreateObject.mockClear()
spyArgonPasswordVerify.mockClear()
spyBunPasswordHash.mockClear()
Expand Down Expand Up @@ -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([])

Expand Down
Loading