0
Fork 0
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:
Gao Sun 2024-03-19 22:51:26 +08:00
parent d3d0f5133b
commit beff82ae2c
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
8 changed files with 164 additions and 9 deletions

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,10 @@ export const getUserClaimsData = async (
];
}
default: {
if (isUserProfileClaim(claim)) {
return [claim, user.profile[claimToUserProfileKey[claim]]];
}
return [claim, user[claimToUserKey[claim]]];
}
}

View file

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

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(
Object.fromEntries(userInfoSelectFields.map((key) => [key, true]))

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

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

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]: [],