From e057e2fc42d44d3686d778f124610294aae51ed6 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 9 Nov 2023 16:16:43 +0800 Subject: [PATCH] refactor: update organization scopes --- packages/core/src/oidc/scope.ts | 22 ++++++++++--------- .../core/src/routes/organization/index.ts | 1 + packages/core/src/utils/SchemaRouter.ts | 10 +++++++-- packages/demo-app/src/App.tsx | 3 +++ .../src/tests/api/oidc/id-token.test.ts | 22 +++++++++++-------- packages/schemas/src/types/organization.ts | 13 ----------- packages/toolkit/core-kit/src/openid.ts | 11 +++++++++- 7 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/core/src/oidc/scope.ts b/packages/core/src/oidc/scope.ts index 5502222cd..2485ee720 100644 --- a/packages/core/src/oidc/scope.ts +++ b/packages/core/src/oidc/scope.ts @@ -1,6 +1,6 @@ import type { UserClaim } from '@logto/core-kit'; import { idTokenClaims, userinfoClaims, UserScope } from '@logto/core-kit'; -import type { OrganizationClaimItem, User } from '@logto/schemas'; +import { type User } from '@logto/schemas'; import type { Nullable } from '@silverhand/essentials'; import type { ClaimsParameterMember } from 'oidc-provider'; @@ -9,7 +9,10 @@ import type Queries from '#src/tenants/Queries.js'; const claimToUserKey: Readonly< Record< - Exclude, + Exclude< + UserClaim, + 'email_verified' | 'phone_number_verified' | 'roles' | 'organizations' | 'organization_roles' + >, keyof User > > = Object.freeze({ @@ -43,15 +46,14 @@ export const getUserClaimData = async ( return roles.map(({ name }) => name); } - if (claim === 'organizations') { + if (claim === 'organizations' || claim === 'organization_roles') { const data = await organizationQueries.relations.users.getOrganizationsByUserId(user.id); - return data.map( - ({ id, organizationRoles }) => - ({ - id, - roles: organizationRoles.map(({ name }) => name), - }) satisfies OrganizationClaimItem - ); + + return claim === 'organizations' + ? data.map(({ id }) => id) + : data.flatMap(({ id, organizationRoles }) => + organizationRoles.map(({ name }) => `${id}:${name}`) + ); } return user[claimToUserKey[claim]]; diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 68eff94db..7dca8f70a 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -40,6 +40,7 @@ export default function organizationRoutes(...args: Rout errorHandler, searchFields: ['name'], disabled: { get: true }, + idLength: 12, }); router.get( diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index b8e83f12a..44daa4671 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -54,6 +54,12 @@ type SchemaRouterConfig = { errorHandler?: (error: unknown) => void; /** The fields that can be searched for the `GET /` route. */ searchFields: SearchOptions['fields']; + /** + * The length of the ID generated by `generateStandardId()`. + * + * @see {@link generateStandardId} for the default length. + */ + idLength?: number; }; type RelationRoutesConfig = { @@ -118,7 +124,7 @@ export default class SchemaRouter< }); } - const { disabled, searchFields } = this.config; + const { disabled, searchFields, idLength } = this.config; if (!disabled.get) { this.get( @@ -152,7 +158,7 @@ export default class SchemaRouter< async (ctx, next) => { // eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well with generics ctx.body = await queries.insert({ - id: generateStandardId(), + id: generateStandardId(idLength), ...ctx.guard.body, } as CreateSchema); ctx.status = 201; diff --git a/packages/demo-app/src/App.tsx b/packages/demo-app/src/App.tsx index b794407ab..cd587e21a 100644 --- a/packages/demo-app/src/App.tsx +++ b/packages/demo-app/src/App.tsx @@ -102,6 +102,9 @@ const App = () => { endpoint: window.location.origin, appId: demoAppApplicationId, prompt: Prompt.Login, + // Use enum values once JS SDK is updated + scopes: ['urn:logto:scope:organizations', 'urn:logto:scope:organization_roles'], + resources: ['urn:logto:resource:organizations'], }} >
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 6d396fa7e..ff1f27608 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 @@ -62,7 +62,7 @@ describe('OpenID Connect ID token', () => { }); }); - it('should be issued with `organizations` claim', async () => { + it('should be issued with `organizations` and `organization_roles` claims', async () => { const [org1, org2] = await Promise.all([ organizationApi.create({ name: 'org1' }), organizationApi.create({ name: 'org2' }), @@ -76,19 +76,23 @@ describe('OpenID Connect ID token', () => { const role = await organizationApi.roleApi.create({ name: 'member' }); await organizationApi.addUserRoles(org1.id, userId, [role.id]); + // Organizations claim 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: [], - }); + expect(organizations).toContainEqual(org1.id); + expect(organizations).toContainEqual(org2.id); + + // Organization roles claim + const idToken2 = await fetchIdToken(['urn:logto:scope:organization_roles']); + + // @ts-expect-error type definition needs to be updated + const organizationRoles = idToken2.organization_roles as unknown; + + expect(organizationRoles).toHaveLength(1); + expect(organizationRoles).toContainEqual(`${org1.id}:${role.name}`); }); }); diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 2dc2fb5d0..5f46cec3f 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -83,16 +83,3 @@ 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/openid.ts b/packages/toolkit/core-kit/src/openid.ts index be5476644..9f5700d85 100644 --- a/packages/toolkit/core-kit/src/openid.ts +++ b/packages/toolkit/core-kit/src/openid.ts @@ -24,6 +24,7 @@ export type UserClaim = | 'phone_number_verified' | 'roles' | 'organizations' + | 'organization_roles' | 'custom_data' | 'identities'; @@ -68,11 +69,17 @@ export enum UserScope { */ Roles = 'roles', /** - * Scope for user's organizations data per [RFC 0001](https://https://github.com/logto-io/rfcs). + * Scope for user's organization IDs and perform organization token grant per [RFC 0001](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', + /** + * Scope for user's organization roles per [RFC 0001](https://github.com/logto-io/rfcs). + * + * See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint. + */ + OrganizationRoles = 'urn:logto:scope:organization_roles', } /** @@ -84,6 +91,7 @@ export const idTokenClaims: Readonly> = Object.fr [UserScope.Phone]: ['phone_number', 'phone_number_verified'], [UserScope.Roles]: ['roles'], [UserScope.Organizations]: ['organizations'], + [UserScope.OrganizationRoles]: ['organization_roles'], [UserScope.CustomData]: [], [UserScope.Identities]: [], }); @@ -97,6 +105,7 @@ export const userinfoClaims: Readonly> = Object.f [UserScope.Phone]: [], [UserScope.Roles]: [], [UserScope.Organizations]: [], + [UserScope.OrganizationRoles]: [], [UserScope.CustomData]: ['custom_data'], [UserScope.Identities]: ['identities'], });