mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(core): token exchange by pat (#6450)
This commit is contained in:
parent
3440b3e5f0
commit
746aa5897b
6 changed files with 264 additions and 20 deletions
45
packages/core/src/oidc/grants/token-exchange/account.ts
Normal file
45
packages/core/src/oidc/grants/token-exchange/account.ts
Normal file
|
@ -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');
|
||||
};
|
|
@ -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<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'));
|
||||
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
Loading…
Add table
Reference in a new issue