diff --git a/packages/core/src/routes/interaction/types/index.ts b/packages/core/src/routes/interaction/types/index.ts index 1ddbcc6ed..81d6b1b25 100644 --- a/packages/core/src/routes/interaction/types/index.ts +++ b/packages/core/src/routes/interaction/types/index.ts @@ -31,7 +31,6 @@ export type PasswordIdentifierPayload = export type SocialVerifiedIdentifierPayload = SocialEmailPayload | SocialPhonePayload; /** - * @deprecated * Legacy type for the interaction API. * Use the latest experience API instead. * Moved to `@logto/schemas` diff --git a/packages/core/src/routes/interaction/utils/single-sign-on-session.ts b/packages/core/src/routes/interaction/utils/single-sign-on-session.ts index f096ca3e7..5a10c5432 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on-session.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on-session.ts @@ -3,9 +3,9 @@ import type Provider from 'oidc-provider'; import { z } from 'zod'; import { - type SingleSignOnConnectorSession, singleSignOnConnectorSessionGuard, singleSignOnInteractionIdentifierResultGuard, + type SingleSignOnConnectorSession, type SingleSignOnInteractionIdentifierResult, } from '#src/sso/index.js'; import assertThat from '#src/utils/assert-that.js'; @@ -42,10 +42,33 @@ export const getSingleSignOnSessionResult = async ( return singleSignOnSessionResult.data.connectorSession; }; +/** + * Assign the single sign on session data to the oidc provider session storage. + * + * @remark Forked from {@link ./social-verification.ts.} + * Use the SingleSignOnConnectorSession type instead of ConnectorSession. + * Remove the dependency from social-verification utils. + */ +export const assignSingleSignOnSessionResult = async ( + ctx: Context, + provider: Provider, + connectorSession: SingleSignOnConnectorSession +) => { + const details = await provider.interactionDetails(ctx.req, ctx.res); + await provider.interactionResult(ctx.req, ctx.res, { + ...details.result, + connectorSession, + }); +}; + +// Remark: +// The following functions are used in the legacy interaction single sign on routes only. +// Deprecated in the experience APIs. `SingleSignOnAuthenticationResult` will be stored as a verification record in the latest experience API implementation. + export const assignSingleSignOnAuthenticationResult = async ( ctx: Context, provider: Provider, - singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult['singleSignOnIdentifier'] + singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult ) => { const details = await provider.interactionDetails(ctx.req, ctx.res); @@ -61,7 +84,7 @@ export const getSingleSignOnAuthenticationResult = async ( ctx: Context, provider: Provider, connectorId: string -): Promise => { +): Promise => { const { result } = await provider.interactionDetails(ctx.req, ctx.res); const singleSignOnInteractionIdentifierResult = diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 682d37b1c..62cdd464a 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -16,7 +16,7 @@ import { type WithInteractionDetailsContext } from '../middleware/koa-interactio import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; const { jest } = import.meta; -const { mockEsm } = createMockUtils(jest); +const { mockEsm, mockEsmWithActual } = createMockUtils(jest); const getAuthorizationUrlMock = jest.fn(); const getIssuerMock = jest.fn(); @@ -43,7 +43,7 @@ mockEsm('./interaction.js', () => ({ const { getSingleSignOnSessionResult: getSingleSignOnSessionResultMock, assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock, -} = mockEsm('./single-sign-on-session.js', () => ({ +} = await mockEsmWithActual('./single-sign-on-session.js', () => ({ getSingleSignOnSessionResult: jest.fn(), assignSingleSignOnAuthenticationResult: jest.fn(), })); diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index 28fb6e618..c50e9fcaa 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- will migrate this file to the latest experience APIs */ import { ConnectorError, type SocialUserInfo } from '@logto/connector-kit'; import { validateRedirectUrl } from '@logto/core-kit'; import { @@ -12,7 +13,6 @@ import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; -import { type WithInteractionDetailsContext } from '#src/routes/interaction/middleware/koa-interaction-details.js'; import { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js'; import type Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; @@ -22,30 +22,25 @@ import { type WithInteractionHooksContext } from '../middleware/koa-interaction- import { assignSingleSignOnAuthenticationResult, + assignSingleSignOnSessionResult, getSingleSignOnSessionResult, } from './single-sign-on-session.js'; -import { assignConnectorSessionResult } from './social-verification.js'; export const authorizationUrlPayloadGuard = z.object({ state: z.string().min(1), redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')), }); - type AuthorizationUrlPayload = z.infer; -// Get the authorization url for the SSO provider export const getSsoAuthorizationUrl = async ( - ctx: WithInteractionDetailsContext, + ctx: WithLogContext, { provider, id: tenantId }: TenantContext, connectorData: SupportedSsoConnector, payload: AuthorizationUrlPayload ): Promise => { const { id: connectorId, providerName } = connectorData; - const { - createLog, - interactionDetails: { jti }, - } = ctx; + const { createLog } = ctx; const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Create`); log.append({ @@ -62,10 +57,12 @@ export const getSsoAuthorizationUrl = async ( assertThat(payload, 'session.insufficient_info'); + const { jti } = await provider.interactionDetails(ctx.req, ctx.res); + return await connectorInstance.getAuthorizationUrl( { jti, ...payload, connectorId }, async (connectorSession: SingleSignOnConnectorSession) => - assignConnectorSessionResult(ctx, provider, connectorSession) + assignSingleSignOnSessionResult(ctx, provider, connectorSession) ); } catch (error: unknown) { // Catch ConnectorError and re-throw as 500 RequestError @@ -78,23 +75,31 @@ export const getSsoAuthorizationUrl = async ( }; type SsoAuthenticationResult = { + /** The issuer of the SSO provider, we need to store this in the user SSO identity to identify the provider. */ issuer: string; userInfo: SocialUserInfo; }; -// Get the user authentication result from the SSO provider -export const getSsoAuthentication = async ( - ctx: WithInteractionHooksContext, +/** + * Verify the SSO identity from the SSO provider + * + * - Load the SSO session from the OIDC provider interaction details + * - Verify the SSO identity from the SSO provider + * + * @returns The SSO authentication result + */ +const verifySsoIdentity = async ( + ctx: WithLogContext, { provider, id: tenantId }: TenantContext, connectorData: SupportedSsoConnector, data: Record ): Promise => { - const { createLog } = ctx; const { id: connectorId, providerName } = connectorData; + const { createLog } = ctx; + const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Submit`); log.append({ connectorId, data }); - const singleSignOnSession = await getSingleSignOnSessionResult(ctx, provider); try { @@ -103,34 +108,49 @@ export const getSsoAuthentication = async ( connectorData, tenantId ); - const issuer = await connectorInstance.getIssuer(); const userInfo = await connectorInstance.getUserInfo(singleSignOnSession, data); - const result = { + log.append({ issuer, userInfo }); + + return { issuer, userInfo, }; - - // Assign the single sign on authentication to the interaction result - await assignSingleSignOnAuthenticationResult(ctx, provider, { - connectorId, - ...result, - }); - - log.append({ issuer, userInfo }); - - return result; } catch (error: unknown) { // Catch ConnectorError and re-throw as 500 RequestError if (error instanceof ConnectorError) { throw new RequestError({ code: `connector.${error.code}`, status: 500 }, error.data); } - throw error; } }; +// Remark: +// The following functions are used in the legacy interaction single sign on routes only. +// The SSO interaction flow will be handled in the latest experience APIs using the SSO verification record. + +/** Verify the SSO identity and assign the authentication result to the interaction result */ +export const getSsoAuthentication = async ( + ctx: WithInteractionHooksContext, + tenantContext: TenantContext, + connectorData: SupportedSsoConnector, + data: Record +): Promise => { + const { provider } = tenantContext; + const { id: connectorId } = connectorData; + + const ssoAuthentication = await verifySsoIdentity(ctx, tenantContext, connectorData, data); + + // Assign the single sign on authentication to the interaction result + await assignSingleSignOnAuthenticationResult(ctx, provider, { + connectorId, + ...ssoAuthentication, + }); + + return ssoAuthentication; +}; + // Handle the SSO authentication result and return the user id export const handleSsoAuthentication = async ( ctx: WithInteractionHooksContext, @@ -372,3 +392,4 @@ export const registerWithSsoAuthentication = async ( return user; }; +/* eslint-enable max-lines */ diff --git a/packages/core/src/sso/types/session.ts b/packages/core/src/sso/types/session.ts index 077d29086..6f8aab4c6 100644 --- a/packages/core/src/sso/types/session.ts +++ b/packages/core/src/sso/types/session.ts @@ -1,53 +1,64 @@ +import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js'; import { z } from 'zod'; -import { extendedSocialUserInfoGuard } from './saml.js'; +import { extendedSocialUserInfoGuard, type ExtendedSocialUserInfo } from './saml.js'; /** * Single sign on connector session * - * @property state The state generated by Logto experience client. - * @property redirectUri The redirect uri for the identity provider. - * @property nonce OIDC only properties, generated by OIDC connector factory, used to verify the identity provider response. - * @property userInfo The user info returned by the identity provider. - * SAML only properties, parsed from the SAML assertion. - * We store the assertion in the session storage after receiving it from the identity provider. - * So the client authentication handler can get it later. - * @property connectorId The connector id. - * * @remark this is a forked version of @logto/connector-kit * Simplified the type definition to only include the properties we need. * Create additional type guard to validate the session data. * @see @logto/connector-kit/types/social.ts */ +export type SingleSignOnConnectorSession = { + /** The state generated by Logto experience client. */ + state: string; + /** The redirect uri for the identity provider. */ + redirectUri: string; + connectorId: string; + /** OIDC only properties, generated by OIDC connector factory, used to verify the identity provider response. */ + nonce?: string; + /** User info returned by the identity provider. + * SAML only properties, parsed from the SAML assertion. + * We store the assertion in the session storage after receiving it from the identity provider. + * So the client authentication handler can get it later. + */ + userInfo?: ExtendedSocialUserInfo; +}; + export const singleSignOnConnectorSessionGuard = z.object({ state: z.string(), redirectUri: z.string(), connectorId: z.string(), nonce: z.string().optional(), userInfo: extendedSocialUserInfoGuard.optional(), -}); - -export type SingleSignOnConnectorSession = z.infer; +}) satisfies ToZodObject; export type CreateSingleSignOnSession = (storage: SingleSignOnConnectorSession) => Promise; /** - * Single sign on interaction identifier session + * Single sign on interaction identifier result * - * @remark this session is used to store the authentication result from the identity provider. {@link /packages/core/src/routes/interaction/utils/single-sign-on.ts} - * This session is needed because we need to split the authentication process into sign in and sign up two parts. + * @remark this session data is used to store the authentication result from the identity provider. {@link /packages/core/src/routes/interaction/utils/single-sign-on.ts} + * This is needed because we need to split the authentication process into sign in and sign up two parts. * If the SSO identity is found in DB we will directly sign in the user. * If the SSO identity is not found in DB we will throw an error and let the client to create a new user. * In the SSO registration endpoint, we will validate this session data and create a new user accordingly. + * + * Deprecated in the experience APIs. We will reply on the SSO verification record to store the authentication result. */ +export type SingleSignOnInteractionIdentifierResult = { + connectorId: string; + issuer: string; + userInfo: ExtendedSocialUserInfo; +}; export const singleSignOnInteractionIdentifierResultGuard = z.object({ singleSignOnIdentifier: z.object({ connectorId: z.string(), issuer: z.string(), userInfo: extendedSocialUserInfoGuard, }), -}); - -export type SingleSignOnInteractionIdentifierResult = z.infer< - typeof singleSignOnInteractionIdentifierResultGuard ->; +}) satisfies ToZodObject<{ + singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult; +}>;