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:
parent
436a1840a8
commit
187847b093
7 changed files with 290 additions and 10 deletions
|
@ -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;
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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()}`);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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}
|
||||||
|
`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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) =>
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
Loading…
Reference in a new issue