mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
Merge pull request #2704 from logto-io/gao-log-4638-core-koahooks-middleware-function
feat(core): interaction hooks
This commit is contained in:
commit
5bd2b31eb6
25 changed files with 442 additions and 167 deletions
|
@ -44,6 +44,7 @@
|
||||||
"dotenv": "^16.0.0",
|
"dotenv": "^16.0.0",
|
||||||
"etag": "^1.8.1",
|
"etag": "^1.8.1",
|
||||||
"find-up": "^6.3.0",
|
"find-up": "^6.3.0",
|
||||||
|
"got": "^12.5.3",
|
||||||
"hash-wasm": "^4.9.0",
|
"hash-wasm": "^4.9.0",
|
||||||
"i18next": "^21.8.16",
|
"i18next": "^21.8.16",
|
||||||
"iconv-lite": "0.6.3",
|
"iconv-lite": "0.6.3",
|
||||||
|
|
|
@ -87,6 +87,9 @@ function createEnvSet() {
|
||||||
|
|
||||||
return queryClient;
|
return queryClient;
|
||||||
},
|
},
|
||||||
|
get queryClientSafe() {
|
||||||
|
return queryClient;
|
||||||
|
},
|
||||||
get oidc() {
|
get oidc() {
|
||||||
if (!oidc) {
|
if (!oidc) {
|
||||||
return throwNotLoadedError();
|
return throwNotLoadedError();
|
||||||
|
|
|
@ -18,7 +18,7 @@ export const grantListener = (
|
||||||
const { params } = ctx.oidc;
|
const { params } = ctx.oidc;
|
||||||
|
|
||||||
const log = ctx.createLog(
|
const log = ctx.createLog(
|
||||||
`${token.Flow.ExchangeTokenBy}.${getExchangeByType(params?.grant_type)}`
|
`${token.Type.ExchangeTokenBy}.${getExchangeByType(params?.grant_type)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const { access_token, refresh_token, id_token, scope } = ctx.body;
|
const { access_token, refresh_token, id_token, scope } = ctx.body;
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
|
import { noop } from '@silverhand/essentials';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
|
|
||||||
import initApp from './app/init.js';
|
|
||||||
import { configDotEnv } from './env-set/dot-env.js';
|
import { configDotEnv } from './env-set/dot-env.js';
|
||||||
import envSet from './env-set/index.js';
|
import envSet from './env-set/index.js';
|
||||||
import initI18n from './i18n/init.js';
|
import initI18n from './i18n/init.js';
|
||||||
|
|
||||||
// Update after we migrate to ESM
|
try {
|
||||||
// eslint-disable-next-line unicorn/prefer-top-level-await
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
await configDotEnv();
|
await configDotEnv();
|
||||||
await envSet.load();
|
await envSet.load();
|
||||||
const app = new Koa({
|
const app = new Koa({
|
||||||
proxy: envSet.values.trustProxyHeader,
|
proxy: envSet.values.trustProxyHeader,
|
||||||
});
|
});
|
||||||
await initI18n();
|
await initI18n();
|
||||||
|
|
||||||
|
// Import last until init completed
|
||||||
|
const { default: initApp } = await import('./app/init.js');
|
||||||
await initApp(app);
|
await initApp(app);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.log('Error while initializing app', error);
|
console.error('Error while initializing app:');
|
||||||
await envSet.poolSafe?.end();
|
console.error(error);
|
||||||
}
|
|
||||||
})();
|
await Promise.all([envSet.poolSafe?.end(), envSet.queryClientSafe?.end()]).catch(noop);
|
||||||
|
}
|
||||||
|
|
89
packages/core/src/libraries/hook.test.ts
Normal file
89
packages/core/src/libraries/hook.test.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import { Event } from '@logto/schemas';
|
||||||
|
import { HookEvent } from '@logto/schemas/lib/models/hooks.js';
|
||||||
|
import { mockEsm, mockEsmDefault } from '@logto/shared/esm';
|
||||||
|
import type { InferModelType } from '@withtyped/server';
|
||||||
|
import { got } from 'got';
|
||||||
|
|
||||||
|
import modelRouters from '#src/model-routers/index.js';
|
||||||
|
import { MockQueryClient } from '#src/test-utils/query-client.js';
|
||||||
|
|
||||||
|
import type { Interaction } from './hook.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
|
||||||
|
const queryClient = new MockQueryClient();
|
||||||
|
const queryFunction = jest.fn();
|
||||||
|
|
||||||
|
const url = 'https://logto.gg';
|
||||||
|
const hook: InferModelType<typeof modelRouters.hook.model> = {
|
||||||
|
id: 'foo',
|
||||||
|
event: HookEvent.PostSignIn,
|
||||||
|
config: { headers: { bar: 'baz' }, url, retries: 3 },
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
const readAll = jest
|
||||||
|
.spyOn(modelRouters.hook.client, 'readAll')
|
||||||
|
.mockResolvedValue({ rows: [hook], rowCount: 1 });
|
||||||
|
|
||||||
|
// @ts-expect-error for testing
|
||||||
|
const post = jest.spyOn(got, 'post').mockImplementation(jest.fn(() => ({ json: jest.fn() })));
|
||||||
|
|
||||||
|
mockEsm('#src/queries/user.js', () => ({
|
||||||
|
findUserById: () => ({ id: 'user_id', username: 'user', extraField: 'not_ok' }),
|
||||||
|
}));
|
||||||
|
mockEsm('#src/queries/application.js', () => ({
|
||||||
|
findApplicationById: () => ({ id: 'app_id', extraField: 'not_ok' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||||
|
mockEsmDefault('#src/env-set/create-query-client-by-env.js', () => () => queryClient);
|
||||||
|
jest.spyOn(queryClient, 'query').mockImplementation(queryFunction);
|
||||||
|
|
||||||
|
const { triggerInteractionHooksIfNeeded } = await import('./hook.js');
|
||||||
|
|
||||||
|
describe('triggerInteractionHooksIfNeeded()', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return if no user ID found', async () => {
|
||||||
|
await triggerInteractionHooksIfNeeded();
|
||||||
|
|
||||||
|
expect(queryFunction).not.toBeCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set correct payload when hook triggered', async () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(100_000);
|
||||||
|
|
||||||
|
await triggerInteractionHooksIfNeeded(
|
||||||
|
// @ts-expect-error for testing
|
||||||
|
{
|
||||||
|
jti: 'some_jti',
|
||||||
|
result: {
|
||||||
|
login: { accountId: '123' },
|
||||||
|
event: Event.SignIn,
|
||||||
|
identifier: { connectorId: 'bar' },
|
||||||
|
},
|
||||||
|
params: { client_id: 'some_client' },
|
||||||
|
} as Interaction
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(readAll).toHaveBeenCalled();
|
||||||
|
expect(post).toHaveBeenCalledWith(url, {
|
||||||
|
headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' },
|
||||||
|
json: {
|
||||||
|
hookId: 'foo',
|
||||||
|
event: 'PostSignIn',
|
||||||
|
interactionEvent: 'SignIn',
|
||||||
|
sessionId: 'some_jti',
|
||||||
|
userId: '123',
|
||||||
|
user: { id: 'user_id', username: 'user' },
|
||||||
|
application: { id: 'app_id' },
|
||||||
|
createdAt: new Date(100_000).toISOString(),
|
||||||
|
},
|
||||||
|
retry: { limit: 3 },
|
||||||
|
timeout: { request: 10_000 },
|
||||||
|
});
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
72
packages/core/src/libraries/hook.ts
Normal file
72
packages/core/src/libraries/hook.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { Event, userInfoSelectFields } from '@logto/schemas';
|
||||||
|
import { HookEventPayload, HookEvent } from '@logto/schemas/models';
|
||||||
|
import { trySafe } from '@logto/shared';
|
||||||
|
import { conditional, pick } from '@silverhand/essentials';
|
||||||
|
import { got } from 'got';
|
||||||
|
import type { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
|
import modelRouters from '#src/model-routers/index.js';
|
||||||
|
import { findApplicationById } from '#src/queries/application.js';
|
||||||
|
import { findUserById } from '#src/queries/user.js';
|
||||||
|
import { getInteractionStorage } from '#src/routes/interaction/utils/interaction.js';
|
||||||
|
|
||||||
|
const eventToHook: Record<Event, HookEvent> = {
|
||||||
|
[Event.Register]: HookEvent.PostRegister,
|
||||||
|
[Event.SignIn]: HookEvent.PostSignIn,
|
||||||
|
[Event.ForgotPassword]: HookEvent.PostResetPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||||
|
|
||||||
|
export const triggerInteractionHooksIfNeeded = async (
|
||||||
|
details?: Interaction,
|
||||||
|
userAgent?: string
|
||||||
|
) => {
|
||||||
|
const userId = details?.result?.login?.accountId;
|
||||||
|
const sessionId = details?.jti;
|
||||||
|
const applicationId = details?.params.client_id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interactionPayload = getInteractionStorage(details.result);
|
||||||
|
const { event } = interactionPayload;
|
||||||
|
|
||||||
|
const hookEvent = eventToHook[event];
|
||||||
|
const { rows } = await modelRouters.hook.client.readAll();
|
||||||
|
|
||||||
|
const [user, application] = await Promise.all([
|
||||||
|
trySafe(findUserById(userId)),
|
||||||
|
trySafe(async () =>
|
||||||
|
conditional(typeof applicationId === 'string' && (await findApplicationById(applicationId)))
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
event: hookEvent,
|
||||||
|
interactionEvent: event,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
sessionId,
|
||||||
|
userAgent,
|
||||||
|
userId,
|
||||||
|
user: user && pick(user, ...userInfoSelectFields),
|
||||||
|
application: application && pick(application, 'id', 'type', 'name', 'description'),
|
||||||
|
} satisfies Omit<HookEventPayload, 'hookId'>;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
rows
|
||||||
|
.filter(({ event }) => event === hookEvent)
|
||||||
|
.map(async ({ config: { url, headers, retries }, id }) => {
|
||||||
|
const json: HookEventPayload = { hookId: id, ...payload };
|
||||||
|
await got
|
||||||
|
.post(url, {
|
||||||
|
headers: { 'user-agent': 'Logto (https://logto.io)', ...headers },
|
||||||
|
json,
|
||||||
|
retry: { limit: retries },
|
||||||
|
timeout: { request: 10_000 },
|
||||||
|
})
|
||||||
|
.json();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
10
packages/core/src/model-routers/index.ts
Normal file
10
packages/core/src/model-routers/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { Hooks } from '@logto/schemas/lib/models/hooks.js';
|
||||||
|
import { createModelRouter } from '@withtyped/postgres';
|
||||||
|
|
||||||
|
import envSet from '#src/env-set/index.js';
|
||||||
|
|
||||||
|
const modelRouters = {
|
||||||
|
hook: createModelRouter(Hooks, envSet.queryClient).withCrud(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default modelRouters;
|
|
@ -59,7 +59,7 @@ export const getDailyActiveUserCountsByTimeInterval = async (
|
||||||
from ${table}
|
from ${table}
|
||||||
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
|
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
|
||||||
and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000)
|
and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000)
|
||||||
and ${fields.key} like ${`${token.Flow.ExchangeTokenBy}.%`}
|
and ${fields.key} like ${`${token.Type.ExchangeTokenBy}.%`}
|
||||||
and ${fields.payload}->>'result' = 'Success'
|
and ${fields.payload}->>'result' = 'Success'
|
||||||
group by date(${fields.createdAt})
|
group by date(${fields.createdAt})
|
||||||
`);
|
`);
|
||||||
|
@ -73,6 +73,6 @@ export const countActiveUsersByTimeInterval = async (
|
||||||
from ${table}
|
from ${table}
|
||||||
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
|
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::double precision / 1000)
|
||||||
and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000)
|
and ${fields.createdAt} <= to_timestamp(${endTimeInclusive}::double precision / 1000)
|
||||||
and ${fields.key} like ${`${token.Flow.ExchangeTokenBy}.%`}
|
and ${fields.key} like ${`${token.Type.ExchangeTokenBy}.%`}
|
||||||
and ${fields.payload}->>'result' = 'Success'
|
and ${fields.payload}->>'result' = 'Success'
|
||||||
`);
|
`);
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
import { Hooks } from '@logto/schemas/models';
|
|
||||||
import { createModelRouter } from '@withtyped/postgres';
|
|
||||||
import { koaAdapter, RequestError } from '@withtyped/server';
|
import { koaAdapter, RequestError } from '@withtyped/server';
|
||||||
import type { MiddlewareType } from 'koa';
|
import type { MiddlewareType } from 'koa';
|
||||||
import koaBody from 'koa-body';
|
import koaBody from 'koa-body';
|
||||||
|
|
||||||
import envSet from '#src/env-set/index.js';
|
|
||||||
import LogtoRequestError from '#src/errors/RequestError/index.js';
|
import LogtoRequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import modelRouters from '#src/model-routers/index.js';
|
||||||
|
|
||||||
import type { AuthedRouter } from './types.js';
|
import type { AuthedRouter } from './types.js';
|
||||||
|
|
||||||
|
@ -26,7 +24,5 @@ const errorHandler: MiddlewareType = async (_, next) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function hookRoutes<T extends AuthedRouter>(router: T) {
|
export default function hookRoutes<T extends AuthedRouter>(router: T) {
|
||||||
const modelRouter = createModelRouter(Hooks, envSet.queryClient).withCrud();
|
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouters.hook.routes()));
|
||||||
|
|
||||||
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouter.routes()));
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,21 +45,22 @@ await mockEsmWithActual('#src/connectors/index.js', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
await mockEsmWithActual('#src/libraries/sign-in-experience/index.js', () => ({
|
||||||
|
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||||
|
}));
|
||||||
|
|
||||||
const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({
|
const { assignInteractionResults } = await mockEsmWithActual('#src/libraries/session.js', () => ({
|
||||||
assignInteractionResults: jest.fn(),
|
assignInteractionResults: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {
|
const { verifySignInModeSettings, verifyIdentifierSettings, verifyProfileSettings } = mockEsm(
|
||||||
getSignInExperience,
|
'./utils/sign-in-experience-validation.js',
|
||||||
verifySignInModeSettings,
|
() => ({
|
||||||
verifyIdentifierSettings,
|
|
||||||
verifyProfileSettings,
|
|
||||||
} = mockEsm('./utils/sign-in-experience-validation.js', () => ({
|
|
||||||
getSignInExperience: jest.fn(async () => mockSignInExperience),
|
|
||||||
verifySignInModeSettings: jest.fn(),
|
verifySignInModeSettings: jest.fn(),
|
||||||
verifyIdentifierSettings: jest.fn(),
|
verifyIdentifierSettings: jest.fn(),
|
||||||
verifyProfileSettings: jest.fn(),
|
verifyProfileSettings: jest.fn(),
|
||||||
}));
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
|
const submitInteraction = mockEsmDefault('./actions/submit-interaction.js', () => jest.fn());
|
||||||
|
|
||||||
|
@ -133,7 +134,6 @@ describe('session -> interactionRoutes', () => {
|
||||||
profile: { phone: '1234567890' },
|
profile: { phone: '1234567890' },
|
||||||
};
|
};
|
||||||
const response = await sessionRequest.put(path).send(body);
|
const response = await sessionRequest.put(path).send(body);
|
||||||
expect(getSignInExperience).toBeCalled();
|
|
||||||
expect(verifySignInModeSettings).toBeCalled();
|
expect(verifySignInModeSettings).toBeCalled();
|
||||||
expect(verifyIdentifierSettings).toBeCalled();
|
expect(verifyIdentifierSettings).toBeCalled();
|
||||||
expect(verifyProfileSettings).toBeCalled();
|
expect(verifyProfileSettings).toBeCalled();
|
||||||
|
@ -154,7 +154,7 @@ describe('session -> interactionRoutes', () => {
|
||||||
const path = `${interactionPrefix}/event`;
|
const path = `${interactionPrefix}/event`;
|
||||||
|
|
||||||
it('should call verifySignInModeSettings properly', async () => {
|
it('should call verifySignInModeSettings properly', async () => {
|
||||||
getInteractionStorage.mockResolvedValueOnce({
|
getInteractionStorage.mockReturnValueOnce({
|
||||||
event: Event.SignIn,
|
event: Event.SignIn,
|
||||||
});
|
});
|
||||||
const body = {
|
const body = {
|
||||||
|
@ -169,7 +169,7 @@ describe('session -> interactionRoutes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject if switch sign-in event to forgot-password directly', async () => {
|
it('should reject if switch sign-in event to forgot-password directly', async () => {
|
||||||
getInteractionStorage.mockResolvedValueOnce({
|
getInteractionStorage.mockReturnValueOnce({
|
||||||
event: Event.SignIn,
|
event: Event.SignIn,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -184,7 +184,7 @@ describe('session -> interactionRoutes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject if switch forgot-password to sign-in directly', async () => {
|
it('should reject if switch forgot-password to sign-in directly', async () => {
|
||||||
getInteractionStorage.mockResolvedValueOnce({
|
getInteractionStorage.mockReturnValueOnce({
|
||||||
event: Event.ForgotPassword,
|
event: Event.ForgotPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -272,7 +272,7 @@ describe('session -> interactionRoutes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
it('should not call validateMandatoryUserProfile for forgot password request', async () => {
|
||||||
getInteractionStorage.mockResolvedValueOnce({
|
getInteractionStorage.mockReturnValueOnce({
|
||||||
event: Event.ForgotPassword,
|
event: Event.ForgotPassword,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import type { LogtoErrorCode } from '@logto/phrases';
|
import type { LogtoErrorCode } from '@logto/phrases';
|
||||||
import { Event, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas';
|
import { Event, eventGuard, identifierPayloadGuard, profileGuard } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import type Router from 'koa-router';
|
||||||
import type { Provider } from 'oidc-provider';
|
import type { Provider } from 'oidc-provider';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
@ -12,6 +12,10 @@ import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
import type { AnonymousRouter } from '../types.js';
|
import type { AnonymousRouter } from '../types.js';
|
||||||
import submitInteraction from './actions/submit-interaction.js';
|
import submitInteraction from './actions/submit-interaction.js';
|
||||||
|
import koaInteractionDetails from './middleware/koa-interaction-details.js';
|
||||||
|
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
|
||||||
|
import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
|
||||||
|
import koaInteractionSie from './middleware/koa-interaction-sie.js';
|
||||||
import { sendPasscodePayloadGuard, socialAuthorizationUrlPayloadGuard } from './types/guard.js';
|
import { sendPasscodePayloadGuard, socialAuthorizationUrlPayloadGuard } from './types/guard.js';
|
||||||
import {
|
import {
|
||||||
getInteractionStorage,
|
getInteractionStorage,
|
||||||
|
@ -20,7 +24,6 @@ import {
|
||||||
} from './utils/interaction.js';
|
} from './utils/interaction.js';
|
||||||
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
|
import { sendPasscodeToIdentifier } from './utils/passcode-validation.js';
|
||||||
import {
|
import {
|
||||||
getSignInExperience,
|
|
||||||
verifySignInModeSettings,
|
verifySignInModeSettings,
|
||||||
verifyIdentifierSettings,
|
verifyIdentifierSettings,
|
||||||
verifyProfileSettings,
|
verifyProfileSettings,
|
||||||
|
@ -36,27 +39,19 @@ import {
|
||||||
export const interactionPrefix = '/interaction';
|
export const interactionPrefix = '/interaction';
|
||||||
export const verificationPath = 'verification';
|
export const verificationPath = 'verification';
|
||||||
|
|
||||||
|
type RouterContext<T> = T extends Router<unknown, infer Context> ? Context : never;
|
||||||
|
|
||||||
export default function interactionRoutes<T extends AnonymousRouter>(
|
export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
router: T,
|
anonymousRouter: T,
|
||||||
provider: Provider
|
provider: Provider
|
||||||
) {
|
) {
|
||||||
router.use(koaAuditLog(), async (ctx, next) => {
|
const router =
|
||||||
await next();
|
// @ts-expect-error for good koa types
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
// Prepend interaction context to log entries
|
(anonymousRouter as Router<unknown, WithInteractionDetailsContext<RouterContext<T>>>).use(
|
||||||
try {
|
koaAuditLog(),
|
||||||
const {
|
koaInteractionDetails(provider)
|
||||||
jti,
|
);
|
||||||
params: { client_id },
|
|
||||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
ctx.prependAllLogEntries({
|
|
||||||
sessionId: jti,
|
|
||||||
applicationId: conditional(typeof client_id === 'string' && client_id),
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(`Failed to get oidc provider interaction details`, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a new interaction
|
// Create a new interaction
|
||||||
router.put(
|
router.put(
|
||||||
|
@ -68,18 +63,19 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
profile: profileGuard.optional(),
|
profile: profileGuard.optional(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
koaInteractionSie(),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { event, identifier, profile } = ctx.guard.body;
|
const { event, identifier, profile } = ctx.guard.body;
|
||||||
const experience = await getSignInExperience(ctx, provider);
|
const { signInExperience } = ctx;
|
||||||
|
|
||||||
verifySignInModeSettings(event, experience);
|
verifySignInModeSettings(event, signInExperience);
|
||||||
|
|
||||||
if (identifier) {
|
if (identifier) {
|
||||||
verifyIdentifierSettings(identifier, experience);
|
verifyIdentifierSettings(identifier, signInExperience);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile) {
|
if (profile) {
|
||||||
verifyProfileSettings(profile, experience);
|
verifyProfileSettings(profile, signInExperience);
|
||||||
}
|
}
|
||||||
|
|
||||||
const verifiedIdentifier = identifier && [
|
const verifiedIdentifier = identifier && [
|
||||||
|
@ -102,7 +98,6 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
|
|
||||||
// Delete Interaction
|
// Delete Interaction
|
||||||
router.delete(interactionPrefix, async (ctx, next) => {
|
router.delete(interactionPrefix, async (ctx, next) => {
|
||||||
await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const error: LogtoErrorCode = 'oidc.aborted';
|
const error: LogtoErrorCode = 'oidc.aborted';
|
||||||
await assignInteractionResults(ctx, provider, { error });
|
await assignInteractionResults(ctx, provider, { error });
|
||||||
|
|
||||||
|
@ -113,11 +108,14 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
router.put(
|
router.put(
|
||||||
`${interactionPrefix}/event`,
|
`${interactionPrefix}/event`,
|
||||||
koaGuard({ body: z.object({ event: eventGuard }) }),
|
koaGuard({ body: z.object({ event: eventGuard }) }),
|
||||||
|
koaInteractionSie(),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const { event } = ctx.guard.body;
|
const { event } = ctx.guard.body;
|
||||||
verifySignInModeSettings(event, await getSignInExperience(ctx, provider));
|
const { signInExperience, interactionDetails } = ctx;
|
||||||
|
|
||||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
verifySignInModeSettings(event, signInExperience);
|
||||||
|
|
||||||
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
// Forgot Password specific event interaction storage can't be shared with other types of interactions
|
// Forgot Password specific event interaction storage can't be shared with other types of interactions
|
||||||
assertThat(
|
assertThat(
|
||||||
|
@ -143,11 +141,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: identifierPayloadGuard,
|
body: identifierPayloadGuard,
|
||||||
}),
|
}),
|
||||||
|
koaInteractionSie(),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const identifierPayload = ctx.guard.body;
|
const identifierPayload = ctx.guard.body;
|
||||||
verifyIdentifierSettings(identifierPayload, await getSignInExperience(ctx, provider));
|
const { signInExperience, interactionDetails } = ctx;
|
||||||
|
verifyIdentifierSettings(identifierPayload, signInExperience);
|
||||||
|
|
||||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
const verifiedIdentifier = await verifyIdentifierPayload(
|
const verifiedIdentifier = await verifyIdentifierPayload(
|
||||||
ctx,
|
ctx,
|
||||||
|
@ -172,11 +172,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
koaGuard({
|
koaGuard({
|
||||||
body: profileGuard,
|
body: profileGuard,
|
||||||
}),
|
}),
|
||||||
|
koaInteractionSie(),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
const profilePayload = ctx.guard.body;
|
const profilePayload = ctx.guard.body;
|
||||||
verifyProfileSettings(profilePayload, await getSignInExperience(ctx, provider));
|
const { signInExperience, interactionDetails } = ctx;
|
||||||
|
verifyProfileSettings(profilePayload, signInExperience);
|
||||||
|
|
||||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
await storeInteractionResult(
|
await storeInteractionResult(
|
||||||
{
|
{
|
||||||
|
@ -198,7 +200,8 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
|
|
||||||
// Delete Interaction Profile
|
// Delete Interaction Profile
|
||||||
router.delete(`${interactionPrefix}/profile`, async (ctx, next) => {
|
router.delete(`${interactionPrefix}/profile`, async (ctx, next) => {
|
||||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
const { interactionDetails } = ctx;
|
||||||
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
const { profile, ...rest } = interactionStorage;
|
const { profile, ...rest } = interactionStorage;
|
||||||
await storeInteractionResult(rest, ctx, provider);
|
await storeInteractionResult(rest, ctx, provider);
|
||||||
|
|
||||||
|
@ -208,8 +211,13 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submit Interaction
|
// Submit Interaction
|
||||||
router.post(`${interactionPrefix}/submit`, async (ctx, next) => {
|
router.post(
|
||||||
const interactionStorage = await getInteractionStorage(ctx, provider);
|
`${interactionPrefix}/submit`,
|
||||||
|
koaInteractionSie(),
|
||||||
|
koaInteractionHooks(),
|
||||||
|
async (ctx, next) => {
|
||||||
|
const { interactionDetails } = ctx;
|
||||||
|
const interactionStorage = getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
const { event } = interactionStorage;
|
const { event } = interactionStorage;
|
||||||
|
|
||||||
|
@ -218,13 +226,14 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
|
const verifiedInteraction = await verifyProfile(accountVerifiedInteraction);
|
||||||
|
|
||||||
if (event !== Event.ForgotPassword) {
|
if (event !== Event.ForgotPassword) {
|
||||||
await validateMandatoryUserProfile(ctx, provider, verifiedInteraction);
|
await validateMandatoryUserProfile(ctx, verifiedInteraction);
|
||||||
}
|
}
|
||||||
|
|
||||||
await submitInteraction(verifiedInteraction, ctx, provider);
|
await submitInteraction(verifiedInteraction, ctx, provider);
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Create social authorization url interaction verification
|
// Create social authorization url interaction verification
|
||||||
router.post(
|
router.post(
|
||||||
|
@ -232,7 +241,7 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
koaGuard({ body: socialAuthorizationUrlPayloadGuard }),
|
koaGuard({ body: socialAuthorizationUrlPayloadGuard }),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
// Check interaction exists
|
// Check interaction exists
|
||||||
await getInteractionStorage(ctx, provider);
|
getInteractionStorage(ctx.interactionDetails.result);
|
||||||
|
|
||||||
const { body: payload } = ctx.guard;
|
const { body: payload } = ctx.guard;
|
||||||
|
|
||||||
|
@ -251,11 +260,11 @@ export default function interactionRoutes<T extends AnonymousRouter>(
|
||||||
body: sendPasscodePayloadGuard,
|
body: sendPasscodePayloadGuard,
|
||||||
}),
|
}),
|
||||||
async (ctx, next) => {
|
async (ctx, next) => {
|
||||||
|
const { interactionDetails, guard, createLog } = ctx;
|
||||||
// Check interaction exists
|
// Check interaction exists
|
||||||
await getInteractionStorage(ctx, provider);
|
getInteractionStorage(interactionDetails.result);
|
||||||
|
|
||||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
await sendPasscodeToIdentifier(guard.body, interactionDetails.jti, createLog);
|
||||||
await sendPasscodeToIdentifier(ctx.guard.body, jti, ctx.createLog);
|
|
||||||
|
|
||||||
ctx.status = 204;
|
ctx.status = 204;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { MiddlewareType } from 'koa';
|
||||||
|
import type { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
|
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
|
|
||||||
|
export type WithInteractionDetailsContext<ContextT = WithLogContext> = ContextT & {
|
||||||
|
interactionDetails: Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function koaInteractionDetails<StateT, ContextT>(
|
||||||
|
provider: Provider
|
||||||
|
): MiddlewareType<StateT, WithInteractionDetailsContext<ContextT>> {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
ctx.interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { MiddlewareType } from 'koa';
|
||||||
|
import type { IRouterParamContext } from 'koa-router';
|
||||||
|
|
||||||
|
import { triggerInteractionHooksIfNeeded } from '#src/libraries/hook.js';
|
||||||
|
|
||||||
|
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
|
||||||
|
|
||||||
|
export default function koaInteractionHooks<
|
||||||
|
StateT,
|
||||||
|
ContextT extends WithInteractionDetailsContext<IRouterParamContext>,
|
||||||
|
ResponseT
|
||||||
|
>(): MiddlewareType<StateT, ContextT, ResponseT> {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
void triggerInteractionHooksIfNeeded(ctx.interactionDetails, ctx.header['user-agent']);
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { SignInExperience } from '@logto/schemas';
|
||||||
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
import type { MiddlewareType } from 'koa';
|
||||||
|
|
||||||
|
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
||||||
|
|
||||||
|
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
|
||||||
|
|
||||||
|
export type WithInteractionSieContext<ContextT> = WithInteractionDetailsContext<ContextT> & {
|
||||||
|
signInExperience: SignInExperience;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function koaInteractionSie<StateT, ContextT, ResponseT>(): MiddlewareType<
|
||||||
|
StateT,
|
||||||
|
WithInteractionSieContext<ContextT>,
|
||||||
|
ResponseT
|
||||||
|
> {
|
||||||
|
return async (ctx, next) => {
|
||||||
|
const { interactionDetails } = ctx;
|
||||||
|
|
||||||
|
const signInExperience = await getSignInExperienceForApplication(
|
||||||
|
conditional(
|
||||||
|
typeof interactionDetails.params.client_id === 'string' &&
|
||||||
|
interactionDetails.params.client_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
ctx.signInExperience = signInExperience;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import type { ConnectorSession } from '@logto/connector-kit';
|
||||||
import { connectorSessionGuard } from '@logto/connector-kit';
|
import { connectorSessionGuard } from '@logto/connector-kit';
|
||||||
import type { Event, Profile } from '@logto/schemas';
|
import type { Event, Profile } from '@logto/schemas';
|
||||||
import type { Context } from 'koa';
|
import type { Context } from 'koa';
|
||||||
import type { Provider } from 'oidc-provider';
|
import type { Provider, InteractionResults } from 'oidc-provider';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -83,10 +83,6 @@ export const isAccountVerifiedInteractionResult = (
|
||||||
interaction: AnonymousInteractionResult
|
interaction: AnonymousInteractionResult
|
||||||
): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId);
|
): interaction is AccountVerifiedInteractionResult => Boolean(interaction.accountId);
|
||||||
|
|
||||||
type Options = {
|
|
||||||
merge?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const storeInteractionResult = async (
|
export const storeInteractionResult = async (
|
||||||
interaction: Omit<AnonymousInteractionResult, 'event'> & { event?: Event },
|
interaction: Omit<AnonymousInteractionResult, 'event'> & { event?: Event },
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
|
@ -107,12 +103,10 @@ export const storeInteractionResult = async (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInteractionStorage = async (
|
export const getInteractionStorage = (
|
||||||
ctx: Context,
|
interaction?: InteractionResults
|
||||||
provider: Provider
|
): AnonymousInteractionResult => {
|
||||||
): Promise<AnonymousInteractionResult> => {
|
const parseResult = anonymousInteractionResultGuard.safeParse(interaction);
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const parseResult = anonymousInteractionResultGuard.safeParse(result);
|
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
parseResult.success,
|
parseResult.success,
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
|
import type { SignInExperience, Profile, IdentifierPayload } from '@logto/schemas';
|
||||||
import { SignInMode, SignInIdentifier, Event } from '@logto/schemas';
|
import { SignInMode, SignInIdentifier, Event } from '@logto/schemas';
|
||||||
import type { Context } from 'koa';
|
|
||||||
import type { Provider } from 'oidc-provider';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
const forbiddenEventError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
||||||
|
@ -122,11 +119,3 @@ export const verifyProfileSettings = (profile: Profile, { signUp }: SignInExperi
|
||||||
assertThat(signUp.password, forbiddenIdentifierError);
|
assertThat(signUp.password, forbiddenIdentifierError);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSignInExperience = async (ctx: Context, provider: Provider) => {
|
|
||||||
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
return getSignInExperienceForApplication(
|
|
||||||
typeof interaction.params.client_id === 'string' ? interaction.params.client_id : undefined
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||||
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
|
import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm';
|
||||||
|
import type { Provider } from 'oidc-provider';
|
||||||
|
|
||||||
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
import { mockSignInExperience } from '#src/__mocks__/sign-in-experience.js';
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
@ -18,24 +19,25 @@ const { isUserPasswordSet } = mockEsm('../utils/index.js', () => ({
|
||||||
isUserPasswordSet: jest.fn(),
|
isUserPasswordSet: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { getSignInExperience } = mockEsm('../utils/sign-in-experience-validation.js', () => ({
|
|
||||||
getSignInExperience: jest.fn().mockReturnValue(mockSignInExperience),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const validateMandatoryUserProfile = await pickDefault(
|
const validateMandatoryUserProfile = await pickDefault(
|
||||||
import('./mandatory-user-profile-validation.js')
|
import('./mandatory-user-profile-validation.js')
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('validateMandatoryUserProfile', () => {
|
describe('validateMandatoryUserProfile', () => {
|
||||||
const provider = createMockProvider();
|
const provider = createMockProvider();
|
||||||
const baseCtx = createContextWithRouteParameters();
|
const baseCtx = {
|
||||||
|
...createContextWithRouteParameters(),
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
interactionDetails: {} as Awaited<ReturnType<Provider['interactionDetails']>>,
|
||||||
|
signInExperience: mockSignInExperience,
|
||||||
|
};
|
||||||
const interaction: IdentifierVerifiedInteractionResult = {
|
const interaction: IdentifierVerifiedInteractionResult = {
|
||||||
event: Event.SignIn,
|
event: Event.SignIn,
|
||||||
accountId: 'foo',
|
accountId: 'foo',
|
||||||
};
|
};
|
||||||
|
|
||||||
it('username and password missing but required', async () => {
|
it('username and password missing but required', async () => {
|
||||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
await expect(validateMandatoryUserProfile(baseCtx, interaction)).rejects.toMatchError(
|
||||||
new RequestError(
|
new RequestError(
|
||||||
{ code: 'user.missing_profile', status: 422 },
|
{ code: 'user.missing_profile', status: 422 },
|
||||||
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
|
{ missingProfile: [MissingProfile.password, MissingProfile.username] }
|
||||||
|
@ -43,7 +45,7 @@ describe('validateMandatoryUserProfile', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
validateMandatoryUserProfile(baseCtx, provider, {
|
validateMandatoryUserProfile(baseCtx, {
|
||||||
...interaction,
|
...interaction,
|
||||||
profile: {
|
profile: {
|
||||||
username: 'username',
|
username: 'username',
|
||||||
|
@ -59,18 +61,19 @@ describe('validateMandatoryUserProfile', () => {
|
||||||
});
|
});
|
||||||
isUserPasswordSet.mockResolvedValueOnce(true);
|
isUserPasswordSet.mockResolvedValueOnce(true);
|
||||||
|
|
||||||
await expect(
|
await expect(validateMandatoryUserProfile(baseCtx, interaction)).resolves.not.toThrow();
|
||||||
validateMandatoryUserProfile(baseCtx, provider, interaction)
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('email missing but required', async () => {
|
it('email missing but required', async () => {
|
||||||
getSignInExperience.mockResolvedValueOnce({
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
|
||||||
new RequestError(
|
new RequestError(
|
||||||
{ code: 'user.missing_profile', status: 422 },
|
{ code: 'user.missing_profile', status: 422 },
|
||||||
{ missingProfile: [MissingProfile.email] }
|
{ missingProfile: [MissingProfile.email] }
|
||||||
|
@ -83,23 +86,27 @@ describe('validateMandatoryUserProfile', () => {
|
||||||
primaryEmail: 'email',
|
primaryEmail: 'email',
|
||||||
});
|
});
|
||||||
|
|
||||||
getSignInExperience.mockResolvedValueOnce({
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
signUp: { identifiers: [SignInIdentifier.Email], password: false, verify: true },
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
|
||||||
validateMandatoryUserProfile(baseCtx, provider, interaction)
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('phone missing but required', async () => {
|
it('phone missing but required', async () => {
|
||||||
getSignInExperience.mockResolvedValueOnce({
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
|
||||||
new RequestError(
|
new RequestError(
|
||||||
{ code: 'user.missing_profile', status: 422 },
|
{ code: 'user.missing_profile', status: 422 },
|
||||||
{ missingProfile: [MissingProfile.phone] }
|
{ missingProfile: [MissingProfile.phone] }
|
||||||
|
@ -112,27 +119,31 @@ describe('validateMandatoryUserProfile', () => {
|
||||||
primaryPhone: 'phone',
|
primaryPhone: 'phone',
|
||||||
});
|
});
|
||||||
|
|
||||||
getSignInExperience.mockResolvedValueOnce({
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
signUp: { identifiers: [SignInIdentifier.Sms], password: false, verify: true },
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await expect(
|
await expect(validateMandatoryUserProfile(ctx, interaction)).resolves.not.toThrow();
|
||||||
validateMandatoryUserProfile(baseCtx, provider, interaction)
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('email or Phone required', async () => {
|
it('email or Phone required', async () => {
|
||||||
getSignInExperience.mockResolvedValue({
|
const ctx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
signUp: {
|
signUp: {
|
||||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
|
identifiers: [SignInIdentifier.Email, SignInIdentifier.Sms],
|
||||||
password: false,
|
password: false,
|
||||||
verify: true,
|
verify: true,
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await expect(validateMandatoryUserProfile(baseCtx, provider, interaction)).rejects.toMatchError(
|
await expect(validateMandatoryUserProfile(ctx, interaction)).rejects.toMatchError(
|
||||||
new RequestError(
|
new RequestError(
|
||||||
{ code: 'user.missing_profile', status: 422 },
|
{ code: 'user.missing_profile', status: 422 },
|
||||||
{ missingProfile: [MissingProfile.emailOrPhone] }
|
{ missingProfile: [MissingProfile.emailOrPhone] }
|
||||||
|
@ -140,14 +151,14 @@ describe('validateMandatoryUserProfile', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
validateMandatoryUserProfile(baseCtx, provider, {
|
validateMandatoryUserProfile(ctx, {
|
||||||
...interaction,
|
...interaction,
|
||||||
profile: { email: 'email' },
|
profile: { email: 'email' },
|
||||||
})
|
})
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
validateMandatoryUserProfile(baseCtx, provider, {
|
validateMandatoryUserProfile(ctx, {
|
||||||
...interaction,
|
...interaction,
|
||||||
profile: { phone: '123456' },
|
profile: { phone: '123456' },
|
||||||
})
|
})
|
||||||
|
|
|
@ -2,15 +2,14 @@ import type { Profile, SignInExperience, User } from '@logto/schemas';
|
||||||
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
import { Event, MissingProfile, SignInIdentifier } from '@logto/schemas';
|
||||||
import type { Nullable } from '@silverhand/essentials';
|
import type { Nullable } from '@silverhand/essentials';
|
||||||
import type { Context } from 'koa';
|
import type { Context } from 'koa';
|
||||||
import type { Provider } from 'oidc-provider';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import { findUserById } from '#src/queries/user.js';
|
import { findUserById } from '#src/queries/user.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
|
||||||
|
import type { WithInteractionSieContext } from '../middleware/koa-interaction-sie.js';
|
||||||
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
import type { IdentifierVerifiedInteractionResult } from '../types/index.js';
|
||||||
import { isUserPasswordSet } from '../utils/index.js';
|
import { isUserPasswordSet } from '../utils/index.js';
|
||||||
import { getSignInExperience } from '../utils/sign-in-experience-validation.js';
|
|
||||||
|
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
const getMissingProfileBySignUpIdentifiers = ({
|
const getMissingProfileBySignUpIdentifiers = ({
|
||||||
|
@ -71,11 +70,10 @@ const getMissingProfileBySignUpIdentifiers = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function validateMandatoryUserProfile(
|
export default async function validateMandatoryUserProfile(
|
||||||
ctx: Context,
|
ctx: WithInteractionSieContext<Context>,
|
||||||
provider: Provider,
|
|
||||||
interaction: IdentifierVerifiedInteractionResult
|
interaction: IdentifierVerifiedInteractionResult
|
||||||
) {
|
) {
|
||||||
const { signUp } = await getSignInExperience(ctx, provider);
|
const { signUp } = ctx.signInExperience;
|
||||||
const { event, accountId, profile } = interaction;
|
const { event, accountId, profile } = interaction;
|
||||||
|
|
||||||
const user = event === Event.Register ? null : await findUserById(accountId);
|
const user = event === Event.Register ? null : await findUserById(accountId);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
|
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
|
||||||
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
|
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
|
||||||
|
|
||||||
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext & ExtendableContext>;
|
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
|
||||||
|
|
||||||
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
|
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
|
||||||
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;
|
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;
|
||||||
|
|
|
@ -2,12 +2,26 @@ import { generateStandardId } from '@logto/core-kit';
|
||||||
import { createModel } from '@withtyped/server';
|
import { createModel } from '@withtyped/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type { Application, User } from '../db-entries/index.js';
|
||||||
|
import type { userInfoSelectFields } from '../types/index.js';
|
||||||
|
|
||||||
export enum HookEvent {
|
export enum HookEvent {
|
||||||
PostRegister = 'PostRegister',
|
PostRegister = 'PostRegister',
|
||||||
PostSignIn = 'PostSignIn',
|
PostSignIn = 'PostSignIn',
|
||||||
PostForgotPassword = 'PostForgotPassword',
|
PostResetPassword = 'PostResetPassword',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HookEventPayload = {
|
||||||
|
hookId: string;
|
||||||
|
event: HookEvent;
|
||||||
|
createdAt: string;
|
||||||
|
sessionId?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
userId?: string;
|
||||||
|
user?: Pick<User, typeof userInfoSelectFields[number]>;
|
||||||
|
application?: Pick<Application, 'id' | 'type' | 'name' | 'description'>;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
export type HookConfig = {
|
export type HookConfig = {
|
||||||
/** We don't need `type` since v1 only has web hook */
|
/** We don't need `type` since v1 only has web hook */
|
||||||
// type: 'web';
|
// type: 'web';
|
||||||
|
|
8
packages/schemas/src/types/log/hook.ts
Normal file
8
packages/schemas/src/types/log/hook.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import type { HookEvent } from '../../models/hooks.js';
|
||||||
|
|
||||||
|
/** The type of a hook event. */
|
||||||
|
export enum Type {
|
||||||
|
ExchangeTokenBy = 'TriggerHook',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogKey = `${Type}.${HookEvent}`;
|
|
@ -1,11 +1,13 @@
|
||||||
import type { ZodType } from 'zod';
|
import type { ZodType } from 'zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import type * as hook from './hook.js';
|
||||||
import type * as interaction from './interaction.js';
|
import type * as interaction from './interaction.js';
|
||||||
import type * as token from './token.js';
|
import type * as token from './token.js';
|
||||||
|
|
||||||
export * as interaction from './interaction.js';
|
export * as interaction from './interaction.js';
|
||||||
export * as token from './token.js';
|
export * as token from './token.js';
|
||||||
|
export * as hook from './hook.js';
|
||||||
|
|
||||||
/** Fallback for empty or unrecognized log keys. */
|
/** Fallback for empty or unrecognized log keys. */
|
||||||
export const LogKeyUnknown = 'Unknown';
|
export const LogKeyUnknown = 'Unknown';
|
||||||
|
@ -17,7 +19,7 @@ export const LogKeyUnknown = 'Unknown';
|
||||||
* @see {@link interaction.LogKey} for interaction log keys.
|
* @see {@link interaction.LogKey} for interaction log keys.
|
||||||
* @see {@link token.LogKey} for token log keys.
|
* @see {@link token.LogKey} for token log keys.
|
||||||
**/
|
**/
|
||||||
export type LogKey = typeof LogKeyUnknown | interaction.LogKey | token.LogKey;
|
export type LogKey = typeof LogKeyUnknown | interaction.LogKey | token.LogKey | hook.LogKey;
|
||||||
|
|
||||||
export enum LogResult {
|
export enum LogResult {
|
||||||
Success = 'Success',
|
Success = 'Success',
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/** The type of a token flow. */
|
/** The type of a token event. */
|
||||||
export enum Flow {
|
export enum Type {
|
||||||
ExchangeTokenBy = 'ExchangeTokenBy',
|
ExchangeTokenBy = 'ExchangeTokenBy',
|
||||||
RevokeToken = 'RevokeToken',
|
RevokeToken = 'RevokeToken',
|
||||||
}
|
}
|
||||||
|
@ -22,4 +22,4 @@ export enum ExchangeByType {
|
||||||
ClientCredentials = 'ClientCredentials',
|
ClientCredentials = 'ClientCredentials',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LogKey = `${Flow.ExchangeTokenBy}.${ExchangeByType}` | `${Flow.RevokeToken}`;
|
export type LogKey = `${Type.ExchangeTokenBy}.${ExchangeByType}` | `${Type.RevokeToken}`;
|
||||||
|
|
|
@ -14,3 +14,11 @@ export const tryThat = async <T, E extends Error>(
|
||||||
return onError(error);
|
return onError(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const trySafe = async <T>(exec: Promise<T> | (() => Promise<T>)): Promise<T | undefined> => {
|
||||||
|
try {
|
||||||
|
return await (typeof exec === 'function' ? exec() : exec);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('trySafe() caught error', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
|
@ -282,6 +282,7 @@ importers:
|
||||||
eslint: ^8.21.0
|
eslint: ^8.21.0
|
||||||
etag: ^1.8.1
|
etag: ^1.8.1
|
||||||
find-up: ^6.3.0
|
find-up: ^6.3.0
|
||||||
|
got: ^12.5.3
|
||||||
hash-wasm: ^4.9.0
|
hash-wasm: ^4.9.0
|
||||||
http-errors: ^1.6.3
|
http-errors: ^1.6.3
|
||||||
i18next: ^21.8.16
|
i18next: ^21.8.16
|
||||||
|
@ -336,6 +337,7 @@ importers:
|
||||||
dotenv: 16.0.0
|
dotenv: 16.0.0
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
find-up: 6.3.0
|
find-up: 6.3.0
|
||||||
|
got: 12.5.3
|
||||||
hash-wasm: 4.9.0
|
hash-wasm: 4.9.0
|
||||||
i18next: 21.8.16
|
i18next: 21.8.16
|
||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
|
|
Loading…
Add table
Reference in a new issue