-
-
Notifications
You must be signed in to change notification settings - Fork 461
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): token exchange by pat (#6450)
- Loading branch information
Showing
6 changed files
with
264 additions
and
20 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
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'); | ||
}; |
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
File renamed without changes.
197 changes: 197 additions & 0 deletions
197
packages/integration-tests/src/tests/api/oidc/token-exchange/personal-access-token.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,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(); | ||
}); | ||
}); |