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
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export async function anonUserController(app: FastifyInstance) {
email: anonEmail,
status: 'confirmed',
createdAt: now,
lastLoginAt: now,
custom_data: {},
identities: [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,15 +128,20 @@ export async function customFunctionController(app: FastifyInstance) {
...(user || {})
}
}
const now = new Date()
const refreshToken = this.createRefreshToken(currentUserData)
const refreshTokenHash = hashToken(refreshToken)
await authDb.collection(refreshTokensCollection).insertOne({
userId: authUser._id,
tokenHash: refreshTokenHash,
createdAt: new Date(),
createdAt: now,
expiresAt: new Date(Date.now() + refreshTokenTtlMs),
revokedAt: null
})
await authDb.collection(authCollection!).updateOne(
{ _id: authUser._id },
{ $set: { lastLoginAt: now } }
)
return {
access_token: this.createAccessToken(currentUserData),
refresh_token: refreshToken,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -283,15 +283,20 @@ export async function localUserPassController(app: FastifyInstance) {
throw new Error(AUTH_ERRORS.USER_NOT_CONFIRMED)
}

const now = new Date()
const refreshToken = this.createRefreshToken(userWithCustomData)
const refreshTokenHash = hashToken(refreshToken)
await authDb.collection(refreshTokensCollection).insertOne({
userId: authUser._id,
tokenHash: refreshTokenHash,
createdAt: new Date(),
createdAt: now,
expiresAt: new Date(Date.now() + refreshTokenTtlMs),
revokedAt: null
})
await authDb.collection(authCollection!).updateOne(
{ _id: authUser._id },
{ $set: { lastLoginAt: now } }
)

return {
access_token: this.createAccessToken(userWithCustomData),
Expand Down
2 changes: 1 addition & 1 deletion packages/flowerbase/src/features/triggers/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ type Config = {
isAutoTrigger?: boolean
match: Record<string, unknown>
operation_types: string[]
operation_type?: 'CREATE' | 'DELETE' | 'LOGOUT'
operation_type?: 'CREATE' | 'DELETE' | 'LOGIN' | 'LOGOUT'
providers?: string[]
project: Record<string, unknown>
service_name: string
Expand Down
60 changes: 60 additions & 0 deletions packages/flowerbase/src/features/triggers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ const handleCronTrigger = async ({
const mapOpInverse = {
CREATE: ['insert', 'update', 'replace'],
DELETE: ['delete'],
LOGIN: ['insert', 'update'],
LOGOUT: ['update'],
}

Expand Down Expand Up @@ -306,6 +307,8 @@ const handleAuthenticationTrigger = async ({
const isUpdate = operationType === 'update'
const isReplace = operationType === 'replace'
const isDelete = operationType === 'delete'
const isLoginInsert = isInsert && !!(fullDocument as Record<string, unknown> | null)?.lastLoginAt
const isLoginUpdate = isUpdate && !!updatedFields && 'lastLoginAt' in updatedFields
const isLogoutUpdate = isUpdate && !!updatedFields && 'lastLogoutAt' in updatedFields

let confirmedCandidate = false
Expand Down Expand Up @@ -397,6 +400,63 @@ const handleAuthenticationTrigger = async ({
return
}

if (operation_type === 'LOGIN') {
if (!isLoginInsert && !isLoginUpdate) {
return
}
let loginDocument = fullDocument ?? confirmedDocument
if (!loginDocument && documentKey?._id) {
loginDocument = await collection.findOne({
_id: documentKey._id
}) as Record<string, unknown> | null
}
if (!matchesProviderFilter(loginDocument, providerFilter)) {
return
}
const userData = buildUserData(loginDocument)
if (!userData) {
return
}
const op = {
operationType: 'LOGIN',
fullDocument,
fullDocumentBeforeChange,
documentKey,
updateDescription
}
try {
emitTriggerEvent({
status: 'fired',
triggerName,
triggerType,
functionName,
meta: { ...baseMeta, event: 'LOGIN' }
})
await GenerateContext({
args: [{ user: userData, ...op }],
app,
rules: StateManager.select("rules"),
user: {}, // TODO from currentUser ??
currentFunction: triggerHandler,
functionName,
functionsList,
services,
runAsSystem: true
})
} catch (error) {
emitTriggerEvent({
status: 'error',
triggerName,
triggerType,
functionName,
meta: { ...baseMeta, event: 'LOGIN' },
error
})
console.log("🚀 ~ handleAuthenticationTrigger ~ error:", error)
}
return
}

if (isDelete) {
if (isAutoTrigger || operation_type !== 'DELETE') {
return
Expand Down
5 changes: 5 additions & 0 deletions tests/e2e/app/functions/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
"private": true,
"run_as_system": true
},
{
"name": "onLoginUser",
"private": true,
"run_as_system": true
},
{
"name": "onDatabaseEvent",
"private": true,
Expand Down
13 changes: 13 additions & 0 deletions tests/e2e/app/functions/onLoginUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module.exports = async function (payload) {
const user = payload?.user ?? payload
const mongoService = context.services.get('mongodb-atlas')
const collection = mongoService.db('flowerbase-e2e').collection('triggerEvents')
const documentId = user?.id?.toString() ?? user?.data?._id?.toString() ?? 'unknown'
await collection.insertOne({
documentId,
type: 'on_user_login',
email: user?.email ?? user?.data?.email ?? null,
createdAt: new Date().toISOString()
})
return { recorded: true, documentId }
}
26 changes: 26 additions & 0 deletions tests/e2e/app/triggers/authLoginTrigger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "authLoginTrigger",
"type": "AUTHENTICATION",
"disabled": false,
"config": {
"collection": "auth_users",
"database": "flowerbase-e2e",
"full_document": true,
"full_document_before_change": false,
"match": {},
"operation_type": "LOGIN",
"project": {},
"service_name": "mongodb-atlas",
"skip_catchup_events": false,
"tolerate_resume_errors": false,
"unordered": false,
"schedule": ""
},
"event_processors": {
"FUNCTION": {
"config": {
"function_name": "onLoginUser"
}
}
}
}
27 changes: 27 additions & 0 deletions tests/e2e/app/triggers/authProviderLoginAnonTrigger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "authProviderLoginAnonTrigger",
"type": "AUTHENTICATION",
"disabled": false,
"config": {
"collection": "auth_users",
"database": "flowerbase-e2e",
"full_document": true,
"full_document_before_change": false,
"match": {},
"operation_type": "LOGIN",
"providers": ["anon-user"],
"project": {},
"service_name": "mongodb-atlas",
"skip_catchup_events": false,
"tolerate_resume_errors": false,
"unordered": false,
"schedule": ""
},
"event_processors": {
"FUNCTION": {
"config": {
"function_name": "logAuthProviderTrigger"
}
}
}
}
27 changes: 27 additions & 0 deletions tests/e2e/app/triggers/authProviderLoginLocalTrigger.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"name": "authProviderLoginLocalTrigger",
"type": "AUTHENTICATION",
"disabled": false,
"config": {
"collection": "auth_users",
"database": "flowerbase-e2e",
"full_document": true,
"full_document_before_change": false,
"match": {},
"operation_type": "LOGIN",
"providers": ["local-userpass"],
"project": {},
"service_name": "mongodb-atlas",
"skip_catchup_events": false,
"tolerate_resume_errors": false,
"unordered": false,
"schedule": ""
},
"event_processors": {
"FUNCTION": {
"config": {
"function_name": "logAuthProviderTrigger"
}
}
}
}
101 changes: 99 additions & 2 deletions tests/e2e/mongodb-atlas.rules.e2e.functions-and-auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,7 +1028,10 @@ describe('MongoDB Atlas rule enforcement (e2e)', () => {
const registrationBody = registration.json() as { userId?: string }
expect(registrationBody.userId).toBeDefined()

const creationEvent = await waitForTriggerEvent(registrationBody.userId!)
const creationEvent = await waitForTriggerEventType(
registrationBody.userId!,
'on_user_creation'
)
expect(creationEvent).toBeDefined()
expect(creationEvent?.type).toBe('on_user_creation')
expect(creationEvent?.email).toBe('autoconfirm-trigger@example.com')
Expand Down Expand Up @@ -1058,7 +1061,10 @@ describe('MongoDB Atlas rule enforcement (e2e)', () => {
const loginBody = login.json() as { user_id?: string }
expect(loginBody.user_id).toBeDefined()

const creationEvent = await waitForTriggerEvent(loginBody.user_id!)
const creationEvent = await waitForTriggerEventType(
loginBody.user_id!,
'on_user_creation'
)
expect(creationEvent).toBeDefined()
expect(creationEvent?.type).toBe('on_user_creation')
expect(creationEvent?.email).toBe('trigger-user@example.com')
Expand Down Expand Up @@ -1177,6 +1183,39 @@ describe('MongoDB Atlas rule enforcement (e2e)', () => {
expect(logoutEvent?.email).toBe(email)
})

it('fires login trigger when auth user logs in', async () => {
const email = 'login-trigger@example.com'
const password = 'login-pass'
const registration = await appInstance!.inject({
method: 'POST',
url: `${AUTH_BASE_URL}/register`,
payload: {
email,
password
}
})
expect(registration.statusCode).toBe(201)

const login = await appInstance!.inject({
method: 'POST',
url: `${AUTH_BASE_URL}/login`,
payload: {
username: email,
password
}
})
expect(login.statusCode).toBe(200)
const loginBody = login.json() as { user_id?: string }
expect(loginBody.user_id).toBeDefined()

const loginEvent = await waitForTriggerEventType(
loginBody.user_id!,
'on_user_login'
)
expect(loginEvent).toBeDefined()
expect(loginEvent?.email).toBe(email)
})

it('fires provider-filtered create trigger for local-userpass only', async () => {
const authId = new ObjectId()
const email = 'provider-local-create@example.com'
Expand Down Expand Up @@ -1325,6 +1364,64 @@ describe('MongoDB Atlas rule enforcement (e2e)', () => {
expect(localEvent).toBeNull()
})

it('fires provider-filtered login trigger for local-userpass only', async () => {
const email = 'provider-local-login@example.com'
const password = 'login-pass'
const registration = await appInstance!.inject({
method: 'POST',
url: `${AUTH_BASE_URL}/register`,
payload: {
email,
password
}
})
expect(registration.statusCode).toBe(201)

const login = await appInstance!.inject({
method: 'POST',
url: `${AUTH_BASE_URL}/login`,
payload: {
username: email,
password
}
})
expect(login.statusCode).toBe(200)
const loginBody = login.json() as { user_id?: string }
expect(loginBody.user_id).toBeDefined()

const localEvent = await waitForProviderTriggerEventType(
loginBody.user_id!,
'auth_provider_login_local-userpass'
)
expect(localEvent).toBeDefined()
const anonEvent = await waitForProviderTriggerEventType(
loginBody.user_id!,
'auth_provider_login_anon-user'
)
expect(anonEvent).toBeNull()
})

it('fires provider-filtered login trigger for anon-user only', async () => {
const login = await appInstance!.inject({
method: 'POST',
url: `${ANON_AUTH_BASE_URL}/login`
})
expect(login.statusCode).toBe(200)
const loginBody = login.json() as { user_id?: string }
expect(loginBody.user_id).toBeDefined()

const anonEvent = await waitForProviderTriggerEventType(
loginBody.user_id!,
'auth_provider_login_anon-user'
)
expect(anonEvent).toBeDefined()
const localEvent = await waitForProviderTriggerEventType(
loginBody.user_id!,
'auth_provider_login_local-userpass'
)
expect(localEvent).toBeNull()
})

it('fires provider-filtered delete trigger for local-userpass only', async () => {
const email = 'provider-local-delete@example.com'
const password = 'delete-pass'
Expand Down
Loading