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;
/**
* @deprecated
* Legacy type for the interaction API.
* Use the latest experience API instead.
* Moved to `@logto/schemas`

View file

@ -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<SingleSignOnInteractionIdentifierResult['singleSignOnIdentifier']> => {
): Promise<SingleSignOnInteractionIdentifierResult> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const singleSignOnInteractionIdentifierResult =

View file

@ -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(),
}));

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 { 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<typeof authorizationUrlPayloadGuard>;
// Get the authorization url for the SSO provider
export const getSsoAuthorizationUrl = async (
ctx: WithInteractionDetailsContext<WithLogContext>,
ctx: WithLogContext,
{ provider, id: tenantId }: TenantContext,
connectorData: SupportedSsoConnector,
payload: AuthorizationUrlPayload
): Promise<string> => {
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<WithLogContext>,
/**
* 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<string, unknown>
): Promise<SsoAuthenticationResult> => {
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<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
export const handleSsoAuthentication = async (
ctx: WithInteractionHooksContext<WithLogContext>,
@ -372,3 +392,4 @@ export const registerWithSsoAuthentication = async (
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 { 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<typeof singleSignOnConnectorSessionGuard>;
}) satisfies ToZodObject<SingleSignOnConnectorSession>;
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}
* 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;
}>;