From beff82ae2c11e0002b2b95213e613adf90669455 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Tue, 19 Mar 2024 22:51:26 +0800 Subject: [PATCH] feat: add oidc standard claims to user --- packages/core/src/oidc/scope.ts | 32 +++++++++++- ...9622-add-oidc-standard-claim-properties.ts | 22 ++++++++ .../src/foundations/jsonb-types/users.ts | 50 ++++++++++++++++++ packages/schemas/src/types/organization.ts | 5 +- packages/schemas/src/types/user.ts | 4 +- packages/schemas/src/utils/zod.ts | 5 ++ packages/schemas/tables/users.sql | 4 ++ packages/toolkit/core-kit/src/openid.ts | 51 +++++++++++++++++-- 8 files changed, 164 insertions(+), 9 deletions(-) create mode 100644 packages/schemas/alterations/next-1710859622-add-oidc-standard-claim-properties.ts create mode 100644 packages/schemas/src/utils/zod.ts diff --git a/packages/core/src/oidc/scope.ts b/packages/core/src/oidc/scope.ts index e7d9e340a..0532c4035 100644 --- a/packages/core/src/oidc/scope.ts +++ b/packages/core/src/oidc/scope.ts @@ -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; + 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> = + 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,10 @@ export const getUserClaimsData = async ( ]; } default: { + if (isUserProfileClaim(claim)) { + return [claim, user.profile[claimToUserProfileKey[claim]]]; + } + return [claim, user[claimToUserKey[claim]]]; } } diff --git a/packages/schemas/alterations/next-1710859622-add-oidc-standard-claim-properties.ts b/packages/schemas/alterations/next-1710859622-add-oidc-standard-claim-properties.ts new file mode 100644 index 000000000..bee3f622d --- /dev/null +++ b/packages/schemas/alterations/next-1710859622-add-oidc-standard-claim-properties.ts @@ -0,0 +1,22 @@ +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()); + `); + }, + down: async (pool) => { + await pool.query(sql` + alter table users + drop column profile, + drop column updated_at; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types/users.ts b/packages/schemas/src/foundations/jsonb-types/users.ts index 9fdb2070f..7f237c7d7 100644 --- a/packages/schemas/src/foundations/jsonb-types/users.ts +++ b/packages/schemas/src/foundations/jsonb-types/users.ts @@ -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> +).partial(); + +export const userProfileKeys = Object.freeze(userProfileGuard.keyof().options); + export const roleNamesGuard = z.string().array(); export const identityGuard = z.object({ diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index fdbefcd42..64ab17f8e 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -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 = z.ZodObject<{ - [K in keyof T]: z.ZodType; -}>; - /** * The simplified organization scope entity that is returned for some endpoints. */ diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index d51d16203..31d77fba1 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -14,9 +14,11 @@ export const userInfoSelectFields = Object.freeze([ 'identities', 'lastSignInAt', 'createdAt', + 'updatedAt', + 'profile', 'applicationId', 'isSuspended', -] as const); +] satisfies Array); export const userInfoGuard = Users.guard.pick( Object.fromEntries(userInfoSelectFields.map((key) => [key, true])) diff --git a/packages/schemas/src/utils/zod.ts b/packages/schemas/src/utils/zod.ts new file mode 100644 index 000000000..7b17b8304 --- /dev/null +++ b/packages/schemas/src/utils/zod.ts @@ -0,0 +1,5 @@ +import { type z } from 'zod'; + +export type ToZodObject = z.ZodObject<{ + [K in keyof T]: z.ZodType; +}>; diff --git a/packages/schemas/tables/users.sql b/packages/schemas/tables/users.sql index be43e5ac4..3981f1b45 100644 --- a/packages/schemas/tables/users.sql +++ b/packages/schemas/tables/users.sql @@ -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), diff --git a/packages/toolkit/core-kit/src/openid.ts b/packages/toolkit/core-kit/src/openid.ts index 2b59ba4bb..d950b8d12 100644 --- a/packages/toolkit/core-kit/src/openid.ts +++ b/packages/toolkit/core-kit/src/openid.ts @@ -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> = 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> = Object.f [UserScope.Profile]: [], [UserScope.Email]: [], [UserScope.Phone]: [], + [UserScope.Address]: [], [UserScope.Roles]: [], [UserScope.Organizations]: ['organization_data'], [UserScope.OrganizationRoles]: [],