0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(core): refactor the sso interaction handlers (#6186)

refactor(core): revert the sso utils input refactor

revert the sso utils input refactor
This commit is contained in:
simeng-li 2024-07-09 13:51:33 +08:00 committed by GitHub
parent 0ca307cb5f
commit d7fa9f5900
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 109 additions and 55 deletions

View file

@ -31,7 +31,6 @@ export type PasswordIdentifierPayload =
export type SocialVerifiedIdentifierPayload = SocialEmailPayload | SocialPhonePayload; export type SocialVerifiedIdentifierPayload = SocialEmailPayload | SocialPhonePayload;
/** /**
* @deprecated
* Legacy type for the interaction API. * Legacy type for the interaction API.
* Use the latest experience API instead. * Use the latest experience API instead.
* Moved to `@logto/schemas` * Moved to `@logto/schemas`

View file

@ -3,9 +3,9 @@ import type Provider from 'oidc-provider';
import { z } from 'zod'; import { z } from 'zod';
import { import {
type SingleSignOnConnectorSession,
singleSignOnConnectorSessionGuard, singleSignOnConnectorSessionGuard,
singleSignOnInteractionIdentifierResultGuard, singleSignOnInteractionIdentifierResultGuard,
type SingleSignOnConnectorSession,
type SingleSignOnInteractionIdentifierResult, type SingleSignOnInteractionIdentifierResult,
} from '#src/sso/index.js'; } from '#src/sso/index.js';
import assertThat from '#src/utils/assert-that.js'; import assertThat from '#src/utils/assert-that.js';
@ -42,10 +42,33 @@ export const getSingleSignOnSessionResult = async (
return singleSignOnSessionResult.data.connectorSession; 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 ( export const assignSingleSignOnAuthenticationResult = async (
ctx: Context, ctx: Context,
provider: Provider, provider: Provider,
singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult['singleSignOnIdentifier'] singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult
) => { ) => {
const details = await provider.interactionDetails(ctx.req, ctx.res); const details = await provider.interactionDetails(ctx.req, ctx.res);
@ -61,7 +84,7 @@ export const getSingleSignOnAuthenticationResult = async (
ctx: Context, ctx: Context,
provider: Provider, provider: Provider,
connectorId: string connectorId: string
): Promise<SingleSignOnInteractionIdentifierResult['singleSignOnIdentifier']> => { ): Promise<SingleSignOnInteractionIdentifierResult> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res); const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const singleSignOnInteractionIdentifierResult = const singleSignOnInteractionIdentifierResult =

View file

@ -16,7 +16,7 @@ import { type WithInteractionDetailsContext } from '../middleware/koa-interactio
import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js';
const { jest } = import.meta; const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest); const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
const getAuthorizationUrlMock = jest.fn(); const getAuthorizationUrlMock = jest.fn();
const getIssuerMock = jest.fn(); const getIssuerMock = jest.fn();
@ -43,7 +43,7 @@ mockEsm('./interaction.js', () => ({
const { const {
getSingleSignOnSessionResult: getSingleSignOnSessionResultMock, getSingleSignOnSessionResult: getSingleSignOnSessionResultMock,
assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock, assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock,
} = mockEsm('./single-sign-on-session.js', () => ({ } = await mockEsmWithActual('./single-sign-on-session.js', () => ({
getSingleSignOnSessionResult: jest.fn(), getSingleSignOnSessionResult: jest.fn(),
assignSingleSignOnAuthenticationResult: jest.fn(), assignSingleSignOnAuthenticationResult: jest.fn(),
})); }));

View file

@ -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 { ConnectorError, type SocialUserInfo } from '@logto/connector-kit';
import { validateRedirectUrl } from '@logto/core-kit'; import { validateRedirectUrl } from '@logto/core-kit';
import { import {
@ -12,7 +13,6 @@ import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.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 { ssoConnectorFactories, type SingleSignOnConnectorSession } from '#src/sso/index.js';
import type Queries from '#src/tenants/Queries.js'; import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js'; import type TenantContext from '#src/tenants/TenantContext.js';
@ -22,30 +22,25 @@ import { type WithInteractionHooksContext } from '../middleware/koa-interaction-
import { import {
assignSingleSignOnAuthenticationResult, assignSingleSignOnAuthenticationResult,
assignSingleSignOnSessionResult,
getSingleSignOnSessionResult, getSingleSignOnSessionResult,
} from './single-sign-on-session.js'; } from './single-sign-on-session.js';
import { assignConnectorSessionResult } from './social-verification.js';
export const authorizationUrlPayloadGuard = z.object({ export const authorizationUrlPayloadGuard = z.object({
state: z.string().min(1), state: z.string().min(1),
redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')), redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')),
}); });
type AuthorizationUrlPayload = z.infer<typeof authorizationUrlPayloadGuard>; type AuthorizationUrlPayload = z.infer<typeof authorizationUrlPayloadGuard>;
// Get the authorization url for the SSO provider
export const getSsoAuthorizationUrl = async ( export const getSsoAuthorizationUrl = async (
ctx: WithInteractionDetailsContext<WithLogContext>, ctx: WithLogContext,
{ provider, id: tenantId }: TenantContext, { provider, id: tenantId }: TenantContext,
connectorData: SupportedSsoConnector, connectorData: SupportedSsoConnector,
payload: AuthorizationUrlPayload payload: AuthorizationUrlPayload
): Promise<string> => { ): Promise<string> => {
const { id: connectorId, providerName } = connectorData; const { id: connectorId, providerName } = connectorData;
const { const { createLog } = ctx;
createLog,
interactionDetails: { jti },
} = ctx;
const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Create`); const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Create`);
log.append({ log.append({
@ -62,10 +57,12 @@ export const getSsoAuthorizationUrl = async (
assertThat(payload, 'session.insufficient_info'); assertThat(payload, 'session.insufficient_info');
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
return await connectorInstance.getAuthorizationUrl( return await connectorInstance.getAuthorizationUrl(
{ jti, ...payload, connectorId }, { jti, ...payload, connectorId },
async (connectorSession: SingleSignOnConnectorSession) => async (connectorSession: SingleSignOnConnectorSession) =>
assignConnectorSessionResult(ctx, provider, connectorSession) assignSingleSignOnSessionResult(ctx, provider, connectorSession)
); );
} catch (error: unknown) { } catch (error: unknown) {
// Catch ConnectorError and re-throw as 500 RequestError // Catch ConnectorError and re-throw as 500 RequestError
@ -78,23 +75,31 @@ export const getSsoAuthorizationUrl = async (
}; };
type SsoAuthenticationResult = { type SsoAuthenticationResult = {
/** The issuer of the SSO provider, we need to store this in the user SSO identity to identify the provider. */
issuer: string; issuer: string;
userInfo: SocialUserInfo; userInfo: SocialUserInfo;
}; };
// Get the user authentication result from the SSO provider /**
export const getSsoAuthentication = async ( * Verify the SSO identity from the SSO provider
ctx: WithInteractionHooksContext<WithLogContext>, *
* - 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, { provider, id: tenantId }: TenantContext,
connectorData: SupportedSsoConnector, connectorData: SupportedSsoConnector,
data: Record<string, unknown> data: Record<string, unknown>
): Promise<SsoAuthenticationResult> => { ): Promise<SsoAuthenticationResult> => {
const { createLog } = ctx;
const { id: connectorId, providerName } = connectorData; const { id: connectorId, providerName } = connectorData;
const { createLog } = ctx;
const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Submit`); const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Submit`);
log.append({ connectorId, data }); log.append({ connectorId, data });
const singleSignOnSession = await getSingleSignOnSessionResult(ctx, provider); const singleSignOnSession = await getSingleSignOnSessionResult(ctx, provider);
try { try {
@ -103,34 +108,49 @@ export const getSsoAuthentication = async (
connectorData, connectorData,
tenantId tenantId
); );
const issuer = await connectorInstance.getIssuer(); const issuer = await connectorInstance.getIssuer();
const userInfo = await connectorInstance.getUserInfo(singleSignOnSession, data); const userInfo = await connectorInstance.getUserInfo(singleSignOnSession, data);
const result = { log.append({ issuer, userInfo });
return {
issuer, issuer,
userInfo, 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 (error: unknown) {
// Catch ConnectorError and re-throw as 500 RequestError // Catch ConnectorError and re-throw as 500 RequestError
if (error instanceof ConnectorError) { if (error instanceof ConnectorError) {
throw new RequestError({ code: `connector.${error.code}`, status: 500 }, error.data); throw new RequestError({ code: `connector.${error.code}`, status: 500 }, error.data);
} }
throw error; 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<WithLogContext>,
tenantContext: TenantContext,
connectorData: SupportedSsoConnector,
data: Record<string, unknown>
): Promise<SsoAuthenticationResult> => {
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 // Handle the SSO authentication result and return the user id
export const handleSsoAuthentication = async ( export const handleSsoAuthentication = async (
ctx: WithInteractionHooksContext<WithLogContext>, ctx: WithInteractionHooksContext<WithLogContext>,
@ -372,3 +392,4 @@ export const registerWithSsoAuthentication = async (
return user; return user;
}; };
/* eslint-enable max-lines */

View file

@ -1,53 +1,64 @@
import { type ToZodObject } from '@logto/schemas/lib/utils/zod.js';
import { z } from 'zod'; import { z } from 'zod';
import { extendedSocialUserInfoGuard } from './saml.js'; import { extendedSocialUserInfoGuard, type ExtendedSocialUserInfo } from './saml.js';
/** /**
* Single sign on connector session * 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 * @remark this is a forked version of @logto/connector-kit
* Simplified the type definition to only include the properties we need. * Simplified the type definition to only include the properties we need.
* Create additional type guard to validate the session data. * Create additional type guard to validate the session data.
* @see @logto/connector-kit/types/social.ts * @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({ export const singleSignOnConnectorSessionGuard = z.object({
state: z.string(), state: z.string(),
redirectUri: z.string(), redirectUri: z.string(),
connectorId: z.string(), connectorId: z.string(),
nonce: z.string().optional(), nonce: z.string().optional(),
userInfo: extendedSocialUserInfoGuard.optional(), userInfo: extendedSocialUserInfoGuard.optional(),
}); }) satisfies ToZodObject<SingleSignOnConnectorSession>;
export type SingleSignOnConnectorSession = z.infer<typeof singleSignOnConnectorSessionGuard>;
export type CreateSingleSignOnSession = (storage: SingleSignOnConnectorSession) => Promise<void>; export type CreateSingleSignOnSession = (storage: SingleSignOnConnectorSession) => Promise<void>;
/** /**
* 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} * @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 session is needed because we need to split the authentication process into sign in and sign up two parts. * 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 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. * 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. * 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({ export const singleSignOnInteractionIdentifierResultGuard = z.object({
singleSignOnIdentifier: z.object({ singleSignOnIdentifier: z.object({
connectorId: z.string(), connectorId: z.string(),
issuer: z.string(), issuer: z.string(),
userInfo: extendedSocialUserInfoGuard, userInfo: extendedSocialUserInfoGuard,
}), }),
}); }) satisfies ToZodObject<{
singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult;
export type SingleSignOnInteractionIdentifierResult = z.infer< }>;
typeof singleSignOnInteractionIdentifierResultGuard
>;