@dereekb/firebase-server 13.28.0 → 13.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/oidc/index.esm.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createParamDecorator, Injectable, Inject, Optional, Logger, UnauthorizedException, Get, Param, Req, Res, Post, HttpCode, HttpStatus, Body, Controller, HttpException, All } from '@nestjs/common';
2
2
  import { errors } from 'oidc-provider';
3
- import { SECONDS_IN_MINUTE, SECONDS_IN_DAY, cachedGetter, unixDateTimeSecondsNumberToDate, unixDateTimeSecondsNumberForNow, selectiveFieldEncryptor, filterUndefinedValues, websiteUrlFromPaths, firstValue, hasHttpPrefix } from '@dereekb/util';
3
+ import { SECONDS_IN_MINUTE, SECONDS_IN_DAY, cachedGetter, unixDateTimeSecondsNumberToDate, unixDateTimeSecondsNumberForNow, selectiveFieldEncryptor, filterUndefinedValues, websiteUrlFromPaths, firstValue, hasHttpPrefix, filterMaybeArrayValues } from '@dereekb/util';
4
4
  import { generateKeyPairSync, randomBytes } from 'node:crypto';
5
5
  import { resolveEncryptionKey, encryptValue, decryptValue, createAesStringEncryptionProvider, isValidAES256GCMEncryptionSecret } from '@dereekb/nestjs';
6
- import { where, iterateFirestoreDocumentSnapshotPairs, firestoreModelIdentity, snapshotConverterFunctions, optionalFirestoreDate, firestoreDate, firestoreEnum, firestorePassThroughField, AbstractFirestoreDocument, createOidcClientParamsType, deleteOidcClientParamsType, deleteOidcTokenParamsType, rotateOidcClientSecretParamsType, updateOidcClientParamsType, firestoreModelKey, oidcEntryIdentity, PUBLIC_PKCE_TOKEN_ENDPOINT_AUTH_METHOD, OIDC_ENTRY_CLIENT_TYPE, oidcEntriesByUserCodeQuery, oidcEntriesByUidQuery, oidcEntriesByGrantIdQuery, oidcEntryFirestoreCollection, callModelOidcScopeForCallType, CALL_MODEL_MISSING_OIDC_SCOPE_ERROR_CODE } from '@dereekb/firebase';
6
+ import { where, iterateFirestoreDocumentSnapshotPairs, firestoreModelIdentity, snapshotConverterFunctions, optionalFirestoreDate, firestoreDate, firestoreEnum, firestorePassThroughField, AbstractFirestoreDocument, createOidcClientParamsType, deleteOidcClientParamsType, deleteOidcTokenParamsType, rotateOidcClientSecretParamsType, updateOidcClientParamsType, firestoreModelKey, oidcEntryIdentity, PUBLIC_PKCE_TOKEN_ENDPOINT_AUTH_METHOD, OIDC_ENTRY_CLIENT_TYPE, oidcEntriesByUserCodeQuery, oidcEntriesByUidQuery, oidcEntriesByGrantIdQuery, oidcProviderProfilesForKeys, requiredScopesForOidcProviderProfiles, scopesForOidcProviderProfiles, oidcEntryFirestoreCollection, callModelOidcScopeForCallType, CALL_MODEL_MISSING_OIDC_SCOPE_ERROR_CODE } from '@dereekb/firebase';
7
7
  import { firestoreEncryptedField, firebaseServerActionsContext, FirebaseServerEnvService, FirebaseServerAnalyticsService, FIREBASE_FIRESTORE_CONTEXT_TOKEN, FirebaseServerFirestoreContextModule, forbiddenError } from '@dereekb/firebase-server';
8
8
  import { nanoid } from 'nanoid';
9
9
  import { safeToJsDate } from '@dereekb/date';
@@ -2429,6 +2429,9 @@ function _ts_generator$7(thisArg, body) {
2429
2429
  if (params.dbx_max_session_ttl != null) {
2430
2430
  properties.dbx_max_session_ttl = params.dbx_max_session_ttl;
2431
2431
  }
2432
+ if (params.dbx_provider_profiles != null) {
2433
+ properties.dbx_provider_profiles = params.dbx_provider_profiles;
2434
+ }
2432
2435
  // Merge any pre-validated metadata (e.g., inline jwks for private_key_jwt in tests)
2433
2436
  if (validatedMetadata === null || validatedMetadata === void 0 ? void 0 : validatedMetadata.jwks) {
2434
2437
  properties.jwks = validatedMetadata.jwks;
@@ -2486,7 +2489,7 @@ function _ts_generator$7(thisArg, body) {
2486
2489
  * @throws {Error} When the client is not found.
2487
2490
  */ function updateClient(clientId, params) {
2488
2491
  return _async_to_generator$7(function() {
2489
- var provider, ProviderClient, existing, updatedMetadata, _params_logo_uri, _params_client_uri, _params_dbx_max_session_ttl, client;
2492
+ var provider, ProviderClient, existing, updatedMetadata, _params_logo_uri, _params_client_uri, _params_dbx_max_session_ttl, _params_dbx_provider_profiles, client;
2490
2493
  return _ts_generator$7(this, function(_state) {
2491
2494
  switch(_state.label){
2492
2495
  case 0:
@@ -2518,6 +2521,12 @@ function _ts_generator$7(thisArg, body) {
2518
2521
  if (params.dbx_max_session_ttl !== undefined) {
2519
2522
  updatedMetadata.dbx_max_session_ttl = (_params_dbx_max_session_ttl = params.dbx_max_session_ttl) !== null && _params_dbx_max_session_ttl !== void 0 ? _params_dbx_max_session_ttl : undefined;
2520
2523
  }
2524
+ // Only overwrite the profile assignment when the field is explicitly provided. An omitted value
2525
+ // (including one stripped for a non-admin caller) preserves the existing assignment; `null`/`[]`
2526
+ // clears it. This is what makes the non-admin strip a no-op rather than a wipe.
2527
+ if (params.dbx_provider_profiles !== undefined) {
2528
+ updatedMetadata.dbx_provider_profiles = (_params_dbx_provider_profiles = params.dbx_provider_profiles) !== null && _params_dbx_provider_profiles !== void 0 ? _params_dbx_provider_profiles : undefined;
2529
+ }
2521
2530
  // Mirrors oidc-provider's lib/helpers/add_client.js: re-validates and persists.
2522
2531
  return [
2523
2532
  4,
@@ -3536,6 +3545,25 @@ OidcProviderConfigService = __decorate([
3536
3545
  __param(2, Inject(FirebaseServerEnvService))
3537
3546
  ], OidcProviderConfigService);
3538
3547
 
3548
+ /**
3549
+ * Custom oidc-provider client metadata field holding the {@link OidcProviderProfile} keys assigned
3550
+ * to a client. Registered via `extraClientMetadata` and persisted on the client's `OidcEntry` payload.
3551
+ */ var DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA = 'dbx_provider_profiles';
3552
+ /**
3553
+ * Resolves the unlocked + required scope sets for a client from the provider-profile registry and
3554
+ * the profile keys assigned to that client.
3555
+ *
3556
+ * @param providerProfiles - The provider-profile registry (from {@link OidcProviderConfig.providerProfiles}).
3557
+ * @param clientProfileKeys - The profile keys assigned to the client (its `dbx_provider_profiles`).
3558
+ * @returns The unlocked and required scope sets for the client.
3559
+ */ function oidcClientProviderProfileScopes(providerProfiles, clientProfileKeys) {
3560
+ var clientProfiles = oidcProviderProfilesForKeys(providerProfiles !== null && providerProfiles !== void 0 ? providerProfiles : [], clientProfileKeys);
3561
+ return {
3562
+ unlocked: scopesForOidcProviderProfiles(clientProfiles),
3563
+ required: requiredScopesForOidcProviderProfiles(clientProfiles)
3564
+ };
3565
+ }
3566
+
3539
3567
  function _array_like_to_array$4(arr, len) {
3540
3568
  if (len == null || len > arr.length) len = arr.length;
3541
3569
  for(var i = 0, arr2 = new Array(len); i < len; i++)arr2[i] = arr[i];
@@ -4040,7 +4068,8 @@ function _ts_generator$5(thisArg, body) {
4040
4068
  logo_uri: existing.logo_uri,
4041
4069
  client_uri: existing.client_uri,
4042
4070
  created_at: existing.created_at,
4043
- dbx_max_session_ttl: existing.dbx_max_session_ttl
4071
+ dbx_max_session_ttl: existing.dbx_max_session_ttl,
4072
+ dbx_provider_profiles: existing.dbx_provider_profiles
4044
4073
  };
4045
4074
  }
4046
4075
  return [
@@ -4147,7 +4176,8 @@ function _ts_generator$5(thisArg, body) {
4147
4176
  ],
4148
4177
  extraClientMetadata: {
4149
4178
  properties: [
4150
- DBX_FIREBASE_SERVER_OIDC_MAX_SESSION_TTL_CLIENT_METADATA
4179
+ DBX_FIREBASE_SERVER_OIDC_MAX_SESSION_TTL_CLIENT_METADATA,
4180
+ DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA
4151
4181
  ],
4152
4182
  validator: function validator(_ctx, key, value) {
4153
4183
  if (key === DBX_FIREBASE_SERVER_OIDC_MAX_SESSION_TTL_CLIENT_METADATA && value !== undefined && value !== null) {
@@ -4157,6 +4187,22 @@ function _ts_generator$5(thisArg, body) {
4157
4187
  if (value > serverMaxSeconds) {
4158
4188
  throw new errors.InvalidClientMetadata("".concat(DBX_FIREBASE_SERVER_OIDC_MAX_SESSION_TTL_CLIENT_METADATA, " cannot exceed the server max of ").concat(serverMaxSeconds, " seconds."));
4159
4189
  }
4190
+ } else if (key === DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA && value !== undefined && value !== null) {
4191
+ var _providerConfig_providerProfiles;
4192
+ if (!Array.isArray(value) || value.some(function(entry) {
4193
+ return typeof entry !== 'string';
4194
+ })) {
4195
+ throw new errors.InvalidClientMetadata("".concat(DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA, " must be an array of provider profile keys."));
4196
+ }
4197
+ var knownProfileKeys = new Set(((_providerConfig_providerProfiles = providerConfig.providerProfiles) !== null && _providerConfig_providerProfiles !== void 0 ? _providerConfig_providerProfiles : []).map(function(profile) {
4198
+ return profile.key;
4199
+ }));
4200
+ var unknownKey = value.find(function(entry) {
4201
+ return !knownProfileKeys.has(entry);
4202
+ });
4203
+ if (unknownKey !== undefined) {
4204
+ throw new errors.InvalidClientMetadata("".concat(DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA, " contains an unknown provider profile key: ").concat(unknownKey, "."));
4205
+ }
4160
4206
  }
4161
4207
  }
4162
4208
  },
@@ -4184,7 +4230,7 @@ function _ts_generator$5(thisArg, body) {
4184
4230
  interactions: {
4185
4231
  url: function url(_ctx, interaction) {
4186
4232
  return _async_to_generator$5(function() {
4187
- var baseUrl, client_id, paramsToEncode, client, scopes, interactionLoginDetails, paramsString;
4233
+ var baseUrl, client_id, paramsToEncode, client, _client_dbx_provider_profiles, scopes, requestedScopeSet, required, requiredScopes, interactionLoginDetails, paramsString;
4188
4234
  return _ts_generator$5(this, function(_state) {
4189
4235
  switch(_state.label){
4190
4236
  case 0:
@@ -4212,13 +4258,23 @@ function _ts_generator$5(thisArg, body) {
4212
4258
  client = _state.sent();
4213
4259
  if (client) {
4214
4260
  scopes = interaction.params.scope;
4215
- interactionLoginDetails = {
4261
+ // Surface the scopes the client's provider profiles force-require so the consent UI can
4262
+ // render them as required. Intersect with the actually-requested scopes so only visible
4263
+ // scope rows are flagged.
4264
+ requestedScopeSet = new Set(scopes.split(' ').filter(Boolean));
4265
+ required = oidcClientProviderProfileScopes(providerConfig.providerProfiles, (_client_dbx_provider_profiles = client.dbx_provider_profiles) !== null && _client_dbx_provider_profiles !== void 0 ? _client_dbx_provider_profiles : undefined).required;
4266
+ requiredScopes = Array.from(required).filter(function(scope) {
4267
+ return requestedScopeSet.has(scope);
4268
+ });
4269
+ interactionLoginDetails = _object_spread$3({
4216
4270
  client_id: client_id,
4217
4271
  client_name: client.client_name,
4218
4272
  client_uri: client.client_uri,
4219
4273
  logo_uri: client.logo_uri,
4220
4274
  scopes: scopes
4221
- };
4275
+ }, requiredScopes.length > 0 ? {
4276
+ requiredScopes: requiredScopes.join(' ')
4277
+ } : {});
4222
4278
  paramsToEncode = _object_spread$3({}, paramsToEncode, interactionLoginDetails);
4223
4279
  }
4224
4280
  _state.label = 3;
@@ -6157,7 +6213,7 @@ var OidcInteractionController_1;
6157
6213
  * @throws {HttpException} HTTP 400 when the consent interaction cannot be completed.
6158
6214
  */ function postConsent(uid, body, res) {
6159
6215
  return _async_to_generator$2(function() {
6160
- var startedAt, clientId, accountId, _ref, _prompt_details_missingOIDCScope, _this_accountService_providerConfig_adminOnlyScopes, _ref1, _ref2, _prompt_details_missingOIDCClaims, _prompt_details_missingResourceScopes, _this_accountService_delegate_isAdminUser, _this_accountService_delegate, _ref3, redirectTo, interaction, prompt, params, session, missingOIDCScope, existingGrant, _tmp, encounteredOIDCScopes, requestedOIDCScopeSet, adminOnlyScopes, requestsServiceToken, isAdmin, _tmp1, _ref4, redirectTo1, requestedRawTtl, clientPayload, clientMaxSessionTtl, expiresInSeconds, grant, _tmp2, _resolveEffectiveSubset, granted, rejected, _iteratorNormalCompletion, _didIteratorError, _iteratorError, _iterator, _step, value, encounteredOIDCClaims, missingOIDCClaims, _resolveEffectiveSubset1, granted1, rejected1, missingResourceScopes, _iteratorNormalCompletion1, _didIteratorError1, _iteratorError1, _iterator1, _step1, _step_value, indicator, scopes, _body_grantedResourceScopes, requestedSubset, encounteredResourceScopes, _resolveEffectiveSubset2, granted2, rejected2, _iteratorNormalCompletion2, _didIteratorError2, _iteratorError2, _iterator2, _step2, value1, grantId, _ref5, redirectTo2, err;
6216
+ var startedAt, clientId, accountId, _ref, _prompt_details_missingOIDCScope, _this_accountService_providerConfig_adminOnlyScopes, _ref1, _ref2, _ref3, _prompt_details_missingOIDCClaims, _prompt_details_missingResourceScopes, _this_accountService_delegate_isAdminUser, _this_accountService_delegate, _ref4, redirectTo, interaction, prompt, params, session, missingOIDCScope, existingGrant, _tmp, encounteredOIDCScopes, clientPayload, requestedOIDCScopeSet, adminOnlyScopes, requestsServiceToken, isAdmin, _tmp1, _ref5, redirectTo1, providerProfiles, _oidcClientProviderProfileScopes, clientUnlockedScopes, clientRequiredScopes, profileGatedScopes, disallowedGatedScopes, _ref6, redirectTo2, missingRequiredScopes, _ref7, redirectTo3, requestedRawTtl, clientMaxSessionTtl, expiresInSeconds, grant, _tmp2, _resolveEffectiveSubset, granted, rejected, _iteratorNormalCompletion, _didIteratorError, _iteratorError, _iterator, _step, value, encounteredOIDCClaims, missingOIDCClaims, _resolveEffectiveSubset1, granted1, rejected1, missingResourceScopes, _iteratorNormalCompletion1, _didIteratorError1, _iteratorError1, _iterator1, _step1, _step_value, indicator, scopes, _body_grantedResourceScopes, requestedSubset, encounteredResourceScopes, _resolveEffectiveSubset2, granted2, rejected2, _iteratorNormalCompletion2, _didIteratorError2, _iteratorError2, _iterator2, _step2, value1, grantId, _ref8, redirectTo4, err;
6161
6217
  return _ts_generator$2(this, function(_state) {
6162
6218
  switch(_state.label){
6163
6219
  case 0:
@@ -6172,9 +6228,9 @@ var OidcInteractionController_1;
6172
6228
  case 2:
6173
6229
  _state.trys.push([
6174
6230
  2,
6175
- 20,
6231
+ 24,
6176
6232
  ,
6177
- 21
6233
+ 25
6178
6234
  ]);
6179
6235
  if (!!body.approved) return [
6180
6236
  3,
@@ -6190,7 +6246,7 @@ var OidcInteractionController_1;
6190
6246
  })
6191
6247
  ];
6192
6248
  case 3:
6193
- _ref3 = _state.sent(), redirectTo = _ref3.returnTo;
6249
+ _ref4 = _state.sent(), redirectTo = _ref4.returnTo;
6194
6250
  res.json({
6195
6251
  redirectTo: redirectTo
6196
6252
  });
@@ -6233,6 +6289,12 @@ var OidcInteractionController_1;
6233
6289
  // and re-submits them all. We accept already-encountered values as silent no-ops so a re-consent on a
6234
6290
  // client the user has previously authorized doesn't fail validation.
6235
6291
  encounteredOIDCScopes = existingGrant ? existingGrant.getOIDCScopeEncountered().split(' ').filter(Boolean) : [];
6292
+ return [
6293
+ 4,
6294
+ this.oidcService.findClientPayload(clientId)
6295
+ ];
6296
+ case 9:
6297
+ clientPayload = _state.sent();
6236
6298
  // Admin-only scope gate. The requested OIDC scope set is `missingOIDCScope ∪ encounteredOIDCScopes`
6237
6299
  // (a fresh consent has everything in `missing`; a re-consent may carry already-granted scopes in
6238
6300
  // `encountered`). If that set intersects the provider's `adminOnlyScopes` and the resolving user is
@@ -6244,26 +6306,26 @@ var OidcInteractionController_1;
6244
6306
  });
6245
6307
  if (!accountId) return [
6246
6308
  3,
6247
- 10
6309
+ 11
6248
6310
  ];
6249
6311
  return [
6250
6312
  4,
6251
6313
  (_ref1 = (_this_accountService_delegate_isAdminUser = (_this_accountService_delegate = this.accountService.delegate).isAdminUser) === null || _this_accountService_delegate_isAdminUser === void 0 ? void 0 : _this_accountService_delegate_isAdminUser.call(_this_accountService_delegate, this.accountService.userContext(accountId).authUserContext)) !== null && _ref1 !== void 0 ? _ref1 : false
6252
6314
  ];
6253
- case 9:
6315
+ case 10:
6254
6316
  _tmp1 = _state.sent();
6255
6317
  return [
6256
6318
  3,
6257
- 11
6319
+ 12
6258
6320
  ];
6259
- case 10:
6260
- _tmp1 = false;
6261
- _state.label = 11;
6262
6321
  case 11:
6322
+ _tmp1 = false;
6323
+ _state.label = 12;
6324
+ case 12:
6263
6325
  isAdmin = _tmp1;
6264
6326
  if (!(requestsServiceToken && !isAdmin)) return [
6265
6327
  3,
6266
- 13
6328
+ 14
6267
6329
  ];
6268
6330
  return [
6269
6331
  4,
@@ -6274,8 +6336,8 @@ var OidcInteractionController_1;
6274
6336
  mergeWithLastSubmission: true
6275
6337
  })
6276
6338
  ];
6277
- case 12:
6278
- _ref4 = _state.sent(), redirectTo1 = _ref4.returnTo;
6339
+ case 13:
6340
+ _ref5 = _state.sent(), redirectTo1 = _ref5.returnTo;
6279
6341
  emitOidcAnalyticsEvent(this._analytics, {
6280
6342
  type: 'consent',
6281
6343
  isSuccessful: false,
@@ -6292,20 +6354,89 @@ var OidcInteractionController_1;
6292
6354
  return [
6293
6355
  2
6294
6356
  ];
6295
- case 13:
6357
+ case 14:
6358
+ // Provider-profile scope gate. Any scope referenced by a provider profile is "gated" — available
6359
+ // only to clients whose assigned profiles unlock it. Independent of the admin-only gate above; a
6360
+ // scope may be subject to both.
6361
+ providerProfiles = this.accountService.providerConfig.providerProfiles;
6362
+ _oidcClientProviderProfileScopes = oidcClientProviderProfileScopes(providerProfiles, (_ref2 = clientPayload === null || clientPayload === void 0 ? void 0 : clientPayload.dbx_provider_profiles) !== null && _ref2 !== void 0 ? _ref2 : undefined), clientUnlockedScopes = _oidcClientProviderProfileScopes.unlocked, clientRequiredScopes = _oidcClientProviderProfileScopes.required;
6363
+ profileGatedScopes = scopesForOidcProviderProfiles(providerProfiles !== null && providerProfiles !== void 0 ? providerProfiles : []);
6364
+ // Unlock gate: reject any requested gated scope this client's profiles do not unlock.
6365
+ disallowedGatedScopes = Array.from(requestedOIDCScopeSet).filter(function(scope) {
6366
+ return profileGatedScopes.has(scope) && !clientUnlockedScopes.has(scope);
6367
+ });
6368
+ if (!(disallowedGatedScopes.length > 0)) return [
6369
+ 3,
6370
+ 16
6371
+ ];
6372
+ return [
6373
+ 4,
6374
+ this.oidcInteractionService.finishInteractionByUid(uid, {
6375
+ error: 'access_denied',
6376
+ error_description: "The following scope(s) are not available to this client: ".concat(disallowedGatedScopes.join(', '), ".")
6377
+ }, {
6378
+ mergeWithLastSubmission: true
6379
+ })
6380
+ ];
6381
+ case 15:
6382
+ _ref6 = _state.sent(), redirectTo2 = _ref6.returnTo;
6383
+ emitOidcAnalyticsEvent(this._analytics, {
6384
+ type: 'consent',
6385
+ isSuccessful: false,
6386
+ uid: accountId,
6387
+ clientId: clientId,
6388
+ reason: 'scope_not_unlocked_for_client',
6389
+ durationMs: Date.now() - startedAt
6390
+ }, this._logger);
6391
+ res.json({
6392
+ redirectTo: redirectTo2
6393
+ });
6394
+ return [
6395
+ 2
6396
+ ];
6397
+ case 16:
6398
+ // Required gate: a scope a client's profiles force-require (`require: 'required'`) must be present
6399
+ // in the request. Reject when the client did not request a required scope.
6400
+ missingRequiredScopes = Array.from(clientRequiredScopes).filter(function(scope) {
6401
+ return !requestedOIDCScopeSet.has(scope);
6402
+ });
6403
+ if (!(missingRequiredScopes.length > 0)) return [
6404
+ 3,
6405
+ 18
6406
+ ];
6407
+ return [
6408
+ 4,
6409
+ this.oidcInteractionService.finishInteractionByUid(uid, {
6410
+ error: 'access_denied',
6411
+ error_description: "This client must request the required scope(s): ".concat(missingRequiredScopes.join(', '), ".")
6412
+ }, {
6413
+ mergeWithLastSubmission: true
6414
+ })
6415
+ ];
6416
+ case 17:
6417
+ _ref7 = _state.sent(), redirectTo3 = _ref7.returnTo;
6418
+ emitOidcAnalyticsEvent(this._analytics, {
6419
+ type: 'consent',
6420
+ isSuccessful: false,
6421
+ uid: accountId,
6422
+ clientId: clientId,
6423
+ reason: 'required_scope_missing',
6424
+ durationMs: Date.now() - startedAt
6425
+ }, this._logger);
6426
+ res.json({
6427
+ redirectTo: redirectTo3
6428
+ });
6429
+ return [
6430
+ 2
6431
+ ];
6432
+ case 18:
6296
6433
  // Resolve the requested login duration up-front. The configured Grant TTL function (in
6297
6434
  // OidcService.buildProviderConfiguration) only fires when oidc-provider's koa middleware
6298
6435
  // drives `grant.save()`, so its `ctx.oidc.params` lookup of `dbx_session_ttl` returns
6299
6436
  // undefined when the consent submit runs in this controller. We pre-set `expiresIn` on
6300
6437
  // newly-created grants so they persist with the correct (tiered) TTL.
6301
6438
  requestedRawTtl = params[DBX_FIREBASE_SERVER_OIDC_SESSION_TTL_PARAM];
6302
- return [
6303
- 4,
6304
- this.oidcService.findClientPayload(clientId)
6305
- ];
6306
- case 14:
6307
- clientPayload = _state.sent();
6308
- clientMaxSessionTtl = (_ref2 = clientPayload === null || clientPayload === void 0 ? void 0 : clientPayload.dbx_max_session_ttl) !== null && _ref2 !== void 0 ? _ref2 : undefined;
6439
+ clientMaxSessionTtl = (_ref3 = clientPayload === null || clientPayload === void 0 ? void 0 : clientPayload.dbx_max_session_ttl) !== null && _ref3 !== void 0 ? _ref3 : undefined;
6309
6440
  expiresInSeconds = this.oidcService.resolveLoginDurationForGrant(requestedRawTtl, {
6310
6441
  dbx_max_session_ttl: clientMaxSessionTtl
6311
6442
  }, {
@@ -6314,28 +6445,30 @@ var OidcInteractionController_1;
6314
6445
  });
6315
6446
  if (!(existingGrant !== null && existingGrant !== void 0)) return [
6316
6447
  3,
6317
- 15
6448
+ 19
6318
6449
  ];
6319
6450
  _tmp2 = existingGrant;
6320
6451
  return [
6321
6452
  3,
6322
- 17
6453
+ 21
6323
6454
  ];
6324
- case 15:
6455
+ case 19:
6325
6456
  return [
6326
6457
  4,
6327
6458
  this.oidcInteractionService.findOrCreateGrant(interaction.grantId, accountId, clientId, expiresInSeconds)
6328
6459
  ];
6329
- case 16:
6460
+ case 20:
6330
6461
  _tmp2 = _state.sent();
6331
- _state.label = 17;
6332
- case 17:
6462
+ _state.label = 21;
6463
+ case 21:
6333
6464
  grant = _tmp2;
6334
6465
  if (missingOIDCScope.length > 0) {
6466
+ // Force-grant the client's required profile scopes (in addition to `openid`) so they cannot be
6467
+ // dropped at consent — the required gate above already guarantees they were requested.
6335
6468
  _resolveEffectiveSubset = resolveEffectiveSubset({
6336
6469
  missing: missingOIDCScope,
6337
6470
  requestedSubset: body.grantedOIDCScopes,
6338
- alwaysGranted: ALWAYS_GRANTED_OIDC_SCOPES,
6471
+ alwaysGranted: _to_consumable_array$1(ALWAYS_GRANTED_OIDC_SCOPES).concat(_to_consumable_array$1(clientRequiredScopes)),
6339
6472
  alreadyEncountered: encounteredOIDCScopes
6340
6473
  }), granted = _resolveEffectiveSubset.granted, rejected = _resolveEffectiveSubset.rejected;
6341
6474
  if (granted.length > 0) {
@@ -6432,7 +6565,7 @@ var OidcInteractionController_1;
6432
6565
  4,
6433
6566
  grant.save()
6434
6567
  ];
6435
- case 18:
6568
+ case 22:
6436
6569
  grantId = _state.sent();
6437
6570
  return [
6438
6571
  4,
@@ -6444,8 +6577,8 @@ var OidcInteractionController_1;
6444
6577
  mergeWithLastSubmission: true
6445
6578
  })
6446
6579
  ];
6447
- case 19:
6448
- _ref5 = _state.sent(), redirectTo2 = _ref5.returnTo;
6580
+ case 23:
6581
+ _ref8 = _state.sent(), redirectTo4 = _ref8.returnTo;
6449
6582
  emitOidcAnalyticsEvent(this._analytics, {
6450
6583
  type: 'consent',
6451
6584
  isSuccessful: true,
@@ -6457,13 +6590,13 @@ var OidcInteractionController_1;
6457
6590
  durationMs: Date.now() - startedAt
6458
6591
  }, this._logger);
6459
6592
  res.json({
6460
- redirectTo: redirectTo2
6593
+ redirectTo: redirectTo4
6461
6594
  });
6462
6595
  return [
6463
6596
  3,
6464
- 21
6597
+ 25
6465
6598
  ];
6466
- case 20:
6599
+ case 24:
6467
6600
  err = _state.sent();
6468
6601
  emitOidcAnalyticsEvent(this._analytics, {
6469
6602
  type: 'consent',
@@ -6478,7 +6611,7 @@ var OidcInteractionController_1;
6478
6611
  throw err;
6479
6612
  }
6480
6613
  throw new HttpException('Consent interaction failed', HttpStatus.BAD_REQUEST);
6481
- case 21:
6614
+ case 25:
6482
6615
  return [
6483
6616
  2
6484
6617
  ];
@@ -7648,31 +7781,43 @@ function _unsupported_iterable_to_array(o, minLen) {
7648
7781
 
7649
7782
  /**
7650
7783
  * Builds a {@link AssertModelCrudRequestFunction} that rejects callModel requests
7651
- * lacking the OIDC scope mapped from the call type.
7784
+ * lacking the required OIDC scope(s) for the call.
7785
+ *
7786
+ * Enforcement is additive: an OIDC caller must hold BOTH the per-verb scope mapped
7787
+ * from the call type (`model.<call>`) AND any per-function scope a handler declared
7788
+ * via `withApiDetails({ requiredScope })` — surfaced on `context.requiredScope`.
7652
7789
  *
7653
7790
  * Bypasses the check (no-op) when the request is not OIDC-authenticated — i.e.
7654
7791
  * when {@link getOidcScopesFromRequest} returns `undefined` because there is no
7655
7792
  * `auth.token.scope` claim on the request. Regular Firebase ID-token callers are
7656
7793
  * unaffected and continue to be gated by auth roles.
7657
7794
  *
7658
- * Bypasses the check for custom (non-CRUD) call types so app-specific verbs
7659
- * remain unrestricted unless an app wires its own assertion.
7795
+ * Bypasses the check for custom (non-CRUD) call types that declare no per-function
7796
+ * scope, so app-specific verbs remain unrestricted unless an app wires its own
7797
+ * assertion or declares a `requiredScope`.
7660
7798
  *
7661
7799
  * Wire as the `preAssert` of {@link onCallModel}.
7662
7800
  *
7663
7801
  * @returns The pre-assertion function ready to plug into `OnCallModelConfig.preAssert`.
7664
7802
  */ function oidcCallModelScopePreAssert() {
7665
7803
  var fn = function fn(context) {
7666
- var requiredScope = callModelOidcScopeForCallType(context.call);
7667
- var scopes = requiredScope == null ? undefined : getOidcScopesFromRequest(context.request);
7668
- var isMissingScope = requiredScope != null && scopes != null && !scopes.has(requiredScope);
7669
- if (isMissingScope) {
7804
+ var requiredScopes = filterMaybeArrayValues([
7805
+ callModelOidcScopeForCallType(context.call),
7806
+ context.requiredScope
7807
+ ]);
7808
+ // No requirement (custom verb + no per-function scope) short-circuits without reading scopes.
7809
+ // A non-OIDC caller yields `undefined` scopes below and bypasses just like the per-verb check.
7810
+ var scopes = requiredScopes.length === 0 ? undefined : getOidcScopesFromRequest(context.request);
7811
+ var missingScopes = scopes == null ? [] : requiredScopes.filter(function(scope) {
7812
+ return !scopes.has(scope);
7813
+ });
7814
+ if (missingScopes.length > 0) {
7670
7815
  throw forbiddenError({
7671
7816
  status: 403,
7672
7817
  code: CALL_MODEL_MISSING_OIDC_SCOPE_ERROR_CODE,
7673
- message: "Missing required OIDC scope for callModel: ".concat(requiredScope),
7818
+ message: "Missing required OIDC scope for callModel: ".concat(missingScopes.join(', ')),
7674
7819
  data: {
7675
- requiredScope: requiredScope,
7820
+ requiredScopes: missingScopes,
7676
7821
  call: context.call
7677
7822
  }
7678
7823
  });
@@ -7681,4 +7826,4 @@ function _unsupported_iterable_to_array(o, minLen) {
7681
7826
  return fn;
7682
7827
  }
7683
7828
 
7684
- export { DEFAULT_APP_OAUTH_CONSENT_PATH_PART, DEFAULT_APP_OAUTH_INTERACTION_PATH, DEFAULT_APP_OAUTH_LOGIN_PATH_PART, DEFAULT_MAX_ADMIN_LOGIN_DURATION_SECONDS, DEFAULT_MAX_NONADMIN_LOGIN_DURATION_SECONDS, DEFAULT_MAX_REQUESTED_LOGIN_DURATION_SECONDS, DEFAULT_MAX_SERVICE_TOKEN_LOGIN_DURATION_SECONDS, DEFAULT_MIN_REQUESTED_LOGIN_DURATION_SECONDS, DEFAULT_OIDC_ANALYTICS_EVENT_NAMES, DEFAULT_OIDC_ANALYTICS_EVENT_NAME_PREFIX, DEFAULT_OIDC_CODE_CHALLENGE_METHODS, DEFAULT_OIDC_ID_TOKEN_SIGNING_ALG_VALUES, DEFAULT_OIDC_ISSUER_PATH, DEFAULT_OIDC_ROUTES, DEFAULT_OIDC_SUBJECT_TYPES, DEFAULT_OIDC_TOKEN_ENDPOINT_AUTH_METHODS, DEFAULT_OIDC_TOKEN_LIFETIMES, DEFAULT_ROTATED_KEY_MAX_AGE, FIREBASE_SERVER_OIDC_ANALYTICS_CONFIG, FIREBASE_SERVER_OIDC_ROUTES_FOR_GLOBAL_ROUTE_EXCLUDE, FirebaseServerOidcAnalyticsService, GRANTABLE_MODEL_NAMES, JwksFirestoreCollections, JwksKeyDocument, JwksService, JwksServiceConfig, JwksServiceStorageConfig, OIDC_ANALYTICS_SERVICE, OIDC_ENCRYPTED_PAYLOAD_FIELDS, OIDC_JSON_RENDER_ERROR_FUNCTION, OIDC_JWKS_ENCRYPTION_SECRET_ENV_KEY, OidcAccountService, OidcAccountServiceDelegate, OidcAccountServiceUserContext, OidcAnalyticsService, OidcAuth, OidcAuthBearerTokenMiddleware, OidcAuthMiddlewareConfig, OidcClientService, OidcEncryptionService, OidcInteractionController, OidcInteractionService, OidcModelServerActions, OidcModuleConfig, OidcProviderConfigService, OidcProviderController, OidcServerFirestoreCollections, OidcService, OidcWellKnownController, activeJwksKeysQuery, allOidcScopesStringForProviderConfig, appOidcAnalyticsModuleMetadata, appOidcModelModuleMetadata, applyOidcAuthMiddleware, applyOidcCorsMiddleware, buildBearerChallenge, buildFirebaseServerMcpResourceServer, createAdapterFactory, createOidcClientFactory, deleteOidcClientFactory, deleteOidcTokenFactory, deriveResourceMetadataUrlFromEnv, emitOidcAnalyticsEvent, getOidcScopesFromRequest, jwksKeyCollectionReference, jwksKeyConverter, jwksKeyFirestoreCollection, jwksKeyIdentity, jwksKeysWithStatusQuery, nonRetiredJwksKeysQuery, noopOidcAnalyticsService, oidcCallModelScopePreAssert, oidcCorsOriginDelegate, oidcFirestoreCollectionsFactory, oidcModelServerActions, oidcModelServerActionsFactory, oidcModuleConfigFactory, oidcModuleMetadata, resolveEffectiveSubset, rotateOidcClientSecretFactory, rotatedJwksKeysQuery, updateOidcClientFactory };
7829
+ export { DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA, DEFAULT_APP_OAUTH_CONSENT_PATH_PART, DEFAULT_APP_OAUTH_INTERACTION_PATH, DEFAULT_APP_OAUTH_LOGIN_PATH_PART, DEFAULT_MAX_ADMIN_LOGIN_DURATION_SECONDS, DEFAULT_MAX_NONADMIN_LOGIN_DURATION_SECONDS, DEFAULT_MAX_REQUESTED_LOGIN_DURATION_SECONDS, DEFAULT_MAX_SERVICE_TOKEN_LOGIN_DURATION_SECONDS, DEFAULT_MIN_REQUESTED_LOGIN_DURATION_SECONDS, DEFAULT_OIDC_ANALYTICS_EVENT_NAMES, DEFAULT_OIDC_ANALYTICS_EVENT_NAME_PREFIX, DEFAULT_OIDC_CODE_CHALLENGE_METHODS, DEFAULT_OIDC_ID_TOKEN_SIGNING_ALG_VALUES, DEFAULT_OIDC_ISSUER_PATH, DEFAULT_OIDC_ROUTES, DEFAULT_OIDC_SUBJECT_TYPES, DEFAULT_OIDC_TOKEN_ENDPOINT_AUTH_METHODS, DEFAULT_OIDC_TOKEN_LIFETIMES, DEFAULT_ROTATED_KEY_MAX_AGE, FIREBASE_SERVER_OIDC_ANALYTICS_CONFIG, FIREBASE_SERVER_OIDC_ROUTES_FOR_GLOBAL_ROUTE_EXCLUDE, FirebaseServerOidcAnalyticsService, GRANTABLE_MODEL_NAMES, JwksFirestoreCollections, JwksKeyDocument, JwksService, JwksServiceConfig, JwksServiceStorageConfig, OIDC_ANALYTICS_SERVICE, OIDC_ENCRYPTED_PAYLOAD_FIELDS, OIDC_JSON_RENDER_ERROR_FUNCTION, OIDC_JWKS_ENCRYPTION_SECRET_ENV_KEY, OidcAccountService, OidcAccountServiceDelegate, OidcAccountServiceUserContext, OidcAnalyticsService, OidcAuth, OidcAuthBearerTokenMiddleware, OidcAuthMiddlewareConfig, OidcClientService, OidcEncryptionService, OidcInteractionController, OidcInteractionService, OidcModelServerActions, OidcModuleConfig, OidcProviderConfigService, OidcProviderController, OidcServerFirestoreCollections, OidcService, OidcWellKnownController, activeJwksKeysQuery, allOidcScopesStringForProviderConfig, appOidcAnalyticsModuleMetadata, appOidcModelModuleMetadata, applyOidcAuthMiddleware, applyOidcCorsMiddleware, buildBearerChallenge, buildFirebaseServerMcpResourceServer, createAdapterFactory, createOidcClientFactory, deleteOidcClientFactory, deleteOidcTokenFactory, deriveResourceMetadataUrlFromEnv, emitOidcAnalyticsEvent, getOidcScopesFromRequest, jwksKeyCollectionReference, jwksKeyConverter, jwksKeyFirestoreCollection, jwksKeyIdentity, jwksKeysWithStatusQuery, nonRetiredJwksKeysQuery, noopOidcAnalyticsService, oidcCallModelScopePreAssert, oidcClientProviderProfileScopes, oidcCorsOriginDelegate, oidcFirestoreCollectionsFactory, oidcModelServerActions, oidcModelServerActionsFactory, oidcModuleConfigFactory, oidcModuleMetadata, resolveEffectiveSubset, rotateOidcClientSecretFactory, rotatedJwksKeysQuery, updateOidcClientFactory };
package/oidc/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@dereekb/firebase-server/oidc",
3
- "version": "13.28.0",
3
+ "version": "13.29.0",
4
4
  "peerDependencies": {
5
- "@dereekb/analytics": "13.28.0",
6
- "@dereekb/date": "13.28.0",
7
- "@dereekb/firebase": "13.28.0",
8
- "@dereekb/firebase-server": "13.28.0",
9
- "@dereekb/model": "13.28.0",
10
- "@dereekb/nestjs": "13.28.0",
11
- "@dereekb/rxjs": "13.28.0",
12
- "@dereekb/util": "13.28.0",
13
- "@dereekb/zoho": "13.28.0",
5
+ "@dereekb/analytics": "13.29.0",
6
+ "@dereekb/date": "13.29.0",
7
+ "@dereekb/firebase": "13.29.0",
8
+ "@dereekb/firebase-server": "13.29.0",
9
+ "@dereekb/model": "13.29.0",
10
+ "@dereekb/nestjs": "13.29.0",
11
+ "@dereekb/rxjs": "13.29.0",
12
+ "@dereekb/util": "13.29.0",
13
+ "@dereekb/zoho": "13.29.0",
14
14
  "@nestjs/common": "^11.1.19",
15
15
  "@nestjs/config": "^4.0.4",
16
16
  "express": "^5.2.1",
@@ -3,5 +3,6 @@ export * from './model';
3
3
  export * from './oidc.config';
4
4
  export * from './controller';
5
5
  export * from './oidc.module';
6
+ export * from './profile';
6
7
  export * from './scope';
7
8
  export * from './service';
@@ -1,6 +1,6 @@
1
1
  import type { Configuration } from 'oidc-provider';
2
2
  import { type WebsitePath, type SlashPath, type Seconds } from '@dereekb/util';
3
- import { type OidcScope, type OidcTokenEndpointAuthMethod } from '@dereekb/firebase';
3
+ import { type OidcProviderProfile, type OidcScope, type OidcTokenEndpointAuthMethod } from '@dereekb/firebase';
4
4
  import { type JwksServiceConfig } from './service/oidc.jwks.service';
5
5
  import { type JwksKeyConverterConfig } from './model';
6
6
  /**
@@ -104,6 +104,19 @@ export interface OidcProviderConfig<S extends OidcScope = OidcScope> {
104
104
  * All other grants keep oidc-provider's default rotation behavior.
105
105
  */
106
106
  readonly nonRotatingScopes?: readonly string[];
107
+ /**
108
+ * Named provider profiles that unlock (and optionally force-require) scopes for the OIDC clients
109
+ * they are assigned to. Any scope referenced by a profile is treated as restricted: a client may
110
+ * only obtain it when one of its assigned profiles (persisted as `dbx_provider_profiles` client
111
+ * metadata) unlocks it. A profile scope marked `require: 'required'` is force-required — surfaced
112
+ * as a required scope at consent and hard-rejected if not granted.
113
+ *
114
+ * Keeps the generic package app-agnostic: apps declare their profiles statically and list them
115
+ * here, rather than the package hard-coding any profile or scope name. This gate is independent of
116
+ * {@link adminOnlyScopes} (which gates by the resolving user's admin status); a scope may be
117
+ * subject to both.
118
+ */
119
+ readonly providerProfiles?: readonly OidcProviderProfile<S>[];
107
120
  }
108
121
  /**
109
122
  * Returns the space-delimited list of every scope declared on `providerConfig.claims`.
@@ -0,0 +1,29 @@
1
+ import { type OidcProviderProfile, type OidcScope, type OidcProviderProfileKey } from '@dereekb/firebase';
2
+ /**
3
+ * Custom oidc-provider client metadata field holding the {@link OidcProviderProfile} keys assigned
4
+ * to a client. Registered via `extraClientMetadata` and persisted on the client's `OidcEntry` payload.
5
+ */
6
+ export declare const DBX_FIREBASE_SERVER_OIDC_PROVIDER_PROFILES_CLIENT_METADATA = "dbx_provider_profiles";
7
+ /**
8
+ * The scopes a client with the given assigned profile keys is allowed/required to obtain, resolved
9
+ * against the provider-profile registry.
10
+ */
11
+ export interface OidcClientProviderProfileScopes<S extends OidcScope = OidcScope> {
12
+ /**
13
+ * Scopes unlocked for the client (the union of its assigned profiles' scopes).
14
+ */
15
+ readonly unlocked: Set<S>;
16
+ /**
17
+ * Scopes the client's profiles force-require (`require: 'required'`).
18
+ */
19
+ readonly required: Set<S>;
20
+ }
21
+ /**
22
+ * Resolves the unlocked + required scope sets for a client from the provider-profile registry and
23
+ * the profile keys assigned to that client.
24
+ *
25
+ * @param providerProfiles - The provider-profile registry (from {@link OidcProviderConfig.providerProfiles}).
26
+ * @param clientProfileKeys - The profile keys assigned to the client (its `dbx_provider_profiles`).
27
+ * @returns The unlocked and required scope sets for the client.
28
+ */
29
+ export declare function oidcClientProviderProfileScopes<S extends OidcScope = OidcScope>(providerProfiles: readonly OidcProviderProfile<S>[] | undefined, clientProfileKeys: readonly OidcProviderProfileKey[] | undefined): OidcClientProviderProfileScopes<S>;
@@ -2,15 +2,20 @@ import { type OnCallTypedModelParams } from '@dereekb/firebase';
2
2
  import { type AssertModelCrudRequestFunction } from '@dereekb/firebase-server';
3
3
  /**
4
4
  * Builds a {@link AssertModelCrudRequestFunction} that rejects callModel requests
5
- * lacking the OIDC scope mapped from the call type.
5
+ * lacking the required OIDC scope(s) for the call.
6
+ *
7
+ * Enforcement is additive: an OIDC caller must hold BOTH the per-verb scope mapped
8
+ * from the call type (`model.<call>`) AND any per-function scope a handler declared
9
+ * via `withApiDetails({ requiredScope })` — surfaced on `context.requiredScope`.
6
10
  *
7
11
  * Bypasses the check (no-op) when the request is not OIDC-authenticated — i.e.
8
12
  * when {@link getOidcScopesFromRequest} returns `undefined` because there is no
9
13
  * `auth.token.scope` claim on the request. Regular Firebase ID-token callers are
10
14
  * unaffected and continue to be gated by auth roles.
11
15
  *
12
- * Bypasses the check for custom (non-CRUD) call types so app-specific verbs
13
- * remain unrestricted unless an app wires its own assertion.
16
+ * Bypasses the check for custom (non-CRUD) call types that declare no per-function
17
+ * scope, so app-specific verbs remain unrestricted unless an app wires its own
18
+ * assertion or declares a `requiredScope`.
14
19
  *
15
20
  * Wire as the `preAssert` of {@link onCallModel}.
16
21
  *