mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat: trigger interaction hooks
This commit is contained in:
parent
8c5ccac59f
commit
464a4a9133
19 changed files with 201 additions and 33 deletions
|
@ -44,6 +44,7 @@
|
|||
"dotenv": "^16.0.0",
|
||||
"etag": "^1.8.1",
|
||||
"find-up": "^6.3.0",
|
||||
"got": "^12.5.3",
|
||||
"hash-wasm": "^4.9.0",
|
||||
"i18next": "^21.8.16",
|
||||
"iconv-lite": "0.6.3",
|
||||
|
|
|
@ -18,7 +18,7 @@ export const grantListener = (
|
|||
const { params } = ctx.oidc;
|
||||
|
||||
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;
|
||||
|
|
|
@ -1,23 +1,16 @@
|
|||
import Koa from 'koa';
|
||||
|
||||
import initApp from './app/init.js';
|
||||
import { configDotEnv } from './env-set/dot-env.js';
|
||||
import envSet from './env-set/index.js';
|
||||
import initI18n from './i18n/init.js';
|
||||
|
||||
// Update after we migrate to ESM
|
||||
// eslint-disable-next-line unicorn/prefer-top-level-await
|
||||
(async () => {
|
||||
try {
|
||||
await configDotEnv();
|
||||
await envSet.load();
|
||||
const app = new Koa({
|
||||
proxy: envSet.values.trustProxyHeader,
|
||||
});
|
||||
await initI18n();
|
||||
await initApp(app);
|
||||
} catch (error: unknown) {
|
||||
console.log('Error while initializing app', error);
|
||||
await envSet.poolSafe?.end();
|
||||
}
|
||||
})();
|
||||
await configDotEnv();
|
||||
await envSet.load();
|
||||
const app = new Koa({
|
||||
proxy: envSet.values.trustProxyHeader,
|
||||
});
|
||||
await initI18n();
|
||||
|
||||
// Import last until init completed
|
||||
const { default: initApp } = await import('./app/init.js');
|
||||
await initApp(app);
|
||||
|
|
102
packages/core/src/libraries/hook.ts
Normal file
102
packages/core/src/libraries/hook.ts
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { Event, userInfoSelectFields } from '@logto/schemas';
|
||||
import { HookEvent } from '@logto/schemas/models';
|
||||
import { trySafe } from '@logto/shared';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { got } from 'got';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
import { getLogtoConnectorById } from '#src/connectors/index.js';
|
||||
import modelRouters from '#src/model-routers/index.js';
|
||||
import { findApplicationById } from '#src/queries/application.js';
|
||||
import { findUserById } from '#src/queries/user.js';
|
||||
import type { InteractionPayload } from '#src/routes/interaction/types/index.js';
|
||||
|
||||
const eventToHook: Record<Event, HookEvent> = {
|
||||
[Event.Register]: HookEvent.PostRegister,
|
||||
[Event.SignIn]: HookEvent.PostSignIn,
|
||||
[Event.ForgotPassword]: HookEvent.PostResetPassword,
|
||||
};
|
||||
|
||||
export type HookEventPayload = {
|
||||
hookId: string;
|
||||
event: HookEvent;
|
||||
Event: Event;
|
||||
createdAt: string;
|
||||
sessionId?: string;
|
||||
userAgent?: string;
|
||||
userId?: string;
|
||||
user?: Record<string, unknown>;
|
||||
application?: Record<string, unknown>;
|
||||
connectors?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
// TODO: replace `lodash.pick`
|
||||
const pick = <T, Keys extends Array<keyof T>>(
|
||||
object: T,
|
||||
...keys: Keys
|
||||
): { [key in Keys[number]]: T[key] } => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return Object.fromEntries(keys.map((key) => [key, object[key]])) as {
|
||||
[key in Keys[number]]: T[key];
|
||||
};
|
||||
};
|
||||
|
||||
export const triggerInteractionHooksIfNeeded = async (
|
||||
interactionPayload: InteractionPayload,
|
||||
details?: Awaited<ReturnType<Provider['interactionDetails']>>,
|
||||
userAgent?: string
|
||||
) => {
|
||||
const userId = details?.result?.login?.accountId;
|
||||
const sessionId = details?.jti;
|
||||
const applicationId = details?.params.client_id;
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { event, identifier } = interactionPayload;
|
||||
const hookEvent = eventToHook[event];
|
||||
const { rows } = await modelRouters.hook.client.readAll();
|
||||
const [user, application, connector] = await Promise.all([
|
||||
trySafe(findUserById(userId)),
|
||||
trySafe(async () =>
|
||||
conditional(typeof applicationId === 'string' && (await findApplicationById(applicationId)))
|
||||
),
|
||||
trySafe(async () =>
|
||||
conditional(
|
||||
identifier &&
|
||||
'connectorId' in identifier &&
|
||||
(await getLogtoConnectorById(identifier.connectorId))
|
||||
)
|
||||
),
|
||||
]);
|
||||
const payload: Omit<HookEventPayload, 'hookId'> = {
|
||||
event: hookEvent,
|
||||
Event: event,
|
||||
createdAt: new Date().toISOString(),
|
||||
sessionId,
|
||||
userAgent,
|
||||
userId,
|
||||
user: user && pick(user, ...userInfoSelectFields),
|
||||
application: application && pick(application, 'id', 'type', 'name', 'description'),
|
||||
connectors: connector && [
|
||||
pick(connector.metadata, 'id', 'name', 'description', 'platform', 'target', 'isStandard'),
|
||||
],
|
||||
};
|
||||
|
||||
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();
|
||||
})
|
||||
);
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import { getUnixTime } from 'date-fns';
|
||||
import type { Context } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import type { InteractionResults, Provider } from 'oidc-provider';
|
||||
import { errors } from 'oidc-provider';
|
||||
|
||||
|
@ -35,7 +36,7 @@ export const assignInteractionResults = async (
|
|||
};
|
||||
|
||||
export const checkSessionHealth = async (
|
||||
ctx: Context,
|
||||
ctx: IRouterParamContext & Context,
|
||||
provider: Provider,
|
||||
tolerance = 10 * 60 // 10 mins
|
||||
) => {
|
||||
|
|
|
@ -42,6 +42,7 @@ export type LogPayload = Partial<LogContextPayload> & Record<string, unknown>;
|
|||
|
||||
export type LogContext = {
|
||||
createLog: (key: LogKey) => LogEntry;
|
||||
getLogs: () => readonly LogEntry[];
|
||||
prependAllLogEntries: (payload: LogPayload) => void;
|
||||
};
|
||||
|
||||
|
@ -109,6 +110,8 @@ export default function koaAuditLog<
|
|||
return entry;
|
||||
};
|
||||
|
||||
ctx.getLogs = () => Object.freeze(entries);
|
||||
|
||||
ctx.prependAllLogEntries = (payload) => {
|
||||
for (const entry of entries) {
|
||||
entry.prepend(payload);
|
||||
|
|
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}
|
||||
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::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'
|
||||
group by date(${fields.createdAt})
|
||||
`);
|
||||
|
@ -73,6 +73,6 @@ export const countActiveUsersByTimeInterval = async (
|
|||
from ${table}
|
||||
where ${fields.createdAt} > to_timestamp(${startTimeExclusive}::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'
|
||||
`);
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { Hooks } from '@logto/schemas/models';
|
||||
import { createModelRouter } from '@withtyped/postgres';
|
||||
import { koaAdapter, RequestError } from '@withtyped/server';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import koaBody from 'koa-body';
|
||||
|
||||
import envSet from '#src/env-set/index.js';
|
||||
import LogtoRequestError from '#src/errors/RequestError/index.js';
|
||||
import modelRouters from '#src/model-routers/index.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) {
|
||||
const modelRouter = createModelRouter(Hooks, envSet.queryClient).withCrud();
|
||||
|
||||
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouter.routes()));
|
||||
router.all('/hooks/(.*)?', koaBody(), errorHandler, koaAdapter(modelRouters.hook.routes()));
|
||||
}
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import type { Provider } from 'oidc-provider';
|
||||
|
||||
export type WithInteractionDetailsContext<ContextT> = ContextT & {
|
||||
interactionDetails: Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||
};
|
||||
|
||||
export default function koaInteractionDetails<StateT, ContextT, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, WithInteractionDetailsContext<ContextT>, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
ctx.interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
|
||||
return next();
|
||||
};
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { triggerInteractionHooksIfNeeded } from '#src/libraries/hook.js';
|
||||
|
||||
import type { WithInteractionPayloadContext } from './koa-interaction-body-guard.js';
|
||||
import type { WithInteractionDetailsContext } from './koa-interaction-details.js';
|
||||
|
||||
export default function koaInteractionHooks<
|
||||
StateT,
|
||||
ContextT extends WithInteractionPayloadContext<
|
||||
WithInteractionDetailsContext<IRouterParamContext>
|
||||
>,
|
||||
ResponseT
|
||||
>(): MiddlewareType<StateT, ContextT, ResponseT> {
|
||||
return async (ctx, next) => {
|
||||
await next();
|
||||
|
||||
void triggerInteractionHooksIfNeeded(
|
||||
ctx.interactionPayload,
|
||||
ctx.interactionDetails,
|
||||
ctx.header['user-agent']
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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 { 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. */
|
||||
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;
|
||||
|
|
|
@ -13,6 +13,7 @@ export const createMockLogContext = (): LogContext & { mockAppend: jest.Mock } =
|
|||
return {
|
||||
createLog: jest.fn(() => mockLogEntry),
|
||||
prependAllLogEntries: jest.fn(),
|
||||
getLogs: jest.fn(),
|
||||
mockAppend: mockLogEntry.append,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ import { z } from 'zod';
|
|||
export enum HookEvent {
|
||||
PostRegister = 'PostRegister',
|
||||
PostSignIn = 'PostSignIn',
|
||||
PostForgotPassword = 'PostForgotPassword',
|
||||
PostResetPassword = 'PostResetPassword',
|
||||
}
|
||||
|
||||
export type HookConfig = {
|
||||
|
|
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 { z } from 'zod';
|
||||
|
||||
import type * as hook from './hook.js';
|
||||
import type * as interaction from './interaction.js';
|
||||
import type * as token from './token.js';
|
||||
|
||||
export * as interaction from './interaction.js';
|
||||
export * as token from './token.js';
|
||||
export * as hook from './hook.js';
|
||||
|
||||
/** Fallback for empty or unrecognized log keys. */
|
||||
export const LogKeyUnknown = 'Unknown';
|
||||
|
@ -17,7 +19,7 @@ export const LogKeyUnknown = 'Unknown';
|
|||
* @see {@link interaction.LogKey} for interaction 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 {
|
||||
Success = 'Success',
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/** The type of a token flow. */
|
||||
export enum Flow {
|
||||
/** The type of a token event. */
|
||||
export enum Type {
|
||||
ExchangeTokenBy = 'ExchangeTokenBy',
|
||||
RevokeToken = 'RevokeToken',
|
||||
}
|
||||
|
@ -22,4 +22,4 @@ export enum ExchangeByType {
|
|||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
etag: ^1.8.1
|
||||
find-up: ^6.3.0
|
||||
got: ^12.5.3
|
||||
hash-wasm: ^4.9.0
|
||||
http-errors: ^1.6.3
|
||||
i18next: ^21.8.16
|
||||
|
@ -336,6 +337,7 @@ importers:
|
|||
dotenv: 16.0.0
|
||||
etag: 1.8.1
|
||||
find-up: 6.3.0
|
||||
got: 12.5.3
|
||||
hash-wasm: 4.9.0
|
||||
i18next: 21.8.16
|
||||
iconv-lite: 0.6.3
|
||||
|
|
Loading…
Add table
Reference in a new issue