0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(core): add post single-sign-on authentication endpoint (#4787)

* feat(core): add put interaction sso authentication api

add put interaction sso authentication api

* fix(test): fix integration test type

fix integration test type
This commit is contained in:
simeng-li 2023-10-31 17:52:27 +08:00 committed by GitHub
parent c67f2c9361
commit 8616496c61
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 279 additions and 3 deletions

View file

@ -46,6 +46,8 @@ export const auditLogEventTitle: Record<string, Optional<string>> &
'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<string, Optional<string>> &
'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,
});

View file

@ -1,2 +1,3 @@
export const interactionPrefix = '/interaction';
export const verificationPath = 'verification';
export const ssoPath = 'single-sign-on';

View file

@ -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<T extends AnonymousRouter>(
consentRoutes(router, tenant);
additionalRoutes(router, tenant);
mfaRoutes(router, tenant);
singleSignOnRoutes(router, tenant);
}

View file

@ -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<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<WithLogContext<T>>>,
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();
}
);
}

View file

@ -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<ConnectorSession> => {

View file

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

View file

@ -144,3 +144,13 @@ export const consent = async (api: Got, cookie: string) =>
followRedirect: false,
})
.json<RedirectResponse>();
export const createSingleSignOnAuthorizationUri = async (
cookie: string,
payload: SocialAuthorizationUriPayload
) =>
api.post('interaction/verification/sso-authorization-uri', {
headers: { cookie },
json: payload,
followRedirect: false,
});

View file

@ -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',
}

View file

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

View file

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

View file

@ -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}`;