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

Merge pull request #4847 from logto-io/gao-update-org-scopes

refactor: update organization scopes
This commit is contained in:
Gao Sun 2023-11-10 14:48:47 +08:00 committed by GitHub
commit a03c0dbe5e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 47 additions and 35 deletions

View file

@ -1,6 +1,6 @@
import type { UserClaim } from '@logto/core-kit'; import type { UserClaim } from '@logto/core-kit';
import { idTokenClaims, userinfoClaims, UserScope } 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 { Nullable } from '@silverhand/essentials';
import type { ClaimsParameterMember } from 'oidc-provider'; import type { ClaimsParameterMember } from 'oidc-provider';
@ -9,7 +9,10 @@ import type Queries from '#src/tenants/Queries.js';
const claimToUserKey: Readonly< const claimToUserKey: Readonly<
Record< Record<
Exclude<UserClaim, 'email_verified' | 'phone_number_verified' | 'roles' | 'organizations'>, Exclude<
UserClaim,
'email_verified' | 'phone_number_verified' | 'roles' | 'organizations' | 'organization_roles'
>,
keyof User keyof User
> >
> = Object.freeze({ > = Object.freeze({
@ -43,15 +46,14 @@ export const getUserClaimData = async (
return roles.map(({ name }) => name); return roles.map(({ name }) => name);
} }
if (claim === 'organizations') { if (claim === 'organizations' || claim === 'organization_roles') {
const data = await organizationQueries.relations.users.getOrganizationsByUserId(user.id); const data = await organizationQueries.relations.users.getOrganizationsByUserId(user.id);
return data.map(
({ id, organizationRoles }) => return claim === 'organizations'
({ ? data.map(({ id }) => id)
id, : data.flatMap(({ id, organizationRoles }) =>
roles: organizationRoles.map(({ name }) => name), organizationRoles.map(({ name }) => `${id}:${name}`)
}) satisfies OrganizationClaimItem );
);
} }
return user[claimToUserKey[claim]]; return user[claimToUserKey[claim]];

View file

@ -39,6 +39,7 @@ export default function organizationRoutes<T extends AuthedRouter>(...args: Rout
errorHandler, errorHandler,
searchFields: ['name'], searchFields: ['name'],
disabled: { get: true }, disabled: { get: true },
idLength: 12,
}); });
router.get( router.get(

View file

@ -54,6 +54,12 @@ type SchemaRouterConfig<Key extends string> = {
errorHandler?: (error: unknown) => void; errorHandler?: (error: unknown) => void;
/** The fields that can be searched for the `GET /` route. */ /** The fields that can be searched for the `GET /` route. */
searchFields: SearchOptions<Key>['fields']; searchFields: SearchOptions<Key>['fields'];
/**
* The length of the ID generated by `generateStandardId()`.
*
* @see {@link generateStandardId} for the default length.
*/
idLength?: number;
}; };
type RelationRoutesConfig = { type RelationRoutesConfig = {
@ -118,7 +124,7 @@ export default class SchemaRouter<
}); });
} }
const { disabled, searchFields } = this.config; const { disabled, searchFields, idLength } = this.config;
if (!disabled.get) { if (!disabled.get) {
this.get( this.get(
@ -152,7 +158,7 @@ export default class SchemaRouter<
async (ctx, next) => { async (ctx, next) => {
// eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well with generics // eslint-disable-next-line no-restricted-syntax -- `.omit()` doesn't play well with generics
ctx.body = await queries.insert({ ctx.body = await queries.insert({
id: generateStandardId(), id: generateStandardId(idLength),
...ctx.guard.body, ...ctx.guard.body,
} as CreateSchema); } as CreateSchema);
ctx.status = 201; ctx.status = 201;

View file

@ -102,6 +102,9 @@ const App = () => {
endpoint: window.location.origin, endpoint: window.location.origin,
appId: demoAppApplicationId, appId: demoAppApplicationId,
prompt: Prompt.Login, 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'],
}} }}
> >
<Main /> <Main />

View file

@ -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([ const [org1, org2] = await Promise.all([
organizationApi.create({ name: 'org1' }), organizationApi.create({ name: 'org1' }),
organizationApi.create({ name: 'org2' }), organizationApi.create({ name: 'org2' }),
@ -76,19 +76,23 @@ describe('OpenID Connect ID token', () => {
const role = await organizationApi.roleApi.create({ name: 'member' }); const role = await organizationApi.roleApi.create({ name: 'member' });
await organizationApi.addUserRoles(org1.id, userId, [role.id]); await organizationApi.addUserRoles(org1.id, userId, [role.id]);
// Organizations claim
const idToken = await fetchIdToken(['urn:logto:scope:organizations']); const idToken = await fetchIdToken(['urn:logto:scope:organizations']);
// @ts-expect-error type definition needs to be updated // @ts-expect-error type definition needs to be updated
const organizations = idToken.organizations as unknown; const organizations = idToken.organizations as unknown;
expect(organizations).toHaveLength(2); expect(organizations).toHaveLength(2);
expect(organizations).toContainEqual({ expect(organizations).toContainEqual(org1.id);
id: org1.id, expect(organizations).toContainEqual(org2.id);
roles: ['member'],
}); // Organization roles claim
expect(organizations).toContainEqual({ const idToken2 = await fetchIdToken(['urn:logto:scope:organization_roles']);
id: org2.id,
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}`);
}); });
}); });

View file

@ -81,16 +81,3 @@ export type OrganizationWithFeatured = Organization & {
usersCount?: number; usersCount?: number;
featuredUsers?: FeaturedUser[]; 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

@ -24,6 +24,7 @@ export type UserClaim =
| 'phone_number_verified' | 'phone_number_verified'
| 'roles' | 'roles'
| 'organizations' | 'organizations'
| 'organization_roles'
| 'custom_data' | 'custom_data'
| 'identities'; | 'identities';
@ -68,11 +69,17 @@ export enum UserScope {
*/ */
Roles = 'roles', 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. * See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/ */
Organizations = 'urn:logto:scope:organizations', 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<Record<UserScope, UserClaim[]>> = Object.fr
[UserScope.Phone]: ['phone_number', 'phone_number_verified'], [UserScope.Phone]: ['phone_number', 'phone_number_verified'],
[UserScope.Roles]: ['roles'], [UserScope.Roles]: ['roles'],
[UserScope.Organizations]: ['organizations'], [UserScope.Organizations]: ['organizations'],
[UserScope.OrganizationRoles]: ['organization_roles'],
[UserScope.CustomData]: [], [UserScope.CustomData]: [],
[UserScope.Identities]: [], [UserScope.Identities]: [],
}); });
@ -97,6 +105,7 @@ export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.f
[UserScope.Phone]: [], [UserScope.Phone]: [],
[UserScope.Roles]: [], [UserScope.Roles]: [],
[UserScope.Organizations]: [], [UserScope.Organizations]: [],
[UserScope.OrganizationRoles]: [],
[UserScope.CustomData]: ['custom_data'], [UserScope.CustomData]: ['custom_data'],
[UserScope.Identities]: ['identities'], [UserScope.Identities]: ['identities'],
}); });