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 +24 -8
- package/lib/helper-rest.ts +20 -23
- package/lib/scimgateway.ts +36 -37
- package/package.json +1 -1
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
|
|
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
|
-
|
|
749
|
-
// example issuer: "https://scimgateway.my-company.com
|
|
750
|
-
//
|
|
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
|
|
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
|
|
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]
|
package/lib/helper-rest.ts
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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 -
|
|
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
|
|
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
|
* }
|
package/lib/scimgateway.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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 (!
|
|
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
|
|
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:
|
|
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
|
|
979
|
-
// example issuer: https://scimgateway.my-company.com
|
|
977
|
+
// { issuer: <scimgateway-baseUrl>, kid: { privateKey, publicKey } }
|
|
978
|
+
// example issuer: https://scimgateway.my-company.com
|
|
980
979
|
const getHandlerOauthWellKnown = async (ctx: Context) => {
|
|
981
|
-
|
|
982
|
-
|
|
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 + '/
|
|
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: /
|
|
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
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
|
1014
|
-
const keyObj = this.jwk[
|
|
1015
|
-
if (typeof keyObj !== 'object' || keyObj === null) continue
|
|
1016
|
-
const jwk = await jose.exportJWK(this.jwk[
|
|
1017
|
-
jwk.kid =
|
|
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
|
-
|
|
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 *
|
|
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('
|
|
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('/
|
|
2682
|
-
await
|
|
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