diff --git a/packages/integration-tests/src/api/application.ts b/packages/integration-tests/src/api/application.ts index 946706dc5..6d1476e84 100644 --- a/packages/integration-tests/src/api/application.ts +++ b/packages/integration-tests/src/api/application.ts @@ -7,12 +7,17 @@ import type { import { authedAdminApi } from './api.js'; -export const createApplication = async (name: string, type: ApplicationType) => +export const createApplication = async ( + name: string, + type: ApplicationType, + rest?: Partial +) => authedAdminApi .post('applications', { json: { name, type, + ...rest, }, }) .json(); diff --git a/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts b/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts new file mode 100644 index 000000000..d5a7cd82a --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/always-issue-refresh-token.test.ts @@ -0,0 +1,68 @@ +import { Prompt } from '@logto/js'; +import { ApplicationType, InteractionEvent } from '@logto/schemas'; + +import { createApplication, deleteApplication, putInteraction } from '#src/api/index.js'; +import MockClient from '#src/client/index.js'; +import { demoAppRedirectUri } from '#src/constants.js'; +import { processSession } from '#src/helpers/client.js'; +import { createUserByAdmin } from '#src/helpers/index.js'; +import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { generateUsername, generatePassword } from '#src/utils.js'; + +describe('always issue Refresh Token config', () => { + const username = generateUsername(); + const password = generatePassword(); + + const validateRefreshToken = async (appId: string, redirectUri: string, expectToken: boolean) => { + const client = new MockClient({ + appId, + prompt: Prompt.Login, + }); + await client.initSession(redirectUri); + await client.successSend(putInteraction, { + event: InteractionEvent.SignIn, + identifier: { username, password }, + }); + const { redirectTo } = await client.submitInteraction(); + await processSession(client, redirectTo); + + if (expectToken) { + expect(await client.getRefreshToken()).not.toBeNull(); + } else { + expect(await client.getRefreshToken()).toBeNull(); + } + }; + + beforeAll(async () => { + await createUserByAdmin(username, password); + await enableAllPasswordSignInMethods(); + }); + + it('can sign in and fetch Refresh Token without `prompt=consent` when always issue Refresh Token is set', async () => { + const app = await createApplication('Integration test app', ApplicationType.SPA, { + oidcClientMetadata: { redirectUris: [demoAppRedirectUri], postLogoutRedirectUris: [] }, + customClientMetadata: { alwaysIssueRefreshToken: true }, + }); + await validateRefreshToken(app.id, demoAppRedirectUri, true); + await deleteApplication(app.id); + }); + + it('cannot fetch Refresh Token if alwaysIssueRefreshToken is false and prompt is not consent', async () => { + const app = await createApplication('Integration test app', ApplicationType.SPA, { + oidcClientMetadata: { redirectUris: [demoAppRedirectUri], postLogoutRedirectUris: [] }, + customClientMetadata: { alwaysIssueRefreshToken: false }, + }); + await validateRefreshToken(app.id, demoAppRedirectUri, false); + await deleteApplication(app.id); + }); + + it('cannot fetch Refresh Token for non-web apps', async () => { + const redirectUri = 'io.logto://callback'; + const app = await createApplication('Integration test app', ApplicationType.Native, { + oidcClientMetadata: { redirectUris: [redirectUri], postLogoutRedirectUris: [] }, + customClientMetadata: { alwaysIssueRefreshToken: true }, + }); + await validateRefreshToken(app.id, redirectUri, false); + await deleteApplication(app.id); + }); +}); diff --git a/packages/integration-tests/src/tests/api/oidc/content-type-json.test.ts b/packages/integration-tests/src/tests/api/oidc/content-type-json.test.ts new file mode 100644 index 000000000..652a3e049 --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/content-type-json.test.ts @@ -0,0 +1,54 @@ +import { demoAppApplicationId } from '@logto/schemas'; +import { trySafe } from '@silverhand/essentials'; +import { HTTPError, type Headers, got } from 'got'; + +import { logtoUrl } from '#src/constants.js'; + +describe('content-type: application/json compatibility', () => { + const api = got.extend({ + prefixUrl: new URL('/oidc', logtoUrl), + }); + + const expectErrorMessageForPayload = async ( + payload: Record, + errorMessage: string, + headers?: Headers + ) => { + return trySafe( + api.post('token', { + headers, + json: payload, + }), + (error) => { + if (!(error instanceof HTTPError)) { + return fail('Error is not a HTTPError instance.'); + } + expect(JSON.parse(String(error.response.body))).toHaveProperty( + 'error_description', + errorMessage + ); + } + ); + }; + + it('recognizes `application/json` content-type in OIDC token endpoints', async () => { + await Promise.all([ + expectErrorMessageForPayload( + { client_id: demoAppApplicationId }, + "missing required parameter 'grant_type'" + ), + expectErrorMessageForPayload( + { client_id: demoAppApplicationId, grant_type: 'refresh_token' }, + "missing required parameter 'refresh_token'" + ), + ]); + }); + + it('does not recognize `application/json1` content-type', async () => { + await expectErrorMessageForPayload( + { client_id: demoAppApplicationId }, + 'only application/x-www-form-urlencoded content-type bodies are supported on POST /token', + { 'content-type': 'application/json1' } + ); + }); +}); diff --git a/packages/integration-tests/src/tests/api/get-access-token.test.ts b/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts similarity index 93% rename from packages/integration-tests/src/tests/api/get-access-token.test.ts rename to packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts index b5cbde339..6acf8b53b 100644 --- a/packages/integration-tests/src/tests/api/get-access-token.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/get-access-token.test.ts @@ -26,7 +26,7 @@ describe('get access token', () => { await enableAllPasswordSignInMethods(); }); - it('sign-in and getAccessToken with admin user', async () => { + it('can sign in and getAccessToken with admin user', async () => { const client = new MockClient({ resources: [defaultManagementApi.resource.indicator], scopes: [defaultManagementApi.scope.name], @@ -49,7 +49,7 @@ describe('get access token', () => { void expect(client.getAccessToken('api.foo.com')).rejects.toThrow(); }); - it('sign-in and getAccessToken with guest user', async () => { + it('can sign in and getAccessToken with guest user', async () => { const client = new MockClient({ resources: [defaultManagementApi.resource.indicator], scopes: [defaultManagementApi.scope.name], @@ -69,7 +69,7 @@ describe('get access token', () => { ); }); - it('sign-in and get multiple Access Token by the same Refresh Token within refreshTokenReuseInterval', async () => { + it('can sign in and get multiple Access Tokens by the same Refresh Token within refreshTokenReuseInterval', async () => { const client = new MockClient({ resources: [defaultManagementApi.resource.indicator] }); await client.initSession();