0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core,experience): extract sso register api (#4934)

* refactor(core,experience): extract sso register api

extract sso register api

* feat(core): add signInMode guard to sso register api (#4935)

add signInMode guard to sso register api

* chore(core): adjust the comments

adjust the comments
This commit is contained in:
simeng-li 2023-11-22 11:19:39 +08:00 committed by GitHub
parent 24972ce46e
commit 6d57a58d68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 289 additions and 90 deletions

View file

@ -1,4 +1,4 @@
import { InteractionEvent } from '@logto/schemas';
import { InteractionEvent, SignInMode } from '@logto/schemas';
import type Router from 'koa-router';
import { type IRouterParamContext } from 'koa-router';
import { z } from 'zod';
@ -13,19 +13,22 @@ import assertThat from '#src/utils/assert-that.js';
import { interactionPrefix, ssoPath } from './const.js';
import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js';
import koaInteractionHooks from './middleware/koa-interaction-hooks.js';
import koaInteractionSie from './middleware/koa-interaction-sie.js';
import { getInteractionStorage, storeInteractionResult } from './utils/interaction.js';
import { getSingleSignOnAuthenticationResult } from './utils/single-sign-on-session.js';
import {
authorizationUrlPayloadGuard,
getSsoAuthorizationUrl,
getSsoAuthentication,
handleSsoAuthentication,
registerWithSsoAuthentication,
} from './utils/single-sign-on.js';
export default function singleSignOnRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
tenant: TenantContext
) {
const { provider, libraries } = tenant;
const { provider, libraries, queries } = tenant;
const { ssoConnectors: ssoConnectorsLibrary } = libraries;
@ -115,6 +118,58 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
}
);
// Register a new user with the given SSO connector authentication result
router.post(
`${interactionPrefix}/${ssoPath}/:connectorId/registration`,
koaGuard({
params: z.object({
connectorId: z.string(),
}),
status: [200, 404, 403],
response: z.object({
redirectTo: z.string(),
}),
}),
koaInteractionSie(queries),
koaInteractionHooks(libraries),
async (ctx, next) => {
const {
createLog,
assignInteractionHookResult,
guard: { params },
} = ctx;
const {
signInExperience: { signInMode },
} = ctx;
assertThat(
signInMode !== SignInMode.SignIn,
new RequestError({ code: 'auth.forbidden', status: 403 })
);
const registerEventUpdateLog = createLog(`Interaction.Register.Update`);
registerEventUpdateLog.append({ event: 'register' });
// Update the interaction session event to register if no related user account found.
// Set the merge flag to true to merge the register event with the existing sso interaction session
await storeInteractionResult({ event: InteractionEvent.Register }, ctx, provider, true);
// Throw 404 if no related session found
const authenticationResult = await getSingleSignOnAuthenticationResult(
ctx,
provider,
params.connectorId
);
const accountId = await registerWithSsoAuthentication(ctx, tenant, authenticationResult);
await assignInteractionResults(ctx, provider, { login: { accountId } });
assignInteractionHookResult({ userId: accountId });
return next();
}
);
// Get the available SSO connectors for the user to choose from by a given email
router.get(
`${interactionPrefix}/${ssoPath}/connectors`,

View file

@ -1,16 +1,9 @@
import { type SocialUserInfo } from '@logto/connector-kit';
import { type IdentifierPayload } from '@logto/schemas';
import { type Context } from 'koa';
import type Provider from 'oidc-provider';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { type SsoConnectorLibrary } from '#src/libraries/sso-connector.js';
import {
type SingleSignOnConnectorSession,
singleSignOnConnectorSessionGuard,
} from '#src/sso/types/session.js';
import assertThat from '#src/utils/assert-that.js';
// Guard the SSO only email identifier
@ -53,35 +46,3 @@ export const verifySsoOnlyEmailIdentifier = async (
)
);
};
/**
* Get the single sign on session data from the oidc provider session storage.
*
* @remark Forked from ./social-verification.ts.
* Use SingleSignOnSession guard instead of ConnectorSession guard.
*/
export const getSingleSignOnSessionResult = async (
ctx: Context,
provider: Provider
): Promise<SingleSignOnConnectorSession> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const singleSignOnSessionResult = z
.object({
connectorSession: singleSignOnConnectorSessionGuard,
})
.safeParse(result);
assertThat(
result && singleSignOnSessionResult.success,
'session.connector_validation_session_not_found'
);
// Clear the session after the session data is retrieved
const { connectorSession, ...rest } = result;
await provider.interactionResult(ctx.req, ctx.res, {
...rest,
});
return singleSignOnSessionResult.data.connectorSession;
};

View file

@ -0,0 +1,83 @@
import { type Context } from 'koa';
import type Provider from 'oidc-provider';
import { z } from 'zod';
import {
type SingleSignOnConnectorSession,
singleSignOnConnectorSessionGuard,
singleSignOnInteractionIdentifierResultGuard,
type SingleSignOnInteractionIdentifierResult,
} from '#src/sso/types/session.js';
import assertThat from '#src/utils/assert-that.js';
/**
* Get the single sign on session data from the oidc provider session storage.
*
* @remark Forked from ./social-verification.ts.
* Use SingleSignOnSession guard instead of ConnectorSession guard.
*/
export const getSingleSignOnSessionResult = async (
ctx: Context,
provider: Provider
): Promise<SingleSignOnConnectorSession> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const singleSignOnSessionResult = z
.object({
connectorSession: singleSignOnConnectorSessionGuard,
})
.safeParse(result);
assertThat(
result && singleSignOnSessionResult.success,
'session.connector_validation_session_not_found'
);
// Clear the session after the session data is retrieved
const { connectorSession, ...rest } = result;
await provider.interactionResult(ctx.req, ctx.res, {
...rest,
});
return singleSignOnSessionResult.data.connectorSession;
};
export const assignSingleSignOnAuthenticationResult = async (
ctx: Context,
provider: Provider,
singleSignOnIdentifier: SingleSignOnInteractionIdentifierResult['singleSignOnIdentifier']
) => {
const details = await provider.interactionDetails(ctx.req, ctx.res);
await provider.interactionResult(
ctx.req,
ctx.res,
{ ...details.result, singleSignOnIdentifier },
{ mergeWithLastSubmission: true }
);
};
export const getSingleSignOnAuthenticationResult = async (
ctx: Context,
provider: Provider,
connectorId: string
): Promise<SingleSignOnInteractionIdentifierResult['singleSignOnIdentifier']> => {
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
const singleSignOnInteractionIdentifierResult =
singleSignOnInteractionIdentifierResultGuard.safeParse(result);
assertThat(
singleSignOnInteractionIdentifierResult.success,
'session.connector_session_not_found'
);
const { singleSignOnIdentifier } = singleSignOnInteractionIdentifierResult.data;
assertThat(
singleSignOnIdentifier.connectorId === connectorId,
'session.connector_session_not_found'
);
return singleSignOnIdentifier;
};

View file

@ -1,4 +1,4 @@
import { InteractionEvent, type SsoConnector } from '@logto/schemas';
import { type SsoConnector } from '@logto/schemas';
import { createMockUtils } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
@ -27,9 +27,7 @@ const updateUserMock = jest.fn();
const findUserByEmailMock = jest.fn();
const insertUserMock = jest.fn();
const generateUserIdMock = jest.fn().mockResolvedValue('foo');
const storeInteractionResultMock = jest.fn();
const getAvailableSsoConnectorsMock = jest.fn();
const getSingleSignOnSessionResultMock = jest.fn();
class MockOidcSsoConnector extends OidcSsoConnector {
override getAuthorizationUrl = getAuthorizationUrlMock;
@ -37,21 +35,28 @@ class MockOidcSsoConnector extends OidcSsoConnector {
override getUserInfo = getUserInfoMock;
}
mockEsm('./interaction.js', () => ({
storeInteractionResult: storeInteractionResultMock,
const { storeInteractionResult: storeInteractionResultMock } = mockEsm('./interaction.js', () => ({
storeInteractionResult: jest.fn(),
}));
mockEsm('./single-sign-on-guard.js', () => ({
const {
getSingleSignOnSessionResult: getSingleSignOnSessionResultMock,
assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock,
} = mockEsm('./single-sign-on-session.js', () => ({
getSingleSignOnSessionResult: jest.fn(),
assignSingleSignOnAuthenticationResult: jest.fn(),
}));
jest
.spyOn(ssoConnectorFactories.OIDC, 'constructor')
.mockImplementation((data: SsoConnector) => new MockOidcSsoConnector(data));
const { getSsoAuthorizationUrl, getSsoAuthentication, handleSsoAuthentication } = await import(
'./single-sign-on.js'
);
const {
getSsoAuthorizationUrl,
getSsoAuthentication,
handleSsoAuthentication,
registerWithSsoAuthentication,
} = await import('./single-sign-on.js');
describe('Single sign on util methods tests', () => {
const mockContext: WithLogContext & WithInteractionDetailsContext = {
@ -88,6 +93,14 @@ describe('Single sign on util methods tests', () => {
}
);
const mockIssuer = 'https://example.com';
const mockSsoUserInfo = {
id: 'identityId',
email: 'foo@logto.io',
name: 'foo',
avatar: 'https://example.com',
};
beforeEach(() => {
jest.clearAllMocks();
});
@ -140,7 +153,7 @@ describe('Single sign on util methods tests', () => {
it('should return the authentication result', async () => {
const session = {
connectorId: 'connectorId',
connectorId: wellConfiguredSsoConnector.id,
jti: 'jti',
redirectUri: 'https://example.com',
state: 'state',
@ -169,18 +182,15 @@ describe('Single sign on util methods tests', () => {
issuer: 'https://example.com',
userInfo: { id: 'id', email: 'email' },
});
expect(assignSingleSignOnAuthenticationResultMock).toBeCalledWith(mockContext, mockProvider, {
connectorId: wellConfiguredSsoConnector.id,
...result,
});
});
});
describe('handleSsoAuthentication tests', () => {
const mockIssuer = 'https://example.com';
const mockSsoUserInfo = {
id: 'identityId',
email: 'foo@logto.io',
name: 'foo',
avatar: 'https://example.com',
};
it('should signIn directly if the user is found', async () => {
findUserSsoIdentityBySsoIdentityIdMock.mockResolvedValueOnce({
id: 'ssoIdentityId',
@ -255,30 +265,39 @@ describe('Single sign on util methods tests', () => {
});
});
it('should register if no related user account found', async () => {
it('should throw if no related user account found', async () => {
findUserSsoIdentityBySsoIdentityIdMock.mockResolvedValueOnce(null);
findUserByEmailMock.mockResolvedValueOnce(null);
insertUserMock.mockResolvedValueOnce({ id: 'foo' });
const accountId = await handleSsoAuthentication(
mockContext,
tenant,
wellConfiguredSsoConnector,
{
await expect(async () =>
handleSsoAuthentication(mockContext, tenant, mockSsoConnector, {
issuer: mockIssuer,
userInfo: mockSsoUserInfo,
}
})
).rejects.toMatchObject(
new RequestError(
{
code: 'user.identity_not_exist',
status: 422,
},
{}
)
);
});
});
describe('registerWithSsoAuthentication tests', () => {
it('should register if no related user account found', async () => {
insertUserMock.mockResolvedValueOnce({ id: 'foo' });
const accountId = await registerWithSsoAuthentication(mockContext, tenant, {
connectorId: wellConfiguredSsoConnector.id,
issuer: mockIssuer,
userInfo: mockSsoUserInfo,
});
expect(accountId).toBe('foo');
// Should update the interaction session event to register
expect(storeInteractionResultMock).toBeCalledWith(
{ event: InteractionEvent.Register },
mockContext,
mockProvider
);
// Should create new user
expect(insertUserMock).toBeCalledWith(
{

View file

@ -19,8 +19,10 @@ import type Queries from '#src/tenants/Queries.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
import { storeInteractionResult } from './interaction.js';
import { getSingleSignOnSessionResult } from './single-sign-on-guard.js';
import {
getSingleSignOnSessionResult,
assignSingleSignOnAuthenticationResult,
} from './single-sign-on-session.js';
import { assignConnectorSessionResult } from './social-verification.js';
export const authorizationUrlPayloadGuard = z.object({
@ -109,6 +111,12 @@ export const getSsoAuthentication = async (
userInfo,
};
// Assign the single sign on authentication to the interaction result
await assignSingleSignOnAuthenticationResult(ctx, provider, {
connectorId,
...result,
});
log.append({ issuer, userInfo });
return result;
@ -160,13 +168,13 @@ export const handleSsoAuthentication = async (
});
}
// Update the interaction session event to register if no related user account found
const registerEventUpdateLog = createLog(`Interaction.Register.Update`);
registerEventUpdateLog.append({ event: 'register' });
await storeInteractionResult({ event: InteractionEvent.Register }, ctx, provider);
// Register
return registerWithSsoAuthentication(ctx, tenant, connectorData, ssoAuthentication);
throw new RequestError(
{
code: 'user.identity_not_exist',
status: 422,
},
{}
);
};
const signInWithSsoAuthentication = async (
@ -281,18 +289,17 @@ const signInAndLinkWithSsoAuthentication = async (
return userId;
};
const registerWithSsoAuthentication = async (
export const registerWithSsoAuthentication = async (
ctx: WithLogContext,
{
queries: { userSsoIdentities: userSsoIdentitiesQueries },
libraries: { users: usersLibrary },
}: TenantContext,
{ id: connectorId }: SupportedSsoConnector,
ssoAuthentication: SsoAuthenticationResult
ssoAuthentication: SsoAuthenticationResult & { connectorId: string }
) => {
const { createLog } = ctx;
const log = createLog(`Interaction.Register.Submit`);
const { issuer, userInfo } = ssoAuthentication;
const { issuer, userInfo, connectorId } = ssoAuthentication;
// Only sync the name, avatar and email (conflict email account will be guarded ahead)
const syncingProfile = {

View file

@ -38,3 +38,24 @@ export const samlConnectorAssertionSessionGuard = z.object({
export type CreateSingleSignOnSession = (storage: SingleSignOnConnectorSession) => Promise<void>;
export type GetSingleSignOnSession = () => Promise<SingleSignOnConnectorSession>;
/**
* Single sign on interaction identifier session
*
* @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.
* 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.
*/
export const singleSignOnInteractionIdentifierResultGuard = z.object({
singleSignOnIdentifier: z.object({
connectorId: z.string(),
issuer: z.string(),
userInfo: extendedSocialUserInfoGuard,
}),
});
export type SingleSignOnInteractionIdentifierResult = z.infer<
typeof singleSignOnInteractionIdentifierResultGuard
>;

View file

@ -38,3 +38,6 @@ export const singleSignOnAuthorization = async (connectorId: string, payload: un
json: payload,
})
.json<Response>();
export const singleSignOnRegistration = async (connectorId: string) =>
api.post(`${ssoPrefix}/${connectorId}/registration`).json<Response>();

View file

@ -1,14 +1,45 @@
import { SignInMode } from '@logto/schemas';
import { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
import { singleSignOnAuthorization } from '@/apis/single-sign-on';
import { singleSignOnAuthorization, singleSignOnRegistration } from '@/apis/single-sign-on';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import useToast from '@/hooks/use-toast';
import { parseQueryParameters } from '@/utils';
import { stateValidation } from '@/utils/social-connectors';
const useSingleSignOnRegister = () => {
const handleError = useErrorHandler();
const request = useApi(singleSignOnRegistration);
const { termsValidation } = useTerms();
return useCallback(
async (connectorId: string) => {
// Agree to terms and conditions first before proceeding
if (!(await termsValidation())) {
return;
}
const [error, result] = await request(connectorId);
if (error) {
await handleError(error);
return;
}
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
},
[handleError, request, termsValidation]
);
};
/**
* Single Sign On authentication callback handler.
*
@ -24,10 +55,12 @@ const useSingleSignOnListener = (connectorId: string) => {
const [isConsumed, setIsConsumed] = useState(false);
const [searchParameters, setSearchParameters] = useSearchParams();
const { setToast } = useToast();
const { signInMode } = useSieMethods();
const handleError = useErrorHandler();
const singleSignOnAuthorizationRequest = useApi(singleSignOnAuthorization);
const registerSingleSignOnIdentity = useSingleSignOnRegister();
const singleSignOnHandler = useCallback(
async (connectorId: string, data: Record<string, unknown>) => {
@ -38,7 +71,18 @@ const useSingleSignOnListener = (connectorId: string) => {
});
if (error) {
await handleError(error);
await handleError(error, {
'user.identity_not_exist': async (error) => {
// Should not let user register new social account under sign-in only mode
if (signInMode === SignInMode.SignIn) {
setToast(error.message);
return;
}
await registerSingleSignOnIdentity(connectorId);
},
});
return;
}
@ -46,7 +90,13 @@ const useSingleSignOnListener = (connectorId: string) => {
window.location.replace(result.redirectTo);
}
},
[handleError, singleSignOnAuthorizationRequest]
[
handleError,
registerSingleSignOnIdentity,
setToast,
signInMode,
singleSignOnAuthorizationRequest,
]
);
// Single Sign On Callback Handler