From 15bb084b6eee0d1a851a4cd63e2da7d6cb263281 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 7 Nov 2022 14:33:47 +0800 Subject: [PATCH] refactor: replace dayjs with date-fns --- packages/console/package.json | 6 +- .../console/src/components/DateTime/index.tsx | 8 +-- .../src/pages/AuditLogDetails/index.tsx | 3 +- .../console/src/pages/Dashboard/index.tsx | 4 +- .../SignInExperience/components/Preview.tsx | 4 +- packages/core/package.json | 2 +- packages/core/src/oidc/adapter.test.ts | 7 +- packages/core/src/oidc/adapter.ts | 4 +- .../core/src/queries/oidc-model-instance.ts | 4 +- packages/core/src/routes/dashboard.test.ts | 63 +++++++++--------- packages/core/src/routes/dashboard.ts | 45 +++++++------ .../routes/session/forgot-password.test.ts | 16 +++-- .../src/routes/session/passwordless.test.ts | 65 ++++++++++--------- packages/core/src/routes/session/utils.ts | 7 +- packages/integration-tests/package.json | 1 + .../integration-tests/src/client/index.ts | 8 ++- .../src/include.d/node-fetch.d.ts | 4 ++ .../tests/api/get-access-token.test.ts | 44 ++++++++++++- packages/shared/package.json | 1 - packages/shared/src/database/utils.test.ts | 3 +- packages/shared/src/database/utils.ts | 4 +- pnpm-lock.yaml | 23 ++++--- 22 files changed, 193 insertions(+), 133 deletions(-) create mode 100644 packages/integration-tests/src/include.d/node-fetch.d.ts diff --git a/packages/console/package.json b/packages/console/package.json index 0eb6f0e5b..98ddfd5ae 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -47,7 +47,6 @@ "clean-deep": "^3.4.0", "cross-env": "^7.0.3", "csstype": "^3.0.11", - "dayjs": "^1.10.5", "deepmerge": "^4.2.2", "dnd-core": "^16.0.0", "eslint": "^8.21.0", @@ -97,5 +96,8 @@ "stylelint": { "extends": "@silverhand/eslint-config-react/.stylelintrc" }, - "prettier": "@silverhand/eslint-config/.prettierrc" + "prettier": "@silverhand/eslint-config/.prettierrc", + "dependencies": { + "date-fns": "^2.29.3" + } } diff --git a/packages/console/src/components/DateTime/index.tsx b/packages/console/src/components/DateTime/index.tsx index 0bc850197..4c20a2473 100644 --- a/packages/console/src/components/DateTime/index.tsx +++ b/packages/console/src/components/DateTime/index.tsx @@ -1,18 +1,18 @@ import type { Nullable } from '@silverhand/essentials'; -import dayjs from 'dayjs'; +import { isValid } from 'date-fns'; type Props = { children: Nullable; }; const DateTime = ({ children }: Props) => { - const date = dayjs(children); + const date = children && new Date(children); - if (!children || !date.isValid()) { + if (!date || !isValid(date)) { return -; } - return {date.toDate().toLocaleDateString()}; + return {date.toLocaleDateString()}; }; export default DateTime; diff --git a/packages/console/src/pages/AuditLogDetails/index.tsx b/packages/console/src/pages/AuditLogDetails/index.tsx index 1a5c8f7ad..173c49b6c 100644 --- a/packages/console/src/pages/AuditLogDetails/index.tsx +++ b/packages/console/src/pages/AuditLogDetails/index.tsx @@ -1,6 +1,5 @@ import type { LogDto, User } from '@logto/schemas'; import classNames from 'classnames'; -import dayjs from 'dayjs'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams } from 'react-router-dom'; import useSWR from 'swr'; @@ -85,7 +84,7 @@ const AuditLogDetails = () => {
{t('log_details.time')}
-
{dayjs(data.createdAt).toDate().toLocaleString()}
+
{new Date(data.createdAt).toLocaleString()}
diff --git a/packages/console/src/pages/Dashboard/index.tsx b/packages/console/src/pages/Dashboard/index.tsx index db727d9e8..4d4c8500b 100644 --- a/packages/console/src/pages/Dashboard/index.tsx +++ b/packages/console/src/pages/Dashboard/index.tsx @@ -1,4 +1,4 @@ -import dayjs from 'dayjs'; +import { format } from 'date-fns'; import type { ChangeEventHandler } from 'react'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -36,7 +36,7 @@ const tickFormatter = new Intl.NumberFormat('en-US', { }); const Dashboard = () => { - const [date, setDate] = useState(dayjs().format('YYYY-MM-DD')); + const [date, setDate] = useState(format(Date.now(), 'yyyy-MM-dd')); const { data: totalData, error: totalError } = useSWR( '/api/dashboard/users/total' ); diff --git a/packages/console/src/pages/SignInExperience/components/Preview.tsx b/packages/console/src/pages/SignInExperience/components/Preview.tsx index 137e467ec..dfee63676 100644 --- a/packages/console/src/pages/SignInExperience/components/Preview.tsx +++ b/packages/console/src/pages/SignInExperience/components/Preview.tsx @@ -4,7 +4,7 @@ import type { ConnectorResponse, ConnectorMetadata, SignInExperience } from '@lo import { AppearanceMode } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; import classNames from 'classnames'; -import dayjs from 'dayjs'; +import { format } from 'date-fns'; import { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; @@ -195,7 +195,7 @@ const Preview = ({ signInExperience, className }: Props) => {
{platform !== 'desktopWeb' && (
-
{dayjs().format('HH:mm')}
+
{format(Date.now(), 'HH:mm')}
)} diff --git a/packages/core/package.json b/packages/core/package.json index 40a987b77..aeaf3ea8f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,7 +30,7 @@ "@silverhand/essentials": "^1.3.0", "chalk": "^4", "clean-deep": "^3.4.0", - "dayjs": "^1.10.5", + "date-fns": "^2.29.3", "debug": "^4.3.4", "decamelize": "^5.0.0", "deepmerge": "^4.2.2", diff --git a/packages/core/src/oidc/adapter.test.ts b/packages/core/src/oidc/adapter.test.ts index 7cb9c54c4..7cefde551 100644 --- a/packages/core/src/oidc/adapter.test.ts +++ b/packages/core/src/oidc/adapter.test.ts @@ -35,10 +35,9 @@ jest.mock('@logto/shared', () => ({ const now = Date.now(); jest.mock( - 'dayjs', - // eslint-disable-next-line unicorn/consistent-function-scoping - jest.fn(() => () => ({ - add: jest.fn((delta: number) => new Date(now + delta * 1000)), + 'date-fns', + jest.fn(() => ({ + add: jest.fn((_: Date, { seconds }: { seconds: number }) => new Date(now + seconds * 1000)), })) ); diff --git a/packages/core/src/oidc/adapter.ts b/packages/core/src/oidc/adapter.ts index 5c8426268..8c72ba604 100644 --- a/packages/core/src/oidc/adapter.ts +++ b/packages/core/src/oidc/adapter.ts @@ -1,7 +1,7 @@ import type { CreateApplication, OidcClientMetadata } from '@logto/schemas'; import { ApplicationType } from '@logto/schemas'; import { adminConsoleApplicationId, demoAppApplicationId } from '@logto/schemas/lib/seeds'; -import dayjs from 'dayjs'; +import { add } from 'date-fns'; import type { AdapterFactory, AllClientMetadata } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; @@ -99,7 +99,7 @@ export default function postgresAdapter(modelName: string): ReturnType findPayloadById(modelName, id), findByUserCode: async (userCode) => findPayloadByPayloadField(modelName, 'userCode', userCode), diff --git a/packages/core/src/queries/oidc-model-instance.ts b/packages/core/src/queries/oidc-model-instance.ts index fccbe0058..319118756 100644 --- a/packages/core/src/queries/oidc-model-instance.ts +++ b/packages/core/src/queries/oidc-model-instance.ts @@ -7,7 +7,7 @@ import { OidcModelInstances } from '@logto/schemas'; import { convertToIdentifiers, convertToTimestamp } from '@logto/shared'; import type { Nullable } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials'; -import dayjs from 'dayjs'; +import { add, isBefore } from 'date-fns'; import type { ValueExpression } from 'slonik'; import { sql } from 'slonik'; @@ -30,7 +30,7 @@ const isConsumed = (modelName: string, consumedAt: Nullable): boolean => return Boolean(consumedAt); } - return dayjs(consumedAt).add(refreshTokenReuseInterval, 'seconds').isBefore(dayjs()); + return isBefore(add(consumedAt, { seconds: refreshTokenReuseInterval }), Date.now()); }; const withConsumed = ( diff --git a/packages/core/src/routes/dashboard.test.ts b/packages/core/src/routes/dashboard.test.ts index 89d75af4d..7e960ab5b 100644 --- a/packages/core/src/routes/dashboard.test.ts +++ b/packages/core/src/routes/dashboard.test.ts @@ -1,4 +1,8 @@ -import dayjs from 'dayjs'; +// The FP version works better for `format()` +/* eslint-disable import/no-duplicates */ +import { endOfDay, subDays } from 'date-fns'; +import { format } from 'date-fns/fp'; +/* eslint-enable import/no-duplicates */ import dashboardRoutes from '@/routes/dashboard'; import { createRequester } from '@/utils/test-utils'; @@ -8,6 +12,7 @@ const countUsers = jest.fn(async () => ({ count: totalUserCount })); const getDailyNewUserCountsByTimeInterval = jest.fn( async (startTimeExclusive: number, endTimeInclusive: number) => mockDailyNewUserCounts ); +const formatToQueryDate = format('yyyy-MM-dd'); jest.mock('@/queries/user', () => ({ countUsers: async () => countUsers(), @@ -83,8 +88,8 @@ describe('dashboardRoutes', () => { it('should call getDailyNewUserCountsByTimeInterval with the time interval (14 days ago 23:59:59.999, today 23:59:59.999]', async () => { await logRequest.get('/dashboard/users/new'); expect(getDailyNewUserCountsByTimeInterval).toHaveBeenCalledWith( - dayjs().endOf('day').subtract(14, 'day').valueOf(), - dayjs().endOf('day').valueOf() + subDays(endOfDay(Date.now()), 14).valueOf(), + endOfDay(Date.now()).valueOf() ); }); @@ -105,10 +110,10 @@ describe('dashboardRoutes', () => { }); describe('GET /dashboard/users/active', () => { - const mockToday = '2022-05-30'; + const mockToday = new Date(2022, 4, 30); beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date(mockToday)); + jest.useFakeTimers().setSystemTime(mockToday); }); it('should fail when the parameter `date` does not match the date regex', async () => { @@ -117,44 +122,44 @@ describe('dashboardRoutes', () => { }); it('should call getDailyActiveUserCountsByTimeInterval with the time interval (2022-05-31, 2022-06-30] when the parameter `date` is 2022-06-30', async () => { - const targetDate = '2022-06-30'; - await logRequest.get(`/dashboard/users/active?date=${targetDate}`); + const targetDate = new Date(2022, 5, 30); + await logRequest.get(`/dashboard/users/active?date=${formatToQueryDate(targetDate)}`); expect(getDailyActiveUserCountsByTimeInterval).toHaveBeenCalledWith( - dayjs('2022-05-31').endOf('day').valueOf(), - dayjs(targetDate).endOf('day').valueOf() + endOfDay(new Date(2022, 4, 31)).valueOf(), + endOfDay(targetDate).valueOf() ); }); it('should call getDailyActiveUserCountsByTimeInterval with the time interval (30 days ago, tomorrow] when there is no parameter `date`', async () => { await logRequest.get('/dashboard/users/active'); expect(getDailyActiveUserCountsByTimeInterval).toHaveBeenCalledWith( - dayjs('2022-04-30').endOf('day').valueOf(), - dayjs(mockToday).endOf('day').valueOf() + endOfDay(new Date(2022, 3, 30)).valueOf(), + endOfDay(mockToday).valueOf() ); }); it('should call countActiveUsersByTimeInterval with correct parameters when the parameter `date` is 2022-06-30', async () => { - const targetDate = '2022-06-30'; - await logRequest.get(`/dashboard/users/active?date=${targetDate}`); + const targetDate = new Date(2022, 5, 30); + await logRequest.get(`/dashboard/users/active?date=${formatToQueryDate(targetDate)}`); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 1, - dayjs('2022-06-16').endOf('day').valueOf(), - dayjs('2022-06-23').endOf('day').valueOf() + endOfDay(new Date(2022, 5, 16)).valueOf(), + endOfDay(new Date(2022, 5, 23)).valueOf() ); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 2, - dayjs('2022-06-23').endOf('day').valueOf(), - dayjs(targetDate).endOf('day').valueOf() + endOfDay(new Date(2022, 5, 23)).valueOf(), + endOfDay(targetDate).valueOf() ); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 3, - dayjs('2022-05-01').endOf('day').valueOf(), - dayjs('2022-05-31').endOf('day').valueOf() + endOfDay(new Date(2022, 4, 1)).valueOf(), + endOfDay(new Date(2022, 4, 31)).valueOf() ); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 4, - dayjs('2022-05-31').endOf('day').valueOf(), - dayjs(targetDate).endOf('day').valueOf() + endOfDay(new Date(2022, 4, 31)).valueOf(), + endOfDay(targetDate).valueOf() ); }); @@ -162,23 +167,23 @@ describe('dashboardRoutes', () => { await logRequest.get('/dashboard/users/active'); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 1, - dayjs('2022-05-16').endOf('day').valueOf(), - dayjs('2022-05-23').endOf('day').valueOf() + endOfDay(new Date(2022, 4, 16)).valueOf(), + endOfDay(new Date(2022, 4, 23)).valueOf() ); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 2, - dayjs('2022-05-23').endOf('day').valueOf(), - dayjs(mockToday).endOf('day').valueOf() + endOfDay(new Date(2022, 4, 23)).valueOf(), + endOfDay(mockToday).valueOf() ); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 3, - dayjs('2022-03-31').endOf('day').valueOf(), - dayjs('2022-04-30').endOf('day').valueOf() + endOfDay(new Date(2022, 2, 31)).valueOf(), + endOfDay(new Date(2022, 3, 30)).valueOf() ); expect(countActiveUsersByTimeInterval).toHaveBeenNthCalledWith( 4, - dayjs('2022-04-30').endOf('day').valueOf(), - dayjs(mockToday).endOf('day').valueOf() + endOfDay(new Date(2022, 3, 30)).valueOf(), + endOfDay(mockToday).valueOf() ); }); diff --git a/packages/core/src/routes/dashboard.ts b/packages/core/src/routes/dashboard.ts index c708fb060..c26475857 100644 --- a/packages/core/src/routes/dashboard.ts +++ b/packages/core/src/routes/dashboard.ts @@ -1,6 +1,5 @@ import { dateRegex } from '@logto/core-kit'; -import type { Dayjs } from 'dayjs'; -import dayjs from 'dayjs'; +import { endOfDay, format, parse, startOfDay, subDays } from 'date-fns'; import { object, string } from 'zod'; import koaGuard from '@/middleware/koa-guard'; @@ -12,11 +11,11 @@ import { countUsers, getDailyNewUserCountsByTimeInterval } from '@/queries/user' import type { AuthedRouter } from './types'; -const getDateString = (day: Dayjs) => day.format('YYYY-MM-DD'); +const getDateString = (date: Date | number) => format(date, 'yyyy-MM-dd'); const indices = (length: number) => [...Array.from({ length }).keys()]; -const lastTimestampOfDay = (day: Dayjs) => day.endOf('day').valueOf(); +const getEndOfDayTimestamp = (date: Date | number) => endOfDay(date).valueOf(); export default function dashboardRoutes(router: T) { router.get('/dashboard/users/total', async (ctx, next) => { @@ -27,11 +26,11 @@ export default function dashboardRoutes(router: T) { }); router.get('/dashboard/users/new', async (ctx, next) => { - const today = dayjs(); + const today = Date.now(); const dailyNewUserCounts = await getDailyNewUserCountsByTimeInterval( // (14 days ago 23:59:59.999, today 23:59:59.999] - lastTimestampOfDay(today.subtract(14, 'day')), - lastTimestampOfDay(today) + getEndOfDayTimestamp(subDays(today, 14)), + getEndOfDayTimestamp(today) ); const last14DaysNewUserCounts = new Map( @@ -39,15 +38,15 @@ export default function dashboardRoutes(router: T) { ); const todayNewUserCount = last14DaysNewUserCounts.get(getDateString(today)) ?? 0; - const yesterday = today.subtract(1, 'day'); + const yesterday = subDays(today, 1); const yesterdayNewUserCount = last14DaysNewUserCounts.get(getDateString(yesterday)) ?? 0; const todayDelta = todayNewUserCount - yesterdayNewUserCount; const last7DaysNewUserCount = indices(7) - .map((index) => getDateString(today.subtract(index, 'day'))) + .map((index) => getDateString(subDays(today, index))) .reduce((sum, date) => sum + (last14DaysNewUserCounts.get(date) ?? 0), 0); const newUserCountFrom13DaysAgoTo7DaysAgo = indices(7) - .map((index) => getDateString(today.subtract(7 + index, 'day'))) + .map((index) => getDateString(subDays(today, index + 7))) .reduce((sum, date) => sum + (last14DaysNewUserCounts.get(date) ?? 0), 0); const last7DaysDelta = last7DaysNewUserCount - newUserCountFrom13DaysAgoTo7DaysAgo; @@ -75,7 +74,7 @@ export default function dashboardRoutes(router: T) { query: { date }, } = ctx.guard; - const targetDay = date ? dayjs(date) : dayjs(); // Defaults to today + const targetDay = date ? parse(date, 'yyyy-MM-dd', startOfDay(Date.now())) : Date.now(); // Defaults to today const [ // DAU: Daily Active User last30DauCounts, @@ -88,39 +87,39 @@ export default function dashboardRoutes(router: T) { ] = await Promise.all([ getDailyActiveUserCountsByTimeInterval( // (30 days ago 23:59:59.999, target day 23:59:59.999] - lastTimestampOfDay(targetDay.subtract(30, 'day')), - lastTimestampOfDay(targetDay) + getEndOfDayTimestamp(subDays(targetDay, 30)), + getEndOfDayTimestamp(targetDay) ), countActiveUsersByTimeInterval( // (14 days ago 23:59:59.999, 7 days ago 23:59:59.999] - lastTimestampOfDay(targetDay.subtract(14, 'day')), - lastTimestampOfDay(targetDay.subtract(7, 'day')) + getEndOfDayTimestamp(subDays(targetDay, 14)), + getEndOfDayTimestamp(subDays(targetDay, 7)) ), countActiveUsersByTimeInterval( // (7 days ago 23:59:59.999, target day 23:59:59.999] - lastTimestampOfDay(targetDay.subtract(7, 'day')), - lastTimestampOfDay(targetDay) + getEndOfDayTimestamp(subDays(targetDay, 7)), + getEndOfDayTimestamp(targetDay) ), countActiveUsersByTimeInterval( // (60 days ago 23:59:59.999, 30 days ago 23:59:59.999] - lastTimestampOfDay(targetDay.subtract(60, 'day')), - lastTimestampOfDay(targetDay.subtract(30, 'day')) + getEndOfDayTimestamp(subDays(targetDay, 60)), + getEndOfDayTimestamp(subDays(targetDay, 30)) ), countActiveUsersByTimeInterval( // (30 days ago 23:59:59.999, target day 23:59:59.999] - lastTimestampOfDay(targetDay.subtract(30, 'day')), - lastTimestampOfDay(targetDay) + getEndOfDayTimestamp(subDays(targetDay, 30)), + getEndOfDayTimestamp(targetDay) ), ]); - const previousDate = getDateString(targetDay.subtract(1, 'day')); + const previousDate = getDateString(subDays(targetDay, 1)); const targetDate = getDateString(targetDay); const previousDAU = last30DauCounts.find(({ date }) => date === previousDate)?.count ?? 0; const dau = last30DauCounts.find(({ date }) => date === targetDate)?.count ?? 0; const dauCurve = indices(30).map((index) => { - const dateString = getDateString(targetDay.subtract(29 - index, 'day')); + const dateString = getDateString(subDays(targetDay, 29 - index)); const count = last30DauCounts.find(({ date }) => date === dateString)?.count ?? 0; return { date: dateString, count }; diff --git a/packages/core/src/routes/session/forgot-password.test.ts b/packages/core/src/routes/session/forgot-password.test.ts index 3182843c7..59c355de8 100644 --- a/packages/core/src/routes/session/forgot-password.test.ts +++ b/packages/core/src/routes/session/forgot-password.test.ts @@ -1,6 +1,6 @@ import type { User } from '@logto/schemas'; import { PasscodeType } from '@logto/schemas'; -import dayjs from 'dayjs'; +import { addDays, subDays } from 'date-fns'; import { Provider } from 'oidc-provider'; import { mockPasswordEncrypted, mockUserWithPassword } from '@/__mocks__'; @@ -15,6 +15,8 @@ const encryptUserPassword = jest.fn(async (password: string) => ({ })); const findUserById = jest.fn(async (): Promise => mockUserWithPassword); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ userId: 'id' })); +const getYesterdayDate = () => subDays(Date.now(), 1); +const getTomorrowDate = () => addDays(Date.now(), 1); jest.mock('@/lib/user', () => ({ ...jest.requireActual('@/lib/user'), @@ -84,7 +86,7 @@ describe('session -> forgotPasswordRoutes', () => { result: { verification: { userId: 'id', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowDate().toISOString(), flow: PasscodeType.ForgotPassword, }, }, @@ -105,7 +107,7 @@ describe('session -> forgotPasswordRoutes', () => { interactionDetails.mockResolvedValueOnce({ result: { verification: { - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowDate().toISOString(), flow: PasscodeType.ForgotPassword, }, }, @@ -121,7 +123,7 @@ describe('session -> forgotPasswordRoutes', () => { result: { verification: { userId: 'id', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowDate().toISOString(), flow: PasscodeType.SignIn, }, }, @@ -165,7 +167,7 @@ describe('session -> forgotPasswordRoutes', () => { result: { verification: { userId: 'id', - expiresAt: dayjs().subtract(1, 'day').toISOString(), + expiresAt: getYesterdayDate().toISOString(), flow: PasscodeType.ForgotPassword, }, }, @@ -181,7 +183,7 @@ describe('session -> forgotPasswordRoutes', () => { result: { verification: { userId: 'id', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowDate().toISOString(), flow: PasscodeType.ForgotPassword, }, }, @@ -198,7 +200,7 @@ describe('session -> forgotPasswordRoutes', () => { result: { verification: { userId: 'id', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowDate().toISOString(), flow: PasscodeType.ForgotPassword, }, }, diff --git a/packages/core/src/routes/session/passwordless.test.ts b/packages/core/src/routes/session/passwordless.test.ts index b0c2688ef..a49fcd19a 100644 --- a/packages/core/src/routes/session/passwordless.test.ts +++ b/packages/core/src/routes/session/passwordless.test.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines */ import type { User } from '@logto/schemas'; import { PasscodeType } from '@logto/schemas'; -import dayjs from 'dayjs'; +import { addDays, addSeconds, subDays } from 'date-fns'; import { Provider } from 'oidc-provider'; import { mockUser } from '@/__mocks__'; @@ -15,6 +15,7 @@ import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless'; const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); const findUserById = jest.fn(async (): Promise => mockUser); const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' })); +const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString(); jest.mock('@/lib/user', () => ({ generateUserId: () => 'user1', @@ -200,7 +201,7 @@ describe('session -> passwordlessRoutes', () => { verification: { flow: PasscodeType.SignIn, phone: '13000000000', - expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), }, }) ); @@ -224,7 +225,7 @@ describe('session -> passwordlessRoutes', () => { verification: { flow: PasscodeType.Register, phone: '13000000000', - expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), }, }) ); @@ -248,7 +249,7 @@ describe('session -> passwordlessRoutes', () => { expect.objectContaining({ verification: { userId: 'id', - expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), flow: PasscodeType.ForgotPassword, }, }) @@ -299,7 +300,7 @@ describe('session -> passwordlessRoutes', () => { verification: { flow: PasscodeType.SignIn, email: 'a@a.com', - expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), }, }) ); @@ -322,7 +323,7 @@ describe('session -> passwordlessRoutes', () => { verification: { flow: PasscodeType.Register, email: 'a@a.com', - expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), }, }) ); @@ -346,7 +347,7 @@ describe('session -> passwordlessRoutes', () => { expect.objectContaining({ verification: { userId: 'id', - expiresAt: dayjs(fakeTime).add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(), flow: PasscodeType.ForgotPassword, }, }) @@ -382,7 +383,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000000', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -406,7 +407,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000000', flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -427,7 +428,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { phone: '13000000000', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -441,7 +442,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000000', flow: PasscodeType.ForgotPassword, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -469,7 +470,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000000', flow: PasscodeType.SignIn, - expiresAt: dayjs().subtract(1, 'day').toISOString(), + expiresAt: subDays(Date.now(), 1).toISOString(), }, }, }); @@ -483,7 +484,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'XX@foo', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -497,7 +498,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000001', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -517,7 +518,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'a@a.com', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -541,7 +542,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'a@a.com', flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -564,7 +565,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { email: 'a@a.com', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -578,7 +579,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'a@a.com', flow: PasscodeType.ForgotPassword, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -591,7 +592,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -605,7 +606,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'b@a.com', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -625,7 +626,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000001', flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -647,7 +648,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000001', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -668,7 +669,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { phone: '13000000001', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -682,7 +683,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000001', flow: PasscodeType.ForgotPassword, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -695,7 +696,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -709,7 +710,7 @@ describe('session -> passwordlessRoutes', () => { verification: { phone: '13000000000', flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -729,7 +730,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'b@a.com', flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -751,7 +752,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'b@a.com', flow: PasscodeType.SignIn, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -772,7 +773,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { email: 'b@a.com', - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -786,7 +787,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'b@a.com', flow: PasscodeType.ForgotPassword, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -799,7 +800,7 @@ describe('session -> passwordlessRoutes', () => { result: { verification: { flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); @@ -813,7 +814,7 @@ describe('session -> passwordlessRoutes', () => { verification: { email: 'a@a.com', flow: PasscodeType.Register, - expiresAt: dayjs().add(1, 'day').toISOString(), + expiresAt: getTomorrowIsoString(), }, }, }); diff --git a/packages/core/src/routes/session/utils.ts b/packages/core/src/routes/session/utils.ts index 8f15038b5..baa70d546 100644 --- a/packages/core/src/routes/session/utils.ts +++ b/packages/core/src/routes/session/utils.ts @@ -1,7 +1,7 @@ import type { LogType, PasscodeType } from '@logto/schemas'; import { logTypeGuard } from '@logto/schemas'; import type { Truthy } from '@silverhand/essentials'; -import dayjs from 'dayjs'; +import { addSeconds, isAfter, isValid, parseISO } from 'date-fns'; import type { Context } from 'koa'; import type { Provider } from 'oidc-provider'; import type { ZodType } from 'zod'; @@ -60,8 +60,9 @@ export const getVerificationStorageFromInteraction = async { + const parsed = parseISO(expiresAt); assertThat( - dayjs(expiresAt).isValid() && dayjs(expiresAt).isAfter(dayjs()), + isValid(parsed) && isAfter(parsed, Date.now()), new RequestError({ code: 'session.verification_expired', status: 401 }) ); }; @@ -75,7 +76,7 @@ export const assignVerificationResult = async ( ) => { const verification: VerificationStorage = { ...verificationData, - expiresAt: dayjs().add(verificationTimeout, 'second').toISOString(), + expiresAt: addSeconds(Date.now(), verificationTimeout).toISOString(), }; await provider.interactionResult(ctx.req, ctx.res, { diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 1b6c75726..57e726479 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@jest/types": "^29.1.2", + "@logto/js": "1.0.0-beta.11", "@logto/node": "1.0.0-beta.12", "@logto/schemas": "workspace:^", "@peculiar/webcrypto": "^1.3.3", diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 75dfe7047..e5129e839 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -10,7 +10,7 @@ import { extractCookie } from '@/utils'; import { MemoryStorage } from './storage'; -const defaultConfig = { +export const defaultConfig = { endpoint: logtoUrl, appId: demoAppApplicationId, persistAccessToken: false, @@ -18,8 +18,8 @@ const defaultConfig = { export default class MockClient { public interactionCookie?: string; - private navigateUrl?: string; + private navigateUrl?: string; private readonly storage: MemoryStorage; private readonly logto: LogtoClient; @@ -88,6 +88,10 @@ export default class MockClient { return this.logto.getAccessToken(resource); } + public async getRefreshToken() { + return this.logto.getRefreshToken(); + } + public async signOut(postSignOutRedirectUri?: string) { return this.logto.signOut(postSignOutRedirectUri); } diff --git a/packages/integration-tests/src/include.d/node-fetch.d.ts b/packages/integration-tests/src/include.d/node-fetch.d.ts new file mode 100644 index 000000000..dd1a8efbe --- /dev/null +++ b/packages/integration-tests/src/include.d/node-fetch.d.ts @@ -0,0 +1,4 @@ +declare module 'node-fetch' { + const nodeFetch: typeof fetch; + export = nodeFetch; +} diff --git a/packages/integration-tests/tests/api/get-access-token.test.ts b/packages/integration-tests/tests/api/get-access-token.test.ts index 94e10e720..da220cdf4 100644 --- a/packages/integration-tests/tests/api/get-access-token.test.ts +++ b/packages/integration-tests/tests/api/get-access-token.test.ts @@ -1,8 +1,13 @@ +import path from 'path'; + +import { fetchTokenByRefreshToken } from '@logto/js'; import { managementResource } from '@logto/schemas/lib/seeds'; import { assert } from '@silverhand/essentials'; +import fetch from 'node-fetch'; import { signInWithUsernameAndPassword } from '@/api'; -import MockClient from '@/client'; +import MockClient, { defaultConfig } from '@/client'; +import { logtoUrl } from '@/constants'; import { createUserByAdmin } from '@/helpers'; import { generateUsername, generatePassword } from '@/utils'; @@ -36,4 +41,41 @@ describe('get access token', () => { // Request for invalid resource should throw void expect(client.getAccessToken('api.foo.com')).rejects.toThrow(); }); + + it('sign-in and get multiple Access Token by the same Refresh Token within refreshTokenReuseInterval', async () => { + const client = new MockClient({ resources: [managementResource.indicator] }); + await client.initSession(); + assert(client.interactionCookie, new Error('Session not found')); + + const { redirectTo } = await signInWithUsernameAndPassword( + username, + password, + client.interactionCookie + ); + + await client.processSession(redirectTo); + assert(client.isAuthenticated, new Error('Sign in get get access token failed')); + + const refreshToken = await client.getRefreshToken(); + assert(refreshToken, new Error('No Refresh Token found')); + + const getAccessTokenByRefreshToken = async () => + fetchTokenByRefreshToken( + { + clientId: defaultConfig.appId, + tokenEndpoint: path.join(logtoUrl, '/oidc/token'), + refreshToken, + resource: managementResource.indicator, + }, + async (...args: Parameters): Promise => { + const response = await fetch(...args); + assert(response.ok, new Error('Request error')); + + return response.json(); + } + ); + + // Allow to use the same refresh token to fetch access token within short time period + await Promise.all([getAccessTokenByRefreshToken(), getAccessTokenByRefreshToken()]); + }); }); diff --git a/packages/shared/package.json b/packages/shared/package.json index c44c5ab15..cf5fe89a6 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -45,7 +45,6 @@ "dependencies": { "@logto/schemas": "workspace:^", "@silverhand/essentials": "^1.3.0", - "dayjs": "^1.10.5", "find-up": "^5.0.0", "nanoid": "^3.3.4", "slonik": "^30.0.0" diff --git a/packages/shared/src/database/utils.test.ts b/packages/shared/src/database/utils.test.ts index 396fd981b..79653f147 100644 --- a/packages/shared/src/database/utils.test.ts +++ b/packages/shared/src/database/utils.test.ts @@ -1,4 +1,3 @@ -import dayjs from 'dayjs'; import { sql } from 'slonik'; import { SqlToken } from 'slonik/dist/src/tokens.js'; @@ -124,7 +123,7 @@ describe('convertToTimestamp()', () => { }); it('converts to sql per time parameter', () => { - const time = dayjs(123_123_123); + const time = new Date(123_123_123); expect(convertToTimestamp(time)).toEqual({ sql: 'to_timestamp($1)', diff --git a/packages/shared/src/database/utils.ts b/packages/shared/src/database/utils.ts index d18afdcf1..910e8e9b2 100644 --- a/packages/shared/src/database/utils.ts +++ b/packages/shared/src/database/utils.ts @@ -1,7 +1,6 @@ import type { SchemaValuePrimitive, SchemaValue } from '@logto/schemas'; import type { Falsy } from '@silverhand/essentials'; import { notFalsy } from '@silverhand/essentials'; -import dayjs from 'dayjs'; import type { SqlSqlToken, SqlToken, QueryResult, IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; @@ -76,7 +75,8 @@ export const convertToIdentifiers = ({ table, fields }: T, with }; }; -export const convertToTimestamp = (time = dayjs()) => sql`to_timestamp(${time.valueOf() / 1000})`; +export const convertToTimestamp = (time = new Date()) => + sql`to_timestamp(${time.valueOf() / 1000})`; export const manyRows = async (query: Promise>): Promise => { const { rows } = await query; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba76cd4de..e95443cba 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,7 +134,7 @@ importers: clean-deep: ^3.4.0 cross-env: ^7.0.3 csstype: ^3.0.11 - dayjs: ^1.10.5 + date-fns: ^2.29.3 deepmerge: ^4.2.2 dnd-core: ^16.0.0 eslint: ^8.21.0 @@ -170,6 +170,8 @@ importers: swr: ^1.3.0 typescript: ^4.7.4 zod: ^3.19.1 + dependencies: + date-fns: 2.29.3 devDependencies: '@fontsource/roboto-mono': 4.5.7 '@logto/core-kit': 1.0.0-beta.20 @@ -201,7 +203,6 @@ importers: clean-deep: 3.4.0 cross-env: 7.0.3 csstype: 3.0.11 - dayjs: 1.10.7 deepmerge: 4.2.2 dnd-core: 16.0.0 eslint: 8.21.0 @@ -271,7 +272,7 @@ importers: chalk: ^4 clean-deep: ^3.4.0 copyfiles: ^2.4.1 - dayjs: ^1.10.5 + date-fns: ^2.29.3 debug: ^4.3.4 decamelize: ^5.0.0 deepmerge: ^4.2.2 @@ -329,7 +330,7 @@ importers: '@silverhand/essentials': 1.3.0 chalk: 4.1.2 clean-deep: 3.4.0 - dayjs: 1.10.7 + date-fns: 2.29.3 debug: 4.3.4 decamelize: 5.0.1 deepmerge: 4.2.2 @@ -462,6 +463,7 @@ importers: packages/integration-tests: specifiers: '@jest/types': ^29.1.2 + '@logto/js': 1.0.0-beta.11 '@logto/node': 1.0.0-beta.12 '@logto/schemas': workspace:^ '@peculiar/webcrypto': ^1.3.3 @@ -487,6 +489,7 @@ importers: typescript: ^4.7.4 devDependencies: '@jest/types': 29.1.2 + '@logto/js': 1.0.0-beta.11 '@logto/node': 1.0.0-beta.12 '@logto/schemas': link:../schemas '@peculiar/webcrypto': 1.3.3 @@ -623,7 +626,6 @@ importers: '@silverhand/ts-config': 1.2.1 '@types/jest': ^29.1.2 '@types/node': ^16.0.0 - dayjs: ^1.10.5 eslint: ^8.21.0 find-up: ^5.0.0 jest: ^29.1.2 @@ -635,7 +637,6 @@ importers: dependencies: '@logto/schemas': link:../schemas '@silverhand/essentials': 1.3.0 - dayjs: 1.10.7 find-up: 5.0.0 nanoid: 3.3.4 slonik: 30.1.2 @@ -3467,7 +3468,7 @@ packages: '@jest/types': 29.1.2 deepmerge: 4.2.2 identity-obj-proxy: 3.0.0 - jest: 29.1.2_k5ytkvaprncdyzidqqws5bqksq + jest: 29.1.2_@types+node@16.11.12 jest-matcher-specific-error: 1.0.0 jest-transform-stub: 2.0.0 ts-jest: 29.0.3_37jxomqt5oevoqzq6g3r6n3ili @@ -5648,8 +5649,10 @@ packages: whatwg-url: 11.0.0 dev: true - /dayjs/1.10.7: - resolution: {integrity: sha512-P6twpd70BcPK34K26uJ1KT3wlhpuOAPoMwJzpsIWUxHZ7wpmbdZL/hQqBDfz7hGurYSa5PhzdhDHtt319hL3ig==} + /date-fns/2.29.3: + resolution: {integrity: sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==} + engines: {node: '>=0.11'} + dev: false /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} @@ -13451,7 +13454,7 @@ packages: '@jest/types': 29.1.2 bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.1.2_k5ytkvaprncdyzidqqws5bqksq + jest: 29.1.2_@types+node@16.11.12 jest-util: 29.2.1 json5: 2.2.1 lodash.memoize: 4.1.2