mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
Merge pull request #4844 from logto-io/gao-organization-token-grant
feat: organization_token grant
This commit is contained in:
commit
0fd4a011f6
34 changed files with 596 additions and 95 deletions
|
@ -145,6 +145,14 @@
|
|||
"rules": {
|
||||
"import/no-unused-modules": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"src/include.d/oidc-provider/**/*"
|
||||
],
|
||||
"rules": {
|
||||
"unicorn/filename-case": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -69,6 +69,11 @@ const grantTypeToExchangeByType: Record<GrantType, token.ExchangeByType> = {
|
|||
[GrantType.AuthorizationCode]: token.ExchangeByType.AuthorizationCode,
|
||||
[GrantType.RefreshToken]: token.ExchangeByType.RefreshToken,
|
||||
[GrantType.ClientCredentials]: token.ExchangeByType.ClientCredentials,
|
||||
/**
|
||||
* The organization token grant also uses refresh token to exchange for access token.
|
||||
* See [RFC 0001](https://github.com/logto-io/rfcs) for more details.
|
||||
*/
|
||||
[GrantType.OrganizationToken]: token.ExchangeByType.RefreshToken,
|
||||
};
|
||||
|
||||
const getExchangeByType = (grantType: unknown): token.ExchangeByType => {
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
The original folder named `lib` which will be ignored by our build system. Change the name to `lib.keep` to avoid confusion.
|
4
packages/core/src/include.d/oidc-provider/lib-keep/helpers/_/difference.d.ts
vendored
Normal file
4
packages/core/src/include.d/oidc-provider/lib-keep/helpers/_/difference.d.ts
vendored
Normal 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[];
|
||||
}
|
5
packages/core/src/include.d/oidc-provider/lib-keep/helpers/revoke.d.ts
vendored
Normal file
5
packages/core/src/include.d/oidc-provider/lib-keep/helpers/revoke.d.ts
vendored
Normal 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>;
|
||||
}
|
6
packages/core/src/include.d/oidc-provider/lib-keep/helpers/validate_presence.d.ts
vendored
Normal file
6
packages/core/src/include.d/oidc-provider/lib-keep/helpers/validate_presence.d.ts
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
declare module 'oidc-provider/lib/helpers/validate_presence.js' {
|
||||
export default function validatePresence(
|
||||
ctx: KoaContextWithOIDC,
|
||||
...required: readonly string[]
|
||||
): void;
|
||||
}
|
18
packages/core/src/include.d/oidc-provider/lib-keep/helpers/weak_cache.d.ts
vendored
Normal file
18
packages/core/src/include.d/oidc-provider/lib-keep/helpers/weak_cache.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
277
packages/core/src/oidc/grants/organization-token.ts
Normal file
277
packages/core/src/oidc/grants/organization-token.ts
Normal 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 */
|
|
@ -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(' '),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -315,6 +288,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));
|
||||
/**
|
||||
|
|
95
packages/core/src/oidc/resource.ts
Normal file
95
packages/core/src/oidc/resource.ts
Normal 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);
|
||||
};
|
|
@ -12,17 +12,17 @@ import {
|
|||
describe('getConstantClientMetadata()', () => {
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.SPA)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Native)).toEqual({
|
||||
application_type: 'native',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
token_endpoint_auth_method: 'none',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.Traditional)).toEqual({
|
||||
application_type: 'web',
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken],
|
||||
grant_types: [GrantType.AuthorizationCode, GrantType.RefreshToken, GrantType.OrganizationToken],
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
});
|
||||
expect(getConstantClientMetadata(mockEnvSet, ApplicationType.MachineToMachine)).toEqual({
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'Der Endnutzer hat die Interaktion abgebrochen.',
|
||||
invalid_scope: 'Scope {{scope}} wird nicht unterstützt.',
|
||||
invalid_scope_plural: 'Scopes {{scopes}} werden nicht unterstützt.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Ungültiger Token übermittelt.',
|
||||
invalid_client_metadata: 'Ungültige Client Metadaten übermittelt.',
|
||||
insufficient_scope: 'Access token fehlen angefragte scope {{scopes}}.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'Anfrage ist ungültig.',
|
||||
invalid_grant: 'Grant request ist ungültig.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'El usuario final abortó la interacción.',
|
||||
invalid_scope: 'El alcance {{scope}} no es compatible.',
|
||||
invalid_scope_plural: 'Los alcances {{scopes}} no son compatibles.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Se proporcionó un token no válido.',
|
||||
invalid_client_metadata: 'Se proporcionaron metadatos de cliente no válidos.',
|
||||
insufficient_scope: 'El token de acceso no tiene el alcance solicitado {{scopes}}.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'La solicitud no es válida.',
|
||||
invalid_grant: 'La solicitud de concesión es incorrecta.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: "L'utilisateur a abandonné l'interaction.",
|
||||
invalid_scope: "Le scope {{scope}} n'est pas pris en charge.",
|
||||
invalid_scope_plural: 'Les scopes {{scopes}} ne sont pas supportés.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Jeton fournis invalide.',
|
||||
invalid_client_metadata: 'Les métadonnées du client fournies sont invalides.',
|
||||
insufficient_scope: "Jeton d'accès manquant pour les scopes {{scopes}}.",
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'La requête est invalide.',
|
||||
invalid_grant: 'Grant request is invalid.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: "L'utente finale ha annullato l'interazione.",
|
||||
invalid_scope: 'La scope {{scope}} non è supportata.',
|
||||
invalid_scope_plural: 'Le scope {{scopes}} non sono supportate.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Token non valido fornito.',
|
||||
invalid_client_metadata: 'Metadata client non valide fornite.',
|
||||
insufficient_scope: 'Token di accesso senza la scope richiesta {{scopes}}.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'La richiesta non è valida.',
|
||||
invalid_grant: 'La richiesta di grant non è valida.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'エンドユーザが操作を中止しました。',
|
||||
invalid_scope: 'スコープ{{scope}}はサポートされていません。',
|
||||
invalid_scope_plural: 'スコープ{{scopes}}はサポートされていません。',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: '提供されたトークンが無効です。',
|
||||
invalid_client_metadata: '提供されたクライアントメタデータが無効です。',
|
||||
insufficient_scope: 'アクセストークンに要求されたスコープ{{scopes}}が含まれていません。',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'リクエストが無効です。',
|
||||
invalid_grant: '付与要求が無効です。',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'End 사용자가 상호 작용을 중단했어요.',
|
||||
invalid_scope: '{{scope}} 범위를 지원하지 않아요.',
|
||||
invalid_scope_plural: '{{scopes}} 범위들을 지원하지 않아요.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: '유효하지 않은 토큰이 제공되었어요.',
|
||||
invalid_client_metadata: '유효하지 않은 클라이언트 메타데이터가 제공되었어요.',
|
||||
insufficient_scope: '요청된 {{scopes}} 범위에서 액세스 토큰을 찾을 수 없어요.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: '요청이 유효하지 않아요.',
|
||||
invalid_grant: '승인 요청이 유효하지 않아요.',
|
||||
invalid_redirect_uri: '`redirect_uri`가 등록된 클라이언트의 `redirect_uris`와 일치하지 않아요.',
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'Koniec interakcji z użytkownikiem.',
|
||||
invalid_scope: 'Zakres {{scope}} nie jest obsługiwany.',
|
||||
invalid_scope_plural: 'Zakresy {{scopes}} nie są obsługiwane.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Podano nieprawidłowy token.',
|
||||
invalid_client_metadata: 'Podano nieprawidłowe metadane klienta.',
|
||||
insufficient_scope: 'Brakujący zakres żądanego tokena dostępu {{scopes}}.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'Żądanie jest nieprawidłowe.',
|
||||
invalid_grant: 'Żądanie przyznania jest nieprawidłowe.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'A interação abortada pelo end-user',
|
||||
invalid_scope: 'Escopo {{scope}} não é suportado.',
|
||||
invalid_scope_plural: 'Escopo {{scopes}} não são suportados.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Token inválido.',
|
||||
invalid_client_metadata: 'Metadados de cliente inválidos.',
|
||||
insufficient_scope: 'Escopo solicitado ausente {{scopes}} do token de acesso.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'A solicitação é inválida.',
|
||||
invalid_grant: 'A solicitação de concessão é inválida.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'O utilizador final abortou a interação.',
|
||||
invalid_scope: 'Scope {{scope}} não é suportado.',
|
||||
invalid_scope_plural: 'Scope {{scopes}} não são suportados.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'O Token fornecido é inválido.',
|
||||
invalid_client_metadata: 'Metadados de cliente inválidos fornecidos.',
|
||||
insufficient_scope: 'Token de acesso sem scope solicitado {{scopes}}.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'Pedido inválido.',
|
||||
invalid_grant: 'Pedido Grant inválido.',
|
||||
invalid_redirect_uri: '`redirect_uri` não correspondeu a nenhum dos `redirect_uris` registados.',
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'Конечный пользователь прервал взаимодействие.',
|
||||
invalid_scope: 'Scope {{scope}} не поддерживается.',
|
||||
invalid_scope_plural: 'Scope {{scopes}} не поддерживаются.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Недействительный токен.',
|
||||
invalid_client_metadata: 'Недопустимые метаданные клиента.',
|
||||
insufficient_scope: 'Отсутствует токен доступа для запрошенного scope {{scopes}}.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'Недопустимый запрос.',
|
||||
invalid_grant: 'Недопустимый запрос на предоставление прав.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: 'Son kullanıcı etkileşimi iptal etti.',
|
||||
invalid_scope: '{{scope}} kapsamı desteklenmiyor.',
|
||||
invalid_scope_plural: '{{scopes}} kapsamları desteklenmiyor.',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Sağlanan token geçersiz.',
|
||||
invalid_client_metadata: 'Sağlanan müşteri metadatası geçersiz.',
|
||||
insufficient_scope: 'Erişim tokenı istenen {{scopes}} kapsamında eksik.',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: 'İstek geçersiz.',
|
||||
invalid_grant: 'Hibe talebi geçersiz.',
|
||||
invalid_redirect_uri:
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: '用户终止了交互。',
|
||||
invalid_scope: '不支持的 scope: {{scopes}}',
|
||||
invalid_scope_plural: '不支持的 scope: {{scopes}}',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Token 无效',
|
||||
invalid_client_metadata: '无效的客户端元数据',
|
||||
insufficient_scope: '请求 token 缺少权限: {{scopes}}',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: '请求无效',
|
||||
invalid_grant: '授权请求无效',
|
||||
invalid_redirect_uri: '无效返回链接, 该 redirect_uri 未被此应用注册。',
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: '用戶終止了交互。',
|
||||
invalid_scope: '不支持的 scope: {{scopes}}',
|
||||
invalid_scope_plural: '不支持的 scope: {{scopes}}',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Token 無效',
|
||||
invalid_client_metadata: '無效的客戶端元數據',
|
||||
insufficient_scope: '請求 token 缺少權限: {{scopes}}',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: '請求無效',
|
||||
invalid_grant: '授權請求無效',
|
||||
invalid_redirect_uri: '無效返回鏈接, 該 redirect_uri 未被此應用注冊。',
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
const oidc = {
|
||||
aborted: '使用者終止了互動。',
|
||||
invalid_scope: '不支援的 scope: {{scopes}}',
|
||||
invalid_scope_plural: '不支援的 scope: {{scopes}}',
|
||||
/** UNTRANSLATED */
|
||||
invalid_scope: 'Invalid scope: {{error_description}}.',
|
||||
invalid_token: 'Token 無效',
|
||||
invalid_client_metadata: '無效的用戶端元數據',
|
||||
insufficient_scope: '請求 token 缺少權限: {{scopes}}',
|
||||
/** UNTRANSLATED */
|
||||
insufficient_scope: 'Token missing scope `{{scope}}`.',
|
||||
invalid_request: '請求無效',
|
||||
invalid_grant: '授權請求無效',
|
||||
invalid_redirect_uri: '無效返回連結, 该 redirect_uri 未被此應用註冊。',
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -9,11 +9,16 @@ import {
|
|||
|
||||
import { type UserInfo, type FeaturedUser, userInfoGuard } 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> =
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
};
|
Loading…
Add table
Reference in a new issue