0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add SAML auth request handling endpoints

This commit is contained in:
Darcy Ye 2024-12-10 18:40:59 +08:00
parent ebeca1607d
commit 10a310d50f
No known key found for this signature in database
GPG key ID: B46F4C07EDEFC610
8 changed files with 466 additions and 20 deletions

View file

@ -134,8 +134,12 @@ export const buildRouterObjects = <T extends UnknownRouter>(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<RouteObject>(({ path: routerPath, stack, methods }) =>
methods
.map((method) => method.toLowerCase())

View file

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

View file

@ -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<T> = {
// 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<SamlApplicationConfig, 'attributeMapping' | 'entityId' | 'acsUrl'> &
NullableObject<SamlApplicationSecretDetails>;
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<SamlApplicationDetails>;
export const createSamlApplicationQueries = (pool: CommonQueryMethods) => {
const getSamlApplicationDetailsById = async (id: string): Promise<SamlApplicationDetails> => {
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,
};
};

View file

@ -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<T extends AnonymousRouter>(
...[router, { libraries }]: RouterInitArgs<T>
...[router, { envSet, libraries, queries }]: RouterInitArgs<T>
) {
const {
samlApplications: { getSamlIdPMetadataByApplicationId },
} = libraries;
const {
samlApplications: { getSamlApplicationDetailsById },
samlApplicationSessions: { insertSession },
} = queries;
router.get(
'/saml-applications/:id/metadata',
@ -29,4 +45,189 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
return next();
}
);
// Redirect binding SAML authentication request endpoint
router.get(
'/saml/:id/authn',
koaGuard({
params: z.object({ id: z.string() }),
query: z
.object({
SAMLRequest: z.string().min(1),
Signature: z.string().optional(),
SigAlg: z.string().optional(),
RelayState: z.string().optional(),
})
.catchall(z.string()),
status: [200, 302, 400, 404],
}),
async (ctx, next) => {
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();
}
);
}

View file

@ -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<string> => {
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(),
}),
});

View file

@ -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<CamelCaseKeys<OidcConfigResponse>> => {
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,

View file

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

View file

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