mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core,toolkit): add new sso_identities claim (#5955)
* feat(core,toolkit): add new sso_identities claim add new sso_identities claim to the userinfo endpoint * chore: update changeset update changeset * chore: update comments update comments * refactor(core): use findUserSsoIdentites query method in user library use findUserSsoIdentites query method in user library
This commit is contained in:
parent
9861b8ac44
commit
0c70d65c7b
7 changed files with 48 additions and 20 deletions
13
.changeset/quick-hairs-fail.md
Normal file
13
.changeset/quick-hairs-fail.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
"@logto/core-kit": minor
|
||||
"@logto/core": minor
|
||||
---
|
||||
|
||||
define new `sso_identities` user claim to the userinfo endpoint response
|
||||
|
||||
- Define a new `sso_identities` user claim that will be used to store the user's SSO identities. The claim will be an array of objects with the following properties:
|
||||
- `details`: detailed user info returned from the SSO provider.
|
||||
- `issuer`: the issuer of the SSO provider.
|
||||
- `identityId`: the user id of the user in the SSO provider.
|
||||
- The new claims will share the same scope as the social `identities` claim.
|
||||
- When the user `identities` scope is requested, the new `sso_identities` claim will be returned along with the `identities` claim in the userinfo endpoint response.
|
|
@ -1,17 +1,17 @@
|
|||
import { runScriptFunctionInLocalVm, buildErrorResponse } from '@logto/core-kit/custom-jwt';
|
||||
import { buildErrorResponse, runScriptFunctionInLocalVm } from '@logto/core-kit/custom-jwt';
|
||||
import {
|
||||
userInfoSelectFields,
|
||||
LogtoJwtTokenKeyType,
|
||||
jwtCustomizerUserContextGuard,
|
||||
type LogtoJwtTokenKey,
|
||||
userInfoSelectFields,
|
||||
type CustomJwtFetcher,
|
||||
type JwtCustomizerType,
|
||||
type JwtCustomizerUserContext,
|
||||
type CustomJwtFetcher,
|
||||
LogtoJwtTokenKeyType,
|
||||
type LogtoJwtTokenKey,
|
||||
} from '@logto/schemas';
|
||||
import { type ConsoleLog } from '@logto/shared';
|
||||
import { deduplicate, pick, pickState, assert } from '@silverhand/essentials';
|
||||
import { assert, deduplicate, pick, pickState } from '@silverhand/essentials';
|
||||
import deepmerge from 'deepmerge';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { ZodError, z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
|
@ -20,10 +20,10 @@ import { type ScopeLibrary } from '#src/libraries/scope.js';
|
|||
import { type UserLibrary } from '#src/libraries/user.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import {
|
||||
LocalVmError,
|
||||
getJwtCustomizerScripts,
|
||||
type CustomJwtDeployRequestBody,
|
||||
} from '#src/utils/custom-jwt/index.js';
|
||||
import { LocalVmError } from '#src/utils/custom-jwt/index.js';
|
||||
|
||||
import { type CloudConnectionLibrary } from './cloud-connection.js';
|
||||
|
||||
|
@ -75,9 +75,7 @@ export class JwtCustomizerLibrary {
|
|||
*/
|
||||
async getUserContext(userId: string): Promise<JwtCustomizerUserContext> {
|
||||
const user = await this.queries.users.findUserById(userId);
|
||||
const fullSsoIdentities = await this.queries.userSsoIdentities.findUserSsoIdentitiesByUserId(
|
||||
userId
|
||||
);
|
||||
const fullSsoIdentities = await this.userLibrary.findUserSsoIdentities(userId);
|
||||
const roles = await this.userLibrary.findUserRoles(userId);
|
||||
const rolesScopes = await this.queries.rolesScopes.findRolesScopesByRoleIds(
|
||||
roles.map(({ id }) => id)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import type { User, CreateUser, Scope, BindMfa, MfaVerification } from '@logto/schemas';
|
||||
import type { BindMfa, CreateUser, MfaVerification, Scope, User } from '@logto/schemas';
|
||||
import { MfaFactor, RoleType, Users, UsersPasswordEncryptionMethod } from '@logto/schemas';
|
||||
import { generateStandardShortId, generateStandardId } from '@logto/shared';
|
||||
import { generateStandardId, generateStandardShortId } from '@logto/shared';
|
||||
import { deduplicateByKey, type Nullable } from '@silverhand/essentials';
|
||||
import { argon2Verify, bcryptVerify, md5, sha1, sha256 } from 'hash-wasm';
|
||||
import pRetry from 'p-retry';
|
||||
|
@ -89,6 +89,7 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
scopes: { findScopesByIdsAndResourceIndicator },
|
||||
organizations,
|
||||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
userSsoIdentities,
|
||||
} = queries;
|
||||
|
||||
const generateUserId = async (retries = 500) =>
|
||||
|
@ -282,6 +283,12 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Expose the findUserSsoIdentitiesByUserId query method for the user library.
|
||||
*/
|
||||
const findUserSsoIdentities = async (userId: string) =>
|
||||
userSsoIdentities.findUserSsoIdentitiesByUserId(userId);
|
||||
|
||||
return {
|
||||
generateUserId,
|
||||
insertUser,
|
||||
|
@ -292,5 +299,6 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
addUserMfaVerification,
|
||||
verifyUserPassword,
|
||||
signOutUser,
|
||||
findUserSsoIdentities,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -50,7 +50,7 @@ describe('OIDC getUserClaims()', () => {
|
|||
it('should return proper Userinfo claims', () => {
|
||||
expect(
|
||||
getAcceptedUserClaims(use.userinfo, 'openid profile custom_data identities', {}, [])
|
||||
).toEqual([...profileExpectation, 'custom_data', 'identities']);
|
||||
).toEqual([...profileExpectation, 'custom_data', 'identities', 'sso_identities']);
|
||||
});
|
||||
|
||||
// Ignore `_claims` since [Claims Parameter](https://github.com/panva/node-oidc-provider/tree/main/docs#featuresclaimsparameter) is not enabled
|
||||
|
|
|
@ -2,8 +2,8 @@ import assert from 'node:assert';
|
|||
|
||||
import type { UserClaim } from '@logto/core-kit';
|
||||
import { idTokenClaims, userinfoClaims, UserScope } from '@logto/core-kit';
|
||||
import { type UserProfile, type User, userProfileKeys } from '@logto/schemas';
|
||||
import { pick, type Nullable, cond } from '@silverhand/essentials';
|
||||
import { userProfileKeys, type User, type UserProfile } from '@logto/schemas';
|
||||
import { cond, pick, type Nullable } from '@silverhand/essentials';
|
||||
import type { ClaimsParameterMember } from 'oidc-provider';
|
||||
import { snakeCase } from 'snake-case';
|
||||
import { type SnakeCaseKeys } from 'snakecase-keys';
|
||||
|
@ -24,6 +24,7 @@ const claimToUserKey: Readonly<
|
|||
| 'organization_data'
|
||||
| 'organization_roles'
|
||||
| UserProfileClaimSnakeCase
|
||||
| 'sso_identities'
|
||||
>,
|
||||
keyof User
|
||||
>
|
||||
|
@ -113,6 +114,13 @@ export const getUserClaimsData = async (
|
|||
organizations.map((element) => pick(element, 'id', 'name', 'description')),
|
||||
];
|
||||
}
|
||||
case 'sso_identities': {
|
||||
const ssoIdentities = await userLibrary.findUserSsoIdentities(user.id);
|
||||
return [
|
||||
claim,
|
||||
ssoIdentities.map(({ issuer, identityId, detail }) => ({ issuer, identityId, detail })),
|
||||
];
|
||||
}
|
||||
default: {
|
||||
if (isUserProfileClaim(claim)) {
|
||||
// Unlike other database fields (e.g. `name`), the claims stored in the `profile` field
|
||||
|
|
|
@ -30,7 +30,6 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
hasUserWithEmail,
|
||||
hasUserWithPhone,
|
||||
},
|
||||
userSsoIdentities,
|
||||
} = queries;
|
||||
const {
|
||||
users: {
|
||||
|
@ -39,6 +38,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
insertUser,
|
||||
verifyUserPassword,
|
||||
signOutUser,
|
||||
findUserSsoIdentities,
|
||||
},
|
||||
} = libraries;
|
||||
|
||||
|
@ -63,7 +63,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
...conditional(
|
||||
includeSsoIdentities &&
|
||||
yes(includeSsoIdentities) && {
|
||||
ssoIdentities: await userSsoIdentities.findUserSsoIdentitiesByUserId(userId),
|
||||
ssoIdentities: await findUserSsoIdentities(userId),
|
||||
}
|
||||
),
|
||||
};
|
||||
|
|
|
@ -43,6 +43,7 @@ export type UserClaim =
|
|||
| 'organization_roles'
|
||||
| 'custom_data'
|
||||
| 'identities'
|
||||
| 'sso_identities'
|
||||
| 'created_at';
|
||||
|
||||
/**
|
||||
|
@ -80,7 +81,7 @@ export enum UserScope {
|
|||
*/
|
||||
CustomData = 'custom_data',
|
||||
/**
|
||||
* Scope for user's social identity details.
|
||||
* Scope for user's social and SSO identity details.
|
||||
*
|
||||
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
|
||||
*/
|
||||
|
@ -153,7 +154,7 @@ export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.f
|
|||
[UserScope.Organizations]: ['organization_data'],
|
||||
[UserScope.OrganizationRoles]: [],
|
||||
[UserScope.CustomData]: ['custom_data'],
|
||||
[UserScope.Identities]: ['identities'],
|
||||
[UserScope.Identities]: ['identities', 'sso_identities'],
|
||||
});
|
||||
|
||||
export const userClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.freeze(
|
||||
|
|
Loading…
Reference in a new issue