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:
commit
a03c0dbe5e
7 changed files with 47 additions and 35 deletions
|
@ -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]];
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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[];
|
|
||||||
};
|
|
||||||
|
|
|
@ -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'],
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue