0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(core)!: update user scopes (#1922)

* refactor(core)!: update user scopes

* refactor(core): add tests

* refactor: update per review
This commit is contained in:
Gao Sun 2022-09-15 11:12:33 +08:00 committed by GitHub
parent 0567fc6347
commit 8d22b5c468
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 320 additions and 33 deletions

View file

@ -1,6 +1,6 @@
import { LogtoProvider } from '@logto/react';
import { adminConsoleApplicationId, managementResource } from '@logto/schemas/lib/seeds';
import { getBasename } from '@logto/shared';
import { getBasename, UserScope } from '@logto/shared';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { SWRConfig } from 'swr';
@ -96,6 +96,7 @@ const App = () => (
endpoint: window.location.origin,
appId: adminConsoleApplicationId,
resources: [managementResource.indicator],
scopes: [UserScope.Identities, UserScope.CustomData],
}}
>
<Main />

View file

@ -16,14 +16,19 @@ const UserInfo = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const anchorRef = useRef<HTMLDivElement>(null);
const [showDropdown, setShowDropdown] = useState(false);
const [user, setUser] = useState<Pick<IdTokenClaims, 'sub' | 'username' | 'avatar'>>();
const [user, setUser] =
useState<Pick<Record<string, unknown> & IdTokenClaims, 'sub' | 'username' | 'picture'>>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
(async () => {
if (isAuthenticated) {
const userInfo = await getIdTokenClaims();
setUser(userInfo ?? { sub: '', username: 'N/A' }); // Provide a fallback to avoid infinite loading state
// TODO: revert after SDK updated
setUser({
picture: undefined,
...(userInfo ?? { sub: '', username: 'N/A' }),
}); // Provide a fallback to avoid infinite loading state
}
})();
}, [isAuthenticated, getIdTokenClaims]);
@ -32,7 +37,7 @@ const UserInfo = () => {
return <UserInfoSkeleton />;
}
const { sub: id, username, avatar } = user;
const { sub: id, username, picture } = user;
return (
<>
@ -43,7 +48,8 @@ const UserInfo = () => {
setShowDropdown(true);
}}
>
<img src={avatar || generateAvatarPlaceHolderById(id)} />
{/* TODO: revert after SDK updated */}
<img src={picture ? String(picture) : generateAvatarPlaceHolderById(id)} />
<div className={styles.wrapper}>
<div className={styles.name}>{username}</div>
</div>

View file

@ -71,6 +71,7 @@
"@silverhand/ts-config": "1.0.0",
"@types/debug": "^4.1.7",
"@types/etag": "^1.8.1",
"@types/http-errors": "^1.8.2",
"@types/inquirer": "^8.2.1",
"@types/jest": "^28.1.6",
"@types/js-yaml": "^4.0.5",
@ -87,6 +88,7 @@
"@types/tar": "^6.1.2",
"copyfiles": "^2.4.1",
"eslint": "^8.21.0",
"http-errors": "^1.6.3",
"jest": "^28.1.3",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^13.0.0",

View file

@ -1,4 +1,5 @@
import { existsSync } from 'fs';
import { mkdir } from 'fs/promises';
import inquirer from 'inquirer';
@ -19,6 +20,8 @@ export const addConnectors = async (directory: string) => {
});
if (!add.value) {
await mkdir(directory);
return;
}
}

View file

@ -1,4 +1,5 @@
import { createMockContext } from '@shopify/jest-koa-mocks';
import createHttpError from 'http-errors';
import RequestError from '@/errors/RequestError';
@ -32,6 +33,13 @@ describe('koaErrorHandler middleware', () => {
expect(ctx.body).toEqual(error.body);
});
// Koa will handle `HttpError` with a built-in manner. Hence it needs to return 200 here.
it('expect to return 200 if error type is HttpError', async () => {
next.mockRejectedValueOnce(createHttpError(404, 'not good'));
await koaErrorHandler()(ctx, next);
expect(ctx.status).toEqual(200);
});
it('expect to return orginal body if not error found', async () => {
await koaErrorHandler()(ctx, next);
expect(ctx.status).toEqual(200);

View file

@ -1,5 +1,5 @@
import { RequestErrorBody } from '@logto/schemas';
import { Middleware } from 'koa';
import { HttpError, Middleware } from 'koa';
import envSet from '@/env-set';
import RequestError from '@/errors/RequestError';
@ -24,6 +24,11 @@ export default function koaErrorHandler<StateT, ContextT, BodyT>(): Middleware<
return;
}
// Koa will handle `HttpError` with a built-in manner.
if (error instanceof HttpError) {
return;
}
ctx.status = 500;
ctx.body = { message: 'Internal server error.' };
}

View file

@ -2,12 +2,11 @@
import { readFileSync } from 'fs';
import { CustomClientMetadataKey, userInfoSelectFields } from '@logto/schemas';
import { CustomClientMetadataKey } from '@logto/schemas';
import { userClaims } from '@logto/shared';
import Koa from 'koa';
import mount from 'koa-mount';
import pick from 'lodash.pick';
import { Provider, errors } from 'oidc-provider';
import { snakeCase } from 'snake-case';
import snakecaseKeys from 'snakecase-keys';
import envSet from '@/env-set';
@ -18,6 +17,8 @@ import { findUserById } from '@/queries/user';
import { routes } from '@/routes/consts';
import { addOidcEventListeners } from '@/utils/oidc-provider-event-listener';
import { claimToUserKey, getUserClaims } from './scope';
export default async function initOidc(app: Koa): Promise<Provider> {
const { issuer, cookieKeys, privateJwks, defaultIdTokenTtl, defaultRefreshTokenTtl } =
envSet.values.oidc;
@ -99,27 +100,33 @@ export default async function initOidc(app: Koa): Promise<Provider> {
ctx.request.origin === origin ||
isOriginAllowed(origin, client.metadata(), client.redirectUris),
// https://github.com/panva/node-oidc-provider/blob/main/recipes/claim_configuration.md
claims: {
profile: userInfoSelectFields.map((value) => snakeCase(value)),
},
// Note node-provider will append `claims` here to the default claims instead of overriding
claims: userClaims,
// https://github.com/panva/node-oidc-provider/tree/main/docs#findaccount
findAccount: async (_ctx, sub) => {
const user = await findUserById(sub);
const { username, name, avatar, roleNames } = user;
return {
accountId: sub,
claims: async (use) => {
claims: async (use, scope, claims, rejected) => {
return snakecaseKeys(
{
/**
* This line is required because:
* 1. TypeScript will complain since `Object.fromEntries()` has a fixed key type `string`
* 2. Scope `openid` is removed from `UserScope` enum
*/
sub,
username,
name,
avatar,
roleNames,
...(use === 'userinfo' && pick(user, ...userInfoSelectFields)),
...Object.fromEntries(
getUserClaims(use, scope, claims, rejected).map((claim) => [
claim,
user[claimToUserKey[claim]],
])
),
},
{ deep: false }
{
deep: false,
}
);
},
};

View file

@ -0,0 +1,64 @@
import { getUserClaims } from './scope';
const use = {
idToken: 'id_token',
userinfo: 'userinfo',
};
describe('OIDC getUserClaims()', () => {
it('should return proper ID Token claims', () => {
expect(getUserClaims(use.idToken, 'openid profile', {}, [])).toEqual([
'name',
'picture',
'username',
'role_names',
]);
expect(getUserClaims(use.idToken, 'openid profile email phone', {}, [])).toEqual([
'name',
'picture',
'username',
'role_names',
'email',
'email_verified',
'phone_number',
'phone_number_verified',
]);
expect(getUserClaims(use.idToken, 'openid profile custom_data identities', {}, [])).toEqual([
'name',
'picture',
'username',
'role_names',
]);
expect(getUserClaims(use.idToken, 'openid profile email', {}, ['email_verified'])).toEqual([
'name',
'picture',
'username',
'role_names',
'email',
]);
});
it('should return proper Userinfo claims', () => {
expect(getUserClaims(use.userinfo, 'openid profile custom_data identities', {}, [])).toEqual([
'name',
'picture',
'username',
'role_names',
'custom_data',
'identities',
]);
});
// Ignore `_claims` since [Claims Parameter](https://github.com/panva/node-oidc-provider/tree/main/docs#featuresclaimsparameter) is not enabled
it('should ignore claims parameter', () => {
expect(getUserClaims(use.idToken, 'openid profile custom_data', { email: null }, [])).toEqual([
'name',
'picture',
'username',
'role_names',
]);
});
});

View file

@ -0,0 +1,48 @@
import { User } from '@logto/schemas';
import { idTokenClaims, UserClaim, userinfoClaims, UserScope } from '@logto/shared';
import { Nullable } from '@silverhand/essentials';
import { ClaimsParameterMember } from 'oidc-provider';
export const claimToUserKey: Readonly<Record<UserClaim, keyof User>> = Object.freeze({
name: 'name',
picture: 'avatar',
username: 'username',
role_names: 'roleNames',
email: 'primaryEmail',
// LOG-4165: Change to proper key/function once profile fulfilling implemented
email_verified: 'primaryEmail',
phone_number: 'primaryPhone',
// LOG-4165: Change to proper key/function once profile fulfilling implemented
phone_number_verified: 'primaryPhone',
custom_data: 'customData',
identities: 'identities',
});
// Ignore `_claims` since [Claims Parameter](https://github.com/panva/node-oidc-provider/tree/main/docs#featuresclaimsparameter) is not enabled
export const getUserClaims = (
use: string,
scope: string,
_claims: Record<string, Nullable<ClaimsParameterMember>>,
rejected: string[]
): UserClaim[] => {
const scopes = scope.split(' ');
const isUserinfo = use === 'userinfo';
const allScopes = Object.values(UserScope);
return scopes
.flatMap((raw) => {
const scope = allScopes.find((value) => value === raw);
if (!scope) {
// Ignore invalid scopes
return [];
}
if (isUserinfo) {
return [...idTokenClaims[scope], ...userinfoClaims[scope]];
}
return idTokenClaims[scope];
})
.filter((claim) => !rejected.includes(claim));
};

View file

@ -1,3 +1,4 @@
export * from './utilities';
export * from './regex';
export * from './language';
export * from './scope';

View file

@ -0,0 +1,85 @@
export enum PreservedScope {
OpenId = 'openid',
OfflineAccess = 'offline_access',
}
export type UserClaim =
| 'name'
| 'picture'
| 'username'
| 'role_names'
| 'email'
| 'email_verified'
| 'phone_number'
| 'phone_number_verified'
| 'custom_data'
| 'identities';
/**
* Scopes for ID Token and Userinfo Endpoint.
*/
export enum UserScope {
/**
* Scope for basic user info.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Profile = 'profile',
/**
* Scope for user email address.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Email = 'email',
/**
* Scope for user phone number.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Phone = 'phone',
/**
* Scope for user's custom data.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
CustomData = 'custom_data',
/**
* Scope for user's social identity details.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Identities = 'identities',
}
/**
* Mapped claims that ID Token includes.
*/
export const idTokenClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.freeze({
[UserScope.Profile]: ['name', 'picture', 'username', 'role_names'],
[UserScope.Email]: ['email', 'email_verified'],
[UserScope.Phone]: ['phone_number', 'phone_number_verified'],
[UserScope.CustomData]: [],
[UserScope.Identities]: [],
});
/**
* Additional claims that Userinfo Endpoint returns.
*/
export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.freeze({
[UserScope.Profile]: [],
[UserScope.Email]: [],
[UserScope.Phone]: [],
[UserScope.CustomData]: ['custom_data'],
[UserScope.Identities]: ['identities'],
});
export const userClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.freeze(
// Hard to infer type directly, use `as` for a workaround.
// eslint-disable-next-line no-restricted-syntax
Object.fromEntries(
Object.values(UserScope).map((current) => [
current,
[...idTokenClaims[current], ...userinfoClaims[current]],
])
) as Record<UserScope, UserClaim[]>
);

83
pnpm-lock.yaml generated
View file

@ -186,6 +186,7 @@ importers:
'@silverhand/ts-config': 1.0.0
'@types/debug': ^4.1.7
'@types/etag': ^1.8.1
'@types/http-errors': ^1.8.2
'@types/inquirer': ^8.2.1
'@types/jest': ^28.1.6
'@types/js-yaml': ^4.0.5
@ -211,6 +212,7 @@ importers:
etag: ^1.8.1
got: ^11.8.2
hash-wasm: ^4.9.0
http-errors: ^1.6.3
i18next: ^21.8.16
iconv-lite: 0.6.3
inquirer: ^8.2.2
@ -298,6 +300,7 @@ importers:
'@silverhand/ts-config': 1.0.0_typescript@4.7.4
'@types/debug': 4.1.7
'@types/etag': 1.8.1
'@types/http-errors': 1.8.2
'@types/inquirer': 8.2.1
'@types/jest': 28.1.6
'@types/js-yaml': 4.0.5
@ -314,6 +317,7 @@ importers:
'@types/tar': 6.1.2
copyfiles: 2.4.1
eslint: 8.21.0
http-errors: 1.8.1
jest: 28.1.3_@types+node@16.11.12
jest-matcher-specific-error: 1.0.0
lint-staged: 13.0.0
@ -3810,7 +3814,7 @@ packages:
eslint-import-resolver-typescript: 3.4.0_jatgrcxl4x7ywe7ak6cnjca2ae
eslint-plugin-consistent-default-export-name: 0.0.15
eslint-plugin-eslint-comments: 3.2.0_eslint@8.21.0
eslint-plugin-import: 2.26.0_eslint@8.21.0
eslint-plugin-import: 2.26.0_klqlxqqxnpnfpttri4irupweri
eslint-plugin-no-use-extend-native: 0.5.0
eslint-plugin-node: 11.1.0_eslint@8.21.0
eslint-plugin-prettier: 4.2.1_h62lvancfh4b7r6zn2dgodrh5e
@ -3820,6 +3824,7 @@ packages:
eslint-plugin-unused-imports: 2.0.0_i7ihj7mda6acsfp32zwgvvndem
prettier: 2.7.1
transitivePeerDependencies:
- eslint-import-resolver-webpack
- supports-color
- typescript
dev: true
@ -3853,7 +3858,7 @@ packages:
'@jest/types': 28.1.3
deepmerge: 4.2.2
identity-obj-proxy: 3.0.0
jest: 28.1.3_k5ytkvaprncdyzidqqws5bqksq
jest: 28.1.3_@types+node@16.11.12
jest-matcher-specific-error: 1.0.0
jest-transform-stub: 2.0.0
ts-jest: 28.0.7_lhw3xkmzugq5tscs3x2ndm4sby
@ -4285,8 +4290,8 @@ packages:
/@types/http-cache-semantics/4.0.1:
resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
/@types/http-errors/1.8.1:
resolution: {integrity: sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q==}
/@types/http-errors/1.8.2:
resolution: {integrity: sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==}
dev: true
/@types/inquirer/8.2.1:
@ -4387,7 +4392,7 @@ packages:
'@types/content-disposition': 0.5.4
'@types/cookies': 0.7.7
'@types/http-assert': 1.5.3
'@types/http-errors': 1.8.1
'@types/http-errors': 1.8.2
'@types/keygrip': 1.0.2
'@types/koa-compose': 3.2.5
'@types/node': 16.11.12
@ -5922,8 +5927,8 @@ packages:
engines: {node: '>=10'}
hasBin: true
dependencies:
is-text-path: 1.0.1
JSONStream: 1.3.5
is-text-path: 1.0.1
lodash: 4.17.21
meow: 8.1.2
split2: 3.2.2
@ -6213,16 +6218,38 @@ packages:
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: true
/debug/3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.1.3
dev: true
/debug/3.2.7_supports-color@5.5.0:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.1.3
supports-color: 5.5.0
dev: true
/debug/4.3.3:
resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==}
engines: {node: '>=6.0'}
@ -6341,7 +6368,7 @@ packages:
resolution: {integrity: sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=}
/depd/1.1.2:
resolution: {integrity: sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=}
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
engines: {node: '>= 0.6'}
/depd/2.0.0:
@ -6754,6 +6781,8 @@ packages:
dependencies:
debug: 3.2.7
resolve: 1.22.0
transitivePeerDependencies:
- supports-color
dev: true
/eslint-import-resolver-typescript/3.4.0_jatgrcxl4x7ywe7ak6cnjca2ae:
@ -6766,7 +6795,7 @@ packages:
debug: 4.3.4
enhanced-resolve: 5.10.0
eslint: 8.21.0
eslint-plugin-import: 2.26.0_eslint@8.21.0
eslint-plugin-import: 2.26.0_klqlxqqxnpnfpttri4irupweri
get-tsconfig: 4.2.0
globby: 13.1.2
is-core-module: 2.9.0
@ -6776,12 +6805,31 @@ packages:
- supports-color
dev: true
/eslint-module-utils/2.7.3:
/eslint-module-utils/2.7.3_dirjbmf3bsnpt3git34hjh5rju:
resolution: {integrity: sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint-import-resolver-node: '*'
eslint-import-resolver-typescript: '*'
eslint-import-resolver-webpack: '*'
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
eslint-import-resolver-node:
optional: true
eslint-import-resolver-typescript:
optional: true
eslint-import-resolver-webpack:
optional: true
dependencies:
'@typescript-eslint/parser': 5.32.0_qugx7qdu5zevzvxaiqyxfiwquq
debug: 3.2.7
eslint-import-resolver-node: 0.3.6
eslint-import-resolver-typescript: 3.4.0_jatgrcxl4x7ywe7ak6cnjca2ae
find-up: 2.1.0
transitivePeerDependencies:
- supports-color
dev: true
/eslint-plugin-consistent-default-export-name/0.0.15:
@ -6814,19 +6862,24 @@ packages:
ignore: 5.2.0
dev: true
/eslint-plugin-import/2.26.0_eslint@8.21.0:
/eslint-plugin-import/2.26.0_klqlxqqxnpnfpttri4irupweri:
resolution: {integrity: sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==}
engines: {node: '>=4'}
peerDependencies:
'@typescript-eslint/parser': '*'
eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8
peerDependenciesMeta:
'@typescript-eslint/parser':
optional: true
dependencies:
'@typescript-eslint/parser': 5.32.0_qugx7qdu5zevzvxaiqyxfiwquq
array-includes: 3.1.4
array.prototype.flat: 1.2.5
debug: 2.6.9
doctrine: 2.1.0
eslint: 8.21.0
eslint-import-resolver-node: 0.3.6
eslint-module-utils: 2.7.3
eslint-module-utils: 2.7.3_dirjbmf3bsnpt3git34hjh5rju
has: 1.0.3
is-core-module: 2.9.0
is-glob: 4.0.3
@ -6834,6 +6887,10 @@ packages:
object.values: 1.1.5
resolve: 1.22.0
tsconfig-paths: 3.14.1
transitivePeerDependencies:
- eslint-import-resolver-typescript
- eslint-import-resolver-webpack
- supports-color
dev: true
/eslint-plugin-no-use-extend-native/0.5.0:
@ -11339,7 +11396,7 @@ packages:
requiresBuild: true
dependencies:
chokidar: 3.5.3
debug: 3.2.7
debug: 3.2.7_supports-color@5.5.0
ignore-by-default: 1.0.1
minimatch: 3.1.2
pstree.remy: 1.1.8
@ -14807,7 +14864,7 @@ packages:
'@jest/types': 28.1.3
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 28.1.3_k5ytkvaprncdyzidqqws5bqksq
jest: 28.1.3_@types+node@16.11.12
jest-util: 28.1.3
json5: 2.2.1
lodash.memoize: 4.1.2