mirror of
https://github.com/logto-io/logto.git
synced 2025-03-17 22:31:28 -05:00
refactor(core): build SamlApplication class (#6909)
This commit is contained in:
parent
ced360b7a4
commit
b0fb35f97e
7 changed files with 774 additions and 643 deletions
|
@ -0,0 +1,170 @@
|
|||
import nock from 'nock';
|
||||
|
||||
import { SamlApplication } from './index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
// Create a test class that exposes protected methods
|
||||
class TestSamlApplication extends SamlApplication {
|
||||
public exposedCreateSamlTemplateCallback = this.createSamlTemplateCallback;
|
||||
public exposedExchangeAuthorizationCode = this.exchangeAuthorizationCode;
|
||||
public exposedGetUserInfo = this.getUserInfo;
|
||||
public exposedFetchOidcConfig = this.fetchOidcConfig;
|
||||
}
|
||||
|
||||
describe('SamlApplication', () => {
|
||||
const mockDetails = {
|
||||
entityId: 'sp-entity-id',
|
||||
acsUrl: {
|
||||
binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
|
||||
url: 'https://sp.example.com/acs',
|
||||
},
|
||||
oidcClientMetadata: {
|
||||
redirectUris: ['https://app.example.com/callback'],
|
||||
},
|
||||
privateKey: 'mock-private-key',
|
||||
certificate: 'mock-certificate',
|
||||
secret: 'mock-secret',
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
sub: 'user123',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
const mockTenantId = 'tenant-id';
|
||||
const mockSamlApplicationId = 'saml-app-id';
|
||||
const mockIssuer = 'https://issuer.example.com';
|
||||
|
||||
const mockEndpoint = 'https://auth.example.com';
|
||||
const mockAuthEndpoint = `${mockEndpoint}/auth`;
|
||||
const mockTokenEndpoint = `${mockEndpoint}/token`;
|
||||
const mockUserinfoEndpoint = `${mockEndpoint}/userinfo`;
|
||||
const mockJwks = `${mockEndpoint}/jwks`;
|
||||
|
||||
// eslint-disable-next-line @silverhand/fp/no-let
|
||||
let samlApp: TestSamlApplication;
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||
samlApp = new TestSamlApplication(mockDetails, mockSamlApplicationId, mockIssuer, mockTenantId);
|
||||
|
||||
nock(mockIssuer).get('/.well-known/openid-configuration').reply(200, {
|
||||
token_endpoint: mockTokenEndpoint,
|
||||
authorization_endpoint: mockAuthEndpoint,
|
||||
userinfo_endpoint: mockUserinfoEndpoint,
|
||||
jwks_uri: mockJwks,
|
||||
issuer: mockIssuer,
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSamlTemplateCallback', () => {
|
||||
it('should create SAML template callback with correct values', () => {
|
||||
const result = samlApp.exposedCreateSamlTemplateCallback(mockUser)(
|
||||
'ID:NameID:attrEmail:attrName'
|
||||
);
|
||||
const generatedId = result.id.replace('ID_', '');
|
||||
|
||||
expect(result.id).toBe('ID_' + generatedId);
|
||||
expect(typeof result.context).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeAuthorizationCode', () => {
|
||||
const mockCode = 'auth-code';
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error -- for testing
|
||||
jest.spyOn(samlApp, 'exposedFetchOidcConfig').mockResolvedValue({
|
||||
tokenEndpoint: mockTokenEndpoint,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should exchange authorization code successfully', async () => {
|
||||
const mockResponse = {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'openid profile email',
|
||||
id_token: 'mock.id.token',
|
||||
};
|
||||
|
||||
const expectedAuthHeader = `Basic ${Buffer.from(
|
||||
`${mockSamlApplicationId}:${mockDetails.secret}`,
|
||||
'utf8'
|
||||
).toString('base64')}`;
|
||||
|
||||
const redirectUri = mockDetails.oidcClientMetadata.redirectUris[0]!;
|
||||
|
||||
nock(mockEndpoint)
|
||||
.post(
|
||||
'/token',
|
||||
`grant_type=authorization_code&code=${mockCode}&client_id=${mockSamlApplicationId}&redirect_uri=${encodeURIComponent(
|
||||
redirectUri
|
||||
)}`
|
||||
)
|
||||
.matchHeader('Authorization', expectedAuthHeader)
|
||||
.matchHeader('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.reply(200, mockResponse);
|
||||
|
||||
const result = await samlApp.exposedExchangeAuthorizationCode({ code: mockCode });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accessToken: mockResponse.access_token,
|
||||
tokenType: mockResponse.token_type,
|
||||
expiresIn: mockResponse.expires_in,
|
||||
scope: mockResponse.scope,
|
||||
idToken: mockResponse.id_token,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error if token exchange fails', async () => {
|
||||
nock(mockEndpoint).post('/token').reply(400, { error: 'invalid_grant' });
|
||||
|
||||
await expect(samlApp.exposedExchangeAuthorizationCode({ code: mockCode })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
const mockAccessToken = 'access-token';
|
||||
|
||||
beforeEach(() => {
|
||||
// @ts-expect-error -- for testing
|
||||
jest.spyOn(samlApp, 'exposedFetchOidcConfig').mockResolvedValue({
|
||||
userinfoEndpoint: mockUserinfoEndpoint,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should get user info successfully', async () => {
|
||||
const scope = nock(mockEndpoint)
|
||||
.get('/userinfo')
|
||||
.matchHeader('Authorization', `Bearer ${mockAccessToken}`)
|
||||
.reply(200, JSON.stringify(mockUser));
|
||||
|
||||
const result = await samlApp.exposedGetUserInfo({
|
||||
accessToken: mockAccessToken,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(scope.isDone()).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw error if userinfo request fails', async () => {
|
||||
nock(mockEndpoint).get('/userinfo').reply(400, { error: 'invalid_token' });
|
||||
|
||||
await expect(samlApp.exposedGetUserInfo({ accessToken: mockAccessToken })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
425
packages/core/src/saml-applications/SamlApplication/index.ts
Normal file
425
packages/core/src/saml-applications/SamlApplication/index.ts
Normal file
|
@ -0,0 +1,425 @@
|
|||
/* eslint-disable max-lines */
|
||||
// TODO: refactor this file to reduce LOC
|
||||
import { parseJson } from '@logto/connector-kit';
|
||||
import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
|
||||
import { type SamlAcsUrl, BindingType } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { tryThat, appendPath, deduplicate } from '@silverhand/essentials';
|
||||
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
|
||||
import { XMLValidator } from 'fast-xml-parser';
|
||||
import saml from 'samlify';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import {
|
||||
fetchOidcConfigRaw,
|
||||
getRawUserInfoResponse,
|
||||
handleTokenExchange,
|
||||
} from '#src/sso/OidcConnector/utils.js';
|
||||
import {
|
||||
idTokenProfileStandardClaimsGuard,
|
||||
type OidcConfigResponse,
|
||||
type IdTokenProfileStandardClaims,
|
||||
} from '#src/sso/types/oidc.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import {
|
||||
samlLogInResponseTemplate,
|
||||
samlAttributeNameFormatBasic,
|
||||
samlValueXmlnsXsi,
|
||||
} from '../libraries/consts.js';
|
||||
import { buildSingleSignOnUrl, buildSamlIdentityProviderEntityId } from '../libraries/utils.js';
|
||||
import { type SamlApplicationDetails } from '../queries/index.js';
|
||||
|
||||
import { buildSamlAssertionNameId } from './utils.js';
|
||||
|
||||
type ValidSamlApplicationDetails = {
|
||||
secret: string;
|
||||
entityId: string;
|
||||
acsUrl: SamlAcsUrl;
|
||||
redirectUri: string;
|
||||
privateKey: string;
|
||||
certificate: string;
|
||||
};
|
||||
|
||||
// Used to check whether xml content is valid in format.
|
||||
saml.setSchemaValidator({
|
||||
validate: async (xmlContent: string) => {
|
||||
try {
|
||||
XMLValidator.validate(xmlContent, {
|
||||
allowBooleanAttributes: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const validateSamlApplicationDetails = (
|
||||
details: SamlApplicationDetails
|
||||
): ValidSamlApplicationDetails => {
|
||||
const {
|
||||
entityId,
|
||||
acsUrl,
|
||||
oidcClientMetadata: { redirectUris },
|
||||
privateKey,
|
||||
certificate,
|
||||
secret,
|
||||
} = details;
|
||||
|
||||
assertThat(acsUrl, 'application.saml.acs_url_required');
|
||||
assertThat(entityId, 'application.saml.entity_id_required');
|
||||
assertThat(redirectUris[0], 'oidc.invalid_redirect_uri');
|
||||
|
||||
assertThat(privateKey, 'application.saml.private_key_required');
|
||||
assertThat(certificate, 'application.saml.certificate_required');
|
||||
|
||||
return {
|
||||
secret,
|
||||
entityId,
|
||||
acsUrl,
|
||||
redirectUri: redirectUris[0],
|
||||
privateKey,
|
||||
certificate,
|
||||
};
|
||||
};
|
||||
|
||||
const buildLoginResponseTemplate = () => {
|
||||
return {
|
||||
context: samlLogInResponseTemplate,
|
||||
attributes: [
|
||||
{
|
||||
name: 'email',
|
||||
valueTag: 'email',
|
||||
nameFormat: samlAttributeNameFormatBasic,
|
||||
valueXsiType: samlValueXmlnsXsi.string,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
valueTag: 'name',
|
||||
nameFormat: samlAttributeNameFormatBasic,
|
||||
valueXsiType: samlValueXmlnsXsi.string,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const buildSamlIdentityProvider = ({
|
||||
entityId,
|
||||
certificate,
|
||||
singleSignOnUrl,
|
||||
privateKey,
|
||||
}: {
|
||||
entityId: string;
|
||||
certificate: string;
|
||||
singleSignOnUrl: string;
|
||||
privateKey: string;
|
||||
}): saml.IdentityProviderInstance => {
|
||||
// eslint-disable-next-line new-cap
|
||||
return saml.IdentityProvider({
|
||||
entityID: entityId,
|
||||
signingCert: certificate,
|
||||
singleSignOnService: [
|
||||
{
|
||||
Location: singleSignOnUrl,
|
||||
Binding: BindingType.Redirect,
|
||||
},
|
||||
{
|
||||
Location: singleSignOnUrl,
|
||||
Binding: BindingType.Post,
|
||||
},
|
||||
],
|
||||
privateKey,
|
||||
isAssertionEncrypted: false,
|
||||
loginResponseTemplate: buildLoginResponseTemplate(),
|
||||
nameIDFormat: [
|
||||
saml.Constants.namespace.format.emailAddress,
|
||||
saml.Constants.namespace.format.persistent,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const buildSamlServiceProvider = ({
|
||||
entityId,
|
||||
acsUrl,
|
||||
certificate,
|
||||
isWantAuthnRequestsSigned,
|
||||
}: {
|
||||
entityId: string;
|
||||
acsUrl: SamlAcsUrl;
|
||||
certificate: string;
|
||||
isWantAuthnRequestsSigned: boolean;
|
||||
}): saml.ServiceProviderInstance => {
|
||||
// eslint-disable-next-line new-cap
|
||||
return saml.ServiceProvider({
|
||||
entityID: entityId,
|
||||
assertionConsumerService: [
|
||||
{
|
||||
Binding: acsUrl.binding,
|
||||
Location: acsUrl.url,
|
||||
},
|
||||
],
|
||||
signingCert: certificate,
|
||||
authnRequestsSigned: isWantAuthnRequestsSigned,
|
||||
allowCreate: false,
|
||||
});
|
||||
};
|
||||
|
||||
export class SamlApplication {
|
||||
public details: ValidSamlApplicationDetails;
|
||||
|
||||
protected tenantEndpoint: URL;
|
||||
protected oidcConfig?: CamelCaseKeys<OidcConfigResponse>;
|
||||
|
||||
private _idp?: saml.IdentityProviderInstance;
|
||||
private _sp?: saml.ServiceProviderInstance;
|
||||
|
||||
constructor(
|
||||
details: SamlApplicationDetails,
|
||||
protected samlApplicationId: string,
|
||||
protected issuer: string,
|
||||
tenantId: string
|
||||
) {
|
||||
this.details = validateSamlApplicationDetails(details);
|
||||
this.tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
|
||||
}
|
||||
|
||||
public get idp(): saml.IdentityProviderInstance {
|
||||
this._idp ||= buildSamlIdentityProvider(this.buildIdpConfig());
|
||||
return this._idp;
|
||||
}
|
||||
|
||||
public get sp(): saml.ServiceProviderInstance {
|
||||
this._sp ||= buildSamlServiceProvider({
|
||||
...this.buildSpConfig(),
|
||||
certificate: this.details.certificate,
|
||||
isWantAuthnRequestsSigned: this.idp.entityMeta.isWantAuthnRequestsSigned(),
|
||||
});
|
||||
return this._sp;
|
||||
}
|
||||
|
||||
public get idPMetadata() {
|
||||
return this.idp.getMetadata();
|
||||
}
|
||||
|
||||
public get idPCertificate() {
|
||||
return this.details.certificate;
|
||||
}
|
||||
|
||||
public get samlAppCallbackUrl() {
|
||||
return appendPath(
|
||||
this.tenantEndpoint,
|
||||
`api/saml-applications/${this.samlApplicationId}/callback`
|
||||
).toString();
|
||||
}
|
||||
|
||||
public async parseLoginRequest(
|
||||
binding: 'post' | 'redirect',
|
||||
loginRequest: Parameters<typeof saml.IdentityProviderInstance.prototype.parseLoginRequest>[2]
|
||||
) {
|
||||
return this.idp.parseLoginRequest(this.sp, binding, loginRequest);
|
||||
}
|
||||
|
||||
public createSamlResponse = async (
|
||||
userInfo: IdTokenProfileStandardClaims
|
||||
): Promise<{ context: string; entityEndpoint: string }> => {
|
||||
// TODO: fix binding method
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { context, entityEndpoint } = await this.idp.createLoginResponse(
|
||||
this.sp,
|
||||
// @ts-expect-error --fix request object later
|
||||
null,
|
||||
'post',
|
||||
userInfo,
|
||||
this.createSamlTemplateCallback(userInfo)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
return { context, entityEndpoint };
|
||||
};
|
||||
|
||||
// Helper functions for SAML callback
|
||||
public handleOidcCallbackAndGetUserInfo = async ({ code }: { code: string }) => {
|
||||
// Exchange authorization code for tokens
|
||||
const { accessToken } = await this.exchangeAuthorizationCode({
|
||||
code,
|
||||
});
|
||||
|
||||
assertThat(accessToken, new RequestError('oidc.access_denied'));
|
||||
|
||||
// Get user info using access token
|
||||
return this.getUserInfo({ accessToken });
|
||||
};
|
||||
|
||||
public getSignInUrl = async ({ scope, state }: { scope?: string; state?: string }) => {
|
||||
const { authorizationEndpoint } = await this.fetchOidcConfig();
|
||||
|
||||
const queryParameters = new URLSearchParams({
|
||||
[QueryKey.ClientId]: this.samlApplicationId,
|
||||
[QueryKey.RedirectUri]: this.details.redirectUri,
|
||||
[QueryKey.ResponseType]: 'code',
|
||||
[QueryKey.Prompt]: Prompt.Login,
|
||||
});
|
||||
|
||||
// TODO: get value of `scope` parameters according to setup in attribute mapping.
|
||||
queryParameters.append(
|
||||
QueryKey.Scope,
|
||||
// For security reasons, DO NOT include the offline_access scope by default.
|
||||
deduplicate([
|
||||
ReservedScope.OpenId,
|
||||
UserScope.Profile,
|
||||
UserScope.Roles,
|
||||
UserScope.Organizations,
|
||||
UserScope.OrganizationRoles,
|
||||
UserScope.CustomData,
|
||||
UserScope.Identities,
|
||||
...(scope?.split(' ') ?? []),
|
||||
]).join(' ')
|
||||
);
|
||||
|
||||
if (state) {
|
||||
queryParameters.append(QueryKey.State, state);
|
||||
}
|
||||
|
||||
return new URL(`${authorizationEndpoint}?${queryParameters.toString()}`);
|
||||
};
|
||||
|
||||
protected getOidcConfig = async (): Promise<CamelCaseKeys<OidcConfigResponse>> => {
|
||||
const oidcConfig = await tryThat(
|
||||
async () => fetchOidcConfigRaw(this.issuer),
|
||||
(error) => {
|
||||
if (error instanceof ZodError) {
|
||||
throw new RequestError({
|
||||
code: 'oidc.invalid_request',
|
||||
message: error.message,
|
||||
error: error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
return oidcConfig;
|
||||
};
|
||||
|
||||
protected exchangeAuthorizationCode = async ({ code }: { code: string }) => {
|
||||
const { tokenEndpoint } = await this.fetchOidcConfig();
|
||||
const result = await handleTokenExchange(tokenEndpoint, {
|
||||
code,
|
||||
clientId: this.samlApplicationId,
|
||||
clientSecret: this.details.secret,
|
||||
redirectUri: this.details.redirectUri,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new RequestError({
|
||||
code: 'oidc.invalid_token',
|
||||
message: 'Invalid token response',
|
||||
});
|
||||
}
|
||||
|
||||
return camelcaseKeys(result.data);
|
||||
};
|
||||
|
||||
protected async fetchOidcConfig() {
|
||||
this.oidcConfig ||= await this.getOidcConfig();
|
||||
|
||||
return this.oidcConfig;
|
||||
}
|
||||
|
||||
protected getUserInfo = async ({
|
||||
accessToken,
|
||||
}: {
|
||||
accessToken: string;
|
||||
}): Promise<IdTokenProfileStandardClaims & Record<string, unknown>> => {
|
||||
const { userinfoEndpoint } = await this.fetchOidcConfig();
|
||||
const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint);
|
||||
const result = idTokenProfileStandardClaimsGuard
|
||||
.catchall(z.unknown())
|
||||
.safeParse(parseJson(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw new RequestError({
|
||||
code: 'oidc.invalid_request',
|
||||
message: 'Invalid user info response',
|
||||
details: result.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
protected createSamlTemplateCallback =
|
||||
(user: IdTokenProfileStandardClaims) => (template: string) => {
|
||||
const assertionConsumerServiceUrl = this.sp.entityMeta.getAssertionConsumerService(
|
||||
saml.Constants.wording.binding.post
|
||||
);
|
||||
|
||||
const { nameIDFormat } = this.idp.entitySetting;
|
||||
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);
|
||||
|
||||
const id = `ID_${generateStandardId()}`;
|
||||
const now = new Date();
|
||||
const expireAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes later
|
||||
|
||||
const tagValues = {
|
||||
ID: id,
|
||||
AssertionID: `ID_${generateStandardId()}`,
|
||||
Destination: assertionConsumerServiceUrl,
|
||||
Audience: this.sp.entityMeta.getEntityID(),
|
||||
EntityID: this.sp.entityMeta.getEntityID(),
|
||||
SubjectRecipient: assertionConsumerServiceUrl,
|
||||
Issuer: this.idp.entityMeta.getEntityID(),
|
||||
IssueInstant: now.toISOString(),
|
||||
AssertionConsumerServiceURL: assertionConsumerServiceUrl,
|
||||
StatusCode: saml.Constants.StatusCode.Success,
|
||||
ConditionsNotBefore: now.toISOString(),
|
||||
ConditionsNotOnOrAfter: expireAt.toISOString(),
|
||||
SubjectConfirmationDataNotOnOrAfter: expireAt.toISOString(),
|
||||
NameIDFormat,
|
||||
NameID,
|
||||
// TODO: should get the request ID from the input parameters, pending https://github.com/logto-io/logto/pull/6881.
|
||||
InResponseTo: 'null',
|
||||
/**
|
||||
* User attributes for SAML response
|
||||
*
|
||||
* @todo Support custom attribute mapping
|
||||
* @see {@link https://github.com/tngan/samlify/blob/master/src/libsaml.ts#L275-L300|samlify implementation}
|
||||
*
|
||||
* @remarks
|
||||
* By examining the code provided in the link above, we can define all the attributes supported by the attribute mapping here. Only the attributes defined in the `loginResponseTemplate.attributes` added when creating the IdP instance will appear in the SAML response.
|
||||
*/
|
||||
attrSub: user.sub,
|
||||
attrEmail: user.email,
|
||||
attrName: user.name,
|
||||
};
|
||||
|
||||
const context = saml.SamlLib.replaceTagsByValue(template, tagValues);
|
||||
|
||||
return {
|
||||
id,
|
||||
context,
|
||||
};
|
||||
};
|
||||
|
||||
private buildIdpConfig() {
|
||||
return {
|
||||
entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId),
|
||||
privateKey: this.details.privateKey,
|
||||
certificate: this.details.certificate,
|
||||
singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSpConfig() {
|
||||
return {
|
||||
entityId: this.details.entityId,
|
||||
acsUrl: this.details.acsUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
/* eslint-enable max-lines */
|
|
@ -0,0 +1,91 @@
|
|||
import { generateAutoSubmitForm, buildSamlAssertionNameId } from './utils.js';
|
||||
|
||||
describe('buildSamlAssertionNameId', () => {
|
||||
it('should use email when email_verified is true', () => {
|
||||
const user = {
|
||||
sub: 'user123',
|
||||
email: 'user@example.com',
|
||||
email_verified: true,
|
||||
};
|
||||
|
||||
const result = buildSamlAssertionNameId(user);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
NameID: user.email,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use sub when email is not verified', () => {
|
||||
const user = {
|
||||
sub: 'user123',
|
||||
email: 'user@example.com',
|
||||
email_verified: false,
|
||||
};
|
||||
|
||||
const result = buildSamlAssertionNameId(user);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
NameID: user.sub,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use sub when email is not available', () => {
|
||||
const user = {
|
||||
sub: 'user123',
|
||||
};
|
||||
|
||||
const result = buildSamlAssertionNameId(user);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
NameID: user.sub,
|
||||
});
|
||||
});
|
||||
|
||||
it('should use specified format when provided', () => {
|
||||
const user = {
|
||||
sub: 'user123',
|
||||
email: 'user@example.com',
|
||||
email_verified: false,
|
||||
};
|
||||
const format = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent';
|
||||
|
||||
const result = buildSamlAssertionNameId(user, format);
|
||||
|
||||
expect(result).toEqual({
|
||||
NameIDFormat: format,
|
||||
NameID: user.sub,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAutoSubmitForm', () => {
|
||||
it('should generate valid HTML form with auto-submit script', () => {
|
||||
const actionUrl = 'https://example.com/acs';
|
||||
const samlResponse = 'base64EncodedSamlResponse';
|
||||
|
||||
const result = generateAutoSubmitForm(actionUrl, samlResponse);
|
||||
|
||||
expect(result).toContain('<html>');
|
||||
expect(result).toContain('<body>');
|
||||
expect(result).toContain('</html>');
|
||||
|
||||
expect(result).toContain(`<form id="redirectForm" action="${actionUrl}" method="POST">`);
|
||||
expect(result).toContain(`<input type="hidden" name="SAMLResponse" value="${samlResponse}" />`);
|
||||
|
||||
expect(result).toContain('window.onload = function()');
|
||||
expect(result).toContain("document.getElementById('redirectForm').submit()");
|
||||
});
|
||||
|
||||
it('should properly escape special characters in URLs and values', () => {
|
||||
const actionUrl = 'https://example.com/acs?param=value&other=123';
|
||||
const samlResponse = 'response+with/special=characters&';
|
||||
|
||||
const result = generateAutoSubmitForm(actionUrl, samlResponse);
|
||||
|
||||
expect(result).toContain('action="https://example.com/acs?param=value&other=123"');
|
||||
expect(result).toContain('value="response+with/special=characters&"');
|
||||
});
|
||||
});
|
70
packages/core/src/saml-applications/SamlApplication/utils.ts
Normal file
70
packages/core/src/saml-applications/SamlApplication/utils.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
// TODO: refactor this file to reduce LOC
|
||||
import saml from 'samlify';
|
||||
|
||||
import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
|
||||
|
||||
/**
|
||||
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
|
||||
* Supports email and persistent formats.
|
||||
*
|
||||
* @param user - The user's standard claims
|
||||
* @param idpNameIDFormat - The NameID format(s) specified by the IdP (optional)
|
||||
* @returns An object containing the NameIDFormat and NameID
|
||||
*/
|
||||
export const buildSamlAssertionNameId = (
|
||||
user: IdTokenProfileStandardClaims,
|
||||
idpNameIDFormat?: string | string[]
|
||||
): { NameIDFormat: string; NameID: string } => {
|
||||
if (idpNameIDFormat) {
|
||||
// Get the first name ID format
|
||||
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
|
||||
// If email format is specified, try to use email first
|
||||
if (
|
||||
format === saml.Constants.namespace.format.emailAddress &&
|
||||
user.email &&
|
||||
user.email_verified
|
||||
) {
|
||||
return {
|
||||
NameIDFormat: format,
|
||||
NameID: user.email,
|
||||
};
|
||||
}
|
||||
// For other formats or when email is not available, use sub
|
||||
if (format === saml.Constants.namespace.format.persistent) {
|
||||
return {
|
||||
NameIDFormat: format,
|
||||
NameID: user.sub,
|
||||
};
|
||||
}
|
||||
}
|
||||
// No nameIDFormat specified, use default logic
|
||||
// Use email if available
|
||||
if (user.email && user.email_verified) {
|
||||
return {
|
||||
NameIDFormat: saml.Constants.namespace.format.emailAddress,
|
||||
NameID: user.email,
|
||||
};
|
||||
}
|
||||
// Fallback to persistent format with user.sub
|
||||
return {
|
||||
NameIDFormat: saml.Constants.namespace.format.persistent,
|
||||
NameID: user.sub,
|
||||
};
|
||||
};
|
||||
|
||||
export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): string => {
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<form id="redirectForm" action="${actionUrl}" method="POST">
|
||||
<input type="hidden" name="SAMLResponse" value="${samlResponse}" />
|
||||
</form>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
document.getElementById('redirectForm').submit();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
|
@ -5,21 +5,13 @@ import { addMinutes } from 'date-fns';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { spInitiatedSamlSsoSessionCookieName } from '#src/constants/index.js';
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type { AnonymousRouter, RouterInitArgs } from '#src/routes/types.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import {
|
||||
generateAutoSubmitForm,
|
||||
createSamlResponse,
|
||||
handleOidcCallbackAndGetUserInfo,
|
||||
getSamlIdpAndSp,
|
||||
getSignInUrl,
|
||||
buildSamlAppCallbackUrl,
|
||||
validateSamlApplicationDetails,
|
||||
} from './utils.js';
|
||||
import { SamlApplication } from '../SamlApplication/index.js';
|
||||
import { generateAutoSubmitForm } from '../SamlApplication/utils.js';
|
||||
|
||||
const samlApplicationSignInCallbackQueryParametersGuard = z.union([
|
||||
z.object({
|
||||
|
@ -38,9 +30,6 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
samlApplications: { getSamlIdPMetadataByApplicationId },
|
||||
} = libraries;
|
||||
const {
|
||||
applications,
|
||||
samlApplicationSecrets,
|
||||
samlApplicationConfigs,
|
||||
samlApplications: { getSamlApplicationDetailsById },
|
||||
samlApplicationSessions: { insertSession },
|
||||
} = queries;
|
||||
|
@ -87,15 +76,11 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
});
|
||||
}
|
||||
|
||||
// Get application configuration
|
||||
const {
|
||||
secret,
|
||||
oidcClientMetadata: { redirectUris },
|
||||
} = await applications.findApplicationById(id);
|
||||
const details = await getSamlApplicationDetailsById(id);
|
||||
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
|
||||
|
||||
const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
|
||||
assertThat(
|
||||
redirectUris[0] === buildSamlAppCallbackUrl(tenantEndpoint, id),
|
||||
samlApplication.details.redirectUri === samlApplication.samlAppCallbackUrl,
|
||||
'oidc.invalid_redirect_uri'
|
||||
);
|
||||
|
||||
|
@ -103,31 +88,11 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
const { code } = query;
|
||||
|
||||
// Handle OIDC callback and get user info
|
||||
const userInfo = await handleOidcCallbackAndGetUserInfo(
|
||||
const userInfo = await samlApplication.handleOidcCallbackAndGetUserInfo({
|
||||
code,
|
||||
id,
|
||||
secret,
|
||||
redirectUris[0],
|
||||
envSet.oidc.issuer
|
||||
);
|
||||
|
||||
// TODO: we will refactor the following code later, to reduce the DB query connections.
|
||||
// Get SAML configuration
|
||||
const { metadata } = await getSamlIdPMetadataByApplicationId(id);
|
||||
const { privateKey, certificate } =
|
||||
await samlApplicationSecrets.findActiveSamlApplicationSecretByApplicationId(id);
|
||||
const { entityId, acsUrl } =
|
||||
await samlApplicationConfigs.findSamlApplicationConfigByApplicationId(id);
|
||||
|
||||
assertThat(entityId, 'application.saml.entity_id_required');
|
||||
assertThat(acsUrl, 'application.saml.acs_url_required');
|
||||
|
||||
// Setup SAML providers and create response
|
||||
const { idp, sp } = getSamlIdpAndSp({
|
||||
idp: { metadata, privateKey, certificate },
|
||||
sp: { entityId, acsUrl },
|
||||
});
|
||||
const { context, entityEndpoint } = await createSamlResponse(idp, sp, userInfo);
|
||||
|
||||
const { context, entityEndpoint } = await samlApplication.createSamlResponse(userInfo);
|
||||
|
||||
// Return auto-submit form
|
||||
ctx.body = generateAutoSubmitForm(entityEndpoint, context);
|
||||
|
@ -156,18 +121,8 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
query: { Signature, RelayState, ...rest },
|
||||
} = ctx.guard;
|
||||
|
||||
const [{ metadata }, details] = await Promise.all([
|
||||
getSamlIdPMetadataByApplicationId(id),
|
||||
getSamlApplicationDetailsById(id),
|
||||
]);
|
||||
|
||||
const { entityId, acsUrl, redirectUri, certificate, privateKey } =
|
||||
validateSamlApplicationDetails(details);
|
||||
|
||||
const { idp, sp } = getSamlIdpAndSp({
|
||||
idp: { metadata, certificate, privateKey },
|
||||
sp: { entityId, acsUrl },
|
||||
});
|
||||
const details = await getSamlApplicationDetailsById(id);
|
||||
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
|
||||
|
||||
const octetString = Object.keys(ctx.request.query)
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
@ -177,7 +132,7 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
|
||||
// Parse login request
|
||||
try {
|
||||
const loginRequestResult = await idp.parseLoginRequest(sp, 'redirect', {
|
||||
const loginRequestResult = await samlApplication.parseLoginRequest('redirect', {
|
||||
query: removeUndefinedKeys({
|
||||
SAMLRequest,
|
||||
Signature,
|
||||
|
@ -196,15 +151,12 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
}
|
||||
|
||||
assertThat(
|
||||
extractResult.data.issuer === entityId,
|
||||
extractResult.data.issuer === samlApplication.details.entityId,
|
||||
'application.saml.auth_request_issuer_not_match'
|
||||
);
|
||||
|
||||
const state = generateStandardId(32);
|
||||
const signInUrl = await getSignInUrl({
|
||||
issuer: envSet.oidc.issuer,
|
||||
applicationId: id,
|
||||
redirectUri,
|
||||
const signInUrl = await samlApplication.getSignInUrl({
|
||||
state,
|
||||
});
|
||||
|
||||
|
@ -262,22 +214,12 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
body: { SAMLRequest, RelayState },
|
||||
} = ctx.guard;
|
||||
|
||||
const [{ metadata }, details] = await Promise.all([
|
||||
getSamlIdPMetadataByApplicationId(id),
|
||||
getSamlApplicationDetailsById(id),
|
||||
]);
|
||||
|
||||
const { acsUrl, entityId, redirectUri, privateKey, certificate } =
|
||||
validateSamlApplicationDetails(details);
|
||||
|
||||
const { idp, sp } = getSamlIdpAndSp({
|
||||
idp: { metadata, privateKey, certificate },
|
||||
sp: { entityId, acsUrl },
|
||||
});
|
||||
const details = await getSamlApplicationDetailsById(id);
|
||||
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
|
||||
|
||||
// Parse login request
|
||||
try {
|
||||
const loginRequestResult = await idp.parseLoginRequest(sp, 'post', {
|
||||
const loginRequestResult = await samlApplication.parseLoginRequest('post', {
|
||||
body: {
|
||||
SAMLRequest,
|
||||
},
|
||||
|
@ -293,15 +235,12 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
|
|||
}
|
||||
|
||||
assertThat(
|
||||
extractResult.data.issuer === entityId,
|
||||
extractResult.data.issuer === samlApplication.details.entityId,
|
||||
'application.saml.auth_request_issuer_not_match'
|
||||
);
|
||||
|
||||
const state = generateStandardShortId();
|
||||
const signInUrl = await getSignInUrl({
|
||||
issuer: envSet.oidc.issuer,
|
||||
applicationId: id,
|
||||
redirectUri,
|
||||
const signInUrl = await samlApplication.getSignInUrl({
|
||||
state,
|
||||
});
|
||||
|
||||
|
|
|
@ -1,155 +0,0 @@
|
|||
import nock from 'nock';
|
||||
import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify';
|
||||
|
||||
import { createSamlTemplateCallback, exchangeAuthorizationCode, getUserInfo } from './utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
describe('createSamlTemplateCallback', () => {
|
||||
const mockIdp = {
|
||||
entityMeta: {
|
||||
getEntityID: () => 'idp-entity-id',
|
||||
},
|
||||
entitySetting: {
|
||||
nameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
|
||||
},
|
||||
createLoginResponse: jest.fn(),
|
||||
parseLoginRequest: jest.fn(),
|
||||
entityType: 'idp',
|
||||
getEntitySetting: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSp = {
|
||||
entityMeta: {
|
||||
getAssertionConsumerService: () => 'https://sp.example.com/acs',
|
||||
getEntityID: () => 'sp-entity-id',
|
||||
},
|
||||
createLoginRequest: jest.fn(),
|
||||
parseLoginResponse: jest.fn(),
|
||||
entitySetting: {},
|
||||
entityType: 'sp',
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
sub: 'user123',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
it('should create SAML template callback with correct values', () => {
|
||||
const callback = createSamlTemplateCallback(
|
||||
mockIdp as unknown as IdentityProviderInstance,
|
||||
mockSp as unknown as ServiceProviderInstance,
|
||||
mockUser
|
||||
);
|
||||
|
||||
const result = callback('ID:NameID:attrEmail:attrName');
|
||||
const generatedId = result.id.replace('ID_', '');
|
||||
|
||||
expect(result.id).toBe('ID_' + generatedId);
|
||||
expect(typeof result.context).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeAuthorizationCode', () => {
|
||||
const mockTokenEndpoint = 'https://auth.example.com/token';
|
||||
const mockCode = 'auth-code';
|
||||
const mockClientId = 'client-id';
|
||||
const mockClientSecret = 'client-secret';
|
||||
const mockRedirectUri = 'https://app.example.com/callback';
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should exchange authorization code successfully', async () => {
|
||||
const mockResponse = {
|
||||
access_token: 'access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'openid profile email',
|
||||
id_token: 'mock.id.token',
|
||||
};
|
||||
|
||||
const expectedAuthHeader = `Basic ${Buffer.from(
|
||||
`${mockClientId}:${mockClientSecret}`,
|
||||
'utf8'
|
||||
).toString('base64')}`;
|
||||
|
||||
nock('https://auth.example.com')
|
||||
.post('/token', {
|
||||
grant_type: 'authorization_code',
|
||||
code: mockCode,
|
||||
client_id: mockClientId,
|
||||
redirect_uri: mockRedirectUri,
|
||||
})
|
||||
.matchHeader('Authorization', expectedAuthHeader)
|
||||
.matchHeader('Content-Type', 'application/x-www-form-urlencoded')
|
||||
.reply(200, JSON.stringify(mockResponse));
|
||||
|
||||
const result = await exchangeAuthorizationCode(mockTokenEndpoint, {
|
||||
code: mockCode,
|
||||
clientId: mockClientId,
|
||||
clientSecret: mockClientSecret,
|
||||
redirectUri: mockRedirectUri,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accessToken: mockResponse.access_token,
|
||||
tokenType: mockResponse.token_type,
|
||||
expiresIn: mockResponse.expires_in,
|
||||
scope: mockResponse.scope,
|
||||
idToken: mockResponse.id_token,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when token response is invalid', async () => {
|
||||
nock('https://auth.example.com').post('/token').reply(200, { invalid: 'response' });
|
||||
|
||||
await expect(
|
||||
exchangeAuthorizationCode(mockTokenEndpoint, {
|
||||
code: mockCode,
|
||||
clientId: mockClientId,
|
||||
clientSecret: mockClientSecret,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'oidc.invalid_token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserInfo', () => {
|
||||
const mockAccessToken = 'access-token';
|
||||
const mockUserinfoEndpoint = 'https://auth.example.com/userinfo';
|
||||
|
||||
afterEach(() => {
|
||||
nock.cleanAll();
|
||||
});
|
||||
|
||||
it('should get user info successfully', async () => {
|
||||
const mockUserInfo = {
|
||||
sub: 'user123',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
};
|
||||
|
||||
nock('https://auth.example.com')
|
||||
.get('/userinfo')
|
||||
.matchHeader('Authorization', `Bearer ${mockAccessToken}`)
|
||||
.reply(200, mockUserInfo);
|
||||
|
||||
const result = await getUserInfo(mockAccessToken, mockUserinfoEndpoint);
|
||||
expect(result).toMatchObject(mockUserInfo);
|
||||
});
|
||||
|
||||
it('should throw error when user info response is invalid', async () => {
|
||||
nock('https://auth.example.com')
|
||||
.get('/userinfo')
|
||||
.matchHeader('Authorization', `Bearer ${mockAccessToken}`)
|
||||
.reply(200, { invalid: 'response' });
|
||||
|
||||
await expect(getUserInfo(mockAccessToken, mockUserinfoEndpoint)).rejects.toMatchObject({
|
||||
code: 'oidc.invalid_request',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,409 +0,0 @@
|
|||
/* eslint-disable max-lines */
|
||||
// TODO: refactor this file to reduce LOC
|
||||
import { parseJson } from '@logto/connector-kit';
|
||||
import { Prompt, QueryKey, ReservedScope, UserScope } from '@logto/js';
|
||||
import { type SamlAcsUrl } from '@logto/schemas';
|
||||
import { generateStandardId } from '@logto/shared';
|
||||
import { tryThat, appendPath, deduplicate } from '@silverhand/essentials';
|
||||
import camelcaseKeys, { type CamelCaseKeys } from 'camelcase-keys';
|
||||
import { XMLValidator } from 'fast-xml-parser';
|
||||
import saml from 'samlify';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import {
|
||||
fetchOidcConfigRaw,
|
||||
getRawUserInfoResponse,
|
||||
handleTokenExchange,
|
||||
} from '#src/sso/OidcConnector/utils.js';
|
||||
import {
|
||||
idTokenProfileStandardClaimsGuard,
|
||||
type OidcConfigResponse,
|
||||
type IdTokenProfileStandardClaims,
|
||||
} from '#src/sso/types/oidc.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import {
|
||||
samlLogInResponseTemplate,
|
||||
samlAttributeNameFormatBasic,
|
||||
samlValueXmlnsXsi,
|
||||
} from '../libraries/consts.js';
|
||||
import { type SamlApplicationDetails } from '../queries/index.js';
|
||||
|
||||
/**
|
||||
* Determines the SAML NameID format and value based on the user's claims and IdP's NameID format.
|
||||
* Supports email and persistent formats.
|
||||
*
|
||||
* @param user - The user's standard claims
|
||||
* @param idpNameIDFormat - The NameID format(s) specified by the IdP (optional)
|
||||
* @returns An object containing the NameIDFormat and NameID
|
||||
*/
|
||||
const buildSamlAssertionNameId = (
|
||||
user: IdTokenProfileStandardClaims,
|
||||
idpNameIDFormat?: string | string[]
|
||||
): { NameIDFormat: string; NameID: string } => {
|
||||
if (idpNameIDFormat) {
|
||||
// Get the first name ID format
|
||||
const format = Array.isArray(idpNameIDFormat) ? idpNameIDFormat[0] : idpNameIDFormat;
|
||||
// If email format is specified, try to use email first
|
||||
if (
|
||||
format === saml.Constants.namespace.format.emailAddress &&
|
||||
user.email &&
|
||||
user.email_verified
|
||||
) {
|
||||
return {
|
||||
NameIDFormat: format,
|
||||
NameID: user.email,
|
||||
};
|
||||
}
|
||||
// For other formats or when email is not available, use sub
|
||||
if (format === saml.Constants.namespace.format.persistent) {
|
||||
return {
|
||||
NameIDFormat: format,
|
||||
NameID: user.sub,
|
||||
};
|
||||
}
|
||||
}
|
||||
// No nameIDFormat specified, use default logic
|
||||
// Use email if available
|
||||
if (user.email && user.email_verified) {
|
||||
return {
|
||||
NameIDFormat: saml.Constants.namespace.format.emailAddress,
|
||||
NameID: user.email,
|
||||
};
|
||||
}
|
||||
// Fallback to persistent format with user.sub
|
||||
return {
|
||||
NameIDFormat: saml.Constants.namespace.format.persistent,
|
||||
NameID: user.sub,
|
||||
};
|
||||
};
|
||||
|
||||
export const createSamlTemplateCallback =
|
||||
(
|
||||
idp: saml.IdentityProviderInstance,
|
||||
sp: saml.ServiceProviderInstance,
|
||||
user: IdTokenProfileStandardClaims
|
||||
) =>
|
||||
(template: string) => {
|
||||
const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(
|
||||
saml.Constants.wording.binding.post
|
||||
);
|
||||
|
||||
const { nameIDFormat } = idp.entitySetting;
|
||||
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);
|
||||
|
||||
const id = `ID_${generateStandardId()}`;
|
||||
const now = new Date();
|
||||
const expireAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes later
|
||||
|
||||
const tagValues = {
|
||||
ID: id,
|
||||
AssertionID: `ID_${generateStandardId()}`,
|
||||
Destination: assertionConsumerServiceUrl,
|
||||
Audience: sp.entityMeta.getEntityID(),
|
||||
EntityID: sp.entityMeta.getEntityID(),
|
||||
SubjectRecipient: assertionConsumerServiceUrl,
|
||||
Issuer: idp.entityMeta.getEntityID(),
|
||||
IssueInstant: now.toISOString(),
|
||||
AssertionConsumerServiceURL: assertionConsumerServiceUrl,
|
||||
StatusCode: saml.Constants.StatusCode.Success,
|
||||
ConditionsNotBefore: now.toISOString(),
|
||||
ConditionsNotOnOrAfter: expireAt.toISOString(),
|
||||
SubjectConfirmationDataNotOnOrAfter: expireAt.toISOString(),
|
||||
NameIDFormat,
|
||||
NameID,
|
||||
// TODO: should get the request ID from the input parameters, pending https://github.com/logto-io/logto/pull/6881.
|
||||
InResponseTo: 'null',
|
||||
/**
|
||||
* User attributes for SAML response
|
||||
*
|
||||
* @todo Support custom attribute mapping
|
||||
* @see {@link https://github.com/tngan/samlify/blob/master/src/libsaml.ts#L275-L300|samlify implementation}
|
||||
*
|
||||
* @remarks
|
||||
* By examining the code provided in the link above, we can define all the attributes supported by the attribute mapping here. Only the attributes defined in the `loginResponseTemplate.attributes` added when creating the IdP instance will appear in the SAML response.
|
||||
*/
|
||||
attrSub: user.sub,
|
||||
attrEmail: user.email,
|
||||
attrName: user.name,
|
||||
};
|
||||
|
||||
const context = saml.SamlLib.replaceTagsByValue(template, tagValues);
|
||||
|
||||
return {
|
||||
id,
|
||||
context,
|
||||
};
|
||||
};
|
||||
|
||||
export const exchangeAuthorizationCode = async (
|
||||
tokenEndpoint: string,
|
||||
{
|
||||
code,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
}: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri?: string;
|
||||
}
|
||||
) => {
|
||||
const result = await handleTokenExchange(tokenEndpoint, {
|
||||
code,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new RequestError({
|
||||
code: 'oidc.invalid_token',
|
||||
message: 'Invalid token response',
|
||||
});
|
||||
}
|
||||
|
||||
return camelcaseKeys(result.data);
|
||||
};
|
||||
|
||||
export const createSamlResponse = async (
|
||||
idp: saml.IdentityProviderInstance,
|
||||
sp: saml.ServiceProviderInstance,
|
||||
userInfo: IdTokenProfileStandardClaims
|
||||
): Promise<{ context: string; entityEndpoint: string }> => {
|
||||
// TODO: fix binding method
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const { context, entityEndpoint } = await idp.createLoginResponse(
|
||||
sp,
|
||||
// @ts-expect-error --fix request object later
|
||||
null,
|
||||
'post',
|
||||
userInfo,
|
||||
createSamlTemplateCallback(idp, sp, userInfo)
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
return { context, entityEndpoint };
|
||||
};
|
||||
|
||||
export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): string => {
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<form id="redirectForm" action="${actionUrl}" method="POST">
|
||||
<input type="hidden" name="SAMLResponse" value="${samlResponse}" />
|
||||
</form>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
document.getElementById('redirectForm').submit();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
|
||||
export const getUserInfo = async (
|
||||
accessToken: string,
|
||||
userinfoEndpoint: string
|
||||
): Promise<IdTokenProfileStandardClaims & Record<string, unknown>> => {
|
||||
const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint);
|
||||
const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(parseJson(body));
|
||||
|
||||
if (!result.success) {
|
||||
throw new RequestError({
|
||||
code: 'oidc.invalid_request',
|
||||
message: 'Invalid user info response',
|
||||
details: result.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
// Helper functions for SAML callback
|
||||
export const handleOidcCallbackAndGetUserInfo = async (
|
||||
code: string,
|
||||
applicationId: string,
|
||||
secret: string,
|
||||
redirectUri: string,
|
||||
issuer: string
|
||||
) => {
|
||||
// Get OIDC configuration
|
||||
const { tokenEndpoint, userinfoEndpoint } = await getOidcConfig(issuer);
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
const { accessToken } = await exchangeAuthorizationCode(tokenEndpoint, {
|
||||
code,
|
||||
clientId: applicationId,
|
||||
clientSecret: secret,
|
||||
redirectUri,
|
||||
});
|
||||
|
||||
assertThat(accessToken, new RequestError('oidc.access_denied'));
|
||||
|
||||
// Get user info using access token
|
||||
return getUserInfo(accessToken, userinfoEndpoint);
|
||||
};
|
||||
|
||||
const getOidcConfig = async (issuer: string): Promise<CamelCaseKeys<OidcConfigResponse>> => {
|
||||
const oidcConfig = await tryThat(
|
||||
async () => fetchOidcConfigRaw(issuer),
|
||||
(error) => {
|
||||
if (error instanceof ZodError) {
|
||||
throw new RequestError({
|
||||
code: 'oidc.invalid_request',
|
||||
message: error.message,
|
||||
error: error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
return oidcConfig;
|
||||
};
|
||||
|
||||
export const getSignInUrl = async ({
|
||||
issuer,
|
||||
applicationId,
|
||||
redirectUri,
|
||||
scope,
|
||||
state,
|
||||
}: {
|
||||
issuer: string;
|
||||
applicationId: string;
|
||||
redirectUri: string;
|
||||
scope?: string;
|
||||
state?: string;
|
||||
}) => {
|
||||
const { authorizationEndpoint } = await getOidcConfig(issuer);
|
||||
|
||||
const queryParameters = new URLSearchParams({
|
||||
[QueryKey.ClientId]: applicationId,
|
||||
[QueryKey.RedirectUri]: redirectUri,
|
||||
[QueryKey.ResponseType]: 'code',
|
||||
[QueryKey.Prompt]: Prompt.Login,
|
||||
});
|
||||
|
||||
// TODO: get value of `scope` parameters according to setup in attribute mapping.
|
||||
queryParameters.append(
|
||||
QueryKey.Scope,
|
||||
// For security reasons, DO NOT include the offline_access scope by default.
|
||||
deduplicate([
|
||||
ReservedScope.OpenId,
|
||||
UserScope.Profile,
|
||||
UserScope.Roles,
|
||||
UserScope.Organizations,
|
||||
UserScope.OrganizationRoles,
|
||||
UserScope.CustomData,
|
||||
UserScope.Identities,
|
||||
...(scope?.split(' ') ?? []),
|
||||
]).join(' ')
|
||||
);
|
||||
|
||||
if (state) {
|
||||
queryParameters.append(QueryKey.State, state);
|
||||
}
|
||||
|
||||
return new URL(`${authorizationEndpoint}?${queryParameters.toString()}`);
|
||||
};
|
||||
|
||||
export const validateSamlApplicationDetails = (details: SamlApplicationDetails) => {
|
||||
const {
|
||||
entityId,
|
||||
acsUrl,
|
||||
oidcClientMetadata: { redirectUris },
|
||||
privateKey,
|
||||
certificate,
|
||||
} = details;
|
||||
|
||||
assertThat(acsUrl, 'application.saml.acs_url_required');
|
||||
assertThat(entityId, 'application.saml.entity_id_required');
|
||||
assertThat(redirectUris[0], 'oidc.invalid_redirect_uri');
|
||||
|
||||
assertThat(privateKey, 'application.saml.private_key_required');
|
||||
assertThat(certificate, 'application.saml.certificate_required');
|
||||
|
||||
return {
|
||||
entityId,
|
||||
acsUrl,
|
||||
redirectUri: redirectUris[0],
|
||||
privateKey,
|
||||
certificate,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSamlIdpAndSp = ({
|
||||
idp: { metadata, privateKey, certificate },
|
||||
sp: { entityId, acsUrl },
|
||||
}: {
|
||||
idp: { metadata: string; privateKey: string; certificate: string };
|
||||
sp: { entityId: string; acsUrl: SamlAcsUrl };
|
||||
}): { idp: saml.IdentityProviderInstance; sp: saml.ServiceProviderInstance } => {
|
||||
// eslint-disable-next-line new-cap
|
||||
const idp = saml.IdentityProvider({
|
||||
metadata,
|
||||
privateKey,
|
||||
isAssertionEncrypted: false,
|
||||
loginResponseTemplate: {
|
||||
context: samlLogInResponseTemplate,
|
||||
attributes: [
|
||||
{
|
||||
name: 'email',
|
||||
valueTag: 'email',
|
||||
nameFormat: samlAttributeNameFormatBasic,
|
||||
valueXsiType: samlValueXmlnsXsi.string,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
valueTag: 'name',
|
||||
nameFormat: samlAttributeNameFormatBasic,
|
||||
valueXsiType: samlValueXmlnsXsi.string,
|
||||
},
|
||||
],
|
||||
},
|
||||
nameIDFormat: [
|
||||
saml.Constants.namespace.format.emailAddress,
|
||||
saml.Constants.namespace.format.persistent,
|
||||
],
|
||||
});
|
||||
|
||||
// eslint-disable-next-line new-cap
|
||||
const sp = saml.ServiceProvider({
|
||||
entityID: entityId,
|
||||
assertionConsumerService: [
|
||||
{
|
||||
Binding: acsUrl.binding,
|
||||
Location: acsUrl.url,
|
||||
},
|
||||
],
|
||||
signingCert: certificate,
|
||||
authnRequestsSigned: idp.entityMeta.isWantAuthnRequestsSigned(),
|
||||
allowCreate: false,
|
||||
});
|
||||
|
||||
// Used to check whether xml content is valid in format.
|
||||
saml.setSchemaValidator({
|
||||
validate: async (xmlContent: string) => {
|
||||
try {
|
||||
XMLValidator.validate(xmlContent, {
|
||||
allowBooleanAttributes: true,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { idp, sp };
|
||||
};
|
||||
|
||||
export const buildSamlAppCallbackUrl = (baseUrl: URL, samlApplicationId: string) =>
|
||||
appendPath(baseUrl, `api/saml-applications/${samlApplicationId}/callback`).toString();
|
||||
/* eslint-enable max-lines */
|
Loading…
Add table
Reference in a new issue