0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(core): support roles scope for ID token (#4600)

* refactor(core): support `roles` scope for ID token

* chore: update changeset
This commit is contained in:
Gao Sun 2023-10-07 05:20:51 -05:00 committed by GitHub
parent 03bc7888b1
commit 2c340d3799
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 86 additions and 7 deletions

View file

@ -0,0 +1,6 @@
---
"@logto/core-kit": minor
"@logto/core": minor
---
support `roles` scope for ID token to issue `roles` claim

View file

@ -230,16 +230,18 @@ export default function initOidc(
return snakecaseKeys(
{
/**
* This line is required because:
* The manual `sub` assignment is required because:
* 1. TypeScript will complain since `Object.fromEntries()` has a fixed key type `string`
* 2. Scope `openid` is removed from `UserScope` enum
*/
sub,
...Object.fromEntries(
getUserClaims(use, scope, claims, rejected).map((claim) => [
claim,
getUserClaimData(user, claim),
])
await Promise.all(
getUserClaims(use, scope, claims, rejected).map(
async (claim) =>
[claim, await getUserClaimData(user, claim, libraries.users)] as const
)
)
),
},
{

View file

@ -4,8 +4,10 @@ import type { User } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import type { ClaimsParameterMember } from 'oidc-provider';
import type Libraries from '#src/tenants/Libraries.js';
const claimToUserKey: Readonly<
Record<Exclude<UserClaim, 'email_verified' | 'phone_number_verified'>, keyof User>
Record<Exclude<UserClaim, 'email_verified' | 'phone_number_verified' | 'roles'>, keyof User>
> = Object.freeze({
name: 'name',
picture: 'avatar',
@ -16,7 +18,11 @@ const claimToUserKey: Readonly<
identities: 'identities',
});
export const getUserClaimData = (user: User, claim: UserClaim): unknown => {
export const getUserClaimData = async (
user: User,
claim: UserClaim,
userLibrary: Libraries['users']
): Promise<unknown> => {
// LOG-4165: Change to proper key/function once profile fulfilling implemented
if (claim === 'email_verified') {
return Boolean(user.primaryEmail);
@ -27,6 +33,11 @@ export const getUserClaimData = (user: User, claim: UserClaim): unknown => {
return Boolean(user.primaryPhone);
}
if (claim === 'roles') {
const roles = await userLibrary.findUserRoles(user.id);
return roles.map(({ name }) => name);
}
return user[claimToUserKey[claim]];
};

View file

@ -0,0 +1,51 @@
import { Prompt } from '@logto/js';
import { InteractionEvent, demoAppApplicationId } from '@logto/schemas';
import { assignRolesToUser, putInteraction } from '#src/api/index.js';
import { createRole } from '#src/api/role.js';
import MockClient from '#src/client/index.js';
import { demoAppRedirectUri } from '#src/constants.js';
import { processSession } from '#src/helpers/client.js';
import { createUserByAdmin } from '#src/helpers/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generatePassword, generateUsername } from '#src/utils.js';
describe('OpenID Connect ID token', () => {
const username = generateUsername();
const password = generatePassword();
// eslint-disable-next-line @silverhand/fp/no-let
let userId = '';
const fetchIdToken = async (scopes: string[], expectClaims: Record<string, unknown>) => {
const client = new MockClient({
appId: demoAppApplicationId,
prompt: Prompt.Login,
scopes,
});
await client.initSession(demoAppRedirectUri);
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
identifier: { username, password },
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
const idToken = await client.getIdTokenClaims();
expect(idToken).toMatchObject(expectClaims);
};
beforeAll(async () => {
const { id } = await createUserByAdmin(username, password);
// eslint-disable-next-line @silverhand/fp/no-mutation
userId = id;
await enableAllPasswordSignInMethods();
});
it('should be issued with correct `username` and `roles` claims', async () => {
const role = await createRole({});
await assignRolesToUser(userId, [role.id]);
await fetchIdToken(['username', 'roles'], {
username,
roles: [role.name],
});
});
});

View file

@ -11,6 +11,7 @@ export type UserClaim =
| 'email_verified'
| 'phone_number'
| 'phone_number_verified'
| 'roles'
| 'custom_data'
| 'identities';
@ -48,6 +49,12 @@ export enum UserScope {
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Identities = 'identities',
/**
* Scope for user's roles.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Roles = 'roles',
}
/**
@ -57,6 +64,7 @@ export const idTokenClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.fr
[UserScope.Profile]: ['name', 'picture', 'username'],
[UserScope.Email]: ['email', 'email_verified'],
[UserScope.Phone]: ['phone_number', 'phone_number_verified'],
[UserScope.Roles]: ['roles'],
[UserScope.CustomData]: [],
[UserScope.Identities]: [],
});
@ -68,6 +76,7 @@ export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.f
[UserScope.Profile]: [],
[UserScope.Email]: [],
[UserScope.Phone]: [],
[UserScope.Roles]: [],
[UserScope.CustomData]: ['custom_data'],
[UserScope.Identities]: ['identities'],
});