0
Fork 0
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:
Gao Sun 2023-11-30 13:51:53 +08:00 committed by GitHub
parent 949708b0f9
commit b4f702a860
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 124 additions and 71 deletions

View 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",
}
]
}
```

View file

@ -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)
),
},
{

View file

@ -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']);
});
});

View file

@ -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[]

View file

@ -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'],