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

Merge pull request #4831 from logto-io/gao-support-org-scope-in-for-id-token

This commit is contained in:
Gao Sun 2023-11-09 10:49:30 +08:00 committed by GitHub
commit c9db7b2ed9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 99 additions and 6 deletions

View file

@ -89,6 +89,7 @@ function Providers() {
UserScope.Email,
UserScope.Identities,
UserScope.CustomData,
UserScope.Organizations,
PredefinedScope.All,
...conditionalArray(
isCloud && cloudApi.scopes.CreateTenant,

View file

@ -45,6 +45,7 @@ export default function initOidc(
const {
resources: { findResourceByIndicator, findDefaultResource },
users: { findUserById },
organizations,
} = queries;
const { findUserScopesForResourceIndicator } = libraries.users;
const { findApplicationScopesForResourceIndicator } = libraries.applications;
@ -240,7 +241,10 @@ export default function initOidc(
await Promise.all(
getUserClaims(use, scope, claims, rejected).map(
async (claim) =>
[claim, await getUserClaimData(user, claim, libraries.users)] as const
[
claim,
await getUserClaimData(user, claim, libraries.users, organizations),
] as const
)
)
),

View file

@ -1,13 +1,17 @@
import type { UserClaim } from '@logto/core-kit';
import { idTokenClaims, userinfoClaims, UserScope } from '@logto/core-kit';
import type { User } from '@logto/schemas';
import type { OrganizationClaimItem, User } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import type { ClaimsParameterMember } from 'oidc-provider';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
const claimToUserKey: Readonly<
Record<Exclude<UserClaim, 'email_verified' | 'phone_number_verified' | 'roles'>, keyof User>
Record<
Exclude<UserClaim, 'email_verified' | 'phone_number_verified' | 'roles' | 'organizations'>,
keyof User
>
> = Object.freeze({
name: 'name',
picture: 'avatar',
@ -21,7 +25,8 @@ const claimToUserKey: Readonly<
export const getUserClaimData = async (
user: User,
claim: UserClaim,
userLibrary: Libraries['users']
userLibrary: Libraries['users'],
organizationQueries: Queries['organizations']
): Promise<unknown> => {
// LOG-4165: Change to proper key/function once profile fulfilling implemented
if (claim === 'email_verified') {
@ -38,6 +43,17 @@ export const getUserClaimData = async (
return roles.map(({ name }) => name);
}
if (claim === 'organizations') {
const data = await organizationQueries.relations.users.getOrganizationsByUserId(user.id);
return data.map(
({ id, organizationRoles }) =>
({
id,
roles: organizationRoles.map(({ name }) => name),
}) satisfies OrganizationClaimItem
);
}
return user[claimToUserKey[claim]];
};

View file

@ -64,6 +64,13 @@ class UserRelationQueries extends TwoRelationsQueries<typeof Organizations, type
return [Number(count), data];
}
/**
* Find all organizations that the user is a member of.
*
* @returns A Promise that resolves to an array of organization with roles. Each item
* is an organization object with `organizationRoles` property.
* @see {@link OrganizationWithRoles} for the definition of an organization with roles.
*/
async getOrganizationsByUserId(userId: string): Promise<Readonly<OrganizationWithRoles[]>> {
const roles = convertToIdentifiers(OrganizationRoles, true);
const organizations = convertToIdentifiers(Organizations, true);

View file

@ -7,16 +7,18 @@ 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 { OrganizationApiTest } from '#src/helpers/organization.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generatePassword, generateUsername } from '#src/utils.js';
describe('OpenID Connect ID token', () => {
const organizationApi = new OrganizationApiTest();
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 fetchIdToken = async (scopes: string[], expectClaims?: Record<string, unknown>) => {
const client = new MockClient({
appId: demoAppApplicationId,
prompt: Prompt.Login,
@ -30,7 +32,10 @@ describe('OpenID Connect ID token', () => {
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
const idToken = await client.getIdTokenClaims();
expect(idToken).toMatchObject(expectClaims);
if (expectClaims) {
expect(idToken).toMatchObject(expectClaims);
}
return idToken;
};
beforeAll(async () => {
@ -40,6 +45,14 @@ describe('OpenID Connect ID token', () => {
await enableAllPasswordSignInMethods();
});
afterEach(async () => {
await Promise.all([
organizationApi.cleanUp(),
organizationApi.roleApi.cleanUp(),
organizationApi.scopeApi.cleanUp(),
]);
});
it('should be issued with correct `username` and `roles` claims', async () => {
const role = await createRole({});
await assignRolesToUser(userId, [role.id]);
@ -48,4 +61,34 @@ describe('OpenID Connect ID token', () => {
roles: [role.name],
});
});
it('should be issued with `organizations` claim', async () => {
const [org1, org2] = await Promise.all([
organizationApi.create({ name: 'org1' }),
organizationApi.create({ name: 'org2' }),
]);
await Promise.all([
organizationApi.addUsers(org1.id, [userId]),
organizationApi.addUsers(org2.id, [userId]),
]);
const role = await organizationApi.roleApi.create({ name: 'member' });
await organizationApi.addUserRoles(org1.id, userId, [role.id]);
const idToken = await fetchIdToken(['urn:logto:scope:organizations']);
// @ts-expect-error type definition needs to be updated
const organizations = idToken.organizations as unknown;
expect(organizations).toHaveLength(2);
expect(organizations).toContainEqual({
id: org1.id,
roles: ['member'],
});
expect(organizations).toContainEqual({
id: org2.id,
roles: [],
});
});
});

View file

@ -76,3 +76,16 @@ export type OrganizationWithFeatured = Organization & {
usersCount?: number;
featuredUsers?: FeaturedUser[];
};
/**
* The item that describes a user's membership and roles in an organization. It is
* designed to be used in the `organizations` claim of the ID token.
*
* @see {@link https://github.com/logto-io/rfcs | RFC 0001} for more details.
*/
export type OrganizationClaimItem = {
/** The ID of the organization. */
id: string;
/** The role names of the user in the organization. */
roles: string[];
};

View file

@ -12,6 +12,7 @@ export type UserClaim =
| 'phone_number'
| 'phone_number_verified'
| 'roles'
| 'organizations'
| 'custom_data'
| 'identities';
@ -55,6 +56,12 @@ export enum UserScope {
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Roles = 'roles',
/**
* Scope for user's organizations data per [RFC 0001](https://https://github.com/logto-io/rfcs).
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Organizations = 'urn:logto:scope:organizations',
}
/**
@ -65,6 +72,7 @@ export const idTokenClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.fr
[UserScope.Email]: ['email', 'email_verified'],
[UserScope.Phone]: ['phone_number', 'phone_number_verified'],
[UserScope.Roles]: ['roles'],
[UserScope.Organizations]: ['organizations'],
[UserScope.CustomData]: [],
[UserScope.Identities]: [],
});
@ -77,6 +85,7 @@ export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.f
[UserScope.Email]: [],
[UserScope.Phone]: [],
[UserScope.Roles]: [],
[UserScope.Organizations]: [],
[UserScope.CustomData]: ['custom_data'],
[UserScope.Identities]: ['identities'],
});