0
Fork 0
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:
Gao Sun 2023-03-13 11:36:10 +08:00 committed by GitHub
parent 8026bdbeb8
commit 5784cfeb18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 77 additions and 32 deletions

View 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.

View file

@ -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');
});
});

View file

@ -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) {

View file

@ -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}
`);

View file

@ -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;

View file

@ -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,

View file

@ -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,
});