Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): add SAML IdP GET /saml-applications/:id/callback API #6872

Merged
merged 4 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: refactor code
  • Loading branch information
darcyYe committed Dec 19, 2024
commit e6fc8d1cdc4bf771156be2b9cc12104b86a37843
11 changes: 9 additions & 2 deletions packages/core/src/saml-applications/routes/anonymous.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';

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';
Expand All @@ -10,6 +11,7 @@
createSamlResponse,
handleOidcCallbackAndGetUserInfo,
setupSamlProviders,
buildSamlAppCallbackUrl,
} from './utils.js';

const samlApplicationSignInCallbackQueryParametersGuard = z.union([
Expand All @@ -23,12 +25,12 @@
]);

export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter>(
...[router, { libraries, queries, envSet }]: RouterInitArgs<T>
...[router, { id: tenantId, libraries, queries, envSet }]: RouterInitArgs<T>

Check warning on line 28 in packages/core/src/saml-applications/routes/anonymous.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/anonymous.ts#L28

Added line #L28 was not covered by tests
) {
const {
samlApplications: { getSamlIdPMetadataByApplicationId },
} = libraries;
const { applications, samlApplicationSecrets, samlApplicationConfigs } = queries;

Check warning on line 33 in packages/core/src/saml-applications/routes/anonymous.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/anonymous.ts#L33

Added line #L33 was not covered by tests

router.get(
'/saml-applications/:id/metadata',
Expand All @@ -49,11 +51,12 @@
return next();
}
);

router.get(
'/saml-applications/:id/callback',
koaGuard({
params: z.object({ id: z.string() }),
// TODO: should be able to handle `state` and `redirectUri`

Check warning on line 59 in packages/core/src/saml-applications/routes/anonymous.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/saml-applications/routes/anonymous.ts#L59

[no-warning-comments] Unexpected 'todo' comment: 'TODO: should be able to handle `state`...'.
query: samlApplicationSignInCallbackQueryParametersGuard,
status: [200, 400],
}),
Expand All @@ -77,9 +80,13 @@
oidcClientMetadata: { redirectUris },
} = await applications.findApplicationById(id);

assertThat(redirectUris[0], 'oidc.redirect_uri_not_set');
const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
assertThat(
redirectUris[0] === buildSamlAppCallbackUrl(tenantEndpoint, id),
'oidc.invalid_redirect_uri'
);

// TODO: should be able to handle `state` and code verifier etc.

Check warning on line 89 in packages/core/src/saml-applications/routes/anonymous.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/saml-applications/routes/anonymous.ts#L89

[no-warning-comments] Unexpected 'todo' comment: 'TODO: should be able to handle `state`...'.
const { code } = query;

// Handle OIDC callback and get user info
Expand Down
57 changes: 31 additions & 26 deletions packages/core/src/saml-applications/routes/utils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { parseJson } from '@logto/connector-kit';
import { generateStandardId } from '@logto/shared';
import { tryThat } from '@silverhand/essentials';
import { tryThat, appendPath } from '@silverhand/essentials';
import camelcaseKeys from 'camelcase-keys';
import { got } from 'got';
import saml from 'samlify';
import { ZodError, z } from 'zod';

import RequestError from '#src/errors/RequestError/index.js';
import { fetchOidcConfigRaw, getRawUserInfoResponse } from '#src/sso/OidcConnector/utils.js';
import {
fetchOidcConfigRaw,
getRawUserInfoResponse,
handleTokenExchange,
} from '#src/sso/OidcConnector/utils.js';
import { idTokenProfileStandardClaimsGuard } from '#src/sso/types/oidc.js';
import { oidcTokenResponseGuard, type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
import { type IdTokenProfileStandardClaims } from '#src/sso/types/oidc.js';
import assertThat from '#src/utils/assert-that.js';

import {
Expand All @@ -18,9 +21,18 @@
samlValueXmlnsXsi,
} from '../libraries/consts.js';

// We only support email and persistent format at the moment.
const getSamlNameId = (user: IdTokenProfileStandardClaims, idpNameIDFormat?: string | string[]) => {
// If IdP has specified nameIDFormat, use it
/**
* 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;
Expand All @@ -30,27 +42,27 @@
user.email &&
user.email_verified
) {
return {
NameIDFormat: format,
NameID: user.email,
};
}

Check warning on line 49 in packages/core/src/saml-applications/routes/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/utils.ts#L45-L49

Added lines #L45 - L49 were not covered by tests
// For other formats or when email is not available, use sub
if (format === saml.Constants.namespace.format.persistent) {
return {
NameIDFormat: format,
NameID: user.sub,
};
}

Check warning on line 56 in packages/core/src/saml-applications/routes/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/utils.ts#L52-L56

Added lines #L52 - L56 were not covered by tests
}
// 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,
};
}

Check warning on line 65 in packages/core/src/saml-applications/routes/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/utils.ts#L61-L65

Added lines #L61 - L65 were not covered by tests
// Fallback to persistent format with user.sub
return {
NameIDFormat: saml.Constants.namespace.format.persistent,
Expand All @@ -70,7 +82,7 @@
);

const { nameIDFormat } = idp.entitySetting;
const { NameIDFormat, NameID } = getSamlNameId(user, nameIDFormat);
const { NameIDFormat, NameID } = buildSamlAssertionNameId(user, nameIDFormat);

const id = `ID_${generateStandardId()}`;
const now = new Date();
Expand All @@ -92,6 +104,7 @@
SubjectConfirmationDataNotOnOrAfter: expireAt.toISOString(),
NameIDFormat,
NameID,
// TODO: should get the request ID from the input parameters, pending https://github.com/logto-io/logto/pull/6881.

Check warning on line 107 in packages/core/src/saml-applications/routes/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/saml-applications/routes/utils.ts#L107

[no-warning-comments] Unexpected 'todo' comment: 'TODO: should get the request ID from the...'.
InResponseTo: 'null',
/**
* User attributes for SAML response
Expand Down Expand Up @@ -129,25 +142,13 @@
redirectUri?: string;
}
) => {
const headers = {
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
};

const tokenRequestParameters = new URLSearchParams({
grant_type: 'authorization_code',
const result = await handleTokenExchange(tokenEndpoint, {
code,
client_id: clientId,
...(redirectUri ? { redirect_uri: redirectUri } : {}),
});

const httpResponse = await got.post(tokenEndpoint, {
body: tokenRequestParameters.toString(),
headers,
clientId,
clientSecret,
redirectUri,
});

const result = oidcTokenResponseGuard.safeParse(parseJson(httpResponse.body));

if (!result.success) {
throw new RequestError({
code: 'oidc.invalid_token',
Expand All @@ -159,11 +160,11 @@
};

export const createSamlResponse = async (
idp: saml.IdentityProviderInstance,
sp: saml.ServiceProviderInstance,
userInfo: IdTokenProfileStandardClaims
): Promise<{ context: string; entityEndpoint: string }> => {
// TODO: fix binding method

Check warning on line 167 in packages/core/src/saml-applications/routes/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/saml-applications/routes/utils.ts#L167

[no-warning-comments] Unexpected 'todo' comment: 'TODO: fix binding method'.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { context, entityEndpoint } = await idp.createLoginResponse(
sp,
Expand All @@ -179,20 +180,21 @@
};

Check warning on line 180 in packages/core/src/saml-applications/routes/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/utils.ts#L163-L180

Added lines #L163 - L180 were not covered by tests

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}" />
<input type="submit" value="Submit" />
</form>
<script>
document.getElementById('redirectForm').submit();
window.onload = function() {
document.getElementById('redirectForm').submit();
};
</script>
</body>
</html>
`;
};

Check warning on line 197 in packages/core/src/saml-applications/routes/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/saml-applications/routes/utils.ts#L183-L197

Added lines #L183 - L197 were not covered by tests

export const getUserInfo = async (
accessToken: string,
Expand All @@ -214,12 +216,12 @@

// Helper functions for SAML callback
export const handleOidcCallbackAndGetUserInfo = async (
code: string,
applicationId: string,
secret: string,
redirectUri: string,
issuer: string
) => {

Check warning on line 224 in packages/core/src/saml-applications/routes/utils.ts

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

packages/core/src/saml-applications/routes/utils.ts#L224

[max-params] Async arrow function has too many parameters (5). Maximum allowed is 4.
// Get OIDC configuration
const { tokenEndpoint, userinfoEndpoint } = await tryThat(
async () => fetchOidcConfigRaw(issuer),
Expand Down Expand Up @@ -297,3 +299,6 @@

return { idp, sp };
};

export const buildSamlAppCallbackUrl = (baseUrl: URL, samlApplicationId: string) =>
appendPath(baseUrl, `api/saml-applications/${samlApplicationId}/callback`).toString();
15 changes: 10 additions & 5 deletions packages/core/src/sso/OidcConnector/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,19 @@ describe('fetchToken', () => {
})
);

expect(postMock).toBeCalledWith({
url: oidcConfigResponseCamelCase.tokenEndpoint,
form: {
expect(postMock).toBeCalledWith(oidcConfigResponseCamelCase.tokenEndpoint, {
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: oidcConfig.clientId,
client_secret: oidcConfig.clientSecret,
code: data.code,
client_id: oidcConfig.clientId,
redirect_uri: redirectUri,
}).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(
`${oidcConfig.clientId}:${oidcConfig.clientSecret}`,
'utf8'
).toString('base64')}`,
},
});
});
Expand Down
70 changes: 55 additions & 15 deletions packages/core/src/sso/OidcConnector/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
type OidcTokenResponse,
} from '../types/oidc.js';

/**
* Fetch the full-list of OIDC config from the issuer. Throws error if config is invalid.
*
* @param issuer The issuer URL
* @returns The full-list of OIDC config
*/
export const fetchOidcConfigRaw = async (issuer: string) => {
const { body } = await got.get(`${issuer}/.well-known/openid-configuration`, {
responseType: 'json',
Expand Down Expand Up @@ -50,6 +56,46 @@
}
};

export const handleTokenExchange = async (
tokenEndpoint: string,
{
code,
clientId,
clientSecret,
redirectUri,
}: {
code: string;
clientId: string;
clientSecret: string;
redirectUri?: string;
}
) => {
const tokenRequestParameters = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: clientId,
...(redirectUri ? { redirect_uri: redirectUri } : {}),
});

const headers = {
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded',
};

const httpResponse = await got.post(tokenEndpoint, {
body: tokenRequestParameters.toString(),
headers,
});

const result = oidcTokenResponseGuard.safeParse(parseJson(httpResponse.body));

if (!result.success) {
return { success: false as const, error: result.error, response: httpResponse };
}

return { success: true as const, data: result.data };
};

export const fetchToken = async (
{ tokenEndpoint, clientId, clientSecret }: BaseOidcConfig,
data: unknown,
Expand All @@ -68,28 +114,22 @@
const { code } = result.data;

try {
const httpResponse = await got.post({
url: tokenEndpoint,
form: {
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
client_secret: clientSecret,
},
const exchangeResult = await handleTokenExchange(tokenEndpoint, {
code,
clientId,
clientSecret,
redirectUri,
});

const result = oidcTokenResponseGuard.safeParse(parseJson(httpResponse.body));

if (!result.success) {
if (!exchangeResult.success) {
throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid token response',
response: httpResponse.body,
error: result.error.flatten(),
response: exchangeResult.response.body,
error: exchangeResult.error.flatten(),
});
}

return camelcaseKeys(result.data);
return camelcaseKeys(exchangeResult.data);
} catch (error: unknown) {
if (error instanceof SsoConnectorError) {
throw error;
Expand Down Expand Up @@ -172,16 +212,16 @@
*/
export const getUserInfo = async (accessToken: string, userinfoEndpoint: string) => {
try {
const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint);

Check warning on line 215 in packages/core/src/sso/OidcConnector/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/sso/OidcConnector/utils.ts#L215

Added line #L215 was not covered by tests

const result = idTokenProfileStandardClaimsGuard
.catchall(z.unknown())
.safeParse(parseJson(body));

Check warning on line 219 in packages/core/src/sso/OidcConnector/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/sso/OidcConnector/utils.ts#L219

Added line #L219 was not covered by tests

if (!result.success) {
throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid user info response',
response: body,

Check warning on line 224 in packages/core/src/sso/OidcConnector/utils.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/sso/OidcConnector/utils.ts#L224

Added line #L224 was not covered by tests
error: result.error.flatten(),
});
}
Expand Down
1 change: 0 additions & 1 deletion packages/phrases/src/locales/en/errors/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const oidc = {
invalid_grant: 'Grant request is invalid.',
invalid_redirect_uri:
"`redirect_uri` did not match any of the client's registered `redirect_uris`.",
redirect_uri_not_set: '`redirect_uri` is not set.',
access_denied: 'Access denied.',
invalid_target: 'Invalid resource indicator.',
unsupported_grant_type: 'Unsupported `grant_type` requested.',
Expand Down
Loading