Skip to content
Merged
4 changes: 4 additions & 0 deletions docs/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -1903,6 +1903,10 @@
"name": "RequestPasswordResetInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "ResendEmailVerificationInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
},
{
"name": "ResetPasswordInputSchema",
"file": "packages/core/src/contracts/schemas/identity.ts"
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/contracts/adapters/email-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type EmailTemplateKey =
| 'rgSelfExclusionLifted';

export type EmailTemplateData = {
verifyEmail: { url: string; token: string };
verifyEmail: { otp: string };
resetPasswordOtp: { otp: string; email: string };
rgLimitUpdated: { period: string; type: string; description: string };
rgCoolingOffActivated: { expiresAt: Date };
Expand Down Expand Up @@ -52,7 +52,7 @@ export const DEFAULT_EMAIL_TEMPLATES: {
} = {
verifyEmail: (data) => ({
subject: 'Verify your email',
body: `Verify your email using this link: ${data.url}\n\nVerification token: ${data.token}`,
body: `Your email verification code is: ${data.otp}`,
}),
resetPasswordOtp: (data) => ({
subject: 'Reset your password',
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/contracts/schemas/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,13 @@ export const VerifyPasswordResetOtpInputSchema = ResetPasswordInputSchema.pick({
otp: true,
});

export const ResendEmailVerificationInputSchema = z.object({
email: z.email(),
});

export const VerifyEmailInputSchema = z.object({
token: z.string().min(1),
email: z.email(),
otp: z.string().length(OTP_CODE_LENGTH),
});

export const UpdateProfileInputSchema = z
Expand Down Expand Up @@ -178,6 +183,7 @@ export type Disable2faInput = z.infer<typeof Disable2faInputSchema>;
export type RequestPasswordResetInput = z.infer<typeof RequestPasswordResetInputSchema>;
export type ResetPasswordInput = z.infer<typeof ResetPasswordInputSchema>;
export type VerifyPasswordResetOtpInput = z.infer<typeof VerifyPasswordResetOtpInputSchema>;
export type ResendEmailVerificationInput = z.infer<typeof ResendEmailVerificationInputSchema>;
export type VerifyEmailInput = z.infer<typeof VerifyEmailInputSchema>;
export type UpdateProfileInput = z.infer<typeof UpdateProfileInputSchema>;
export type ChangePasswordInput = z.infer<typeof ChangePasswordInputSchema>;
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/contracts/schemas/platform-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,11 @@ export const RegistrationConfigSchema = z
.object({
/** Version recorded beside the player's affirmative terms acceptance. */
termsVersion: z.string().min(1),
/** Public consumer origin used in verification-email links. */
webUrl: z.url(),
/**
* Blocks sign-in until the player has verified their address. Off by default:
* unverified players stay unrestricted while the KYC toggle is off.
*/
requireEmailVerification: z.boolean().default(false),
Comment thread
zaxovaiko marked this conversation as resolved.
})
.strict();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('IdentityService - rate limiting (real Redis)', () => {
events,
limiter,
platformConfig: definePlatformConfig({
registration: { termsVersion: '2026-08', webUrl: 'https://app.example.test' },
registration: { termsVersion: '2026-08', requireEmailVerification: false },
}),
});

Expand Down Expand Up @@ -158,29 +158,38 @@ describe('IdentityService - rate limiting on secret-guessing routes (ABC-208 fin
).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
});

it('rejects verifyEmail with a 429 once the per-caller limit is exhausted', async () => {
it('rejects verifyEmail with a 429 once the per-address limit is exhausted', async () => {
const limiter = makeLimiter();
for (let i = 0; i < 5; i++) {
await limiter.consume('verify-email:203.0.113.5', { limit: 5, windowMs: 15 * 60 * 1000 });
await limiter.consume('verify-email:target@e2e.test', { limit: 5, windowMs: 15 * 60 * 1000 });
}
const svc = withTemplateRenderer({ drizzle, events, limiter });

// Six digits are guessable, so the budget follows the address under attack.
await expect(
svc.verifyEmail({ token: 'sometoken' }, { 'x-real-ip': '203.0.113.5' }),
svc.verifyEmail(
{ email: 'target@e2e.test', otp: '000000' },
{ 'x-real-ip': '203.0.113.5' },
new Headers(),
),
).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
});

it('buckets unauthenticated verifyEmail callers separately, so one cannot stall the rest', async () => {
it('buckets verifyEmail addresses separately, so one target cannot stall the rest', async () => {
const limiter = makeLimiter();
for (let i = 0; i < 5; i++) {
await limiter.consume('verify-email:203.0.113.6', { limit: 5, windowMs: 15 * 60 * 1000 });
await limiter.consume('verify-email:target@e2e.test', { limit: 5, windowMs: 15 * 60 * 1000 });
}
const svc = withTemplateRenderer({ drizzle, events, limiter });

// Verification links are followed without a session. A shared bucket would let the
// exhausted caller above block every other sign-up in flight.
// Codes are entered without a session. A shared bucket would let the exhausted
// address above block every other sign-up in flight.
await expect(
svc.verifyEmail({ token: 'sometoken' }, { 'x-real-ip': '203.0.113.7' }),
svc.verifyEmail(
{ email: 'other@e2e.test', otp: '000000' },
{ 'x-real-ip': '203.0.113.7' },
new Headers(),
),
).rejects.not.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ vi.mock('@openora/core/server', async (importOriginal) => {

const events = makeEventBus();
const registrationConfig = definePlatformConfig({
registration: { termsVersion: 'test-v1', webUrl: 'https://app.example.test' },
registration: { termsVersion: 'test-v1', requireEmailVerification: false },
});

let db: TestDb;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,12 @@ import { DefaultEmailTemplateRenderer } from '../default-email-template-renderer
describe('DefaultEmailTemplateRenderer', () => {
const renderer = new DefaultEmailTemplateRenderer();

it('renders the verifyEmail template with the url and token interpolated', () => {
const result = renderer.render(
'verifyEmail',
{ url: 'https://example.com/verify', token: 'tok123' },
'de',
);
it('renders the verifyEmail template with the otp interpolated', () => {
const result = renderer.render('verifyEmail', { otp: '123456' }, 'de');

expect(result).toEqual({
subject: 'Verify your email',
body: 'Verify your email using this link: https://example.com/verify\n\nVerification token: tok123',
body: 'Your email verification code is: 123456',
});
});

Expand Down
15 changes: 14 additions & 1 deletion packages/core/src/pam/identity/contract/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
RequestPasswordResetInputSchema,
VerifyPasswordResetOtpInputSchema,
ResetPasswordInputSchema,
ResendEmailVerificationInputSchema,
VerifyEmailInputSchema,
UpdateProfileInputSchema,
ChangePasswordInputSchema,
Expand Down Expand Up @@ -132,14 +133,26 @@ export const identityContract = {
.input(ChangePasswordInputSchema)
.output(IdentitySuccessSchema),

// Unauthenticated: the player has no session until the emailed code is verified.
// Always answers success, so it never reveals whether the address has an account.
sendEmailVerification: oc
.route({ method: 'POST', path: '/identity/email/verify/send' })
.input(ResendEmailVerificationInputSchema)
.output(IdentitySuccessSchema),

// Verifying the registration code is what mints the session (sign-up never does). A
// 2FA-enrolled account is verified but NOT signed in - it gets `twoFactorRedirect` and
// completes the challenge through `login`, exactly as that route signals it.
verifyEmail: oc
.route({ method: 'POST', path: '/identity/email/verify' })
.input(VerifyEmailInputSchema)
.output(IdentitySuccessSchema),
.output(
z.object({
user: UserSchema.optional(),
session: SessionSchema.optional(),
twoFactorRedirect: z.boolean().optional(),
}),
),

changeEmail: oc
.route({ method: 'POST', path: '/identity/email/change' })
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/pam/identity/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,12 @@ export function createIdentityRouter(
identity.changePassword(input, context.request.headers, context.resHeaders ?? new Headers()),
),

sendEmailVerification: os.sendEmailVerification.handler(({ context }) =>
identity.sendEmailVerification(context.request.headers),
sendEmailVerification: os.sendEmailVerification.handler(({ input, context }) =>
identity.sendEmailVerification(input, context.request.headers),
),

verifyEmail: os.verifyEmail.handler(({ input, context }) =>
identity.verifyEmail(input, context.request.headers),
identity.verifyEmail(input, context.request.headers, context.resHeaders ?? new Headers()),
),

changeEmail: os.changeEmail.handler(({ input, context }) =>
Expand Down
Loading
Loading