mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -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 Provider from 'oidc-provider';
|
||||||
|
|
||||||
import { UserApps } from '#src/env-set/index.js';
|
import { UserApps } from '#src/env-set/index.js';
|
||||||
|
import { MockQueries } from '#src/test-utils/tenant.js';
|
||||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||||
|
|
||||||
const { jest } = import.meta;
|
const { jest } = import.meta;
|
||||||
|
@ -22,6 +23,8 @@ describe('koaSpaSessionGuard', () => {
|
||||||
const envBackup = process.env;
|
const envBackup = process.env;
|
||||||
const provider = new Provider('https://logto.test');
|
const provider = new Provider('https://logto.test');
|
||||||
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
|
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
|
||||||
|
const getRowsByKeys = jest.fn().mockResolvedValue({ rows: [] });
|
||||||
|
const queries = new MockQueries({ logtoConfigs: { getRowsByKeys } });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env = { ...envBackup };
|
process.env = { ...envBackup };
|
||||||
|
@ -41,7 +44,7 @@ describe('koaSpaSessionGuard', () => {
|
||||||
url: `/${app}/foo`,
|
url: `/${app}/foo`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await koaSpaSessionGuard(provider)(ctx, next);
|
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||||
|
|
||||||
expect(ctx.redirect).not.toBeCalled();
|
expect(ctx.redirect).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
@ -52,7 +55,7 @@ describe('koaSpaSessionGuard', () => {
|
||||||
const ctx = createContextWithRouteParameters({
|
const ctx = createContextWithRouteParameters({
|
||||||
url: `${sessionNotFoundPath}`,
|
url: `${sessionNotFoundPath}`,
|
||||||
});
|
});
|
||||||
await koaSpaSessionGuard(provider)(ctx, next);
|
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||||
expect(ctx.redirect).not.toBeCalled();
|
expect(ctx.redirect).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -61,7 +64,7 @@ describe('koaSpaSessionGuard', () => {
|
||||||
const ctx = createContextWithRouteParameters({
|
const ctx = createContextWithRouteParameters({
|
||||||
url: '/callback/github',
|
url: '/callback/github',
|
||||||
});
|
});
|
||||||
await koaSpaSessionGuard(provider)(ctx, next);
|
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||||
expect(ctx.redirect).not.toBeCalled();
|
expect(ctx.redirect).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -71,19 +74,30 @@ describe('koaSpaSessionGuard', () => {
|
||||||
const ctx = createContextWithRouteParameters({
|
const ctx = createContextWithRouteParameters({
|
||||||
url: `/sign-in`,
|
url: `/sign-in`,
|
||||||
});
|
});
|
||||||
await koaSpaSessionGuard(provider)(ctx, next);
|
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||||
expect(ctx.redirect).not.toBeCalled();
|
expect(ctx.redirect).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const path of guardedPath) {
|
for (const path of ['/', ...guardedPath]) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||||
it(`should redirect if session not found for ${path}`, async () => {
|
it(`should redirect if session not found for ${path}`, async () => {
|
||||||
interactionDetails.mockRejectedValue(new Error('session not found'));
|
interactionDetails.mockRejectedValue(new Error('session not found'));
|
||||||
const ctx = createContextWithRouteParameters({
|
const ctx = createContextWithRouteParameters({
|
||||||
url: `${path}/foo`,
|
url: `${path}/foo`,
|
||||||
});
|
});
|
||||||
await koaSpaSessionGuard(provider)(ctx, next);
|
await koaSpaSessionGuard(provider, queries)(ctx, next);
|
||||||
expect(ctx.redirect).toBeCalled();
|
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 { MiddlewareType } from 'koa';
|
||||||
import type { IRouterParamContext } from 'koa-router';
|
import type { IRouterParamContext } from 'koa-router';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
|
|
||||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||||
import RequestError from '#src/errors/RequestError/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';
|
import { getTenantId } from '#src/utils/tenant.js';
|
||||||
|
|
||||||
// Need To Align With UI
|
// Need To Align With UI
|
||||||
|
@ -21,16 +23,32 @@ export default function koaSpaSessionGuard<
|
||||||
StateT,
|
StateT,
|
||||||
ContextT extends IRouterParamContext,
|
ContextT extends IRouterParamContext,
|
||||||
ResponseBodyT
|
ResponseBodyT
|
||||||
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
>(provider: Provider, queries: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||||
return async (ctx, next) => {
|
return async (ctx, next) => {
|
||||||
const requestPath = ctx.request.path;
|
const requestPath = ctx.request.path;
|
||||||
const isPreview = ctx.URL.searchParams.get('preview');
|
const isPreview = ctx.request.URL.searchParams.get('preview');
|
||||||
const isSessionRequiredPath = guardedPath.some((path) => requestPath.startsWith(path));
|
const isSessionRequiredPath =
|
||||||
|
requestPath === '/' || guardedPath.some((path) => requestPath.startsWith(path));
|
||||||
|
|
||||||
if (isSessionRequiredPath && !isPreview) {
|
if (isSessionRequiredPath && !isPreview) {
|
||||||
try {
|
try {
|
||||||
await provider.interactionDetails(ctx.req, ctx.res);
|
await provider.interactionDetails(ctx.req, ctx.res);
|
||||||
} catch {
|
} 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);
|
const tenantId = getTenantId(ctx.URL);
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { AdminConsoleData, LogtoConfig, LogtoConfigKey } from '@logto/schemas';
|
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 { convertToIdentifiers } from '@logto/shared';
|
||||||
import type { CommonQueryMethods } from 'slonik';
|
import type { CommonQueryMethods } from 'slonik';
|
||||||
import { sql } from 'slonik';
|
import { sql } from 'slonik';
|
||||||
|
@ -10,14 +10,14 @@ export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
|
||||||
const getAdminConsoleConfig = async () =>
|
const getAdminConsoleConfig = async () =>
|
||||||
pool.one<Record<string, unknown>>(sql`
|
pool.one<Record<string, unknown>>(sql`
|
||||||
select ${fields.value} from ${table}
|
select ${fields.value} from ${table}
|
||||||
where ${fields.key} = ${AdminConsoleConfigKey.AdminConsole}
|
where ${fields.key} = ${LogtoTenantConfigKey.AdminConsole}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const updateAdminConsoleConfig = async (value: Partial<AdminConsoleData>) =>
|
const updateAdminConsoleConfig = async (value: Partial<AdminConsoleData>) =>
|
||||||
pool.one<Record<string, unknown>>(sql`
|
pool.one<Record<string, unknown>>(sql`
|
||||||
update ${table}
|
update ${table}
|
||||||
set ${fields.value} = coalesce(${fields.value},'{}'::jsonb) || ${sql.jsonb(value)}
|
set ${fields.value} = coalesce(${fields.value},'{}'::jsonb) || ${sql.jsonb(value)}
|
||||||
where ${fields.key} = ${AdminConsoleConfigKey.AdminConsole}
|
where ${fields.key} = ${LogtoTenantConfigKey.AdminConsole}
|
||||||
returning ${fields.value}
|
returning ${fields.value}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|
|
@ -131,7 +131,7 @@ export default class Tenant implements TenantContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount UI
|
// Mount UI
|
||||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));
|
app.use(compose([koaSpaSessionGuard(provider, queries), koaSpaProxy(mountedApps)]));
|
||||||
|
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.provider = provider;
|
this.provider = provider;
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { CreateLogtoConfig } from '../db-entries/index.js';
|
import { CreateLogtoConfig } from '../db-entries/index.js';
|
||||||
import { AppearanceMode } from '../foundations/index.js';
|
import { AppearanceMode } from '../foundations/index.js';
|
||||||
import type { AdminConsoleData } from '../types/index.js';
|
import type { AdminConsoleData } from '../types/index.js';
|
||||||
import { AdminConsoleConfigKey } from '../types/index.js';
|
import { LogtoTenantConfigKey } from '../types/index.js';
|
||||||
|
|
||||||
export const createDefaultAdminConsoleConfig = (
|
export const createDefaultAdminConsoleConfig = (
|
||||||
forTenantId: string
|
forTenantId: string
|
||||||
): Readonly<{
|
): Readonly<{
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
key: AdminConsoleConfigKey;
|
key: LogtoTenantConfigKey;
|
||||||
value: AdminConsoleData;
|
value: AdminConsoleData;
|
||||||
}> =>
|
}> =>
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
tenantId: forTenantId,
|
tenantId: forTenantId,
|
||||||
key: AdminConsoleConfigKey.AdminConsole,
|
key: LogtoTenantConfigKey.AdminConsole,
|
||||||
value: {
|
value: {
|
||||||
language: 'en',
|
language: 'en',
|
||||||
appearanceMode: AppearanceMode.SyncWithSystem,
|
appearanceMode: AppearanceMode.SyncWithSystem,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { ZodType } from 'zod';
|
import type { ZodType } from 'zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Logto OIDC config
|
/* --- Logto OIDC configs --- */
|
||||||
export enum LogtoOidcConfigKey {
|
export enum LogtoOidcConfigKey {
|
||||||
PrivateKeys = 'oidc.privateKeys',
|
PrivateKeys = 'oidc.privateKeys',
|
||||||
CookieKeys = 'oidc.cookieKeys',
|
CookieKeys = 'oidc.cookieKeys',
|
||||||
|
@ -19,7 +19,7 @@ export const logtoOidcConfigGuard: Readonly<{
|
||||||
[LogtoOidcConfigKey.CookieKeys]: z.string().array(),
|
[LogtoOidcConfigKey.CookieKeys]: z.string().array(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin console config
|
/* --- Logto tenant configs --- */
|
||||||
export const adminConsoleDataGuard = z.object({
|
export const adminConsoleDataGuard = z.object({
|
||||||
// Get started challenges
|
// Get started challenges
|
||||||
livePreviewChecked: z.boolean(),
|
livePreviewChecked: z.boolean(),
|
||||||
|
@ -33,30 +33,34 @@ export const adminConsoleDataGuard = z.object({
|
||||||
|
|
||||||
export type AdminConsoleData = z.infer<typeof adminConsoleDataGuard>;
|
export type AdminConsoleData = z.infer<typeof adminConsoleDataGuard>;
|
||||||
|
|
||||||
export enum AdminConsoleConfigKey {
|
export enum LogtoTenantConfigKey {
|
||||||
AdminConsole = 'adminConsole',
|
AdminConsole = 'adminConsole',
|
||||||
|
/** The URL to redirect when session not found in Sign-in Experience. */
|
||||||
|
SessionNotFoundRedirectUrl = 'sessionNotFoundRedirectUrl',
|
||||||
}
|
}
|
||||||
export type AdminConsoleConfigType = {
|
export type LogtoTenantConfigType = {
|
||||||
[AdminConsoleConfigKey.AdminConsole]: AdminConsoleData;
|
[LogtoTenantConfigKey.AdminConsole]: AdminConsoleData;
|
||||||
|
[LogtoTenantConfigKey.SessionNotFoundRedirectUrl]: { url: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminConsoleConfigGuard: Readonly<{
|
export const logtoTenantConfigGuard: Readonly<{
|
||||||
[key in AdminConsoleConfigKey]: ZodType<AdminConsoleConfigType[key]>;
|
[key in LogtoTenantConfigKey]: ZodType<LogtoTenantConfigType[key]>;
|
||||||
}> = Object.freeze({
|
}> = Object.freeze({
|
||||||
[AdminConsoleConfigKey.AdminConsole]: adminConsoleDataGuard,
|
[LogtoTenantConfigKey.AdminConsole]: adminConsoleDataGuard,
|
||||||
|
[LogtoTenantConfigKey.SessionNotFoundRedirectUrl]: z.object({ url: z.string() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Summary
|
/* --- Summary --- */
|
||||||
export type LogtoConfigKey = LogtoOidcConfigKey | AdminConsoleConfigKey;
|
export type LogtoConfigKey = LogtoOidcConfigKey | LogtoTenantConfigKey;
|
||||||
export type LogtoConfigType = LogtoOidcConfigType | AdminConsoleConfigType;
|
export type LogtoConfigType = LogtoOidcConfigType | LogtoTenantConfigType;
|
||||||
export type LogtoConfigGuard = typeof logtoOidcConfigGuard & typeof adminConsoleConfigGuard;
|
export type LogtoConfigGuard = typeof logtoOidcConfigGuard & typeof logtoTenantConfigGuard;
|
||||||
|
|
||||||
export const logtoConfigKeys: readonly LogtoConfigKey[] = Object.freeze([
|
export const logtoConfigKeys: readonly LogtoConfigKey[] = Object.freeze([
|
||||||
...Object.values(LogtoOidcConfigKey),
|
...Object.values(LogtoOidcConfigKey),
|
||||||
...Object.values(AdminConsoleConfigKey),
|
...Object.values(LogtoTenantConfigKey),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
|
export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
|
||||||
...logtoOidcConfigGuard,
|
...logtoOidcConfigGuard,
|
||||||
...adminConsoleConfigGuard,
|
...logtoTenantConfigGuard,
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue