mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor(core): return organization data in userinfo endpoint (#5010)
* refactor(core): return organization data in userinfo endpoint * chore(core): add comments * chore: fix tests
This commit is contained in:
parent
949708b0f9
commit
b4f702a860
5 changed files with 124 additions and 71 deletions
20
.changeset/silent-eyes-leave.md
Normal file
20
.changeset/silent-eyes-leave.md
Normal file
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
"@logto/core-kit": patch
|
||||
"@logto/core": patch
|
||||
---
|
||||
|
||||
userinfo endpoint will return `organization_data` claim if organization scope is requested
|
||||
|
||||
The claim includes all organizations that the user is a member of with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"organization_data": [
|
||||
{
|
||||
"id": "organization_id",
|
||||
"name": "organization_name",
|
||||
"description": "organization_description",
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
|
@ -1,5 +1,6 @@
|
|||
/* istanbul ignore file */
|
||||
|
||||
import assert from 'node:assert';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { userClaims } from '@logto/core-kit';
|
||||
|
@ -32,7 +33,7 @@ import type Queries from '#src/tenants/Queries.js';
|
|||
import defaults from './defaults.js';
|
||||
import { registerGrants } from './grants/index.js';
|
||||
import { findResource, findResourceScopes, getSharedResourceServerData } from './resource.js';
|
||||
import { getUserClaimData, getUserClaims } from './scope.js';
|
||||
import { getAcceptedUserClaims, getUserClaimsData } from './scope.js';
|
||||
import { OIDCExtraParametersKey, InteractionMode } from './type.js';
|
||||
|
||||
// Temporarily removed 'EdDSA' since it's not supported by browser yet
|
||||
|
@ -195,6 +196,12 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
|
|||
return {
|
||||
accountId: sub,
|
||||
claims: async (use, scope, claims, rejected) => {
|
||||
assert(
|
||||
use === 'id_token' || use === 'userinfo',
|
||||
'use should be either `id_token` or `userinfo`'
|
||||
);
|
||||
const acceptedClaims = getAcceptedUserClaims(use, scope, claims, rejected);
|
||||
|
||||
return snakecaseKeys(
|
||||
{
|
||||
/**
|
||||
|
@ -204,15 +211,7 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li
|
|||
*/
|
||||
sub,
|
||||
...Object.fromEntries(
|
||||
await Promise.all(
|
||||
getUserClaims(use, scope, claims, rejected).map(
|
||||
async (claim) =>
|
||||
[
|
||||
claim,
|
||||
await getUserClaimData(user, claim, libraries.users, organizations),
|
||||
] as const
|
||||
)
|
||||
)
|
||||
await getUserClaimsData(user, acceptedClaims, libraries.users, organizations)
|
||||
),
|
||||
},
|
||||
{
|
||||
|
|
|
@ -1,19 +1,19 @@
|
|||
import { getUserClaims } from './scope.js';
|
||||
import { getAcceptedUserClaims } from './scope.js';
|
||||
|
||||
const use = {
|
||||
idToken: 'id_token',
|
||||
userinfo: 'userinfo',
|
||||
};
|
||||
} as const;
|
||||
|
||||
describe('OIDC getUserClaims()', () => {
|
||||
it('should return proper ID Token claims', () => {
|
||||
expect(getUserClaims(use.idToken, 'openid profile', {}, [])).toEqual([
|
||||
expect(getAcceptedUserClaims(use.idToken, 'openid profile', {}, [])).toEqual([
|
||||
'name',
|
||||
'picture',
|
||||
'username',
|
||||
]);
|
||||
|
||||
expect(getUserClaims(use.idToken, 'openid profile email phone', {}, [])).toEqual([
|
||||
expect(getAcceptedUserClaims(use.idToken, 'openid profile email phone', {}, [])).toEqual([
|
||||
'name',
|
||||
'picture',
|
||||
'username',
|
||||
|
@ -23,36 +23,25 @@ describe('OIDC getUserClaims()', () => {
|
|||
'phone_number_verified',
|
||||
]);
|
||||
|
||||
expect(getUserClaims(use.idToken, 'openid profile custom_data identities', {}, [])).toEqual([
|
||||
'name',
|
||||
'picture',
|
||||
'username',
|
||||
]);
|
||||
expect(
|
||||
getAcceptedUserClaims(use.idToken, 'openid profile custom_data identities', {}, [])
|
||||
).toEqual(['name', 'picture', 'username']);
|
||||
|
||||
expect(getUserClaims(use.idToken, 'openid profile email', {}, ['email_verified'])).toEqual([
|
||||
'name',
|
||||
'picture',
|
||||
'username',
|
||||
'email',
|
||||
]);
|
||||
expect(
|
||||
getAcceptedUserClaims(use.idToken, 'openid profile email', {}, ['email_verified'])
|
||||
).toEqual(['name', 'picture', 'username', 'email']);
|
||||
});
|
||||
|
||||
it('should return proper Userinfo claims', () => {
|
||||
expect(getUserClaims(use.userinfo, 'openid profile custom_data identities', {}, [])).toEqual([
|
||||
'name',
|
||||
'picture',
|
||||
'username',
|
||||
'custom_data',
|
||||
'identities',
|
||||
]);
|
||||
expect(
|
||||
getAcceptedUserClaims(use.userinfo, 'openid profile custom_data identities', {}, [])
|
||||
).toEqual(['name', 'picture', 'username', 'custom_data', 'identities']);
|
||||
});
|
||||
|
||||
// Ignore `_claims` since [Claims Parameter](https://github.com/panva/node-oidc-provider/tree/main/docs#featuresclaimsparameter) is not enabled
|
||||
it('should ignore claims parameter', () => {
|
||||
expect(getUserClaims(use.idToken, 'openid profile custom_data', { email: null }, [])).toEqual([
|
||||
'name',
|
||||
'picture',
|
||||
'username',
|
||||
]);
|
||||
expect(
|
||||
getAcceptedUserClaims(use.idToken, 'openid profile custom_data', { email: null }, [])
|
||||
).toEqual(['name', 'picture', 'username']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import assert from 'node:assert';
|
||||
|
||||
import type { UserClaim } from '@logto/core-kit';
|
||||
import { idTokenClaims, userinfoClaims, UserScope } from '@logto/core-kit';
|
||||
import { type User } from '@logto/schemas';
|
||||
import type { Nullable } from '@silverhand/essentials';
|
||||
import { pick, type Nullable, cond } from '@silverhand/essentials';
|
||||
import type { ClaimsParameterMember } from 'oidc-provider';
|
||||
|
||||
import type Libraries from '#src/tenants/Libraries.js';
|
||||
|
@ -11,7 +13,12 @@ const claimToUserKey: Readonly<
|
|||
Record<
|
||||
Exclude<
|
||||
UserClaim,
|
||||
'email_verified' | 'phone_number_verified' | 'roles' | 'organizations' | 'organization_roles'
|
||||
| 'email_verified'
|
||||
| 'phone_number_verified'
|
||||
| 'roles'
|
||||
| 'organizations'
|
||||
| 'organization_data'
|
||||
| 'organization_roles'
|
||||
>,
|
||||
keyof User
|
||||
>
|
||||
|
@ -25,43 +32,80 @@ const claimToUserKey: Readonly<
|
|||
identities: 'identities',
|
||||
});
|
||||
|
||||
export const getUserClaimData = async (
|
||||
/**
|
||||
* Get user claims data according to the claims.
|
||||
*
|
||||
* @param user The current user.
|
||||
* @param claims The claims to be fulfilled.
|
||||
* @param userLibrary The user library of the current tenant.
|
||||
* @param organizationQueries The organization queries of the current tenant.
|
||||
* @returns A Promise that resolves to an array of fulfilled claims.
|
||||
*/
|
||||
export const getUserClaimsData = async (
|
||||
user: User,
|
||||
claim: UserClaim,
|
||||
claims: UserClaim[],
|
||||
userLibrary: Libraries['users'],
|
||||
organizationQueries: Queries['organizations']
|
||||
): Promise<unknown> => {
|
||||
// LOG-4165: Change to proper key/function once profile fulfilling implemented
|
||||
if (claim === 'email_verified') {
|
||||
return Boolean(user.primaryEmail);
|
||||
}
|
||||
): Promise<ReadonlyArray<[UserClaim, unknown]>> => {
|
||||
const organizations = cond(
|
||||
claims.some((claim) => claim.startsWith('organization')) &&
|
||||
(await organizationQueries.relations.users.getOrganizationsByUserId(user.id))
|
||||
);
|
||||
|
||||
// LOG-4165: Change to proper key/function once profile fulfilling implemented
|
||||
if (claim === 'phone_number_verified') {
|
||||
return Boolean(user.primaryPhone);
|
||||
}
|
||||
|
||||
if (claim === 'roles') {
|
||||
const roles = await userLibrary.findUserRoles(user.id);
|
||||
return roles.map(({ name }) => name);
|
||||
}
|
||||
|
||||
if (claim === 'organizations' || claim === 'organization_roles') {
|
||||
const data = await organizationQueries.relations.users.getOrganizationsByUserId(user.id);
|
||||
|
||||
return claim === 'organizations'
|
||||
? data.map(({ id }) => id)
|
||||
: data.flatMap(({ id, organizationRoles }) =>
|
||||
organizationRoles.map(({ name }) => `${id}:${name}`)
|
||||
);
|
||||
}
|
||||
|
||||
return user[claimToUserKey[claim]];
|
||||
return Promise.all(
|
||||
claims.map(async (claim) => {
|
||||
switch (claim) {
|
||||
case 'email_verified': {
|
||||
// LOG-4165: Change to proper key/function once profile fulfilling implemented
|
||||
return [claim, Boolean(user.primaryEmail)];
|
||||
}
|
||||
case 'phone_number_verified': {
|
||||
// LOG-4165: Change to proper key/function once profile fulfilling implemented
|
||||
return [claim, Boolean(user.primaryPhone)];
|
||||
}
|
||||
case 'roles': {
|
||||
const roles = await userLibrary.findUserRoles(user.id);
|
||||
return [claim, roles.map(({ name }) => name)];
|
||||
}
|
||||
case 'organizations': {
|
||||
assert(organizations, 'organizations should be defined');
|
||||
return [claim, organizations.map(({ id }) => id)];
|
||||
}
|
||||
case 'organization_roles': {
|
||||
assert(organizations, 'organizations should be defined');
|
||||
return [
|
||||
claim,
|
||||
organizations.flatMap(({ id, organizationRoles }) =>
|
||||
organizationRoles.map(({ name }) => `${id}:${name}`)
|
||||
),
|
||||
];
|
||||
}
|
||||
case 'organization_data': {
|
||||
assert(organizations, 'organizations should be defined');
|
||||
return [
|
||||
claim,
|
||||
organizations.map((element) => pick(element, 'id', 'name', 'description')),
|
||||
];
|
||||
}
|
||||
default: {
|
||||
return [claim, user[claimToUserKey[claim]]];
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Ignore `_claims` since [Claims Parameter](https://github.com/panva/node-oidc-provider/tree/main/docs#featuresclaimsparameter) is not enabled
|
||||
export const getUserClaims = (
|
||||
use: string,
|
||||
/**
|
||||
* Get accepted user claims according to the context.
|
||||
*
|
||||
* @param use Where the claims will be used. Either `id_token` or `userinfo`.
|
||||
* @param scope The scope of the request. Each scope will be expanded to the corresponding claims.
|
||||
* @param _claims Claims parameter. (Ignored since [Claims Parameter](https://github.com/panva/node-oidc-provider/tree/main/docs#featuresclaimsparameter) is not enabled)
|
||||
* @param rejected Claims rejected by the user.
|
||||
* @returns An array of accepted user claims.
|
||||
*/
|
||||
export const getAcceptedUserClaims = (
|
||||
use: 'id_token' | 'userinfo',
|
||||
scope: string,
|
||||
_claims: Record<string, Nullable<ClaimsParameterMember>>,
|
||||
rejected: string[]
|
||||
|
|
|
@ -24,6 +24,7 @@ export type UserClaim =
|
|||
| 'phone_number_verified'
|
||||
| 'roles'
|
||||
| 'organizations'
|
||||
| 'organization_data'
|
||||
| 'organization_roles'
|
||||
| 'custom_data'
|
||||
| 'identities';
|
||||
|
@ -104,7 +105,7 @@ export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.f
|
|||
[UserScope.Email]: [],
|
||||
[UserScope.Phone]: [],
|
||||
[UserScope.Roles]: [],
|
||||
[UserScope.Organizations]: [],
|
||||
[UserScope.Organizations]: ['organization_data'],
|
||||
[UserScope.OrganizationRoles]: [],
|
||||
[UserScope.CustomData]: ['custom_data'],
|
||||
[UserScope.Identities]: ['identities'],
|
||||
|
|
Loading…
Reference in a new issue