diff --git a/packages/core/src/routes/swagger/utils/operation.ts b/packages/core/src/routes/swagger/utils/operation.ts index 0a3e2b3e9..0e921a3e2 100644 --- a/packages/core/src/routes/swagger/utils/operation.ts +++ b/packages/core/src/routes/swagger/utils/operation.ts @@ -134,8 +134,12 @@ export const buildRouterObjects = (routers: T[], option // Filter out universal routes (mostly like a proxy route to withtyped) .filter(({ path }) => !path.includes('.*')) // TODO: Remove this and bring back `/saml-applications` routes before release. - // Exclude `/saml-applications` routes for now. - .filter(({ path }) => !path.startsWith('/saml-applications')) + // Exclude `/saml-applications` routes and `/saml/:id/authn` for now. + .filter( + ({ path }) => + !path.startsWith('/saml-applications') && + !(path.startsWith('/saml') && path.endsWith('/authn')) + ) .flatMap(({ path: routerPath, stack, methods }) => methods .map((method) => method.toLowerCase()) diff --git a/packages/core/src/saml-applications/libraries/saml-applications.ts b/packages/core/src/saml-applications/libraries/saml-applications.ts index 3b178c2a1..4695e0616 100644 --- a/packages/core/src/saml-applications/libraries/saml-applications.ts +++ b/packages/core/src/saml-applications/libraries/saml-applications.ts @@ -115,7 +115,9 @@ export const createSamlApplicationsLibrary = (queries: Queries) => { }); }; - const getSamlIdPMetadataByApplicationId = async (id: string): Promise<{ metadata: string }> => { + const getSamlIdPMetadataByApplicationId = async ( + id: string + ): Promise<{ metadata: string; certificate: string }> => { const [{ tenantId }, { certificate }] = await Promise.all([ findSamlApplicationConfigByApplicationId(id), findActiveSamlApplicationSecretByApplicationId(id), @@ -132,11 +134,16 @@ export const createSamlApplicationsLibrary = (queries: Queries) => { Location: buildSingleSignOnUrl(tenantEndpoint, id), Binding: BindingType.Redirect, }, + { + Location: buildSingleSignOnUrl(tenantEndpoint, id), + Binding: BindingType.Post, + }, ], }); return { metadata: idp.getMetadata(), + certificate, }; }; diff --git a/packages/core/src/saml-applications/queries/index.ts b/packages/core/src/saml-applications/queries/index.ts new file mode 100644 index 000000000..2ad6039f1 --- /dev/null +++ b/packages/core/src/saml-applications/queries/index.ts @@ -0,0 +1,82 @@ +import { type ToZodObject } from '@logto/connector-kit'; +import { + SamlApplicationConfigs, + SamlApplicationSecrets, + Applications, + ApplicationType, + type Application, + type SamlApplicationConfig, + type SamlApplicationSecret, +} from '@logto/schemas'; +import type { CommonQueryMethods } from '@silverhand/slonik'; +import { sql } from '@silverhand/slonik'; +import { z } from 'zod'; + +import { convertToIdentifiers } from '#src/utils/sql.js'; + +const { table, fields } = convertToIdentifiers(Applications, true); +const { table: samlApplicationConfigsTable, fields: samlApplicationConfigsFields } = + convertToIdentifiers(SamlApplicationConfigs, true); +const { table: samlApplicationSecretsTable, fields: samlApplicationSecretsFields } = + convertToIdentifiers(SamlApplicationSecrets, true); + +type NullableObject = { + // eslint-disable-next-line @typescript-eslint/ban-types + [P in keyof T]: T[P] | null; +}; + +type SamlApplicationSecretDetails = Pick< + SamlApplicationSecret, + 'privateKey' | 'certificate' | 'active' | 'expiresAt' +>; + +export type SamlApplicationDetails = Pick< + Application, + 'id' | 'secret' | 'name' | 'description' | 'customData' | 'oidcClientMetadata' +> & + Pick & + NullableObject; + +const samlApplicationDetailsGuard = Applications.guard + .pick({ + id: true, + secret: true, + name: true, + description: true, + customData: true, + oidcClientMetadata: true, + }) + .merge( + SamlApplicationConfigs.guard.pick({ + attributeMapping: true, + entityId: true, + acsUrl: true, + }) + ) + .merge( + // Zod does not provide a way to convert all fields to nullable, so we need to do it manually. Other implementations seems can not make TypeScript happy. + z.object({ + privateKey: SamlApplicationSecrets.guard.shape.privateKey.nullable(), + certificate: SamlApplicationSecrets.guard.shape.certificate.nullable(), + active: SamlApplicationSecrets.guard.shape.active.nullable(), + expiresAt: SamlApplicationSecrets.guard.shape.expiresAt.nullable(), + }) + ) satisfies ToZodObject; + +export const createSamlApplicationQueries = (pool: CommonQueryMethods) => { + const getSamlApplicationDetailsById = async (id: string): Promise => { + const result = await pool.one(sql` + select ${fields.id} as id, ${fields.secret} as secret, ${fields.name} as name, ${fields.description} as description, ${fields.customData} as custom_data, ${fields.oidcClientMetadata} as oidc_client_metadata, ${samlApplicationConfigsFields.attributeMapping} as attribute_mapping, ${samlApplicationConfigsFields.entityId} as entity_id, ${samlApplicationConfigsFields.acsUrl} as acs_url, ${samlApplicationSecretsFields.privateKey} as private_key, ${samlApplicationSecretsFields.certificate} as certificate, ${samlApplicationSecretsFields.active} as active, ${samlApplicationSecretsFields.expiresAt} as expires_at + from ${table} + left join ${samlApplicationConfigsTable} on ${fields.id}=${samlApplicationConfigsFields.applicationId} + left join ${samlApplicationSecretsTable} on ${fields.id}=${samlApplicationSecretsFields.applicationId} + where ${fields.id}=${id} and ${fields.type}=${ApplicationType.SAML} and ${samlApplicationSecretsFields.active}=true + `); + + return samlApplicationDetailsGuard.parse(result); + }; + + return { + getSamlApplicationDetailsById, + }; +}; diff --git a/packages/core/src/saml-applications/routes/anonymous.ts b/packages/core/src/saml-applications/routes/anonymous.ts index 661eb48bb..ba93855e9 100644 --- a/packages/core/src/saml-applications/routes/anonymous.ts +++ b/packages/core/src/saml-applications/routes/anonymous.ts @@ -1,14 +1,30 @@ +import { generateStandardId, generateStandardShortId } from '@logto/shared'; +import { removeUndefinedKeys } from '@silverhand/essentials'; +import { addMinutes } from 'date-fns'; import { z } from 'zod'; +import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type { AnonymousRouter, RouterInitArgs } from '#src/routes/types.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { + getSignInUrl, + validateSamlApplicationDetails, + loginRequestExtractGuard, + getSamlIdpAndSp, +} from './utils.js'; export default function samlApplicationAnonymousRoutes( - ...[router, { libraries }]: RouterInitArgs + ...[router, { envSet, libraries, queries }]: RouterInitArgs ) { const { samlApplications: { getSamlIdPMetadataByApplicationId }, } = libraries; + const { + samlApplications: { getSamlApplicationDetailsById }, + samlApplicationSessions: { insertSession }, + } = queries; router.get( '/saml-applications/:id/metadata', @@ -29,4 +45,189 @@ export default function samlApplicationAnonymousRoutes { + const { + params: { id }, + // TODO: handle `RelayState` later + query: { Signature, ...rest }, + } = ctx.guard; + + const [{ metadata, certificate }, details] = await Promise.all([ + getSamlIdPMetadataByApplicationId(id), + getSamlApplicationDetailsById(id), + ]); + + const { entityId, acsUrl, redirectUri } = validateSamlApplicationDetails(details); + + const { idp, sp } = getSamlIdpAndSp({ + idp: { metadata, certificate }, + sp: { entityId, acsUrl }, + }); + + const octetString = Object.keys(ctx.request.query) + // eslint-disable-next-line no-restricted-syntax + .map((key) => key + '=' + encodeURIComponent(ctx.request.query[key] as string)) + .join('&'); + const { SAMLRequest, SigAlg } = rest; + + // Parse login request + try { + const loginRequestResult = await idp.parseLoginRequest(sp, 'redirect', { + query: removeUndefinedKeys({ + SAMLRequest, + Signature, + SigAlg, + }), + octetString, + }); + + const extractResult = loginRequestExtractGuard.safeParse(loginRequestResult.extract); + + if (!extractResult.success) { + throw new RequestError({ + code: 'application.saml.invalid_saml_request', + error: extractResult.error.flatten(), + }); + } + + assertThat( + extractResult.data.issuer === entityId, + 'application.saml.auth_request_issuer_not_match' + ); + + const state = generateStandardId(32); + const signInUrl = await getSignInUrl({ + issuer: envSet.oidc.issuer, + applicationId: id, + redirectUri, + state, + }); + + const currentDate = new Date(); + const expiresAt = addMinutes(currentDate, 10); + const createSession = { + id: generateStandardId(32), + applicationId: id, + oidcState: state, + samlRequestId: extractResult.data.request.id, + // Expire the session in 10 minutes. + expiresAt: expiresAt.getTime(), + }; + + const insertSamlAppSession = await insertSession(createSession); + + ctx.redirect(signInUrl.toString()); + } catch (error: unknown) { + if (error instanceof RequestError) { + throw error; + } + + throw new RequestError({ + code: 'application.saml.invalid_saml_request', + }); + } + + return next(); + } + ); + + // Post binding SAML authentication request endpoint + router.post( + '/saml/:id/authn', + koaGuard({ + params: z.object({ id: z.string() }), + body: z.object({ + SAMLRequest: z.string().min(1), + RelayState: z.string().optional(), + }), + status: [200, 302, 400, 404], + }), + async (ctx, next) => { + const { + params: { id }, + // TODO: handle `RelayState` later + body: { SAMLRequest }, + } = ctx.guard; + + const [{ metadata, certificate }, details] = await Promise.all([ + getSamlIdPMetadataByApplicationId(id), + getSamlApplicationDetailsById(id), + ]); + + const { acsUrl, entityId, redirectUri } = validateSamlApplicationDetails(details); + + const { idp, sp } = getSamlIdpAndSp({ + idp: { metadata, certificate }, + sp: { entityId, acsUrl }, + }); + + // Parse login request + try { + const loginRequestResult = await idp.parseLoginRequest(sp, 'post', { + body: { + SAMLRequest, + }, + }); + + const extractResult = loginRequestExtractGuard.safeParse(loginRequestResult.extract); + + if (!extractResult.success) { + throw new RequestError({ + code: 'application.saml.invalid_saml_request', + error: extractResult.error.flatten(), + }); + } + + assertThat( + extractResult.data.issuer === entityId, + 'application.saml.auth_request_issuer_not_match' + ); + + const state = generateStandardShortId(); + const signInUrl = await getSignInUrl({ + issuer: envSet.oidc.issuer, + applicationId: id, + redirectUri, + state, + }); + + const insertSamlAppSession = await insertSession({ + id: generateStandardId(), + applicationId: id, + oidcState: state, + samlRequestId: extractResult.data.request.id, + // Expire the session in 10 minutes. + expiresAt: addMinutes(new Date(), 10).getTime(), + }); + + ctx.redirect(signInUrl.toString()); + } catch (error: unknown) { + if (error instanceof RequestError) { + throw error; + } + + throw new RequestError({ + code: 'application.saml.invalid_saml_request', + }); + } + + return next(); + } + ); } diff --git a/packages/core/src/saml-applications/routes/utils.ts b/packages/core/src/saml-applications/routes/utils.ts new file mode 100644 index 000000000..1089b306f --- /dev/null +++ b/packages/core/src/saml-applications/routes/utils.ts @@ -0,0 +1,148 @@ +import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js'; +import { type SamlAcsUrl } from '@logto/schemas'; +import { deduplicate, tryThat } from '@silverhand/essentials'; +import { XMLValidator } from 'fast-xml-parser'; +import saml from 'samlify'; +import { z, ZodError } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { fetchOidcConfigRaw } from '#src/sso/OidcConnector/utils.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { type SamlApplicationDetails } from '../queries/index.js'; + +const getAuthorizationEndpoint = async (issuer: string): Promise => { + const { authorizationEndpoint } = await tryThat( + async () => fetchOidcConfigRaw(issuer), + (error) => { + if (error instanceof ZodError) { + throw new RequestError({ + code: 'oidc.invalid_request', + message: error.message, + error: error.flatten(), + }); + } + + throw error; + } + ); + + return authorizationEndpoint; +}; + +export const getSignInUrl = async ({ + issuer, + applicationId, + redirectUri, + scope, + state, +}: { + issuer: string; + applicationId: string; + redirectUri: string; + scope?: string; + state?: string; +}) => { + const authorizationEndpoint = await getAuthorizationEndpoint(issuer); + + const queryParameters = new URLSearchParams({ + [QueryKey.ClientId]: applicationId, + [QueryKey.RedirectUri]: redirectUri, + [QueryKey.ResponseType]: 'code', + [QueryKey.Prompt]: Prompt.Login, + }); + + // TODO: get value of `scope` parameters according to setup in attribute mapping. + queryParameters.append( + QueryKey.Scope, + // For security reasons, DO NOT include the offline_access scope by default. + deduplicate([ + ReservedScope.OpenId, + UserScope.Profile, + UserScope.Roles, + UserScope.Organizations, + UserScope.OrganizationRoles, + UserScope.CustomData, + UserScope.Identities, + ...(scope?.split(' ') ?? []), + ]).join(' ') + ); + + if (state) { + queryParameters.append(QueryKey.State, state); + } + + return new URL(`${authorizationEndpoint}?${queryParameters.toString()}`); +}; + +export const validateSamlApplicationDetails = (details: SamlApplicationDetails) => { + const { + entityId, + acsUrl, + oidcClientMetadata: { redirectUris }, + } = details; + + assertThat(acsUrl, 'application.saml.acs_url_required'); + assertThat(entityId, 'application.saml.entity_id_required'); + assertThat(redirectUris[0], 'oidc.invalid_redirect_uri'); + + return { + entityId, + acsUrl, + redirectUri: redirectUris[0], + }; +}; + +export const getSamlIdpAndSp = ({ + idp: { metadata, certificate }, + sp: { entityId, acsUrl }, +}: { + idp: { metadata: string; certificate: string }; + sp: { entityId: string; acsUrl: SamlAcsUrl }; +}): { idp: saml.IdentityProviderInstance; sp: saml.ServiceProviderInstance } => { + // eslint-disable-next-line new-cap + const idp = saml.IdentityProvider({ + metadata, + }); + + // eslint-disable-next-line new-cap + const sp = saml.ServiceProvider({ + entityID: entityId, + assertionConsumerService: [ + { + Binding: acsUrl.binding, + Location: acsUrl.url, + }, + ], + signingCert: certificate, + authnRequestsSigned: idp.entityMeta.isWantAuthnRequestsSigned(), + allowCreate: false, + }); + + // Used to check whether xml content is valid in format. + saml.setSchemaValidator({ + validate: async (xmlContent: string) => { + try { + XMLValidator.validate(xmlContent, { + allowBooleanAttributes: true, + }); + + return true; + } catch { + return false; + } + }, + }); + + return { idp, sp }; +}; + +export const loginRequestExtractGuard = z.object({ + issuer: z.string(), + request: z.object({ + id: z.string(), + destination: z.string(), + issueInstant: z.string(), + assertionConsumerServiceUrl: z.string(), + }), +}); diff --git a/packages/core/src/sso/OidcConnector/utils.ts b/packages/core/src/sso/OidcConnector/utils.ts index 0ec57ade9..b723ead4e 100644 --- a/packages/core/src/sso/OidcConnector/utils.ts +++ b/packages/core/src/sso/OidcConnector/utils.ts @@ -3,7 +3,7 @@ import { assert } from '@silverhand/essentials'; import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys'; import { got, HTTPError } from 'got'; import { createRemoteJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose'; -import { z } from 'zod'; +import { z, ZodError } from 'zod'; import { SsoConnectorConfigErrorCodes, @@ -20,30 +20,28 @@ import { type OidcTokenResponse, } from '../types/oidc.js'; +export const fetchOidcConfigRaw = async (issuer: string) => { + const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, { + responseType: 'json', + }); + + return camelcaseKeys(oidcConfigResponseGuard.parse(body)); +}; + export const fetchOidcConfig = async ( issuer: string ): Promise> => { try { - const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, { - responseType: 'json', - }); - - const result = oidcConfigResponseGuard.safeParse(body); - - if (!result.success) { + return await fetchOidcConfigRaw(issuer); + } catch (error: unknown) { + if (error instanceof ZodError) { throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { config: { issuer }, message: SsoConnectorConfigErrorCodes.InvalidConfigResponse, - error: result.error.flatten(), + error: error.flatten(), }); } - return camelcaseKeys(result.data); - } catch (error: unknown) { - if (error instanceof SsoConnectorError) { - throw error; - } - throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { config: { issuer }, message: SsoConnectorConfigErrorCodes.FailToFetchConfig, diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 59afe303f..509b7ca1c 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -29,6 +29,7 @@ import { createUserQueries } from '#src/queries/user.js'; 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 { createSamlApplicationQueries } from '#src/saml-applications/queries/index.js'; import { createSamlApplicationSecretsQueries } from '#src/saml-applications/queries/secrets.js'; import { createSamlApplicationSessionQueries } from '#src/saml-applications/queries/sessions.js'; @@ -66,6 +67,7 @@ export default class Queries { samlApplicationSecrets = createSamlApplicationSecretsQueries(this.pool); samlApplicationConfigs = createSamlApplicationConfigQueries(this.pool); samlApplicationSessions = createSamlApplicationSessionQueries(this.pool); + samlApplications = createSamlApplicationQueries(this.pool); personalAccessTokens = new PersonalAccessTokensQueries(this.pool); verificationRecords = new VerificationRecordQueries(this.pool); accountCenters = new AccountCenterQueries(this.pool); diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index 21a1e7a2b..bcf3a417b 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -27,8 +27,12 @@ const application = { 'Only HTTP-POST binding is supported for receiving SAML assertions.', can_not_delete_active_secret: 'Can not delete the active secret.', no_active_secret: 'No active secret found.', - entity_id_required: 'Entity ID is required to generate metadata.', + entity_id_required: 'Entity ID is required.', invalid_certificate_pem_format: 'Invalid PEM certificate format', + acs_url_required: 'Assertion Consumer Service URL is required.', + invalid_saml_request: 'Invalid SAML authentication request.', + auth_request_issuer_not_match: + 'The issuer of the SAML authentication request mismatch with service provider entity ID.', }, };