@easy-cms/core 0.34.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +170 -1
- package/dist/index.js +450 -18
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -65,6 +65,11 @@ interface PasswordEmailArgs {
|
|
|
65
65
|
readonly locale: string;
|
|
66
66
|
/** When the link stops working. */
|
|
67
67
|
readonly expiresAt?: Date;
|
|
68
|
+
/**
|
|
69
|
+
* Invitations when passwords are off (`auth.password: false`): the providers to sign in with,
|
|
70
|
+
* e.g. `['Google']`; `url` is then the login page.
|
|
71
|
+
*/
|
|
72
|
+
readonly sso?: readonly string[];
|
|
68
73
|
}
|
|
69
74
|
/** An email's content: a subject, and text and/or HTML. */
|
|
70
75
|
interface PasswordEmail {
|
|
@@ -80,6 +85,50 @@ declare const DEFAULT_PASSWORD_EMAILS: {
|
|
|
80
85
|
passwordChanged: PasswordEmailFn;
|
|
81
86
|
};
|
|
82
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Signing in to the admin with an outside account (`auth.providers`): Google, Microsoft, GitHub
|
|
90
|
+
* or any OpenID Connect provider. `@easy-cms/auth-oauth` makes these; Easy CMS runs the rest
|
|
91
|
+
* (the redirect, matching users, sessions).
|
|
92
|
+
*/
|
|
93
|
+
/** Values bound to one sign-in, checked when the browser comes back. */
|
|
94
|
+
interface AuthProviderRequest {
|
|
95
|
+
/** Where the provider sends the browser back: `<serverURL><api>/auth/<id>/callback`. */
|
|
96
|
+
readonly redirectUri: string;
|
|
97
|
+
readonly state: string;
|
|
98
|
+
/** For PKCE: the provider gets its S256 challenge. */
|
|
99
|
+
readonly codeVerifier: string;
|
|
100
|
+
/** For OpenID Connect: must come back in the ID token. */
|
|
101
|
+
readonly nonce: string;
|
|
102
|
+
}
|
|
103
|
+
/** Who signed in, as the provider says. */
|
|
104
|
+
interface AuthProviderProfile {
|
|
105
|
+
/** The provider's stable id for the account (`sub`). */
|
|
106
|
+
readonly subject: string;
|
|
107
|
+
readonly email: string | null;
|
|
108
|
+
/** The provider vouches for the email. Only verified emails are matched to users. */
|
|
109
|
+
readonly emailVerified: boolean;
|
|
110
|
+
readonly name?: string | undefined;
|
|
111
|
+
}
|
|
112
|
+
interface AuthProvider {
|
|
113
|
+
/** In URLs and stored identities, e.g. `google`: lowercase letters, digits and `-`. */
|
|
114
|
+
readonly id: string;
|
|
115
|
+
/** On the sign-in button, e.g. `Google`. */
|
|
116
|
+
readonly name: string;
|
|
117
|
+
/** The button's icon in the admin. Default `key`. */
|
|
118
|
+
readonly icon?: 'google' | 'microsoft' | 'github' | 'key';
|
|
119
|
+
/** The provider's page to send the browser to. */
|
|
120
|
+
authorizationURL(request: AuthProviderRequest): Promise<URL>;
|
|
121
|
+
/**
|
|
122
|
+
* After the browser comes back to `redirectUri` (`url`, with its query): checks the response
|
|
123
|
+
* and exchanges the code. Throws when anything does not check out.
|
|
124
|
+
*/
|
|
125
|
+
callback(request: AuthProviderRequest & {
|
|
126
|
+
readonly url: URL;
|
|
127
|
+
}): Promise<AuthProviderProfile>;
|
|
128
|
+
/** For Settings → SSO: what it connects to, never secrets. */
|
|
129
|
+
describe?(): Readonly<Record<string, string>>;
|
|
130
|
+
}
|
|
131
|
+
|
|
83
132
|
interface Logger {
|
|
84
133
|
info(message: string): void;
|
|
85
134
|
warn(message: string): void;
|
|
@@ -339,6 +388,103 @@ type PreviewTarget = {
|
|
|
339
388
|
/** Why a password link was sent: a forgotten password, or an invitation to a new account. */
|
|
340
389
|
type PasswordPurpose = 'reset' | 'invite';
|
|
341
390
|
|
|
391
|
+
/** The cookie that carries a sign-in through the provider and back. */
|
|
392
|
+
declare const SSO_COOKIE = "ecms-sso";
|
|
393
|
+
/** A provider as the login page shows it (`GET <api>/users/init`). */
|
|
394
|
+
interface SsoProviderRef {
|
|
395
|
+
id: string;
|
|
396
|
+
name: string;
|
|
397
|
+
icon: string;
|
|
398
|
+
}
|
|
399
|
+
/** Settings → SSO (`GET <api>/admin/sso`). */
|
|
400
|
+
interface AdminSso {
|
|
401
|
+
providers: (SsoProviderRef & {
|
|
402
|
+
/** Give this to the provider as the redirect (callback) URL. */
|
|
403
|
+
callbackURL: string | null;
|
|
404
|
+
details: Record<string, string>;
|
|
405
|
+
})[];
|
|
406
|
+
/** Who may sign in with a password: everyone, or only admins. */
|
|
407
|
+
password: 'everyone' | 'admins';
|
|
408
|
+
/** Email domains whose people get an account when they first sign in. */
|
|
409
|
+
signUp: {
|
|
410
|
+
domains: string[];
|
|
411
|
+
role: string;
|
|
412
|
+
} | null;
|
|
413
|
+
/** Without `serverURL`, sign-in only works in development. */
|
|
414
|
+
serverURL: boolean;
|
|
415
|
+
}
|
|
416
|
+
/** An outside account a user signs in with (`GET <api>/auth/identities`). */
|
|
417
|
+
interface UserIdentity {
|
|
418
|
+
id: ID;
|
|
419
|
+
provider: string;
|
|
420
|
+
/** The provider's name, e.g. `Google`. */
|
|
421
|
+
name: string;
|
|
422
|
+
email: string | null;
|
|
423
|
+
createdAt: string;
|
|
424
|
+
lastUsedAt: string | null;
|
|
425
|
+
}
|
|
426
|
+
/** Why a sign-in did not work, as `?sso=` on the page it ends on. */
|
|
427
|
+
type SsoOutcome = 'linked' | 'cancelled' | 'expired' | 'failed' | 'no-account' | 'unverified' | 'inactive' | 'taken';
|
|
428
|
+
/** Single sign-on with `auth.providers`. Available as `cms.auth.sso`. */
|
|
429
|
+
declare class SingleSignOn {
|
|
430
|
+
private readonly cms;
|
|
431
|
+
constructor(cms: EasyCMS);
|
|
432
|
+
private get config();
|
|
433
|
+
get enabled(): boolean;
|
|
434
|
+
providers(): SsoProviderRef[];
|
|
435
|
+
/** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
|
|
436
|
+
passwordAllowed(user: {
|
|
437
|
+
readonly [key: string]: unknown;
|
|
438
|
+
}): boolean;
|
|
439
|
+
/** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
|
|
440
|
+
callbackURL(provider: string, origin?: string): string | null;
|
|
441
|
+
/**
|
|
442
|
+
* Starts signing in (or, with `user`, linking an account): the provider's URL to send the
|
|
443
|
+
* browser to, and the sealed value of the cookie that must come back with it.
|
|
444
|
+
*/
|
|
445
|
+
start(providerId: string, options: {
|
|
446
|
+
origin?: string;
|
|
447
|
+
redirect?: string | null;
|
|
448
|
+
user?: AuthUser;
|
|
449
|
+
}): Promise<{
|
|
450
|
+
url: string;
|
|
451
|
+
cookie: string;
|
|
452
|
+
}>;
|
|
453
|
+
/**
|
|
454
|
+
* Finishes at `<api>/auth/<id>/callback`: checks the response, finds (or makes) the user and
|
|
455
|
+
* starts a session. Returns where to send the browser; failures end on the login page (or the
|
|
456
|
+
* account page, when linking) with `?sso=<outcome>`.
|
|
457
|
+
*/
|
|
458
|
+
finish(providerId: string, options: {
|
|
459
|
+
url: URL;
|
|
460
|
+
cookie: string | undefined;
|
|
461
|
+
origin?: string;
|
|
462
|
+
user: AuthUser | null;
|
|
463
|
+
}): Promise<{
|
|
464
|
+
redirect: string;
|
|
465
|
+
session?: Session;
|
|
466
|
+
}>;
|
|
467
|
+
/** A user's outside accounts. */
|
|
468
|
+
identities(userId: ID): Promise<UserIdentity[]>;
|
|
469
|
+
/**
|
|
470
|
+
* Unlinks an outside account: the user's own, or anyone's for admins. Refuses to unlink the
|
|
471
|
+
* last way a user has to sign in.
|
|
472
|
+
*/
|
|
473
|
+
unlink(actor: AuthUser, identityId: ID): Promise<void>;
|
|
474
|
+
/** Forgets a deleted user's outside accounts. */
|
|
475
|
+
forget(userId: ID): Promise<void>;
|
|
476
|
+
settings(origin?: string): AdminSso;
|
|
477
|
+
private provider;
|
|
478
|
+
/** Only pages of the admin: anything else (another site) goes to the admin's home. */
|
|
479
|
+
private adminPage;
|
|
480
|
+
private identity;
|
|
481
|
+
private addIdentity;
|
|
482
|
+
private userByEmail;
|
|
483
|
+
/** A new account for people from `allowSignUp.domains`. */
|
|
484
|
+
private signUp;
|
|
485
|
+
private defaultRole;
|
|
486
|
+
}
|
|
487
|
+
|
|
342
488
|
interface LoginArgs {
|
|
343
489
|
readonly email: string;
|
|
344
490
|
readonly password: string;
|
|
@@ -363,6 +509,8 @@ interface PasswordLinkOptions {
|
|
|
363
509
|
/** Login, logout and session verification. Available as `cms.auth`. */
|
|
364
510
|
declare class Auth {
|
|
365
511
|
private readonly cms;
|
|
512
|
+
/** Signing in with outside accounts (`auth.providers`). */
|
|
513
|
+
readonly sso: SingleSignOn;
|
|
366
514
|
constructor(cms: EasyCMS);
|
|
367
515
|
private get db();
|
|
368
516
|
private get config();
|
|
@@ -1920,6 +2068,24 @@ interface AuthConfig {
|
|
|
1920
2068
|
readonly resetPasswordExpiration?: number;
|
|
1921
2069
|
/** How long an invitation link works, in seconds. Default 7 days. */
|
|
1922
2070
|
readonly inviteExpiration?: number;
|
|
2071
|
+
/**
|
|
2072
|
+
* Signing in to the admin with outside accounts, e.g. `[google({ clientId, clientSecret })]`
|
|
2073
|
+
* from `@easy-cms/auth-oauth`. Users are matched by their verified email.
|
|
2074
|
+
*/
|
|
2075
|
+
readonly providers?: readonly AuthProvider[];
|
|
2076
|
+
/**
|
|
2077
|
+
* With `providers`: people from these email domains who sign in for the first time get an
|
|
2078
|
+
* account with `role` (default: as new users). Without it, only existing users can sign in.
|
|
2079
|
+
*/
|
|
2080
|
+
readonly allowSignUp?: {
|
|
2081
|
+
readonly domains: readonly string[];
|
|
2082
|
+
readonly role?: string;
|
|
2083
|
+
};
|
|
2084
|
+
/**
|
|
2085
|
+
* `false`: only admins may sign in with a password (a way in when the provider is down);
|
|
2086
|
+
* everyone else signs in with `providers`. Default `true`.
|
|
2087
|
+
*/
|
|
2088
|
+
readonly password?: boolean;
|
|
1923
2089
|
/** Your own text for the password emails, e.g. in your brand's voice. Default: English or Thai. */
|
|
1924
2090
|
readonly emails?: {
|
|
1925
2091
|
readonly resetPassword?: PasswordEmailFn;
|
|
@@ -2567,9 +2733,12 @@ interface AdminSchema {
|
|
|
2567
2733
|
backups: boolean;
|
|
2568
2734
|
email: boolean;
|
|
2569
2735
|
roles: boolean;
|
|
2736
|
+
sso: boolean;
|
|
2570
2737
|
};
|
|
2571
2738
|
/** Roles from the admin are on (`auth.rbac`): documents have owners. */
|
|
2572
2739
|
rbac: boolean;
|
|
2740
|
+
/** Outside accounts users can sign in with and link (`auth.providers`). */
|
|
2741
|
+
providers: SsoProviderRef[];
|
|
2573
2742
|
/** Pages this user may open (`admin.pages`). */
|
|
2574
2743
|
pages: AdminPageRef[];
|
|
2575
2744
|
/** Dashboard panels this user may see (`admin.dashboard`). */
|
|
@@ -2668,4 +2837,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
|
|
|
2668
2837
|
/** The version of `@easy-cms/core`, set when the package is built. */
|
|
2669
2838
|
declare const VERSION: string;
|
|
2670
2839
|
|
|
2671
|
-
export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
|
|
2840
|
+
export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, configSignature, consoleEmail, consoleLogger, copyDatabase, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword, writeBackupFile };
|
package/dist/index.js
CHANGED
|
@@ -332,6 +332,7 @@ var WEBHOOK_DELIVERIES = "webhook-deliveries";
|
|
|
332
332
|
var EMAIL_DELIVERIES = "email-deliveries";
|
|
333
333
|
var DATABASE_BACKUPS = "database-backups";
|
|
334
334
|
var ROLES = "user-roles";
|
|
335
|
+
var USER_IDENTITIES = "user-identities";
|
|
335
336
|
var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
336
337
|
SESSIONS,
|
|
337
338
|
LOGIN_ATTEMPTS,
|
|
@@ -340,7 +341,8 @@ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
|
340
341
|
WEBHOOK_DELIVERIES,
|
|
341
342
|
EMAIL_DELIVERIES,
|
|
342
343
|
DATABASE_BACKUPS,
|
|
343
|
-
ROLES
|
|
344
|
+
ROLES,
|
|
345
|
+
USER_IDENTITIES
|
|
344
346
|
]);
|
|
345
347
|
var DEFAULT_ROLES = ["admin", "editor"];
|
|
346
348
|
var CREATED_BY_FIELD = {
|
|
@@ -600,6 +602,18 @@ var databaseBackupsCollection = {
|
|
|
600
602
|
{ name: "downloadedAt", type: "text" }
|
|
601
603
|
]
|
|
602
604
|
};
|
|
605
|
+
var userIdentitiesCollection = {
|
|
606
|
+
slug: USER_IDENTITIES,
|
|
607
|
+
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
608
|
+
fields: [
|
|
609
|
+
{ name: "user", type: "relationship", to: USERS, required: true, index: true },
|
|
610
|
+
// The provider's id (`google`) and its id for the account (`sub`).
|
|
611
|
+
{ name: "provider", type: "text", required: true, index: true },
|
|
612
|
+
{ name: "subject", type: "text", required: true, index: true },
|
|
613
|
+
{ name: "email", type: "text" },
|
|
614
|
+
{ name: "lastUsedAt", type: "text" }
|
|
615
|
+
]
|
|
616
|
+
};
|
|
603
617
|
var emailDeliveriesCollection = {
|
|
604
618
|
slug: EMAIL_DELIVERIES,
|
|
605
619
|
access: { read: nobody, create: nobody, update: nobody, delete: nobody },
|
|
@@ -642,7 +656,17 @@ var DEFAULT_PASSWORD_EMAILS = {
|
|
|
642
656
|
],
|
|
643
657
|
{ label: "Set a new password", url }
|
|
644
658
|
),
|
|
645
|
-
invite: ({ url = "", locale, expiresAt, user }) => locale === "th" ? email(
|
|
659
|
+
invite: ({ url = "", locale, expiresAt, user, sso }) => sso ? locale === "th" ? email(
|
|
660
|
+
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
661
|
+
[`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A\u0E14\u0E49\u0E27\u0E22 ${sso.join(" \u0E2B\u0E23\u0E37\u0E2D ")} \u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`],
|
|
662
|
+
{ label: "\u0E40\u0E02\u0E49\u0E32\u0E2A\u0E39\u0E48\u0E23\u0E30\u0E1A\u0E1A", url }
|
|
663
|
+
) : email(
|
|
664
|
+
"You're invited to the content admin",
|
|
665
|
+
[
|
|
666
|
+
`An account for ${user.email} is waiting for you. Sign in with ${sso.join(" or ")} to start.`
|
|
667
|
+
],
|
|
668
|
+
{ label: "Sign in", url }
|
|
669
|
+
) : locale === "th" ? email(
|
|
646
670
|
"\u0E04\u0E38\u0E13\u0E44\u0E14\u0E49\u0E23\u0E31\u0E1A\u0E40\u0E0A\u0E34\u0E0D\u0E43\u0E2B\u0E49\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E23\u0E30\u0E1A\u0E1A\u0E08\u0E31\u0E14\u0E01\u0E32\u0E23\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32",
|
|
647
671
|
[
|
|
648
672
|
`\u0E21\u0E35\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E2A\u0E33\u0E2B\u0E23\u0E31\u0E1A ${user.email} \u0E23\u0E2D\u0E04\u0E38\u0E13\u0E2D\u0E22\u0E39\u0E48 \u0E15\u0E31\u0E49\u0E07\u0E23\u0E2B\u0E31\u0E2A\u0E1C\u0E48\u0E32\u0E19\u0E40\u0E1E\u0E37\u0E48\u0E2D\u0E40\u0E23\u0E34\u0E48\u0E21\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19`,
|
|
@@ -711,6 +735,9 @@ async function fakeVerify(password) {
|
|
|
711
735
|
return false;
|
|
712
736
|
}
|
|
713
737
|
|
|
738
|
+
// src/auth/sso.ts
|
|
739
|
+
import { createHmac as createHmac2, randomBytes as randomBytes4 } from "crypto";
|
|
740
|
+
|
|
714
741
|
// src/auth/tokens.ts
|
|
715
742
|
import { createHash as createHash2, createHmac, randomBytes as randomBytes3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
716
743
|
function newToken() {
|
|
@@ -792,14 +819,279 @@ function passwordTokenMatches(secret, token, fingerprint) {
|
|
|
792
819
|
return safeEqual(token.slice(dot + 1), hmac(secret, `password:${payload}:${fingerprint}`));
|
|
793
820
|
}
|
|
794
821
|
|
|
822
|
+
// src/auth/sso.ts
|
|
823
|
+
var SSO_COOKIE = "ecms-sso";
|
|
824
|
+
var PENDING_TTL = 10 * 6e4;
|
|
825
|
+
var random = () => randomBytes4(32).toString("base64url");
|
|
826
|
+
function sign(secret, payload) {
|
|
827
|
+
return createHmac2("sha256", secret).update(`sso:${payload}`).digest("base64url");
|
|
828
|
+
}
|
|
829
|
+
function seal(secret, pending) {
|
|
830
|
+
const payload = Buffer.from(JSON.stringify(pending)).toString("base64url");
|
|
831
|
+
return `${payload}.${sign(secret, payload)}`;
|
|
832
|
+
}
|
|
833
|
+
function unseal(secret, value) {
|
|
834
|
+
if (!value) return null;
|
|
835
|
+
const dot = value.lastIndexOf(".");
|
|
836
|
+
if (dot <= 0) return null;
|
|
837
|
+
const payload = value.slice(0, dot);
|
|
838
|
+
if (!safeEqual(value.slice(dot + 1), sign(secret, payload))) return null;
|
|
839
|
+
try {
|
|
840
|
+
const pending = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
841
|
+
return typeof pending.e === "number" && pending.e > Date.now() ? pending : null;
|
|
842
|
+
} catch {
|
|
843
|
+
return null;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
var SingleSignOn = class {
|
|
847
|
+
constructor(cms) {
|
|
848
|
+
this.cms = cms;
|
|
849
|
+
}
|
|
850
|
+
cms;
|
|
851
|
+
get config() {
|
|
852
|
+
return this.cms.config;
|
|
853
|
+
}
|
|
854
|
+
get enabled() {
|
|
855
|
+
return this.config.auth.providers.length > 0;
|
|
856
|
+
}
|
|
857
|
+
providers() {
|
|
858
|
+
return this.config.auth.providers.map((p) => ({
|
|
859
|
+
id: p.id,
|
|
860
|
+
name: p.name,
|
|
861
|
+
icon: p.icon ?? "key"
|
|
862
|
+
}));
|
|
863
|
+
}
|
|
864
|
+
/** Whether a user may sign in with a password (`auth.password: false` leaves only admins). */
|
|
865
|
+
passwordAllowed(user) {
|
|
866
|
+
return this.config.auth.password || user.role === "admin";
|
|
867
|
+
}
|
|
868
|
+
/** `<serverURL><api>/auth/<id>/callback`, or `null` without `serverURL` in production. */
|
|
869
|
+
callbackURL(provider, origin) {
|
|
870
|
+
const base = this.cms.auth.passwordLinkBase(origin);
|
|
871
|
+
return base ? `${base}${this.config.routes.api}/auth/${provider}/callback` : null;
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Starts signing in (or, with `user`, linking an account): the provider's URL to send the
|
|
875
|
+
* browser to, and the sealed value of the cookie that must come back with it.
|
|
876
|
+
*/
|
|
877
|
+
async start(providerId, options) {
|
|
878
|
+
const provider = this.provider(providerId);
|
|
879
|
+
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
880
|
+
if (!redirectUri)
|
|
881
|
+
throw new QueryError("Signing in with providers needs `serverURL` in production");
|
|
882
|
+
const pending = {
|
|
883
|
+
p: provider.id,
|
|
884
|
+
s: random(),
|
|
885
|
+
v: random(),
|
|
886
|
+
n: random(),
|
|
887
|
+
r: this.adminPage(options.redirect),
|
|
888
|
+
...options.user ? { u: String(options.user.id) } : {},
|
|
889
|
+
e: Date.now() + PENDING_TTL
|
|
890
|
+
};
|
|
891
|
+
const url = await provider.authorizationURL({
|
|
892
|
+
redirectUri,
|
|
893
|
+
state: pending.s,
|
|
894
|
+
codeVerifier: pending.v,
|
|
895
|
+
nonce: pending.n
|
|
896
|
+
});
|
|
897
|
+
return { url: url.toString(), cookie: seal(this.config.secret, pending) };
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Finishes at `<api>/auth/<id>/callback`: checks the response, finds (or makes) the user and
|
|
901
|
+
* starts a session. Returns where to send the browser; failures end on the login page (or the
|
|
902
|
+
* account page, when linking) with `?sso=<outcome>`.
|
|
903
|
+
*/
|
|
904
|
+
async finish(providerId, options) {
|
|
905
|
+
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
906
|
+
const pending = unseal(this.config.secret, options.cookie);
|
|
907
|
+
const back = (page2, outcome) => ({ redirect: `${admin}/${page2}?sso=${outcome}` });
|
|
908
|
+
const page = pending?.u ? "account" : "login";
|
|
909
|
+
if (!pending || pending.p !== providerId) return back(page, "expired");
|
|
910
|
+
const state = options.url.searchParams.get("state") ?? "";
|
|
911
|
+
if (!safeEqual(state, pending.s)) return back(page, "expired");
|
|
912
|
+
if (options.url.searchParams.has("error")) return back(page, "cancelled");
|
|
913
|
+
const provider = this.provider(providerId);
|
|
914
|
+
const redirectUri = this.callbackURL(provider.id, options.origin);
|
|
915
|
+
let profile;
|
|
916
|
+
try {
|
|
917
|
+
if (!redirectUri) throw new Error("no callback URL");
|
|
918
|
+
profile = await provider.callback({
|
|
919
|
+
redirectUri,
|
|
920
|
+
state: pending.s,
|
|
921
|
+
codeVerifier: pending.v,
|
|
922
|
+
nonce: pending.n,
|
|
923
|
+
url: options.url
|
|
924
|
+
});
|
|
925
|
+
} catch (error) {
|
|
926
|
+
this.cms.logger.warn(
|
|
927
|
+
`Signing in with ${provider.name} failed: ${error.message || String(error)}`
|
|
928
|
+
);
|
|
929
|
+
return back(page, "failed");
|
|
930
|
+
}
|
|
931
|
+
const subject = String(profile.subject);
|
|
932
|
+
const email2 = profile.email?.trim().toLowerCase() || null;
|
|
933
|
+
if (pending.u) {
|
|
934
|
+
if (!options.user || String(options.user.id) !== pending.u) return back("account", "failed");
|
|
935
|
+
const existing = await this.identity(provider.id, subject);
|
|
936
|
+
if (existing && String(existing.user) !== pending.u) return back("account", "taken");
|
|
937
|
+
if (!existing) await this.addIdentity(options.user.id, provider.id, subject, email2);
|
|
938
|
+
return { redirect: `${admin}/account?sso=linked` };
|
|
939
|
+
}
|
|
940
|
+
let user = null;
|
|
941
|
+
const identity = await this.identity(provider.id, subject);
|
|
942
|
+
if (identity) {
|
|
943
|
+
user = await this.cms.db.findById({ collection: USERS, id: identity.user });
|
|
944
|
+
} else {
|
|
945
|
+
if (!email2 || !profile.emailVerified)
|
|
946
|
+
return back("login", email2 ? "unverified" : "no-account");
|
|
947
|
+
user = await this.userByEmail(email2);
|
|
948
|
+
if (!user) user = await this.signUp(email2, profile.name);
|
|
949
|
+
if (!user) return back("login", "no-account");
|
|
950
|
+
await this.addIdentity(user.id, provider.id, subject, email2);
|
|
951
|
+
}
|
|
952
|
+
if (!user) return back("login", "no-account");
|
|
953
|
+
if (user.active === false) return back("login", "inactive");
|
|
954
|
+
const found = identity ?? await this.identity(provider.id, subject);
|
|
955
|
+
if (found) {
|
|
956
|
+
const { id, ...rest } = found;
|
|
957
|
+
await this.cms.db.update({
|
|
958
|
+
collection: USER_IDENTITIES,
|
|
959
|
+
id,
|
|
960
|
+
data: { ...rest, email: email2, lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
return { redirect: pending.r, session: await this.cms.auth.createSession(user.id) };
|
|
964
|
+
}
|
|
965
|
+
/** A user's outside accounts. */
|
|
966
|
+
async identities(userId) {
|
|
967
|
+
if (!this.enabled) return [];
|
|
968
|
+
const { docs } = await this.cms.db.find({
|
|
969
|
+
collection: USER_IDENTITIES,
|
|
970
|
+
where: { user: { equals: userId } },
|
|
971
|
+
sort: ["id"],
|
|
972
|
+
limit: 0,
|
|
973
|
+
page: 1
|
|
974
|
+
});
|
|
975
|
+
return docs.map((row) => ({
|
|
976
|
+
id: row.id,
|
|
977
|
+
provider: String(row.provider),
|
|
978
|
+
name: this.config.auth.providers.find((p) => p.id === row.provider)?.name ?? String(row.provider),
|
|
979
|
+
email: typeof row.email === "string" ? row.email : null,
|
|
980
|
+
createdAt: String(row.createdAt),
|
|
981
|
+
lastUsedAt: typeof row.lastUsedAt === "string" ? row.lastUsedAt : null
|
|
982
|
+
}));
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Unlinks an outside account: the user's own, or anyone's for admins. Refuses to unlink the
|
|
986
|
+
* last way a user has to sign in.
|
|
987
|
+
*/
|
|
988
|
+
async unlink(actor, identityId) {
|
|
989
|
+
if (!this.enabled) throw new NotFoundError(USER_IDENTITIES, identityId);
|
|
990
|
+
const row = await this.cms.db.findById({ collection: USER_IDENTITIES, id: identityId });
|
|
991
|
+
if (!row) throw new NotFoundError(USER_IDENTITIES, identityId);
|
|
992
|
+
const own = String(row.user) === String(actor.id);
|
|
993
|
+
if (!own && (actor.role !== "admin" || actor.apiKey)) throw new ForbiddenError();
|
|
994
|
+
const user = await this.cms.db.findById({ collection: USERS, id: row.user });
|
|
995
|
+
const others = (await this.identities(row.user)).length - 1;
|
|
996
|
+
const password = !!user?.passwordHash && this.passwordAllowed(user);
|
|
997
|
+
if (others === 0 && !password)
|
|
998
|
+
throw new ValidationError(USER_IDENTITIES, [
|
|
999
|
+
{ field: "provider", message: "is the only way left to sign in to this account" }
|
|
1000
|
+
]);
|
|
1001
|
+
await this.cms.db.delete({ collection: USER_IDENTITIES, id: row.id });
|
|
1002
|
+
}
|
|
1003
|
+
/** Forgets a deleted user's outside accounts. */
|
|
1004
|
+
async forget(userId) {
|
|
1005
|
+
if (!this.enabled) return;
|
|
1006
|
+
for (const identity of await this.identities(userId))
|
|
1007
|
+
await this.cms.db.delete({ collection: USER_IDENTITIES, id: identity.id });
|
|
1008
|
+
}
|
|
1009
|
+
settings(origin) {
|
|
1010
|
+
const { allowSignUp } = this.config.auth;
|
|
1011
|
+
return {
|
|
1012
|
+
providers: this.config.auth.providers.map((p) => ({
|
|
1013
|
+
id: p.id,
|
|
1014
|
+
name: p.name,
|
|
1015
|
+
icon: p.icon ?? "key",
|
|
1016
|
+
callbackURL: this.callbackURL(p.id, origin),
|
|
1017
|
+
details: { ...p.describe?.() ?? {} }
|
|
1018
|
+
})),
|
|
1019
|
+
password: this.config.auth.password ? "everyone" : "admins",
|
|
1020
|
+
signUp: allowSignUp.domains.length > 0 ? { domains: [...allowSignUp.domains], role: allowSignUp.role ?? this.defaultRole() } : null,
|
|
1021
|
+
serverURL: !!this.config.serverURL
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
// -------------------------------------------------------------------------
|
|
1025
|
+
provider(id) {
|
|
1026
|
+
const provider = this.config.auth.providers.find((p) => p.id === id);
|
|
1027
|
+
if (!provider) throw new NotFoundError("auth providers", id);
|
|
1028
|
+
return provider;
|
|
1029
|
+
}
|
|
1030
|
+
/** Only pages of the admin: anything else (another site) goes to the admin's home. */
|
|
1031
|
+
adminPage(redirect) {
|
|
1032
|
+
const admin = this.config.admin.path.replace(/\/+$/, "");
|
|
1033
|
+
if (typeof redirect === "string" && (redirect === admin || redirect.startsWith(`${admin}/`)) && !redirect.startsWith("//") && !redirect.includes("\\"))
|
|
1034
|
+
return redirect;
|
|
1035
|
+
return `${admin}/`;
|
|
1036
|
+
}
|
|
1037
|
+
async identity(provider, subject) {
|
|
1038
|
+
const { docs } = await this.cms.db.find({
|
|
1039
|
+
collection: USER_IDENTITIES,
|
|
1040
|
+
where: { and: [{ provider: { equals: provider } }, { subject: { equals: subject } }] },
|
|
1041
|
+
sort: [],
|
|
1042
|
+
limit: 1,
|
|
1043
|
+
page: 1
|
|
1044
|
+
});
|
|
1045
|
+
return docs[0];
|
|
1046
|
+
}
|
|
1047
|
+
async addIdentity(user, provider, subject, email2) {
|
|
1048
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1049
|
+
await this.cms.db.create({
|
|
1050
|
+
collection: USER_IDENTITIES,
|
|
1051
|
+
data: { user, provider, subject, email: email2, lastUsedAt: now, createdAt: now, updatedAt: now }
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
async userByEmail(email2) {
|
|
1055
|
+
const { docs } = await this.cms.db.find({
|
|
1056
|
+
collection: USERS,
|
|
1057
|
+
where: { email: { equals: email2 } },
|
|
1058
|
+
sort: [],
|
|
1059
|
+
limit: 1,
|
|
1060
|
+
page: 1
|
|
1061
|
+
});
|
|
1062
|
+
return docs[0] ?? null;
|
|
1063
|
+
}
|
|
1064
|
+
/** A new account for people from `allowSignUp.domains`. */
|
|
1065
|
+
async signUp(email2, name) {
|
|
1066
|
+
const { domains, role } = this.config.auth.allowSignUp;
|
|
1067
|
+
const domain = email2.slice(email2.lastIndexOf("@") + 1);
|
|
1068
|
+
if (!domains.some((d) => d.toLowerCase() === domain)) return null;
|
|
1069
|
+
const created = await this.cms.create(USERS, {
|
|
1070
|
+
email: email2,
|
|
1071
|
+
role: role ?? this.defaultRole(),
|
|
1072
|
+
active: true,
|
|
1073
|
+
...name ? { name } : {}
|
|
1074
|
+
});
|
|
1075
|
+
this.cms.logger.info(`${email2} signed up with a provider`);
|
|
1076
|
+
return this.cms.db.findById({ collection: USERS, id: created.id });
|
|
1077
|
+
}
|
|
1078
|
+
defaultRole() {
|
|
1079
|
+
const roles = this.config.auth.roles;
|
|
1080
|
+
return roles.includes("editor") ? "editor" : roles[roles.length - 1];
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
|
|
795
1084
|
// src/auth/auth.ts
|
|
796
1085
|
var INVALID = "Invalid email or password";
|
|
797
1086
|
var RESET_REQUESTS = 3;
|
|
798
1087
|
var Auth = class {
|
|
799
1088
|
constructor(cms) {
|
|
800
1089
|
this.cms = cms;
|
|
1090
|
+
this.sso = new SingleSignOn(cms);
|
|
801
1091
|
}
|
|
802
1092
|
cms;
|
|
1093
|
+
/** Signing in with outside accounts (`auth.providers`). */
|
|
1094
|
+
sso;
|
|
803
1095
|
get db() {
|
|
804
1096
|
return this.cms.db;
|
|
805
1097
|
}
|
|
@@ -826,6 +1118,10 @@ var Auth = class {
|
|
|
826
1118
|
throw new UnauthorizedError(INVALID);
|
|
827
1119
|
}
|
|
828
1120
|
await this.clearFailures(key2);
|
|
1121
|
+
if (!this.sso.passwordAllowed(user))
|
|
1122
|
+
throw new UnauthorizedError(
|
|
1123
|
+
`Sign in with ${this.config.auth.providers.map((p) => p.name).join(" or ")}`
|
|
1124
|
+
);
|
|
829
1125
|
await this.deleteExpiredSessions(user.id);
|
|
830
1126
|
return this.startSession(user);
|
|
831
1127
|
}
|
|
@@ -925,7 +1221,7 @@ var Auth = class {
|
|
|
925
1221
|
if (recent >= RESET_REQUESTS) return;
|
|
926
1222
|
await this.recordFailure(key2);
|
|
927
1223
|
const user = await this.findByEmail(email2);
|
|
928
|
-
if (!user || user.active === false) return;
|
|
1224
|
+
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return;
|
|
929
1225
|
await this.mailPasswordLink(user, user.passwordHash ? "reset" : "invite", args);
|
|
930
1226
|
}
|
|
931
1227
|
/**
|
|
@@ -981,7 +1277,7 @@ var Auth = class {
|
|
|
981
1277
|
if (!claims || claims.expiresAt <= Date.now()) return null;
|
|
982
1278
|
const id = /^\d+$/.test(claims.userId) ? Number(claims.userId) : claims.userId;
|
|
983
1279
|
const user = await this.db.findById({ collection: USERS, id });
|
|
984
|
-
if (!user || user.active === false) return null;
|
|
1280
|
+
if (!user || user.active === false || !this.sso.passwordAllowed(user)) return null;
|
|
985
1281
|
const fingerprint = passwordFingerprint(user.passwordHash);
|
|
986
1282
|
if (!passwordTokenMatches(this.config.secret, token, fingerprint)) return null;
|
|
987
1283
|
return { user, purpose: claims.purpose };
|
|
@@ -996,13 +1292,14 @@ var Auth = class {
|
|
|
996
1292
|
expiresAt,
|
|
997
1293
|
fingerprint: passwordFingerprint(user.passwordHash)
|
|
998
1294
|
});
|
|
999
|
-
const
|
|
1295
|
+
const sso = this.sso.passwordAllowed(user) ? void 0 : this.config.auth.providers.map((p) => p.name);
|
|
1296
|
+
const url = sso ? `${base}${this.config.admin.path}/login` : `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
|
|
1000
1297
|
const write = purpose === "invite" ? this.config.auth.emails.invite ?? DEFAULT_PASSWORD_EMAILS.invite : this.config.auth.emails.resetPassword ?? DEFAULT_PASSWORD_EMAILS.resetPassword;
|
|
1001
1298
|
const content = await write({
|
|
1002
1299
|
user: await this.toAuthUser(user),
|
|
1003
1300
|
url,
|
|
1004
1301
|
locale: this.localeOf(options.locale),
|
|
1005
|
-
expiresAt: new Date(expiresAt)
|
|
1302
|
+
...sso ? { sso } : { expiresAt: new Date(expiresAt) }
|
|
1006
1303
|
});
|
|
1007
1304
|
await this.cms.sendEmail({ to: String(user.email), ...content });
|
|
1008
1305
|
}
|
|
@@ -2877,7 +3174,7 @@ function rank(role, config) {
|
|
|
2877
3174
|
}
|
|
2878
3175
|
|
|
2879
3176
|
// src/webhooks.ts
|
|
2880
|
-
import { createHmac as
|
|
3177
|
+
import { createHmac as createHmac3, randomUUID as randomUUID2 } from "crypto";
|
|
2881
3178
|
var WEBHOOK_EVENTS = [
|
|
2882
3179
|
"create",
|
|
2883
3180
|
"update",
|
|
@@ -3021,7 +3318,7 @@ var Webhooks = class {
|
|
|
3021
3318
|
...hook.headers
|
|
3022
3319
|
};
|
|
3023
3320
|
if (hook.secret) {
|
|
3024
|
-
headers["x-easy-cms-signature"] = `sha256=${
|
|
3321
|
+
headers["x-easy-cms-signature"] = `sha256=${createHmac3("sha256", hook.secret).update(body).digest("hex")}`;
|
|
3025
3322
|
}
|
|
3026
3323
|
try {
|
|
3027
3324
|
const response = await this.fetcher(hook.url, {
|
|
@@ -3068,8 +3365,10 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
|
|
|
3068
3365
|
"email-deliveries",
|
|
3069
3366
|
"database-backups",
|
|
3070
3367
|
"user-roles",
|
|
3368
|
+
"user-identities",
|
|
3071
3369
|
"api-keys",
|
|
3072
3370
|
"jobs",
|
|
3371
|
+
"auth",
|
|
3073
3372
|
"migrations",
|
|
3074
3373
|
"access"
|
|
3075
3374
|
]);
|
|
@@ -3317,6 +3616,52 @@ function validateAuth(config, add) {
|
|
|
3317
3616
|
if (auth === void 0) return;
|
|
3318
3617
|
if (auth.rbac !== void 0 && typeof auth.rbac !== "boolean")
|
|
3319
3618
|
add("auth.rbac", "must be true or false");
|
|
3619
|
+
if (auth.password !== void 0 && typeof auth.password !== "boolean")
|
|
3620
|
+
add("auth.password", "must be true or false");
|
|
3621
|
+
const providers = auth.providers;
|
|
3622
|
+
if (providers !== void 0) {
|
|
3623
|
+
if (!Array.isArray(providers)) {
|
|
3624
|
+
add(
|
|
3625
|
+
"auth.providers",
|
|
3626
|
+
"must be an array",
|
|
3627
|
+
"e.g. providers: [google({ clientId, clientSecret })]"
|
|
3628
|
+
);
|
|
3629
|
+
} else {
|
|
3630
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3631
|
+
providers.forEach((provider, i) => {
|
|
3632
|
+
const p = provider;
|
|
3633
|
+
const path = `auth.providers[${i}]`;
|
|
3634
|
+
if (typeof p !== "object" || p === null || typeof p.authorizationURL !== "function" || typeof p.callback !== "function") {
|
|
3635
|
+
add(
|
|
3636
|
+
path,
|
|
3637
|
+
"must be a provider",
|
|
3638
|
+
"e.g. google({ clientId, clientSecret }) from @easy-cms/auth-oauth"
|
|
3639
|
+
);
|
|
3640
|
+
return;
|
|
3641
|
+
}
|
|
3642
|
+
if (typeof p.id !== "string" || !PAGE_PATH.test(p.id))
|
|
3643
|
+
add(`${path}.id`, 'must be lowercase letters, digits and "-"');
|
|
3644
|
+
else if (ids.has(p.id))
|
|
3645
|
+
add(`${path}.id`, `"${p.id}" is used by another provider`, "give one of them its own id");
|
|
3646
|
+
else ids.add(p.id);
|
|
3647
|
+
if (typeof p.name !== "string" || p.name.trim() === "")
|
|
3648
|
+
add(`${path}.name`, "must be a name");
|
|
3649
|
+
});
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
const signUp = auth.allowSignUp;
|
|
3653
|
+
if (signUp !== void 0) {
|
|
3654
|
+
if (typeof signUp !== "object" || signUp === null || !Array.isArray(signUp.domains) || signUp.domains.some((d) => typeof d !== "string" || !/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(d)))
|
|
3655
|
+
add("auth.allowSignUp.domains", "must be email domains", "e.g. domains: ['example.com']");
|
|
3656
|
+
if (signUp?.role === "admin")
|
|
3657
|
+
add("auth.allowSignUp.role", `can't be "admin"`, "make admins by hand, in Settings \u2192 Users");
|
|
3658
|
+
else if (signUp?.role !== void 0 && (typeof signUp.role !== "string" || !auth.rbac && !(auth.roles ?? ["admin", "editor"]).includes(signUp.role)))
|
|
3659
|
+
add("auth.allowSignUp.role", "must be one of auth.roles");
|
|
3660
|
+
if (!Array.isArray(providers) || providers.length === 0)
|
|
3661
|
+
add("auth.allowSignUp", "needs auth.providers");
|
|
3662
|
+
}
|
|
3663
|
+
if (auth.password === false && (!Array.isArray(providers) || providers.length === 0))
|
|
3664
|
+
add("auth.password", "false needs auth.providers", "otherwise only admins could sign in");
|
|
3320
3665
|
if (auth.roles !== void 0) {
|
|
3321
3666
|
const roles = auth.roles;
|
|
3322
3667
|
if (!Array.isArray(roles) || roles.some((r) => typeof r !== "string" || r === "")) {
|
|
@@ -3843,7 +4188,15 @@ function validateComponents(components, path, add) {
|
|
|
3843
4188
|
}
|
|
3844
4189
|
var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
3845
4190
|
var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
|
|
3846
|
-
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
|
|
4191
|
+
var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set([
|
|
4192
|
+
"users",
|
|
4193
|
+
"globals",
|
|
4194
|
+
"admin",
|
|
4195
|
+
"jobs",
|
|
4196
|
+
"media",
|
|
4197
|
+
"api-keys",
|
|
4198
|
+
"auth"
|
|
4199
|
+
]);
|
|
3847
4200
|
function validateCommands(config, add) {
|
|
3848
4201
|
const commands = config.commands;
|
|
3849
4202
|
if (commands === void 0) return;
|
|
@@ -4029,6 +4382,9 @@ async function resolveConfig(input) {
|
|
|
4029
4382
|
auth: {
|
|
4030
4383
|
roles: config.auth?.roles ?? DEFAULT_ROLES,
|
|
4031
4384
|
rbac: config.auth?.rbac === true,
|
|
4385
|
+
providers: config.auth?.providers ?? [],
|
|
4386
|
+
allowSignUp: config.auth?.allowSignUp ?? { domains: [] },
|
|
4387
|
+
password: config.auth?.password !== false,
|
|
4032
4388
|
tokenExpiration: config.auth?.tokenExpiration ?? DEFAULT_TOKEN_EXPIRATION,
|
|
4033
4389
|
maxLoginAttempts: config.auth?.maxLoginAttempts ?? 5,
|
|
4034
4390
|
lockWindow: config.auth?.lockWindow ?? 15 * 60,
|
|
@@ -4047,7 +4403,8 @@ async function resolveConfig(input) {
|
|
|
4047
4403
|
...config.apiKeys ? [apiKeysCollection] : [],
|
|
4048
4404
|
...config.email ? [emailDeliveriesCollection] : [],
|
|
4049
4405
|
databaseBackupsCollection,
|
|
4050
|
-
...config.auth?.rbac ? [rolesCollection] : []
|
|
4406
|
+
...config.auth?.rbac ? [rolesCollection] : [],
|
|
4407
|
+
...(config.auth?.providers?.length ?? 0) > 0 ? [userIdentitiesCollection] : []
|
|
4051
4408
|
],
|
|
4052
4409
|
globals: config.globals ?? [],
|
|
4053
4410
|
endpoints: config.endpoints ?? [],
|
|
@@ -4113,7 +4470,7 @@ async function loadConfig(options = {}) {
|
|
|
4113
4470
|
}
|
|
4114
4471
|
|
|
4115
4472
|
// src/local-api.ts
|
|
4116
|
-
import { randomBytes as
|
|
4473
|
+
import { randomBytes as randomBytes5 } from "crypto";
|
|
4117
4474
|
|
|
4118
4475
|
// src/localization.ts
|
|
4119
4476
|
var ALL_LOCALES = "all";
|
|
@@ -4310,10 +4667,10 @@ function imageDimensions(data, type) {
|
|
|
4310
4667
|
}
|
|
4311
4668
|
return void 0;
|
|
4312
4669
|
}
|
|
4313
|
-
function storageKey(originalName, type,
|
|
4670
|
+
function storageKey(originalName, type, random2) {
|
|
4314
4671
|
const name = originalName.split(/[\\/]/).pop() ?? "";
|
|
4315
4672
|
const base = name.replace(/(?<=.)\.[^.]*$/, "").normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "file";
|
|
4316
|
-
return `${base}-${
|
|
4673
|
+
return `${base}-${random2}.${EXTENSIONS[type] ?? "bin"}`;
|
|
4317
4674
|
}
|
|
4318
4675
|
|
|
4319
4676
|
// src/populate.ts
|
|
@@ -4752,8 +5109,8 @@ var EasyCMS = class {
|
|
|
4752
5109
|
}
|
|
4753
5110
|
]);
|
|
4754
5111
|
}
|
|
4755
|
-
const
|
|
4756
|
-
const filename = storageKey(file.name, mimeType,
|
|
5112
|
+
const random2 = randomBytes5(4).toString("hex");
|
|
5113
|
+
const filename = storageKey(file.name, mimeType, random2);
|
|
4757
5114
|
const stored = [];
|
|
4758
5115
|
try {
|
|
4759
5116
|
await this.storage.put(filename, file.data, { contentType: mimeType });
|
|
@@ -5175,6 +5532,7 @@ var EasyCMS = class {
|
|
|
5175
5532
|
await this.roles.transfer(parsed, heir.id);
|
|
5176
5533
|
} else await this.roles.transfer(parsed, null);
|
|
5177
5534
|
await this.auth.revokeSessions(parsed);
|
|
5535
|
+
await this.auth.sso.forget(parsed);
|
|
5178
5536
|
}
|
|
5179
5537
|
await this.db.delete({ collection: collection2, id: parsed });
|
|
5180
5538
|
if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
|
|
@@ -6678,7 +7036,8 @@ async function adminSchema(cms, user, origin) {
|
|
|
6678
7036
|
deliveries: deliveries && await cms.roles.canView(user, "deliveries"),
|
|
6679
7037
|
backups: admin,
|
|
6680
7038
|
email: admin,
|
|
6681
|
-
roles: admin && cms.roles.enabled
|
|
7039
|
+
roles: admin && cms.roles.enabled,
|
|
7040
|
+
sso: admin && cms.auth.sso.enabled
|
|
6682
7041
|
};
|
|
6683
7042
|
const roles = await cms.roles.options();
|
|
6684
7043
|
return {
|
|
@@ -6694,6 +7053,7 @@ async function adminSchema(cms, user, origin) {
|
|
|
6694
7053
|
uploadFromURL: cms.config.upload.fromURL !== void 0,
|
|
6695
7054
|
views,
|
|
6696
7055
|
rbac: cms.roles.enabled,
|
|
7056
|
+
providers: cms.auth.sso.providers(),
|
|
6697
7057
|
...views.deliveries ? {
|
|
6698
7058
|
deliveries: {
|
|
6699
7059
|
webhook: cms.config.collections.some((c) => c.slug === WEBHOOK_DELIVERIES),
|
|
@@ -6739,7 +7099,7 @@ async function widgets(cms, user) {
|
|
|
6739
7099
|
}
|
|
6740
7100
|
|
|
6741
7101
|
// src/version.ts
|
|
6742
|
-
var VERSION = true ? "0.
|
|
7102
|
+
var VERSION = true ? "0.35.0" : "0.0.0-dev";
|
|
6743
7103
|
|
|
6744
7104
|
// src/rest/admin-status.ts
|
|
6745
7105
|
var DAY = 864e5;
|
|
@@ -7089,6 +7449,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7089
7449
|
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
7090
7450
|
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
7091
7451
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
7452
|
+
if (first === "auth") return ssoRoute(cms, ctx, method, segments);
|
|
7092
7453
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
7093
7454
|
switch (`${method} ${second}`) {
|
|
7094
7455
|
case "POST login": {
|
|
@@ -7119,7 +7480,10 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7119
7480
|
body: {
|
|
7120
7481
|
hasUsers: await cms.auth.hasUsers(),
|
|
7121
7482
|
// The login page offers "Forgot password?" only when the email can be sent.
|
|
7122
|
-
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin)
|
|
7483
|
+
passwordReset: cms.auth.canSendPasswordLinks(ctx.url.origin),
|
|
7484
|
+
// Buttons to sign in with outside accounts; `password: false`: only admins use one.
|
|
7485
|
+
providers: cms.auth.sso.providers(),
|
|
7486
|
+
password: cms.config.auth.password
|
|
7123
7487
|
}
|
|
7124
7488
|
};
|
|
7125
7489
|
// Forgot password: the same answer whether or not the email has an account.
|
|
@@ -7230,6 +7594,12 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
7230
7594
|
}
|
|
7231
7595
|
throw new HttpError("Not found", 404);
|
|
7232
7596
|
}
|
|
7597
|
+
if (first === "admin" && second === "sso" && third === void 0) {
|
|
7598
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
7599
|
+
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
7600
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
7601
|
+
return { body: cms.auth.sso.settings(ctx.url.origin) };
|
|
7602
|
+
}
|
|
7233
7603
|
if (first === "admin" && second === "owned" && third !== void 0 && segments.length === 3) {
|
|
7234
7604
|
if (!ctx.user) throw new UnauthorizedError();
|
|
7235
7605
|
if (ctx.user.role !== "admin" || ctx.user.apiKey) throw new ForbiddenError();
|
|
@@ -7703,6 +8073,67 @@ function cookie(ctx, name, value, opts) {
|
|
|
7703
8073
|
if (opts.maxAge !== void 0) parts.push(`Max-Age=${opts.maxAge}`);
|
|
7704
8074
|
return parts.join("; ");
|
|
7705
8075
|
}
|
|
8076
|
+
async function ssoRoute(cms, ctx, method, segments) {
|
|
8077
|
+
const sso = cms.auth.sso;
|
|
8078
|
+
if (!sso.enabled) throw new HttpError("Not found", 404);
|
|
8079
|
+
const [, second, third, extra] = segments;
|
|
8080
|
+
if (extra !== void 0 || second === void 0) throw new HttpError("Not found", 404);
|
|
8081
|
+
if (second === "identities") {
|
|
8082
|
+
if (!ctx.user) throw new UnauthorizedError();
|
|
8083
|
+
if (third === void 0) {
|
|
8084
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8085
|
+
const asked = ctx.url.searchParams.get("user");
|
|
8086
|
+
const target = asked === null ? ctx.user.id : parseId(asked) ?? asked;
|
|
8087
|
+
if (String(target) !== String(ctx.user.id) && (ctx.user.role !== "admin" || ctx.user.apiKey))
|
|
8088
|
+
throw new ForbiddenError();
|
|
8089
|
+
return { body: await sso.identities(target) };
|
|
8090
|
+
}
|
|
8091
|
+
if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
|
|
8092
|
+
const id = parseId(third);
|
|
8093
|
+
if (id === void 0) throw new HttpError("Not found", 404);
|
|
8094
|
+
await sso.unlink(ctx.user, id);
|
|
8095
|
+
return { body: { deleted: 1 } };
|
|
8096
|
+
}
|
|
8097
|
+
const pendingCookie = (value, maxAge) => cookie(ctx, SSO_COOKIE, value, { httpOnly: true, maxAge });
|
|
8098
|
+
if (third === "login") {
|
|
8099
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8100
|
+
const started = await sso.start(second, {
|
|
8101
|
+
origin: ctx.url.origin,
|
|
8102
|
+
redirect: ctx.url.searchParams.get("redirect")
|
|
8103
|
+
});
|
|
8104
|
+
ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
|
|
8105
|
+
return { body: redirectResponse(ctx, started.url) };
|
|
8106
|
+
}
|
|
8107
|
+
if (third === "link") {
|
|
8108
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
8109
|
+
if (!ctx.user || ctx.user.apiKey) throw new UnauthorizedError();
|
|
8110
|
+
const started = await sso.start(second, {
|
|
8111
|
+
origin: ctx.url.origin,
|
|
8112
|
+
redirect: null,
|
|
8113
|
+
user: ctx.user
|
|
8114
|
+
});
|
|
8115
|
+
ctx.headers.append("set-cookie", pendingCookie(started.cookie, 600));
|
|
8116
|
+
return { body: { url: started.url } };
|
|
8117
|
+
}
|
|
8118
|
+
if (third === "callback") {
|
|
8119
|
+
if (method !== "GET") throw methodNotAllowed(ctx, "GET");
|
|
8120
|
+
const finished = await sso.finish(second, {
|
|
8121
|
+
url: ctx.url,
|
|
8122
|
+
cookie: readCookie(ctx.request, SSO_COOKIE),
|
|
8123
|
+
origin: ctx.url.origin,
|
|
8124
|
+
user: ctx.user
|
|
8125
|
+
});
|
|
8126
|
+
ctx.headers.append("set-cookie", pendingCookie("", 0));
|
|
8127
|
+
if (finished.session) setSessionCookies(cms, ctx, finished.session);
|
|
8128
|
+
return { body: redirectResponse(ctx, finished.redirect) };
|
|
8129
|
+
}
|
|
8130
|
+
throw new HttpError("Not found", 404);
|
|
8131
|
+
}
|
|
8132
|
+
function redirectResponse(ctx, location) {
|
|
8133
|
+
const headers = new Headers({ location, "cache-control": "no-store" });
|
|
8134
|
+
for (const value of ctx.headers.getSetCookie()) headers.append("set-cookie", value);
|
|
8135
|
+
return new Response(null, { status: 302, headers });
|
|
8136
|
+
}
|
|
7706
8137
|
function setSessionCookies(cms, ctx, session) {
|
|
7707
8138
|
const maxAge = cms.config.auth.tokenExpiration;
|
|
7708
8139
|
ctx.headers.append(
|
|
@@ -7932,6 +8363,7 @@ export {
|
|
|
7932
8363
|
ROLE_OPERATIONS,
|
|
7933
8364
|
Roles,
|
|
7934
8365
|
SESSION_COOKIE,
|
|
8366
|
+
SSO_COOKIE,
|
|
7935
8367
|
SchemaError,
|
|
7936
8368
|
TooManyRequestsError,
|
|
7937
8369
|
UnauthorizedError,
|