0
Fork 0
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:
simeng-li 2024-05-31 14:31:26 +08:00 committed by GitHub
parent 9861b8ac44
commit 0c70d65c7b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 48 additions and 20 deletions

View 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.

View file

@ -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)

View file

@ -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,
};
};

View file

@ -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

View file

@ -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

View file

@ -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),
}
),
};

View file

@ -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(