Skip to content

Commit

Permalink
feat(core): token exchange by pat (#6450)
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie authored Aug 19, 2024
1 parent 3440b3e commit 746aa58
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 20 deletions.
45 changes: 45 additions & 0 deletions packages/core/src/oidc/grants/token-exchange/account.ts
Original file line number Diff line number Diff line change
@@ -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');
};
37 changes: 19 additions & 18 deletions packages/core/src/oidc/grants/token-exchange/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -59,9 +59,6 @@ export const buildHandler: (
) => Parameters<Provider['registerGrantType']>['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'));
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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!,
});
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/oidc/grants/token-exchange/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export type TokenExchangeAct = z.infer<typeof tokenExchangeActGuard>;

export enum TokenExchangeTokenType {
AccessToken = 'urn:ietf:params:oauth:token-type:access_token',
PersonalAccessToken = 'urn:logto:token-type:personal_access_token',
}
4 changes: 2 additions & 2 deletions packages/core/src/queries/personal-access-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PersonalAccessToken>(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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Resource, 'name' | 'indicator'> = {
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();
});
});

0 comments on commit 746aa58

Please sign in to comment.