0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): consume IdP initiated session on SSO verification flow (#6669)

* feat(core): consume IdP initiated session on SSO verification flow

Auto consume the IdP initiated SAML SSO session on the SSO sign-in verification flow

* refactor(core): remove offline_access scope by default

remove the offline_access scope by default

* test(core): add unit test cases

add unit test cases
This commit is contained in:
simeng-li 2024-10-15 10:47:25 +08:00 committed by GitHub
parent 436a1840a8
commit 187847b093
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 290 additions and 10 deletions

View file

@ -27,3 +27,17 @@ export const wellConfiguredSsoConnector = {
syncProfile: true, syncProfile: true,
createdAt: Date.now(), createdAt: Date.now(),
} satisfies SsoConnector; } satisfies SsoConnector;
export const mockSamlSsoConnector = {
id: 'mock-saml-sso-connector',
tenantId: 'mock-tenant',
providerName: SsoProviderName.SAML,
connectorName: 'mock-connector-name',
config: {
metadata: 'mock-metadata',
},
domains: ['foo.com'],
branding: {},
syncProfile: true,
createdAt: Date.now(),
} satisfies SsoConnector;

View file

@ -1,4 +1,4 @@
import { Prompt, QueryKey, withReservedScopes } from '@logto/js'; import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
import { ApplicationType, type SsoConnectorIdpInitiatedAuthConfig } from '@logto/schemas'; import { ApplicationType, type SsoConnectorIdpInitiatedAuthConfig } from '@logto/schemas';
import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js';
@ -204,7 +204,7 @@ describe('SsoConnectorLibrary', () => {
for (const [key, value] of Object.entries(defaultQueryParameters)) { for (const [key, value] of Object.entries(defaultQueryParameters)) {
expect(parameters.get(key)).toBe(value); expect(parameters.get(key)).toBe(value);
} }
expect(parameters.get(QueryKey.Scope)).toBe(withReservedScopes()); expect(parameters.get(QueryKey.Scope)).toBe(`${ReservedScope.OpenId} ${UserScope.Profile}`);
}); });
it('should use the provided redirectUri', async () => { it('should use the provided redirectUri', async () => {
@ -233,7 +233,7 @@ describe('SsoConnectorLibrary', () => {
}); });
it('should append extra scopes to the query parameters', async () => { it('should append extra scopes to the query parameters', async () => {
const scopes = ['scope1', 'scope2']; const scopes = ['organization', 'email', 'profile'];
const url = await getIdpInitiatedSamlSsoSignInUrl(issuer, { const url = await getIdpInitiatedSamlSsoSignInUrl(issuer, {
...authConfig, ...authConfig,
@ -243,7 +243,9 @@ describe('SsoConnectorLibrary', () => {
}); });
const parameters = new URLSearchParams(url.search); const parameters = new URLSearchParams(url.search);
expect(parameters.get(QueryKey.Scope)).toBe(withReservedScopes(scopes)); expect(parameters.get(QueryKey.Scope)).toBe(
`${ReservedScope.OpenId} ${UserScope.Profile} organization email`
);
}); });
it('should be able to append extra query parameters', async () => { it('should be able to append extra query parameters', async () => {

View file

@ -1,4 +1,4 @@
import { type DirectSignInOptions, Prompt, QueryKey, withReservedScopes } from '@logto/js'; import { type DirectSignInOptions, Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
import { import {
ApplicationType, ApplicationType,
type SsoSamlAssertionContent, type SsoSamlAssertionContent,
@ -7,7 +7,7 @@ import {
type SsoConnectorIdpInitiatedAuthConfig, type SsoConnectorIdpInitiatedAuthConfig,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { assert, trySafe } from '@silverhand/essentials'; import { assert, deduplicate, trySafe } from '@silverhand/essentials';
import { defaultIdPInitiatedSamlSsoSessionTtl } from '#src/constants/index.js'; import { defaultIdPInitiatedSamlSsoSessionTtl } from '#src/constants/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.js';
@ -141,6 +141,9 @@ export const createSsoConnectorLibrary = (queries: Queries) => {
* *
* @remarks * @remarks
* For IdP-initiated SAML SSO flow use only. Generate the sign-in URL for the user to sign in. * For IdP-initiated SAML SSO flow use only. Generate the sign-in URL for the user to sign in.
* Default scopes: openid, profile
* Default prompt: login
* Default response type: code
* *
* @param issuer The oidc issuer endpoint of the current tenant. * @param issuer The oidc issuer endpoint of the current tenant.
* @param authConfig The IdP-initiated SAML SSO authentication configuration. * @param authConfig The IdP-initiated SAML SSO authentication configuration.
@ -183,7 +186,11 @@ export const createSsoConnectorLibrary = (queries: Queries) => {
...extraParams, ...extraParams,
}); });
queryParameters.append(QueryKey.Scope, withReservedScopes(scope?.split(' ') ?? [])); queryParameters.append(
QueryKey.Scope,
// For security reasons, DO NOT include the offline_access scope for IdP-initiated SAML SSO by default
deduplicate([ReservedScope.OpenId, UserScope.Profile, ...(scope?.split(' ') ?? [])]).join(' ')
);
return new URL(`${issuer}/auth?${queryParameters.toString()}`); return new URL(`${issuer}/auth?${queryParameters.toString()}`);
}; };

View file

@ -6,6 +6,7 @@ import {
type SsoConnectorKeys, type SsoConnectorKeys,
SsoConnectors, SsoConnectors,
IdpInitiatedSamlSsoSessions, IdpInitiatedSamlSsoSessions,
type IdpInitiatedSamlSsoSession,
} from '@logto/schemas'; } from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik'; import { sql, type CommonQueryMethods } from '@silverhand/slonik';
@ -92,4 +93,12 @@ export default class SsoConnectorQueries extends SchemaQueries<
throw new DeletionError(SsoConnectorIdpInitiatedAuthConfigs.table); throw new DeletionError(SsoConnectorIdpInitiatedAuthConfigs.table);
} }
} }
async findIdpInitiatedSamlSsoSessionById(id: string) {
const { table, fields } = convertToIdentifiers(IdpInitiatedSamlSsoSessions);
return this.pool.maybeOne<IdpInitiatedSamlSsoSession>(sql`
SELECT * FROM ${table}
WHERE ${fields.id}=${id}
`);
}
} }

View file

@ -1,7 +1,14 @@
/* eslint-disable max-lines */
import { createMockUtils } from '@logto/shared/esm'; import { createMockUtils } from '@logto/shared/esm';
import type Provider from 'oidc-provider'; import type Provider from 'oidc-provider';
import Sinon from 'sinon';
import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js'; import {
mockSsoConnector,
wellConfiguredSsoConnector,
mockSamlSsoConnector,
} from '#src/__mocks__/sso.js';
import { EnvSet } from '#src/env-set/index.js';
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/middleware/koa-interaction-details.js'; import { type WithInteractionDetailsContext } from '#src/middleware/koa-interaction-details.js';
@ -13,6 +20,8 @@ import { createMockProvider } from '#src/test-utils/oidc-provider.js';
import { MockTenant } from '#src/test-utils/tenant.js'; import { MockTenant } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import { idpInitiatedSamlSsoSessionCookieName } from '../../../constants/index.js';
import { SamlSsoConnector } from '../../../sso/SamlSsoConnector/index.js';
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;
@ -30,20 +39,31 @@ const insertUserMock = jest.fn().mockResolvedValue([{ id: 'foo' }, { organizatio
const generateUserIdMock = jest.fn().mockResolvedValue('foo'); const generateUserIdMock = jest.fn().mockResolvedValue('foo');
const getAvailableSsoConnectorsMock = jest.fn(); const getAvailableSsoConnectorsMock = jest.fn();
const findIdpInitiatedSamlSsoSessionMock = jest.fn();
const deleteIdpInitiatedSamlSsoSessionMock = jest.fn();
class MockOidcSsoConnector extends OidcSsoConnector { class MockOidcSsoConnector extends OidcSsoConnector {
override getAuthorizationUrl = getAuthorizationUrlMock; override getAuthorizationUrl = getAuthorizationUrlMock;
override getIssuer = getIssuerMock; override getIssuer = getIssuerMock;
override getUserInfo = getUserInfoMock; override getUserInfo = getUserInfoMock;
} }
class MockSamlSsoConnector extends SamlSsoConnector {
override getAuthorizationUrl = getAuthorizationUrlMock;
override getIssuer = getIssuerMock;
override getUserInfo = getUserInfoMock;
}
mockEsm('./interaction.js', () => ({ mockEsm('./interaction.js', () => ({
storeInteractionResult: jest.fn(), storeInteractionResult: jest.fn(),
})); }));
const { const {
assignSingleSignOnSessionResult: assignSingleSignOnSessionResultMock,
getSingleSignOnSessionResult: getSingleSignOnSessionResultMock, getSingleSignOnSessionResult: getSingleSignOnSessionResultMock,
assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock, assignSingleSignOnAuthenticationResult: assignSingleSignOnAuthenticationResultMock,
} = await mockEsmWithActual('./single-sign-on-session.js', () => ({ } = await mockEsmWithActual('./single-sign-on-session.js', () => ({
assignSingleSignOnSessionResult: jest.fn(),
getSingleSignOnSessionResult: jest.fn(), getSingleSignOnSessionResult: jest.fn(),
assignSingleSignOnAuthenticationResult: jest.fn(), assignSingleSignOnAuthenticationResult: jest.fn(),
})); }));
@ -51,6 +71,11 @@ const {
jest jest
.spyOn(ssoConnectorFactories.OIDC, 'constructor') .spyOn(ssoConnectorFactories.OIDC, 'constructor')
.mockImplementation((data: SingleSignOnConnectorData) => new MockOidcSsoConnector(data)); .mockImplementation((data: SingleSignOnConnectorData) => new MockOidcSsoConnector(data));
jest
.spyOn(ssoConnectorFactories.SAML, 'constructor')
.mockImplementation(
(data: SingleSignOnConnectorData) => new MockSamlSsoConnector(data, 'tenantId')
);
const { const {
getSsoAuthorizationUrl, getSsoAuthorizationUrl,
@ -83,6 +108,10 @@ describe('Single sign on util methods tests', () => {
updateUserById: updateUserMock, updateUserById: updateUserMock,
findUserByEmail: findUserByEmailMock, findUserByEmail: findUserByEmailMock,
}, },
ssoConnectors: {
findIdpInitiatedSamlSsoSessionById: findIdpInitiatedSamlSsoSessionMock,
deleteIdpInitiatedSamlSsoSessionById: deleteIdpInitiatedSamlSsoSessionMock,
},
}, },
undefined, undefined,
{ {
@ -327,4 +356,171 @@ describe('Single sign on util methods tests', () => {
}); });
}); });
}); });
describe('getSsoAuthorizationUrl tests with idp initiated sso session', () => {
const stub = Sinon.stub(EnvSet, 'values').value({
...EnvSet.values,
isDevFeaturesEnabled: true,
});
const payload = {
state: 'state',
redirectUri: 'https://example.com',
};
const samlSsoSessionId = 'samlSsoSessionId';
const samlAuthorizationUrl = 'https://saml-connector/callback';
const mockContextWithIdpInitiatedSsoSession = {
...mockContext,
...createContextWithRouteParameters({
cookies: {
[idpInitiatedSamlSsoSessionCookieName]: samlSsoSessionId,
},
}),
};
afterAll(() => {
stub.restore();
});
beforeEach(() => {
getAuthorizationUrlMock.mockResolvedValueOnce(samlAuthorizationUrl);
});
it('should not check idp initiated sso session if the connector is not SAML', async () => {
await expect(
getSsoAuthorizationUrl(
mockContextWithIdpInitiatedSsoSession,
tenant,
wellConfiguredSsoConnector,
payload
)
).resolves.toBe(samlAuthorizationUrl);
expect(findIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled();
});
it('should not check idp initiated sso session if the session cookie is not found', async () => {
await expect(
getSsoAuthorizationUrl(mockContext, tenant, mockSamlSsoConnector, payload)
).resolves.toBe(samlAuthorizationUrl);
expect(findIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled();
expect(deleteIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled();
});
it('should redirect to the connector authorization uri if the idp initiated sso session is not found', async () => {
findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce(null);
await expect(
getSsoAuthorizationUrl(
mockContextWithIdpInitiatedSsoSession,
tenant,
mockSamlSsoConnector,
payload
)
).resolves.toBe(samlAuthorizationUrl);
expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId);
});
it('should redirect to the connector authorization uri if the idp initiated sso session connectorId mismatch', async () => {
findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce({
id: samlSsoSessionId,
connectorId: 'foo',
});
await expect(
getSsoAuthorizationUrl(
mockContextWithIdpInitiatedSsoSession,
tenant,
mockSamlSsoConnector,
payload
)
).resolves.toBe(samlAuthorizationUrl);
expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId);
expect(deleteIdpInitiatedSamlSsoSessionMock).not.toHaveBeenCalled();
});
it('should redirect to the connector authorization uri if the idp initiated sso session is expired', async () => {
findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce({
id: samlSsoSessionId,
connectorId: mockSamlSsoConnector.id,
expiresAt: Date.now() - 1000 * 60 * 11,
});
await expect(
getSsoAuthorizationUrl(
mockContextWithIdpInitiatedSsoSession,
tenant,
mockSamlSsoConnector,
payload
)
).resolves.toBe(samlAuthorizationUrl);
expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId);
// Should delete the idp initiated sso session
expect(mockContextWithIdpInitiatedSsoSession.cookies.set).toBeCalledWith(
idpInitiatedSamlSsoSessionCookieName,
'',
{
httpOnly: true,
expires: new Date(0),
}
);
expect(deleteIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId);
});
it('should assign the user info and redirect to the SSO callback uri if the idp initiated sso session is valid', async () => {
findIdpInitiatedSamlSsoSessionMock.mockResolvedValueOnce({
id: samlSsoSessionId,
connectorId: mockSamlSsoConnector.id,
expiresAt: Date.now() + 1000 * 60 * 10,
assertionContent: {
nameID: mockSsoUserInfo.id,
attributes: {
email: mockSsoUserInfo.email,
name: mockSsoUserInfo.name,
avatar: mockSsoUserInfo.avatar,
},
},
});
await expect(
getSsoAuthorizationUrl(
mockContextWithIdpInitiatedSsoSession,
tenant,
mockSamlSsoConnector,
payload
)
).resolves.toBe(`${payload.redirectUri}/?state=${payload.state}`);
expect(findIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId);
expect(assignSingleSignOnSessionResultMock).toBeCalledWith(
mockContextWithIdpInitiatedSsoSession,
mockProvider,
{
connectorId: mockSamlSsoConnector.id,
...payload,
userInfo: mockSsoUserInfo,
}
);
// Should delete the idp initiated sso session
expect(mockContextWithIdpInitiatedSsoSession.cookies.set).toBeCalledWith(
idpInitiatedSamlSsoSessionCookieName,
'',
{
httpOnly: true,
expires: new Date(0),
}
);
expect(deleteIdpInitiatedSamlSsoSessionMock).toBeCalledWith(samlSsoSessionId);
});
});
}); });
/* eslint-enable max-lines */

View file

@ -8,11 +8,14 @@ import {
type UserSsoIdentity, type UserSsoIdentity,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { conditional } from '@silverhand/essentials'; import { conditional, trySafe } from '@silverhand/essentials';
import { z } from 'zod'; import { z } from 'zod';
import { idpInitiatedSamlSsoSessionCookieName } from '#src/constants/index.js';
import { EnvSet } from '#src/env-set/index.js';
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 SamlConnector from '#src/sso/SamlConnector/index.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';
@ -34,7 +37,7 @@ type AuthorizationUrlPayload = z.infer<typeof authorizationUrlPayloadGuard>;
export const getSsoAuthorizationUrl = async ( export const getSsoAuthorizationUrl = async (
ctx: WithLogContext, ctx: WithLogContext,
{ provider, id: tenantId }: TenantContext, { provider, id: tenantId, queries }: TenantContext,
connectorData: SupportedSsoConnector, connectorData: SupportedSsoConnector,
payload: AuthorizationUrlPayload payload: AuthorizationUrlPayload
): Promise<string> => { ): Promise<string> => {
@ -43,6 +46,7 @@ export const getSsoAuthorizationUrl = async (
const { createLog } = ctx; const { createLog } = ctx;
const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Create`); const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Create`);
log.append({ log.append({
connectorId, connectorId,
payload, payload,
@ -59,6 +63,53 @@ export const getSsoAuthorizationUrl = async (
const { jti } = await provider.interactionDetails(ctx.req, ctx.res); const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
if (
// TODO: Remove this check when IdP-initiated SSO is fully supported
EnvSet.values.isDevFeaturesEnabled &&
connectorInstance instanceof SamlConnector
) {
// Check if a IdP-initiated SSO session exists
const sessionId = ctx.cookies.get(idpInitiatedSamlSsoSessionCookieName);
const idpInitiatedSamlSsoSession =
sessionId && (await queries.ssoConnectors.findIdpInitiatedSamlSsoSessionById(sessionId));
// Consume the session if it exists and the connector matches
if (idpInitiatedSamlSsoSession && idpInitiatedSamlSsoSession.connectorId === connectorId) {
log.append({ idpInitiatedSamlSsoSession });
// Clear the session cookie
ctx.cookies.set(idpInitiatedSamlSsoSessionCookieName, '', {
httpOnly: true,
expires: new Date(0),
});
// Safely clear the session record
void trySafe(async () => {
await queries.ssoConnectors.deleteIdpInitiatedSamlSsoSessionById(sessionId);
});
const { expiresAt, assertionContent } = idpInitiatedSamlSsoSession;
// Validate the session expiry
// Directly assign the SAML assertion result to the interaction and redirect to the SSO callback URL
if (expiresAt > Date.now()) {
const userInfo = connectorInstance.getUserInfoFromSamlAssertion(assertionContent);
const { redirectUri, state } = payload;
await assignSingleSignOnSessionResult(ctx, provider, {
redirectUri,
state,
userInfo,
connectorId,
});
// Redirect to the callback URL directly if the session is valid
const url = new URL(redirectUri);
url.searchParams.append('state', state);
return url.toString();
}
}
}
return await connectorInstance.getAuthorizationUrl( return await connectorInstance.getAuthorizationUrl(
{ jti, ...payload, connectorId }, { jti, ...payload, connectorId },
async (connectorSession: SingleSignOnConnectorSession) => async (connectorSession: SingleSignOnConnectorSession) =>

View file

@ -99,6 +99,7 @@ export const createContextWithRouteParameters = (
set: ctx.set, set: ctx.set,
path: ctx.path, path: ctx.path,
URL: ctx.URL, URL: ctx.URL,
cookies: ctx.cookies,
params: {}, params: {},
headers: {}, headers: {},
router: new Router(), router: new Router(),