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:
parent
ebeca1607d
commit
10a310d50f
8 changed files with 466 additions and 20 deletions
|
@ -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 out universal routes (mostly like a proxy route to withtyped)
|
||||||
.filter(({ path }) => !path.includes('.*'))
|
.filter(({ path }) => !path.includes('.*'))
|
||||||
// TODO: Remove this and bring back `/saml-applications` routes before release.
|
// TODO: Remove this and bring back `/saml-applications` routes before release.
|
||||||
// Exclude `/saml-applications` routes for now.
|
// Exclude `/saml-applications` routes and `/saml/:id/authn` for now.
|
||||||
.filter(({ path }) => !path.startsWith('/saml-applications'))
|
.filter(
|
||||||
|
({ path }) =>
|
||||||
|
!path.startsWith('/saml-applications') &&
|
||||||
|
!(path.startsWith('/saml') && path.endsWith('/authn'))
|
||||||
|
)
|
||||||
.flatMap<RouteObject>(({ path: routerPath, stack, methods }) =>
|
.flatMap<RouteObject>(({ path: routerPath, stack, methods }) =>
|
||||||
methods
|
methods
|
||||||
.map((method) => method.toLowerCase())
|
.map((method) => method.toLowerCase())
|
||||||
|
|
|
@ -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([
|
const [{ tenantId }, { certificate }] = await Promise.all([
|
||||||
findSamlApplicationConfigByApplicationId(id),
|
findSamlApplicationConfigByApplicationId(id),
|
||||||
findActiveSamlApplicationSecretByApplicationId(id),
|
findActiveSamlApplicationSecretByApplicationId(id),
|
||||||
|
@ -132,11 +134,16 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
|
||||||
Location: buildSingleSignOnUrl(tenantEndpoint, id),
|
Location: buildSingleSignOnUrl(tenantEndpoint, id),
|
||||||
Binding: BindingType.Redirect,
|
Binding: BindingType.Redirect,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Location: buildSingleSignOnUrl(tenantEndpoint, id),
|
||||||
|
Binding: BindingType.Post,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadata: idp.getMetadata(),
|
metadata: idp.getMetadata(),
|
||||||
|
certificate,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
82
packages/core/src/saml-applications/queries/index.ts
Normal file
82
packages/core/src/saml-applications/queries/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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 { z } from 'zod';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
import koaGuard from '#src/middleware/koa-guard.js';
|
||||||
import type { AnonymousRouter, RouterInitArgs } from '#src/routes/types.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>(
|
export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter>(
|
||||||
...[router, { libraries }]: RouterInitArgs<T>
|
...[router, { envSet, libraries, queries }]: RouterInitArgs<T>
|
||||||
) {
|
) {
|
||||||
const {
|
const {
|
||||||
samlApplications: { getSamlIdPMetadataByApplicationId },
|
samlApplications: { getSamlIdPMetadataByApplicationId },
|
||||||
} = libraries;
|
} = libraries;
|
||||||
|
const {
|
||||||
|
samlApplications: { getSamlApplicationDetailsById },
|
||||||
|
samlApplicationSessions: { insertSession },
|
||||||
|
} = queries;
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/saml-applications/:id/metadata',
|
'/saml-applications/:id/metadata',
|
||||||
|
@ -29,4 +45,189 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
||||||
return next();
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
148
packages/core/src/saml-applications/routes/utils.ts
Normal file
148
packages/core/src/saml-applications/routes/utils.ts
Normal 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(),
|
||||||
|
}),
|
||||||
|
});
|
|
@ -3,7 +3,7 @@ import { assert } from '@silverhand/essentials';
|
||||||
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
|
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
|
||||||
import { got, HTTPError } from 'got';
|
import { got, HTTPError } from 'got';
|
||||||
import { createRemoteJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose';
|
import { createRemoteJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose';
|
||||||
import { z } from 'zod';
|
import { z, ZodError } from 'zod';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SsoConnectorConfigErrorCodes,
|
SsoConnectorConfigErrorCodes,
|
||||||
|
@ -20,30 +20,28 @@ import {
|
||||||
type OidcTokenResponse,
|
type OidcTokenResponse,
|
||||||
} from '../types/oidc.js';
|
} 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 (
|
export const fetchOidcConfig = async (
|
||||||
issuer: string
|
issuer: string
|
||||||
): Promise<CamelCaseKeys<OidcConfigResponse>> => {
|
): Promise<CamelCaseKeys<OidcConfigResponse>> => {
|
||||||
try {
|
try {
|
||||||
const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, {
|
return await fetchOidcConfigRaw(issuer);
|
||||||
responseType: 'json',
|
} catch (error: unknown) {
|
||||||
});
|
if (error instanceof ZodError) {
|
||||||
|
|
||||||
const result = oidcConfigResponseGuard.safeParse(body);
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, {
|
throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, {
|
||||||
config: { issuer },
|
config: { issuer },
|
||||||
message: SsoConnectorConfigErrorCodes.InvalidConfigResponse,
|
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, {
|
throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, {
|
||||||
config: { issuer },
|
config: { issuer },
|
||||||
message: SsoConnectorConfigErrorCodes.FailToFetchConfig,
|
message: SsoConnectorConfigErrorCodes.FailToFetchConfig,
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { createUserQueries } from '#src/queries/user.js';
|
||||||
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
|
||||||
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
import { createVerificationStatusQueries } from '#src/queries/verification-status.js';
|
||||||
import { createSamlApplicationConfigQueries } from '#src/saml-applications/queries/configs.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 { createSamlApplicationSecretsQueries } from '#src/saml-applications/queries/secrets.js';
|
||||||
import { createSamlApplicationSessionQueries } from '#src/saml-applications/queries/sessions.js';
|
import { createSamlApplicationSessionQueries } from '#src/saml-applications/queries/sessions.js';
|
||||||
|
|
||||||
|
@ -66,6 +67,7 @@ export default class Queries {
|
||||||
samlApplicationSecrets = createSamlApplicationSecretsQueries(this.pool);
|
samlApplicationSecrets = createSamlApplicationSecretsQueries(this.pool);
|
||||||
samlApplicationConfigs = createSamlApplicationConfigQueries(this.pool);
|
samlApplicationConfigs = createSamlApplicationConfigQueries(this.pool);
|
||||||
samlApplicationSessions = createSamlApplicationSessionQueries(this.pool);
|
samlApplicationSessions = createSamlApplicationSessionQueries(this.pool);
|
||||||
|
samlApplications = createSamlApplicationQueries(this.pool);
|
||||||
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
|
personalAccessTokens = new PersonalAccessTokensQueries(this.pool);
|
||||||
verificationRecords = new VerificationRecordQueries(this.pool);
|
verificationRecords = new VerificationRecordQueries(this.pool);
|
||||||
accountCenters = new AccountCenterQueries(this.pool);
|
accountCenters = new AccountCenterQueries(this.pool);
|
||||||
|
|
|
@ -27,8 +27,12 @@ const application = {
|
||||||
'Only HTTP-POST binding is supported for receiving SAML assertions.',
|
'Only HTTP-POST binding is supported for receiving SAML assertions.',
|
||||||
can_not_delete_active_secret: 'Can not delete the active secret.',
|
can_not_delete_active_secret: 'Can not delete the active secret.',
|
||||||
no_active_secret: 'No active secret found.',
|
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',
|
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.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue