mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core,schemas): token exchange grant (#6057)
This commit is contained in:
parent
5c4ddee042
commit
685a97476a
10 changed files with 441 additions and 5 deletions
|
@ -69,6 +69,7 @@ const grantTypeToExchangeByType: Record<GrantType, token.ExchangeByType> = {
|
|||
[GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode,
|
||||
[GrantType.RefreshToken]: token.ExchangeByType.RefreshToken,
|
||||
[GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials,
|
||||
[GrantType.TokenExchange]: token.ExchangeByType.TokenExchange,
|
||||
};
|
||||
|
||||
const getExchangeByType = (grantType: unknown): token.ExchangeByType => {
|
||||
|
|
|
@ -7,6 +7,7 @@ import type Queries from '#src/tenants/Queries.js';
|
|||
|
||||
import * as clientCredentials from './client-credentials.js';
|
||||
import * as refreshToken from './refresh-token.js';
|
||||
import * as tokenExchange from './token-exchange.js';
|
||||
|
||||
export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries) => {
|
||||
const {
|
||||
|
@ -33,4 +34,9 @@ export const registerGrants = (oidc: Provider, envSet: EnvSet, queries: Queries)
|
|||
clientCredentials.buildHandler(envSet, queries),
|
||||
...getParameterConfig(clientCredentials.parameters)
|
||||
);
|
||||
oidc.registerGrantType(
|
||||
GrantType.TokenExchange,
|
||||
tokenExchange.buildHandler(envSet, queries),
|
||||
...getParameterConfig(tokenExchange.parameters)
|
||||
);
|
||||
};
|
||||
|
|
152
packages/core/src/oidc/grants/token-exchange.test.ts
Normal file
152
packages/core/src/oidc/grants/token-exchange.test.ts
Normal file
|
@ -0,0 +1,152 @@
|
|||
import { type SubjectToken } from '@logto/schemas';
|
||||
import { type KoaContextWithOIDC, errors } from 'oidc-provider';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { mockApplication } from '#src/__mocks__/index.js';
|
||||
import { createOidcContext } from '#src/test-utils/oidc-provider.js';
|
||||
import { MockTenant } from '#src/test-utils/tenant.js';
|
||||
|
||||
import { buildHandler } from './token-exchange.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const noop = async () => {};
|
||||
const findSubjectToken = jest.fn();
|
||||
const updateSubjectTokenById = jest.fn();
|
||||
|
||||
const mockTenant = new MockTenant(undefined, {
|
||||
subjectTokens: {
|
||||
findSubjectToken,
|
||||
updateSubjectTokenById,
|
||||
},
|
||||
applications: {
|
||||
findApplicationById: jest.fn().mockResolvedValue(mockApplication),
|
||||
},
|
||||
});
|
||||
const mockHandler = (tenant = mockTenant) => {
|
||||
return buildHandler(tenant.envSet, tenant.queries);
|
||||
};
|
||||
|
||||
const clientId = 'some_client_id';
|
||||
const subjectTokenId = 'some_token_id';
|
||||
const accountId = 'some_account_id';
|
||||
|
||||
type Client = InstanceType<KoaContextWithOIDC['oidc']['provider']['Client']>;
|
||||
|
||||
// @ts-expect-error
|
||||
const validClient: Client = {
|
||||
clientId,
|
||||
grantTypeAllowed: jest.fn().mockResolvedValue(true),
|
||||
clientAuthMethod: 'none',
|
||||
};
|
||||
|
||||
const validSubjectToken: SubjectToken = {
|
||||
id: subjectTokenId,
|
||||
userId: accountId,
|
||||
context: {},
|
||||
expiresAt: Date.now() + 1000,
|
||||
consumedAt: null,
|
||||
tenantId: 'some_tenant_id',
|
||||
creatorId: 'some_creator_id',
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const validOidcContext: Partial<KoaContextWithOIDC['oidc']> = {
|
||||
params: {
|
||||
subject_token: 'some_subject_token',
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
},
|
||||
entities: {
|
||||
Client: validClient,
|
||||
},
|
||||
client: validClient,
|
||||
};
|
||||
|
||||
const createPreparedContext = () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
return ctx;
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// `oidc-provider` will warn for dev interactions
|
||||
Sinon.stub(console, 'warn');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
describe('token exchange', () => {
|
||||
it('should throw when client is not available', async () => {
|
||||
const ctx = createOidcContext({ ...validOidcContext, client: undefined });
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidClient);
|
||||
});
|
||||
|
||||
it('should throw when subject token type is incorrect', async () => {
|
||||
const ctx = createOidcContext({
|
||||
...validOidcContext,
|
||||
params: { ...validOidcContext.params, subject_token_type: 'invalid' },
|
||||
});
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
|
||||
new errors.InvalidGrant('unsupported subject token type')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when subject token is not available', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
|
||||
new errors.InvalidGrant('subject token not found')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when subject token is expired', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
findSubjectToken.mockResolvedValueOnce({ ...validSubjectToken, expiresAt: Date.now() - 1000 });
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
|
||||
new errors.InvalidGrant('subject token is expired')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when subject token has been consumed', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
findSubjectToken.mockResolvedValueOnce({ ...validSubjectToken, consumedAt: Date.now() - 1000 });
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toMatchError(
|
||||
new errors.InvalidGrant('subject token is already consumed')
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when account cannot be found', async () => {
|
||||
const ctx = createOidcContext(validOidcContext);
|
||||
findSubjectToken.mockResolvedValueOnce(validSubjectToken);
|
||||
Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves();
|
||||
await expect(mockHandler()(ctx, noop)).rejects.toThrow(errors.InvalidGrant);
|
||||
});
|
||||
|
||||
// The handler returns void so we cannot check the return value, and it's also not
|
||||
// straightforward to assert the token is issued correctly. Here we just do the sanity
|
||||
// check and basic token validation. Comprehensive token validation should be done in
|
||||
// integration tests.
|
||||
it('should not explode when everything looks fine', async () => {
|
||||
const ctx = createPreparedContext();
|
||||
findSubjectToken.mockResolvedValueOnce(validSubjectToken);
|
||||
Sinon.stub(ctx.oidc.provider.Account, 'findAccount').resolves({ accountId });
|
||||
|
||||
const entityStub = Sinon.stub(ctx.oidc, 'entity');
|
||||
const noopStub = Sinon.stub().resolves();
|
||||
|
||||
await expect(mockHandler(mockTenant)(ctx, noopStub)).resolves.toBeUndefined();
|
||||
expect(noopStub.callCount).toBe(1);
|
||||
expect(updateSubjectTokenById).toHaveBeenCalled();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const [key, value] = entityStub.lastCall.args;
|
||||
expect(key).toBe('AccessToken');
|
||||
expect(value).toMatchObject({
|
||||
accountId,
|
||||
clientId,
|
||||
grantId: subjectTokenId,
|
||||
gty: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
||||
});
|
||||
});
|
||||
});
|
148
packages/core/src/oidc/grants/token-exchange.ts
Normal file
148
packages/core/src/oidc/grants/token-exchange.ts
Normal file
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* @overview This file implements the `token_exchange` grant type. The grant type is used to impersonate
|
||||
*
|
||||
* @see {@link https://github.com/logto-io/rfcs | Logto RFCs} for more information about RFC 0005.
|
||||
*/
|
||||
|
||||
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';
|
||||
import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js';
|
||||
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
|
||||
|
||||
import { type EnvSet } from '#src/env-set/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
const { InvalidClient, InvalidGrant } = errors;
|
||||
|
||||
/**
|
||||
* The valid parameters for the `urn:ietf:params:oauth:grant-type:token-exchange` grant type. Note the `resource` parameter is
|
||||
* not included here since it should be handled per configuration when registering the grant type.
|
||||
*/
|
||||
export const parameters = Object.freeze([
|
||||
'subject_token',
|
||||
'subject_token_type',
|
||||
'organization_id',
|
||||
'scope',
|
||||
] as const);
|
||||
|
||||
/**
|
||||
* The required parameters for the grant type.
|
||||
*
|
||||
* @see {@link parameters} for the full list of valid parameters.
|
||||
*/
|
||||
const requiredParameters = Object.freeze([
|
||||
'subject_token',
|
||||
'subject_token_type',
|
||||
] as const) satisfies ReadonlyArray<(typeof parameters)[number]>;
|
||||
|
||||
export const buildHandler: (
|
||||
envSet: EnvSet,
|
||||
queries: Queries
|
||||
) => 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'));
|
||||
assertThat(
|
||||
params.subject_token_type === 'urn:ietf:params:oauth:token-type:access_token',
|
||||
new InvalidGrant('unsupported subject token type')
|
||||
);
|
||||
|
||||
validatePresence(ctx, ...requiredParameters);
|
||||
|
||||
const providerInstance = instance(provider);
|
||||
const {
|
||||
features: { userinfo, resourceIndicators },
|
||||
} = 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 account = await Account.findAccount(ctx, subjectToken.userId);
|
||||
|
||||
if (!account) {
|
||||
throw new InvalidGrant('refresh token invalid (referenced account not found)');
|
||||
}
|
||||
|
||||
// TODO: (LOG-9501) Implement general security checks like dPop
|
||||
ctx.oidc.entity('Account', account);
|
||||
|
||||
// TODO: (LOG-9140) Check organization permissions
|
||||
|
||||
const accessToken = new AccessToken({
|
||||
accountId: account.accountId,
|
||||
clientId: client.clientId,
|
||||
gty: GrantType.TokenExchange,
|
||||
client,
|
||||
grantId: subjectToken.id, // There is no actual grant, so we use the subject token ID
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
scope: undefined!,
|
||||
});
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
|
||||
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
|
||||
const scope = requestParamScopes;
|
||||
const resource = await resolveResource(
|
||||
ctx,
|
||||
{
|
||||
// We don't restrict the resource indicators to the requested resource,
|
||||
// because the subject token does not have a resource indicator.
|
||||
// Use the params.resource to bypass the resource indicator check.
|
||||
resourceIndicators: new Set([params.resource]),
|
||||
},
|
||||
{ userinfo, resourceIndicators },
|
||||
scope
|
||||
);
|
||||
|
||||
if (resource) {
|
||||
const resourceServerInfo = await resourceIndicators.getResourceServerInfo(
|
||||
ctx,
|
||||
resource,
|
||||
client
|
||||
);
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
|
||||
accessToken.resourceServer = new provider.ResourceServer(resource, resourceServerInfo);
|
||||
// For access token scopes, there is no "grant" to check,
|
||||
// filter the scopes based on the resource server's scopes
|
||||
accessToken.scope = [...scope]
|
||||
// @ts-expect-error -- code from oidc-provider
|
||||
.filter(Set.prototype.has.bind(accessToken.resourceServer.scopes))
|
||||
.join(' ');
|
||||
} else {
|
||||
// TODO: (LOG-9166) Check claims and scopes
|
||||
accessToken.claims = ctx.oidc.claims;
|
||||
accessToken.scope = Array.from(scope).join(' ');
|
||||
}
|
||||
// TODO: (LOG-9140) Handle organization token
|
||||
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
ctx.oidc.entity('AccessToken', accessToken);
|
||||
const accessTokenString = await accessToken.save();
|
||||
|
||||
// Consume the subject token
|
||||
await updateSubjectTokenById(subjectToken.id, {
|
||||
consumedAt: Date.now(),
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
access_token: accessTokenString,
|
||||
expires_in: accessToken.expiration,
|
||||
scope: accessToken.scope,
|
||||
token_type: accessToken.tokenType,
|
||||
};
|
||||
|
||||
await next();
|
||||
};
|
|
@ -20,17 +20,17 @@ import {
|
|||
describe('getConstantClientMetadata()', () => {
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SPA)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Native)).toEqual({
|
||||
application_type: 'native',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Traditional)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.MachineToMachine)).toEqual({
|
||||
|
|
|
@ -39,7 +39,7 @@ export const getConstantClientMetadata = (
|
|||
grant_types:
|
||||
type === ApplicationType.MachineToMachine
|
||||
? [GrantType.ClientCredentials]
|
||||
: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.TokenExchange],
|
||||
token_endpoint_auth_method: getTokenEndpointAuthMethod(),
|
||||
response_types: conditional(type === ApplicationType.MachineToMachine && []),
|
||||
// https://www.scottbrady91.com/jose/jwts-which-signing-algorithm-should-i-use
|
||||
|
|
|
@ -1,14 +1,28 @@
|
|||
import { SubjectTokens } from '@logto/schemas';
|
||||
import { type CreateSubjectToken, SubjectTokens } from '@logto/schemas';
|
||||
import type { CommonQueryMethods } from '@silverhand/slonik';
|
||||
|
||||
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
|
||||
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
|
||||
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
|
||||
import { type OmitAutoSetFields } from '#src/utils/sql.js';
|
||||
|
||||
export const createSubjectTokenQueries = (pool: CommonQueryMethods) => {
|
||||
const insertSubjectToken = buildInsertIntoWithPool(pool)(SubjectTokens, {
|
||||
returning: true,
|
||||
});
|
||||
|
||||
const findSubjectToken = buildFindEntityByIdWithPool(pool)(SubjectTokens);
|
||||
|
||||
const updateSubjectToken = buildUpdateWhereWithPool(pool)(SubjectTokens, true);
|
||||
|
||||
const updateSubjectTokenById = async (
|
||||
id: string,
|
||||
set: Partial<OmitAutoSetFields<CreateSubjectToken>>
|
||||
) => updateSubjectToken({ set, where: { id }, jsonbMode: 'merge' });
|
||||
|
||||
return {
|
||||
insertSubjectToken,
|
||||
findSubjectToken,
|
||||
updateSubjectTokenById,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
import { ApplicationType, GrantType } from '@logto/schemas';
|
||||
import { formUrlEncodedHeaders } from '@logto/shared';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { oidcApi } from '#src/api/api.js';
|
||||
import { createApplication, deleteApplication } from '#src/api/application.js';
|
||||
import { createSubjectToken } from '#src/api/subject-token.js';
|
||||
import { createUserByAdmin } from '#src/helpers/index.js';
|
||||
import { devFeatureTest } from '#src/utils.js';
|
||||
|
||||
const { describe, it } = devFeatureTest;
|
||||
|
||||
describe('Token Exchange', () => {
|
||||
/* eslint-disable @silverhand/fp/no-let */
|
||||
let userId: string;
|
||||
let applicationId: string;
|
||||
/* eslint-enable @silverhand/fp/no-let */
|
||||
|
||||
/* eslint-disable @silverhand/fp/no-mutation */
|
||||
beforeAll(async () => {
|
||||
const user = await createUserByAdmin();
|
||||
userId = user.id;
|
||||
const applicationName = 'test-token-exchange-app';
|
||||
const applicationType = ApplicationType.SPA;
|
||||
const application = await createApplication(applicationName, applicationType, {
|
||||
oidcClientMetadata: { redirectUris: ['http://localhost:3000'], postLogoutRedirectUris: [] },
|
||||
});
|
||||
applicationId = application.id;
|
||||
});
|
||||
/* eslint-enable @silverhand/fp/no-mutation */
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUser(userId);
|
||||
await deleteApplication(applicationId);
|
||||
});
|
||||
|
||||
describe('Basic flow', () => {
|
||||
it('should exchange an access token by a subject token', async () => {
|
||||
const { subjectToken } = await createSubjectToken(userId);
|
||||
|
||||
const body = await oidcApi
|
||||
.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
client_id: applicationId,
|
||||
grant_type: GrantType.TokenExchange,
|
||||
subject_token: subjectToken,
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
}),
|
||||
})
|
||||
.json();
|
||||
|
||||
expect(body).toHaveProperty('access_token');
|
||||
expect(body).toHaveProperty('token_type', 'Bearer');
|
||||
expect(body).toHaveProperty('expires_in');
|
||||
expect(body).toHaveProperty('scope', '');
|
||||
});
|
||||
|
||||
it('should fail without valid client_id', async () => {
|
||||
const { subjectToken } = await createSubjectToken(userId);
|
||||
|
||||
await expect(
|
||||
oidcApi.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
grant_type: GrantType.TokenExchange,
|
||||
subject_token: subjectToken,
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
}),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should failed with invalid subject token', async () => {
|
||||
await expect(
|
||||
oidcApi.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
client_id: applicationId,
|
||||
grant_type: GrantType.TokenExchange,
|
||||
subject_token: 'invalid_subject_token',
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
}),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should failed with consumed subject token', async () => {
|
||||
const { subjectToken } = await createSubjectToken(userId);
|
||||
|
||||
await oidcApi.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
client_id: applicationId,
|
||||
grant_type: GrantType.TokenExchange,
|
||||
subject_token: subjectToken,
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
oidcApi.post('token', {
|
||||
headers: formUrlEncodedHeaders,
|
||||
body: new URLSearchParams({
|
||||
client_id: applicationId,
|
||||
grant_type: GrantType.TokenExchange,
|
||||
subject_token: subjectToken,
|
||||
subject_token_type: 'urn:ietf:params:oauth:token-type:access_token',
|
||||
}),
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -20,6 +20,7 @@ export enum ExchangeByType {
|
|||
AuthorizationCode = 'AuthorizationCode',
|
||||
RefreshToken = 'RefreshToken',
|
||||
ClientCredentials = 'ClientCredentials',
|
||||
TokenExchange = 'TokenExchange',
|
||||
}
|
||||
|
||||
export type LogKey = `${Type.ExchangeTokenBy}.${ExchangeByType}` | `${Type.RevokeToken}`;
|
||||
|
|
|
@ -12,4 +12,5 @@ export enum GrantType {
|
|||
AuthorizationCode = 'authorization_code',
|
||||
RefreshToken = 'refresh_token',
|
||||
ClientCredentials = 'client_credentials',
|
||||
TokenExchange = 'urn:ietf:params:oauth:grant-type:token-exchange',
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue