From e7ca10056c5278a9ef0d5cd3fdb4f4ade28a5340 Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 12 Dec 2024 17:41:17 +0800 Subject: [PATCH 1/2] feat: add saml app sessions table --- .../src/saml-applications/queries/sessions.ts | 66 +++++++++++++++++++ packages/core/src/tenants/Queries.ts | 2 + ...422-add-saml-application-sessions-table.ts | 44 +++++++++++++ .../src/foundations/jsonb-types/index.ts | 1 + .../jsonb-types/saml-application-sessions.ts | 22 +++++++ .../tables/saml_application_sessions.sql | 32 +++++++++ 6 files changed, 167 insertions(+) create mode 100644 packages/core/src/saml-applications/queries/sessions.ts create mode 100644 packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts create mode 100644 packages/schemas/src/foundations/jsonb-types/saml-application-sessions.ts create mode 100644 packages/schemas/tables/saml_application_sessions.sql diff --git a/packages/core/src/saml-applications/queries/sessions.ts b/packages/core/src/saml-applications/queries/sessions.ts new file mode 100644 index 000000000..c5e728f85 --- /dev/null +++ b/packages/core/src/saml-applications/queries/sessions.ts @@ -0,0 +1,66 @@ +import { type SamlApplicationSession, SamlApplicationSessions } from '@logto/schemas'; +import type { CommonQueryMethods } from '@silverhand/slonik'; +import { sql } from '@silverhand/slonik'; + +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; +import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; +import { convertToIdentifiers } from '#src/utils/sql.js'; + +const { table, fields } = convertToIdentifiers(SamlApplicationSessions); + +export const createSamlApplicationSessionQueries = (pool: CommonQueryMethods) => { + const insertSession = buildInsertIntoWithPool(pool)(SamlApplicationSessions, { + returning: true, + }); + + const updateSession = buildUpdateWhereWithPool(pool)(SamlApplicationSessions, true); + + const deleteExpiredOrFullyUsedSessions = async () => { + const { rowCount } = await pool.query(sql` + delete from ${table} + where ${fields.expiresAt} < now() + or (${fields.isSamlResponseSent} = true and ${fields.isOidcStateChecked} = true) + `); + + return rowCount; + }; + + const findSessionsByApplicationId = async (applicationId: string) => + pool.any(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.applicationId}=${applicationId} + `); + + const findAvailableSessionByAppIdAndState = async (applicationId: string, state: string) => + pool.one(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.applicationId}=${applicationId} + and ${fields.oidcState}=${state} and ${fields.isOidcStateChecked} = false and ${ + fields.expiresAt + } > now() + `); + + const findAvailableSessionByAppIdAndSamlRequestId = async ( + applicationId: string, + samlRequestId: string + ) => + pool.one(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.applicationId}=${applicationId} + and ${fields.samlRequestId}=${samlRequestId} and ${fields.isSamlResponseSent} = false and ${ + fields.expiresAt + } > now() + `); + + return { + insertSession, + updateSession, + deleteExpiredOrFullyUsedSessions, + findSessionsByApplicationId, + findAvailableSessionByAppIdAndState, + findAvailableSessionByAppIdAndSamlRequestId, + }; +}; diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 61de25720..59afe303f 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -30,6 +30,7 @@ import { createUsersRolesQueries } from '#src/queries/users-roles.js'; import { createVerificationStatusQueries } from '#src/queries/verification-status.js'; import { createSamlApplicationConfigQueries } from '#src/saml-applications/queries/configs.js'; import { createSamlApplicationSecretsQueries } from '#src/saml-applications/queries/secrets.js'; +import { createSamlApplicationSessionQueries } from '#src/saml-applications/queries/sessions.js'; import { AccountCenterQueries } from '../queries/account-center.js'; import { PersonalAccessTokensQueries } from '../queries/personal-access-tokens.js'; @@ -64,6 +65,7 @@ export default class Queries { subjectTokens = createSubjectTokenQueries(this.pool); samlApplicationSecrets = createSamlApplicationSecretsQueries(this.pool); samlApplicationConfigs = createSamlApplicationConfigQueries(this.pool); + samlApplicationSessions = createSamlApplicationSessionQueries(this.pool); personalAccessTokens = new PersonalAccessTokensQueries(this.pool); verificationRecords = new VerificationRecordQueries(this.pool); accountCenters = new AccountCenterQueries(this.pool); diff --git a/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts b/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts new file mode 100644 index 000000000..c7a07ea3b --- /dev/null +++ b/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts @@ -0,0 +1,44 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + create table saml_application_sessions ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + id varchar(32) not null, + application_id varchar(21) not null + references applications (id) on update cascade on delete cascade, + saml_request_id varchar(128), + oidc_state varchar(32), + is_oidc_state_checked boolean not null default false, + is_saml_response_sent boolean not null default false, + relay_state varchar(256), + auth_request_info jsonb not null, + created_at timestamptz not null default(now()), + expires_at timestamptz not null, + primary key (tenant_id, id), + constraint saml_application_sessions__application_type + check (check_application_type(application_id, 'SAML')) + ); + + create unique index saml_application_sessions__oidc_state + on saml_application_sessions (tenant_id, oidc_state); + create unique index saml_application_sessions__saml_request_id + on saml_application_sessions (tenant_id, saml_request_id); + `); + await applyTableRls(pool, 'saml_application_sessions'); + }, + down: async (pool) => { + await dropTableRls(pool, 'saml_application_sessions'); + await pool.query(sql` + drop table if exists saml_application_sessions; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types/index.ts b/packages/schemas/src/foundations/jsonb-types/index.ts index 3c8512083..2c192c283 100644 --- a/packages/schemas/src/foundations/jsonb-types/index.ts +++ b/packages/schemas/src/foundations/jsonb-types/index.ts @@ -11,6 +11,7 @@ export * from './applications.js'; export * from './verification-records.js'; export * from './account-centers.js'; export * from './saml-application-configs.js'; +export * from './saml-application-sessions.js'; export { configurableConnectorMetadataGuard, diff --git a/packages/schemas/src/foundations/jsonb-types/saml-application-sessions.ts b/packages/schemas/src/foundations/jsonb-types/saml-application-sessions.ts new file mode 100644 index 000000000..a2cdc3f19 --- /dev/null +++ b/packages/schemas/src/foundations/jsonb-types/saml-application-sessions.ts @@ -0,0 +1,22 @@ +import { type ToZodObject } from '@logto/connector-kit'; +import { z } from 'zod'; + +export type AuthRequestInfo = { + issuer: string; + request: { + id: string; + destination: string; + issueInstant: string; + assertionConsumerServiceUrl: string; + }; +}; + +export const authRequestInfoGuard = z.object({ + issuer: z.string(), + request: z.object({ + id: z.string(), + destination: z.string(), + issueInstant: z.string(), + assertionConsumerServiceUrl: z.string(), + }), +}) satisfies ToZodObject; diff --git a/packages/schemas/tables/saml_application_sessions.sql b/packages/schemas/tables/saml_application_sessions.sql new file mode 100644 index 000000000..344197ffe --- /dev/null +++ b/packages/schemas/tables/saml_application_sessions.sql @@ -0,0 +1,32 @@ +/* init_order = 2 */ + +create table saml_application_sessions ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + /** The globally unique identifier of the session. */ + id varchar(32) not null, + application_id varchar(21) not null + references applications (id) on update cascade on delete cascade, + /** The identifier of the SAML SSO auth request ID, SAML request ID is pretty long. */ + saml_request_id varchar(128), + /** The identifier of the OIDC auth request state. */ + oidc_state varchar(32), + /** When checking the OIDC auth state, we should have this flag to prevent replay attack. */ + is_oidc_state_checked boolean not null default false, + /** When sending the SAML authn response, we should have this flag to prevent replay attack. */ + is_saml_response_sent boolean not null default false, + /** The relay state of the SAML auth request. */ + relay_state varchar(256), + /** The request info of the SAML auth request. */ + auth_request_info jsonb /* @use AuthRequestInfo */ not null, + created_at timestamptz not null default(now()), + expires_at timestamptz not null, + primary key (tenant_id, id), + constraint saml_application_sessions__application_type + check (check_application_type(application_id, 'SAML')) +); + +create unique index saml_application_sessions__oidc_state + on saml_application_sessions (tenant_id, oidc_state); +create unique index saml_application_sessions__saml_request_id + on saml_application_sessions (tenant_id, saml_request_id); From fd2ea4a24ea7e24b513c066fdf985102638f839c Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 26 Dec 2024 12:22:17 +0800 Subject: [PATCH 2/2] chore: update code --- .../src/saml-applications/queries/sessions.ts | 69 +++++++++++-------- ...422-add-saml-application-sessions-table.ts | 11 +-- .../tables/saml_application_sessions.sql | 15 +--- 3 files changed, 45 insertions(+), 50 deletions(-) diff --git a/packages/core/src/saml-applications/queries/sessions.ts b/packages/core/src/saml-applications/queries/sessions.ts index c5e728f85..7d96d9862 100644 --- a/packages/core/src/saml-applications/queries/sessions.ts +++ b/packages/core/src/saml-applications/queries/sessions.ts @@ -4,6 +4,7 @@ import { sql } from '@silverhand/slonik'; import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; +import { DeletionError } from '#src/errors/SlonikError/index.js'; import { convertToIdentifiers } from '#src/utils/sql.js'; const { table, fields } = convertToIdentifiers(SamlApplicationSessions); @@ -15,16 +16,48 @@ export const createSamlApplicationSessionQueries = (pool: CommonQueryMethods) => const updateSession = buildUpdateWhereWithPool(pool)(SamlApplicationSessions, true); - const deleteExpiredOrFullyUsedSessions = async () => { + /** + * Removes the OIDC state from a session, which marks OIDC state as consumed. + * + * @param id The ID of the session. + * @returns The updated session. + */ + const removeSessionOidcStateById = async (id: string) => + updateSession({ + set: { oidcState: null }, + where: { id }, + jsonbMode: 'merge', + }); + + const deleteExpiredSessions = async () => { const { rowCount } = await pool.query(sql` delete from ${table} where ${fields.expiresAt} < now() - or (${fields.isSamlResponseSent} = true and ${fields.isOidcStateChecked} = true) `); - return rowCount; + if (rowCount < 1) { + throw new DeletionError(SamlApplicationSessions.table); + } }; + const deleteSessionById = async (id: string) => { + const { rowCount } = await pool.query(sql` + delete from ${table} + where ${fields.id} = ${id} + `); + + if (rowCount < 1) { + throw new DeletionError(SamlApplicationSessions.table); + } + }; + + const findSessionById = async (id: string) => + pool.maybeOne(sql` + select ${sql.join(Object.values(fields), sql`, `)} + from ${table} + where ${fields.id}=${id} + `); + const findSessionsByApplicationId = async (applicationId: string) => pool.any(sql` select ${sql.join(Object.values(fields), sql`, `)} @@ -32,35 +65,13 @@ export const createSamlApplicationSessionQueries = (pool: CommonQueryMethods) => where ${fields.applicationId}=${applicationId} `); - const findAvailableSessionByAppIdAndState = async (applicationId: string, state: string) => - pool.one(sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - where ${fields.applicationId}=${applicationId} - and ${fields.oidcState}=${state} and ${fields.isOidcStateChecked} = false and ${ - fields.expiresAt - } > now() - `); - - const findAvailableSessionByAppIdAndSamlRequestId = async ( - applicationId: string, - samlRequestId: string - ) => - pool.one(sql` - select ${sql.join(Object.values(fields), sql`, `)} - from ${table} - where ${fields.applicationId}=${applicationId} - and ${fields.samlRequestId}=${samlRequestId} and ${fields.isSamlResponseSent} = false and ${ - fields.expiresAt - } > now() - `); - return { insertSession, updateSession, - deleteExpiredOrFullyUsedSessions, + removeSessionOidcStateById, + deleteExpiredSessions, + deleteSessionById, + findSessionById, findSessionsByApplicationId, - findAvailableSessionByAppIdAndState, - findAvailableSessionByAppIdAndSamlRequestId, }; }; diff --git a/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts b/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts index c7a07ea3b..80c789860 100644 --- a/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts +++ b/packages/schemas/alterations/next-1735012422-add-saml-application-sessions-table.ts @@ -13,23 +13,16 @@ const alteration: AlterationScript = { id varchar(32) not null, application_id varchar(21) not null references applications (id) on update cascade on delete cascade, - saml_request_id varchar(128), + saml_request_id varchar(128) not null, oidc_state varchar(32), - is_oidc_state_checked boolean not null default false, - is_saml_response_sent boolean not null default false, relay_state varchar(256), - auth_request_info jsonb not null, + raw_auth_request text not null, created_at timestamptz not null default(now()), expires_at timestamptz not null, primary key (tenant_id, id), constraint saml_application_sessions__application_type check (check_application_type(application_id, 'SAML')) ); - - create unique index saml_application_sessions__oidc_state - on saml_application_sessions (tenant_id, oidc_state); - create unique index saml_application_sessions__saml_request_id - on saml_application_sessions (tenant_id, saml_request_id); `); await applyTableRls(pool, 'saml_application_sessions'); }, diff --git a/packages/schemas/tables/saml_application_sessions.sql b/packages/schemas/tables/saml_application_sessions.sql index 344197ffe..e3f97dd38 100644 --- a/packages/schemas/tables/saml_application_sessions.sql +++ b/packages/schemas/tables/saml_application_sessions.sql @@ -8,25 +8,16 @@ create table saml_application_sessions ( application_id varchar(21) not null references applications (id) on update cascade on delete cascade, /** The identifier of the SAML SSO auth request ID, SAML request ID is pretty long. */ - saml_request_id varchar(128), + saml_request_id varchar(128) not null, /** The identifier of the OIDC auth request state. */ oidc_state varchar(32), - /** When checking the OIDC auth state, we should have this flag to prevent replay attack. */ - is_oidc_state_checked boolean not null default false, - /** When sending the SAML authn response, we should have this flag to prevent replay attack. */ - is_saml_response_sent boolean not null default false, /** The relay state of the SAML auth request. */ relay_state varchar(256), - /** The request info of the SAML auth request. */ - auth_request_info jsonb /* @use AuthRequestInfo */ not null, + /** The raw request of the SAML auth request. */ + raw_auth_request text not null, created_at timestamptz not null default(now()), expires_at timestamptz not null, primary key (tenant_id, id), constraint saml_application_sessions__application_type check (check_application_type(application_id, 'SAML')) ); - -create unique index saml_application_sessions__oidc_state - on saml_application_sessions (tenant_id, oidc_state); -create unique index saml_application_sessions__saml_request_id - on saml_application_sessions (tenant_id, saml_request_id);