Explorar el Código

feat(core): API Key support (#3815)

Daniel Biegler hace 1 mes
padre
commit
c4e0d9928a
Se han modificado 50 ficheros con 1976 adiciones y 74 borrados
  1. 186 0
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 3 0
      packages/admin-ui/src/lib/core/src/common/introspection-result.ts
  3. 9 0
      packages/asset-server-plugin/e2e/graphql/graphql-env-admin.d.ts
  4. 1 1
      packages/asset-server-plugin/e2e/graphql/graphql-env-shop.d.ts
  5. 8 0
      packages/common/src/generated-shop-types.ts
  6. 186 0
      packages/common/src/generated-types.ts
  7. 1 0
      packages/common/src/shared-constants.ts
  8. 4 0
      packages/core/e2e/__snapshots__/administrator.e2e-spec.ts.snap
  9. 216 0
      packages/core/e2e/api-key.e2e-spec.ts
  10. 9 0
      packages/core/e2e/graphql/graphql-env-admin.d.ts
  11. 1 1
      packages/core/e2e/graphql/graphql-env-shop.d.ts
  12. 2 0
      packages/core/src/api/api-internal-modules.ts
  13. 32 28
      packages/core/src/api/common/extract-session-token.ts
  14. 91 6
      packages/core/src/api/middleware/auth-guard.ts
  15. 90 0
      packages/core/src/api/resolvers/admin/api-key.resolver.ts
  16. 3 3
      packages/core/src/api/resolvers/admin/auth.resolver.ts
  17. 15 4
      packages/core/src/api/resolvers/base/base-auth.resolver.ts
  18. 3 1
      packages/core/src/api/resolvers/shop/shop-auth.resolver.ts
  19. 76 0
      packages/core/src/api/schema/admin-api/api-key.api.graphql
  20. 44 0
      packages/core/src/api/schema/admin-api/api-key.type.graphql
  21. 1 0
      packages/core/src/common/constants.ts
  22. 192 0
      packages/core/src/config/api-key-strategy/api-key-strategy.ts
  23. 112 0
      packages/core/src/config/api-key-strategy/random-bytes-api-key-strategy.ts
  24. 8 3
      packages/core/src/config/auth/password-hashing-strategy.ts
  25. 4 0
      packages/core/src/config/config.module.ts
  26. 1 2
      packages/core/src/config/config.service.ts
  27. 5 4
      packages/core/src/config/custom-field/custom-field-types.ts
  28. 6 0
      packages/core/src/config/default-config.ts
  29. 2 0
      packages/core/src/config/index.ts
  30. 25 1
      packages/core/src/config/vendure-config.ts
  31. 29 0
      packages/core/src/entity/api-key/api-key-translation.entity.ts
  32. 83 0
      packages/core/src/entity/api-key/api-key.entity.ts
  33. 2 2
      packages/core/src/entity/authentication-method/authentication-method.entity.ts
  34. 6 2
      packages/core/src/entity/channel/channel.entity.ts
  35. 2 0
      packages/core/src/entity/custom-entity-fields.ts
  36. 4 0
      packages/core/src/entity/entities.ts
  37. 27 0
      packages/core/src/event-bus/events/api-key-event.ts
  38. 2 0
      packages/core/src/service/service.module.ts
  39. 376 0
      packages/core/src/service/services/api-key.service.ts
  40. 44 7
      packages/core/src/service/services/session.service.ts
  41. 22 1
      packages/core/src/service/services/user.service.ts
  42. 6 6
      packages/dev-server/dev-config.ts
  43. 9 0
      packages/dev-server/graphql/graphql-env.d.ts
  44. 9 0
      packages/elasticsearch-plugin/e2e/graphql/graphql-env-admin.d.ts
  45. 1 1
      packages/elasticsearch-plugin/e2e/graphql/graphql-env-shop.d.ts
  46. 9 0
      packages/payments-plugin/e2e/graphql/graphql-env-admin.d.ts
  47. 1 1
      packages/payments-plugin/e2e/graphql/graphql-env-shop.d.ts
  48. 8 0
      packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts
  49. 0 0
      schema-admin.json
  50. 0 0
      schema-shop.json

+ 186 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -172,6 +172,80 @@ export type AlreadyRefundedError = ErrorResult & {
   refundId: Scalars['ID']['output'];
 };
 
+export type ApiKey = Node & {
+  __typename?: 'ApiKey';
+  createdAt: Scalars['DateTime']['output'];
+  customFields?: Maybe<Scalars['JSON']['output']>;
+  id: Scalars['ID']['output'];
+  /** Helps you identify unused keys */
+  lastUsedAt?: Maybe<Scalars['DateTime']['output']>;
+  /**
+   * ID by which we can look up the API-Key.
+   * Also helps you identify keys without leaking the underlying secret API-Key.
+   */
+  lookupId: Scalars['String']['output'];
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name: Scalars['String']['output'];
+  /**
+   * Usually the user who created the ApiKey but could also be used as the basis for
+   * restricting resolvers to `Permission.Owner` queries for customers for example.
+   */
+  owner: User;
+  translations: Array<ApiKeyTranslation>;
+  updatedAt: Scalars['DateTime']['output'];
+  /** This is the underlying User which determines the kind of permissions for this API-Key. */
+  user: User;
+};
+
+export type ApiKeyFilterParameter = {
+  _and?: InputMaybe<Array<ApiKeyFilterParameter>>;
+  _or?: InputMaybe<Array<ApiKeyFilterParameter>>;
+  createdAt?: InputMaybe<DateOperators>;
+  id?: InputMaybe<IdOperators>;
+  lastUsedAt?: InputMaybe<DateOperators>;
+  lookupId?: InputMaybe<StringOperators>;
+  name?: InputMaybe<StringOperators>;
+  updatedAt?: InputMaybe<DateOperators>;
+};
+
+export type ApiKeyList = PaginatedList & {
+  __typename?: 'ApiKeyList';
+  items: Array<ApiKey>;
+  totalItems: Scalars['Int']['output'];
+};
+
+export type ApiKeyListOptions = {
+  /** Allows the results to be filtered */
+  filter?: InputMaybe<ApiKeyFilterParameter>;
+  /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+  filterOperator?: InputMaybe<LogicalOperator>;
+  /** Skips the first n results, for use in pagination */
+  skip?: InputMaybe<Scalars['Int']['input']>;
+  /** Specifies which properties to sort the results by */
+  sort?: InputMaybe<ApiKeySortParameter>;
+  /** Takes n results, for use in pagination */
+  take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ApiKeySortParameter = {
+  createdAt?: InputMaybe<SortOrder>;
+  id?: InputMaybe<SortOrder>;
+  lastUsedAt?: InputMaybe<SortOrder>;
+  lookupId?: InputMaybe<SortOrder>;
+  name?: InputMaybe<SortOrder>;
+  updatedAt?: InputMaybe<SortOrder>;
+};
+
+export type ApiKeyTranslation = Node & {
+  __typename?: 'ApiKeyTranslation';
+  createdAt: Scalars['DateTime']['output'];
+  id: Scalars['ID']['output'];
+  languageCode: LanguageCode;
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name: Scalars['String']['output'];
+  updatedAt: Scalars['DateTime']['output'];
+};
+
 export type ApplyCouponCodeResult = CouponCodeExpiredError | CouponCodeInvalidError | CouponCodeLimitError | Order;
 
 export type Asset = Node & {
@@ -753,6 +827,35 @@ export type CreateAdministratorInput = {
   roleIds: Array<Scalars['ID']['input']>;
 };
 
+/**
+ * There is no User ID because you can only create API-Keys for yourself,
+ * which gets determined by the User who does the request.
+ */
+export type CreateApiKeyInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  /**
+   * Which roles to attach to this ApiKey.
+   * You may only grant roles which you, yourself have.
+   */
+  roleIds: Array<Scalars['ID']['input']>;
+  translations: Array<CreateApiKeyTranslationInput>;
+};
+
+export type CreateApiKeyResult = {
+  __typename?: 'CreateApiKeyResult';
+  /** The generated API-Key. API-Keys cannot be viewed again after creation! */
+  apiKey: Scalars['String']['output'];
+  /** ID of the created ApiKey-Entity */
+  entityId: Scalars['ID']['output'];
+};
+
+export type CreateApiKeyTranslationInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  languageCode: LanguageCode;
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name: Scalars['String']['input'];
+};
+
 export type CreateAssetInput = {
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   file: Scalars['Upload']['input'];
@@ -1366,6 +1469,7 @@ export type CustomFields = {
   __typename?: 'CustomFields';
   Address: Array<CustomFieldConfig>;
   Administrator: Array<CustomFieldConfig>;
+  ApiKey: Array<CustomFieldConfig>;
   Asset: Array<CustomFieldConfig>;
   Channel: Array<CustomFieldConfig>;
   Collection: Array<CustomFieldConfig>;
@@ -2806,6 +2910,12 @@ export type Mutation = {
   cancelPayment: CancelPaymentResult;
   /** Create a new Administrator */
   createAdministrator: Administrator;
+  /**
+   * Generates a new API-Key and attaches it to an Administrator.
+   * Returns the generated API-Key.
+   * API-Keys cannot be viewed again after creation.
+   */
+  createApiKey: CreateApiKeyResult;
   /** Create a new Asset */
   createAssets: Array<CreateAssetResult>;
   /** Create a new Channel */
@@ -2860,6 +2970,8 @@ export type Mutation = {
   deleteAdministrator: DeletionResponse;
   /** Delete multiple Administrators */
   deleteAdministrators: Array<DeletionResponse>;
+  /** Deletes API-Keys */
+  deleteApiKeys: Array<DeletionResponse>;
   /** Delete an Asset */
   deleteAsset: DeletionResponse;
   /** Delete multiple Assets */
@@ -3001,6 +3113,12 @@ export type Mutation = {
   removeStockLocationsFromChannel: Array<StockLocation>;
   requestCompleted: Scalars['Int']['output'];
   requestStarted: Scalars['Int']['output'];
+  /**
+   * Replaces the old with a new API-Key.
+   * This is a convenience method to invalidate an API-Key without
+   * deleting the underlying roles and permissions.
+   */
+  rotateApiKey: RotateApiKeyResult;
   runPendingSearchIndexUpdates: Success;
   runScheduledTask: Success;
   setActiveChannel: UserStatus;
@@ -3041,6 +3159,8 @@ export type Mutation = {
   updateActiveAdministrator: Administrator;
   /** Update an existing Administrator */
   updateAdministrator: Administrator;
+  /** Updates an API-Key */
+  updateApiKey: ApiKey;
   /** Update an existing Asset */
   updateAsset: Asset;
   /** Update an existing Channel */
@@ -3234,6 +3354,11 @@ export type MutationCreateAdministratorArgs = {
 };
 
 
+export type MutationCreateApiKeyArgs = {
+  input: CreateApiKeyInput;
+};
+
+
 export type MutationCreateAssetsArgs = {
   input: Array<CreateAssetInput>;
 };
@@ -3371,6 +3496,11 @@ export type MutationDeleteAdministratorsArgs = {
 };
 
 
+export type MutationDeleteApiKeysArgs = {
+  ids: Array<Scalars['ID']['input']>;
+};
+
+
 export type MutationDeleteAssetArgs = {
   input: DeleteAssetInput;
 };
@@ -3710,6 +3840,11 @@ export type MutationRemoveStockLocationsFromChannelArgs = {
 };
 
 
+export type MutationRotateApiKeyArgs = {
+  id: Scalars['ID']['input'];
+};
+
+
 export type MutationRunScheduledTaskArgs = {
   id: Scalars['String']['input'];
 };
@@ -3854,6 +3989,11 @@ export type MutationUpdateAdministratorArgs = {
 };
 
 
+export type MutationUpdateApiKeyArgs = {
+  input: UpdateApiKeyInput;
+};
+
+
 export type MutationUpdateAssetArgs = {
   input: UpdateAssetInput;
 };
@@ -4563,6 +4703,8 @@ export enum Permission {
   Authenticated = 'Authenticated',
   /** Grants permission to create Administrator */
   CreateAdministrator = 'CreateAdministrator',
+  /** Grants permission to create ApiKey */
+  CreateApiKey = 'CreateApiKey',
   /** Grants permission to create Asset */
   CreateAsset = 'CreateAsset',
   /** Grants permission to create Products, Facets, Assets, Collections */
@@ -4607,6 +4749,8 @@ export enum Permission {
   CreateZone = 'CreateZone',
   /** Grants permission to delete Administrator */
   DeleteAdministrator = 'DeleteAdministrator',
+  /** Grants permission to delete ApiKey */
+  DeleteApiKey = 'DeleteApiKey',
   /** Grants permission to delete Asset */
   DeleteAsset = 'DeleteAsset',
   /** Grants permission to delete Products, Facets, Assets, Collections */
@@ -4656,6 +4800,8 @@ export enum Permission {
   Public = 'Public',
   /** Grants permission to read Administrator */
   ReadAdministrator = 'ReadAdministrator',
+  /** Grants permission to read ApiKey */
+  ReadApiKey = 'ReadApiKey',
   /** Grants permission to read Asset */
   ReadAsset = 'ReadAsset',
   /** Grants permission to read Products, Facets, Assets, Collections */
@@ -4702,6 +4848,8 @@ export enum Permission {
   SuperAdmin = 'SuperAdmin',
   /** Grants permission to update Administrator */
   UpdateAdministrator = 'UpdateAdministrator',
+  /** Grants permission to update ApiKey */
+  UpdateApiKey = 'UpdateApiKey',
   /** Grants permission to update Asset */
   UpdateAsset = 'UpdateAsset',
   /** Grants permission to update Products, Facets, Assets, Collections */
@@ -5252,6 +5400,8 @@ export type Query = {
   activeChannel: Channel;
   administrator?: Maybe<Administrator>;
   administrators: AdministratorList;
+  apiKey?: Maybe<ApiKey>;
+  apiKeys: ApiKeyList;
   /** Get a single Asset by id */
   asset?: Maybe<Asset>;
   /** Get a list of Assets */
@@ -5357,6 +5507,16 @@ export type QueryAdministratorsArgs = {
 };
 
 
+export type QueryApiKeyArgs = {
+  id: Scalars['ID']['input'];
+};
+
+
+export type QueryApiKeysArgs = {
+  options?: InputMaybe<ApiKeyListOptions>;
+};
+
+
 export type QueryAssetArgs = {
   id: Scalars['ID']['input'];
 };
@@ -5906,6 +6066,12 @@ export type RoleSortParameter = {
   updatedAt?: InputMaybe<SortOrder>;
 };
 
+export type RotateApiKeyResult = {
+  __typename?: 'RotateApiKeyResult';
+  /** The generated API-Key. API-Keys cannot be viewed again after creation! */
+  apiKey: Scalars['String']['output'];
+};
+
 export type Sale = Node & StockMovement & {
   __typename?: 'Sale';
   createdAt: Scalars['DateTime']['output'];
@@ -6696,6 +6862,26 @@ export type UpdateAdministratorInput = {
   roleIds?: InputMaybe<Array<Scalars['ID']['input']>>;
 };
 
+export type UpdateApiKeyInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  /** ID of the ApiKey */
+  id: Scalars['ID']['input'];
+  /**
+   * Which roles to attach to this ApiKey.
+   * You may only grant roles which you, yourself have.
+   */
+  roleIds?: InputMaybe<Array<Scalars['ID']['input']>>;
+  translations?: InputMaybe<Array<UpdateApiKeyTranslationInput>>;
+};
+
+export type UpdateApiKeyTranslationInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  id?: InputMaybe<Scalars['ID']['input']>;
+  languageCode: LanguageCode;
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name?: InputMaybe<Scalars['String']['input']>;
+};
+
 export type UpdateAssetInput = {
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   focalPoint?: InputMaybe<CoordinateInput>;

+ 3 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -128,6 +128,8 @@ const result: PossibleTypesResultData = {
             'Address',
             'Administrator',
             'Allocation',
+            'ApiKey',
+            'ApiKeyTranslation',
             'Asset',
             'AuthenticationMethod',
             'Cancellation',
@@ -171,6 +173,7 @@ const result: PossibleTypesResultData = {
         ],
         PaginatedList: [
             'AdministratorList',
+            'ApiKeyList',
             'AssetList',
             'ChannelList',
             'CollectionList',

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 9 - 0
packages/asset-server-plugin/e2e/graphql/graphql-env-admin.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/asset-server-plugin/e2e/graphql/graphql-env-shop.d.ts


+ 8 - 0
packages/common/src/generated-shop-types.ts

@@ -2465,6 +2465,8 @@ export enum Permission {
     Authenticated = 'Authenticated',
     /** Grants permission to create Administrator */
     CreateAdministrator = 'CreateAdministrator',
+    /** Grants permission to create ApiKey */
+    CreateApiKey = 'CreateApiKey',
     /** Grants permission to create Asset */
     CreateAsset = 'CreateAsset',
     /** Grants permission to create Products, Facets, Assets, Collections */
@@ -2509,6 +2511,8 @@ export enum Permission {
     CreateZone = 'CreateZone',
     /** Grants permission to delete Administrator */
     DeleteAdministrator = 'DeleteAdministrator',
+    /** Grants permission to delete ApiKey */
+    DeleteApiKey = 'DeleteApiKey',
     /** Grants permission to delete Asset */
     DeleteAsset = 'DeleteAsset',
     /** Grants permission to delete Products, Facets, Assets, Collections */
@@ -2557,6 +2561,8 @@ export enum Permission {
     Public = 'Public',
     /** Grants permission to read Administrator */
     ReadAdministrator = 'ReadAdministrator',
+    /** Grants permission to read ApiKey */
+    ReadApiKey = 'ReadApiKey',
     /** Grants permission to read Asset */
     ReadAsset = 'ReadAsset',
     /** Grants permission to read Products, Facets, Assets, Collections */
@@ -2603,6 +2609,8 @@ export enum Permission {
     SuperAdmin = 'SuperAdmin',
     /** Grants permission to update Administrator */
     UpdateAdministrator = 'UpdateAdministrator',
+    /** Grants permission to update ApiKey */
+    UpdateApiKey = 'UpdateApiKey',
     /** Grants permission to update Asset */
     UpdateAsset = 'UpdateAsset',
     /** Grants permission to update Products, Facets, Assets, Collections */

+ 186 - 0
packages/common/src/generated-types.ts

@@ -171,6 +171,80 @@ export type AlreadyRefundedError = ErrorResult & {
   refundId: Scalars['ID']['output'];
 };
 
+export type ApiKey = Node & {
+  __typename?: 'ApiKey';
+  createdAt: Scalars['DateTime']['output'];
+  customFields?: Maybe<Scalars['JSON']['output']>;
+  id: Scalars['ID']['output'];
+  /** Helps you identify unused keys */
+  lastUsedAt?: Maybe<Scalars['DateTime']['output']>;
+  /**
+   * ID by which we can look up the API-Key.
+   * Also helps you identify keys without leaking the underlying secret API-Key.
+   */
+  lookupId: Scalars['String']['output'];
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name: Scalars['String']['output'];
+  /**
+   * Usually the user who created the ApiKey but could also be used as the basis for
+   * restricting resolvers to `Permission.Owner` queries for customers for example.
+   */
+  owner: User;
+  translations: Array<ApiKeyTranslation>;
+  updatedAt: Scalars['DateTime']['output'];
+  /** This is the underlying User which determines the kind of permissions for this API-Key. */
+  user: User;
+};
+
+export type ApiKeyFilterParameter = {
+  _and?: InputMaybe<Array<ApiKeyFilterParameter>>;
+  _or?: InputMaybe<Array<ApiKeyFilterParameter>>;
+  createdAt?: InputMaybe<DateOperators>;
+  id?: InputMaybe<IdOperators>;
+  lastUsedAt?: InputMaybe<DateOperators>;
+  lookupId?: InputMaybe<StringOperators>;
+  name?: InputMaybe<StringOperators>;
+  updatedAt?: InputMaybe<DateOperators>;
+};
+
+export type ApiKeyList = PaginatedList & {
+  __typename?: 'ApiKeyList';
+  items: Array<ApiKey>;
+  totalItems: Scalars['Int']['output'];
+};
+
+export type ApiKeyListOptions = {
+  /** Allows the results to be filtered */
+  filter?: InputMaybe<ApiKeyFilterParameter>;
+  /** Specifies whether multiple top-level "filter" fields should be combined with a logical AND or OR operation. Defaults to AND. */
+  filterOperator?: InputMaybe<LogicalOperator>;
+  /** Skips the first n results, for use in pagination */
+  skip?: InputMaybe<Scalars['Int']['input']>;
+  /** Specifies which properties to sort the results by */
+  sort?: InputMaybe<ApiKeySortParameter>;
+  /** Takes n results, for use in pagination */
+  take?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ApiKeySortParameter = {
+  createdAt?: InputMaybe<SortOrder>;
+  id?: InputMaybe<SortOrder>;
+  lastUsedAt?: InputMaybe<SortOrder>;
+  lookupId?: InputMaybe<SortOrder>;
+  name?: InputMaybe<SortOrder>;
+  updatedAt?: InputMaybe<SortOrder>;
+};
+
+export type ApiKeyTranslation = Node & {
+  __typename?: 'ApiKeyTranslation';
+  createdAt: Scalars['DateTime']['output'];
+  id: Scalars['ID']['output'];
+  languageCode: LanguageCode;
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name: Scalars['String']['output'];
+  updatedAt: Scalars['DateTime']['output'];
+};
+
 export type ApplyCouponCodeResult = CouponCodeExpiredError | CouponCodeInvalidError | CouponCodeLimitError | Order;
 
 export type Asset = Node & {
@@ -752,6 +826,35 @@ export type CreateAdministratorInput = {
   roleIds: Array<Scalars['ID']['input']>;
 };
 
+/**
+ * There is no User ID because you can only create API-Keys for yourself,
+ * which gets determined by the User who does the request.
+ */
+export type CreateApiKeyInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  /**
+   * Which roles to attach to this ApiKey.
+   * You may only grant roles which you, yourself have.
+   */
+  roleIds: Array<Scalars['ID']['input']>;
+  translations: Array<CreateApiKeyTranslationInput>;
+};
+
+export type CreateApiKeyResult = {
+  __typename?: 'CreateApiKeyResult';
+  /** The generated API-Key. API-Keys cannot be viewed again after creation! */
+  apiKey: Scalars['String']['output'];
+  /** ID of the created ApiKey-Entity */
+  entityId: Scalars['ID']['output'];
+};
+
+export type CreateApiKeyTranslationInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  languageCode: LanguageCode;
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name: Scalars['String']['input'];
+};
+
 export type CreateAssetInput = {
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   file: Scalars['Upload']['input'];
@@ -1358,6 +1461,7 @@ export type CustomFields = {
   __typename?: 'CustomFields';
   Address: Array<CustomFieldConfig>;
   Administrator: Array<CustomFieldConfig>;
+  ApiKey: Array<CustomFieldConfig>;
   Asset: Array<CustomFieldConfig>;
   Channel: Array<CustomFieldConfig>;
   Collection: Array<CustomFieldConfig>;
@@ -2798,6 +2902,12 @@ export type Mutation = {
   cancelPayment: CancelPaymentResult;
   /** Create a new Administrator */
   createAdministrator: Administrator;
+  /**
+   * Generates a new API-Key and attaches it to an Administrator.
+   * Returns the generated API-Key.
+   * API-Keys cannot be viewed again after creation.
+   */
+  createApiKey: CreateApiKeyResult;
   /** Create a new Asset */
   createAssets: Array<CreateAssetResult>;
   /** Create a new Channel */
@@ -2852,6 +2962,8 @@ export type Mutation = {
   deleteAdministrator: DeletionResponse;
   /** Delete multiple Administrators */
   deleteAdministrators: Array<DeletionResponse>;
+  /** Deletes API-Keys */
+  deleteApiKeys: Array<DeletionResponse>;
   /** Delete an Asset */
   deleteAsset: DeletionResponse;
   /** Delete multiple Assets */
@@ -2991,6 +3103,12 @@ export type Mutation = {
   removeShippingMethodsFromChannel: Array<ShippingMethod>;
   /** Removes StockLocations from the specified Channel */
   removeStockLocationsFromChannel: Array<StockLocation>;
+  /**
+   * Replaces the old with a new API-Key.
+   * This is a convenience method to invalidate an API-Key without
+   * deleting the underlying roles and permissions.
+   */
+  rotateApiKey: RotateApiKeyResult;
   runPendingSearchIndexUpdates: Success;
   runScheduledTask: Success;
   setCustomerForDraftOrder: SetCustomerForDraftOrderResult;
@@ -3022,6 +3140,8 @@ export type Mutation = {
   updateActiveAdministrator: Administrator;
   /** Update an existing Administrator */
   updateAdministrator: Administrator;
+  /** Updates an API-Key */
+  updateApiKey: ApiKey;
   /** Update an existing Asset */
   updateAsset: Asset;
   /** Update an existing Channel */
@@ -3214,6 +3334,11 @@ export type MutationCreateAdministratorArgs = {
 };
 
 
+export type MutationCreateApiKeyArgs = {
+  input: CreateApiKeyInput;
+};
+
+
 export type MutationCreateAssetsArgs = {
   input: Array<CreateAssetInput>;
 };
@@ -3351,6 +3476,11 @@ export type MutationDeleteAdministratorsArgs = {
 };
 
 
+export type MutationDeleteApiKeysArgs = {
+  ids: Array<Scalars['ID']['input']>;
+};
+
+
 export type MutationDeleteAssetArgs = {
   input: DeleteAssetInput;
 };
@@ -3690,6 +3820,11 @@ export type MutationRemoveStockLocationsFromChannelArgs = {
 };
 
 
+export type MutationRotateApiKeyArgs = {
+  id: Scalars['ID']['input'];
+};
+
+
 export type MutationRunScheduledTaskArgs = {
   id: Scalars['String']['input'];
 };
@@ -3794,6 +3929,11 @@ export type MutationUpdateAdministratorArgs = {
 };
 
 
+export type MutationUpdateApiKeyArgs = {
+  input: UpdateApiKeyInput;
+};
+
+
 export type MutationUpdateAssetArgs = {
   input: UpdateAssetInput;
 };
@@ -4493,6 +4633,8 @@ export enum Permission {
   Authenticated = 'Authenticated',
   /** Grants permission to create Administrator */
   CreateAdministrator = 'CreateAdministrator',
+  /** Grants permission to create ApiKey */
+  CreateApiKey = 'CreateApiKey',
   /** Grants permission to create Asset */
   CreateAsset = 'CreateAsset',
   /** Grants permission to create Products, Facets, Assets, Collections */
@@ -4537,6 +4679,8 @@ export enum Permission {
   CreateZone = 'CreateZone',
   /** Grants permission to delete Administrator */
   DeleteAdministrator = 'DeleteAdministrator',
+  /** Grants permission to delete ApiKey */
+  DeleteApiKey = 'DeleteApiKey',
   /** Grants permission to delete Asset */
   DeleteAsset = 'DeleteAsset',
   /** Grants permission to delete Products, Facets, Assets, Collections */
@@ -4585,6 +4729,8 @@ export enum Permission {
   Public = 'Public',
   /** Grants permission to read Administrator */
   ReadAdministrator = 'ReadAdministrator',
+  /** Grants permission to read ApiKey */
+  ReadApiKey = 'ReadApiKey',
   /** Grants permission to read Asset */
   ReadAsset = 'ReadAsset',
   /** Grants permission to read Products, Facets, Assets, Collections */
@@ -4631,6 +4777,8 @@ export enum Permission {
   SuperAdmin = 'SuperAdmin',
   /** Grants permission to update Administrator */
   UpdateAdministrator = 'UpdateAdministrator',
+  /** Grants permission to update ApiKey */
+  UpdateApiKey = 'UpdateApiKey',
   /** Grants permission to update Asset */
   UpdateAsset = 'UpdateAsset',
   /** Grants permission to update Products, Facets, Assets, Collections */
@@ -5181,6 +5329,8 @@ export type Query = {
   activeChannel: Channel;
   administrator?: Maybe<Administrator>;
   administrators: AdministratorList;
+  apiKey?: Maybe<ApiKey>;
+  apiKeys: ApiKeyList;
   /** Get a single Asset by id */
   asset?: Maybe<Asset>;
   /** Get a list of Assets */
@@ -5283,6 +5433,16 @@ export type QueryAdministratorsArgs = {
 };
 
 
+export type QueryApiKeyArgs = {
+  id: Scalars['ID']['input'];
+};
+
+
+export type QueryApiKeysArgs = {
+  options?: InputMaybe<ApiKeyListOptions>;
+};
+
+
 export type QueryAssetArgs = {
   id: Scalars['ID']['input'];
 };
@@ -5832,6 +5992,12 @@ export type RoleSortParameter = {
   updatedAt?: InputMaybe<SortOrder>;
 };
 
+export type RotateApiKeyResult = {
+  __typename?: 'RotateApiKeyResult';
+  /** The generated API-Key. API-Keys cannot be viewed again after creation! */
+  apiKey: Scalars['String']['output'];
+};
+
 export type Sale = Node & StockMovement & {
   __typename?: 'Sale';
   createdAt: Scalars['DateTime']['output'];
@@ -6612,6 +6778,26 @@ export type UpdateAdministratorInput = {
   roleIds?: InputMaybe<Array<Scalars['ID']['input']>>;
 };
 
+export type UpdateApiKeyInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  /** ID of the ApiKey */
+  id: Scalars['ID']['input'];
+  /**
+   * Which roles to attach to this ApiKey.
+   * You may only grant roles which you, yourself have.
+   */
+  roleIds?: InputMaybe<Array<Scalars['ID']['input']>>;
+  translations?: InputMaybe<Array<UpdateApiKeyTranslationInput>>;
+};
+
+export type UpdateApiKeyTranslationInput = {
+  customFields?: InputMaybe<Scalars['JSON']['input']>;
+  id?: InputMaybe<Scalars['ID']['input']>;
+  languageCode: LanguageCode;
+  /** A descriptive name so you can remind yourself where the API-Key gets used */
+  name?: InputMaybe<Scalars['String']['input']>;
+};
+
 export type UpdateAssetInput = {
   customFields?: InputMaybe<Scalars['JSON']['input']>;
   focalPoint?: InputMaybe<CoordinateInput>;

+ 1 - 0
packages/common/src/shared-constants.ts

@@ -14,6 +14,7 @@ export const CUSTOMER_ROLE_CODE = '__customer_role__';
 export const CUSTOMER_ROLE_DESCRIPTION = 'Customer';
 export const ROOT_COLLECTION_NAME = '__root_collection__';
 export const DEFAULT_AUTH_TOKEN_HEADER_KEY = 'vendure-auth-token';
+export const DEFAULT_APIKEY_HEADER_KEY = 'vendure-api-key';
 export const DEFAULT_COOKIE_NAME = 'session';
 export const DEFAULT_CHANNEL_TOKEN_KEY = 'vendure-token';
 

+ 4 - 0
packages/core/e2e/__snapshots__/administrator.e2e-spec.ts.snap

@@ -31,6 +31,10 @@ exports[`Administrator resolver > createAdministrator 1`] = `
           "ReadAdministrator",
           "UpdateAdministrator",
           "DeleteAdministrator",
+          "CreateApiKey",
+          "ReadApiKey",
+          "UpdateApiKey",
+          "DeleteApiKey",
           "CreateAsset",
           "ReadAsset",
           "UpdateAsset",

+ 216 - 0
packages/core/e2e/api-key.e2e-spec.ts

@@ -0,0 +1,216 @@
+import { DeletionResult, LanguageCode } from '@vendure/common/lib/generated-types';
+import { SUPER_ADMIN_USER_IDENTIFIER } from '@vendure/common/lib/shared-constants';
+import { createTestEnvironment } from '@vendure/testing';
+import path from 'path';
+import { afterAll, beforeAll, describe, it } from 'vitest';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+
+import { graphql } from './graphql/graphql-admin';
+
+describe('ApiKey resolver', () => {
+    const config = testConfig();
+    config.authOptions.tokenMethod = ['cookie', 'bearer', 'api-key'];
+
+    const { server, adminClient } = createTestEnvironment(config);
+    const adminApiUrl = `http://localhost:${config.apiOptions.port}/${String(config.apiOptions.adminApiPath)}`;
+
+    let createdApiKeyId: string;
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 1,
+        });
+        await adminClient.asSuperAdmin();
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('createApiKey', async ({ expect }) => {
+        const result = await adminClient.query(CREATE_API_KEY, {
+            input: {
+                roleIds: ['1'],
+                translations: [{ languageCode: LanguageCode.en, name: 'Test API Key' }],
+            },
+        });
+        expect(result.createApiKey.apiKey).toBeDefined();
+        createdApiKeyId = result.createApiKey.entityId;
+    });
+
+    it('apiKey', async ({ expect }) => {
+        const result = await adminClient.query(API_KEY, { id: createdApiKeyId });
+        expect(result.apiKey).toBeDefined();
+        expect(result.apiKey?.id).toBe(createdApiKeyId);
+        expect(result.apiKey?.name).toBe('Test API Key');
+        expect(result.apiKey?.translations.find(t => t.languageCode === LanguageCode.en)).toBeDefined();
+    });
+
+    it('apiKeys', async ({ expect }) => {
+        const result = await adminClient.query(API_KEYS);
+        expect(result.apiKeys.items.length).toBeGreaterThan(0);
+        expect(result.apiKeys.items.some((k: any) => k.id === createdApiKeyId)).toBe(true);
+    });
+
+    it('updateApiKey', async ({ expect }) => {
+        const result = await adminClient.query(UPDATE_API_KEY, {
+            input: {
+                id: createdApiKeyId,
+                // TODO roles
+                translations: [
+                    { languageCode: LanguageCode.en, name: 'Updated API Key' },
+                    { languageCode: LanguageCode.de, name: 'Neuer Eintrag' },
+                ],
+            },
+        });
+        expect(result.updateApiKey.id).toBe(createdApiKeyId);
+        expect(result.updateApiKey.name).toBe('Updated API Key');
+        expect(result.updateApiKey.translations.find(t => t.languageCode === LanguageCode.de)?.name).toBe(
+            'Neuer Eintrag',
+        );
+    });
+
+    it('deleteApiKey', async ({ expect }) => {
+        const result = await adminClient.query(DELETE_API_KEY, { ids: [createdApiKeyId] });
+        expect(result.deleteApiKeys?.[0]?.result).toBe(DeletionResult.DELETED);
+        // Should not be found anymore
+        const { apiKey } = await adminClient.query(API_KEY, { id: createdApiKeyId });
+        expect(apiKey).toBeNull();
+    });
+
+    it('rotateApiKey', async ({ expect }) => {
+        const apiKey = await adminClient.query(CREATE_API_KEY, {
+            input: {
+                roleIds: ['1'],
+                translations: [{ languageCode: LanguageCode.en, name: 'Test API Key' }],
+            },
+        });
+        const result = await adminClient.query(ROTATE_API_KEY, { id: apiKey.createApiKey.entityId });
+        expect(result.rotateApiKey.apiKey).toBeDefined();
+        expect(apiKey.createApiKey.apiKey).not.toBe(result.rotateApiKey.apiKey);
+    });
+
+    it('API-Key usage life cycle: Read, Rotate, Delete', async ({ expect }) => {
+        const { apiKey, entityId } = (
+            await adminClient.query(CREATE_API_KEY, {
+                input: {
+                    roleIds: ['1'],
+                    translations: [{ languageCode: LanguageCode.en, name: 'Test API Key' }],
+                },
+            })
+        ).createApiKey;
+
+        type _fetchResponse =
+            | {
+                  data?: { administrator?: { user?: { identifier?: string } } };
+                  errors?: any[];
+              }
+            | undefined;
+
+        const fetchConfig = {
+            method: 'POST',
+            headers: {
+                'Content-Type': 'application/json',
+                [String(config.authOptions.apiKeyHeaderKey)]: apiKey,
+            },
+            body: '{ "query": "query { administrator(id: 1) { user { identifier } } }" }',
+        };
+
+        // (1/4): Successfully read
+
+        const readOk01 = (await fetch(adminApiUrl, fetchConfig).then(res => res.json())) as _fetchResponse;
+        expect(readOk01?.data?.administrator?.user?.identifier).toBe(SUPER_ADMIN_USER_IDENTIFIER);
+
+        // (2/4): Fail to read due to rotation of key
+
+        const rotate = await adminClient.query(ROTATE_API_KEY, { id: entityId });
+        const readErr01 = (await fetch(adminApiUrl, fetchConfig).then(res => res.json())) as _fetchResponse;
+        expect(readErr01?.errors).toBeDefined();
+        expect(readErr01?.data?.administrator?.user?.identifier).toBeUndefined();
+
+        // (3/4): Successfully read via rotated key
+
+        fetchConfig.headers[String(config.authOptions.apiKeyHeaderKey)] = rotate.rotateApiKey.apiKey;
+        const readOk02 = (await fetch(adminApiUrl, fetchConfig).then(res => res.json())) as _fetchResponse;
+        expect(readOk02?.data?.administrator?.user?.identifier).toBe(SUPER_ADMIN_USER_IDENTIFIER);
+
+        // (4/4): Fail to read due to deletion
+
+        const deletion = await adminClient.query(DELETE_API_KEY, { ids: [entityId] });
+        expect(deletion.deleteApiKeys?.[0]?.result).toBe(DeletionResult.DELETED);
+
+        const readErr02 = (await fetch(adminApiUrl, fetchConfig).then(res => res.json())) as _fetchResponse;
+        expect(readErr02?.errors).toBeDefined();
+        expect(readErr02?.data?.administrator?.user?.identifier).toBeUndefined();
+    });
+});
+
+export const CREATE_API_KEY = graphql(`
+    mutation CreateApiKey($input: CreateApiKeyInput!) {
+        createApiKey(input: $input) {
+            apiKey
+            entityId
+        }
+    }
+`);
+
+export const API_KEY = graphql(`
+    query ApiKey($id: ID!) {
+        apiKey(id: $id) {
+            id
+            name
+            translations {
+                id
+                languageCode
+                name
+            }
+        }
+    }
+`);
+
+export const API_KEYS = graphql(`
+    query ApiKeys($options: ApiKeyListOptions) {
+        apiKeys(options: $options) {
+            items {
+                id
+                name
+            }
+            totalItems
+        }
+    }
+`);
+
+export const UPDATE_API_KEY = graphql(`
+    mutation UpdateApiKey($input: UpdateApiKeyInput!) {
+        updateApiKey(input: $input) {
+            id
+            name
+            translations {
+                id
+                languageCode
+                name
+            }
+        }
+    }
+`);
+
+export const DELETE_API_KEY = graphql(`
+    mutation DeleteApiKey($ids: [ID!]!) {
+        deleteApiKeys(ids: $ids) {
+            result
+            message
+        }
+    }
+`);
+
+export const ROTATE_API_KEY = graphql(`
+    mutation RotateApiKey($id: ID!) {
+        rotateApiKey(id: $id) {
+            apiKey
+        }
+    }
+`);

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 9 - 0
packages/core/e2e/graphql/graphql-env-admin.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/core/e2e/graphql/graphql-env-shop.d.ts


+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -13,6 +13,7 @@ import { ConfigurableOperationCodec } from './common/configurable-operation-code
 import { CustomFieldRelationResolverService } from './common/custom-field-relation-resolver.service';
 import { IdCodecService } from './common/id-codec.service';
 import { AdministratorResolver } from './resolvers/admin/administrator.resolver';
+import { ApiKeyResolver } from './resolvers/admin/api-key.resolver';
 import { AssetResolver } from './resolvers/admin/asset.resolver';
 import { AuthResolver } from './resolvers/admin/auth.resolver';
 import { ChannelResolver } from './resolvers/admin/channel.resolver';
@@ -96,6 +97,7 @@ import { ShopShippingMethodsResolver } from './resolvers/shop/shop-shipping-meth
 
 const adminResolvers = [
     AdministratorResolver,
+    ApiKeyResolver,
     AssetResolver,
     AuthResolver,
     ChannelResolver,

+ 32 - 28
packages/core/src/api/common/extract-session-token.ts

@@ -2,42 +2,46 @@ import { Request } from 'express';
 
 import { AuthOptions } from '../../config/vendure-config';
 
+// Helper that gives us the content of the tokenmethod array so we dont duplicate options
+type ExtractArrayElement<T> = T extends ReadonlyArray<infer U> ? U : T;
+
+export type ExtractTokenResult = {
+    method: Exclude<ExtractArrayElement<AuthOptions['tokenMethod']>, undefined>;
+    token: string;
+};
+
 /**
- * Get the session token from either the cookie or the Authorization header, depending
- * on the configured tokenMethod.
+ * Depending on the configured `tokenMethod`, tries to extract a session token in the order:
+ *
+ * 1. Cookie
+ * 2. Authorization Header
+ * 3. API-Key Header
+ *
+ * @see {@link AuthOptions}
  */
 export function extractSessionToken(
     req: Request,
     tokenMethod: Exclude<AuthOptions['tokenMethod'], undefined>,
-): string | undefined {
-    const tokenFromCookie = getFromCookie(req);
-    const tokenFromHeader = getFromHeader(req);
-
-    if (tokenMethod === 'cookie') {
-        return tokenFromCookie;
-    } else if (tokenMethod === 'bearer') {
-        return tokenFromHeader;
-    }
-    if (tokenMethod.includes('cookie') && tokenFromCookie) {
-        return tokenFromCookie;
-    }
-    if (tokenMethod.includes('bearer') && tokenFromHeader) {
-        return tokenFromHeader;
+    apiKeyHeaderKey: string,
+): ExtractTokenResult | undefined {
+    if (req.session?.token && (tokenMethod === 'cookie' || tokenMethod.includes('cookie'))) {
+        return { method: 'cookie', token: req.session.token as string };
     }
-}
 
-function getFromCookie(req: Request): string | undefined {
-    if (req.session && req.session.token) {
-        return req.session.token;
+    const authHeader = req.get('Authorization')?.trim();
+    if (authHeader && (tokenMethod === 'bearer' || tokenMethod.includes('bearer'))) {
+        const matchesBearer = authHeader.match(/^bearer\s(.+)$/i);
+        if (matchesBearer) {
+            return { method: 'bearer', token: matchesBearer[1] };
+        }
     }
-}
 
-function getFromHeader(req: Request): string | undefined {
-    const authHeader = req.get('Authorization');
-    if (authHeader) {
-        const matches = authHeader.trim().match(/^bearer\s(.+)$/i);
-        if (matches) {
-            return matches[1];
-        }
+    // TODO: For some reason `apiKeyHeaderKey` is undefined on CI Server Smoke tests... (confirmed on 2025-12-07)
+    // Maybe it has something to do with the package versions that get installed by the CI, I do not know.
+    // Anyway, this simple check fixes this for now, let's try removing this again when versions change.
+    if (!apiKeyHeaderKey) return;
+    const apiKeyHeader = req.get(apiKeyHeaderKey)?.trim();
+    if (apiKeyHeader && tokenMethod.includes('api-key')) {
+        return { method: 'api-key', token: apiKeyHeader };
     }
 }

+ 91 - 6
packages/core/src/api/middleware/auth-guard.ts

@@ -2,17 +2,22 @@ import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { Permission } from '@vendure/common/lib/generated-types';
 import { Request, Response } from 'express';
+import { GraphQLResolveInfo } from 'graphql';
+import ms from 'ms';
 
 import { ForbiddenError } from '../../common/error/errors';
+import { API_KEY_AUTH_STRATEGY_NAME } from '../../config';
 import { ConfigService } from '../../config/config.service';
-import { LogLevel } from '../../config/logger/vendure-logger';
+import { Logger, LogLevel } from '../../config/logger/vendure-logger';
 import { CachedSession } from '../../config/session-cache/session-cache-strategy';
 import { Customer } from '../../entity/customer/customer.entity';
 import { RequestContextService } from '../../service/helpers/request-context/request-context.service';
+import { ApiKeyService } from '../../service/services/api-key.service';
 import { ChannelService } from '../../service/services/channel.service';
 import { CustomerService } from '../../service/services/customer.service';
 import { SessionService } from '../../service/services/session.service';
-import { extractSessionToken } from '../common/extract-session-token';
+import { extractSessionToken, ExtractTokenResult } from '../common/extract-session-token';
+import { getApiType } from '../common/get-api-type';
 import { isFieldResolver } from '../common/is-field-resolver';
 import { parseContext } from '../common/parse-context';
 import {
@@ -43,6 +48,7 @@ export class AuthGuard implements CanActivate {
         private sessionService: SessionService,
         private customerService: CustomerService,
         private channelService: ChannelService,
+        private apiKeyService: ApiKeyService,
     ) {}
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -59,7 +65,7 @@ export class AuthGuard implements CanActivate {
         if (targetIsFieldResolver) {
             requestContext = internal_getRequestContext(req);
         } else {
-            const session = await this.getSession(req, res, hasOwnerPermission);
+            const session = await this.getSession(req, res, hasOwnerPermission, info);
             requestContext = await this.requestContextService.fromRequest(req, info, permissions, session);
 
             const requestContextShouldBeReinitialized = await this.setActiveChannel(requestContext, session);
@@ -137,14 +143,21 @@ export class AuthGuard implements CanActivate {
         req: Request,
         res: Response,
         hasOwnerPermission: boolean,
+        info?: GraphQLResolveInfo,
     ): Promise<CachedSession | undefined> {
-        const sessionToken = extractSessionToken(req, this.configService.authOptions.tokenMethod);
+        const sessionToken = extractSessionToken(
+            req,
+            this.configService.authOptions.tokenMethod,
+            this.configService.authOptions.apiKeyHeaderKey,
+        );
+
         let serializedSession: CachedSession | undefined;
-        if (sessionToken) {
-            serializedSession = await this.sessionService.getSessionFromToken(sessionToken);
+        if (sessionToken?.token) {
+            serializedSession = await this.getSessionFromToken(req, sessionToken, info);
             if (serializedSession) {
                 return serializedSession;
             }
+
             // if there is a token but it cannot be validated to a Session,
             // then the token is no longer valid and should be unset.
             setSessionToken({
@@ -168,4 +181,76 @@ export class AuthGuard implements CanActivate {
         }
         return serializedSession;
     }
+
+    private async getSessionFromToken(
+        req: Request,
+        extracted: ExtractTokenResult,
+        info?: GraphQLResolveInfo,
+    ): Promise<CachedSession | undefined> {
+        if (extracted.method !== 'api-key') {
+            return this.sessionService.getSessionFromToken(extracted.token);
+        }
+
+        const strategy = this.apiKeyService.getApiKeyStrategyByApiType(getApiType(info));
+        const parseResult = strategy.parse(extracted.token);
+        if (!parseResult) {
+            return;
+        }
+
+        const ctx = await this.requestContextService.fromRequest(req, info);
+
+        const apiKey = await this.apiKeyService.findOneByLookupId(ctx, parseResult.lookupId, [
+            'user',
+            'user.roles',
+            'user.roles.channels',
+        ]);
+        if (!apiKey) {
+            return;
+        }
+
+        const isHashMatching = await strategy.hashingStrategy.check(extracted.token, apiKey.apiKeyHash);
+        if (!isHashMatching) {
+            return;
+        }
+
+        const lastUsedThreshold = new Date(
+            Date.now() -
+                (typeof strategy.lastUsedAtUpdateInterval === 'string'
+                    ? ms(strategy.lastUsedAtUpdateInterval)
+                    : strategy.lastUsedAtUpdateInterval),
+        );
+        if (!apiKey.lastUsedAt || apiKey.lastUsedAt < lastUsedThreshold) {
+            this.apiKeyService
+                .updateLastUsedAtByLookupId(apiKey.lookupId)
+                // Update the lastUsedAt timestamp in the background, we don't want to hold up the request
+                .catch(err =>
+                    Logger.error(
+                        `Failed to update lastUsedAt for ApiKey with lookupId ${parseResult.lookupId}`,
+                        undefined,
+                        err?.stack,
+                    ),
+                );
+        }
+
+        const session = await this.sessionService.getSessionFromToken(apiKey.apiKeyHash);
+        if (session) {
+            return session;
+        }
+
+        // At this point we may assert:
+        // 1. The token came from the api-key header
+        // 2. The hash matches
+        // 3. There is no session
+        // We can conclude that the API-Key is actually broken.
+        // For example someone could have deleted the session manually in the DB.
+        // We must create a new session, otherwise the API-Key is unusable.
+        await this.sessionService.createNewAuthenticatedSession(
+            ctx,
+            apiKey.user,
+            API_KEY_AUTH_STRATEGY_NAME,
+            apiKey.apiKeyHash,
+        );
+
+        return this.sessionService.getSessionFromToken(apiKey.apiKeyHash);
+    }
 }

+ 90 - 0
packages/core/src/api/resolvers/admin/api-key.resolver.ts

@@ -0,0 +1,90 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import {
+    CreateApiKeyResult,
+    DeletionResponse,
+    MutationCreateApiKeyArgs,
+    MutationDeleteApiKeysArgs,
+    MutationRotateApiKeyArgs,
+    MutationUpdateApiKeyArgs,
+    Permission,
+    QueryApiKeyArgs,
+    QueryApiKeysArgs,
+    RotateApiKeyResult,
+} from '@vendure/common/lib/generated-types';
+import { PaginatedList } from '@vendure/common/lib/shared-types';
+
+import { Ctx, RelationPaths, Relations, RequestContext } from '../..';
+import { InternalServerError, Translated } from '../../../common';
+import { ApiKey } from '../../../entity/api-key/api-key.entity';
+import { ApiKeyService } from '../../../service/services/api-key.service';
+import { Allow } from '../../decorators/allow.decorator';
+import { Transaction } from '../../decorators/transaction.decorator';
+
+@Resolver('ApiKey')
+export class ApiKeyResolver {
+    constructor(private apiKeyService: ApiKeyService) {}
+
+    @Query()
+    @Allow(Permission.ReadApiKey)
+    async apiKey(
+        @Ctx() ctx: RequestContext,
+        @Args() { id }: QueryApiKeyArgs,
+        @Relations(ApiKey) relations: RelationPaths<ApiKey>,
+    ): Promise<Translated<ApiKey> | null> {
+        return this.apiKeyService.findOne(ctx, id, relations);
+    }
+
+    @Query()
+    @Allow(Permission.ReadApiKey)
+    async apiKeys(
+        @Ctx() ctx: RequestContext,
+        @Args() { options }: QueryApiKeysArgs,
+        @Relations(ApiKey) relations: RelationPaths<ApiKey>,
+    ): Promise<PaginatedList<Translated<ApiKey>>> {
+        return this.apiKeyService.findAll(ctx, options, relations);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.CreateApiKey)
+    async createApiKey(
+        @Ctx() ctx: RequestContext,
+        @Args() { input }: MutationCreateApiKeyArgs,
+    ): Promise<CreateApiKeyResult> {
+        if (!ctx.activeUserId)
+            throw new InternalServerError('error.active-user-does-not-have-sufficient-permissions');
+
+        return this.apiKeyService.create(ctx, input, ctx.activeUserId);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateApiKey)
+    async updateApiKey(
+        @Ctx() ctx: RequestContext,
+        @Args() { input }: MutationUpdateApiKeyArgs,
+        @Relations(ApiKey) relations: RelationPaths<ApiKey>,
+    ): Promise<Translated<ApiKey>> {
+        return this.apiKeyService.update(ctx, input, relations);
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.DeleteApiKey)
+    async deleteApiKeys(
+        @Ctx() ctx: RequestContext,
+        @Args() { ids }: MutationDeleteApiKeysArgs,
+    ): Promise<DeletionResponse[]> {
+        return Promise.all(ids.map(id => this.apiKeyService.softDelete(ctx, id)));
+    }
+
+    @Transaction()
+    @Mutation()
+    @Allow(Permission.UpdateApiKey)
+    async rotateApiKey(
+        @Ctx() ctx: RequestContext,
+        @Args() { id }: MutationRotateApiKeyArgs,
+    ): Promise<RotateApiKeyResult> {
+        return this.apiKeyService.rotate(ctx, id);
+    }
+}

+ 3 - 3
packages/core/src/api/resolvers/admin/auth.resolver.ts

@@ -14,9 +14,8 @@ import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentic
 import { ConfigService } from '../../../config/config.service';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { AdministratorService } from '../../../service/services/administrator.service';
+import { ApiKeyService } from '../../../service/services/api-key.service';
 import { AuthService } from '../../../service/services/auth.service';
-import { ChannelService } from '../../../service/services/channel.service';
-import { CustomerService } from '../../../service/services/customer.service';
 import { UserService } from '../../../service/services/user.service';
 import { RequestContext } from '../../common/request-context';
 import { Allow } from '../../decorators/allow.decorator';
@@ -31,8 +30,9 @@ export class AuthResolver extends BaseAuthResolver {
         userService: UserService,
         configService: ConfigService,
         administratorService: AdministratorService,
+        apiKeyService: ApiKeyService,
     ) {
-        super(authService, userService, administratorService, configService);
+        super(authService, userService, administratorService, configService, apiKeyService);
     }
 
     @Transaction()

+ 15 - 4
packages/core/src/api/resolvers/base/base-auth.resolver.ts

@@ -1,6 +1,6 @@
 import {
-    AuthenticationResult as ShopAuthenticationResult,
     PasswordValidationError,
+    AuthenticationResult as ShopAuthenticationResult,
 } from '@vendure/common/lib/generated-shop-types';
 import {
     AuthenticationResult as AdminAuthenticationResult,
@@ -24,6 +24,7 @@ import { LogLevel } from '../../../config/logger/vendure-logger';
 import { User } from '../../../entity/user/user.entity';
 import { getUserChannelsPermissions } from '../../../service/helpers/utils/get-user-channels-permissions';
 import { AdministratorService } from '../../../service/services/administrator.service';
+import { ApiKeyService } from '../../../service/services/api-key.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { UserService } from '../../../service/services/user.service';
 import { extractSessionToken } from '../../common/extract-session-token';
@@ -37,6 +38,7 @@ export class BaseAuthResolver {
         protected userService: UserService,
         protected administratorService: AdministratorService,
         protected configService: ConfigService,
+        protected apiKeyService: ApiKeyService,
     ) {}
 
     /**
@@ -61,11 +63,19 @@ export class BaseAuthResolver {
     }
 
     async logout(ctx: RequestContext, req: Request, res: Response): Promise<Success> {
-        const token = extractSessionToken(req, this.configService.authOptions.tokenMethod);
-        if (!token) {
+        const extraction = extractSessionToken(
+            req,
+            this.configService.authOptions.tokenMethod,
+            this.configService.authOptions.apiKeyHeaderKey,
+        );
+
+        // ApiKey "Sessions" are not meant to be logged out of
+        if (!extraction?.token || extraction.method === 'api-key') {
             return { success: false };
         }
-        await this.authService.destroyAuthenticatedSession(ctx, token);
+
+        await this.authService.destroyAuthenticatedSession(ctx, extraction.token);
+
         setSessionToken({
             req,
             res,
@@ -73,6 +83,7 @@ export class BaseAuthResolver {
             rememberMe: false,
             sessionToken: '',
         });
+
         return { success: true };
     }
 

+ 3 - 1
packages/core/src/api/resolvers/shop/shop-auth.resolver.ts

@@ -36,6 +36,7 @@ import { NATIVE_AUTH_STRATEGY_NAME } from '../../../config/auth/native-authentic
 import { ConfigService } from '../../../config/config.service';
 import { Logger } from '../../../config/logger/vendure-logger';
 import { AdministratorService } from '../../../service/services/administrator.service';
+import { ApiKeyService } from '../../../service/services/api-key.service';
 import { AuthService } from '../../../service/services/auth.service';
 import { CustomerService } from '../../../service/services/customer.service';
 import { HistoryService } from '../../../service/services/history.service';
@@ -54,10 +55,11 @@ export class ShopAuthResolver extends BaseAuthResolver {
         userService: UserService,
         administratorService: AdministratorService,
         configService: ConfigService,
+        apiKeyService: ApiKeyService,
         protected customerService: CustomerService,
         protected historyService: HistoryService,
     ) {
-        super(authService, userService, administratorService, configService);
+        super(authService, userService, administratorService, configService, apiKeyService);
     }
 
     @Transaction()

+ 76 - 0
packages/core/src/api/schema/admin-api/api-key.api.graphql

@@ -0,0 +1,76 @@
+type Query {
+    apiKey(id: ID!): ApiKey
+    apiKeys(options: ApiKeyListOptions): ApiKeyList!
+}
+
+type Mutation {
+    """
+    Generates a new API-Key and attaches it to an Administrator.
+    Returns the generated API-Key.
+    API-Keys cannot be viewed again after creation.
+    """
+    createApiKey(input: CreateApiKeyInput!): CreateApiKeyResult!
+    "Updates an API-Key"
+    updateApiKey(input: UpdateApiKeyInput!): ApiKey!
+    "Deletes API-Keys"
+    deleteApiKeys(ids: [ID!]!): [DeletionResponse!]!
+    """
+    Replaces the old with a new API-Key.
+    This is a convenience method to invalidate an API-Key without
+    deleting the underlying roles and permissions.
+    """
+    rotateApiKey(id: ID!): RotateApiKeyResult!
+}
+
+input CreateApiKeyTranslationInput {
+    languageCode: LanguageCode!
+
+    "A descriptive name so you can remind yourself where the API-Key gets used"
+    name: String!
+}
+
+input UpdateApiKeyTranslationInput {
+    id: ID
+    languageCode: LanguageCode!
+
+    "A descriptive name so you can remind yourself where the API-Key gets used"
+    name: String
+}
+
+"""
+There is no User ID because you can only create API-Keys for yourself,
+which gets determined by the User who does the request.
+"""
+input CreateApiKeyInput {
+    """
+    Which roles to attach to this ApiKey.
+    You may only grant roles which you, yourself have.
+    """
+    roleIds: [ID!]!
+
+    translations: [CreateApiKeyTranslationInput!]!
+}
+
+type CreateApiKeyResult {
+    "The generated API-Key. API-Keys cannot be viewed again after creation!"
+    apiKey: String!
+    "ID of the created ApiKey-Entity"
+    entityId: ID!
+}
+
+input UpdateApiKeyInput {
+    "ID of the ApiKey"
+    id: ID!
+    """
+    Which roles to attach to this ApiKey.
+    You may only grant roles which you, yourself have.
+    """
+    roleIds: [ID!]
+
+    translations: [UpdateApiKeyTranslationInput!]
+}
+
+type RotateApiKeyResult {
+    "The generated API-Key. API-Keys cannot be viewed again after creation!"
+    apiKey: String!
+}

+ 44 - 0
packages/core/src/api/schema/admin-api/api-key.type.graphql

@@ -0,0 +1,44 @@
+type ApiKey implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+
+    """
+    ID by which we can look up the API-Key.
+    Also helps you identify keys without leaking the underlying secret API-Key.
+    """
+    lookupId: String!
+    "Helps you identify unused keys"
+    lastUsedAt: DateTime
+
+    """
+    Usually the user who created the ApiKey but could also be used as the basis for
+    restricting resolvers to `Permission.Owner` queries for customers for example.
+    """
+    owner: User!
+    "This is the underlying User which determines the kind of permissions for this API-Key."
+    user: User!
+
+    "A descriptive name so you can remind yourself where the API-Key gets used"
+    name: String!
+
+    translations: [ApiKeyTranslation!]!
+}
+
+type ApiKeyTranslation implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    languageCode: LanguageCode!
+
+    "A descriptive name so you can remind yourself where the API-Key gets used"
+    name: String!
+}
+
+# generated
+input ApiKeyListOptions
+
+type ApiKeyList implements PaginatedList {
+    items: [ApiKey!]!
+    totalItems: Int!
+}

+ 1 - 0
packages/core/src/common/constants.ts

@@ -51,6 +51,7 @@ export const DEFAULT_PERMISSIONS: PermissionDefinition[] = [
             `Grants permission to ${operation} PaymentMethods, ShippingMethods, TaxCategories, TaxRates, Zones, Countries, System & GlobalSettings`,
     ),
     new CrudPermissionDefinition('Administrator'),
+    new CrudPermissionDefinition('ApiKey'),
     new CrudPermissionDefinition('Asset'),
     new CrudPermissionDefinition('Channel'),
     new CrudPermissionDefinition('Collection'),

+ 192 - 0
packages/core/src/config/api-key-strategy/api-key-strategy.ts

@@ -0,0 +1,192 @@
+import ms from 'ms';
+
+import { RequestContext } from '../../api/common/request-context';
+import { InjectableStrategy } from '../../common/types/injectable-strategy';
+import { SessionService } from '../../service';
+import { PasswordHashingStrategy } from '../auth/password-hashing-strategy';
+
+/**
+ * Needed to identify types of sessions when creating and deleting.
+ *
+ * @see {@link SessionService}
+ */
+export const API_KEY_AUTH_STRATEGY_NAME = 'apikey';
+
+/**
+ * Since API-Keys build upon Vendures Session mechanism, we need to set a very long
+ * default duration to mimic the "forever" nature of API-Keys.
+ *
+ * @unit Milliseconds
+ * @see {@link SessionService}
+ */
+export const API_KEY_AUTH_STRATEGY_DEFAULT_DURATION_MS = ms('100y');
+
+/**
+ * @see {@link ApiKeyStrategy}
+ */
+export type ApiKeyStrategyParseResult = null | {
+    lookupId: string;
+    apiKey: string;
+};
+
+/**
+ * @description
+ * Defines a custom strategy for how API-Keys get handled.
+ *
+ * Defining different strategies between production-/ and development-environments and or
+ * differing strategies for Admin-/ and Customer-Users can be worthwhile to guarantee that API-Keys will never overlap.
+ *
+ * :::info
+ *
+ * This is configured in either:
+ *
+ * - `authOptions.adminApiKeyStrategy`
+ * - `authOptions.shopApiKeyStrategy`
+ *
+ * of your VendureConfig.
+ * :::
+ *
+ * @docsCategory auth
+ * @since 3.6.0
+ */
+export interface ApiKeyStrategy extends InjectableStrategy {
+    /**
+     * @description
+     * Generates the API-Key secret which ultimately gets hashed and determines the session id.
+     *
+     * @since 3.6.0
+     */
+    generateSecret(ctx: RequestContext): Promise<string>;
+
+    /**
+     * @description
+     * Generates an API-Key lookup ID.
+     *
+     * A separate lookup ID enables us to use stronger salted hashing methods for the actual API-Key.
+     * This is because when we extract the incoming API-Key from the request, hashing methods that
+     * automatically handle salting (e.g. bcrypt) will produce different hashes for the same input,
+     * making it impossible to compare the incoming API-Key with the stored hash.
+     *
+     * By using a separate lookup ID, we can identify the correct stored hash to compare against.
+     *
+     * @since 3.6.0
+     */
+    generateLookupId(ctx: RequestContext): Promise<string>;
+
+    /**
+     * @description
+     * Defines a custom strategy for how API-Keys get hashed and checked.
+     *
+     * :::important Performance Consideration
+     *
+     * Vendure does not store API-Keys in plain text, but rather a hashed version of the key,
+     * similar to how passwords are handled. This means that when a request comes in with an API-Key,
+     * Vendure needs to hash the provided key and compare it with the stored hash. This means your hashing
+     * strategy must preferably be as fast as possible to avoid performance issues since hashing happens
+     * on every request that uses API-Key authorization.
+     *
+     * :::
+     *
+     * @since 3.6.0
+     */
+    hashingStrategy: PasswordHashingStrategy;
+
+    /**
+     * @description
+     * Used when constructing and parsing API-Keys. You might need to override this
+     * delimiter if you customized the secret-/ and or lookup-generation.
+     * See {@link constructApiKey} or {@link parse} for more detailed information.
+     *
+     * @default ":"
+     * @see {@link BaseApiKeyStrategy} for a default implementation
+     */
+    delimiter: string;
+
+    /**
+     * @description
+     * Constructs an API-Key which the {@link User}s supply to the API.
+     *
+     * Each strategy determines how it combines the lookup ID with the secret itself,
+     * because lookup-/ and secret-generation are customizable leading to Vendure not
+     * enforcing a fixed delimiter.
+     *
+     * The output of this function must be parsable via this strategys {@link parse} function.
+     *
+     * @see {@link BaseApiKeyStrategy} for a default implementation
+     * @since 3.6.0
+     */
+    constructApiKey(lookupId: string, secret: string): string;
+
+    /**
+     * @description
+     * In order to allow users to send only one header with their API-Key, while still supporting
+     * a separate lookup ID, strategies must be able to parse provided tokens into both parts, more
+     * specifically this function must be able to parse the output of {@link constructApiKey}.
+     *
+     * We do not force an arbitrary delimiter, for example a colon `':'`, because the secret itself
+     * and the lookup ID are both customizable and may conflict with it, hence the need for custom parsing.
+     *
+     * Custom parsing also allows strategies to use special characters in their API-Keys, by requiring
+     * the token to be base64 encoded for example or include prefixes such as:
+     *
+     * - `'test_'`, `'prod_'`
+     * - `'admin_'`, `'customer_'`
+     *
+     * to visually distinguish between keys more easily.
+     *
+     * @see {@link BaseApiKeyStrategy} for a default implementation
+     * @since 3.6.0
+     * @returns Either both parts for further processing or `null` if parsing failed.
+     */
+    parse(token: string): ApiKeyStrategyParseResult;
+
+    /**
+     * @description
+     * The main use of the `lastUsedAt`-field is enabling the detection and invalidation of unused API-Keys.
+     * By default, every request which gets authorized by an API-Key persists the usage date. This might not
+     * be desirable for larger instances, due to the cost of frequent database writes.
+     *
+     * Defining a longer duration, for example 15 minutes, means that the `lastUsedAt` field will only be
+     * written to in 15 minute intervals, resulting in considerably fewer database writes. This technically
+     * reduces the accuracy of the `lastUsedAt`-field but keep in mind that when looking for unused keys,
+     * what often matters is that a key has been used at all in the last days or weeks and not the specific second.
+     *
+     * If passed as a number should represent milliseconds and if passed as a string describes a time span per
+     * [zeit/ms](https://github.com/zeit/ms.js).  Eg: `5m`, `'1 hour'`, `'10h'`
+     *
+     * @see {@link BaseApiKeyStrategy} for a default implementation
+     * @since 3.6.0
+     * @default 0
+     */
+    lastUsedAtUpdateInterval: number | string;
+}
+
+/**
+ * Intended to be extended by consumers of the {@link ApiKeyStrategy} if they do not
+ * require their own construction/parsing logic.
+ */
+export abstract class BaseApiKeyStrategy implements ApiKeyStrategy {
+    abstract hashingStrategy: PasswordHashingStrategy;
+    abstract generateSecret(ctx: RequestContext): Promise<string>;
+    abstract generateLookupId(ctx: RequestContext): Promise<string>;
+    delimiter = ':';
+    lastUsedAtUpdateInterval: ApiKeyStrategy['lastUsedAtUpdateInterval'] = 0;
+
+    constructApiKey(lookupId: string, secret: string): string {
+        return `${lookupId}${this.delimiter}${secret}`;
+    }
+
+    parse(token: string): ApiKeyStrategyParseResult {
+        if (!token) {
+            return null;
+        }
+
+        const [lookupId, apiKey] = token.split(this.delimiter, 2);
+
+        if (!lookupId || !apiKey) {
+            return null;
+        }
+
+        return { lookupId, apiKey };
+    }
+}

+ 112 - 0
packages/core/src/config/api-key-strategy/random-bytes-api-key-strategy.ts

@@ -0,0 +1,112 @@
+import { randomBytes } from 'node:crypto';
+
+import { RequestContext } from '../../api';
+import { BcryptPasswordHashingStrategy } from '../auth/bcrypt-password-hashing-strategy';
+import { PasswordHashingStrategy } from '../auth/password-hashing-strategy';
+
+import { ApiKeyStrategy, BaseApiKeyStrategy } from './api-key-strategy';
+
+export const DEFAULT_SIZE_RANDOM_BYTES_SECRET = 32;
+export const DEFAULT_SIZE_RANDOM_BYTES_LOOKUP = 12;
+export const DEFAULT_LAST_USED_AT_UPDATE_INTERVAL = 0;
+
+/**
+ * @see {@link RandomBytesApiKeyStrategy}
+ * @since 3.6.0
+ */
+export interface RandomBytesApiKeyStrategyOptions {
+    /**
+     * @description
+     * The number of bytes to generate. The size must not be larger than 2**31 - 1.
+     * This is the input size for `crypto.randomBytes`
+     *
+     * @default 32
+     * @see {@link DEFAULT_SIZE_RANDOM_BYTES_SECRET}
+     */
+    secretSize?: number;
+    /**
+     * @description
+     * The number of bytes to generate. The size must not be larger than 2**31 - 1.
+     * This is the input size for `crypto.randomBytes`
+     *
+     * @default 12
+     * @see {@link DEFAULT_SIZE_RANDOM_BYTES_LOOKUP}
+     */
+    lookupSize?: number;
+    /**
+     * @description
+     * Defines a custom strategy for how API-Keys get hashed and checked.
+     * Performance considerations should be kept in mind, for more info see {@link ApiKeyStrategy}.
+     *
+     * @default {BcryptPasswordHashingStrategy}
+     */
+    hashingStrategy?: PasswordHashingStrategy;
+    /**
+     * @description
+     * The main use of the `lastUsedAt`-field is enabling the detection and invalidation of unused API-Keys.
+     * By default, every request which gets authorized by an API-Key persists the usage date. This might not
+     * be desirable for larger instances, due to the cost of frequent database writes.
+     *
+     * Defining a longer duration, for example 15 minutes, means that the `lastUsedAt` field will only be
+     * written to in 15 minute intervals, resulting in considerably fewer database writes. This technically
+     * reduces the accuracy of the `lastUsedAt`-field but keep in mind that when looking for unused keys,
+     * what often matters is that a key has been used at all in the last days or weeks and not the specific second.
+     *
+     * If passed as a number should represent milliseconds and if passed as a string describes a time span per
+     * [zeit/ms](https://github.com/zeit/ms.js).  Eg: `5m`, `'1 hour'`, `'10h'`
+     *
+     * @default 0
+     * @see {@link DEFAULT_LAST_USED_AT_UPDATE_INTERVAL}
+     */
+    lastUsedAtUpdateInterval?: string | number;
+}
+
+/**
+ * @description
+ * A generation strategy that uses `node:crypto` to generate random hex strings for API-Keys via `randomBytes`.
+ *
+ * This strategy defines API-Keys where both parts are the aforementioned random bytes like so:
+ *
+ * ```text
+ * <lookupId>:<apiKey>
+ * ```
+ *
+ * Note the colon `':'` delimiter between the lookup ID and the api key.
+ *
+ * @docsCategory auth
+ * @since 3.6.0
+ */
+export class RandomBytesApiKeyStrategy extends BaseApiKeyStrategy {
+    readonly secretSize: number;
+    readonly lookupSize: number;
+    readonly hashingStrategy: PasswordHashingStrategy;
+
+    constructor(input?: RandomBytesApiKeyStrategyOptions) {
+        super();
+        this.secretSize = input?.secretSize ?? DEFAULT_SIZE_RANDOM_BYTES_SECRET;
+        this.lookupSize = input?.lookupSize ?? DEFAULT_SIZE_RANDOM_BYTES_LOOKUP;
+        this.lastUsedAtUpdateInterval =
+            input?.lastUsedAtUpdateInterval ?? DEFAULT_LAST_USED_AT_UPDATE_INTERVAL;
+        this.hashingStrategy = input?.hashingStrategy ?? new BcryptPasswordHashingStrategy();
+    }
+
+    generateSecret(ctx: RequestContext): Promise<string> {
+        return this.promisifyRandomBytes(this.secretSize);
+    }
+
+    generateLookupId(ctx: RequestContext): Promise<string> {
+        return this.promisifyRandomBytes(this.lookupSize);
+    }
+
+    private promisifyRandomBytes(size: number): Promise<string> {
+        return new Promise((resolve, reject) => {
+            randomBytes(size, (err, buf) => {
+                if (err) {
+                    reject(err);
+                } else {
+                    resolve(buf.toString('hex'));
+                }
+            });
+        });
+    }
+}

+ 8 - 3
packages/core/src/config/auth/password-hashing-strategy.ts

@@ -2,13 +2,18 @@ import { InjectableStrategy } from '../../common/types/injectable-strategy';
 
 /**
  * @description
- * Defines how user passwords get hashed when using the {@link NativeAuthenticationStrategy}.
+ * Defines how sensitive user credentials like passwords and API-Keys get hashed.
  *
- * :::info
+ * :::info Config
  *
- * This is configured via the `authOptions.passwordHashingStrategy` property of
+ * Hashing for passwords when using the {@link NativeAuthenticationStrategy} can be
+ * configured via the `authOptions.passwordHashingStrategy` property of
  * your VendureConfig.
  *
+ * Hashing for API-Keys can be configured via the {@link ApiKeyStrategy},
+ * more specifically `authOptions.adminApiKeyStrategy.hashingStrategy` and
+ * `authOptions.shopApiKeyStrategy.hashingStrategy`.
+ *
  * :::
  *
  * @docsCategory auth

+ 4 - 0
packages/core/src/config/config.module.ts

@@ -83,6 +83,8 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             passwordHashingStrategy,
             passwordValidationStrategy,
             verificationTokenStrategy,
+            adminApiKeyStrategy,
+            shopApiKeyStrategy,
         } = this.configService.authOptions;
         const { taxZoneStrategy, taxLineCalculationStrategy } = this.configService.taxOptions;
         const { jobQueueStrategy, jobBufferStorageStrategy } = this.configService.jobQueueOptions;
@@ -158,6 +160,8 @@ export class ConfigModule implements OnApplicationBootstrap, OnApplicationShutdo
             ...(instrumentationStrategy ? [instrumentationStrategy] : []),
             ...orderInterceptors,
             schedulerStrategy,
+            adminApiKeyStrategy,
+            shopApiKeyStrategy,
         ];
     }
 

+ 1 - 2
packages/core/src/config/config.service.ts

@@ -10,7 +10,6 @@ import { SettingsStoreFields } from './settings-store/settings-store-types';
 import {
     ApiOptions,
     AssetOptions,
-    AuthOptions,
     CatalogOptions,
     EntityOptions,
     ImportExportOptions,
@@ -43,7 +42,7 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.apiOptions;
     }
 
-    get authOptions(): Required<AuthOptions> {
+    get authOptions() {
         return this.activeConfig.authOptions;
     }
 

+ 5 - 4
packages/core/src/config/custom-field/custom-field-types.ts

@@ -36,10 +36,10 @@ import { VendureEntity } from '../../entity/base/base.entity';
 // prettier-ignore
 export type DefaultValueType<T extends CustomFieldType | StructFieldType> =
     T extends 'string' | 'localeString' | 'text' | 'localeText' ? string :
-        T extends 'int' | 'float' ? number :
-            T extends 'boolean' ? boolean :
-                T extends 'datetime' ? Date :
-                    T extends 'relation' ? any : never;
+    T extends 'int' | 'float' ? number :
+    T extends 'boolean' ? boolean :
+    T extends 'datetime' ? Date :
+    T extends 'relation' ? any : never;
 
 export type BaseTypedCustomFieldConfig<T extends CustomFieldType, C extends CustomField> = Omit<
     C,
@@ -273,6 +273,7 @@ export type CustomFieldConfig =
 export type CustomFields = {
     Address?: CustomFieldConfig[];
     Administrator?: CustomFieldConfig[];
+    ApiKey?: CustomFieldConfig[];
     Asset?: CustomFieldConfig[];
     Channel?: CustomFieldConfig[];
     Collection?: CustomFieldConfig[];

+ 6 - 0
packages/core/src/config/default-config.ts

@@ -1,5 +1,6 @@
 import { LanguageCode } from '@vendure/common/lib/generated-types';
 import {
+    DEFAULT_APIKEY_HEADER_KEY,
     DEFAULT_AUTH_TOKEN_HEADER_KEY,
     DEFAULT_CHANNEL_TOKEN_KEY,
     SUPER_ADMIN_USER_IDENTIFIER,
@@ -13,6 +14,7 @@ import { InMemoryJobBufferStorageStrategy } from '../job-queue/job-buffer/in-mem
 import { NoopSchedulerStrategy } from '../scheduler/noop-scheduler-strategy';
 import { cleanSessionsTask } from '../scheduler/tasks/clean-sessions-task';
 
+import { RandomBytesApiKeyStrategy } from './api-key-strategy/random-bytes-api-key-strategy';
 import { DefaultAssetImportStrategy } from './asset-import-strategy/default-asset-import-strategy';
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
@@ -103,6 +105,7 @@ export const defaultConfig: RuntimeVendureConfig = {
             sameSite: 'lax',
         },
         authTokenHeaderKey: DEFAULT_AUTH_TOKEN_HEADER_KEY,
+        apiKeyHeaderKey: DEFAULT_APIKEY_HEADER_KEY,
         sessionDuration: '1y',
         sessionCacheStrategy: new DefaultSessionCacheStrategy(),
         sessionCacheTTL: 300,
@@ -114,6 +117,8 @@ export const defaultConfig: RuntimeVendureConfig = {
         },
         shopAuthenticationStrategy: [new NativeAuthenticationStrategy()],
         adminAuthenticationStrategy: [new NativeAuthenticationStrategy()],
+        adminApiKeyStrategy: new RandomBytesApiKeyStrategy(),
+        shopApiKeyStrategy: new RandomBytesApiKeyStrategy(),
         customPermissions: [],
         passwordHashingStrategy: new BcryptPasswordHashingStrategy(),
         passwordValidationStrategy: new DefaultPasswordValidationStrategy({ minLength: 4, maxLength: 72 }),
@@ -208,6 +213,7 @@ export const defaultConfig: RuntimeVendureConfig = {
     customFields: {
         Address: [],
         Administrator: [],
+        ApiKey: [],
         Asset: [],
         Channel: [],
         Collection: [],

+ 2 - 0
packages/core/src/config/index.ts

@@ -1,3 +1,5 @@
+export * from './api-key-strategy/api-key-strategy';
+export * from './api-key-strategy/random-bytes-api-key-strategy';
 export * from './asset-import-strategy/asset-import-strategy';
 export * from './asset-naming-strategy/asset-naming-strategy';
 export * from './asset-naming-strategy/default-asset-naming-strategy';

+ 25 - 1
packages/core/src/config/vendure-config.ts

@@ -12,6 +12,7 @@ import { JobBufferStorageStrategy } from '../job-queue/job-buffer/job-buffer-sto
 import { ScheduledTask } from '../scheduler/scheduled-task';
 import { SchedulerStrategy } from '../scheduler/scheduler-strategy';
 
+import { ApiKeyStrategy } from './api-key-strategy/api-key-strategy';
 import { AssetImportStrategy } from './asset-import-strategy/asset-import-strategy';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
@@ -376,6 +377,9 @@ export interface AuthOptions {
      *   should automatically send the session cookie with each request.
      * * 'bearer': Upon login, the token is returned in the response and should be then stored by the
      *   client app. Each request should include the header `Authorization: Bearer <token>`.
+     * * 'api-key': The mutation `createApiKey` will return a generated API-Key once, which should then be
+     *   stored by the User. Each request should include the API-Key inside the header defined by `apiKeyHeaderKey`
+     * ('vendure-api-key' by default).
      *
      * Note that if the bearer method is used, Vendure will automatically expose the configured
      * `authTokenHeaderKey` in the server's CORS configuration (adding `Access-Control-Expose-Headers: vendure-auth-token`
@@ -383,9 +387,12 @@ export interface AuthOptions {
      *
      * From v1.2.0 it is possible to specify both methods as a tuple: `['cookie', 'bearer']`.
      *
+     * From v3.6.0 it is possible to include 'api-key' as additional method in the method-tuple to allow for long-lived
+     * API-Key based authorization.
+     *
      * @default 'cookie'
      */
-    tokenMethod?: 'cookie' | 'bearer' | ReadonlyArray<'cookie' | 'bearer'>;
+    tokenMethod?: 'cookie' | 'bearer' | ReadonlyArray<'cookie' | 'bearer' | 'api-key'>;
     /**
      * @description
      * Options related to the handling of cookies when using the 'cookie' tokenMethod.
@@ -398,6 +405,13 @@ export interface AuthOptions {
      * @default 'vendure-auth-token'
      */
     authTokenHeaderKey?: string;
+    /**
+     * @description
+     * Defines which header will be used to read the API-Key when using the 'api-key' token method.
+     *
+     * @default 'vendure-api-key'
+     */
+    apiKeyHeaderKey?: string;
     /**
      * @description
      * Session duration, i.e. the time which must elapse from the last authenticated request
@@ -483,6 +497,16 @@ export interface AuthOptions {
      * @since 1.3.0
      */
     passwordHashingStrategy?: PasswordHashingStrategy;
+    /**
+     * Defines how authorization via API-Keys is managed for the Admin API.
+     * @since 3.6.0
+     */
+    adminApiKeyStrategy?: ApiKeyStrategy;
+    /**
+     * Defines how authorization via API-Keys is managed for the Shop API.
+     * @since 3.6.0
+     */
+    shopApiKeyStrategy?: ApiKeyStrategy;
     /**
      * @description
      * Allows you to set a custom policy for passwords when using the {@link NativeAuthenticationStrategy}.

+ 29 - 0
packages/core/src/entity/api-key/api-key-translation.entity.ts

@@ -0,0 +1,29 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { Column, Entity, Index, ManyToOne } from 'typeorm';
+
+import { Translation } from '../../common/types/locale-types';
+import { HasCustomFields } from '../../config/custom-field/custom-field-types';
+import { VendureEntity } from '../base/base.entity';
+import { CustomApiKeyFieldsTranslation } from '../custom-entity-fields';
+
+import { ApiKey } from './api-key.entity';
+
+@Entity()
+export class ApiKeyTranslation extends VendureEntity implements Translation<ApiKey>, HasCustomFields {
+    constructor(input?: DeepPartial<Translation<ApiKey>>) {
+        super(input);
+    }
+
+    @Column('varchar') languageCode: LanguageCode;
+
+    @Index()
+    @ManyToOne(type => ApiKey, base => base.translations, { onDelete: 'CASCADE' })
+    base: ApiKey;
+
+    @Column(type => CustomApiKeyFieldsTranslation)
+    customFields: CustomApiKeyFieldsTranslation;
+
+    @Column()
+    name: string;
+}

+ 83 - 0
packages/core/src/entity/api-key/api-key.entity.ts

@@ -0,0 +1,83 @@
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
+
+import { Channel } from '..';
+import { ChannelAware, LocaleString, SoftDeletable, Translatable, Translation } from '../../common';
+import { HasCustomFields } from '../../config/custom-field/custom-field-types';
+import { VendureEntity } from '../base/base.entity';
+import { CustomApiKeyFields } from '../custom-entity-fields';
+import { EntityId } from '../entity-id.decorator';
+import { User } from '../user/user.entity';
+
+import { ApiKeyTranslation } from './api-key-translation.entity';
+
+/**
+ * @description
+ * An ApiKey is mostly used for authenticating non-interactive clients such as scripts
+ * or other types of services. An ApiKey is associated with a {@link User} whose
+ * permissions will apply when the ApiKey is used for authorization.
+ *
+ * Similar to how passwords are handled, only a hash of the API key is stored in the database
+ * meaning, generated API-Keys are not viewable after creation, Users are responsible for storing them.
+ *
+ * Hence, if a User forgets their ApiKey, the old one must be deleted and a new one created.
+ * This is called "rotating" an ApiKey.
+ *
+ * @docsCategory entities
+ */
+@Entity()
+export class ApiKey
+    extends VendureEntity
+    implements HasCustomFields, ChannelAware, Translatable, SoftDeletable
+{
+    constructor(input?: DeepPartial<ApiKey>) {
+        super(input);
+    }
+
+    /**
+     * ID by which we can look up the API-Key.
+     * Also helps you identify keys without leaking the underlying secret API-Key.
+     */
+    @Column({ unique: true })
+    lookupId: string;
+
+    @Column({ unique: true })
+    apiKeyHash: string;
+
+    @Column({ type: Date, nullable: true })
+    lastUsedAt: Date | null;
+
+    @Column({ type: Date, nullable: true })
+    deletedAt: Date | null;
+
+    /**
+     * Usually the user who created the ApiKey but could also be used as the basis for
+     * restricting resolvers to `Permission.Owner` queries for customers for example.
+     */
+    @ManyToOne(type => User)
+    owner: User;
+
+    @EntityId()
+    ownerId: ID;
+
+    /**
+     * This is the underlying User which determines the kind of permissions for this API-Key.
+     */
+    @ManyToOne(type => User)
+    user: User;
+
+    @EntityId()
+    userId: ID;
+
+    @ManyToMany(() => Channel)
+    @JoinTable()
+    channels: Channel[];
+
+    @OneToMany(() => ApiKeyTranslation, t => t.base, { eager: true })
+    translations: Array<Translation<ApiKey>>;
+
+    @Column(type => CustomApiKeyFields)
+    customFields: CustomApiKeyFields;
+
+    name: LocaleString;
+}

+ 2 - 2
packages/core/src/entity/authentication-method/authentication-method.entity.ts

@@ -5,8 +5,8 @@ import { User } from '../user/user.entity';
 
 /**
  * @description
- * An AuthenticationMethod represents the means by which a {@link User} is authenticated. There are two kinds:
- * {@link NativeAuthenticationMethod} and {@link ExternalAuthenticationMethod}.
+ * An AuthenticationMethod represents the means by which a {@link User} is authenticated. There are three kinds:
+ * {@link NativeAuthenticationMethod}, {@link ExternalAuthenticationMethod} and ${@link ApiKeyAuthenticationMethod}.
  *
  * @docsCategory entities
  * @docsPage AuthenticationMethod

+ 6 - 2
packages/core/src/entity/channel/channel.entity.ts

@@ -3,14 +3,15 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, Index, ManyToMany, ManyToOne } from 'typeorm';
 
 import { Customer, PaymentMethod, Promotion, Role, ShippingMethod, StockLocation } from '..';
+import { ApiKey } from '../api-key/api-key.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Collection } from '../collection/collection.entity';
 import { CustomChannelFields } from '../custom-entity-fields';
 import { EntityId } from '../entity-id.decorator';
-import { Facet } from '../facet/facet.entity';
 import { FacetValue } from '../facet-value/facet-value.entity';
-import { Product } from '../product/product.entity';
+import { Facet } from '../facet/facet.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
+import { Product } from '../product/product.entity';
 import { Seller } from '../seller/seller.entity';
 import { Zone } from '../zone/zone.entity';
 
@@ -142,6 +143,9 @@ export class Channel extends VendureEntity {
     @ManyToMany(type => StockLocation, stockLocation => stockLocation.channels, { onDelete: 'CASCADE' })
     stockLocations: StockLocation[];
 
+    @ManyToMany(type => ApiKey, apiKey => apiKey.channels, { onDelete: 'CASCADE' })
+    apiKeys: ApiKey[];
+
     private generateToken(): string {
         const randomString = () => Math.random().toString(36).substr(3, 10);
         return `${randomString()}${randomString()}`;

+ 2 - 0
packages/core/src/entity/custom-entity-fields.ts

@@ -1,5 +1,7 @@
 export class CustomAddressFields {}
 export class CustomAdministratorFields {}
+export class CustomApiKeyFields {}
+export class CustomApiKeyFieldsTranslation {}
 export class CustomAssetFields {}
 export class CustomChannelFields {}
 export class CustomCollectionFields {}

+ 4 - 0
packages/core/src/entity/entities.ts

@@ -1,5 +1,7 @@
 import { Address } from './address/address.entity';
 import { Administrator } from './administrator/administrator.entity';
+import { ApiKeyTranslation } from './api-key/api-key-translation.entity';
+import { ApiKey } from './api-key/api-key.entity';
 import { Asset } from './asset/asset.entity';
 import { AuthenticationMethod } from './authentication-method/authentication-method.entity';
 import { ExternalAuthenticationMethod } from './authentication-method/external-authentication-method.entity';
@@ -79,6 +81,8 @@ export const coreEntitiesMap = {
     Administrator,
     Allocation,
     AnonymousSession,
+    ApiKey,
+    ApiKeyTranslation,
     Asset,
     AuthenticatedSession,
     AuthenticationMethod,

+ 27 - 0
packages/core/src/event-bus/events/api-key-event.ts

@@ -0,0 +1,27 @@
+import { CreateApiKeyInput, UpdateApiKeyInput } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+
+import { RequestContext } from '../../api';
+import { ApiKey } from '../../entity/api-key/api-key.entity';
+import { VendureEntityEvent } from '../vendure-entity-event';
+
+type ApiKeyInputTypes = CreateApiKeyInput | UpdateApiKeyInput | ID;
+
+/**
+ * @description
+ * This event is fired whenever a {@link ApiKey} is added, updated or deleted.
+ *
+ * @docsCategory events
+ * @docsPage Event Types
+ * @since 3.6.0
+ */
+export class ApiKeyEvent extends VendureEntityEvent<ApiKey, ApiKeyInputTypes> {
+    constructor(
+        ctx: RequestContext,
+        entity: ApiKey,
+        type: 'created' | 'updated' | 'deleted',
+        input?: ApiKeyInputTypes,
+    ) {
+        super(entity, type, ctx, input);
+    }
+}

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -37,6 +37,7 @@ import { TranslatorService } from './helpers/translator/translator.service';
 import { VerificationTokenGenerator } from './helpers/verification-token-generator/verification-token-generator';
 import { InitializerService } from './initializer.service';
 import { AdministratorService } from './services/administrator.service';
+import { ApiKeyService } from './services/api-key.service';
 import { AssetService } from './services/asset.service';
 import { AuthService } from './services/auth.service';
 import { ChannelService } from './services/channel.service';
@@ -74,6 +75,7 @@ import { ZoneService } from './services/zone.service';
 
 const services = [
     AdministratorService,
+    ApiKeyService,
     AssetService,
     AuthService,
     ChannelService,

+ 376 - 0
packages/core/src/service/services/api-key.service.ts

@@ -0,0 +1,376 @@
+import { Injectable } from '@nestjs/common';
+import {
+    CreateApiKeyInput,
+    CreateApiKeyResult,
+    DeletionResponse,
+    DeletionResult,
+    RotateApiKeyResult,
+    UpdateApiKeyInput,
+} from '@vendure/common/lib/generated-types';
+import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
+import { In, IsNull, UpdateResult } from 'typeorm';
+
+import { ApiType, RelationPaths, RequestContext } from '../../api';
+import {
+    assertFound,
+    EntityNotFoundError,
+    Instrument,
+    ListQueryOptions,
+    Translated,
+    UserInputError,
+} from '../../common';
+import { API_KEY_AUTH_STRATEGY_NAME, ConfigService, Logger } from '../../config';
+import { ApiKeyStrategy } from '../../config/api-key-strategy/api-key-strategy';
+import { TransactionalConnection } from '../../connection';
+import { AuthenticationMethod, Role, User } from '../../entity';
+import { ApiKeyTranslation } from '../../entity/api-key/api-key-translation.entity';
+import { ApiKey } from '../../entity/api-key/api-key.entity';
+import { EventBus } from '../../event-bus';
+import { ApiKeyEvent } from '../../event-bus/events/api-key-event';
+import { CustomFieldRelationService } from '../helpers/custom-field-relation/custom-field-relation.service';
+import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
+import { TranslatableSaver } from '../helpers/translatable-saver/translatable-saver';
+import { TranslatorService } from '../helpers/translator/translator.service';
+import { getChannelPermissions } from '../helpers/utils/get-user-channels-permissions';
+
+import { ChannelService } from './channel.service';
+import { RoleService } from './role.service';
+import { SessionService } from './session.service';
+import { UserService } from './user.service';
+
+@Injectable()
+@Instrument()
+export class ApiKeyService {
+    constructor(
+        private channelService: ChannelService,
+        private configService: ConfigService,
+        private connection: TransactionalConnection,
+        private customFieldRelationService: CustomFieldRelationService,
+        private eventBus: EventBus,
+        private listQueryBuilder: ListQueryBuilder,
+        private roleService: RoleService,
+        private sessionService: SessionService,
+        private translatableSaver: TranslatableSaver,
+        private translator: TranslatorService,
+        private userService: UserService,
+    ) {}
+
+    /**
+     * @description
+     * Returns the appropriate {@link ApiKeyStrategy} based on the {@link ApiType}.
+     * This is needed because the admin and shop ApiKeyStrategy may differ.
+     */
+    getApiKeyStrategyByApiType(apiType: ApiType): ApiKeyStrategy {
+        return apiType === 'admin'
+            ? this.configService.authOptions.adminApiKeyStrategy
+            : this.configService.authOptions.shopApiKeyStrategy;
+    }
+
+    /**
+     * @description
+     * Checks that the active user is allowed to grant the specified Roles for an API-Key
+     *
+     * // TODO this is taken & slightly modified from adminservice, could merge to not repeat logic
+     *
+     * @throws {UserInputError} If the active User has insufficient permissions
+     * @returns Role-Entities with relations to Channels
+     */
+    private async assertActiveUserCanGrantRoles(ctx: RequestContext, roleIds: ID[]): Promise<Role[]> {
+        if (roleIds.length === 0) return [];
+
+        const roles = await this.connection.getRepository(ctx, Role).find({
+            where: { id: In(roleIds) },
+            relations: { channels: true },
+        });
+        const permissionsRequired = getChannelPermissions(roles);
+        for (const channelPermissions of permissionsRequired) {
+            const isAllowed = await this.roleService.userHasAllPermissionsOnChannel(
+                ctx,
+                channelPermissions.id,
+                channelPermissions.permissions,
+            );
+
+            if (!isAllowed)
+                throw new UserInputError('error.active-user-does-not-have-sufficient-permissions');
+        }
+
+        return roles;
+    }
+
+    /**
+     * @description
+     * Simple user identifier generation function because it is a non-nullable field on User.
+     *
+     * Because this simply appends the lookupId, some databases like MySQL/Maria may run into
+     * length issues if the lookupId has too many characters. Practically speaking this
+     * should not happen but worth to keep in mind.
+     *
+     * @internal
+     */
+    private generateApiKeyUserIdentifier(lookupId: string): string {
+        return `apikey-user-${lookupId}`;
+    }
+
+    /**
+     * @description
+     * Creates a new API-Key for the given User
+     *
+     * **Important**: The caller is responsible for avoiding privilege escalations by
+     * verifying `userIdOwner` and `userIdApiKeyUser`; **Use this with great care!**
+     *
+     * If you allow users to specify these IDs, they may leak existing User IDs via thrown errors.
+     *
+     * @throws {EntityNotFoundError} When either Owner or ApiKeyUser cannot be found
+     * @throws {UserInputError} When the User tries to grant a role which they themselves dont have
+     */
+    async create(
+        ctx: RequestContext,
+        input: CreateApiKeyInput,
+        userIdOwner: ID,
+        /**
+         * Optionally allow overriding the creation of a separate User.
+         * This is an advanced use case for plugin-authors to allow impersonation.
+         * You are responsible for avoiding privilege escalation by verifying this ID.
+         */
+        userIdApiKeyUser?: ID,
+    ): Promise<CreateApiKeyResult> {
+        const roles = await this.assertActiveUserCanGrantRoles(ctx, input.roleIds);
+
+        const ownerUser = await this.connection.getEntityOrThrow(ctx, User, userIdOwner);
+        const strategy = this.getApiKeyStrategyByApiType(ctx.apiType);
+        const lookupId = await strategy.generateLookupId(ctx);
+        const apiKeyUser = userIdApiKeyUser
+            ? await this.connection.getEntityOrThrow(ctx, User, userIdApiKeyUser, {
+                  // ApiKeyUsers generally require roles and their channels, its important for sessions!
+                  relations: { roles: { channels: true } },
+              })
+            : await this.userService.createApiKeyUser(
+                  ctx,
+                  roles,
+                  this.generateApiKeyUserIdentifier(lookupId),
+              );
+
+        const secret = await strategy.generateSecret(ctx);
+        const apiKey = strategy.constructApiKey(lookupId, secret);
+        const hash = await strategy.hashingStrategy.hash(apiKey);
+
+        const newEntity = await this.translatableSaver.create({
+            ctx,
+            input,
+            entityType: ApiKey,
+            translationType: ApiKeyTranslation,
+            beforeSave: async e => {
+                e.ownerId = ownerUser.id;
+                e.userId = apiKeyUser.id;
+                e.apiKeyHash = hash;
+                e.lookupId = lookupId;
+                await this.channelService.assignToCurrentChannel(e, ctx);
+            },
+        });
+
+        await this.customFieldRelationService.updateRelations(ctx, ApiKey, input, newEntity);
+
+        // Important: The hash becomes the session token, this is what allows us to authorize on a per-request basis
+        // Important: The User of the session may be new User to allow configuring separate permissions
+        await this.sessionService.createNewAuthenticatedSession(
+            ctx,
+            apiKeyUser,
+            API_KEY_AUTH_STRATEGY_NAME,
+            hash,
+        );
+
+        Logger.verbose(
+            `Created ApiKey (${newEntity.id}) for User (${userIdOwner}) with ApiKeyUser (${apiKeyUser.id}, ${apiKeyUser.identifier})`,
+        );
+        await this.eventBus.publish(new ApiKeyEvent(ctx, newEntity, 'created', input));
+
+        return { apiKey, entityId: newEntity.id };
+    }
+
+    /**
+     * @description
+     * Updates an API-Key. Is Channel-Aware.
+     *
+     * @throws {EntityNotFoundError} If API-Key cannot be found
+     */
+    async update(
+        ctx: RequestContext,
+        input: UpdateApiKeyInput,
+        relations?: RelationPaths<ApiKey>,
+    ): Promise<Translated<ApiKey>> {
+        const entity = await this.connection.getEntityOrThrow(ctx, ApiKey, input.id, {
+            channelId: ctx.channelId,
+            relations: ['user'],
+        });
+
+        if (input.roleIds) {
+            entity.user.roles = await this.assertActiveUserCanGrantRoles(ctx, input.roleIds);
+        }
+
+        const apiKey = await this.translatableSaver.update({
+            ctx,
+            input,
+            entityType: ApiKey,
+            translationType: ApiKeyTranslation,
+            beforeSave: async () => {
+                // Keep in mind that if the user of the ApiKey is being impersonated,
+                // this would change the roles of the impersonated user!
+                if (input.roleIds) {
+                    await this.connection.getRepository(ctx, User).save(entity.user, { reload: false });
+                }
+            },
+        });
+        await this.customFieldRelationService.updateRelations(ctx, ApiKey, input, apiKey);
+
+        Logger.verbose(`Updated ApiKey (${apiKey.id}) by User (${String(ctx.activeUserId)})`);
+        await this.eventBus.publish(new ApiKeyEvent(ctx, apiKey, 'updated', input));
+
+        return assertFound(this.findOne(ctx, input.id, relations));
+    }
+
+    /**
+     * @description
+     * Soft-Deletes an API-Key and removes its session. Is Channel-Aware.
+     *
+     * @throws {EntityNotFoundError} If API-Key cannot be found
+     */
+    async softDelete(ctx: RequestContext, id: ID): Promise<DeletionResponse> {
+        const apiKey = await this.connection.getEntityOrThrow(ctx, ApiKey, id, {
+            channelId: ctx.channelId,
+        });
+
+        const hasAuthMethod = await this.connection.getRepository(ctx, AuthenticationMethod).existsBy({
+            user: { id: apiKey.userId },
+        });
+
+        // If this is an impersonated user who can login, we dont want to delete them
+        if (hasAuthMethod) {
+            await this.sessionService.deleteApiKeySession(ctx, apiKey);
+        }
+        // If this is an underlying user solely for holding permission, delete them
+        else {
+            // SoftDelete should also delete the related sessions & cache
+            await this.userService.softDelete(ctx, apiKey.userId);
+        }
+
+        apiKey.deletedAt = new Date();
+        await this.connection
+            .getRepository(ctx, ApiKey)
+            .update({ id: apiKey.id }, { deletedAt: apiKey.deletedAt });
+
+        Logger.verbose(`Deleted ApiKey (${id}) by User (${String(ctx.activeUserId)})`);
+        await this.eventBus.publish(new ApiKeyEvent(ctx, apiKey, 'deleted', id));
+
+        return { result: DeletionResult.DELETED };
+    }
+
+    /**
+     * @description
+     * Replaces the old with a new API-Key.
+     *
+     * This is a convenience method to invalidate an API-Key without
+     * deleting the underlying roles and permissions.
+     *
+     * @throws {EntityNotFoundError} If API-Key cannot be found
+     */
+    async rotate(ctx: RequestContext, id: ID): Promise<RotateApiKeyResult> {
+        const entity = await this.connection.getEntityOrThrow(ctx, ApiKey, id, {
+            channelId: ctx.channelId,
+            includeSoftDeleted: false,
+            // Need roles and channels for session
+            relations: { user: { roles: { channels: true } } },
+        });
+
+        const strategy = this.getApiKeyStrategyByApiType(ctx.apiType);
+        const secret = await strategy.generateSecret(ctx);
+        const apiKey = strategy.constructApiKey(entity.lookupId, secret);
+        const hash = await strategy.hashingStrategy.hash(apiKey);
+
+        await this.sessionService.deleteApiKeySession(ctx, entity);
+        await this.sessionService.createNewAuthenticatedSession(
+            ctx,
+            entity.user,
+            API_KEY_AUTH_STRATEGY_NAME,
+            hash,
+        );
+
+        entity.apiKeyHash = hash;
+        await this.connection.getRepository(ctx, ApiKey).save(entity, { reload: false });
+
+        Logger.verbose(`Rotated ApiKey (${entity.id}) by User (${String(ctx.activeUserId)})`);
+        await this.eventBus.publish(new ApiKeyEvent(ctx, entity, 'updated', id));
+
+        return { apiKey };
+    }
+
+    /**
+     * @description
+     * Is channel-/ and soft-delete aware, translates the entity as well.
+     */
+    async findOne(
+        ctx: RequestContext,
+        id: ID,
+        relations?: RelationPaths<ApiKey>,
+    ): Promise<Translated<ApiKey> | null> {
+        const entity = await this.connection.findOneInChannel(ctx, ApiKey, id, ctx.channelId, {
+            relations,
+            where: { deletedAt: IsNull() },
+        });
+        if (!entity) return null;
+        return this.translator.translate(entity, ctx);
+    }
+
+    /**
+     * @description
+     * Is channel-/ and soft-delete aware, translates the entity as well.
+     */
+    async findAll(
+        ctx: RequestContext,
+        options?: ListQueryOptions<ApiKey>,
+        relations?: RelationPaths<ApiKey>,
+    ): Promise<PaginatedList<Translated<ApiKey>>> {
+        return this.listQueryBuilder
+            .build(ApiKey, options, {
+                ctx,
+                relations,
+                channelId: ctx.channelId,
+                where: { deletedAt: IsNull() },
+            })
+            .getManyAndCount()
+            .then(([notifications, totalItems]) => {
+                const items = notifications.map(n => this.translator.translate(n, ctx));
+                return { items, totalItems };
+            });
+    }
+
+    /**
+     * @description
+     * Is channel-/ and soft-delete aware, translates the entity as well.
+     */
+    async findOneByLookupId(
+        ctx: RequestContext,
+        lookupId: ApiKey['lookupId'],
+        relations?: RelationPaths<ApiKey>,
+    ): Promise<ApiKey | null> {
+        const entity = await this.connection.getRepository(ctx, ApiKey).findOne({
+            relations: [...(relations ?? []), 'channels'],
+            where: {
+                lookupId,
+                deletedAt: IsNull(),
+                channels: { id: ctx.channelId },
+            },
+        });
+        if (!entity) return null;
+        return this.translator.translate(entity, ctx);
+    }
+
+    /**
+     * @description
+     * Helper, intended for the AuthGuard to quickly update the lastUsedAt timestamp
+     */
+    async updateLastUsedAtByLookupId(lookupId: ApiKey['lookupId']): Promise<UpdateResult> {
+        return this.connection.rawConnection
+            .getRepository(ApiKey)
+            .update({ lookupId }, { lastUsedAt: new Date() });
+    }
+}

+ 44 - 7
packages/core/src/service/services/session.service.ts

@@ -6,10 +6,11 @@ import { Brackets, EntitySubscriberInterface, InsertEvent, RemoveEvent, UpdateEv
 
 import { RequestContext } from '../../api/common/request-context';
 import { Instrument } from '../../common/instrument-decorator';
-import { Logger } from '../../config';
+import { API_KEY_AUTH_STRATEGY_DEFAULT_DURATION_MS, API_KEY_AUTH_STRATEGY_NAME, Logger } from '../../config';
 import { ConfigService } from '../../config/config.service';
 import { CachedSession, SessionCacheStrategy } from '../../config/session-cache/session-cache-strategy';
 import { TransactionalConnection } from '../../connection/transactional-connection';
+import { ApiKey } from '../../entity/api-key/api-key.entity';
 import { Channel } from '../../entity/channel/channel.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Role } from '../../entity/role/role.entity';
@@ -108,21 +109,29 @@ export class SessionService implements EntitySubscriberInterface, OnApplicationB
         ctx: RequestContext,
         user: User,
         authenticationStrategyName: string,
+        sessionToken?: string,
     ): Promise<AuthenticatedSession> {
-        const token = await this.generateSessionToken();
+        const token = sessionToken ?? (await this.generateSessionToken());
         const guestOrder =
             ctx.session && ctx.session.activeOrderId
                 ? await this.orderService.findOne(ctx, ctx.session.activeOrderId)
                 : undefined;
         const existingOrder = await this.orderService.getActiveOrderForUser(ctx, user.id);
         const activeOrder = await this.orderService.mergeOrders(ctx, user, guestOrder, existingOrder);
+
+        const expires = this.getExpiryDate(
+            authenticationStrategyName === API_KEY_AUTH_STRATEGY_NAME
+                ? API_KEY_AUTH_STRATEGY_DEFAULT_DURATION_MS
+                : this.sessionDurationInMs,
+        );
+
         const authenticatedSession = await this.connection.getRepository(ctx, AuthenticatedSession).save(
             new AuthenticatedSession({
                 token,
                 user,
                 activeOrder,
                 authenticationStrategy: authenticationStrategyName,
-                expires: this.getExpiryDate(this.sessionDurationInMs),
+                expires,
                 invalidated: false,
             }),
         );
@@ -311,12 +320,36 @@ export class SessionService implements EntitySubscriberInterface, OnApplicationB
         }
     }
 
+    /**
+     * @description
+     * Deletes session related to API-Key
+     */
+    async deleteApiKeySession(ctx: RequestContext, apiKey: ApiKey): Promise<void> {
+        await this.connection.getRepository(ctx, AuthenticatedSession).delete({
+            token: apiKey.apiKeyHash,
+            user: { id: apiKey.userId },
+            authenticationStrategy: API_KEY_AUTH_STRATEGY_NAME,
+        });
+        await this.withTimeout(this.sessionCacheStrategy.delete(apiKey.apiKeyHash));
+    }
+
     /**
      * @description
      * Deletes all existing sessions with the given activeOrder.
      */
     async deleteSessionsByActiveOrderId(ctx: RequestContext, activeOrderId: ID): Promise<void> {
-        const sessions = await this.connection.getRepository(ctx, Session).find({ where: { activeOrderId } });
+        const sessions = await this.connection
+            .getRepository(ctx, Session)
+            // Using a query builder here because sessions utilize @TableInheritance,
+            // `authenticationStrategy` only exists on `AuthenticatedSession`
+            .createQueryBuilder('s')
+            .where('s.activeOrderId = :activeOrderId', { activeOrderId })
+            // Specifically do not delete api key based sessions,
+            // else the api key becomes unusable!
+            .andWhere('(s.authenticationStrategy != :name OR s.authenticationStrategy IS NULL)', {
+                name: API_KEY_AUTH_STRATEGY_NAME,
+            })
+            .getMany();
         await this.connection.getRepository(ctx, Session).remove(sessions);
         for (const session of sessions) {
             await this.withTimeout(this.sessionCacheStrategy.delete(session.token));
@@ -366,9 +399,13 @@ export class SessionService implements EntitySubscriberInterface, OnApplicationB
      * needing to run an update query on *every* request.
      */
     private async updateSessionExpiry(session: Session) {
-        const now = new Date().getTime();
-        if (session.expires.getTime() - now < this.sessionDurationInMs / 2) {
-            const newExpiryDate = this.getExpiryDate(this.sessionDurationInMs);
+        const isApiKeySession =
+            this.isAuthenticatedSession(session) &&
+            session.authenticationStrategy === API_KEY_AUTH_STRATEGY_NAME;
+        const ttlMs = isApiKeySession ? API_KEY_AUTH_STRATEGY_DEFAULT_DURATION_MS : this.sessionDurationInMs;
+
+        if (session.expires.getTime() - Date.now() < ttlMs / 2) {
+            const newExpiryDate = this.getExpiryDate(ttlMs);
             session.expires = newExpiryDate;
             await this.connection.rawConnection
                 .getRepository(Session)

+ 22 - 1
packages/core/src/service/services/user.service.ts

@@ -19,9 +19,10 @@ import {
     VerificationTokenInvalidError,
 } from '../../common/error/generated-graphql-shop-errors';
 import { Instrument } from '../../common/instrument-decorator';
-import { isEmailAddressLike, normalizeEmailAddress } from '../../common/utils';
+import { assertFound, isEmailAddressLike, normalizeEmailAddress } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { TransactionalConnection } from '../../connection/transactional-connection';
+import { Role } from '../../entity';
 import { NativeAuthenticationMethod } from '../../entity/authentication-method/native-authentication-method.entity';
 import { User } from '../../entity/user/user.entity';
 import { PasswordCipher } from '../helpers/password-cipher/password-cipher';
@@ -179,6 +180,26 @@ export class UserService {
         return this.connection.getRepository(ctx, User).save(user);
     }
 
+    /**
+     * @description
+     * Creates a new User which will be responsible for the permissions of an API-Key.
+     *
+     * IMPORTANT: The caller is responsible for avoiding privilege escalations!
+     */
+    async createApiKeyUser(ctx: RequestContext, roles: Role[], identifier: string): Promise<User> {
+        const newUser = await this.connection.getRepository(ctx, User).save(new User({ identifier, roles }));
+
+        const userWithRelations = await assertFound(
+            this.connection.getRepository(ctx, User).findOne({
+                where: { id: newUser.id },
+                // ApiKeyUsers generally require roles and their channels, its important for sessions!
+                relations: { roles: { channels: true } },
+            }),
+        );
+
+        return userWithRelations;
+    }
+
     async softDelete(ctx: RequestContext, userId: ID) {
         // Dynamic import to avoid the circular dependency of SessionService
         await this.moduleRef

+ 6 - 6
packages/dev-server/dev-config.ts

@@ -10,7 +10,7 @@ import {
     dummyPaymentHandler,
     LogLevel,
     SettingsStoreScopes,
-    VendureConfig,
+    VendureConfig
 } from '@vendure/core';
 import { DashboardPlugin } from '@vendure/dashboard/plugin';
 import { defaultEmailHandlers, EmailPlugin, FileBasedTemplateLoader } from '@vendure/email-plugin';
@@ -47,7 +47,7 @@ export const devConfig: VendureConfig = {
     },
     authOptions: {
         disableAuth: false,
-        tokenMethod: ['bearer', 'cookie'] as const,
+        tokenMethod: ['bearer', 'cookie', 'api-key'] as const,
         requireVerification: true,
         customPermissions: [],
         cookieOptions: {
@@ -116,10 +116,10 @@ export const devConfig: VendureConfig = {
         ...(IS_INSTRUMENTED ? [TelemetryPlugin.init({})] : []),
         ...(process.env.ENABLE_SENTRY === 'true' && process.env.SENTRY_DSN
             ? [
-                  SentryPlugin.init({
-                      includeErrorTestMutation: true,
-                  }),
-              ]
+                SentryPlugin.init({
+                    includeErrorTestMutation: true,
+                }),
+            ]
             : []),
         // AdminUiPlugin.init({
         //     route: 'admin',

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 9 - 0
packages/dev-server/graphql/graphql-env.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 9 - 0
packages/elasticsearch-plugin/e2e/graphql/graphql-env-admin.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/elasticsearch-plugin/e2e/graphql/graphql-env-shop.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 9 - 0
packages/payments-plugin/e2e/graphql/graphql-env-admin.d.ts


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/payments-plugin/e2e/graphql/graphql-env-shop.d.ts


+ 8 - 0
packages/payments-plugin/src/mollie/graphql/generated-shop-types.ts

@@ -2547,6 +2547,8 @@ export enum Permission {
     Authenticated = 'Authenticated',
     /** Grants permission to create Administrator */
     CreateAdministrator = 'CreateAdministrator',
+    /** Grants permission to create ApiKey */
+    CreateApiKey = 'CreateApiKey',
     /** Grants permission to create Asset */
     CreateAsset = 'CreateAsset',
     /** Grants permission to create Products, Facets, Assets, Collections */
@@ -2591,6 +2593,8 @@ export enum Permission {
     CreateZone = 'CreateZone',
     /** Grants permission to delete Administrator */
     DeleteAdministrator = 'DeleteAdministrator',
+    /** Grants permission to delete ApiKey */
+    DeleteApiKey = 'DeleteApiKey',
     /** Grants permission to delete Asset */
     DeleteAsset = 'DeleteAsset',
     /** Grants permission to delete Products, Facets, Assets, Collections */
@@ -2639,6 +2643,8 @@ export enum Permission {
     Public = 'Public',
     /** Grants permission to read Administrator */
     ReadAdministrator = 'ReadAdministrator',
+    /** Grants permission to read ApiKey */
+    ReadApiKey = 'ReadApiKey',
     /** Grants permission to read Asset */
     ReadAsset = 'ReadAsset',
     /** Grants permission to read Products, Facets, Assets, Collections */
@@ -2685,6 +2691,8 @@ export enum Permission {
     SuperAdmin = 'SuperAdmin',
     /** Grants permission to update Administrator */
     UpdateAdministrator = 'UpdateAdministrator',
+    /** Grants permission to update ApiKey */
+    UpdateApiKey = 'UpdateApiKey',
     /** Grants permission to update Asset */
     UpdateAsset = 'UpdateAsset',
     /** Grants permission to update Products, Facets, Assets, Collections */

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
schema-admin.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
schema-shop.json


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio