diff --git a/packages/console/src/consts/logs.ts b/packages/console/src/consts/logs.ts index d7341a57f..5a1ce2d10 100644 --- a/packages/console/src/consts/logs.ts +++ b/packages/console/src/consts/logs.ts @@ -46,6 +46,8 @@ export const auditLogEventTitle: Record> & 'Interaction.Register.BindMfa.BackupCode.Submit': undefined, 'Interaction.Register.BindMfa.WebAuthn.Create': undefined, 'Interaction.Register.BindMfa.WebAuthn.Submit': undefined, + 'Interaction.Register.SingleSignOn.Create': undefined, + 'Interaction.Register.SingleSignOn.Submit': undefined, 'Interaction.SignIn.Identifier.Password.Submit': 'Submit sign-in identifier with password', 'Interaction.SignIn.Identifier.Social.Create': 'Create social sign-in authorization-url', 'Interaction.SignIn.Identifier.Social.Submit': 'Authenticate and submit social identifier', @@ -70,6 +72,8 @@ export const auditLogEventTitle: Record> & 'Interaction.SignIn.Mfa.BackupCode.Submit': undefined, 'Interaction.SignIn.Mfa.WebAuthn.Create': undefined, 'Interaction.SignIn.Mfa.WebAuthn.Submit': undefined, + 'Interaction.SignIn.SingleSignOn.Create': 'Create single-sign-on authentication session', + 'Interaction.SignIn.SingleSignOn.Submit': 'Submit single-sign-on authentication interaction', RevokeToken: undefined, Unknown: undefined, }); diff --git a/packages/core/src/routes/interaction/const.ts b/packages/core/src/routes/interaction/const.ts index df73ea486..6dde5b9d7 100644 --- a/packages/core/src/routes/interaction/const.ts +++ b/packages/core/src/routes/interaction/const.ts @@ -1,2 +1,3 @@ export const interactionPrefix = '/interaction'; export const verificationPath = 'verification'; +export const ssoPath = 'single-sign-on'; diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index 1c49521cf..f7a650d25 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -20,6 +20,7 @@ import koaInteractionDetails from './middleware/koa-interaction-details.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 singleSignOnRoutes from './single-sign-on.js'; import { getInteractionStorage, storeInteractionResult, @@ -374,4 +375,5 @@ export default function interactionRoutes( consentRoutes(router, tenant); additionalRoutes(router, tenant); mfaRoutes(router, tenant); + singleSignOnRoutes(router, tenant); } diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts new file mode 100644 index 000000000..7809b5dfd --- /dev/null +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -0,0 +1,103 @@ +import { ConnectorError, type ConnectorSession } from '@logto/connector-kit'; +import { validateRedirectUrl } from '@logto/core-kit'; +import { InteractionEvent } from '@logto/schemas'; +import type Router from 'koa-router'; +import { type IRouterParamContext } from 'koa-router'; +import { z } from 'zod'; + +import RequestError from '#src/errors/RequestError/index.js'; +import { type WithLogContext } from '#src/middleware/koa-audit-log.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import { ssoConnectorFactories } from '#src/sso/index.js'; +import type TenantContext from '#src/tenants/TenantContext.js'; +import assertThat from '#src/utils/assert-that.js'; + +import { interactionPrefix, ssoPath } from './const.js'; +import type { WithInteractionDetailsContext } from './middleware/koa-interaction-details.js'; +import { getInteractionStorage } from './utils/interaction.js'; +import { assignConnectorSessionResult } from './utils/social-verification.js'; + +export default function singleSignOnRoutes( + router: Router>>, + tenant: TenantContext +) { + const { + provider, + libraries: { ssoConnector }, + } = tenant; + + // Create Sso authorization url for user interaction + router.post( + `${interactionPrefix}/${ssoPath}/:connectorId/authentication`, + koaGuard({ + params: z.object({ + connectorId: z.string(), + }), + body: z.object({ + state: z.string().min(1), + redirectUri: z.string().refine((url) => validateRedirectUrl(url, 'web')), + }), + status: [200, 500, 404], + response: z.object({ + redirectTo: z.string(), + }), + }), + async (ctx, next) => { + const { interactionDetails, guard, createLog } = ctx; + + // Check interaction exists + const { event } = getInteractionStorage(interactionDetails.result); + + assertThat( + event !== InteractionEvent.ForgotPassword, + 'session.not_supported_for_forgot_password' + ); + + const log = createLog(`Interaction.${event}.SingleSignOn.Create`); + + const { + params: { connectorId }, + } = guard; + + const { body: payload } = guard; + + log.append({ + connectorId, + ...payload, + }); + + const { state, redirectUri } = payload; + assertThat(state && redirectUri, 'session.insufficient_info'); + + // Will throw 404 if connector not found, or not supported + const connectorData = await ssoConnector.getSsoConnectorById(connectorId); + + try { + // Will throw ConnectorError if the config is invalid + const connectorInstance = new ssoConnectorFactories[connectorData.providerName].constructor( + connectorData + ); + + // Will throw ConnectorError if failed to fetch the provider's config + const redirectTo = await connectorInstance.getAuthorizationUrl( + { state, redirectUri }, + async (connectorSession: ConnectorSession) => + assignConnectorSessionResult(ctx, provider, connectorSession) + ); + + // TODO: Add SAML connector support later + + ctx.body = { redirectTo }; + } 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; + } + + return next(); + } + ); +} diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index 946c4f303..89b277751 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -72,7 +72,7 @@ export const verifySocialIdentity = async ( return userInfo; }; -const assignConnectorSessionResult = async ( +export const assignConnectorSessionResult = async ( ctx: Context, provider: Provider, connectorSession: ConnectorSession @@ -84,7 +84,7 @@ const assignConnectorSessionResult = async ( }); }; -const getConnectorSessionResult = async ( +export const getConnectorSessionResult = async ( ctx: Context, provider: Provider ): Promise => { diff --git a/packages/integration-tests/src/api/interaction-sso.ts b/packages/integration-tests/src/api/interaction-sso.ts new file mode 100644 index 000000000..429c99d25 --- /dev/null +++ b/packages/integration-tests/src/api/interaction-sso.ts @@ -0,0 +1,21 @@ +import api from './api.js'; + +export const ssoPath = 'single-sign-on'; + +export const getSsoAuthorizationUrl = async ( + cookie: string, + data: { + connectorId: string; + state: string; + redirectUri: string; + } +) => { + const { connectorId, ...payload } = data; + return api + .post(`interaction/${ssoPath}/${connectorId}/authentication`, { + headers: { cookie }, + json: payload, + followRedirect: false, + }) + .json<{ redirectTo: string }>(); +}; diff --git a/packages/integration-tests/src/api/interaction.ts b/packages/integration-tests/src/api/interaction.ts index b944b0bf6..423377f71 100644 --- a/packages/integration-tests/src/api/interaction.ts +++ b/packages/integration-tests/src/api/interaction.ts @@ -144,3 +144,13 @@ export const consent = async (api: Got, cookie: string) => followRedirect: false, }) .json(); + +export const createSingleSignOnAuthorizationUri = async ( + cookie: string, + payload: SocialAuthorizationUriPayload +) => + api.post('interaction/verification/sso-authorization-uri', { + headers: { cookie }, + json: payload, + followRedirect: false, + }); diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts index af4ee4ad6..cdbf58b74 100644 --- a/packages/integration-tests/src/constants.ts +++ b/packages/integration-tests/src/constants.ts @@ -25,3 +25,8 @@ export const signUpIdentifiers = { export const consoleUsername = 'svhd'; export const consolePassword = 'silverhandasd_1'; export const mockSocialAuthPageUrl = 'http://mock.social.com'; + +// @see {@link packages/core/src/sso/types} +export enum ProviderName { + OIDC = 'OIDC', +} diff --git a/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts b/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts new file mode 100644 index 000000000..f40106c85 --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/single-sign-on/happy-path.test.ts @@ -0,0 +1,51 @@ +import { InteractionEvent, type SsoConnectorMetadata } from '@logto/schemas'; + +import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js'; +import { putInteraction } from '#src/api/interaction.js'; +import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js'; +import { ProviderName, logtoUrl } from '#src/constants.js'; +import { initClient } from '#src/helpers/client.js'; + +describe('Single Sign On Happy Path', () => { + const connectorIdMap = new Map(); + + const state = 'foo_state'; + const redirectUri = 'http://foo.dev/callback'; + + beforeAll(async () => { + const { id, connectorName, domains } = await createSsoConnector({ + providerName: ProviderName.OIDC, + connectorName: 'test-oidc', + config: { + clientId: 'foo', + clientSecret: 'bar', + issuer: `${logtoUrl}/oidc`, + }, + }); + + connectorIdMap.set(id, { id, connectorName, domains, logo: '' }); + }); + + afterAll(async () => { + const connectorIds = Array.from(connectorIdMap.keys()); + await Promise.all(connectorIds.map(async (id) => deleteSsoConnectorById(id))); + }); + + it('should get sso authorization url properly', async () => { + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + const response = await client.send(getSsoAuthorizationUrl, { + connectorId: Array.from(connectorIdMap.keys())[0]!, + state, + redirectUri, + }); + + expect(response.redirectTo).not.toBeUndefined(); + expect(response.redirectTo.indexOf(logtoUrl)).not.toBe(-1); + expect(response.redirectTo.indexOf(state)).not.toBe(-1); + }); +}); diff --git a/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts b/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts new file mode 100644 index 000000000..5a5946ab0 --- /dev/null +++ b/packages/integration-tests/src/tests/api/interaction/single-sign-on/sad-path.test.ts @@ -0,0 +1,75 @@ +import { InteractionEvent } from '@logto/schemas'; + +import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js'; +import { putInteraction } from '#src/api/interaction.js'; +import { createSsoConnector } from '#src/api/sso-connector.js'; +import { ProviderName, logtoUrl } from '#src/constants.js'; +import { initClient } from '#src/helpers/client.js'; + +describe('Single Sign On Sad Path', () => { + const state = 'foo_state'; + const redirectUri = 'http://foo.dev/callback'; + + it('should throw 404 if session not found', async () => { + const { id } = await createSsoConnector({ + providerName: ProviderName.OIDC, + connectorName: 'test-oidc', + config: { + clientId: 'foo', + clientSecret: 'bar', + issuer: `${logtoUrl}/oidc`, + }, + }); + + const client = await initClient(); + + await expect( + client.send(getSsoAuthorizationUrl, { + connectorId: id, + state, + redirectUri, + }) + ).rejects.toThrow(); + }); + + it('should throw if connector not found', async () => { + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await expect( + client.send(getSsoAuthorizationUrl, { + connectorId: 'foo', + state, + redirectUri, + }) + ).rejects.toThrow(); + }); + + it('should throw if connector config is invalid', async () => { + const { id } = await createSsoConnector({ + providerName: ProviderName.OIDC, + connectorName: 'test-oidc', + config: { + clientId: 'foo', + clientSecret: 'bar', + }, + }); + + const client = await initClient(); + + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + }); + + await expect( + client.send(getSsoAuthorizationUrl, { + connectorId: id, + state, + redirectUri, + }) + ).rejects.toThrow(); + }); +}); diff --git a/packages/schemas/src/types/log/interaction.ts b/packages/schemas/src/types/log/interaction.ts index ed32ffce7..dbc4e8d92 100644 --- a/packages/schemas/src/types/log/interaction.ts +++ b/packages/schemas/src/types/log/interaction.ts @@ -11,6 +11,7 @@ export enum Field { Identifier = 'Identifier', Profile = 'Profile', BindMfa = 'BindMfa', + SingleSignOn = 'SingleSignOn', Mfa = 'Mfa', } @@ -88,4 +89,7 @@ export type LogKey = | `${Prefix}.${InteractionEvent}.${Field.BindMfa}.${MfaFactor}.${Action.Submit | Action.Create}` | `${Prefix}.${InteractionEvent.SignIn}.${Field.Mfa}.${MfaFactor}.${ | Action.Submit - | Action.Create}`; + | Action.Create}` + | `${Prefix}.${InteractionEvent.SignIn | InteractionEvent.Register}.${Field.SingleSignOn}.${ + | Action.Create + | Action.Submit}`;