0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

refactor(core): build SamlApplication class (#6909)

This commit is contained in:
Darcy Ye 2025-01-06 20:01:27 +08:00 committed by GitHub
parent ced360b7a4
commit b0fb35f97e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 774 additions and 643 deletions

View file

@ -0,0 +1,170 @@
import nock from 'nock';
import { SamlApplication } from './index.js';
const { jest } = import.meta;
// Create a test class that exposes protected methods
class TestSamlApplication extends SamlApplication {
public exposedCreateSamlTemplateCallback = this.createSamlTemplateCallback;
public exposedExchangeAuthorizationCode = this.exchangeAuthorizationCode;
public exposedGetUserInfo = this.getUserInfo;
public exposedFetchOidcConfig = this.fetchOidcConfig;
}
describe('SamlApplication', () => {
const mockDetails = {
entityId: 'sp-entity-id',
acsUrl: {
binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
url: 'https://sp.example.com/acs',
},
oidcClientMetadata: {
redirectUris: ['https://app.example.com/callback'],
},
privateKey: 'mock-private-key',
certificate: 'mock-certificate',
secret: 'mock-secret',
};
const mockUser = {
sub: 'user123',
email: 'user@example.com',
name: 'Test User',
};
const mockTenantId = 'tenant-id';
const mockSamlApplicationId = 'saml-app-id';
const mockIssuer = 'https://issuer.example.com';
const mockEndpoint = 'https://auth.example.com';
const mockAuthEndpoint = `${mockEndpoint}/auth`;
const mockTokenEndpoint = `${mockEndpoint}/token`;
const mockUserinfoEndpoint = `${mockEndpoint}/userinfo`;
const mockJwks = `${mockEndpoint}/jwks`;
// eslint-disable-next-line @silverhand/fp/no-let
let samlApp: TestSamlApplication;
beforeEach(() => {
// @ts-expect-error
// eslint-disable-next-line @silverhand/fp/no-mutation
samlApp = new TestSamlApplication(mockDetails, mockSamlApplicationId, mockIssuer, mockTenantId);
nock(mockIssuer).get('/.well-known/openid-configuration').reply(200, {
token_endpoint: mockTokenEndpoint,
authorization_endpoint: mockAuthEndpoint,
userinfo_endpoint: mockUserinfoEndpoint,
jwks_uri: mockJwks,
issuer: mockIssuer,
});
});
describe('createSamlTemplateCallback', () => {
it('should create SAML template callback with correct values', () => {
const result = samlApp.exposedCreateSamlTemplateCallback(mockUser)(
'ID:NameID:attrEmail:attrName'
);
const generatedId = result.id.replace('ID_', '');
expect(result.id).toBe('ID_' + generatedId);
expect(typeof result.context).toBe('string');
});
});
describe('exchangeAuthorizationCode', () => {
const mockCode = 'auth-code';
beforeEach(() => {
// @ts-expect-error -- for testing
jest.spyOn(samlApp, 'exposedFetchOidcConfig').mockResolvedValue({
tokenEndpoint: mockTokenEndpoint,
});
});
afterEach(() => {
nock.cleanAll();
jest.restoreAllMocks();
});
it('should exchange authorization code successfully', async () => {
const mockResponse = {
access_token: 'access-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email',
id_token: 'mock.id.token',
};
const expectedAuthHeader = `Basic ${Buffer.from(
`${mockSamlApplicationId}:${mockDetails.secret}`,
'utf8'
).toString('base64')}`;
const redirectUri = mockDetails.oidcClientMetadata.redirectUris[0]!;
nock(mockEndpoint)
.post(
'/token',
`grant_type=authorization_code&code=${mockCode}&client_id=${mockSamlApplicationId}&redirect_uri=${encodeURIComponent(
redirectUri
)}`
)
.matchHeader('Authorization', expectedAuthHeader)
.matchHeader('Content-Type', 'application/x-www-form-urlencoded')
.reply(200, mockResponse);
const result = await samlApp.exposedExchangeAuthorizationCode({ code: mockCode });
expect(result).toMatchObject({
accessToken: mockResponse.access_token,
tokenType: mockResponse.token_type,
expiresIn: mockResponse.expires_in,
scope: mockResponse.scope,
idToken: mockResponse.id_token,
});
});
it('should throw error if token exchange fails', async () => {
nock(mockEndpoint).post('/token').reply(400, { error: 'invalid_grant' });
await expect(samlApp.exposedExchangeAuthorizationCode({ code: mockCode })).rejects.toThrow();
});
});
describe('getUserInfo', () => {
const mockAccessToken = 'access-token';
beforeEach(() => {
// @ts-expect-error -- for testing
jest.spyOn(samlApp, 'exposedFetchOidcConfig').mockResolvedValue({
userinfoEndpoint: mockUserinfoEndpoint,
});
});
afterEach(() => {
nock.cleanAll();
jest.restoreAllMocks();
});
it('should get user info successfully', async () => {
const scope = nock(mockEndpoint)
.get('/userinfo')
.matchHeader('Authorization', `Bearer ${mockAccessToken}`)
.reply(200, JSON.stringify(mockUser));
const result = await samlApp.exposedGetUserInfo({
accessToken: mockAccessToken,
});
expect(result).toEqual(mockUser);
expect(scope.isDone()).toBe(true);
});
it('should throw error if userinfo request fails', async () => {
nock(mockEndpoint).get('/userinfo').reply(400, { error: 'invalid_token' });
await expect(samlApp.exposedGetUserInfo({ accessToken: mockAccessToken })).rejects.toThrow();
});
});
});

View file

@ -0,0 +1,425 @@
/* eslint-disable max-lines */
// TODO: refactor this file to reduce LOC
import { parseJson } from '@logto/connector-kit';
import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
import { type SamlAcsUrl, BindingType } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat, appendPath, deduplicate } from '@silverhand/essentials';
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
import { XMLValidator } from 'fast-xml-parser';
import saml from 'samlify';
import { ZodError, z } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import {
fetchOidcConfigRaw,
getRawUserInfoResponse,
handleTokenExchange,
} from '#src/sso/OidcConnector/utils.js';
import {
idTokenProfileStandardClaimsGuard,
type OidcConfigResponse,
type IdTokenProfileStandardClaims,
} from '#src/sso/types/oidc.js';
import assertThat from '#src/utils/assert-that.js';
import {
samlLogInResponseTemplate,
samlAttributeNameFormatBasic,
samlValueXmlnsXsi,
} from '../libraries/consts.js';
import { buildSingleSignOnUrl, buildSamlIdentityProviderEntityId } from '../libraries/utils.js';
import { type SamlApplicationDetails } from '../queries/index.js';
import { buildSamlAssertionNameId } from './utils.js';
type ValidSamlApplicationDetails = {
secret: string;
entityId: string;
acsUrl: SamlAcsUrl;
redirectUri: string;
privateKey: string;
certificate: string;
};
// 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;
}
},
});
const validateSamlApplicationDetails = (
details: SamlApplicationDetails
): ValidSamlApplicationDetails => {
const {
entityId,
acsUrl,
oidcClientMetadata: { redirectUris },
privateKey,
certificate,
secret,
} = details;
assertThat(acsUrl, 'application.saml.acs_url_required');
assertThat(entityId, 'application.saml.entity_id_required');
assertThat(redirectUris[0], 'oidc.invalid_redirect_uri');
assertThat(privateKey, 'application.saml.private_key_required');
assertThat(certificate, 'application.saml.certificate_required');
return {
secret,
entityId,
acsUrl,
redirectUri: redirectUris[0],
privateKey,
certificate,
};
};
const buildLoginResponseTemplate = () => {
return {
context: samlLogInResponseTemplate,
attributes: [
{
name: 'email',
valueTag: 'email',
nameFormat: samlAttributeNameFormatBasic,
valueXsiType: samlValueXmlnsXsi.string,
},
{
name: 'name',
valueTag: 'name',
nameFormat: samlAttributeNameFormatBasic,
valueXsiType: samlValueXmlnsXsi.string,
},
],
};
};
const buildSamlIdentityProvider = ({
entityId,
certificate,
singleSignOnUrl,
privateKey,
}: {
entityId: string;
certificate: string;
singleSignOnUrl: string;
privateKey: string;
}): saml.IdentityProviderInstance => {
// eslint-disable-next-line new-cap
return saml.IdentityProvider({
entityID: entityId,
signingCert: certificate,
singleSignOnService: [
{
Location: singleSignOnUrl,
Binding: BindingType.Redirect,
},
{
Location: singleSignOnUrl,
Binding: BindingType.Post,
},
],
privateKey,
isAssertionEncrypted: false,
loginResponseTemplate: buildLoginResponseTemplate(),
nameIDFormat: [
saml.Constants.namespace.format.emailAddress,
saml.Constants.namespace.format.persistent,
],
});
};
const buildSamlServiceProvider = ({
entityId,
acsUrl,
certificate,
isWantAuthnRequestsSigned,
}: {
entityId: string;
acsUrl: SamlAcsUrl;
certificate: string;
isWantAuthnRequestsSigned: boolean;
}): saml.ServiceProviderInstance => {
// eslint-disable-next-line new-cap
return saml.ServiceProvider({
entityID: entityId,
assertionConsumerService: [
{
Binding: acsUrl.binding,
Location: acsUrl.url,
},
],
signingCert: certificate,
authnRequestsSigned: isWantAuthnRequestsSigned,
allowCreate: false,
});
};
export class SamlApplication {
public details: ValidSamlApplicationDetails;
protected tenantEndpoint: URL;
protected oidcConfig?: CamelCaseKeys<OidcConfigResponse>;
private _idp?: saml.IdentityProviderInstance;
private _sp?: saml.ServiceProviderInstance;
constructor(
details: SamlApplicationDetails,
protected samlApplicationId: string,
protected issuer: string,
tenantId: string
) {
this.details = validateSamlApplicationDetails(details);
this.tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
}
public get idp(): saml.IdentityProviderInstance {
this._idp ||= buildSamlIdentityProvider(this.buildIdpConfig());
return this._idp;
}
public get sp(): saml.ServiceProviderInstance {
this._sp ||= buildSamlServiceProvider({
...this.buildSpConfig(),
certificate: this.details.certificate,
isWantAuthnRequestsSigned: this.idp.entityMeta.isWantAuthnRequestsSigned(),
});
return this._sp;
}
public get idPMetadata() {
return this.idp.getMetadata();
}
public get idPCertificate() {
return this.details.certificate;
}
public get samlAppCallbackUrl() {
return appendPath(
this.tenantEndpoint,
`api/saml-applications/${this.samlApplicationId}/callback`
).toString();
}
public async parseLoginRequest(
binding: 'post' | 'redirect',
loginRequest: Parameters<typeof saml.IdentityProviderInstance.prototype.parseLoginRequest>[2]
) {
return this.idp.parseLoginRequest(this.sp, binding, loginRequest);
}
public createSamlResponse = async (
userInfo: IdTokenProfileStandardClaims
): Promise<{ context: string; entityEndpoint: string }> => {
// TODO: fix binding method
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { context, entityEndpoint } = await this.idp.createLoginResponse(
this.sp,
// @ts-expect-error --fix request object later
null,
'post',
userInfo,
this.createSamlTemplateCallback(userInfo)
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { context, entityEndpoint };
};
// Helper functions for SAML callback
public handleOidcCallbackAndGetUserInfo = async ({ code }: { code: string }) => {
// Exchange authorization code for tokens
const { accessToken } = await this.exchangeAuthorizationCode({
code,
});
assertThat(accessToken, new RequestError('oidc.access_denied'));
// Get user info using access token
return this.getUserInfo({ accessToken });
};
public getSignInUrl = async ({ scope, state }: { scope?: string; state?: string }) => {
const { authorizationEndpoint } = await this.fetchOidcConfig();
const queryParameters = new URLSearchParams({
[QueryKey.ClientId]: this.samlApplicationId,
[QueryKey.RedirectUri]: this.details.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()}`);
};
protected getOidcConfig = async (): Promise<CamelCaseKeys<OidcConfigResponse>> => {
const oidcConfig = await tryThat(
async () => fetchOidcConfigRaw(this.issuer),
(error) => {
if (error instanceof ZodError) {
throw new RequestError({
code: 'oidc.invalid_request',
message: error.message,
error: error.flatten(),
});
}
throw error;
}
);
return oidcConfig;
};
protected exchangeAuthorizationCode = async ({ code }: { code: string }) => {
const { tokenEndpoint } = await this.fetchOidcConfig();
const result = await handleTokenExchange(tokenEndpoint, {
code,
clientId: this.samlApplicationId,
clientSecret: this.details.secret,
redirectUri: this.details.redirectUri,
});
if (!result.success) {
throw new RequestError({
code: 'oidc.invalid_token',
message: 'Invalid token response',
});
}
return camelcaseKeys(result.data);
};
protected async fetchOidcConfig() {
this.oidcConfig ||= await this.getOidcConfig();
return this.oidcConfig;
}
protected getUserInfo = async ({
accessToken,
}: {
accessToken: string;
}): Promise<IdTokenProfileStandardClaims & Record<string, unknown>> => {
const { userinfoEndpoint } = await this.fetchOidcConfig();
const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint);
const result = idTokenProfileStandardClaimsGuard
.catchall(z.unknown())
.safeParse(parseJson(body));
if (!result.success) {
throw new RequestError({
code: 'oidc.invalid_request',
message: 'Invalid user info response',
details: result.error.flatten(),
});
}
return result.data;
};
protected createSamlTemplateCallback =
(user: IdTokenProfileStandardClaims) => (template: string) => {
const assertionConsumerServiceUrl = this.sp.entityMeta.getAssertionConsumerService(
saml.Constants.wording.binding.post
);
const { nameIDFormat } = this.idp.entitySetting;
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);
const id = `ID_${generateStandardId()}`;
const now = new Date();
const expireAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes later
const tagValues = {
ID: id,
AssertionID: `ID_${generateStandardId()}`,
Destination: assertionConsumerServiceUrl,
Audience: this.sp.entityMeta.getEntityID(),
EntityID: this.sp.entityMeta.getEntityID(),
SubjectRecipient: assertionConsumerServiceUrl,
Issuer: this.idp.entityMeta.getEntityID(),
IssueInstant: now.toISOString(),
AssertionConsumerServiceURL: assertionConsumerServiceUrl,
StatusCode: saml.Constants.StatusCode.Success,
ConditionsNotBefore: now.toISOString(),
ConditionsNotOnOrAfter: expireAt.toISOString(),
SubjectConfirmationDataNotOnOrAfter: expireAt.toISOString(),
NameIDFormat,
NameID,
// TODO: should get the request ID from the input parameters, pending https://github.com/logto-io/logto/pull/6881.
InResponseTo: 'null',
/**
* User attributes for SAML response
*
* @todo Support custom attribute mapping
* @see {@link https://github.com/tngan/samlify/blob/master/src/libsaml.ts#L275-L300|samlify implementation}
*
* @remarks
* By examining the code provided in the link above, we can define all the attributes supported by the attribute mapping here. Only the attributes defined in the `loginResponseTemplate.attributes` added when creating the IdP instance will appear in the SAML response.
*/
attrSub: user.sub,
attrEmail: user.email,
attrName: user.name,
};
const context = saml.SamlLib.replaceTagsByValue(template, tagValues);
return {
id,
context,
};
};
private buildIdpConfig() {
return {
entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId),
privateKey: this.details.privateKey,
certificate: this.details.certificate,
singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId),
};
}
private buildSpConfig() {
return {
entityId: this.details.entityId,
acsUrl: this.details.acsUrl,
};
}
}
/* eslint-enable max-lines */

View file

@ -0,0 +1,91 @@
import { generateAutoSubmitForm, buildSamlAssertionNameId } from './utils.js';
describe('buildSamlAssertionNameId', () => {
it('should use email when email_verified is true', () => {
const user = {
sub: 'user123',
email: 'user@example.com',
email_verified: true,
};
const result = buildSamlAssertionNameId(user);
expect(result).toEqual({
NameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
NameID: user.email,
});
});
it('should use sub when email is not verified', () => {
const user = {
sub: 'user123',
email: 'user@example.com',
email_verified: false,
};
const result = buildSamlAssertionNameId(user);
expect(result).toEqual({
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
NameID: user.sub,
});
});
it('should use sub when email is not available', () => {
const user = {
sub: 'user123',
};
const result = buildSamlAssertionNameId(user);
expect(result).toEqual({
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
NameID: user.sub,
});
});
it('should use specified format when provided', () => {
const user = {
sub: 'user123',
email: 'user@example.com',
email_verified: false,
};
const format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
const result = buildSamlAssertionNameId(user, format);
expect(result).toEqual({
NameIDFormat: format,
NameID: user.sub,
});
});
});
describe('generateAutoSubmitForm', () => {
it('should generate valid HTML form with auto-submit script', () => {
const actionUrl = 'https://example.com/acs';
const samlResponse = 'base64EncodedSamlResponse';
const result = generateAutoSubmitForm(actionUrl, samlResponse);
expect(result).toContain('<html>');
expect(result).toContain('<body>');
expect(result).toContain('</html>');
expect(result).toContain(`<form id="redirectForm" action="${actionUrl}" method="POST">`);
expect(result).toContain(`<input type="hidden" name="SAMLResponse" value="${samlResponse}" />`);
expect(result).toContain('window.onload = function()');
expect(result).toContain("document.getElementById('redirectForm').submit()");
});
it('should properly escape special characters in URLs and values', () => {
const actionUrl = 'https://example.com/acs?param=value&other=123';
const samlResponse = 'response+with/special=characters&';
const result = generateAutoSubmitForm(actionUrl, samlResponse);
expect(result).toContain('action="https://example.com/acs?param=value&other=123"');
expect(result).toContain('value="response+with/special=characters&"');
});
});

View file

@ -0,0 +1,70 @@
// TODO: refactor this file to reduce LOC
import saml from 'samlify';
import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
/**
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
* Supports email and persistent formats.
*
* @param user - The user's standard claims
* @param idpNameIDFormat - The NameID format(s) specified by the IdP (optional)
* @returns An object containing the NameIDFormat and NameID
*/
export const buildSamlAssertionNameId = (
user: IdTokenProfileStandardClaims,
idpNameIDFormat?: string | string[]
): { NameIDFormat: string; NameID: string } => {
if (idpNameIDFormat) {
// Get the first name ID format
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
// If email format is specified, try to use email first
if (
format === saml.Constants.namespace.format.emailAddress &&
user.email &&
user.email_verified
) {
return {
NameIDFormat: format,
NameID: user.email,
};
}
// For other formats or when email is not available, use sub
if (format === saml.Constants.namespace.format.persistent) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}
}
// No nameIDFormat specified, use default logic
// Use email if available
if (user.email && user.email_verified) {
return {
NameIDFormat: saml.Constants.namespace.format.emailAddress,
NameID: user.email,
};
}
// Fallback to persistent format with user.sub
return {
NameIDFormat: saml.Constants.namespace.format.persistent,
NameID: user.sub,
};
};
export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): string => {
return `
<html>
<body>
<form id="redirectForm" action="${actionUrl}" method="POST">
<input type="hidden" name="SAMLResponse" value="${samlResponse}" />
</form>
<script>
window.onload = function() {
document.getElementById('redirectForm').submit();
};
</script>
</body>
</html>
`;
};

View file

@ -5,21 +5,13 @@ import { addMinutes } from 'date-fns';
import { z } from 'zod';
import { spInitiatedSamlSsoSessionCookieName } from '#src/constants/index.js';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
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 {
generateAutoSubmitForm,
createSamlResponse,
handleOidcCallbackAndGetUserInfo,
getSamlIdpAndSp,
getSignInUrl,
buildSamlAppCallbackUrl,
validateSamlApplicationDetails,
} from './utils.js';
import { SamlApplication } from '../SamlApplication/index.js';
import { generateAutoSubmitForm } from '../SamlApplication/utils.js';
const samlApplicationSignInCallbackQueryParametersGuard = z.union([
z.object({
@ -38,9 +30,6 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
samlApplications: { getSamlIdPMetadataByApplicationId },
} = libraries;
const {
applications,
samlApplicationSecrets,
samlApplicationConfigs,
samlApplications: { getSamlApplicationDetailsById },
samlApplicationSessions: { insertSession },
} = queries;
@ -87,15 +76,11 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
});
}
// Get application configuration
const {
secret,
oidcClientMetadata: { redirectUris },
} = await applications.findApplicationById(id);
const details = await getSamlApplicationDetailsById(id);
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
assertThat(
redirectUris[0] === buildSamlAppCallbackUrl(tenantEndpoint, id),
samlApplication.details.redirectUri === samlApplication.samlAppCallbackUrl,
'oidc.invalid_redirect_uri'
);
@ -103,31 +88,11 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
const { code } = query;
// Handle OIDC callback and get user info
const userInfo = await handleOidcCallbackAndGetUserInfo(
const userInfo = await samlApplication.handleOidcCallbackAndGetUserInfo({
code,
id,
secret,
redirectUris[0],
envSet.oidc.issuer
);
// TODO: we will refactor the following code later, to reduce the DB query connections.
// Get SAML configuration
const { metadata } = await getSamlIdPMetadataByApplicationId(id);
const { privateKey, certificate } =
await samlApplicationSecrets.findActiveSamlApplicationSecretByApplicationId(id);
const { entityId, acsUrl } =
await samlApplicationConfigs.findSamlApplicationConfigByApplicationId(id);
assertThat(entityId, 'application.saml.entity_id_required');
assertThat(acsUrl, 'application.saml.acs_url_required');
// Setup SAML providers and create response
const { idp, sp } = getSamlIdpAndSp({
idp: { metadata, privateKey, certificate },
sp: { entityId, acsUrl },
});
const { context, entityEndpoint } = await createSamlResponse(idp, sp, userInfo);
const { context, entityEndpoint } = await samlApplication.createSamlResponse(userInfo);
// Return auto-submit form
ctx.body = generateAutoSubmitForm(entityEndpoint, context);
@ -156,18 +121,8 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
query: { Signature, RelayState, ...rest },
} = ctx.guard;
const [{ metadata }, details] = await Promise.all([
getSamlIdPMetadataByApplicationId(id),
getSamlApplicationDetailsById(id),
]);
const { entityId, acsUrl, redirectUri, certificate, privateKey } =
validateSamlApplicationDetails(details);
const { idp, sp } = getSamlIdpAndSp({
idp: { metadata, certificate, privateKey },
sp: { entityId, acsUrl },
});
const details = await getSamlApplicationDetailsById(id);
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
const octetString = Object.keys(ctx.request.query)
// eslint-disable-next-line no-restricted-syntax
@ -177,7 +132,7 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
// Parse login request
try {
const loginRequestResult = await idp.parseLoginRequest(sp, 'redirect', {
const loginRequestResult = await samlApplication.parseLoginRequest('redirect', {
query: removeUndefinedKeys({
SAMLRequest,
Signature,
@ -196,15 +151,12 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
}
assertThat(
extractResult.data.issuer === entityId,
extractResult.data.issuer === samlApplication.details.entityId,
'application.saml.auth_request_issuer_not_match'
);
const state = generateStandardId(32);
const signInUrl = await getSignInUrl({
issuer: envSet.oidc.issuer,
applicationId: id,
redirectUri,
const signInUrl = await samlApplication.getSignInUrl({
state,
});
@ -262,22 +214,12 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
body: { SAMLRequest, RelayState },
} = ctx.guard;
const [{ metadata }, details] = await Promise.all([
getSamlIdPMetadataByApplicationId(id),
getSamlApplicationDetailsById(id),
]);
const { acsUrl, entityId, redirectUri, privateKey, certificate } =
validateSamlApplicationDetails(details);
const { idp, sp } = getSamlIdpAndSp({
idp: { metadata, privateKey, certificate },
sp: { entityId, acsUrl },
});
const details = await getSamlApplicationDetailsById(id);
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
// Parse login request
try {
const loginRequestResult = await idp.parseLoginRequest(sp, 'post', {
const loginRequestResult = await samlApplication.parseLoginRequest('post', {
body: {
SAMLRequest,
},
@ -293,15 +235,12 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
}
assertThat(
extractResult.data.issuer === entityId,
extractResult.data.issuer === samlApplication.details.entityId,
'application.saml.auth_request_issuer_not_match'
);
const state = generateStandardShortId();
const signInUrl = await getSignInUrl({
issuer: envSet.oidc.issuer,
applicationId: id,
redirectUri,
const signInUrl = await samlApplication.getSignInUrl({
state,
});

View file

@ -1,155 +0,0 @@
import nock from 'nock';
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
import { createSamlTemplateCallback, exchangeAuthorizationCode, getUserInfo } from './utils.js';
const { jest } = import.meta;
describe('createSamlTemplateCallback', () => {
const mockIdp = {
entityMeta: {
getEntityID: () => 'idp-entity-id',
},
entitySetting: {
nameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
},
createLoginResponse: jest.fn(),
parseLoginRequest: jest.fn(),
entityType: 'idp',
getEntitySetting: jest.fn(),
};
const mockSp = {
entityMeta: {
getAssertionConsumerService: () => 'https://sp.example.com/acs',
getEntityID: () => 'sp-entity-id',
},
createLoginRequest: jest.fn(),
parseLoginResponse: jest.fn(),
entitySetting: {},
entityType: 'sp',
};
const mockUser = {
sub: 'user123',
email: 'user@example.com',
name: 'Test User',
};
it('should create SAML template callback with correct values', () => {
const callback = createSamlTemplateCallback(
mockIdp as unknown as IdentityProviderInstance,
mockSp as unknown as ServiceProviderInstance,
mockUser
);
const result = callback('ID:NameID:attrEmail:attrName');
const generatedId = result.id.replace('ID_', '');
expect(result.id).toBe('ID_' + generatedId);
expect(typeof result.context).toBe('string');
});
});
describe('exchangeAuthorizationCode', () => {
const mockTokenEndpoint = 'https://auth.example.com/token';
const mockCode = 'auth-code';
const mockClientId = 'client-id';
const mockClientSecret = 'client-secret';
const mockRedirectUri = 'https://app.example.com/callback';
afterEach(() => {
nock.cleanAll();
});
it('should exchange authorization code successfully', async () => {
const mockResponse = {
access_token: 'access-token',
token_type: 'Bearer',
expires_in: 3600,
scope: 'openid profile email',
id_token: 'mock.id.token',
};
const expectedAuthHeader = `Basic ${Buffer.from(
`${mockClientId}:${mockClientSecret}`,
'utf8'
).toString('base64')}`;
nock('https://auth.example.com')
.post('/token', {
grant_type: 'authorization_code',
code: mockCode,
client_id: mockClientId,
redirect_uri: mockRedirectUri,
})
.matchHeader('Authorization', expectedAuthHeader)
.matchHeader('Content-Type', 'application/x-www-form-urlencoded')
.reply(200, JSON.stringify(mockResponse));
const result = await exchangeAuthorizationCode(mockTokenEndpoint, {
code: mockCode,
clientId: mockClientId,
clientSecret: mockClientSecret,
redirectUri: mockRedirectUri,
});
expect(result).toMatchObject({
accessToken: mockResponse.access_token,
tokenType: mockResponse.token_type,
expiresIn: mockResponse.expires_in,
scope: mockResponse.scope,
idToken: mockResponse.id_token,
});
});
it('should throw error when token response is invalid', async () => {
nock('https://auth.example.com').post('/token').reply(200, { invalid: 'response' });
await expect(
exchangeAuthorizationCode(mockTokenEndpoint, {
code: mockCode,
clientId: mockClientId,
clientSecret: mockClientSecret,
})
).rejects.toMatchObject({
code: 'oidc.invalid_token',
});
});
});
describe('getUserInfo', () => {
const mockAccessToken = 'access-token';
const mockUserinfoEndpoint = 'https://auth.example.com/userinfo';
afterEach(() => {
nock.cleanAll();
});
it('should get user info successfully', async () => {
const mockUserInfo = {
sub: 'user123',
email: 'user@example.com',
name: 'Test User',
};
nock('https://auth.example.com')
.get('/userinfo')
.matchHeader('Authorization', `Bearer ${mockAccessToken}`)
.reply(200, mockUserInfo);
const result = await getUserInfo(mockAccessToken, mockUserinfoEndpoint);
expect(result).toMatchObject(mockUserInfo);
});
it('should throw error when user info response is invalid', async () => {
nock('https://auth.example.com')
.get('/userinfo')
.matchHeader('Authorization', `Bearer ${mockAccessToken}`)
.reply(200, { invalid: 'response' });
await expect(getUserInfo(mockAccessToken, mockUserinfoEndpoint)).rejects.toMatchObject({
code: 'oidc.invalid_request',
});
});
});

View file

@ -1,409 +0,0 @@
/* eslint-disable max-lines */
// TODO: refactor this file to reduce LOC
import { parseJson } from '@logto/connector-kit';
import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
import { type SamlAcsUrl } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { tryThat, appendPath, deduplicate } from '@silverhand/essentials';
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
import { XMLValidator } from 'fast-xml-parser';
import saml from 'samlify';
import { ZodError, z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import {
fetchOidcConfigRaw,
getRawUserInfoResponse,
handleTokenExchange,
} from '#src/sso/OidcConnector/utils.js';
import {
idTokenProfileStandardClaimsGuard,
type OidcConfigResponse,
type IdTokenProfileStandardClaims,
} from '#src/sso/types/oidc.js';
import assertThat from '#src/utils/assert-that.js';
import {
samlLogInResponseTemplate,
samlAttributeNameFormatBasic,
samlValueXmlnsXsi,
} from '../libraries/consts.js';
import { type SamlApplicationDetails } from '../queries/index.js';
/**
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
* Supports email and persistent formats.
*
* @param user - The user's standard claims
* @param idpNameIDFormat - The NameID format(s) specified by the IdP (optional)
* @returns An object containing the NameIDFormat and NameID
*/
const buildSamlAssertionNameId = (
user: IdTokenProfileStandardClaims,
idpNameIDFormat?: string | string[]
): { NameIDFormat: string; NameID: string } => {
if (idpNameIDFormat) {
// Get the first name ID format
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
// If email format is specified, try to use email first
if (
format === saml.Constants.namespace.format.emailAddress &&
user.email &&
user.email_verified
) {
return {
NameIDFormat: format,
NameID: user.email,
};
}
// For other formats or when email is not available, use sub
if (format === saml.Constants.namespace.format.persistent) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}
}
// No nameIDFormat specified, use default logic
// Use email if available
if (user.email && user.email_verified) {
return {
NameIDFormat: saml.Constants.namespace.format.emailAddress,
NameID: user.email,
};
}
// Fallback to persistent format with user.sub
return {
NameIDFormat: saml.Constants.namespace.format.persistent,
NameID: user.sub,
};
};
export const createSamlTemplateCallback =
(
idp: saml.IdentityProviderInstance,
sp: saml.ServiceProviderInstance,
user: IdTokenProfileStandardClaims
) =>
(template: string) => {
const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(
saml.Constants.wording.binding.post
);
const { nameIDFormat } = idp.entitySetting;
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);
const id = `ID_${generateStandardId()}`;
const now = new Date();
const expireAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes later
const tagValues = {
ID: id,
AssertionID: `ID_${generateStandardId()}`,
Destination: assertionConsumerServiceUrl,
Audience: sp.entityMeta.getEntityID(),
EntityID: sp.entityMeta.getEntityID(),
SubjectRecipient: assertionConsumerServiceUrl,
Issuer: idp.entityMeta.getEntityID(),
IssueInstant: now.toISOString(),
AssertionConsumerServiceURL: assertionConsumerServiceUrl,
StatusCode: saml.Constants.StatusCode.Success,
ConditionsNotBefore: now.toISOString(),
ConditionsNotOnOrAfter: expireAt.toISOString(),
SubjectConfirmationDataNotOnOrAfter: expireAt.toISOString(),
NameIDFormat,
NameID,
// TODO: should get the request ID from the input parameters, pending https://github.com/logto-io/logto/pull/6881.
InResponseTo: 'null',
/**
* User attributes for SAML response
*
* @todo Support custom attribute mapping
* @see {@link https://github.com/tngan/samlify/blob/master/src/libsaml.ts#L275-L300|samlify implementation}
*
* @remarks
* By examining the code provided in the link above, we can define all the attributes supported by the attribute mapping here. Only the attributes defined in the `loginResponseTemplate.attributes` added when creating the IdP instance will appear in the SAML response.
*/
attrSub: user.sub,
attrEmail: user.email,
attrName: user.name,
};
const context = saml.SamlLib.replaceTagsByValue(template, tagValues);
return {
id,
context,
};
};
export const exchangeAuthorizationCode = async (
tokenEndpoint: string,
{
code,
clientId,
clientSecret,
redirectUri,
}: {
code: string;
clientId: string;
clientSecret: string;
redirectUri?: string;
}
) => {
const result = await handleTokenExchange(tokenEndpoint, {
code,
clientId,
clientSecret,
redirectUri,
});
if (!result.success) {
throw new RequestError({
code: 'oidc.invalid_token',
message: 'Invalid token response',
});
}
return camelcaseKeys(result.data);
};
export const createSamlResponse = async (
idp: saml.IdentityProviderInstance,
sp: saml.ServiceProviderInstance,
userInfo: IdTokenProfileStandardClaims
): Promise<{ context: string; entityEndpoint: string }> => {
// TODO: fix binding method
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { context, entityEndpoint } = await idp.createLoginResponse(
sp,
// @ts-expect-error --fix request object later
null,
'post',
userInfo,
createSamlTemplateCallback(idp, sp, userInfo)
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return { context, entityEndpoint };
};
export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): string => {
return `
<html>
<body>
<form id="redirectForm" action="${actionUrl}" method="POST">
<input type="hidden" name="SAMLResponse" value="${samlResponse}" />
</form>
<script>
window.onload = function() {
document.getElementById('redirectForm').submit();
};
</script>
</body>
</html>
`;
};
export const getUserInfo = async (
accessToken: string,
userinfoEndpoint: string
): Promise<IdTokenProfileStandardClaims & Record<string, unknown>> => {
const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint);
const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(parseJson(body));
if (!result.success) {
throw new RequestError({
code: 'oidc.invalid_request',
message: 'Invalid user info response',
details: result.error.flatten(),
});
}
return result.data;
};
// Helper functions for SAML callback
export const handleOidcCallbackAndGetUserInfo = async (
code: string,
applicationId: string,
secret: string,
redirectUri: string,
issuer: string
) => {
// Get OIDC configuration
const { tokenEndpoint, userinfoEndpoint } = await getOidcConfig(issuer);
// Exchange authorization code for tokens
const { accessToken } = await exchangeAuthorizationCode(tokenEndpoint, {
code,
clientId: applicationId,
clientSecret: secret,
redirectUri,
});
assertThat(accessToken, new RequestError('oidc.access_denied'));
// Get user info using access token
return getUserInfo(accessToken, userinfoEndpoint);
};
const getOidcConfig = async (issuer: string): Promise<CamelCaseKeys<OidcConfigResponse>> => {
const oidcConfig = 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 oidcConfig;
};
export const getSignInUrl = async ({
issuer,
applicationId,
redirectUri,
scope,
state,
}: {
issuer: string;
applicationId: string;
redirectUri: string;
scope?: string;
state?: string;
}) => {
const { authorizationEndpoint } = await getOidcConfig(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 },
privateKey,
certificate,
} = details;
assertThat(acsUrl, 'application.saml.acs_url_required');
assertThat(entityId, 'application.saml.entity_id_required');
assertThat(redirectUris[0], 'oidc.invalid_redirect_uri');
assertThat(privateKey, 'application.saml.private_key_required');
assertThat(certificate, 'application.saml.certificate_required');
return {
entityId,
acsUrl,
redirectUri: redirectUris[0],
privateKey,
certificate,
};
};
export const getSamlIdpAndSp = ({
idp: { metadata, privateKey, certificate },
sp: { entityId, acsUrl },
}: {
idp: { metadata: string; privateKey: 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,
privateKey,
isAssertionEncrypted: false,
loginResponseTemplate: {
context: samlLogInResponseTemplate,
attributes: [
{
name: 'email',
valueTag: 'email',
nameFormat: samlAttributeNameFormatBasic,
valueXsiType: samlValueXmlnsXsi.string,
},
{
name: 'name',
valueTag: 'name',
nameFormat: samlAttributeNameFormatBasic,
valueXsiType: samlValueXmlnsXsi.string,
},
],
},
nameIDFormat: [
saml.Constants.namespace.format.emailAddress,
saml.Constants.namespace.format.persistent,
],
});
// 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 buildSamlAppCallbackUrl = (baseUrl: URL, samlApplicationId: string) =>
appendPath(baseUrl, `api/saml-applications/${samlApplicationId}/callback`).toString();
/* eslint-enable max-lines */