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:
parent
03bc7888b1
commit
2c340d3799
5 changed files with 86 additions and 7 deletions
6
.changeset/sweet-cows-burn.md
Normal file
6
.changeset/sweet-cows-burn.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@logto/core-kit": minor
|
||||
"@logto/core": minor
|
||||
---
|
||||
|
||||
support `roles` scope for ID token to issue `roles` claim
|
|
@ -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
|
||||
)
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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]];
|
||||
};
|
||||
|
||||
|
|
|
@ -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],
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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'],
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue