0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

Merge pull request #5530 from logto-io/gao-add-oidc-standard-claims

feat: add oidc standard claims to user
This commit is contained in:
Gao Sun 2024-03-21 23:25:50 +08:00 committed by GitHub
commit a98bc3da54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 521 additions and 178 deletions

View file

@ -0,0 +1,18 @@
---
"@logto/core-kit": minor
"@logto/schemas": minor
"@logto/core": minor
"@logto/phrases-experience": patch
"@logto/integration-tests": patch
---
full oidc standard claims support
We have added support for the remaining [OpenID Connect standard claims](https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims). Now, these claims are accessible in both ID tokens and the response from the `/me` endpoint.
Additionally, we adhere to the standard scopes - claims mapping. This means that you can retrieve most of the profile claims using the `profile` scope, and the `address` claim can be obtained by using the `address` scope.
For all newly introduced claims, we store them in the `user.profile` field.
> ![Note]
> Unlike other database fields (e.g. `name`), the claims stored in the `profile` field will fall back to `undefined` rather than `null`. We refrain from using `?? null` here to reduce the size of ID tokens, since `undefined` fields will be stripped in tokens.

View file

@ -24,7 +24,8 @@ jobs:
uses: silverhand-io/actions-node-pnpm-run-steps@v4
- name: Commitlint
run: npx commitlint --from HEAD~${{ github.event.pull_request.commits }} --to HEAD
# Credit to https://stackoverflow.com/a/67365254/12514940
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
- name: Commitlint on PR title
run: echo '${{ github.event.pull_request.title }}' | npx commitlint

View file

@ -19,9 +19,11 @@ export const mockUser: User = {
logtoConfig: {},
mfaVerifications: [],
customData: {},
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_789,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
};
@ -73,9 +75,11 @@ export const mockUserWithPassword: User = {
customData: {},
logtoConfig: {},
mfaVerifications: [],
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_789,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
};
@ -94,9 +98,11 @@ export const mockUserList: User[] = [
customData: {},
logtoConfig: {},
mfaVerifications: [],
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
},
{
@ -113,9 +119,11 @@ export const mockUserList: User[] = [
customData: {},
logtoConfig: {},
mfaVerifications: [],
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
},
{
@ -132,9 +140,11 @@ export const mockUserList: User[] = [
customData: {},
logtoConfig: {},
mfaVerifications: [],
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
},
{
@ -151,9 +161,11 @@ export const mockUserList: User[] = [
customData: {},
logtoConfig: {},
mfaVerifications: [],
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
},
{
@ -170,9 +182,11 @@ export const mockUserList: User[] = [
customData: {},
logtoConfig: {},
mfaVerifications: [],
profile: {},
applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
createdAt: 1_650_969_000_000,
updatedAt: 1_650_969_000_000,
isSuspended: false,
},
];

View file

@ -65,10 +65,12 @@ export const generateHookTestPayload = (hookId: string, event: HookEvent): HookE
userId: 'fake-google-user-id',
},
},
profile: {},
applicationId: 'fake-application-id',
isSuspended: false,
lastSignInAt: now.getTime(),
createdAt: now.getTime(),
updatedAt: now.getTime(),
},
application: {
id: 'fake-spa-application-id',

View file

@ -5,18 +5,33 @@ const use = {
userinfo: 'userinfo',
} as const;
const profileExpectation = Object.freeze([
'name',
'family_name',
'given_name',
'middle_name',
'nickname',
'preferred_username',
'profile',
'picture',
'website',
'gender',
'birthdate',
'zoneinfo',
'locale',
'updated_at',
'username',
'created_at',
]);
describe('OIDC getUserClaims()', () => {
it('should return proper ID Token claims', () => {
expect(getAcceptedUserClaims(use.idToken, 'openid profile', {}, [])).toEqual([
'name',
'picture',
'username',
]);
expect(getAcceptedUserClaims(use.idToken, 'openid profile', {}, [])).toEqual(
profileExpectation
);
expect(getAcceptedUserClaims(use.idToken, 'openid profile email phone', {}, [])).toEqual([
'name',
'picture',
'username',
...profileExpectation,
'email',
'email_verified',
'phone_number',
@ -25,23 +40,23 @@ describe('OIDC getUserClaims()', () => {
expect(
getAcceptedUserClaims(use.idToken, 'openid profile custom_data identities', {}, [])
).toEqual(['name', 'picture', 'username']);
).toEqual(profileExpectation);
expect(
getAcceptedUserClaims(use.idToken, 'openid profile email', {}, ['email_verified'])
).toEqual(['name', 'picture', 'username', 'email']);
).toEqual([...profileExpectation, 'email']);
});
it('should return proper Userinfo claims', () => {
expect(
getAcceptedUserClaims(use.userinfo, 'openid profile custom_data identities', {}, [])
).toEqual(['name', 'picture', 'username', 'custom_data', 'identities']);
).toEqual([...profileExpectation, '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(
getAcceptedUserClaims(use.idToken, 'openid profile custom_data', { email: null }, [])
).toEqual(['name', 'picture', 'username']);
).toEqual(profileExpectation);
});
});

View file

@ -2,13 +2,17 @@ import assert from 'node:assert';
import type { UserClaim } from '@logto/core-kit';
import { idTokenClaims, userinfoClaims, UserScope } from '@logto/core-kit';
import { type User } from '@logto/schemas';
import { type UserProfile, type User, userProfileKeys } from '@logto/schemas';
import { pick, type Nullable, cond } from '@silverhand/essentials';
import type { ClaimsParameterMember } from 'oidc-provider';
import { snakeCase } from 'snake-case';
import { type SnakeCaseKeys } from 'snakecase-keys';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
type UserProfileClaimSnakeCase = keyof SnakeCaseKeys<UserProfile>;
const claimToUserKey: Readonly<
Record<
Exclude<
@ -19,6 +23,7 @@ const claimToUserKey: Readonly<
| 'organizations'
| 'organization_data'
| 'organization_roles'
| UserProfileClaimSnakeCase
>,
keyof User
>
@ -30,8 +35,29 @@ const claimToUserKey: Readonly<
phone_number: 'primaryPhone',
custom_data: 'customData',
identities: 'identities',
created_at: 'createdAt',
updated_at: 'updatedAt',
});
const claimToUserProfileKey: Readonly<Record<UserProfileClaimSnakeCase, keyof UserProfile>> =
Object.freeze({
family_name: 'familyName',
given_name: 'givenName',
middle_name: 'middleName',
nickname: 'nickname',
preferred_username: 'preferredUsername',
profile: 'profile',
website: 'website',
gender: 'gender',
birthdate: 'birthdate',
zoneinfo: 'zoneinfo',
locale: 'locale',
address: 'address',
});
const isUserProfileClaim = (claim: string): claim is UserProfileClaimSnakeCase =>
userProfileKeys.some((key) => snakeCase(key) === claim);
/**
* Get user claims data according to the claims.
*
@ -88,6 +114,19 @@ export const getUserClaimsData = async (
];
}
default: {
if (isUserProfileClaim(claim)) {
// Unlike other database fields (e.g. `name`), the claims stored in the `profile` field
// will fall back to `undefined` rather than `null`. We refrain from using `?? null`
// here to reduce the size of ID tokens, since `undefined` fields will be stripped in
// tokens.
//
// The only consideration here is the inconsistency for `name` and `picture`, which are
// also standard claims but they will fall back to `null`. While it's possible to align
// their behavior by having their fallback to `undefined`, it's better to maintain the
// current setup for now to prevent breaking changes.
return [claim, user.profile[claimToUserProfileKey[claim]]];
}
return [claim, user[claimToUserKey[claim]]];
}
}

View file

@ -49,6 +49,7 @@ describe('user query', () => {
const { table, fields } = convertToIdentifiers(Users);
const databaseValue = {
...mockUser,
profile: JSON.stringify({}),
identities: JSON.stringify(mockUser.identities),
customData: JSON.stringify(mockUser.customData),
logtoConfig: JSON.stringify(mockUser.logtoConfig),
@ -321,6 +322,7 @@ describe('user query', () => {
const { connector1, ...restIdentities } = mockUser.identities;
const finalDbvalue = {
...mockUser,
profile: JSON.stringify({}),
identities: JSON.stringify(restIdentities),
customData: JSON.stringify(mockUser.customData),
logtoConfig: JSON.stringify(mockUser.logtoConfig),

View file

@ -108,6 +108,30 @@
"description": "Update custom data for the given user ID. This method performs a partial update of the custom data object."
}
},
"/api/users/{userId}/profile": {
"patch": {
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"profile": {
"description": "Partial profile object to update for the given user ID."
}
}
}
}
}
},
"responses": {
"200": {
"description": "Updated profile in JSON for the given user ID."
}
},
"summary": "Update user profile",
"description": "Update profile for the given user ID. This method performs a partial update of the profile object."
}
},
"/api/users": {
"post": {
"requestBody": {

View file

@ -3,6 +3,7 @@ import {
UsersPasswordEncryptionMethod,
jsonObjectGuard,
userInfoSelectFields,
userProfileGuard,
userProfileResponseGuard,
} from '@logto/schemas';
import { conditional, pick, yes } from '@silverhand/essentials';
@ -108,6 +109,32 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
}
);
router.patch(
'/users/:userId/profile',
koaGuard({
params: object({ userId: string() }),
body: object({ profile: userProfileGuard }),
response: userProfileGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { userId },
body: { profile },
} = ctx.guard;
await findUserById(userId);
const user = await updateUserById(userId, {
profile,
});
ctx.body = user.profile;
return next();
}
);
router.post(
'/users',
koaGuard({
@ -121,6 +148,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
name: string(),
avatar: string().url().or(literal('')).nullable(),
customData: jsonObjectGuard,
profile: userProfileGuard,
}).partial(),
response: userProfileResponseGuard,
status: [200, 404, 422],
@ -136,6 +164,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
passwordAlgorithm,
avatar,
customData,
profile,
} = ctx.guard.body;
assertThat(!(password && passwordDigest), new RequestError('user.password_and_digest'));
@ -178,6 +207,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
passwordEncryptionMethod: passwordAlgorithm,
}
),
...conditional(profile && { profile }),
},
[]
);
@ -199,6 +229,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
name: string().or(literal('')).nullable(),
avatar: string().url().or(literal('')).nullable(),
customData: jsonObjectGuard,
profile: userProfileGuard,
}).partial(),
response: userProfileResponseGuard,
status: [200, 404, 422],
@ -317,6 +348,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
return next();
}
// eslint-disable-next-line max-lines
);
router.delete(

View file

@ -48,6 +48,13 @@ export const updateUser = async (userId: string, payload: Partial<User>) =>
})
.json<User>();
export const updateUserProfile = async (userId: string, profile: Partial<User['profile']>) =>
authedAdminApi
.patch(`users/${userId}/profile`, {
json: { profile },
})
.json<User['profile']>();
export const suspendUser = async (userId: string, isSuspended: boolean) =>
authedAdminApi.patch(`users/${userId}/is-suspended`, { json: { isSuspended } }).json<User>();

View file

@ -2,7 +2,11 @@ import fs from 'node:fs/promises';
import { createServer, type RequestListener } from 'node:http';
import { mockConnectorFilePaths, type SendMessagePayload } from '@logto/connector-kit';
import { type JsonObject, type UsersPasswordEncryptionMethod } from '@logto/schemas';
import {
type UserProfile,
type JsonObject,
type UsersPasswordEncryptionMethod,
} from '@logto/schemas';
import { RequestError } from 'got';
import { createUser } from '#src/api/index.js';
@ -18,6 +22,7 @@ export const createUserByAdmin = async (
passwordDigest?: string;
passwordAlgorithm?: UsersPasswordEncryptionMethod;
customData?: JsonObject;
profile?: UserProfile;
} = {}
) => {
const { username, name, ...rest } = payload;

View file

@ -20,6 +20,7 @@ import {
postUserIdentity,
verifyUserPassword,
putUserIdentity,
updateUserProfile,
} from '#src/api/index.js';
import { clearConnectorsByTypes } from '#src/helpers/connector.js';
import { createUserByAdmin, expectRejects } from '#src/helpers/index.js';
@ -55,12 +56,14 @@ describe('admin console user management', () => {
await expect(verifyUserPassword(user.id, 'password')).resolves.not.toThrow();
});
it('should create user with custom data successfully', async () => {
it('should create user with custom data and profile successfully', async () => {
const user = await createUserByAdmin({
customData: { foo: 'bar' },
profile: { gender: 'neutral' },
});
const { customData } = await getUser(user.id);
const { customData, profile } = await getUser(user.id);
expect(customData).toStrictEqual({ foo: 'bar' });
expect(profile).toStrictEqual({ gender: 'neutral' });
});
it('should fail when create user with conflict identifiers', async () => {
@ -104,11 +107,38 @@ describe('admin console user management', () => {
customData: {
level: 1,
},
profile: {
familyName: 'new family name',
address: {
formatted: 'new formatted address',
},
},
};
const updatedUser = await updateUser(user.id, newUserData);
expect(updatedUser).toMatchObject(newUserData);
expect(updatedUser.updatedAt).toBeGreaterThan(user.updatedAt);
});
it('should able to update profile partially', async () => {
const user = await createUserByAdmin();
const profile = {
familyName: 'new family name',
address: {
formatted: 'new formatted address',
},
};
const updatedProfile = await updateUserProfile(user.id, profile);
expect(updatedProfile).toStrictEqual(profile);
const patchProfile = {
familyName: 'another name',
website: 'https://logto.io/',
};
const updatedProfile2 = await updateUserProfile(user.id, patchProfile);
expect(updatedProfile2).toStrictEqual({ ...profile, ...patchProfile });
});
it('should respond 422 when no update data provided', async () => {
@ -157,9 +187,10 @@ describe('admin console user management', () => {
});
it('should update user password successfully', async () => {
const user = await createUserByAdmin();
const userEntity = await updateUserPassword(user.id, 'new_password');
expect(userEntity).toMatchObject(user);
const { updatedAt, ...rest } = await createUserByAdmin();
const userEntity = await updateUserPassword(rest.id, 'new_password');
expect(userEntity).toMatchObject(rest);
expect(userEntity.updatedAt).toBeGreaterThan(updatedAt);
});
it('should link social identity successfully', async () => {

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,15 +1,14 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
[UserScope.CustomData]: 'Your custom data',
[UserScope.Email]: 'Your email address',
[UserScope.Phone]: 'Your phone number',
[UserScope.Profile]: 'Your name, username and avatar',
[UserScope.Roles]: 'Your roles',
[UserScope.Identities]: 'Your linked social identities',
[UserScope.Organizations]: 'Your organizations info',
[UserScope.OrganizationRoles]: 'Your organization roles',
custom_data: 'Your custom data',
email: 'Your email address',
phone: 'Your phone number',
profile: 'Your name, username, avatar, and other profile info',
roles: 'Your roles',
identities: 'Your linked social identities',
'urn:logto:scope:organizations': 'Your organizations info',
'urn:logto:scope:organization_roles': 'Your organization roles',
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -1,23 +1,23 @@
import { UserScope } from '@logto/core-kit';
const user_scopes = {
descriptions: {
/** UNTRANSLATED */
[UserScope.CustomData]: 'Your custom data',
custom_data: 'Your custom data',
/** UNTRANSLATED */
[UserScope.Email]: 'Your email address',
email: 'Your email address',
/** UNTRANSLATED */
[UserScope.Phone]: 'Your phone number',
phone: 'Your phone number',
/** UNTRANSLATED */
[UserScope.Profile]: 'Your name, username and avatar',
profile: 'Your name, username, avatar, and other profile info',
/** UNTRANSLATED */
[UserScope.Roles]: 'Your roles',
roles: 'Your roles',
/** UNTRANSLATED */
[UserScope.Identities]: 'Your linked social identities',
identities: 'Your linked social identities',
/** UNTRANSLATED */
[UserScope.Organizations]: 'Your organizations info',
'urn:logto:scope:organizations': 'Your organizations info',
/** UNTRANSLATED */
[UserScope.OrganizationRoles]: 'Your organization roles',
'urn:logto:scope:organization_roles': 'Your organization roles',
/** UNTRANSLATED */
address: 'Your address',
},
};

View file

@ -0,0 +1,38 @@
import { sql } from '@silverhand/slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
alter table users
add column profile jsonb not null default '{}'::jsonb,
add column updated_at timestamptz not null default (now());
`);
await pool.query(sql`
create function set_updated_at() returns trigger as
$$ begin
new.updated_at = now();
return new;
end; $$ language plpgsql;
create trigger set_updated_at
before update on users
for each row
execute procedure set_updated_at();
`);
},
down: async (pool) => {
await pool.query(sql`
alter table users
drop column profile,
drop column updated_at;
`);
await pool.query(sql`
drop trigger set_updated_at on users;
drop function set_updated_at();
`);
},
};
export default alteration;

View file

@ -2,6 +2,56 @@ import { z } from 'zod';
import { MfaFactor } from './sign-in-experience.js';
export type UserProfile = Partial<{
familyName: string;
givenName: string;
middleName: string;
nickname: string;
preferredUsername: string;
profile: string;
website: string;
gender: string;
birthdate: string;
zoneinfo: string;
locale: string;
address: Partial<{
formatted: string;
streetAddress: string;
locality: string;
region: string;
postalCode: string;
country: string;
}>;
}>;
export const userProfileGuard = (
z.object({
familyName: z.string(),
givenName: z.string(),
middleName: z.string(),
nickname: z.string(),
preferredUsername: z.string(),
profile: z.string(),
website: z.string(),
gender: z.string(),
birthdate: z.string(),
zoneinfo: z.string(),
locale: z.string(),
address: z
.object({
formatted: z.string(),
streetAddress: z.string(),
locality: z.string(),
region: z.string(),
postalCode: z.string(),
country: z.string(),
})
.partial(),
}) satisfies z.ZodType<Required<UserProfile>>
).partial();
export const userProfileKeys = Object.freeze(userProfileGuard.keyof().options);
export const roleNamesGuard = z.string().array();
export const identityGuard = z.object({

View file

@ -8,13 +8,10 @@ import {
type OrganizationInvitation,
OrganizationInvitations,
} from '../db-entries/index.js';
import { type ToZodObject } from '../utils/zod.js';
import { type UserInfo, type FeaturedUser, userInfoGuard } from './user.js';
type ToZodObject<T> = z.ZodObject<{
[K in keyof T]: z.ZodType<T[K]>;
}>;
/**
* The simplified organization scope entity that is returned for some endpoints.
*/

View file

@ -14,9 +14,11 @@ export const userInfoSelectFields = Object.freeze([
'identities',
'lastSignInAt',
'createdAt',
'updatedAt',
'profile',
'applicationId',
'isSuspended',
] as const);
] satisfies Array<keyof User>);
export const userInfoGuard = Users.guard.pick(
// eslint-disable-next-line no-restricted-syntax

View file

@ -0,0 +1,5 @@
import { type z } from 'zod';
export type ToZodObject<T> = z.ZodObject<{
[K in keyof T]: z.ZodType<T[K]>;
}>;

View file

@ -1,5 +1,6 @@
/* init_order = 0.5 */
/** A function to set the tenant_id column based on the current user. */
create function set_tenant_id() returns trigger as
$$ begin
if new.tenant_id is not null then
@ -13,4 +14,11 @@ $$ begin
return new;
end; $$ language plpgsql;
/** A function to set the `updated_at` column to the current time. */
create function set_updated_at() returns trigger as
$$ begin
new.updated_at = now();
return new;
end; $$ language plpgsql;
/* no_after_each */

View file

@ -12,7 +12,10 @@ create table users (
password_encrypted varchar(128),
password_encryption_method users_password_encryption_method,
name varchar(128),
/** The URL that points to the user's profile picture. Mapped to OpenID Connect's `picture` claim. */
avatar varchar(2048),
/** Additional OpenID Connect standard claims that are not included in user's properties. */
profile jsonb /* @use UserProfile */ not null default '{}'::jsonb,
application_id varchar(21),
identities jsonb /* @use Identities */ not null default '{}'::jsonb,
custom_data jsonb /* @use JsonObject */ not null default '{}'::jsonb,
@ -21,6 +24,7 @@ create table users (
is_suspended boolean not null default false,
last_sign_in_at timestamptz,
created_at timestamptz not null default (now()),
updated_at timestamptz not null default (now()),
primary key (id),
constraint users__username
unique (tenant_id, username),
@ -35,3 +39,8 @@ create index users__id
create index users__name
on users (tenant_id, name);
create trigger set_updated_at
before update on users
for each row
execute procedure set_updated_at();

View file

@ -15,19 +15,35 @@ export enum ReservedResource {
}
export type UserClaim =
// OIDC standard claims
| 'name'
| 'given_name'
| 'family_name'
| 'middle_name'
| 'nickname'
| 'preferred_username'
| 'profile'
| 'picture'
| 'username'
| 'website'
| 'email'
| 'email_verified'
| 'gender'
| 'birthdate'
| 'zoneinfo'
| 'locale'
| 'phone_number'
| 'phone_number_verified'
| 'address'
| 'updated_at'
// Custom claims
| 'username'
| 'roles'
| 'organizations'
| 'organization_data'
| 'organization_roles'
| 'custom_data'
| 'identities';
| 'identities'
| 'created_at';
/**
* Scopes for ID Token and Userinfo Endpoint.
@ -51,6 +67,12 @@ export enum UserScope {
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Phone = 'phone',
/**
* Scope for user address.
*
* See {@link idTokenClaims} for mapped claims in ID Token and {@link userinfoClaims} for additional claims in Userinfo Endpoint.
*/
Address = 'address',
/**
* Scope for user's custom data.
*
@ -85,11 +107,33 @@ export enum UserScope {
/**
* Mapped claims that ID Token includes.
*
* @see {@link https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims | OpenID Connect Core 1.0} for standard scope - claim mapping.
*/
export const idTokenClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.freeze({
[UserScope.Profile]: ['name', 'picture', 'username'],
[UserScope.Profile]: [
// Standard claims
'name',
'family_name',
'given_name',
'middle_name',
'nickname',
'preferred_username',
'profile',
'picture',
'website',
'gender',
'birthdate',
'zoneinfo',
'locale',
'updated_at',
// Custom claims
'username',
'created_at',
],
[UserScope.Email]: ['email', 'email_verified'],
[UserScope.Phone]: ['phone_number', 'phone_number_verified'],
[UserScope.Address]: ['address'],
[UserScope.Roles]: ['roles'],
[UserScope.Organizations]: ['organizations'],
[UserScope.OrganizationRoles]: ['organization_roles'],
@ -104,6 +148,7 @@ export const userinfoClaims: Readonly<Record<UserScope, UserClaim[]>> = Object.f
[UserScope.Profile]: [],
[UserScope.Email]: [],
[UserScope.Phone]: [],
[UserScope.Address]: [],
[UserScope.Roles]: [],
[UserScope.Organizations]: ['organization_data'],
[UserScope.OrganizationRoles]: [],