diff --git a/packages/core/src/oidc/grants/token-exchange/account.ts b/packages/core/src/oidc/grants/token-exchange/account.ts new file mode 100644 index 000000000..71122b0a4 --- /dev/null +++ b/packages/core/src/oidc/grants/token-exchange/account.ts @@ -0,0 +1,45 @@ +import { generateStandardShortId } from '@logto/shared'; +import { trySafe } from '@silverhand/essentials'; +import { errors } from 'oidc-provider'; + +import type Queries from '../../../tenants/Queries.js'; +import assertThat from '../../../utils/assert-that.js'; + +import { TokenExchangeTokenType } from './types.js'; + +const { InvalidGrant } = errors; + +export const validateSubjectToken = async ( + queries: Queries, + subjectToken: string, + type: string +): Promise<{ userId: string; grantId: string; subjectTokenId?: string }> => { + const { + subjectTokens: { findSubjectToken }, + personalAccessTokens: { findByValue }, + } = queries; + + if (type === TokenExchangeTokenType.AccessToken) { + const token = await trySafe(async () => findSubjectToken(subjectToken)); + assertThat(token, new InvalidGrant('subject token not found')); + assertThat(token.expiresAt > Date.now(), new InvalidGrant('subject token is expired')); + assertThat(!token.consumedAt, new InvalidGrant('subject token is already consumed')); + + return { + userId: token.userId, + grantId: token.id, + subjectTokenId: token.id, + }; + } + if (type === TokenExchangeTokenType.PersonalAccessToken) { + const token = await findByValue(subjectToken); + assertThat(token, new InvalidGrant('subject token not found')); + assertThat( + !token.expiresAt || token.expiresAt > Date.now(), + new InvalidGrant('subject token is expired') + ); + + return { userId: token.userId, grantId: generateStandardShortId() }; + } + throw new InvalidGrant('unsupported subject token type'); +}; diff --git a/packages/core/src/oidc/grants/token-exchange/index.ts b/packages/core/src/oidc/grants/token-exchange/index.ts index 669e1ee49..2ad6cffce 100644 --- a/packages/core/src/oidc/grants/token-exchange/index.ts +++ b/packages/core/src/oidc/grants/token-exchange/index.ts @@ -6,7 +6,6 @@ import { buildOrganizationUrn } from '@logto/core-kit'; import { GrantType } from '@logto/schemas'; -import { trySafe } from '@silverhand/essentials'; import type Provider from 'oidc-provider'; import { errors } from 'oidc-provider'; import resolveResource from 'oidc-provider/lib/helpers/resolve_resource.js'; @@ -24,10 +23,11 @@ import { } from '../../resource.js'; import { handleClientCertificate, handleDPoP, checkOrganizationAccess } from '../utils.js'; +import { validateSubjectToken } from './account.js'; import { handleActorToken } from './actor-token.js'; import { TokenExchangeTokenType, type TokenExchangeAct } from './types.js'; -const { InvalidClient, InvalidGrant, AccessDenied } = errors; +const { InvalidClient, InvalidGrant } = errors; /** * The valid parameters for the `urn:ietf:params:oauth:grant-type:token-exchange` grant type. Note the `resource` parameter is @@ -59,9 +59,6 @@ export const buildHandler: ( ) => Parameters['1'] = (envSet, queries) => async (ctx, next) => { const { client, params, requestParamScopes, provider } = ctx.oidc; const { Account, AccessToken } = provider; - const { - subjectTokens: { findSubjectToken, updateSubjectTokenById }, - } = queries; assertThat(params, new InvalidGrant('parameters must be available')); assertThat(client, new InvalidClient('client must be available')); @@ -70,9 +67,11 @@ export const buildHandler: ( !(await isThirdPartyApplication(queries, client.clientId)), new InvalidClient('third-party applications are not allowed for this grant type') ); + // Personal access tokens require secured client assertThat( - params.subject_token_type === TokenExchangeTokenType.AccessToken, - new InvalidGrant('unsupported subject token type') + params.subject_token_type !== TokenExchangeTokenType.PersonalAccessToken || + client.tokenEndpointAuthMethod === 'client_secret_basic', + new InvalidClient('third-party applications are not allowed for this grant type') ); validatePresence(ctx, ...requiredParameters); @@ -83,15 +82,16 @@ export const buildHandler: ( scopes: oidcScopes, } = providerInstance.configuration(); - const subjectToken = await trySafe(async () => findSubjectToken(String(params.subject_token))); - assertThat(subjectToken, new InvalidGrant('subject token not found')); - assertThat(subjectToken.expiresAt > Date.now(), new InvalidGrant('subject token is expired')); - assertThat(!subjectToken.consumedAt, new InvalidGrant('subject token is already consumed')); + const { userId, grantId, subjectTokenId } = await validateSubjectToken( + queries, + String(params.subject_token), + String(params.subject_token_type) + ); - const account = await Account.findAccount(ctx, subjectToken.userId); + const account = await Account.findAccount(ctx, userId); if (!account) { - throw new InvalidGrant('refresh token invalid (referenced account not found)'); + throw new InvalidGrant('subject token invalid (referenced account not found)'); } ctx.oidc.entity('Account', account); @@ -103,7 +103,7 @@ export const buildHandler: ( clientId: client.clientId, gty: GrantType.TokenExchange, client, - grantId: subjectToken.id, // There is no actual grant, so we use the subject token ID + grantId, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion scope: undefined!, }); @@ -190,10 +190,11 @@ export const buildHandler: ( ctx.oidc.entity('AccessToken', accessToken); const accessTokenString = await accessToken.save(); - // Consume the subject token - await updateSubjectTokenById(subjectToken.id, { - consumedAt: Date.now(), - }); + if (subjectTokenId) { + await queries.subjectTokens.updateSubjectTokenById(subjectTokenId, { + consumedAt: Date.now(), + }); + } ctx.body = { access_token: accessTokenString, diff --git a/packages/core/src/oidc/grants/token-exchange/types.ts b/packages/core/src/oidc/grants/token-exchange/types.ts index 019c68269..a35f49916 100644 --- a/packages/core/src/oidc/grants/token-exchange/types.ts +++ b/packages/core/src/oidc/grants/token-exchange/types.ts @@ -10,4 +10,5 @@ export type TokenExchangeAct = z.infer; export enum TokenExchangeTokenType { AccessToken = 'urn:ietf:params:oauth:token-type:access_token', + PersonalAccessToken = 'urn:logto:token-type:personal_access_token', } diff --git a/packages/core/src/queries/personal-access-tokens.ts b/packages/core/src/queries/personal-access-tokens.ts index 2e7ff7a85..ac6a451bf 100644 --- a/packages/core/src/queries/personal-access-tokens.ts +++ b/packages/core/src/queries/personal-access-tokens.ts @@ -18,13 +18,13 @@ export class PersonalAccessTokensQueries { constructor(public readonly pool: CommonQueryMethods) {} - async findByValue(value: string) { + public readonly findByValue = async (value: string) => { return this.pool.maybeOne(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} where ${fields.value} = ${value} `); - } + }; async updateName(userId: string, name: string, newName: string) { return this.update({ diff --git a/packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts b/packages/integration-tests/src/tests/api/oidc/token-exchange/index.test.ts similarity index 100% rename from packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts rename to packages/integration-tests/src/tests/api/oidc/token-exchange/index.test.ts diff --git a/packages/integration-tests/src/tests/api/oidc/token-exchange/personal-access-token.test.ts b/packages/integration-tests/src/tests/api/oidc/token-exchange/personal-access-token.test.ts new file mode 100644 index 000000000..13920aadd --- /dev/null +++ b/packages/integration-tests/src/tests/api/oidc/token-exchange/personal-access-token.test.ts @@ -0,0 +1,197 @@ +import { ApplicationType, GrantType, type Resource } from '@logto/schemas'; +import { formUrlEncodedHeaders } from '@logto/shared'; + +import { createPersonalAccessToken } from '#src/api/admin-user.js'; +import { oidcApi } from '#src/api/api.js'; +import { + createApplication, + deleteApplication, + getApplicationSecrets, +} from '#src/api/application.js'; +import { createResource, deleteResource } from '#src/api/resource.js'; +import { createUserByAdmin } from '#src/helpers/index.js'; +import { + devFeatureTest, + generatePassword, + generateUsername, + getAccessTokenPayload, +} from '#src/utils.js'; + +const tokenType = 'urn:logto:token-type:personal_access_token'; + +const { describe, it } = devFeatureTest; + +describe('Token Exchange (Personal Access Token)', () => { + const username = generateUsername(); + const password = generatePassword(); + // Add test resource to ensure that the access token is JWT, + // make it easy to check claims. + const testApiResourceInfo: Pick = { + name: 'test-api-resource', + indicator: 'https://foo.logto.io/api', + }; + + /* eslint-disable @silverhand/fp/no-let */ + let testApiResourceId: string; + let testApplicationId: string; + let testUserId: string; + let testToken: string; + let authorizationHeader: string; + /* eslint-enable @silverhand/fp/no-let */ + + beforeAll(async () => { + /* eslint-disable @silverhand/fp/no-mutation */ + const resource = await createResource(testApiResourceInfo.name, testApiResourceInfo.indicator); + testApiResourceId = resource.id; + const applicationName = 'test-pat-app'; + const applicationType = ApplicationType.Traditional; + const application = await createApplication(applicationName, applicationType, { + oidcClientMetadata: { redirectUris: ['http://localhost:3000'], postLogoutRedirectUris: [] }, + }); + testApplicationId = application.id; + const secrets = await getApplicationSecrets(application.id); + authorizationHeader = `Basic ${Buffer.from(`${application.id}:${secrets[0]?.value}`).toString( + 'base64' + )}`; + const { id } = await createUserByAdmin({ username, password }); + testUserId = id; + const { value } = await createPersonalAccessToken({ + userId: testUserId, + name: 'test-pat', + }); + testToken = value; + /* eslint-enable @silverhand/fp/no-mutation */ + }); + + afterAll(async () => { + // Await deleteUser(testUserId); + await deleteResource(testApiResourceId); + await deleteApplication(testApplicationId); + }); + + it('should exchange an access token by a subject token', async () => { + const body = await oidcApi + .post('token', { + headers: { + ...formUrlEncodedHeaders, + Authorization: authorizationHeader, + }, + body: new URLSearchParams({ + grant_type: GrantType.TokenExchange, + subject_token: testToken, + subject_token_type: tokenType, + }), + }) + .json(); + + expect(body).toHaveProperty('access_token'); + expect(body).toHaveProperty('token_type', 'Bearer'); + expect(body).toHaveProperty('expires_in'); + expect(body).toHaveProperty('scope', ''); + }); + + it('should be able to use for multiple times', async () => { + await oidcApi.post('token', { + headers: { + ...formUrlEncodedHeaders, + Authorization: authorizationHeader, + }, + body: new URLSearchParams({ + grant_type: GrantType.TokenExchange, + subject_token: testToken, + subject_token_type: tokenType, + }), + }); + + await expect( + oidcApi.post('token', { + headers: { + ...formUrlEncodedHeaders, + Authorization: authorizationHeader, + }, + body: new URLSearchParams({ + grant_type: GrantType.TokenExchange, + subject_token: testToken, + subject_token_type: tokenType, + }), + }) + ).resolves.not.toThrow(); + }); + + it('should exchange a JWT access token', async () => { + const { access_token } = await oidcApi + .post('token', { + headers: { + ...formUrlEncodedHeaders, + Authorization: authorizationHeader, + }, + body: new URLSearchParams({ + grant_type: GrantType.TokenExchange, + subject_token: testToken, + subject_token_type: tokenType, + resource: testApiResourceInfo.indicator, + }), + }) + .json<{ access_token: string }>(); + + const payload = getAccessTokenPayload(access_token); + expect(payload).toHaveProperty('aud', testApiResourceInfo.indicator); + expect(payload).toHaveProperty('scope', ''); + expect(payload).toHaveProperty('sub', testUserId); + }); + + it('should fail with non-secure client authentication method', async () => { + await expect( + oidcApi.post('token', { + headers: formUrlEncodedHeaders, + body: new URLSearchParams({ + client_id: testApiResourceId, + grant_type: GrantType.TokenExchange, + subject_token: testToken, + subject_token_type: tokenType, + }), + }) + ).rejects.toThrow(); + }); + + it('should fail with invalid PAT', async () => { + await expect( + oidcApi.post('token', { + headers: { + ...formUrlEncodedHeaders, + Authorization: authorizationHeader, + }, + body: new URLSearchParams({ + grant_type: GrantType.TokenExchange, + subject_token: 'invalid_pat', + subject_token_type: tokenType, + }), + }) + ).rejects.toThrow(); + }); + + it('should failed with expired PAT', async () => { + const expiredToken = await createPersonalAccessToken({ + userId: testUserId, + name: 'expired-pat', + expiresAt: Date.now() + 100, + }); + // Wait for the token to be expired + await new Promise((resolve) => { + setTimeout(resolve, 100); + }); + await expect( + oidcApi.post('token', { + headers: { + ...formUrlEncodedHeaders, + Authorization: authorizationHeader, + }, + body: new URLSearchParams({ + grant_type: GrantType.TokenExchange, + subject_token: expiredToken.value, + subject_token_type: tokenType, + }), + }) + ).rejects.toThrow(); + }); +});