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-1734059671-add-saml-application-sessions-table.ts b/packages/schemas/alterations/next-1734059671-add-saml-application-sessions-table.ts new file mode 100644 index 000000000..d7f826d33 --- /dev/null +++ b/packages/schemas/alterations/next-1734059671-add-saml-application-sessions-table.ts @@ -0,0 +1,42 @@ +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, + 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/tables/saml_application_sessions.sql b/packages/schemas/tables/saml_application_sessions.sql new file mode 100644 index 000000000..6cc37d00e --- /dev/null +++ b/packages/schemas/tables/saml_application_sessions.sql @@ -0,0 +1,28 @@ +/* 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, + 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);