scimgateway 5.5.0 → 5.5.2

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/README.md CHANGED
@@ -738,16 +738,16 @@ Example Entra ID (plugin-entra-id) using federated credentials:
738
738
  "options": {
739
739
  "tenantIdGUID": "<tenantId>",
740
740
  "fedCred": {
741
- "issuer": "<https://FQDN-scimgateway/oauth>",
741
+ "issuer": "<https://FQDN-scimgateway>",
742
742
  "subject": "<entra id application object id - client id>",
743
743
  "name": "<entra id federated credentials unique name>"
744
744
  }
745
745
  }
746
746
  }
747
747
  }
748
- // Note: Federated credentials defined for the application in Entra ID must match the corresponding `issuer`, `subject`, and `name`
749
- // example issuer: "https://scimgateway.my-company.com/oauth" and must be reachable from the internet
750
- // exampole name: "plugin-entra-id"
748
+ // Note, fedCred configuration must match corresponding configuration in Entra ID Application - Certificates & Secrets - Federated credentials - scenario "Other issuer"
749
+ // example issuer: "https://scimgateway.my-company.com" note, this scimgateway base URL must be reachable from the internet
750
+ // example name: "plugin-entra-id"
751
751
 
752
752
 
753
753
  Example using general OAuth:
@@ -1491,6 +1491,23 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1491
1491
 
1492
1492
  ## Change log
1493
1493
 
1494
+ ### v5.5.2
1495
+
1496
+ [Improved]
1497
+
1498
+ - Entra ID Federated Identity Credentials introduced in v5.5.0, the issuer configuration should be scimgateway base URL
1499
+ old: `"issuer": "<https://FQDN-scimgateway>/oauth"`
1500
+ new: `"issuer": "<https://FQDN-scimgateway>"`
1501
+
1502
+ Change log v5.5.0 have been corrected with the new issuer having base URL only
1503
+
1504
+
1505
+ ### v5.5.1
1506
+
1507
+ [Fixed]
1508
+
1509
+ - 401 Unauthorized response did include scim-formatted error message when using `helper-rest` and authentication `PassThrough`. 401 should not include scim-formatted error message
1510
+
1494
1511
  ### v5.5.0
1495
1512
 
1496
1513
  [Improved]
@@ -1504,7 +1521,7 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1504
1521
  "options": {
1505
1522
  "tenantIdGUID": "<Entra ID tenantIdGUID",
1506
1523
  "fedCred": {
1507
- "issuer": "<https://FQDN-scimgateway/oauth>",
1524
+ "issuer": "<https://FQDN-scimgateway>",
1508
1525
  "subject": "<entra id application object id - client id>",
1509
1526
  "name": "<entra id federated credentials unique name>"
1510
1527
  }
@@ -1518,18 +1535,17 @@ MIT © [Jarle Elshaug](https://www.elshaug.xyz)
1518
1535
  "options": {
1519
1536
  "tenantIdGUID": "11111111-2222-3333-4444-555555555555",
1520
1537
  "fedCred": {
1521
- "issuer": "https://scimgateway.my-company.com/oauth>",
1538
+ "issuer": "https://scimgateway.my-company.com",
1522
1539
  "subject": "99999999-8888-7777-6666-555555555555",
1523
1540
  "name": "plugin-entra-id"
1524
1541
  }
1525
1542
  }
1526
1543
  }
1527
1544
 
1528
- Note: Federated credentials defined for the application in Entra ID must match the corresponding `issuer`, `subject`, and `name` values defined in the SCIM Gateway endpoint configuration. An example of this can be using `plugin-entra-id` and other plugins that interact with endpoints or applications protected by Entra ID.
1545
+ Note: Federated credentials (scenario "Other issuer") defined for the application in Entra ID must match the corresponding `issuer`, `subject`, and `name` values defined in the SCIM Gateway endpoint configuration. An example of this can be using `plugin-entra-id` and other plugins that interact with endpoints or applications protected by Entra ID.
1529
1546
 
1530
1547
  Also note: SCIM Gateway must be reachable from the internet (as defined by the `issuer` URL). This requires allowing inbound internet communication — or alternatively, Azure Relay can be used for outbound-only communication.
1531
1548
 
1532
-
1533
1549
  ### v5.4.4
1534
1550
 
1535
1551
  [Improved]
@@ -154,27 +154,9 @@ export class HelperRest {
154
154
 
155
155
  if (tenantIdGUID) { // Microsoft Entra ID
156
156
  if (this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer) { // federated credentials
157
- const name = JSON.stringify(this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.name) // ensure not using none valid json key
158
- if (!this.scimgateway.jwk) this.scimgateway.jwk = {}
159
- if (!this.scimgateway.jwk[baseEntity]) this.scimgateway.jwk[baseEntity] = {}
160
- if (!this.scimgateway.jwk[baseEntity][name]) {
161
- const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
162
- this.scimgateway.jwk[baseEntity][name] = { publicKey, privateKey }
163
- const ttl = 5 * 60 // 5 minutes
164
- ;(async () => {
165
- // rotate - delete JWK after 5 minutes, will be regenerated on next token request
166
- // entra id only lookup well-known uri and corresponding jwks_uri on token request validation if kid not found in entra cached JWKS
167
- setTimeout(async () => {
168
- delete this.scimgateway.jwk[baseEntity][name]
169
- }, ttl * 1000)
170
- })()
171
- }
172
-
173
- this.scimgateway.jwk[baseEntity].issuer = this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer // updates .well-known
174
-
175
157
  const now = Date.now()
176
158
  const jwtPayload: jose.JWTPayload = {
177
- iss: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer, // entra id federated credentials issuer e.g. https://scimgateway.my-company.com/oauth
159
+ iss: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer, // entra id federated credentials issuer - scimgateway base URL, e.g. https://scimgateway.my-company.com
178
160
  sub: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.subject, // entra id application object id - client id
179
161
  name: this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.name, // entra id federated credentials unique name e.g. plugin-entra-id
180
162
  aud: 'api://AzureADTokenExchange', // entra id federated credentials audience
@@ -188,7 +170,8 @@ export class HelperRest {
188
170
  ...jwtPayload,
189
171
  }
190
172
 
191
- const jwk = await jose.exportJWK(this.scimgateway.jwk[baseEntity][name].publicKey)
173
+ const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
174
+ const jwk = await jose.exportJWK(publicKey)
192
175
  const kid = createHash('sha256') // kid required for JWKS
193
176
  .update(JSON.stringify(jwk))
194
177
  .digest('base64url')
@@ -206,8 +189,22 @@ export class HelperRest {
206
189
  client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
207
190
  client_assertion: await new jose.SignJWT(jwtClaims)
208
191
  .setProtectedHeader(jwtHeaders)
209
- .sign(this.scimgateway.jwk[baseEntity][name].privateKey),
192
+ .sign(privateKey),
193
+ }
194
+
195
+ // keep JWK for 5 minutes, will be regenerated on next token request
196
+ // entra id only lookup well-known uri and corresponding jwks_uri on token request validation if kid not found in entra cached JWKS
197
+ if (!this.scimgateway.jwk) this.scimgateway.jwk = {}
198
+ if (!this.scimgateway.jwk[kid]) {
199
+ this.scimgateway.jwk[kid] = { publicKey, privateKey }
200
+ const ttl = 5 * 60
201
+ ;(async () => {
202
+ setTimeout(async () => {
203
+ delete this.scimgateway.jwk[kid]
204
+ }, ttl * 1000)
205
+ })()
210
206
  }
207
+ this.scimgateway.jwk.issuer = this.config_entity[baseEntity]?.connection?.auth?.options?.fedCred?.issuer // all baseEntities should use same issuer
211
208
  } else { // standard certificate
212
209
  if (!this.config_entity[baseEntity]?.connection?.auth?.options?.tls?.cert) {
213
210
  throw new Error(`auth type '${this.config_entity[baseEntity]?.connection?.auth?.type}' - missing options.tls.key/cert configuration`)
@@ -918,13 +915,13 @@ export class HelperRest {
918
915
  * }
919
916
  *
920
917
  * // Microsoft Entra ID - using Federated credentials
921
- * // Note, fedCred configuration must match corresponding configuration in Entra ID Application - Federation credentials
918
+ * // Note, fedCred configuration must match corresponding configuration in Entra ID Application - Certificates & Secrets - Federated credentials - scenario "Other issuer"
922
919
  * {
923
920
  * "type": "oauthJwtBearer",
924
921
  * "options": {
925
922
  * "tenantIdGUID": "<Entra ID tenantIdGUID",
926
923
  * "fedCred": {
927
- * "issuer": "<https://FQDN-scimgateway/oauth>", // e.g. https://scimgateway.my-company.com/oauth
924
+ * "issuer": "<https://FQDN-scimgateway", // scimgateway base URL, e.g. https://scimgateway.my-company.com
928
925
  * "subject": "<entra id application object id - client id>",
929
926
  * "name": "<entra id federated credentials unique name>" // e.g. plugin-entra-id
930
927
  * }
@@ -486,7 +486,7 @@ export class ScimGateway {
486
486
  getMethod: 'getAppRoles',
487
487
  }
488
488
  /** handlers supported url paths */
489
- const handlers = ['users', 'groups', 'bulk', 'serviceplans', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'oauth', 'logger']
489
+ const handlers = ['users', 'groups', 'bulk', 'serviceplans', 'approles', 'api', 'schemas', 'resourcetypes', 'serviceproviderconfig', 'serviceproviderconfigs', 'oauth', '.well-known', 'logger']
490
490
 
491
491
  try {
492
492
  if (!fs.existsSync(configDir + '/wsdls')) fs.mkdirSync(configDir + '/wsdls')
@@ -586,7 +586,7 @@ export class ScimGateway {
586
586
  }
587
587
 
588
588
  if (ctx.response.status && (ctx.response.status < 200 || ctx.response.status > 299)) {
589
- if (ctx.response.status === 401 && !ctx.request.headers.get('authorization')) {
589
+ if (ctx.response.status === 401 && !ctx.request.headers.has('authorization')) {
590
590
  logger.warn(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
591
591
  } else if (ctx.response.status === 404) {
592
592
  logger.warn(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] ${ellapsed} ${ctx.ip} ${userName} ${ctx.response.status} ${ctx.request.method} ${ctx.request.url} Inbound=${JSON.stringify(ctx.request.body)} Outbound=${outbound}`, { baseEntity: ctx?.routeObj?.baseEntity })
@@ -597,11 +597,10 @@ export class ScimGateway {
597
597
  }
598
598
 
599
599
  // start auth methods - used by auth
600
- const basic = async (baseEntity: string, method: string, authType: string, authToken: string, path: string): Promise<boolean> => {
600
+ const basic = async (baseEntity: string, method: string, authType: string, authToken: string): Promise<boolean> => {
601
601
  return await new Promise((resolve, reject) => { // basic auth
602
602
  if (!found.Basic) return resolve(false)
603
603
  if (authType !== 'Basic' || !authToken) return resolve(false)
604
- if (found.PassThrough && this.authPassThroughAllowed && !path.endsWith('/logger')) return resolve(false) // Auth PassThrough browser logon dialog support
605
604
  const [userName, userPassword] = (Buffer.from(authToken, 'base64').toString() || '').split(':')
606
605
  if (!userName || !userPassword) return resolve(false)
607
606
  const arr = this.config.scimgateway.auth.basic
@@ -819,7 +818,7 @@ export class ScimGateway {
819
818
  const obj = this.config.scimgateway.auth.passThrough
820
819
  if (obj.baseEntities) {
821
820
  if (Array.isArray(obj.baseEntities) && obj.baseEntities.length > 0) {
822
- if (!baseEntity || !obj.baseEntities.includes(baseEntity)) throw new Error(`baseEntity=${baseEntity} not allowed for passThrough according to passThrough configuration baseEntitites=${obj.baseEntities}`)
821
+ if (!obj.baseEntities.includes(baseEntity)) throw new Error(`baseEntity=${baseEntity} not allowed for passThrough according to passThrough configuration baseEntitites=${obj.baseEntities}`)
823
822
  }
824
823
  }
825
824
  if (obj.readOnly === true && method !== 'GET') throw new Error('only allowing readOnly for passThrough according to passThrough configuration readOnly=true')
@@ -834,7 +833,7 @@ export class ScimGateway {
834
833
  try {
835
834
  // authenticate
836
835
  arrResolve = await Promise.all([
837
- basic(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken, ctx.path),
836
+ basic(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
838
837
  bearerToken(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
839
838
  bearerJwtAzure(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
840
839
  bearerJwt(ctx.routeObj.baseEntity, ctx.request.method, authType, authToken),
@@ -973,53 +972,44 @@ export class ScimGateway {
973
972
  )
974
973
  }
975
974
 
976
- // oauth well-known: /oauth/.well-known/openid-configuration
975
+ // oauth well-known: /.well-known/openid-configuration
977
976
  // this.jwk is managed by helper-rest oauthJwtBearer - Entra ID Federated Identity
978
- // {issuer: <scimgateway-baseUrl>/oauth, <federated-identity-unique-name>: {privateKey, publicKey}}
979
- // example issuer: https://scimgateway.my-company.com/oauth
977
+ // { issuer: <scimgateway-baseUrl>, kid: { privateKey, publicKey } }
978
+ // example issuer: https://scimgateway.my-company.com
980
979
  const getHandlerOauthWellKnown = async (ctx: Context) => {
981
- const baseEntity = ctx.routeObj.baseEntity
982
- logger.debug(`${gwName}[${pluginName}][${baseEntity}] [oauth] .well-known request`)
983
-
984
- if (!this.jwk || !this.jwk[baseEntity] || !this.jwk[baseEntity].issuer) {
980
+ logger.debug(`${gwName}[${pluginName}] [oauth] .well-known request`)
981
+ if (!this.jwk || (Object.keys(this.jwk).length < 1)) {
985
982
  ctx.response.body = '{}'
986
983
  ctx.response.status = 200
987
984
  return ctx
988
985
  }
989
-
990
- const issuer = this.jwk[baseEntity].issuer // dynamic set by helper-rest oauthJwtBearer e.g. 'https://scimgateway.my-company.com/oauth'
986
+ const issuer = this.jwk.issuer
991
987
  let body = {
992
988
  issuer,
993
- jwks_uri: issuer + '/certs',
989
+ jwks_uri: issuer + '/.well-known/jwks.json',
994
990
  }
995
991
  ctx.response.body = JSON.stringify(body)
996
992
  ctx.response.status = 200
997
993
  }
998
994
 
999
- // oauth JWKS: /oauth/certs
995
+ // oauth JWKS: /.well-known/jwks.json
1000
996
  // this.jwk is managed by helper-rest oauthJwtBearer - Entra ID Federated Identity
1001
- // {issuer: <scimgateway-baseUrl>/oauth, <federated-identity-unique-name>: {privateKey, publicKey}}
1002
- const getHandlerOauthCerts = async (ctx: Context) => {
1003
- const baseEntity = ctx.routeObj.baseEntity
1004
- logger.debug(`${gwName}[${pluginName}][${baseEntity}] [oauth] jwks_uri certs request`)
1005
-
1006
- if (!this.jwk || !this.jwk[baseEntity]) {
997
+ // { issuer: <scimgateway-baseUrl>, kid: { privateKey, publicKey } }
998
+ const getHandlerOauthJwks = async (ctx: Context) => {
999
+ logger.debug(`${gwName}[${pluginName}] [oauth] jwks_uri request`)
1000
+ if (!this.jwk || (Object.keys(this.jwk).length < 1)) {
1007
1001
  ctx.response.body = '{"keys":[]}'
1008
1002
  ctx.response.status = 200
1009
1003
  return ctx
1010
1004
  }
1011
-
1012
1005
  const keys: Array<Record<string, any>> = []
1013
- for (const name in this.jwk[baseEntity]) {
1014
- const keyObj = this.jwk[baseEntity][name]
1015
- if (typeof keyObj !== 'object' || keyObj === null) continue // skip issuer
1016
- const jwk = await jose.exportJWK(this.jwk[baseEntity][name].publicKey)
1017
- jwk.kid = createHash('sha256') // needed for JWKS
1018
- .update(JSON.stringify(jwk))
1019
- .digest('base64url')
1006
+ for (const kid in this.jwk) {
1007
+ const keyObj = this.jwk[kid]
1008
+ if (typeof keyObj !== 'object' || keyObj === null) continue
1009
+ const jwk = await jose.exportJWK(this.jwk[kid].publicKey)
1010
+ jwk.kid = kid // needed for JWKS
1020
1011
  keys.push(jwk)
1021
1012
  }
1022
-
1023
1013
  let body = {
1024
1014
  keys,
1025
1015
  }
@@ -1100,11 +1090,12 @@ export class ScimGateway {
1100
1090
  if (pwErrCount < 3) {
1101
1091
  pwErrCount += 1
1102
1092
  } else { // delay brute force attempts
1103
- logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] ${ctx.origin + ctx.path} ${errDescr} => delaying response with 2 minutes to prevent brute force`)
1093
+ const delay = (this.config.scimgateway.idleTimeout || 120) - 5
1094
+ logger.error(`${gwName}[${pluginName}][${ctx?.routeObj?.baseEntity}] [oauth] ${ctx.origin + ctx.path} ${errDescr} => delaying response with ${delay} seconds to prevent brute force`)
1104
1095
  await new Promise((resolve) => {
1105
1096
  setTimeout(() => {
1106
1097
  resolve(ctx)
1107
- }, 1000 * 60 * 2)
1098
+ }, 1000 * delay)
1108
1099
  })
1109
1100
  ctx.response.status = 401
1110
1101
  return
@@ -2673,13 +2664,13 @@ export class ScimGateway {
2673
2664
  return ctx
2674
2665
  }
2675
2666
  }
2676
- if (ctx.request.method === 'GET' && ctx.path.endsWith('/oauth/.well-known/openid-configuration')) {
2667
+ if (ctx.request.method === 'GET' && ctx.path.endsWith('/.well-known/openid-configuration')) {
2677
2668
  await getHandlerOauthWellKnown(ctx)
2678
2669
  if (!ctx.response.status) ctx.response.status = 404
2679
2670
  return ctx
2680
2671
  }
2681
- if (ctx.request.method === 'GET' && ctx.path.endsWith('/oauth/certs')) {
2682
- await getHandlerOauthCerts(ctx)
2672
+ if (ctx.request.method === 'GET' && ctx.path.endsWith('/.well-known/jwks.json')) {
2673
+ await getHandlerOauthJwks(ctx)
2683
2674
  if (!ctx.response.status) ctx.response.status = 404
2684
2675
  return ctx
2685
2676
  }
@@ -2766,6 +2757,14 @@ export class ScimGateway {
2766
2757
  if (!ctx.response.body) {
2767
2758
  ctx.response.body = 'Unauthorized'
2768
2759
  ctx.response.headers.set('content-type', 'text/plain')
2760
+ } else {
2761
+ try {
2762
+ const b = JSON.parse(ctx.response.body)
2763
+ if (b.schemas) { // 401 - do not return scim formatted error message e.g., using PassThrough
2764
+ ctx.response.body = 'Unauthorized'
2765
+ ctx.response.headers.set('content-type', 'text/plain')
2766
+ }
2767
+ } catch (err) { void 0 }
2769
2768
  }
2770
2769
  break
2771
2770
  case 403:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scimgateway",
3
- "version": "5.5.0",
3
+ "version": "5.5.2",
4
4
  "type": "module",
5
5
  "description": "Using SCIM protocol as a gateway for user provisioning to other endpoints",
6
6
  "author": "Jarle Elshaug <jarle.elshaug@gmail.com> (https://elshaug.xyz)",