@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 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 url = `${base}${this.config.admin.path}/reset-password?token=${encodeURIComponent(token)}`;
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 createHmac2, randomUUID as randomUUID2 } from "crypto";
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=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
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(["users", "globals", "admin", "jobs", "media", "api-keys"]);
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 randomBytes4 } from "crypto";
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, random) {
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}-${random}.${EXTENSIONS[type] ?? "bin"}`;
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 random = randomBytes4(4).toString("hex");
4756
- const filename = storageKey(file.name, mimeType, random);
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.34.0" : "0.0.0-dev";
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",