diff --git a/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts b/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts index 5cc216e759..e72b53644f 100644 --- a/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts +++ b/src/domain/siwe/entities/__tests__/siwe-message.schema.spec.ts @@ -1,9 +1,12 @@ +import { getSecondsUntil } from '@/domain/common/utils/time'; import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-message.builder'; -import { SiweMessageSchema } from '@/domain/siwe/entities/siwe-message.entity'; +import { getSiweMessageSchema } from '@/domain/siwe/entities/siwe-message.entity'; import { faker } from '@faker-js/faker'; import { getAddress } from 'viem'; import { ZodError } from 'zod'; +const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes + describe('SiweMessageSchema', () => { beforeEach(() => { jest.useFakeTimers(); @@ -15,8 +18,11 @@ describe('SiweMessageSchema', () => { it('should validate a SiWe message', () => { const message = siweMessageBuilder().build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -26,16 +32,22 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('scheme', faker.internet.protocol()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); it('should validate without scheme', () => { const message = siweMessageBuilder().with('scheme', undefined).build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -47,8 +59,11 @@ describe('SiweMessageSchema', () => { .with('scheme', undefined) .with('domain', faker.internet.domainName()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -59,8 +74,11 @@ describe('SiweMessageSchema', () => { // A scheme is present in the domain .with('domain', faker.internet.url()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -78,8 +96,11 @@ describe('SiweMessageSchema', () => { .with('scheme', faker.internet.protocol()) .with('domain', faker.lorem.sentence()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -98,8 +119,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('address', getAddress(faker.finance.ethereumAddress())) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -111,8 +135,11 @@ describe('SiweMessageSchema', () => { faker.finance.ethereumAddress().toLowerCase() as `0x${string}`, ) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -129,8 +156,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('address', faker.lorem.word() as `0x${string}`) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -149,16 +179,22 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('statement', faker.lorem.sentence()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); it('should allow an optional statement', () => { const message = siweMessageBuilder().with('statement', undefined).build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -167,8 +203,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('statement', `${faker.lorem.sentence()}\n`) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -187,8 +226,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('uri', faker.internet.url({ appendSlash: false })) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -197,8 +239,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('uri', faker.internet.domainName()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -216,8 +261,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('uri', faker.lorem.word()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -235,8 +283,11 @@ describe('SiweMessageSchema', () => { describe('version', () => { it('should validate version 1', () => { const message = siweMessageBuilder().with('version', '1').build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -245,8 +296,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('version', '2' as '1') .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -267,8 +321,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('chainId', faker.number.int()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -277,8 +334,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('chainId', faker.lorem.word() as unknown as number) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -299,8 +359,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('nonce', faker.string.alphanumeric({ length: 8 })) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -309,8 +372,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('nonce', faker.lorem.sentence()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -328,8 +394,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('nonce', faker.string.alphanumeric({ length: 7 })) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -352,8 +421,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('issuedAt', faker.date.recent().toISOString()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -362,8 +434,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('issuedAt', faker.lorem.sentence()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -383,8 +458,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('expirationTime', faker.date.future().toISOString()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -393,8 +471,10 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('expirationTime', undefined) .build(); + const maxValidityInSecs = faker.number.int({ min: 1 }); + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -403,8 +483,10 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('expirationTime', faker.lorem.sentence()) .build(); + const maxValidityInSecs = faker.number.int({ min: 1 }); + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -414,6 +496,34 @@ describe('SiweMessageSchema', () => { message: 'Invalid datetime', path: ['expirationTime'], }, + { + code: 'custom', + message: `Must be within ${maxValidityInSecs} seconds`, + path: ['expirationTime'], + }, + ]), + ); + }); + + it('should only allow a maximum validity period', () => { + const expirationTime = faker.date.future({ + refDate: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); + const message = siweMessageBuilder() + .with('expirationTime', expirationTime.toISOString()) + .build(); + const maxValidityInSecs = getSecondsUntil(expirationTime) - 1; + const schema = getSiweMessageSchema(maxValidityInSecs); + + const result = schema.safeParse(message); + + expect(!result.success && result.error).toStrictEqual( + new ZodError([ + { + code: 'custom', + message: `Must be within ${maxValidityInSecs} seconds`, + path: ['expirationTime'], + }, ]), ); }); @@ -424,16 +534,22 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('notBefore', faker.date.past().toISOString()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); it('should allow an optional notBefore', () => { const message = siweMessageBuilder().with('notBefore', undefined).build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -442,8 +558,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('notBefore', faker.lorem.sentence()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -463,16 +582,22 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('requestId', faker.string.uuid()) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); it('allow an optional requestId', () => { const message = siweMessageBuilder().with('requestId', undefined).build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -481,8 +606,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('requestId', faker.number.int() as unknown as string) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -503,8 +631,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('resources', [faker.internet.url({ appendSlash: false })]) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -513,8 +644,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('resources', [faker.internet.domainName()]) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -530,8 +664,11 @@ describe('SiweMessageSchema', () => { it('allow an optional resources', () => { const message = siweMessageBuilder().with('resources', undefined).build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(result.success).toBe(true); }); @@ -540,8 +677,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('resources', faker.lorem.sentence() as unknown as string[]) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -560,8 +700,11 @@ describe('SiweMessageSchema', () => { const message = siweMessageBuilder() .with('resources', [`${faker.internet.url({ appendSlash: false })}\n`]) .build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect(!result.success && result.error).toStrictEqual( new ZodError([ @@ -584,9 +727,12 @@ describe('SiweMessageSchema', () => { ['nonce' as const], ])('should not allow %s to be undefined', (key) => { const message = siweMessageBuilder().build(); + const maxValidityInSecs = + getSecondsUntil(new Date(message.expirationTime!)) + 1; + const schema = getSiweMessageSchema(maxValidityInSecs); delete message[key]; - const result = SiweMessageSchema.safeParse(message); + const result = schema.safeParse(message); expect( !result.success && diff --git a/src/domain/siwe/entities/siwe-message.entity.ts b/src/domain/siwe/entities/siwe-message.entity.ts index f9c2630389..01d5fcd0ec 100644 --- a/src/domain/siwe/entities/siwe-message.entity.ts +++ b/src/domain/siwe/entities/siwe-message.entity.ts @@ -1,7 +1,7 @@ import { getAddress } from 'viem'; import { z } from 'zod'; -export type SiweMessage = z.infer; +export type SiweMessage = z.infer>; /** * The following adheres to the EIP-4361 (SiWe) standard message fields @@ -9,92 +9,116 @@ export type SiweMessage = z.infer; * * Note: we do not coerce any values as they will have been referenced in the message signed */ -export const SiweMessageSchema = z.object({ - /** - * OPTIONAL. The URI scheme of the origin of the request. Its value MUST be an RFC 3986 URI scheme. - */ - scheme: z - // Valid RFC 3986 URI schemes that suit our needs - .enum(['http', 'https']) - .optional(), - /** - * REQUIRED. The domain that is requesting the signing. Its value MUST be an RFC 3986 authority. The authority includes - * an OPTIONAL port. If the port is not specified, the default port for the provided scheme is assumed (e.g., 443 for HTTPS). - * If scheme is not specified, HTTPS is assumed by default. - */ - domain: z - .string() - // We cannot use z.url() here as assumes scheme is present - .refine(isRfc3986Authority, { - message: 'Invalid RFC 3986 authority', - }), - /** - * REQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address - * encoding specified in ERC-55 where applicable. - */ - address: z - .string() - // We cannot use AddressSchema here as the given address will have been referenced in the message signed - .refine(isChecksummedAddress, { - message: 'Invalid checksummed address', - }), - /** - * OPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a). - */ - statement: z - .string() - .optional() - .refine((value) => !value || isOneLine(value), { - message: 'Must not include newlines', - }), - /** - * REQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing (as in the subject of a claim). - */ - uri: z.string().url(), - /** - * REQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification. - */ - version: z.literal('1'), - /** - * REQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved. - */ - chainId: z.number().int(), - /** - * REQUIRED. A random string typically chosen by the relying party and used to prevent replay attacks, at least 8 alphanumeric - * characters. - */ - nonce: z - .string() - .min(8) - .regex(/^[a-zA-Z0-9]+$/), - /** - * REQUIRED. The time when the message was generated, typically the current time. Its value MUST be an ISO 8601 datetime string. - */ - issuedAt: z.string().datetime(), - /** - * OPTIONAL. The time when the signed authentication message is no longer valid. Its value MUST be an ISO 8601 datetime string. - */ - expirationTime: z.string().datetime().optional(), - /** - * OPTIONAL. The time when the signed authentication message will become valid. Its value MUST be an ISO 8601 datetime string. - */ - notBefore: z.string().datetime().optional(), - /** - * OPTIONAL. A system-specific identifier that MAY be used to uniquely refer to the sign-in request. - */ - requestId: z.string().optional(), - /** - * OPTIONAL. A list of information or references to information the user wishes to have resolved as part of authentication by the - * relying party. Every resource MUST be an RFC 3986 URI separated by "\n- " where \n is the byte 0x0a. - */ - resources: z - .array( - z.string().url().refine(isOneLine, { + +// Use inferred schema +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getSiweMessageSchema(maxValidityPeriodInSeconds: number) { + return z.object({ + /** + * OPTIONAL. The URI scheme of the origin of the request. Its value MUST be an RFC 3986 URI scheme. + */ + scheme: z + // Valid RFC 3986 URI schemes that suit our needs + .enum(['http', 'https']) + .optional(), + /** + * REQUIRED. The domain that is requesting the signing. Its value MUST be an RFC 3986 authority. The authority includes + * an OPTIONAL port. If the port is not specified, the default port for the provided scheme is assumed (e.g., 443 for HTTPS). + * If scheme is not specified, HTTPS is assumed by default. + */ + domain: z + .string() + // We cannot use z.url() here as assumes scheme is present + .refine(isRfc3986Authority, { + message: 'Invalid RFC 3986 authority', + }), + /** + * REQUIRED. The Ethereum address performing the signing. Its value SHOULD be conformant to mixed-case checksum address + * encoding specified in ERC-55 where applicable. + */ + address: z + .string() + // We cannot use AddressSchema here as the given address will have been referenced in the message signed + .refine(isChecksummedAddress, { + message: 'Invalid checksummed address', + }), + /** + * OPTIONAL. A human-readable ASCII assertion that the user will sign which MUST NOT include '\n' (the byte 0x0a). + */ + statement: z + .string() + .optional() + .refine((value) => !value || isOneLine(value), { message: 'Must not include newlines', }), - ) - .optional(), -}); + /** + * REQUIRED. An RFC 3986 URI referring to the resource that is the subject of the signing (as in the subject of a claim). + */ + uri: z.string().url(), + /** + * REQUIRED. The current version of the SIWE Message, which MUST be 1 for this specification. + */ + version: z.literal('1'), + /** + * REQUIRED. The EIP-155 Chain ID to which the session is bound, and the network where Contract Accounts MUST be resolved. + */ + chainId: z.number().int(), + /** + * REQUIRED. A random string typically chosen by the relying party and used to prevent replay attacks, at least 8 alphanumeric + * characters. + */ + nonce: z + .string() + .min(8) + .regex(/^[a-zA-Z0-9]+$/), + /** + * REQUIRED. The time when the message was generated, typically the current time. Its value MUST be an ISO 8601 datetime string. + */ + issuedAt: z.string().datetime(), + /** + * OPTIONAL. The time when the signed authentication message is no longer valid. Its value MUST be an ISO 8601 datetime string. + */ + expirationTime: z + .string() + .datetime() + .optional() + .refine( + (expirationTime) => { + if (!expirationTime) { + return true; + } + + const maxExpirationTime = new Date( + Date.now() + maxValidityPeriodInSeconds * 1_000, + ); + + return new Date(expirationTime) <= maxExpirationTime; + }, + { + message: `Must be within ${maxValidityPeriodInSeconds} seconds`, + }, + ), + /** + * OPTIONAL. The time when the signed authentication message will become valid. Its value MUST be an ISO 8601 datetime string. + */ + notBefore: z.string().datetime().optional(), + /** + * OPTIONAL. A system-specific identifier that MAY be used to uniquely refer to the sign-in request. + */ + requestId: z.string().optional(), + /** + * OPTIONAL. A list of information or references to information the user wishes to have resolved as part of authentication by the + * relying party. Every resource MUST be an RFC 3986 URI separated by "\n- " where \n is the byte 0x0a. + */ + resources: z + .array( + z.string().url().refine(isOneLine, { + message: 'Must not include newlines', + }), + ) + .optional(), + }); +} function isRfc3986Authority(value: string): boolean { return ( diff --git a/src/routes/auth/auth.controller.spec.ts b/src/routes/auth/auth.controller.spec.ts index 1e21e64d2b..6656782ac8 100644 --- a/src/routes/auth/auth.controller.spec.ts +++ b/src/routes/auth/auth.controller.spec.ts @@ -28,6 +28,8 @@ import { } from '@/datasources/jwt/configuration/jwt.configuration.module'; import jwtConfiguration from '@/datasources/jwt/configuration/__tests__/jwt.configuration'; +const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes + describe('AuthController', () => { let app: INestApplication; let cacheService: FakeCacheService; @@ -104,7 +106,10 @@ describe('AuthController', () => { `auth_nonce_${nonceResponse.body.nonce}`, '', ); - const expirationTime = faker.date.future(); + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); const message = siweMessageBuilder() .with('address', signer.address) .with('nonce', nonceResponse.body.nonce) @@ -143,11 +148,64 @@ describe('AuthController', () => { await expect(cacheService.get(cacheDir)).resolves.toBe(undefined); }); + it('should not verify a signer if expirationTime is too high', async () => { + const privateKey = generatePrivateKey(); + const signer = privateKeyToAccount(privateKey); + const nonceResponse = await request(app.getHttpServer()).get( + '/v1/auth/nonce', + ); + const cacheDir = new CacheDir( + `auth_nonce_${nonceResponse.body.nonce}`, + '', + ); + const expirationTime = faker.date.future({ + refDate: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); + const message = siweMessageBuilder() + .with('address', signer.address) + .with('nonce', nonceResponse.body.nonce) + .with('expirationTime', expirationTime.toISOString()) + .build(); + const signature = await signer.signMessage({ + message: toSignableSiweMessage(message), + }); + + await expect(cacheService.get(cacheDir)).resolves.toBe( + nonceResponse.body.nonce, + ); + await request(app.getHttpServer()) + .post('/v1/auth/verify') + .send({ + message, + signature, + }) + .expect(422) + .expect(({ headers, body }) => { + expect(headers['set-cookie']).toBe(undefined); + + expect(body).toStrictEqual({ + code: 'custom', + message: 'Must be within 900 seconds', + path: ['message', 'expirationTime'], + statusCode: 422, + }); + }); + // Nonce not deleted + await expect(cacheService.get(cacheDir)).resolves.toBe( + nonceResponse.body.nonce, + ); + }); + it('should not verify a signer if using an unsigned nonce', async () => { const privateKey = generatePrivateKey(); const signer = privateKeyToAccount(privateKey); + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); const message = siweMessageBuilder() .with('address', signer.address) + .with('expirationTime', expirationTime.toISOString()) .build(); const cacheDir = new CacheDir(`auth_nonce_${message.nonce}`, ''); const signature = await signer.signMessage({ @@ -184,9 +242,14 @@ describe('AuthController', () => { `auth_nonce_${nonceResponse.body.nonce}`, '', ); + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); const message = siweMessageBuilder() .with('address', signer.address) .with('nonce', nonceResponse.body.nonce) + .with('expirationTime', expirationTime.toISOString()) .build(); const signature = await signer.signMessage({ message: toSignableSiweMessage(message), @@ -222,8 +285,13 @@ describe('AuthController', () => { const nonceResponse = await request(app.getHttpServer()).get( '/v1/auth/nonce', ); + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); const message = siweMessageBuilder() .with('nonce', nonceResponse.body.nonce) + .with('expirationTime', expirationTime.toISOString()) .build(); const cacheDir = new CacheDir( `auth_nonce_${nonceResponse.body.nonce}`, diff --git a/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts b/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts index 61159f8c20..f4b049c2a1 100644 --- a/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts +++ b/src/routes/auth/entities/schemas/__tests__/verify-auth-message.dto.schema.spec.ts @@ -2,10 +2,26 @@ import { siweMessageBuilder } from '@/domain/siwe/entities/__tests__/siwe-messag import { VerifyAuthMessageDtoSchema } from '@/routes/auth/entities/verify-auth-message.dto.entity'; import { faker } from '@faker-js/faker'; +const MAX_VALIDITY_PERIOD_IN_MS = 15 * 60 * 1_000; // 15 minutes + describe('VerifyAuthMessageDto', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + it('should validate a VerifyAuthMessageDto', () => { + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); const verifyAuthMessageDto = { - message: siweMessageBuilder().build(), + message: siweMessageBuilder() + .with('expirationTime', expirationTime.toISOString()) + .build(), signature: faker.string.hexadecimal(), }; @@ -17,8 +33,14 @@ describe('VerifyAuthMessageDto', () => { it.each([['message' as const], ['signature' as const]])( 'should not allow %s to be undefined', (key) => { + const expirationTime = faker.date.between({ + from: new Date(), + to: new Date(Date.now() + MAX_VALIDITY_PERIOD_IN_MS), + }); const verifyAuthMessageDto = { - message: siweMessageBuilder().build(), + message: siweMessageBuilder() + .with('expirationTime', expirationTime.toISOString()) + .build(), signature: faker.string.hexadecimal(), }; delete verifyAuthMessageDto[key]; diff --git a/src/routes/auth/entities/verify-auth-message.dto.entity.ts b/src/routes/auth/entities/verify-auth-message.dto.entity.ts index df7c4db97b..0d4b483362 100644 --- a/src/routes/auth/entities/verify-auth-message.dto.entity.ts +++ b/src/routes/auth/entities/verify-auth-message.dto.entity.ts @@ -1,10 +1,13 @@ -import { SiweMessageSchema } from '@/domain/siwe/entities/siwe-message.entity'; +import { getSiweMessageSchema } from '@/domain/siwe/entities/siwe-message.entity'; import { HexSchema } from '@/validation/entities/schemas/hex.schema'; import { z } from 'zod'; +// TODO: Inject +const MAX_VALIDITY_PERIOD_IN_SECONDS = 15 * 60; // 15 minutes + export type VerifyAuthMessageDto = z.infer; export const VerifyAuthMessageDtoSchema = z.object({ - message: SiweMessageSchema, + message: getSiweMessageSchema(MAX_VALIDITY_PERIOD_IN_SECONDS), signature: HexSchema, });