diff --git a/packages/core/src/saml-applications/SamlApplication/index.test.ts b/packages/core/src/saml-applications/SamlApplication/index.test.ts new file mode 100644 index 000000000..14fccc29e --- /dev/null +++ b/packages/core/src/saml-applications/SamlApplication/index.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/core/src/saml-applications/SamlApplication/index.ts b/packages/core/src/saml-applications/SamlApplication/index.ts new file mode 100644 index 000000000..6961920df --- /dev/null +++ b/packages/core/src/saml-applications/SamlApplication/index.ts @@ -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; + + 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[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> => { + 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> => { + 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 */ diff --git a/packages/core/src/saml-applications/SamlApplication/utils.test.ts b/packages/core/src/saml-applications/SamlApplication/utils.test.ts new file mode 100644 index 000000000..ddcb12dbd --- /dev/null +++ b/packages/core/src/saml-applications/SamlApplication/utils.test.ts @@ -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(''); + expect(result).toContain(''); + expect(result).toContain(''); + + expect(result).toContain(`
`); + expect(result).toContain(``); + + 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&"'); + }); +}); diff --git a/packages/core/src/saml-applications/SamlApplication/utils.ts b/packages/core/src/saml-applications/SamlApplication/utils.ts new file mode 100644 index 000000000..9980b0331 --- /dev/null +++ b/packages/core/src/saml-applications/SamlApplication/utils.ts @@ -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 ` + + + + +
+ + + + `; +}; diff --git a/packages/core/src/saml-applications/routes/anonymous.ts b/packages/core/src/saml-applications/routes/anonymous.ts index c67ffe3be..71a7e1a6b 100644 --- a/packages/core/src/saml-applications/routes/anonymous.ts +++ b/packages/core/src/saml-applications/routes/anonymous.ts @@ -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 { - 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', - }); - }); -}); diff --git a/packages/core/src/saml-applications/routes/utils.ts b/packages/core/src/saml-applications/routes/utils.ts deleted file mode 100644 index 25215bc95..000000000 --- a/packages/core/src/saml-applications/routes/utils.ts +++ /dev/null @@ -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 ` - - -
- -
- - - - `; -}; - -export const getUserInfo = async ( - accessToken: string, - userinfoEndpoint: string -): Promise> => { - 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> => { - 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 */