0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

chore: add tests and changeset

This commit is contained in:
Gao Sun 2024-06-08 10:19:13 +08:00
parent f28a083ed0
commit b50ba0b7e6
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
10 changed files with 181 additions and 30 deletions

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

@ -27,6 +27,7 @@ function BackchannelLogout() {
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'),
})}

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';
@ -36,7 +37,6 @@ import RefreshTokenSettings from './RefreshTokenSettings';
import Settings from './Settings';
import * as styles from './index.module.scss';
import { type ApplicationForm, applicationFormDataParser } from './utils';
import BackchannelLogout from './BackchannelLogout';
type Props = {
readonly data: ApplicationResponse;

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

@ -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 we 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(...)`.
*