0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat: organization_token grant

This commit is contained in:
Gao Sun 2023-11-08 15:30:05 +08:00
parent 9ae4d9aad7
commit 9e4ee1be19
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
19 changed files with 534 additions and 50 deletions

2
packages/core/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
# OIDC uses `lib` as the source directory, we need to keep it for custom type definitions
!src/include.d/oidc-provider/lib

View file

@ -143,6 +143,14 @@
"rules": {
"import/no-unused-modules": "off"
}
},
{
"files": [
"src/include.d/oidc-provider/**/*"
],
"rules": {
"unicorn/filename-case": "off"
}
}
]
},

View file

@ -69,6 +69,7 @@ const grantTypeToExchangeByType: Record<GrantType, token.ExchangeByType> = {
[GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode,
[GrantType.RefreshToken]: token.ExchangeByType.RefreshToken,
[GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials,
[GrantType.OrganizationToken]: token.ExchangeByType.RefreshToken,
};
const getExchangeByType = (grantType: unknown): token.ExchangeByType => {

View file

@ -0,0 +1,4 @@
declare module 'oidc-provider/lib/helpers/_/difference.js' {
/** Returns an array of values that are in `setA` but not in `setB`. */
export default function difference<T>(setA: T[], setB: T[]): T[];
}

View file

@ -0,0 +1,5 @@
declare module 'oidc-provider/lib/helpers/revoke.js' {
import type { KoaContextWithOIDC } from 'oidc-provider';
export default function revoke(ctx: KoaContextWithOIDC, grantId: string): Promise<void>;
}

View file

@ -0,0 +1,6 @@
declare module 'oidc-provider/lib/helpers/validate_presence.js' {
export default function validatePresence(
ctx: KoaContextWithOIDC,
...required: readonly string[]
): void;
}

View file

@ -0,0 +1,18 @@
declare module 'oidc-provider/lib/helpers/weak_cache.js' {
import type Provider, { type Configuration } from 'oidc-provider';
/** Deeply make all properties of a record required. */
type DeepRequired<T> = T extends Record<string | number | symbol, unknown>
? {
[P in keyof T]-?: DeepRequired<T[P]>;
}
: T;
type RequiredConfiguration = {
[K in keyof Configuration]-?: DeepRequired<Configuration[K]>;
};
export default function instance(ctx: Provider): {
configuration: () => RequiredConfiguration;
};
}

View file

@ -0,0 +1,277 @@
/**
* @overview This file implements the custom grant type for organization token, which is defined
* in RFC 0001.
*
* Note the code is edited from the `refresh_token` grant type from [oidc-provider](https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/actions/grants/refresh_token.js).
* Most parts are kept the same unless it requires changes for TypeScript or RFC 0001.
*
* For "RFC 0001"-related edited parts, we added comments with `=== RFC 0001 ===` and
* `=== End RFC 0001 ===` to indicate the changes.
*
* @remarks
* The original implementation supports DPoP and mutual TLS client authentication, which are not
* enabled in Logto. So we removed related code to simplify the implementation. They can be added
* back if needed.
*
* The original implementation also supports issuing ID tokens. But we don't support it for now
* due to the lack of development type definitions in the `IdToken` class.
*/
import assert from 'node:assert';
import { UserScope, buildOrganizationUrn } from '@logto/core-kit';
import { GrantType } from '@logto/schemas';
import { isKeyInObject } from '@silverhand/essentials';
import type Provider from 'oidc-provider';
import { errors } from 'oidc-provider';
import difference from 'oidc-provider/lib/helpers/_/difference.js';
import revoke from 'oidc-provider/lib/helpers/revoke.js';
import validatePresence from 'oidc-provider/lib/helpers/validate_presence.js';
import instance from 'oidc-provider/lib/helpers/weak_cache.js';
import { type EnvSet } from '#src/env-set/index.js';
import type OrganizationQueries from '#src/queries/organizations.js';
import { getSharedResourceServerData, reversedResourceAccessTokenTtl } from '../resource.js';
const {
InvalidClient,
InvalidRequest,
InvalidGrant,
InvalidScope,
InsufficientScope,
AccessDenied,
} = errors;
const grantType = GrantType.OrganizationToken;
/** The valid parameters for the `organization_token` grant type. */
export const parameters = Object.freeze(['refresh_token', 'organization_id', 'scope'] as const);
/**
* The required parameters for the `organization_token` grant type.
*
* @see {@link parameters} for the full list of valid parameters.
*/
const requiredParameters = Object.freeze([
'refresh_token',
'organization_id',
] as const) satisfies ReadonlyArray<(typeof parameters)[number]>;
/**
* The required scope for the `urn:logto:grant-type:organization_token` grant type.
*
* @see {@link GrantType.OrganizationToken}
*/
const requiredScope = UserScope.Organizations;
// We have to disable the rules because the original implementation is written in JavaScript and
// uses mutable variables.
/* eslint-disable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */
export const buildHandler: (
envSet: EnvSet,
queries: OrganizationQueries
// eslint-disable-next-line complexity
) => Parameters<Provider['registerGrantType']>['1'] = (envSet, queries) => async (ctx, next) => {
validatePresence(ctx, ...requiredParameters);
const providerInstance = instance(ctx.oidc.provider);
const { rotateRefreshToken } = providerInstance.configuration();
const { client, params, requestParamScopes, provider } = ctx.oidc;
const { RefreshToken, Account, AccessToken, Grant } = provider;
assert(client, new InvalidClient('client must be available'));
assert(params, new InvalidGrant('parameters must be available'));
// @gao: I believe the presence of the param is validated by required parameters of this grant.
// Add `String` to make TS happy.
let refreshTokenValue = String(params.refresh_token);
let refreshToken = await RefreshToken.find(refreshTokenValue, { ignoreExpiration: true });
if (!refreshToken) {
throw new InvalidGrant('refresh token not found');
}
if (refreshToken.clientId !== client.clientId) {
throw new InvalidGrant('client mismatch');
}
if (refreshToken.isExpired) {
throw new InvalidGrant('refresh token is expired');
}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- code from oidc-provider
if (client.tlsClientCertificateBoundAccessTokens || refreshToken['x5t#S256']) {
throw new InvalidRequest(
'mutual TLS client authentication is not supported for this grant type'
);
}
/* === RFC 0001 === */
// Validate if the refresh token has the required scope from RFC 0001.
if (!refreshToken.scopes.has(requiredScope)) {
throw new InsufficientScope('refresh token missing required scope', requiredScope);
}
/* === End RFC 0001 === */
if (!refreshToken.grantId) {
throw new InvalidGrant('grant id not found');
}
const grant = await Grant.find(refreshToken.grantId, {
ignoreExpiration: true,
});
if (!grant) {
throw new InvalidGrant('grant not found');
}
/**
* It's actually available on the `BaseModel` class - but missing from the typings.
*
* @see {@link https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/models/base_model.js#L128 | oidc-provider/lib/models/base_model.js#L128}
*/
if (isKeyInObject(grant, 'isExpired') && grant.isExpired) {
throw new InvalidGrant('grant is expired');
}
if (grant.clientId !== client.clientId) {
throw new InvalidGrant('client mismatch');
}
if (params.scope) {
const missing = difference([...requestParamScopes], [...refreshToken.scopes]);
if (missing.length > 0) {
throw new InvalidScope(
`refresh token missing requested ${missing.length > 1 ? 'scopes' : 'scope'}`,
missing.join(' ')
);
}
}
if (refreshToken.jkt) {
throw new InvalidRequest('DPoP is not supported for this grant type');
}
ctx.oidc.entity('RefreshToken', refreshToken);
ctx.oidc.entity('Grant', grant);
// @ts-expect-error -- code from oidc-provider. the original type definition does not include
// `RefreshToken` but it's actually available.
const account = await Account.findAccount(ctx, refreshToken.accountId, refreshToken);
if (!account) {
throw new InvalidGrant('refresh token invalid (referenced account not found)');
}
if (refreshToken.accountId !== grant.accountId) {
throw new InvalidGrant('accountId mismatch');
}
ctx.oidc.entity('Account', account);
if (refreshToken.consumed) {
await Promise.all([refreshToken.destroy(), revoke(ctx, refreshToken.grantId)]);
throw new InvalidGrant('refresh token already used');
}
/* === RFC 0001 === */
// Check membership
const organizationId = String(params.organization_id);
if (!(await queries.relations.users.exists(organizationId, account.accountId))) {
throw new AccessDenied('user is not a member of the organization');
}
/* === End RFC 0001 === */
if (
rotateRefreshToken === true ||
(typeof rotateRefreshToken === 'function' && (await rotateRefreshToken(ctx)))
) {
await refreshToken.consume();
ctx.oidc.entity('RotatedRefreshToken', refreshToken);
refreshToken = new RefreshToken({
accountId: refreshToken.accountId,
acr: refreshToken.acr,
amr: refreshToken.amr,
authTime: refreshToken.authTime,
claims: refreshToken.claims,
client,
expiresWithSession: refreshToken.expiresWithSession,
iiat: refreshToken.iiat,
grantId: refreshToken.grantId,
gty: refreshToken.gty!,
nonce: refreshToken.nonce,
resource: refreshToken.resource,
rotations: typeof refreshToken.rotations === 'number' ? refreshToken.rotations + 1 : 1,
scope: refreshToken.scope!,
sessionUid: refreshToken.sessionUid,
sid: refreshToken.sid,
'x5t#S256': refreshToken['x5t#S256'],
jkt: refreshToken.jkt,
});
if (refreshToken.gty && !refreshToken.gty.endsWith(grantType)) {
refreshToken.gty = `${refreshToken.gty} ${grantType}`;
}
ctx.oidc.entity('RefreshToken', refreshToken);
refreshTokenValue = await refreshToken.save();
}
const at = new AccessToken({
accountId: account.accountId,
client,
expiresWithSession: refreshToken.expiresWithSession,
grantId: refreshToken.grantId!,
gty: refreshToken.gty!,
sessionUid: refreshToken.sessionUid,
sid: refreshToken.sid,
scope: undefined!,
});
if (at.gty && !at.gty.endsWith(grantType)) {
at.gty = `${at.gty} ${grantType}`;
}
/* === RFC 0001 === */
const audience = buildOrganizationUrn(organizationId);
/** All available scopes for the user in the organization. */
const availableScopes = await queries.relations.rolesUsers
.getUserScopes(organizationId, account.accountId)
.then((scopes) => scopes.map(({ name }) => name));
/** The scopes requested by the client. If not provided, use the scopes from the refresh token. */
const scope = params.scope ? requestParamScopes : refreshToken.scopes;
/** The intersection of the available scopes and the requested scopes. */
const issuedScopes = availableScopes.filter((name) => scope.has(name)).join(' ');
at.aud = audience;
// Note: the original implementation uses `new provider.ResourceServer` to create the resource
// server. But it's not available in the typings. The class is actually very simple and holds
// no provider-specific context. So we just create the object manually.
// See https://github.com/panva/node-oidc-provider/blob/cf2069cbb31a6a855876e95157372d25dde2511c/lib/helpers/resource_server.js
at.resourceServer = {
...getSharedResourceServerData(envSet),
accessTokenTTL: reversedResourceAccessTokenTtl,
audience,
scope: availableScopes.join(' '),
};
at.scope = issuedScopes;
/* === End RFC 0001 === */
ctx.oidc.entity('AccessToken', at);
const accessToken = await at.save();
ctx.body = {
access_token: accessToken,
expires_in: at.expiration,
// `id_token: idToken` -- see the comment at the beginning of this file.
refresh_token: refreshTokenValue,
scope: at.scope,
token_type: at.tokenType,
};
await next();
};
/* eslint-enable @silverhand/fp/no-let, @typescript-eslint/no-non-null-assertion, @silverhand/fp/no-mutation, unicorn/no-array-method-this-argument */

View file

@ -8,6 +8,7 @@ import {
customClientMetadataDefault,
CustomClientMetadataKey,
demoAppApplicationId,
GrantType,
inSeconds,
logtoCookieKey,
type LogtoUiCookie,
@ -15,7 +16,7 @@ import {
import { conditional, tryThat } from '@silverhand/essentials';
import i18next from 'i18next';
import koaBody from 'koa-body';
import Provider, { errors, type ResourceServer } from 'oidc-provider';
import Provider, { errors } from 'oidc-provider';
import snakecaseKeys from 'snakecase-keys';
import type { EnvSet } from '#src/env-set/index.js';
@ -30,6 +31,8 @@ import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
import defaults from './defaults.js';
import * as organizationToken from './grants/organization-token.js';
import { findResource, findResourceScopes, getSharedResourceServerData } from './resource.js';
import { getUserClaimData, getUserClaims } from './scope.js';
import { OIDCExtraParametersKey, InteractionMode } from './type.js';
@ -43,12 +46,10 @@ export default function initOidc(
libraries: Libraries
): Provider {
const {
resources: { findResourceByIndicator, findDefaultResource },
resources: { findDefaultResource },
users: { findUserById },
organizations,
} = queries;
const { findUserScopesForResourceIndicator } = libraries.users;
const { findApplicationScopesForResourceIndicator } = libraries.applications;
const logoutSource = readFileSync('static/html/logout.html', 'utf8');
const logoutSuccessSource = readFileSync('static/html/post-logout/index.html', 'utf8');
@ -116,47 +117,19 @@ export default function initOidc(
// Disable the auto use of authorization_code granted resource feature
useGrantedResource: () => false,
getResourceServerInfo: async (ctx, indicator) => {
const resourceServer = await findResourceByIndicator(indicator);
const { oidc } = ctx;
const resourceServer = await findResource(queries, indicator);
if (!resourceServer) {
throw new errors.InvalidTarget();
}
const { accessTokenTtl: accessTokenTTL } = resourceServer;
const result = {
accessTokenFormat: 'jwt',
const scopes = await findResourceScopes(queries, libraries, ctx, indicator);
return {
...getSharedResourceServerData(envSet),
accessTokenTTL,
jwt: {
sign: { alg: envSet.oidc.jwkSigningAlg },
},
scope: '',
} satisfies ResourceServer;
const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId;
if (userId) {
const scopes = await findUserScopesForResourceIndicator(userId, indicator);
return {
...result,
scope: scopes.map(({ name }) => name).join(' '),
};
}
const clientId = oidc.entities.Client?.clientId;
// Machine to machine app
if (clientId) {
const scopes = await findApplicationScopesForResourceIndicator(clientId, indicator);
return {
...result,
scope: scopes.map(({ name }) => name).join(' '),
};
}
return result;
scope: scopes.map(({ name }) => name).join(' '),
};
},
},
},
@ -316,6 +289,13 @@ export default function initOidc(
addOidcEventListeners(oidc, queries);
// Register custom grant types
oidc.registerGrantType(
GrantType.OrganizationToken,
organizationToken.buildHandler(envSet, organizations),
[...organizationToken.parameters]
);
// Provide audit log context for event listeners
oidc.use(koaAuditLog(queries));
/**

View file

@ -0,0 +1,95 @@
import { ReservedResource } from '@logto/core-kit';
import { type Resource } from '@logto/schemas';
import { type Nullable } from '@silverhand/essentials';
import { type KoaContextWithOIDC } from 'oidc-provider';
import type Provider from 'oidc-provider';
import { type EnvSet } from '#src/env-set/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
const isReservedResource = (indicator: string): indicator is ReservedResource =>
// eslint-disable-next-line no-restricted-syntax -- it's the best way to do it
Object.values(ReservedResource).includes(indicator as ReservedResource);
export const getSharedResourceServerData = (
envSet: EnvSet
): Pick<Provider.ResourceServer, 'accessTokenFormat' | 'jwt'> => ({
accessTokenFormat: 'jwt',
jwt: {
sign: { alg: envSet.oidc.jwkSigningAlg },
},
});
/**
* Find the scopes for a given resource indicator according to the subject in the
* context. The subject can be either a user or an application.
*
* This function also handles the reserved resources.
*
* @see {@link ReservedResource} for the list of reserved resources.
*/
export const findResourceScopes = async (
queries: Queries,
libraries: Libraries,
ctx: KoaContextWithOIDC,
indicator: string
): Promise<ReadonlyArray<{ name: string }>> => {
if (isReservedResource(indicator)) {
switch (indicator) {
case ReservedResource.Organization: {
const [, rows] = await queries.organizations.scopes.findAll();
return rows;
}
default: {
return [];
}
}
}
const { oidc } = ctx;
const {
users: { findUserScopesForResourceIndicator },
applications: { findApplicationScopesForResourceIndicator },
} = libraries;
const userId = oidc.session?.accountId ?? oidc.entities.Account?.accountId;
if (userId) {
return findUserScopesForResourceIndicator(userId, indicator);
}
const clientId = oidc.entities.Client?.clientId;
if (clientId) {
return findApplicationScopesForResourceIndicator(clientId, indicator);
}
return [];
};
/**
* The default TTL (Time To Live) of the access token for the reversed resources.
* It may be configurable in the future.
*/
export const reversedResourceAccessTokenTtl = 3600;
/**
* Find the resource for a given indicator. This function also handles the reserved
* resources.
*
* @see {@link ReservedResource} for the list of reserved resources.
*/
export const findResource = async (
queries: Queries,
indicator: string
): Promise<Nullable<Pick<Resource, 'indicator' | 'accessTokenTtl'>>> => {
if (isReservedResource(indicator)) {
return {
indicator,
accessTokenTtl: reversedResourceAccessTokenTtl,
};
}
return queries.resources.findResourceByIndicator(indicator);
};

View file

@ -1,8 +1,7 @@
import type { CustomClientMetadata, OidcClientMetadata } from '@logto/schemas';
import { ApplicationType, customClientMetadataGuard, GrantType } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { AllClientMetadata, ClientAuthMethod } from 'oidc-provider';
import { errors } from 'oidc-provider';
import { type AllClientMetadata, type ClientAuthMethod, errors } from 'oidc-provider';
import type { EnvSet } from '#src/env-set/index.js';
@ -30,7 +29,7 @@ export const getConstantClientMetadata = (
grant_types:
type === ApplicationType.MachineToMachine
? [GrantType.ClientCredentials]
: [GrantType.AuthorizationCode, GrantType.RefreshToken],
: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
token_endpoint_auth_method: getTokenEndpointAuthMethod(),
response_types: conditional(type === ApplicationType.MachineToMachine && []),
// https://www.scottbrady91.com/jose/jwts-which-signing-algorithm-should-i-use

View file

@ -16,6 +16,7 @@ import {
type OrganizationWithRoles,
type UserWithOrganizationRoles,
type FeaturedUser,
type OrganizationScopeEntity,
} from '@logto/schemas';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { sql, type CommonQueryMethods } from 'slonik';
@ -228,6 +229,28 @@ class RoleUserRelationQueries extends RelationQueries<
super(pool, OrganizationRoleUserRelations.table, Organizations, OrganizationRoles, Users);
}
/** Get the available scopes of a user in an organization. */
async getUserScopes(
organizationId: string,
userId: string
): Promise<readonly OrganizationScopeEntity[]> {
const { fields } = convertToIdentifiers(OrganizationRoleUserRelations, true);
const roleScopeRelations = convertToIdentifiers(OrganizationRoleScopeRelations, true);
const scopes = convertToIdentifiers(OrganizationScopes, true);
return this.pool.any<OrganizationScopeEntity>(sql`
select distinct on (${scopes.fields.id})
${scopes.fields.id}, ${scopes.fields.name}
from ${this.table}
join ${roleScopeRelations.table}
on ${roleScopeRelations.fields.organizationRoleId} = ${fields.organizationRoleId}
join ${scopes.table}
on ${scopes.fields.id} = ${roleScopeRelations.fields.organizationScopeId}
where ${fields.organizationId} = ${organizationId}
and ${fields.userId} = ${userId}
`);
}
/** Replace the roles of a user in an organization. */
async replace(organizationId: string, userId: string, roleIds: string[]) {
const users = convertToIdentifiers(Users);

View file

@ -12,7 +12,7 @@ import type { WithInteractionDetailsContext } from './middleware/koa-interaction
export default function consentRoutes<T extends IRouterParamContext>(
router: Router<unknown, WithInteractionDetailsContext<T>>,
{ provider, libraries, queries }: TenantContext
{ provider, queries }: TenantContext
) {
router.post(`${interactionPrefix}/consent`, async (ctx, next) => {
const { interactionDetails } = ctx;

View file

@ -1,10 +1,9 @@
const oidc = {
aborted: 'The end-user aborted interaction.',
invalid_scope: 'Scope {{scope}} is not supported.',
invalid_scope_plural: 'Scope {{scopes}} are not supported.',
invalid_scope: 'Invalid scope: {{error_description}}.',
invalid_token: 'Invalid token provided.',
invalid_client_metadata: 'Invalid client metadata provided.',
insufficient_scope: 'Access token missing requested scope {{scopes}}.',
insufficient_scope: 'Token missing scope `{{scope}}`.',
invalid_request: 'Request is invalid.',
invalid_grant: 'Grant request is invalid.',
invalid_redirect_uri:

View file

@ -12,4 +12,10 @@ export enum GrantType {
AuthorizationCode = 'authorization_code',
RefreshToken = 'refresh_token',
ClientCredentials = 'client_credentials',
/**
* The grant type for using refresh token to get organization access token.
*
* @see {@link https://github.com/logto-io/rfcs | RFC 0001} for more details.
*/
OrganizationToken = 'urn:logto:grant-type:organization_token',
}

View file

@ -11,11 +11,16 @@ import {
import { type FeaturedUser } from './user.js';
/**
* The simplified organization scope entity that is returned for some endpoints.
*/
export type OrganizationScopeEntity = {
id: string;
name: string;
};
export type OrganizationRoleWithScopes = OrganizationRole & {
scopes: Array<{
id: string;
name: string;
}>;
scopes: OrganizationScopeEntity[];
};
export const organizationRoleWithScopesGuard: z.ZodType<OrganizationRoleWithScopes> =

View file

@ -1,6 +1,6 @@
export * from './utils/index.js';
export * from './regex.js';
export * from './scope.js';
export * from './openid.js';
export * from './models/index.js';
export * from './http.js';
export * from './password-policy.js';

View file

@ -1,8 +1,19 @@
/** Scopes that reserved by Logto, which will be added to the auth request automatically. */
export enum ReservedScope {
OpenId = 'openid',
OfflineAccess = 'offline_access',
}
/** Resources that reserved by Logto, which cannot be defined by users. */
export enum ReservedResource {
/**
* The resource for organization template per RFC 0001.
*
* @see {@link https://github.com/logto-io/rfcs | RFC 0001} for more details.
*/
Organization = 'urn:logto:resource:organizations',
}
export type UserClaim =
| 'name'
| 'picture'
@ -100,3 +111,48 @@ export const userClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.freez
])
) as Record<UserScope, UserClaim[]>
);
/**
* The prefix of the URN (Uniform Resource Name) for the organization in Logto.
*
* @example
* ```
* urn:logto:organization:123 // organization with ID 123
* ```
* @see {@link https://en.wikipedia.org/wiki/Uniform_Resource_Name | Uniform Resource Name}
*/
export const organizationUrnPrefix = 'urn:logto:organization:';
/**
* Build the URN (Uniform Resource Name) for the organization in Logto.
*
* @param organizationId The ID of the organization.
* @returns The URN for the organization.
* @see {@link organizationUrnPrefix} for the prefix of the URN.
* @example
* ```ts
* buildOrganizationUrn('1') // returns 'urn:logto:organization:1'
* ```
*/
export const buildOrganizationUrn = (organizationId: string): string =>
`${organizationUrnPrefix}${organizationId}`;
/**
* Get the organization ID from the URN (Uniform Resource Name) for the organization in Logto.
*
* @param urn The URN for the organization. Must start with {@link organizationUrnPrefix}.
* @returns The ID of the organization.
* @throws {TypeError} If the URN is invalid.
* @example
* ```ts
* getOrganizationIdFromUrn('1') // throws TypeError
* getOrganizationIdFromUrn('urn:logto:organization:1') // returns '1'
* ```
*/
export const getOrganizationIdFromUrn = (urn: string): string => {
if (!urn.startsWith(organizationUrnPrefix)) {
throw new TypeError('Invalid organization URN.');
}
return urn.slice(organizationUrnPrefix.length);
};