mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat: support organizations scope for ID token
This commit is contained in:
parent
6a5682ac5f
commit
9ae4d9aad7
7 changed files with 99 additions and 6 deletions
|
@ -89,6 +89,7 @@ function Providers() {
|
|||
UserScope.Email,
|
||||
UserScope.Identities,
|
||||
UserScope.CustomData,
|
||||
UserScope.Organizations,
|
||||
PredefinedScope.All,
|
||||
...conditionalArray(
|
||||
isCloud && cloudApi.scopes.CreateTenant,
|
||||
|
|
|
@ -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
|
||||
)
|
||||
)
|
||||
),
|
||||
|
|
|
@ -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]];
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -78,3 +78,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[];
|
||||
};
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue