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:
parent
0ca307cb5f
commit
d7fa9f5900
5 changed files with 109 additions and 55 deletions
|
@ -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`
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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(),
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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
|
|
||||||
>;
|
|
||||||
|
|
Loading…
Reference in a new issue