-
-
Notifications
You must be signed in to change notification settings - Fork 476
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core,schemas): token exchange grant
- Loading branch information
Showing
10 changed files
with
287 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
/** | ||
* @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, | ||
InvalidScope, | ||
InsufficientScope, | ||
AccessDenied, | ||
InvalidRequest, | ||
} = 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]>; | ||
|
||
// We have to disable the rules because the original implementation is written in JavaScript and | ||
// uses mutable variables. | ||
|
||
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 expired')); | ||
assertThat(!subjectToken.consumedAt, new InvalidGrant('subject token already consumed')); | ||
|
||
const account = await Account.findAccount(ctx, subjectToken.userId); | ||
|
||
if (!account) { | ||
throw new InvalidGrant('refresh token invalid (referenced account not found)'); | ||
} | ||
|
||
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(); | ||
}; | ||
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
95 changes: 95 additions & 0 deletions
95
packages/integration-tests/src/tests/api/oidc/token-exchange.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
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'; | ||
|
||
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 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(); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters