diff --git a/packages/core/src/saml-applications/routes/anonymous.ts b/packages/core/src/saml-applications/routes/anonymous.ts index 16c5c9e2c..aa7a0e52f 100644 --- a/packages/core/src/saml-applications/routes/anonymous.ts +++ b/packages/core/src/saml-applications/routes/anonymous.ts @@ -1,17 +1,16 @@ -import { tryThat } from '@silverhand/essentials'; -import saml from 'samlify'; 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 { fetchOidcConfig, getUserInfo } from '#src/sso/OidcConnector/utils.js'; -import { SsoConnectorError } from '#src/sso/types/error.js'; import assertThat from '#src/utils/assert-that.js'; -import { samlLogInResponseTemplate, samlAttributeNameFormatBasic,samlValueXmlnsXsi } from '../libraries/consts.js'; - -import { exchangeAuthorizationCode, generateAutoSubmitForm, createSamlResponse } from './utils.js'; +import { + generateAutoSubmitForm, + createSamlResponse, + handleOidcCallbackAndGetUserInfo, + setupSamlProviders, +} from './utils.js'; const samlApplicationSignInCallbackQueryParametersGuard = z.union([ z.object({ @@ -29,6 +28,7 @@ export default function samlApplicationAnonymousRoutes fetchOidcConfig(envSet.oidc.issuer), - (error) => { - if (error instanceof SsoConnectorError) { - throw new RequestError({ - code: 'oidc.invalid_request', - message: error.message, - }); - } - - // Should rarely happen, fetch OIDC configuration should only throw SSO connector error . - throw error; - } + // Handle OIDC callback and get user info + const userInfo = await handleOidcCallbackAndGetUserInfo( + code, + id, + secret, + redirectUris[0], + envSet.oidc.issuer ); - // Exchange authorization code for tokens - const { accessToken } = await exchangeAuthorizationCode(tokenEndpoint, { - code, - clientId: id, - clientSecret: secret, - redirectUri: redirectUris[0], - }); - - assertThat(accessToken, new RequestError('oidc.access_denied')); - - // Get user info using access token - const userInfo = await getUserInfo(accessToken, userinfoEndpoint); - - // Get SAML configuration and create SAML response + // Get SAML configuration const { metadata } = await getSamlIdPMetadataByApplicationId(id); const { privateKey } = await samlApplicationSecrets.findActiveSamlApplicationSecretByApplicationId(id); @@ -118,41 +101,8 @@ export default function samlApplicationAnonymousRoutes (template: string) => { const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService( @@ -63,7 +74,7 @@ const createSamlTemplateCallback = }; }; -export const exchangeAuthorizationCode = async ( +const exchangeAuthorizationCode = async ( tokenEndpoint: string, { code, @@ -109,7 +120,7 @@ export const exchangeAuthorizationCode = async ( export const createSamlResponse = async ( idp: saml.IdentityProviderInstance, sp: saml.ServiceProviderInstance, - userInfo: idTokenProfileStandardClaims + userInfo: IdTokenProfileStandardClaims ): Promise<{ context: string; entityEndpoint: string }> => { // TODO: fix binding method // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -141,3 +152,103 @@ export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): `; }; + +const getUserInfo = async ( + accessToken: string, + userinfoEndpoint: string +): Promise> => { + const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint); + const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(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..3909dc177 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,29 @@ 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); + const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(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 935109f9c..b6ccd34b2 100644 --- a/packages/core/src/sso/types/oidc.ts +++ b/packages/core/src/sso/types/oidc.ts @@ -78,4 +78,4 @@ export const idTokenProfileStandardClaimsGuard = z.object({ nonce: z.string().nullish(), }); -export type idTokenProfileStandardClaims = z.infer; +export type IdTokenProfileStandardClaims = z.infer; 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.',