Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/warm-lizards-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@logto/connector-http-email": minor
"@logto/connector-http-sms": minor
"@logto/connector-kit": minor
"@logto/core": minor
---

add client IP address to passwordless connector message payload

The `SendMessageData` type now includes an optional `ip` field that contains the client IP address of the user who triggered the message. This can be used by HTTP email/SMS connectors for rate limiting, fraud detection, or logging purposes.
54 changes: 54 additions & 0 deletions packages/connectors/connector-http-email/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,58 @@ describe('HTTP email connector', () => {

expect(mockPost.isDone()).toBe(true);
});

it('should include IP address in request when provided', async () => {
const url = new URL(mockedConfig.endpoint);
const mockPost = nock(url.origin)
.post(url.pathname, (body) => {
expect(body).toMatchObject({
to: 'foo@logto.io',
type: TemplateType.SignIn,
payload: {
code: '123456',
},
ip: '192.168.1.100',
});
return true;
})
.reply(200, {
message: 'Email sent successfully',
});

const connector = await createConnector({ getConfig });
await connector.sendMessage({
to: 'foo@logto.io',
type: TemplateType.SignIn,
payload: {
code: '123456',
},
ip: '192.168.1.100',
});

expect(mockPost.isDone()).toBe(true);
});

it('should not include IP field when IP is not provided', async () => {
const url = new URL(mockedConfig.endpoint);
const mockPost = nock(url.origin)
.post(url.pathname, (body) => {
expect(body).not.toHaveProperty('ip');
return true;
})
.reply(200, {
message: 'Email sent successfully',
});

const connector = await createConnector({ getConfig });
await connector.sendMessage({
to: 'foo@logto.io',
type: TemplateType.SignIn,
payload: {
code: '123456',
},
});

expect(mockPost.isDone()).toBe(true);
});
});
3 changes: 2 additions & 1 deletion packages/connectors/connector-http-email/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { httpMailConfigGuard } from './types.js';
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const { to, type, payload, ip } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig(config, httpMailConfigGuard);
const { endpoint, authorization } = config;
Expand All @@ -35,6 +35,7 @@ const sendMessage =
to,
type,
payload,
...(ip && { ip }),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Should we put it in the message payload?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payload contains data that are used in the template, but ip is not, so I think it is better to be in top-level.

},
});
} catch (error: unknown) {
Expand Down
54 changes: 54 additions & 0 deletions packages/connectors/connector-http-sms/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,58 @@ describe('HTTP SMS connector', () => {

expect(mockPost.isDone()).toBe(true);
});

it('should include IP address in request when provided', async () => {
const url = new URL(mockedConfig.endpoint);
const mockPost = nock(url.origin)
.post(url.pathname, (body) => {
expect(body).toMatchObject({
to: '+1234567890',
type: TemplateType.SignIn,
payload: {
code: '123456',
},
ip: '192.168.1.100',
});
return true;
})
.reply(200, {
message: 'SMS sent successfully',
});

const connector = await createConnector({ getConfig });
await connector.sendMessage({
to: '+1234567890',
type: TemplateType.SignIn,
payload: {
code: '123456',
},
ip: '192.168.1.100',
});

expect(mockPost.isDone()).toBe(true);
});

it('should not include IP field when IP is not provided', async () => {
const url = new URL(mockedConfig.endpoint);
const mockPost = nock(url.origin)
.post(url.pathname, (body) => {
expect(body).not.toHaveProperty('ip');
return true;
})
.reply(200, {
message: 'SMS sent successfully',
});

const connector = await createConnector({ getConfig });
await connector.sendMessage({
to: '+1234567890',
type: TemplateType.SignIn,
payload: {
code: '123456',
},
});

expect(mockPost.isDone()).toBe(true);
});
});
3 changes: 2 additions & 1 deletion packages/connectors/connector-http-sms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { httpSmsConfigGuard } from './types.js';
const sendMessage =
(getConfig: GetConnectorConfig): SendMessageFunction =>
async (data, inputConfig) => {
const { to, type, payload } = data;
const { to, type, payload, ip } = data;
const config = inputConfig ?? (await getConfig(defaultMetadata.id));
validateConfig(config, httpSmsConfigGuard);
const { endpoint, authorization } = config;
Expand All @@ -35,6 +35,7 @@ const sendMessage =
to,
type,
payload,
...(ip && { ip }),
},
});
} catch (error: unknown) {
Expand Down
22 changes: 16 additions & 6 deletions packages/core/src/libraries/organization-invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
CreateOrganizationInvitation,
'inviterId' | 'invitee' | 'organizationId' | 'expiresAt'
> & { organizationRoleIds?: string[] },
messagePayload: SendMessagePayload | false
messagePayload: SendMessagePayload | false,
ip?: string
) {
const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data;

Expand Down Expand Up @@ -101,10 +102,14 @@
inviterId
);

await this.sendEmail(invitee, {
...templateContext,
...messagePayload,
});
await this.sendEmail(
invitee,
{
...templateContext,
...messagePayload,
},
ip
);
}

// Additional query to get the full invitation data
Expand Down Expand Up @@ -145,7 +150,7 @@
status: OrganizationInvitationStatus.Accepted,
acceptedUserId: string
): Promise<OrganizationInvitationEntity>;
// TODO: Error i18n

Check warning on line 153 in packages/core/src/libraries/organization-invitation.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/libraries/organization-invitation.ts#L153

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Error i18n'.
async updateStatus(
id: string,
status: OrganizationInvitationStatus,
Expand Down Expand Up @@ -244,12 +249,17 @@
}

/** Send an organization invitation email. */
async sendEmail(to: string, payload: SendMessagePayload & OrganizationInvitationContextInfo) {
async sendEmail(
to: string,
payload: SendMessagePayload & OrganizationInvitationContextInfo,
ip?: string
) {
const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email);
return emailConnector.sendMessage({
to,
type: TemplateType.OrganizationInvitation,
payload,
...(ip && { ip }),
});
}
}
40 changes: 40 additions & 0 deletions packages/core/src/libraries/passcode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,46 @@ describe('sendPasscode', () => {
},
});
});

it('should include IP address when provided in context payload', async () => {
const sendMessage = jest.fn();
getMessageConnector.mockResolvedValueOnce({
...defaultConnectorMethods,
configGuard: any(),
dbEntry: {
...mockConnector,
id: 'id0',
},
metadata: {
...mockMetadata,
platform: null,
},
type: ConnectorType.Sms,
sendMessage,
});
const passcode: Passcode = {
tenantId: 'fake_tenant',
id: 'passcode_id',
interactionJti: 'jti',
phone: 'phone',
email: null,
type: TemplateType.SignIn,
code: '1234',
consumed: false,
tryCount: 0,
createdAt: Date.now(),
};
await sendPasscode(passcode, { locale: 'en', ip: '192.168.1.100' });
expect(sendMessage).toHaveBeenCalledWith({
to: passcode.phone,
type: passcode.type,
payload: {
code: passcode.code,
locale: 'en',
},
ip: '192.168.1.100',
});
});
});

describe('verifyPasscode', () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/libraries/passcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export const passcodeMaxTryCount = 10;
export type PasscodeLibrary = ReturnType<typeof createPasscodeLibrary>;

export type SendPasscodeContextPayload = Pick<SendMessagePayload, 'locale' | 'uiLocales'> &
VerificationCodeContextInfo;
VerificationCodeContextInfo & {
/** The client IP address for rate limiting and fraud detection. */
ip?: string;
};

export const createPasscodeLibrary = (queries: Queries, connectorLibrary: ConnectorLibrary) => {
const {
Expand Down Expand Up @@ -91,13 +94,16 @@ export const createPasscodeLibrary = (queries: Queries, connectorLibrary: Connec
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig);
}

const { ip, ...payloadContext } = contextPayload ?? {};

const response = await sendMessage({
to: emailOrPhone,
type: messageTypeResult.data,
payload: {
code: passcode.code,
...contextPayload,
...payloadContext,
},
...(ip && { ip }),
});

return { dbEntry, metadata, response };
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/routes-me/verification-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default function verificationCodeRoutes<T extends AuthedMeRouter>(
await sendPasscode(code, {
locale: ctx.locale,
...(uiLocales && { uiLocales }),
ip: ctx.request.ip,
});

ctx.status = 204;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/routes/connector/config-testing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe('connector services route', () => {
payload: {
code: '000000',
},
ip: '::ffff:127.0.0.1',
},
{ test: 123 }
);
Expand Down Expand Up @@ -113,6 +114,7 @@ describe('connector services route', () => {
payload: {
code: '000000',
},
ip: '::ffff:127.0.0.1',
},
{ test: 123 }
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/routes/connector/config-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export default function connectorConfigTestingRoutes<T extends ManagementApiRout
code: '000000',
...conditional(locale && { locale }),
},
ip: ctx.request.ip,
},
config
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/routes/interaction/additional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default function additionalRoutes<T extends IRouterParamContext>(
locale: ctx.locale,
...(uiLocales && { uiLocales }),
messageContext,
ip: ctx.request.ip,
},
interactionDetails.jti,
createLog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,21 @@ export const sendVerificationCodeToIdentifier = async (
locale?: string;
uiLocales?: string;
messageContext?: VerificationCodeContextInfo;
/** The client IP address for rate limiting and fraud detection. */
ip?: string;
},
jti: string,
createLog: LogContext['createLog'],
{ createPasscode, sendPasscode }: PasscodeLibrary
) => {
const { event, locale, messageContext, ...identifier } = payload;
const { event, locale, messageContext, ip, ...identifier } = payload;
const messageType = getTemplateTypeByEvent(event);

const log = createLog(`Interaction.${event}.Identifier.VerificationCode.Create`);
log.append(identifier);

const verificationCode = await createPasscode(jti, messageType, identifier);
const { dbEntry } = await sendPasscode(verificationCode, { locale, ...messageContext });
const { dbEntry } = await sendPasscode(verificationCode, { locale, ...messageContext, ip });

log.append({ connectorId: dbEntry.id });
};
Expand Down
14 changes: 9 additions & 5 deletions packages/core/src/routes/organization-invitation/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
})
);

ctx.body = await organizationInvitations.insert(body, messagePayload);
ctx.body = await organizationInvitations.insert(body, messagePayload, ctx.request.ip);
ctx.status = 201;
return next();
}
Expand All @@ -107,10 +107,14 @@
inviterId
);

await organizationInvitations.sendEmail(invitee, {
...templateContext,
...body,
});
await organizationInvitations.sendEmail(
invitee,
{
...templateContext,
...body,
},
ctx.request.ip
);
ctx.status = 204;
return next();
}
Expand Down Expand Up @@ -144,7 +148,7 @@
return next();
}

// TODO: Error i18n

Check warning on line 151 in packages/core/src/routes/organization-invitation/index.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/routes/organization-invitation/index.ts#L151

[no-warning-comments] Unexpected 'todo' comment: 'TODO: Error i18n'.
assertThat(
acceptedUserId,
new RequestError({
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/routes/verification-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default function verificationCodeRoutes<T extends ManagementApiRouter>(
}),
async (ctx, next) => {
const code = await createPasscode(undefined, codeType, ctx.guard.body);
await sendPasscode(code);
await sendPasscode(code, { ip: ctx.request.ip });

ctx.status = 204;

Expand Down
Loading
Loading