diff --git a/packages/console/src/App.tsx b/packages/console/src/App.tsx index dd847ddd9..4252ebcb2 100644 --- a/packages/console/src/App.tsx +++ b/packages/console/src/App.tsx @@ -89,6 +89,7 @@ function Providers() { UserScope.Email, UserScope.Identities, UserScope.CustomData, + UserScope.Organizations, PredefinedScope.All, ...conditionalArray( isCloud && cloudApi.scopes.CreateTenant, diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 1d4bd603f..e1c73dea4 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -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 ) ) ), diff --git a/packages/core/src/oidc/scope.ts b/packages/core/src/oidc/scope.ts index 132e52613..5502222cd 100644 --- a/packages/core/src/oidc/scope.ts +++ b/packages/core/src/oidc/scope.ts @@ -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, keyof User> + Record< + Exclude, + 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 => { // 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]]; }; diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index 6bdc04f58..aeea229d4 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -64,6 +64,13 @@ class UserRelationQueries extends TwoRelationsQueries> { const roles = convertToIdentifiers(OrganizationRoles, true); const organizations = convertToIdentifiers(Organizations, true); diff --git a/packages/integration-tests/src/tests/api/oidc/id-token.test.ts b/packages/integration-tests/src/tests/api/oidc/id-token.test.ts index d3993f265..6d396fa7e 100644 --- a/packages/integration-tests/src/tests/api/oidc/id-token.test.ts +++ b/packages/integration-tests/src/tests/api/oidc/id-token.test.ts @@ -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) => { + const fetchIdToken = async (scopes: string[], expectClaims?: Record) => { 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: [], + }); + }); }); diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index b5849e637..fea4b712e 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -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[]; +}; diff --git a/packages/toolkit/core-kit/src/scope.ts b/packages/toolkit/core-kit/src/scope.ts index ee9b26326..f9c1f4901 100644 --- a/packages/toolkit/core-kit/src/scope.ts +++ b/packages/toolkit/core-kit/src/scope.ts @@ -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> = 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> = Object.f [UserScope.Email]: [], [UserScope.Phone]: [], [UserScope.Roles]: [], + [UserScope.Organizations]: [], [UserScope.CustomData]: ['custom_data'], [UserScope.Identities]: ['identities'], });