diff --git a/packages/core/src/saml-applications/libraries/consts.ts b/packages/core/src/saml-applications/libraries/consts.ts new file mode 100644 index 000000000..4eb72609d --- /dev/null +++ b/packages/core/src/saml-applications/libraries/consts.ts @@ -0,0 +1,36 @@ +export const samlLogInResponseTemplate = ` + + {Issuer} + + + + + {Issuer} + + {NameID} + + + + + + + {Audience} + + + {AttributeStatement} + +`; + +export const samlAttributeNameFormatBasic = 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'; + +const samlValueXmlnsXsiString = 'xs:string'; +const samlValueXmlnsXsiInteger = 'xsd:integer'; +const samlValueXmlnsXsiBoolean = 'xsd:boolean'; +const samlValueXmlnsXsiDatetime = 'xsd:dateTime'; + +export const samlValueXmlnsXsi = { + string: samlValueXmlnsXsiString, + integer: samlValueXmlnsXsiInteger, + boolean: samlValueXmlnsXsiBoolean, + datetime: samlValueXmlnsXsiDatetime, +}; diff --git a/packages/core/src/saml-applications/libraries/saml-applications.ts b/packages/core/src/saml-applications/libraries/saml-applications.ts index 3b178c2a1..5a7dbaca6 100644 --- a/packages/core/src/saml-applications/libraries/saml-applications.ts +++ b/packages/core/src/saml-applications/libraries/saml-applications.ts @@ -7,7 +7,7 @@ import { } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { removeUndefinedKeys } from '@silverhand/essentials'; -import * as saml from 'samlify'; +import saml from 'samlify'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; diff --git a/packages/core/src/saml-applications/routes/anonymous.ts b/packages/core/src/saml-applications/routes/anonymous.ts index 661eb48bb..aa7a0e52f 100644 --- a/packages/core/src/saml-applications/routes/anonymous.ts +++ b/packages/core/src/saml-applications/routes/anonymous.ts @@ -1,14 +1,34 @@ import { z } from 'zod'; +import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import type { AnonymousRouter, RouterInitArgs } from '#src/routes/types.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { + generateAutoSubmitForm, + createSamlResponse, + handleOidcCallbackAndGetUserInfo, + setupSamlProviders, +} from './utils.js'; + +const samlApplicationSignInCallbackQueryParametersGuard = z.union([ + z.object({ + code: z.string(), + }), + z.object({ + error: z.string(), + error_description: z.string().optional(), + }), +]); export default function samlApplicationAnonymousRoutes( - ...[router, { libraries }]: RouterInitArgs + ...[router, { libraries, queries, envSet }]: RouterInitArgs ) { const { samlApplications: { getSamlIdPMetadataByApplicationId }, } = libraries; + const { applications, samlApplicationSecrets, samlApplicationConfigs } = queries; router.get( '/saml-applications/:id/metadata', @@ -29,4 +49,65 @@ export default function samlApplicationAnonymousRoutes { + const { + params: { id }, + query, + } = ctx.guard; + + // Handle error in query parameters + if ('error' in query) { + throw new RequestError({ + code: 'oidc.invalid_request', + message: query.error_description, + }); + } + + // Get application configuration + const { + secret, + oidcClientMetadata: { redirectUris }, + } = await applications.findApplicationById(id); + + assertThat(redirectUris[0], 'oidc.redirect_uri_not_set'); + + // TODO: should be able to handle `state` and code verifier etc. + const { code } = query; + + // Handle OIDC callback and get user info + const userInfo = await handleOidcCallbackAndGetUserInfo( + code, + id, + secret, + redirectUris[0], + envSet.oidc.issuer + ); + + // Get SAML configuration + const { metadata } = await getSamlIdPMetadataByApplicationId(id); + const { privateKey } = + 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 } = setupSamlProviders(metadata, privateKey, entityId, acsUrl); + const { context, entityEndpoint } = await createSamlResponse(idp, sp, userInfo); + + // Return auto-submit form + ctx.body = generateAutoSubmitForm(entityEndpoint, context); + return next(); + } + ); } diff --git a/packages/core/src/saml-applications/routes/utils.test.ts b/packages/core/src/saml-applications/routes/utils.test.ts new file mode 100644 index 000000000..73cbc4af3 --- /dev/null +++ b/packages/core/src/saml-applications/routes/utils.test.ts @@ -0,0 +1,178 @@ +import nock from 'nock'; +import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; + +import { + createSamlTemplateCallback, + exchangeAuthorizationCode, + getUserInfo, + setupSamlProviders, +} 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', + }); + }); +}); + +describe('setupSamlProviders', () => { + it('should setup SAML providers with correct configuration', () => { + const mockMetadata = '...'; + const mockPrivateKey = '-----BEGIN PRIVATE KEY-----...'; + const mockEntityId = 'https://sp.example.com'; + const mockAcsUrl = { + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + url: 'https://sp.example.com/acs', + }; + + const { idp, sp } = setupSamlProviders(mockMetadata, mockPrivateKey, mockEntityId, mockAcsUrl); + + expect(idp).toBeDefined(); + expect(sp).toBeDefined(); + expect(sp.entityMeta.getEntityID()).toBe(mockEntityId); + }); +}); diff --git a/packages/core/src/saml-applications/routes/utils.ts b/packages/core/src/saml-applications/routes/utils.ts new file mode 100644 index 000000000..872b0e17b --- /dev/null +++ b/packages/core/src/saml-applications/routes/utils.ts @@ -0,0 +1,254 @@ +import { parseJson } from '@logto/connector-kit'; +import { generateStandardId } from '@logto/shared'; +import { tryThat } from '@silverhand/essentials'; +import camelcaseKeys from 'camelcase-keys'; +import { got } from 'got'; +import saml from 'samlify'; +import { ZodError, z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { fetchOidcConfigRaw, getRawUserInfoResponse } from '#src/sso/OidcConnector/utils.js'; +import { idTokenProfileStandardClaimsGuard } from '#src/sso/types/oidc.js'; +import { oidcTokenResponseGuard, type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { + samlLogInResponseTemplate, + samlAttributeNameFormatBasic, + samlValueXmlnsXsi, +} from '../libraries/consts.js'; + +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 selectedNameIDFormat = Array.isArray(nameIDFormat) ? nameIDFormat[0] : 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: selectedNameIDFormat, + NameID: user.sub, + 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. + */ + 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 headers = { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const tokenRequestParameters = new URLSearchParams({ + grant_type: 'authorization_code', + code, + client_id: clientId, + ...(redirectUri ? { redirect_uri: redirectUri } : {}), + }); + + const httpResponse = await got.post(tokenEndpoint, { + body: tokenRequestParameters.toString(), + headers, + }); + + const result = oidcTokenResponseGuard.safeParse(parseJson(httpResponse.body)); + + 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 tryThat( + async () => fetchOidcConfigRaw(issuer), + (error) => { + if (error instanceof ZodError) { + throw new RequestError({ + code: 'oidc.invalid_request', + message: error.message, + error: error.flatten(), + }); + } + + throw error; + } + ); + + // 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); +}; + +export const setupSamlProviders = ( + metadata: string, + privateKey: string, + entityId: string, + acsUrl: { binding: string; url: string } +) => { + // 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, + }, + ], + }, + }); + + // eslint-disable-next-line new-cap + const sp = saml.ServiceProvider({ + entityID: entityId, + assertionConsumerService: [ + { + Binding: acsUrl.binding, + Location: acsUrl.url, + }, + ], + }); + + return { idp, sp }; +}; diff --git a/packages/core/src/sso/OidcConnector/utils.ts b/packages/core/src/sso/OidcConnector/utils.ts index 0ec57ade9..e0c0706d9 100644 --- a/packages/core/src/sso/OidcConnector/utils.ts +++ b/packages/core/src/sso/OidcConnector/utils.ts @@ -3,7 +3,7 @@ import { assert } from '@silverhand/essentials'; import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys'; import { got, HTTPError } from 'got'; import { createRemoteJWKSet, jwtVerify, type JWTVerifyOptions } from 'jose'; -import { z } from 'zod'; +import { z, ZodError } from 'zod'; import { SsoConnectorConfigErrorCodes, @@ -20,30 +20,28 @@ import { type OidcTokenResponse, } from '../types/oidc.js'; +export const fetchOidcConfigRaw = async (issuer: string) => { + const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, { + responseType: 'json', + }); + + return camelcaseKeys(oidcConfigResponseGuard.parse(body)); +}; + export const fetchOidcConfig = async ( issuer: string ): Promise> => { try { - const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, { - responseType: 'json', - }); - - const result = oidcConfigResponseGuard.safeParse(body); - - if (!result.success) { + return await fetchOidcConfigRaw(issuer); + } catch (error: unknown) { + if (error instanceof ZodError) { throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { config: { issuer }, message: SsoConnectorConfigErrorCodes.InvalidConfigResponse, - error: result.error.flatten(), + error: error.flatten(), }); } - return camelcaseKeys(result.data); - } catch (error: unknown) { - if (error instanceof SsoConnectorError) { - throw error; - } - throw new SsoConnectorError(SsoConnectorErrorCodes.InvalidConfig, { config: { issuer }, message: SsoConnectorConfigErrorCodes.FailToFetchConfig, @@ -159,26 +157,31 @@ export const getIdTokenClaims = async ( } }; +export const getRawUserInfoResponse = async (accessToken: string, userinfoEndpoint: string) => { + const httpResponse = await got.get(userinfoEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + return httpResponse.body; +}; + /** * Get the user info from the userinfo endpoint incase id token does not contain sufficient user claims. */ export const getUserInfo = async (accessToken: string, userinfoEndpoint: string) => { try { - const httpResponse = await got.get(userinfoEndpoint, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - responseType: 'json', - }); + const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint); const result = idTokenProfileStandardClaimsGuard .catchall(z.unknown()) - .safeParse(httpResponse.body); + .safeParse(parseJson(body)); if (!result.success) { throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, { message: 'Invalid user info response', - response: httpResponse.body, + response: body, error: result.error.flatten(), }); } diff --git a/packages/core/src/sso/types/oidc.ts b/packages/core/src/sso/types/oidc.ts index 3125ea020..b6ccd34b2 100644 --- a/packages/core/src/sso/types/oidc.ts +++ b/packages/core/src/sso/types/oidc.ts @@ -77,3 +77,5 @@ export const idTokenProfileStandardClaimsGuard = z.object({ profile: z.string().nullish(), nonce: z.string().nullish(), }); + +export type IdTokenProfileStandardClaims = z.infer; diff --git a/packages/phrases/src/locales/en/errors/application.ts b/packages/phrases/src/locales/en/errors/application.ts index 21a1e7a2b..9fa9e4d49 100644 --- a/packages/phrases/src/locales/en/errors/application.ts +++ b/packages/phrases/src/locales/en/errors/application.ts @@ -28,6 +28,7 @@ const application = { can_not_delete_active_secret: 'Can not delete the active secret.', no_active_secret: 'No active secret found.', entity_id_required: 'Entity ID is required to generate metadata.', + acs_url_required: 'Assertion consumer service URL is required to generate metadata.', invalid_certificate_pem_format: 'Invalid PEM certificate format', }, }; diff --git a/packages/phrases/src/locales/en/errors/oidc.ts b/packages/phrases/src/locales/en/errors/oidc.ts index 4f65ef651..8984a1864 100644 --- a/packages/phrases/src/locales/en/errors/oidc.ts +++ b/packages/phrases/src/locales/en/errors/oidc.ts @@ -8,6 +8,7 @@ const oidc = { invalid_grant: 'Grant request is invalid.', invalid_redirect_uri: "`redirect_uri` did not match any of the client's registered `redirect_uris`.", + redirect_uri_not_set: '`redirect_uri` is not set.', access_denied: 'Access denied.', invalid_target: 'Invalid resource indicator.', unsupported_grant_type: 'Unsupported `grant_type` requested.',