mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat: add oidc standard claims to user
This commit is contained in:
parent
d3d0f5133b
commit
beff82ae2c
8 changed files with 164 additions and 9 deletions
|
@ -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,10 @@ export const getUserClaimsData = async (
|
|||
];
|
||||
}
|
||||
default: {
|
||||
if (isUserProfileClaim(claim)) {
|
||||
return [claim, user.profile[claimToUserProfileKey[claim]]];
|
||||
}
|
||||
|
||||
return [claim, user[claimToUserKey[claim]]];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -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({
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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(
|
||||
Object.fromEntries(userInfoSelectFields.map((key) => [key, true]))
|
||||
|
|
5
packages/schemas/src/utils/zod.ts
Normal file
5
packages/schemas/src/utils/zod.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { type z } from 'zod';
|
||||
|
||||
export type ToZodObject<T> = z.ZodObject<{
|
||||
[K in keyof T]: z.ZodType<T[K]>;
|
||||
}>;
|
|
@ -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),
|
||||
|
|
|
@ -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]: [],
|
||||
|
|
Loading…
Reference in a new issue