mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(schemas,core): add sessionNotFoundRedirectUrl
tenant config (#3365)
This commit is contained in:
parent
8026bdbeb8
commit
5784cfeb18
7 changed files with 77 additions and 32 deletions
9
.changeset-staged/lemon-spies-switch.md
Normal file
9
.changeset-staged/lemon-spies-switch.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
"@logto/core": minor
|
||||
"@logto/schemas": minor
|
||||
---
|
||||
|
||||
**Add `sessionNotFoundRedirectUrl` tenant config**
|
||||
|
||||
- User can use this optional config to designate the URL to redirect if session not found in Sign-in Experience.
|
||||
- Session guard now works for root path as well.
|
|
@ -2,6 +2,7 @@ import { createMockUtils } from '@logto/shared/esm';
|
|||
import Provider from 'oidc-provider';
|
||||
|
||||
import { UserApps } from '#src/env-set/index.js';
|
||||
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -22,6 +23,8 @@ describe('koaSpaSessionGuard', () => {
|
|||
const envBackup = process.env;
|
||||
const provider = new Provider('https://logto.test');
|
||||
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
|
||||
const getRowsByKeys = jest.fn().mockResolvedValue({ rows: [] });
|
||||
const queries = new MockQueries({ logtoConfigs: { getRowsByKeys } });
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...envBackup };
|
||||
|
@ -41,7 +44,7 @@ describe('koaSpaSessionGuard', () => {
|
|||
url: `/${app}/foo`,
|
||||
});
|
||||
|
||||
await koaSpaSessionGuard(provider)(ctx, next);
|
||||
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||
|
||||
expect(ctx.redirect).not.toBeCalled();
|
||||
});
|
||||
|
@ -52,7 +55,7 @@ describe('koaSpaSessionGuard', () => {
|
|||
const ctx = createContextWithRouteParameters({
|
||||
url: `${sessionNotFoundPath}`,
|
||||
});
|
||||
await koaSpaSessionGuard(provider)(ctx, next);
|
||||
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||
expect(ctx.redirect).not.toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -61,7 +64,7 @@ describe('koaSpaSessionGuard', () => {
|
|||
const ctx = createContextWithRouteParameters({
|
||||
url: '/callback/github',
|
||||
});
|
||||
await koaSpaSessionGuard(provider)(ctx, next);
|
||||
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||
expect(ctx.redirect).not.toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -71,19 +74,30 @@ describe('koaSpaSessionGuard', () => {
|
|||
const ctx = createContextWithRouteParameters({
|
||||
url: `/sign-in`,
|
||||
});
|
||||
await koaSpaSessionGuard(provider)(ctx, next);
|
||||
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||
expect(ctx.redirect).not.toBeCalled();
|
||||
});
|
||||
|
||||
for (const path of guardedPath) {
|
||||
for (const path of ['/', ...guardedPath]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
it(`should redirect if session not found for ${path}`, async () => {
|
||||
interactionDetails.mockRejectedValue(new Error('session not found'));
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: `${path}/foo`,
|
||||
});
|
||||
await koaSpaSessionGuard(provider)(ctx, next);
|
||||
expect(ctx.redirect).toBeCalled();
|
||||
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||
expect(ctx.redirect).toBeCalledWith('https://logto.test/unknown-session');
|
||||
});
|
||||
}
|
||||
|
||||
it('should redirect to configured URL if session not found for a selected path', async () => {
|
||||
interactionDetails.mockRejectedValue(new Error('session not found'));
|
||||
getRowsByKeys.mockResolvedValueOnce({ rows: [{ value: { url: 'https://foo.bar' } }] });
|
||||
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: `${guardedPath[0]!}/foo`,
|
||||
});
|
||||
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||
expect(ctx.redirect).toBeCalledWith('https://foo.bar');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { appendPath } from '@silverhand/essentials';
|
||||
import { logtoConfigGuards, LogtoTenantConfigKey } from '@logto/schemas';
|
||||
import { appendPath, trySafe } from '@silverhand/essentials';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { getTenantId } from '#src/utils/tenant.js';
|
||||
|
||||
// Need To Align With UI
|
||||
|
@ -21,16 +23,32 @@ export default function koaSpaSessionGuard<
|
|||
StateT,
|
||||
ContextT extends IRouterParamContext,
|
||||
ResponseBodyT
|
||||
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
>(provider: Provider, queries: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
const requestPath = ctx.request.path;
|
||||
const isPreview = ctx.URL.searchParams.get('preview');
|
||||
const isSessionRequiredPath = guardedPath.some((path) => requestPath.startsWith(path));
|
||||
const isPreview = ctx.request.URL.searchParams.get('preview');
|
||||
const isSessionRequiredPath =
|
||||
requestPath === '/' || guardedPath.some((path) => requestPath.startsWith(path));
|
||||
|
||||
if (isSessionRequiredPath && !isPreview) {
|
||||
try {
|
||||
await provider.interactionDetails(ctx.req, ctx.res);
|
||||
} catch {
|
||||
const {
|
||||
rows: [data],
|
||||
} = await queries.logtoConfigs.getRowsByKeys([
|
||||
LogtoTenantConfigKey.SessionNotFoundRedirectUrl,
|
||||
]);
|
||||
const parsed = trySafe(() =>
|
||||
logtoConfigGuards.sessionNotFoundRedirectUrl.parse(data?.value)
|
||||
);
|
||||
|
||||
if (parsed?.url) {
|
||||
ctx.redirect(parsed.url);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const tenantId = getTenantId(ctx.URL);
|
||||
|
||||
if (!tenantId) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { AdminConsoleData, LogtoConfig, LogtoConfigKey } from '@logto/schemas';
|
||||
import { AdminConsoleConfigKey, LogtoConfigs } from '@logto/schemas';
|
||||
import { LogtoTenantConfigKey, LogtoConfigs } from '@logto/schemas';
|
||||
import { convertToIdentifiers } from '@logto/shared';
|
||||
import type { CommonQueryMethods } from 'slonik';
|
||||
import { sql } from 'slonik';
|
||||
|
@ -10,14 +10,14 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
|
|||
const getAdminConsoleConfig = async () =>
|
||||
pool.one<Record<string, unknown>>(sql`
|
||||
select ${fields.value} from ${table}
|
||||
where ${fields.key} = ${AdminConsoleConfigKey.AdminConsole}
|
||||
where ${fields.key} = ${LogtoTenantConfigKey.AdminConsole}
|
||||
`);
|
||||
|
||||
const updateAdminConsoleConfig = async (value: Partial<AdminConsoleData>) =>
|
||||
pool.one<Record<string, unknown>>(sql`
|
||||
update ${table}
|
||||
set ${fields.value} = coalesce(${fields.value},'{}'::jsonb) || ${sql.jsonb(value)}
|
||||
where ${fields.key} = ${AdminConsoleConfigKey.AdminConsole}
|
||||
where ${fields.key} = ${LogtoTenantConfigKey.AdminConsole}
|
||||
returning ${fields.value}
|
||||
`);
|
||||
|
||||
|
|
|
@ -131,7 +131,7 @@ export default class Tenant implements TenantContext {
|
|||
}
|
||||
|
||||
// Mount UI
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));
|
||||
app.use(compose([koaSpaSessionGuard(provider, queries), koaSpaProxy(mountedApps)]));
|
||||
|
||||
this.app = app;
|
||||
this.provider = provider;
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import { CreateLogtoConfig } from '../db-entries/index.js';
|
||||
import { AppearanceMode } from '../foundations/index.js';
|
||||
import type { AdminConsoleData } from '../types/index.js';
|
||||
import { AdminConsoleConfigKey } from '../types/index.js';
|
||||
import { LogtoTenantConfigKey } from '../types/index.js';
|
||||
|
||||
export const createDefaultAdminConsoleConfig = (
|
||||
forTenantId: string
|
||||
): Readonly<{
|
||||
tenantId: string;
|
||||
key: AdminConsoleConfigKey;
|
||||
key: LogtoTenantConfigKey;
|
||||
value: AdminConsoleData;
|
||||
}> =>
|
||||
Object.freeze({
|
||||
tenantId: forTenantId,
|
||||
key: AdminConsoleConfigKey.AdminConsole,
|
||||
key: LogtoTenantConfigKey.AdminConsole,
|
||||
value: {
|
||||
language: 'en',
|
||||
appearanceMode: AppearanceMode.SyncWithSystem,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { ZodType } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Logto OIDC config
|
||||
/* --- Logto OIDC configs --- */
|
||||
export enum LogtoOidcConfigKey {
|
||||
PrivateKeys = 'oidc.privateKeys',
|
||||
CookieKeys = 'oidc.cookieKeys',
|
||||
|
@ -19,7 +19,7 @@ export const logtoOidcConfigGuard: Readonly<{
|
|||
[LogtoOidcConfigKey.CookieKeys]: z.string().array(),
|
||||
});
|
||||
|
||||
// Admin console config
|
||||
/* --- Logto tenant configs --- */
|
||||
export const adminConsoleDataGuard = z.object({
|
||||
// Get started challenges
|
||||
livePreviewChecked: z.boolean(),
|
||||
|
@ -33,30 +33,34 @@ export const adminConsoleDataGuard = z.object({
|
|||
|
||||
export type AdminConsoleData = z.infer<typeof adminConsoleDataGuard>;
|
||||
|
||||
export enum AdminConsoleConfigKey {
|
||||
export enum LogtoTenantConfigKey {
|
||||
AdminConsole = 'adminConsole',
|
||||
/** The URL to redirect when session not found in Sign-in Experience. */
|
||||
SessionNotFoundRedirectUrl = 'sessionNotFoundRedirectUrl',
|
||||
}
|
||||
export type AdminConsoleConfigType = {
|
||||
[AdminConsoleConfigKey.AdminConsole]: AdminConsoleData;
|
||||
export type LogtoTenantConfigType = {
|
||||
[LogtoTenantConfigKey.AdminConsole]: AdminConsoleData;
|
||||
[LogtoTenantConfigKey.SessionNotFoundRedirectUrl]: { url: string };
|
||||
};
|
||||
|
||||
export const adminConsoleConfigGuard: Readonly<{
|
||||
[key in AdminConsoleConfigKey]: ZodType<AdminConsoleConfigType[key]>;
|
||||
export const logtoTenantConfigGuard: Readonly<{
|
||||
[key in LogtoTenantConfigKey]: ZodType<LogtoTenantConfigType[key]>;
|
||||
}> = Object.freeze({
|
||||
[AdminConsoleConfigKey.AdminConsole]: adminConsoleDataGuard,
|
||||
[LogtoTenantConfigKey.AdminConsole]: adminConsoleDataGuard,
|
||||
[LogtoTenantConfigKey.SessionNotFoundRedirectUrl]: z.object({ url: z.string() }),
|
||||
});
|
||||
|
||||
// Summary
|
||||
export type LogtoConfigKey = LogtoOidcConfigKey | AdminConsoleConfigKey;
|
||||
export type LogtoConfigType = LogtoOidcConfigType | AdminConsoleConfigType;
|
||||
export type LogtoConfigGuard = typeof logtoOidcConfigGuard & typeof adminConsoleConfigGuard;
|
||||
/* --- Summary --- */
|
||||
export type LogtoConfigKey = LogtoOidcConfigKey | LogtoTenantConfigKey;
|
||||
export type LogtoConfigType = LogtoOidcConfigType | LogtoTenantConfigType;
|
||||
export type LogtoConfigGuard = typeof logtoOidcConfigGuard & typeof logtoTenantConfigGuard;
|
||||
|
||||
export const logtoConfigKeys: readonly LogtoConfigKey[] = Object.freeze([
|
||||
...Object.values(LogtoOidcConfigKey),
|
||||
...Object.values(AdminConsoleConfigKey),
|
||||
...Object.values(LogtoTenantConfigKey),
|
||||
]);
|
||||
|
||||
export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
|
||||
...logtoOidcConfigGuard,
|
||||
...adminConsoleConfigGuard,
|
||||
...logtoTenantConfigGuard,
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue