0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

Merge pull request from logto-io/gao-enable-backchannel-logout

feat(core,console): enable backchannel logout
This commit is contained in:
Gao Sun 2024-06-14 11:10:42 +08:00 committed by GitHub
commit 752059d6b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 285 additions and 46 deletions
.changeset
.vscode
packages
console/src/pages/ApplicationDetails/ApplicationDetailsContent
core/src/oidc
integration-tests/src
phrases/src/locales/en/translation/admin-console
schemas/src
foundations/jsonb-types
utils

View file

@ -0,0 +1,16 @@
---
"@logto/console": minor
"@logto/schemas": minor
"@logto/core": minor
"@logto/phrases": patch
---
enable backchannel logout support
Enable the support of [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html).
To register for backchannel logout, navigate to the application details page in the Logto Console and locate the "Backchannel logout" section. Enter the backchannel logout URL of your RP and click "Save".
You can also enable session requirements for backchannel logout. When enabled, Logto will include the `sid` claim in the logout token.
For programmatic registration, you can set the `backchannelLogoutUri` and `backchannelLogoutSessionRequired` properties in the application `oidcClientMetadata` object.

View file

@ -54,6 +54,7 @@
"timestamptz",
"topbar",
"upsell",
"withtyped"
"withtyped",
"backchannel"
]
}

View file

@ -0,0 +1,48 @@
import { type Application } from '@logto/schemas';
import { useFormContext } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import { z } from 'zod';
import FormCard from '@/components/FormCard';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import TextInput from '@/ds-components/TextInput';
function BackchannelLogout() {
const {
register,
formState: { errors },
} = useFormContext<Application>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<FormCard
title="application_details.backchannel_logout"
description="application_details.backchannel_logout_description"
learnMoreLink={{ href: 'https://openid.net/specs/openid-connect-backchannel-1_0-final.html' }}
>
<FormField title="application_details.backchannel_logout_uri">
<TextInput
error={errors.oidcClientMetadata?.backchannelLogoutUri?.message}
placeholder="https://your.website.com/backchannel_logout"
{...register('oidcClientMetadata.backchannelLogoutUri', {
validate: (value) =>
!value ||
z.string().url().optional().safeParse(value).success ||
t('errors.invalid_uri_format'),
})}
/>
</FormField>
<FormField title="application_details.backchannel_logout_uri_session_required">
<Switch
label={
<Trans i18nKey="admin_console.application_details.backchannel_logout_uri_session_required_description" />
}
{...register('oidcClientMetadata.backchannelLogoutSessionRequired')}
/>
</FormField>
</FormCard>
);
}
export default BackchannelLogout;

View file

@ -26,6 +26,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import { applicationTypeI18nKey } from '@/types/applications';
import { trySubmitSafe } from '@/utils/form';
import BackchannelLogout from './BackchannelLogout';
import Branding from './Branding';
import EndpointsAndCredentials from './EndpointsAndCredentials';
import GuideDrawer from './GuideDrawer';
@ -204,6 +205,7 @@ function ApplicationDetailsContent({ data, oidcConfig, onApplicationUpdated }: P
{![ApplicationType.MachineToMachine, ApplicationType.Protected].includes(data.type) && (
<RefreshTokenSettings data={data} />
)}
<BackchannelLogout />
</DetailsForm>
</FormProvider>
{tab === ApplicationDetailsTabs.Settings && (

View file

@ -75,6 +75,8 @@ export const applicationFormDataParser = {
postLogoutRedirectUris: mapToUriFormatArrays(
oidcClientMetadata?.postLogoutRedirectUris
),
// Empty string is not a valid URL
backchannelLogoutUri: cond(oidcClientMetadata?.backchannelLogoutUri),
},
customClientMetadata: {
...customClientMetadata,

View file

@ -112,6 +112,7 @@ export default function initOidc(
introspection: { enabled: true },
devInteractions: { enabled: false },
clientCredentials: { enabled: true },
backchannelLogout: { enabled: true },
rpInitiatedLogout: {
logoutSource: (ctx, form) => {
// eslint-disable-next-line no-template-curly-in-string

View file

@ -157,11 +157,12 @@ export default class MockClient {
}
public async signOut(postSignOutRedirectUri?: string) {
this.navigateUrl = undefined;
await this.logto.signOut(postSignOutRedirectUri);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!this.navigateUrl) {
throw new Error('No navigate URL found for sign-out');
}
await this.logto.signOut(postSignOutRedirectUri);
await ky(this.navigateUrl);
}

View file

@ -1,8 +1,10 @@
import type { SignInExperience } from '@logto/schemas';
import { SignInMode, SignInIdentifier, MfaFactor, MfaPolicy } from '@logto/schemas';
import { SignInMode, SignInIdentifier, MfaFactor, MfaPolicy, ConnectorType } from '@logto/schemas';
import { updateSignInExperience } from '#src/api/index.js';
import { clearConnectorsByTypes } from './connector.js';
export const defaultSignUpMethod = {
identifiers: [],
password: false,
@ -126,3 +128,27 @@ export const enableMandatoryMfaWithWebAuthnAndBackupCode = async () =>
export const resetMfaSettings = async () =>
updateSignInExperience({ mfa: { policy: MfaPolicy.UserControlled, factors: [] } });
/** Enable only username and password sign-in and sign-up. */
export const setUsernamePasswordOnly = async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await updateSignInExperience({
signInMode: SignInMode.SignInAndRegister,
signUp: {
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
},
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
passwordPolicy: {},
});
};

View file

@ -99,7 +99,7 @@ export const expectToProceedSdkGuide = async (
const postSignOutRedirectUriWrapper = await expect(page).toMatchElement(
'div[class$=wrapper]:has(>div[class$=field]>div[class$=headline]>div[class$=title])',
{ text: 'Post Sign-out Redirect URI' }
{ text: 'Post sign-out redirect URI' }
);
await expect(postSignOutRedirectUriWrapper).toFill('input', postSignOutRedirectUri);

View file

@ -0,0 +1,117 @@
/**
* @fileoverview
* A test suite for the backchannel logout feature. Note that Console is the only possible
* application that can use in this test, since:
*
* - The headless client in API tests cannot follow a soft redirect, while the backchannel logout
* will only be triggered when a logout confirmation is received, which needs a [soft redirect](https://github.com/panva/node-oidc-provider/blob/f52140233146e77d0dcc34ee44fd2b95b488c8d9/lib/actions/end_session.js#L76)
* on the end session page.
* - We cannot update demo app's OIDC client metadata via API, then it'll be tricky to add the
* backchannel logout URI conditionally (use environment variables looks not right).
* - To trigger the backchannel logout on other apps, a [shared session](https://github.com/panva/node-oidc-provider/blob/f52140233146e77d0dcc34ee44fd2b95b488c8d9/lib/actions/end_session.js#L135)
* is required, which requires us to sign in with all the apps in the same browser session. This
* sounds tricky. Since we can trust the `oidc-provider` library's implementation, we can just
* test the backchannel logout feature of the Console application.
*
* In summary, we will set the backchannel logout URI for the Console application, then sign out
* from the Console and check if the backchannel logout endpoint is called.
*/
import { type Server, type RequestListener, createServer } from 'node:http';
import { adminConsoleApplicationId } from '@logto/schemas';
import { authedAdminTenantApi } from '#src/api/api.js';
import ExpectConsole from '#src/ui-helpers/expect-console.js';
import { waitFor } from '#src/utils.js';
type RequestHistory = {
method?: string;
pathname?: string;
body: string;
};
class MockServer {
public readonly endpoint = `http://localhost:${this.port}`;
public readonly history: RequestHistory[] = [];
private readonly server: Server;
constructor(
/** The port number to listen on. */
private readonly port: number
) {
// eslint-disable-next-line unicorn/consistent-function-scoping -- We need to access `this`
const requestListener: RequestListener = (request, response) => {
const data: Uint8Array[] = [];
request.on('data', (chunk: Uint8Array) => {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
data.push(chunk);
});
request.on('end', () => {
const body = Buffer.concat(data).toString();
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
this.history.push({ method: request.method, pathname: request.url, body });
response.end(body);
});
};
this.server = createServer(requestListener);
}
public async listen() {
return new Promise((resolve) => {
this.server.listen(this.port, () => {
resolve(true);
});
});
}
public async close() {
return new Promise((resolve) => {
this.server.close(() => {
resolve(true);
});
});
}
}
const port = 9998;
const mockServer = new MockServer(port);
const backchannelLogoutUri = `http://localhost:${port}/backchannel_logout`;
describe('backchannel logout', () => {
beforeAll(async () => {
await mockServer.listen();
});
afterAll(async () => {
await mockServer.close();
});
it('should call the backchannel logout endpoint when a user logs out', async () => {
await authedAdminTenantApi.patch('applications/' + adminConsoleApplicationId, {
json: {
oidcClientMetadata: {
backchannelLogoutUri,
},
},
});
expect(mockServer.history.length).toBe(0);
const expectConsole = new ExpectConsole(await browser.newPage());
await expectConsole.start();
await expectConsole.end();
// Give some time for redirecting and processing the backchannel logout request
await waitFor(100);
expect(mockServer.history.length).toBe(1);
// Only check method and pathname since we trust the `oidc-provider` library's implementation
expect(mockServer.history[0]).toMatchObject({
method: 'POST',
pathname: '/backchannel_logout',
});
});
});

View file

@ -1,8 +1,5 @@
import { SignInMode, SignInIdentifier, ConnectorType } from '@logto/schemas';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { demoAppUrl } from '#src/constants.js';
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
import { setUsernamePasswordOnly } from '#src/helpers/sign-in-experience.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
const credentials = {
@ -19,26 +16,7 @@ const credentials = {
// for convenient expect methods
describe('smoke testing on the demo app', () => {
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
await updateSignInExperience({
signInMode: SignInMode.SignInAndRegister,
signUp: {
identifiers: [SignInIdentifier.Username],
password: true,
verify: false,
},
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
],
},
passwordPolicy: {},
});
await setUsernamePasswordOnly();
});
it('should be able to create a new account with a credential preset', async () => {

View file

@ -3,7 +3,7 @@ import path from 'node:path';
import { appendPath, condString } from '@silverhand/essentials';
import { consolePassword, consoleUsername, logtoConsoleUrl } from '#src/constants.js';
import { cls, dcls } from '#src/utils.js';
import { cls, dcls, waitFor } from '#src/utils.js';
import ExpectPage, { ExpectPageError } from './expect-page.js';
import { expectConfirmModalAndAct, expectToSaveChanges } from './index.js';
@ -52,6 +52,16 @@ export default class ExpectConsole extends ExpectPage {
}
}
/** Sign out from the Console by clicking the top-right dropdown. */
async end() {
await expect(this.page).toClick('div[class$=topbar] > div[class$=container]');
// Try awaiting for 500ms to ensure the dropdown is rendered.
await waitFor(500);
await expect(this.page).toClick(
'.ReactModalPortal div[class$=dropdownContainer] div[class$=dropdownItem]:last-child'
);
}
/**
* Alias for `expect(page).toMatchElement(...)`.
*

View file

@ -22,8 +22,8 @@ const application_details = {
application_name_placeholder: 'My App',
description: 'Description',
description_placeholder: 'Enter your application description',
config_endpoint: 'OpenID Provider configuration endpoint',
authorization_endpoint: 'Authorization Endpoint',
config_endpoint: 'OpenID provider configuration endpoint',
authorization_endpoint: 'Authorization endpoint',
authorization_endpoint_tip:
"The endpoint to perform authentication and authorization. It's used for OpenID Connect <a>Authentication</a>.",
show_endpoint_details: 'Show endpoint details',
@ -39,8 +39,8 @@ const application_details = {
redirect_uri_placeholder_native: 'io.logto://callback',
redirect_uri_tip:
'The URI redirects after a user sign-in (whether successful or not). See OpenID Connect <a>AuthRequest</a> for more info.',
post_sign_out_redirect_uri: 'Post Sign-out Redirect URI',
post_sign_out_redirect_uris: 'Post Sign-out Redirect URIs',
post_sign_out_redirect_uri: 'Post sign-out redirect URI',
post_sign_out_redirect_uris: 'Post sign-out redirect URIs',
post_sign_out_redirect_uri_placeholder: 'https://your.website.com/home',
post_sign_out_redirect_uri_tip:
'The URI redirects after a user sign-out (optional). It may have no practical effect in some app types.',
@ -48,20 +48,27 @@ const application_details = {
cors_allowed_origins_placeholder: 'https://your.website.com',
cors_allowed_origins_tip:
'By default, all the origins of Redirect URIs will be allowed. Usually no action is required for this field. See the <a>MDN doc</a> for detailed info.',
token_endpoint: 'Token Endpoint',
user_info_endpoint: 'Userinfo Endpoint',
token_endpoint: 'Token endpoint',
user_info_endpoint: 'Userinfo endpoint',
enable_admin_access: 'Enable admin access',
enable_admin_access_label:
'Enable or disable the access to Management API. Once enabled, you can use access tokens to call Management API on behalf on this application.',
always_issue_refresh_token: 'Always issue Refresh Token',
always_issue_refresh_token: 'Always issue refresh token',
always_issue_refresh_token_label:
'When enabled, Logto will always issue Refresh Tokens, regardless of whether `prompt=consent` is presented in the authentication request. However, this practice is discouraged unless necessary, as it is not compatible with OpenID Connect and may potentially cause issues.',
refresh_token_ttl: 'Refresh Token Time to Live (TTL) in days',
'When enabled, Logto will always issue refresh tokens, regardless of whether `prompt=consent` is presented in the authentication request. However, this practice is discouraged unless necessary, as it is not compatible with OpenID Connect and may potentially cause issues.',
refresh_token_ttl: 'Refresh token time to live (TTL) in days',
refresh_token_ttl_tip:
'The duration for which a Refresh Token can be used to request new access tokens before it expires and becomes invalid. Token requests will extend the TTL of the Refresh Token to this value.',
rotate_refresh_token: 'Rotate Refresh Token',
'The duration for which a refresh token can be used to request new access tokens before it expires and becomes invalid. Token requests will extend the TTL of the refresh token to this value.',
rotate_refresh_token: 'Rotate refresh token',
rotate_refresh_token_label:
'When enabled, Logto will issue a new Refresh Token for token requests when 70% of the original Time to Live (TTL) has passed or certain conditions are met. <a>Learn more</a>',
'When enabled, Logto will issue a new refresh token for token requests when 70% of the original time to live (TTL) has passed or certain conditions are met. <a>Learn more</a>',
backchannel_logout: 'Backchannel Logout',
backchannel_logout_description:
'Configure the OpenID Connect backchannel logout endpoint and if session is required for this application.',
backchannel_logout_uri: 'Backchannel logout URI',
backchannel_logout_uri_session_required: 'Is session required?',
backchannel_logout_uri_session_required_description:
'When enabled, the RP requires that a `sid` (session ID) claim be included in the logout token to identify the RP session with the OP when the `backchannel_logout_uri` is used.',
delete_description:
'This action cannot be undone. It will permanently delete the application. Please enter the application name <span>{{name}}</span> to confirm.',
enter_your_application_name: 'Enter your application name',

View file

@ -1,6 +1,8 @@
import { validateRedirectUrl } from '@logto/core-kit';
import { z } from 'zod';
import { type ToZodObject } from '../../utils/zod.js';
export const oidcModelInstancePayloadGuard = z
.object({
userCode: z.string().optional(),
@ -15,6 +17,34 @@ export const oidcModelInstancePayloadGuard = z
export type OidcModelInstancePayload = z.infer<typeof oidcModelInstancePayloadGuard>;
export type OidcClientMetadata = {
/**
* The redirect URIs that the client is allowed to use.
*
* @see {@link https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata | OpenID Connect Dynamic Client Registration 1.0}
*/
redirectUris: string[];
/**
* The post-logout redirect URIs that the client is allowed to use.
*
* @see {@link https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata | OpenID Connect RP-Initiated Logout 1.0}
*/
postLogoutRedirectUris: string[];
/**
* The URI for backchannel logout.
*
* @see {@link https://openid.net/specs/openid-connect-backchannel-1_0-final.html#BCRegistration | OpenID Connect Back-Channel Logout 1.0}
*/
backchannelLogoutUri?: string;
/**
* Whether the RP requires that a `sid` (session ID) Claim be included in the Logout Token.
*
* @see {@link https://openid.net/specs/openid-connect-backchannel-1_0-final.html#BCRegistration | OpenID Connect Back-Channel Logout 1.0}
*/
backchannelLogoutSessionRequired?: boolean;
logoUri?: string;
};
export const oidcClientMetadataGuard = z.object({
redirectUris: z
.string()
@ -22,10 +52,10 @@ export const oidcClientMetadataGuard = z.object({
.or(z.string().refine((url) => validateRedirectUrl(url, 'mobile')))
.array(),
postLogoutRedirectUris: z.string().url().array(),
backchannelLogoutUri: z.string().url().optional(),
backchannelLogoutSessionRequired: z.boolean().optional(),
logoUri: z.string().optional(),
});
export type OidcClientMetadata = z.infer<typeof oidcClientMetadataGuard>;
}) satisfies ToZodObject<OidcClientMetadata>;
export enum CustomClientMetadataKey {
CorsAllowedOrigins = 'corsAllowedOrigins',

View file

@ -1,5 +1,5 @@
import { type z } from 'zod';
export type ToZodObject<T> = z.ZodObject<{
[K in keyof T]: z.ZodType<T[K]>;
[K in keyof T]-?: z.ZodType<T[K]>;
}>;