@backstage/plugin-auth-backend 0.26.1-next.0 → 0.27.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/CHANGELOG.md +58 -0
- package/config.d.ts +59 -4
- package/dist/authPlugin.cjs.js +14 -1
- package/dist/authPlugin.cjs.js.map +1 -1
- package/dist/database/OfflineSessionDatabase.cjs.js +136 -0
- package/dist/database/OfflineSessionDatabase.cjs.js.map +1 -0
- package/dist/lib/refreshToken.cjs.js +60 -0
- package/dist/lib/refreshToken.cjs.js.map +1 -0
- package/dist/service/CimdClient.cjs.js +133 -0
- package/dist/service/CimdClient.cjs.js.map +1 -0
- package/dist/service/OfflineAccessService.cjs.js +177 -0
- package/dist/service/OfflineAccessService.cjs.js.map +1 -0
- package/dist/service/OidcError.cjs.js +57 -0
- package/dist/service/OidcError.cjs.js.map +1 -0
- package/dist/service/OidcRouter.cjs.js +219 -148
- package/dist/service/OidcRouter.cjs.js.map +1 -1
- package/dist/service/OidcService.cjs.js +174 -60
- package/dist/service/OidcService.cjs.js.map +1 -1
- package/dist/service/readTokenExpiration.cjs.js +9 -26
- package/dist/service/readTokenExpiration.cjs.js.map +1 -1
- package/dist/service/router.cjs.js +19 -27
- package/dist/service/router.cjs.js.map +1 -1
- package/migrations/20251020000000_offline_sessions.js +78 -0
- package/migrations/20251217120000_drop_oidc_clients_fk.js +44 -0
- package/package.json +18 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# @backstage/plugin-auth-backend
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 31de2c9: Added experimental support for Client ID Metadata Documents (CIMD).
|
|
8
|
+
|
|
9
|
+
This allows Backstage to act as an OAuth 2.0 authorization server that supports the [IETF Client ID Metadata Document draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). External OAuth clients can use HTTPS URLs as their `client_id`, and Backstage will fetch metadata from those URLs to validate the client.
|
|
10
|
+
|
|
11
|
+
**Configuration example:**
|
|
12
|
+
|
|
13
|
+
```yaml
|
|
14
|
+
auth:
|
|
15
|
+
experimentalClientIdMetadataDocuments:
|
|
16
|
+
enabled: true
|
|
17
|
+
# Optional: restrict which `client_id` URLs are allowed (defaults to ['*'])
|
|
18
|
+
allowedClientIdPatterns:
|
|
19
|
+
- 'https://example.com/*'
|
|
20
|
+
- 'https://*.trusted-domain.com/*'
|
|
21
|
+
# Optional: restrict which redirect URIs are allowed (defaults to ['*'])
|
|
22
|
+
allowedRedirectUriPatterns:
|
|
23
|
+
- 'http://localhost:*'
|
|
24
|
+
- 'https://*.example.com/*'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Clients using CIMD must host a JSON metadata document at their `client_id` URL containing at minimum:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"client_id": "https://example.com/.well-known/oauth-client/my-app",
|
|
32
|
+
"client_name": "My Application",
|
|
33
|
+
"redirect_uris": ["http://localhost:8080/callback"],
|
|
34
|
+
"token_endpoint_auth_method": "none"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- d0786b9: Added experimental support for refresh tokens via the `auth.experimentalRefreshToken.enabled` configuration option. When enabled, clients can request the `offline_access` scope to receive refresh tokens that can be used to obtain new access tokens without re-authentication.
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- 7455dae: Use node prefix on native imports
|
|
43
|
+
- Updated dependencies
|
|
44
|
+
- @backstage/plugin-catalog-node@2.0.0
|
|
45
|
+
- @backstage/backend-plugin-api@1.7.0
|
|
46
|
+
- @backstage/plugin-auth-node@0.6.13
|
|
47
|
+
|
|
48
|
+
## 0.27.0-next.1
|
|
49
|
+
|
|
50
|
+
### Minor Changes
|
|
51
|
+
|
|
52
|
+
- d0786b9: Added experimental support for refresh tokens via the `auth.experimentalRefreshToken.enabled` configuration option. When enabled, clients can request the `offline_access` scope to receive refresh tokens that can be used to obtain new access tokens without re-authentication.
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- Updated dependencies
|
|
57
|
+
- @backstage/plugin-catalog-node@2.0.0-next.1
|
|
58
|
+
- @backstage/backend-plugin-api@1.7.0-next.1
|
|
59
|
+
- @backstage/plugin-auth-node@0.6.13-next.1
|
|
60
|
+
|
|
3
61
|
## 0.26.1-next.0
|
|
4
62
|
|
|
5
63
|
### Patch Changes
|
package/config.d.ts
CHANGED
|
@@ -95,10 +95,40 @@ export interface Config {
|
|
|
95
95
|
|
|
96
96
|
/**
|
|
97
97
|
* The backstage token expiration.
|
|
98
|
-
* Defaults to 1 hour (3600s). Maximum allowed is 24 hours.
|
|
99
98
|
*/
|
|
100
99
|
backstageTokenExpiration?: HumanDuration | string;
|
|
101
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Configuration for refresh tokens (offline access)
|
|
103
|
+
* @visibility backend
|
|
104
|
+
*/
|
|
105
|
+
experimentalRefreshToken?: {
|
|
106
|
+
/**
|
|
107
|
+
* Whether to enable refresh tokens
|
|
108
|
+
* @default false
|
|
109
|
+
* @visibility backend
|
|
110
|
+
*/
|
|
111
|
+
enabled?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Token lifetime before rotation required
|
|
114
|
+
* @default '30 days'
|
|
115
|
+
* @visibility backend
|
|
116
|
+
*/
|
|
117
|
+
tokenLifetime?: HumanDuration | string;
|
|
118
|
+
/**
|
|
119
|
+
* Maximum session lifetime across all rotations
|
|
120
|
+
* @default '1 year'
|
|
121
|
+
* @visibility backend
|
|
122
|
+
*/
|
|
123
|
+
maxRotationLifetime?: HumanDuration | string;
|
|
124
|
+
/**
|
|
125
|
+
* Maximum number of refresh tokens per user
|
|
126
|
+
* @default 20
|
|
127
|
+
* @visibility backend
|
|
128
|
+
*/
|
|
129
|
+
maxTokensPerUser?: number;
|
|
130
|
+
};
|
|
131
|
+
|
|
102
132
|
/**
|
|
103
133
|
* Additional app origins to allow for authenticating
|
|
104
134
|
*/
|
|
@@ -119,12 +149,37 @@ export interface Config {
|
|
|
119
149
|
* dynamic client registration. Defaults to '[*]' which allows any redirect URI.
|
|
120
150
|
*/
|
|
121
151
|
allowedRedirectUriPatterns?: string[];
|
|
152
|
+
};
|
|
122
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Configuration for Client ID Metadata Documents (CIMD)
|
|
156
|
+
*
|
|
157
|
+
* @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/
|
|
158
|
+
*/
|
|
159
|
+
experimentalClientIdMetadataDocuments?: {
|
|
123
160
|
/**
|
|
124
|
-
*
|
|
125
|
-
* Defaults to
|
|
161
|
+
* Whether to enable Client ID Metadata Documents support
|
|
162
|
+
* Defaults to false
|
|
126
163
|
*/
|
|
127
|
-
|
|
164
|
+
enabled?: boolean;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A list of allowed URI patterns for client_id URLs.
|
|
168
|
+
* Uses glob-style pattern matching where `*` matches any characters.
|
|
169
|
+
* Defaults to ['*'] which allows any client_id URL.
|
|
170
|
+
*
|
|
171
|
+
* @example ['https://example.com/*', 'https://*.trusted-domain.com/*']
|
|
172
|
+
*/
|
|
173
|
+
allowedClientIdPatterns?: string[];
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A list of allowed URI patterns for redirect URIs.
|
|
177
|
+
* Uses glob-style pattern matching where `*` matches any characters.
|
|
178
|
+
* Defaults to ['*'] which allows any redirect URI.
|
|
179
|
+
*
|
|
180
|
+
* @example ['http://localhost:*', 'http://127.0.0.1:*\/callback']
|
|
181
|
+
*/
|
|
182
|
+
allowedRedirectUriPatterns?: string[];
|
|
128
183
|
};
|
|
129
184
|
};
|
|
130
185
|
}
|
package/dist/authPlugin.cjs.js
CHANGED
|
@@ -4,6 +4,7 @@ var backendPluginApi = require('@backstage/backend-plugin-api');
|
|
|
4
4
|
var pluginAuthNode = require('@backstage/plugin-auth-node');
|
|
5
5
|
var pluginCatalogNode = require('@backstage/plugin-catalog-node');
|
|
6
6
|
var router = require('./service/router.cjs.js');
|
|
7
|
+
var OfflineAccessService = require('./service/OfflineAccessService.cjs.js');
|
|
7
8
|
|
|
8
9
|
const authPlugin = backendPluginApi.createBackendPlugin({
|
|
9
10
|
pluginId: "auth",
|
|
@@ -37,6 +38,7 @@ const authPlugin = backendPluginApi.createBackendPlugin({
|
|
|
37
38
|
discovery: backendPluginApi.coreServices.discovery,
|
|
38
39
|
auth: backendPluginApi.coreServices.auth,
|
|
39
40
|
httpAuth: backendPluginApi.coreServices.httpAuth,
|
|
41
|
+
lifecycle: backendPluginApi.coreServices.lifecycle,
|
|
40
42
|
catalog: pluginCatalogNode.catalogServiceRef
|
|
41
43
|
},
|
|
42
44
|
async init({
|
|
@@ -47,8 +49,18 @@ const authPlugin = backendPluginApi.createBackendPlugin({
|
|
|
47
49
|
discovery,
|
|
48
50
|
auth,
|
|
49
51
|
httpAuth,
|
|
52
|
+
lifecycle,
|
|
50
53
|
catalog
|
|
51
54
|
}) {
|
|
55
|
+
const refreshTokensEnabled = config.getOptionalBoolean(
|
|
56
|
+
"auth.experimentalRefreshToken.enabled"
|
|
57
|
+
);
|
|
58
|
+
const offlineAccess = refreshTokensEnabled ? await OfflineAccessService.OfflineAccessService.create({
|
|
59
|
+
config,
|
|
60
|
+
database,
|
|
61
|
+
logger,
|
|
62
|
+
lifecycle
|
|
63
|
+
}) : void 0;
|
|
52
64
|
const router$1 = await router.createRouter({
|
|
53
65
|
logger,
|
|
54
66
|
config,
|
|
@@ -58,7 +70,8 @@ const authPlugin = backendPluginApi.createBackendPlugin({
|
|
|
58
70
|
catalog,
|
|
59
71
|
providerFactories: Object.fromEntries(providers),
|
|
60
72
|
ownershipResolver,
|
|
61
|
-
httpAuth
|
|
73
|
+
httpAuth,
|
|
74
|
+
offlineAccess
|
|
62
75
|
});
|
|
63
76
|
httpRouter.addAuthPolicy({
|
|
64
77
|
path: "/",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authPlugin.cjs.js","sources":["../src/authPlugin.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport {\n authOwnershipResolutionExtensionPoint,\n AuthOwnershipResolver,\n AuthProviderFactory,\n authProvidersExtensionPoint,\n} from '@backstage/plugin-auth-node';\nimport { catalogServiceRef } from '@backstage/plugin-catalog-node';\nimport { createRouter } from './service/router';\n\n/**\n * Auth plugin\n *\n * @public\n */\nexport const authPlugin = createBackendPlugin({\n pluginId: 'auth',\n register(reg) {\n const providers = new Map<string, AuthProviderFactory>();\n let ownershipResolver: AuthOwnershipResolver | undefined = undefined;\n\n reg.registerExtensionPoint(authProvidersExtensionPoint, {\n registerProvider({ providerId, factory }) {\n if (providers.has(providerId)) {\n throw new Error(\n `Auth provider '${providerId}' was already registered`,\n );\n }\n providers.set(providerId, factory);\n },\n });\n\n reg.registerExtensionPoint(authOwnershipResolutionExtensionPoint, {\n setAuthOwnershipResolver(resolver) {\n if (ownershipResolver) {\n throw new Error('Auth ownership resolver is already set');\n }\n ownershipResolver = resolver;\n },\n });\n\n reg.registerInit({\n deps: {\n httpRouter: coreServices.httpRouter,\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n database: coreServices.database,\n discovery: coreServices.discovery,\n auth: coreServices.auth,\n httpAuth: coreServices.httpAuth,\n catalog: catalogServiceRef,\n },\n async init({\n httpRouter,\n logger,\n config,\n database,\n discovery,\n auth,\n httpAuth,\n catalog,\n }) {\n const router = await createRouter({\n logger,\n config,\n database,\n discovery,\n auth,\n catalog,\n providerFactories: Object.fromEntries(providers),\n ownershipResolver,\n httpAuth,\n });\n httpRouter.addAuthPolicy({\n path: '/',\n allow: 'unauthenticated',\n });\n httpRouter.use(router);\n },\n });\n },\n});\n"],"names":["createBackendPlugin","authProvidersExtensionPoint","authOwnershipResolutionExtensionPoint","coreServices","catalogServiceRef","router","createRouter"],"mappings":"
|
|
1
|
+
{"version":3,"file":"authPlugin.cjs.js","sources":["../src/authPlugin.ts"],"sourcesContent":["/*\n * Copyright 2023 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n coreServices,\n createBackendPlugin,\n} from '@backstage/backend-plugin-api';\nimport {\n authOwnershipResolutionExtensionPoint,\n AuthOwnershipResolver,\n AuthProviderFactory,\n authProvidersExtensionPoint,\n} from '@backstage/plugin-auth-node';\nimport { catalogServiceRef } from '@backstage/plugin-catalog-node';\nimport { createRouter } from './service/router';\nimport { OfflineAccessService } from './service/OfflineAccessService';\n\n/**\n * Auth plugin\n *\n * @public\n */\nexport const authPlugin = createBackendPlugin({\n pluginId: 'auth',\n register(reg) {\n const providers = new Map<string, AuthProviderFactory>();\n let ownershipResolver: AuthOwnershipResolver | undefined = undefined;\n\n reg.registerExtensionPoint(authProvidersExtensionPoint, {\n registerProvider({ providerId, factory }) {\n if (providers.has(providerId)) {\n throw new Error(\n `Auth provider '${providerId}' was already registered`,\n );\n }\n providers.set(providerId, factory);\n },\n });\n\n reg.registerExtensionPoint(authOwnershipResolutionExtensionPoint, {\n setAuthOwnershipResolver(resolver) {\n if (ownershipResolver) {\n throw new Error('Auth ownership resolver is already set');\n }\n ownershipResolver = resolver;\n },\n });\n\n reg.registerInit({\n deps: {\n httpRouter: coreServices.httpRouter,\n logger: coreServices.logger,\n config: coreServices.rootConfig,\n database: coreServices.database,\n discovery: coreServices.discovery,\n auth: coreServices.auth,\n httpAuth: coreServices.httpAuth,\n lifecycle: coreServices.lifecycle,\n catalog: catalogServiceRef,\n },\n async init({\n httpRouter,\n logger,\n config,\n database,\n discovery,\n auth,\n httpAuth,\n lifecycle,\n catalog,\n }) {\n const refreshTokensEnabled = config.getOptionalBoolean(\n 'auth.experimentalRefreshToken.enabled',\n );\n\n const offlineAccess = refreshTokensEnabled\n ? await OfflineAccessService.create({\n config,\n database,\n logger,\n lifecycle,\n })\n : undefined;\n\n const router = await createRouter({\n logger,\n config,\n database,\n discovery,\n auth,\n catalog,\n providerFactories: Object.fromEntries(providers),\n ownershipResolver,\n httpAuth,\n offlineAccess,\n });\n httpRouter.addAuthPolicy({\n path: '/',\n allow: 'unauthenticated',\n });\n httpRouter.use(router);\n },\n });\n },\n});\n"],"names":["createBackendPlugin","authProvidersExtensionPoint","authOwnershipResolutionExtensionPoint","coreServices","catalogServiceRef","OfflineAccessService","router","createRouter"],"mappings":";;;;;;;;AAmCO,MAAM,aAAaA,oCAAA,CAAoB;AAAA,EAC5C,QAAA,EAAU,MAAA;AAAA,EACV,SAAS,GAAA,EAAK;AACZ,IAAA,MAAM,SAAA,uBAAgB,GAAA,EAAiC;AACvD,IAAA,IAAI,iBAAA,GAAuD,MAAA;AAE3D,IAAA,GAAA,CAAI,uBAAuBC,0CAAA,EAA6B;AAAA,MACtD,gBAAA,CAAiB,EAAE,UAAA,EAAY,OAAA,EAAQ,EAAG;AACxC,QAAA,IAAI,SAAA,CAAU,GAAA,CAAI,UAAU,CAAA,EAAG;AAC7B,UAAA,MAAM,IAAI,KAAA;AAAA,YACR,kBAAkB,UAAU,CAAA,wBAAA;AAAA,WAC9B;AAAA,QACF;AACA,QAAA,SAAA,CAAU,GAAA,CAAI,YAAY,OAAO,CAAA;AAAA,MACnC;AAAA,KACD,CAAA;AAED,IAAA,GAAA,CAAI,uBAAuBC,oDAAA,EAAuC;AAAA,MAChE,yBAAyB,QAAA,EAAU;AACjC,QAAA,IAAI,iBAAA,EAAmB;AACrB,UAAA,MAAM,IAAI,MAAM,wCAAwC,CAAA;AAAA,QAC1D;AACA,QAAA,iBAAA,GAAoB,QAAA;AAAA,MACtB;AAAA,KACD,CAAA;AAED,IAAA,GAAA,CAAI,YAAA,CAAa;AAAA,MACf,IAAA,EAAM;AAAA,QACJ,YAAYC,6BAAA,CAAa,UAAA;AAAA,QACzB,QAAQA,6BAAA,CAAa,MAAA;AAAA,QACrB,QAAQA,6BAAA,CAAa,UAAA;AAAA,QACrB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,WAAWA,6BAAA,CAAa,SAAA;AAAA,QACxB,MAAMA,6BAAA,CAAa,IAAA;AAAA,QACnB,UAAUA,6BAAA,CAAa,QAAA;AAAA,QACvB,WAAWA,6BAAA,CAAa,SAAA;AAAA,QACxB,OAAA,EAASC;AAAA,OACX;AAAA,MACA,MAAM,IAAA,CAAK;AAAA,QACT,UAAA;AAAA,QACA,MAAA;AAAA,QACA,MAAA;AAAA,QACA,QAAA;AAAA,QACA,SAAA;AAAA,QACA,IAAA;AAAA,QACA,QAAA;AAAA,QACA,SAAA;AAAA,QACA;AAAA,OACF,EAAG;AACD,QAAA,MAAM,uBAAuB,MAAA,CAAO,kBAAA;AAAA,UAClC;AAAA,SACF;AAEA,QAAA,MAAM,aAAA,GAAgB,oBAAA,GAClB,MAAMC,yCAAA,CAAqB,MAAA,CAAO;AAAA,UAChC,MAAA;AAAA,UACA,QAAA;AAAA,UACA,MAAA;AAAA,UACA;AAAA,SACD,CAAA,GACD,MAAA;AAEJ,QAAA,MAAMC,QAAA,GAAS,MAAMC,mBAAA,CAAa;AAAA,UAChC,MAAA;AAAA,UACA,MAAA;AAAA,UACA,QAAA;AAAA,UACA,SAAA;AAAA,UACA,IAAA;AAAA,UACA,OAAA;AAAA,UACA,iBAAA,EAAmB,MAAA,CAAO,WAAA,CAAY,SAAS,CAAA;AAAA,UAC/C,iBAAA;AAAA,UACA,QAAA;AAAA,UACA;AAAA,SACD,CAAA;AACD,QAAA,UAAA,CAAW,aAAA,CAAc;AAAA,UACvB,IAAA,EAAM,GAAA;AAAA,UACN,KAAA,EAAO;AAAA,SACR,CAAA;AACD,QAAA,UAAA,CAAW,IAAID,QAAM,CAAA;AAAA,MACvB;AAAA,KACD,CAAA;AAAA,EACH;AACF,CAAC;;;;"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var luxon = require('luxon');
|
|
4
|
+
|
|
5
|
+
const TABLE_NAME = "offline_sessions";
|
|
6
|
+
class OfflineSessionDatabase {
|
|
7
|
+
#knex;
|
|
8
|
+
#tokenLifetimeSeconds;
|
|
9
|
+
#maxRotationLifetimeSeconds;
|
|
10
|
+
#maxTokensPerUser;
|
|
11
|
+
static create(options) {
|
|
12
|
+
return new OfflineSessionDatabase(
|
|
13
|
+
options.knex,
|
|
14
|
+
options.tokenLifetimeSeconds,
|
|
15
|
+
options.maxRotationLifetimeSeconds,
|
|
16
|
+
options.maxTokensPerUser
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
constructor(knex, tokenLifetimeSeconds, maxRotationLifetimeSeconds, maxTokensPerUser) {
|
|
20
|
+
this.#knex = knex;
|
|
21
|
+
this.#tokenLifetimeSeconds = tokenLifetimeSeconds;
|
|
22
|
+
this.#maxRotationLifetimeSeconds = maxRotationLifetimeSeconds;
|
|
23
|
+
this.#maxTokensPerUser = maxTokensPerUser;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Create a new offline session
|
|
27
|
+
* Automatically enforces per-user and per-client limits
|
|
28
|
+
*/
|
|
29
|
+
async createSession(options) {
|
|
30
|
+
const { id, userEntityRef, oidcClientId, tokenHash } = options;
|
|
31
|
+
await this.#knex.transaction(async (trx) => {
|
|
32
|
+
if (oidcClientId) {
|
|
33
|
+
await trx(TABLE_NAME).where("oidc_client_id", oidcClientId).andWhere("user_entity_ref", userEntityRef).delete();
|
|
34
|
+
}
|
|
35
|
+
const userSessions = await trx(TABLE_NAME).where("user_entity_ref", userEntityRef).select("id", "last_used_at").orderBy("last_used_at", "asc").orderBy("id", "asc");
|
|
36
|
+
const tokensToDelete = userSessions.length - (this.#maxTokensPerUser - 1);
|
|
37
|
+
if (tokensToDelete > 0) {
|
|
38
|
+
const idsToDelete = userSessions.slice(0, tokensToDelete).map((s) => s.id);
|
|
39
|
+
await trx(TABLE_NAME).whereIn("id", idsToDelete).delete();
|
|
40
|
+
}
|
|
41
|
+
await trx(TABLE_NAME).insert({
|
|
42
|
+
id,
|
|
43
|
+
user_entity_ref: userEntityRef,
|
|
44
|
+
oidc_client_id: oidcClientId ?? null,
|
|
45
|
+
token_hash: tokenHash,
|
|
46
|
+
created_at: trx.fn.now(),
|
|
47
|
+
last_used_at: trx.fn.now()
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
const session = await this.getSessionById(id);
|
|
51
|
+
if (!session) {
|
|
52
|
+
throw new Error("Failed to create session");
|
|
53
|
+
}
|
|
54
|
+
return session;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Get a session by its ID without expiration filtering
|
|
58
|
+
* Used for internal operations that need to check expiration explicitly
|
|
59
|
+
*/
|
|
60
|
+
async getSessionById(id) {
|
|
61
|
+
const row = await this.#knex(TABLE_NAME).where("id", id).first();
|
|
62
|
+
if (!row) {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
return this.#mapRow(row);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get a session and rotate its token atomically
|
|
69
|
+
* This prevents race conditions where multiple refresh requests use the same token
|
|
70
|
+
*/
|
|
71
|
+
async getAndRotateToken(id, expectedTokenHash, newTokenHash) {
|
|
72
|
+
const now = luxon.DateTime.utc();
|
|
73
|
+
const tokenLifetimeThreshold = now.minus({ seconds: this.#tokenLifetimeSeconds }).toJSDate();
|
|
74
|
+
const maxRotationThreshold = now.minus({ seconds: this.#maxRotationLifetimeSeconds }).toJSDate();
|
|
75
|
+
return await this.#knex.transaction(async (trx) => {
|
|
76
|
+
const row = await trx(TABLE_NAME).where("id", id).where("token_hash", expectedTokenHash).where("last_used_at", ">=", tokenLifetimeThreshold).where("created_at", ">=", maxRotationThreshold).forUpdate().first();
|
|
77
|
+
if (!row) {
|
|
78
|
+
return void 0;
|
|
79
|
+
}
|
|
80
|
+
await trx(TABLE_NAME).where("id", id).update({
|
|
81
|
+
token_hash: newTokenHash,
|
|
82
|
+
last_used_at: trx.fn.now()
|
|
83
|
+
});
|
|
84
|
+
return this.#mapRow(row);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Delete a session by ID
|
|
89
|
+
*/
|
|
90
|
+
async deleteSession(id) {
|
|
91
|
+
return await this.#knex(TABLE_NAME).where("id", id).delete();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Delete all sessions for a user entity ref
|
|
95
|
+
*/
|
|
96
|
+
async deleteSessionsByUserEntityRef(userEntityRef) {
|
|
97
|
+
return await this.#knex(TABLE_NAME).where("user_entity_ref", userEntityRef).delete();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Cleanup expired sessions based on both time windows
|
|
101
|
+
* - Short window: last_used_at + tokenLifetime
|
|
102
|
+
* - Long window: created_at + maxRotationLifetime
|
|
103
|
+
*/
|
|
104
|
+
async cleanupExpiredSessions() {
|
|
105
|
+
const now = luxon.DateTime.utc();
|
|
106
|
+
const tokenLifetimeThreshold = now.minus({ seconds: this.#tokenLifetimeSeconds }).toJSDate();
|
|
107
|
+
const maxRotationThreshold = now.minus({ seconds: this.#maxRotationLifetimeSeconds }).toJSDate();
|
|
108
|
+
return await this.#knex(TABLE_NAME).where("last_used_at", "<", tokenLifetimeThreshold).orWhere("created_at", "<", maxRotationThreshold).delete();
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Check if a session is expired based on both time windows
|
|
112
|
+
*/
|
|
113
|
+
isSessionExpired(session) {
|
|
114
|
+
const now = luxon.DateTime.utc();
|
|
115
|
+
const lastUsedExpiry = luxon.DateTime.fromJSDate(session.lastUsedAt).plus({
|
|
116
|
+
seconds: this.#tokenLifetimeSeconds
|
|
117
|
+
});
|
|
118
|
+
const createdExpiry = luxon.DateTime.fromJSDate(session.createdAt).plus({
|
|
119
|
+
seconds: this.#maxRotationLifetimeSeconds
|
|
120
|
+
});
|
|
121
|
+
return now > lastUsedExpiry || now > createdExpiry;
|
|
122
|
+
}
|
|
123
|
+
#mapRow(row) {
|
|
124
|
+
return {
|
|
125
|
+
id: row.id,
|
|
126
|
+
userEntityRef: row.user_entity_ref,
|
|
127
|
+
oidcClientId: row.oidc_client_id,
|
|
128
|
+
tokenHash: row.token_hash,
|
|
129
|
+
createdAt: new Date(row.created_at),
|
|
130
|
+
lastUsedAt: new Date(row.last_used_at)
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
exports.OfflineSessionDatabase = OfflineSessionDatabase;
|
|
136
|
+
//# sourceMappingURL=OfflineSessionDatabase.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OfflineSessionDatabase.cjs.js","sources":["../../src/database/OfflineSessionDatabase.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Knex } from 'knex';\nimport { DateTime } from 'luxon';\n\nconst TABLE_NAME = 'offline_sessions';\n\ntype DbOfflineSessionRow = {\n id: string;\n user_entity_ref: string;\n oidc_client_id: string | null;\n token_hash: string;\n created_at: Date;\n last_used_at: Date;\n};\n\n/**\n * Represents an offline session for refresh tokens\n * @internal\n */\nexport type OfflineSession = {\n id: string;\n userEntityRef: string;\n oidcClientId: string | null;\n tokenHash: string;\n createdAt: Date;\n lastUsedAt: Date;\n};\n\n/**\n * Options for creating a new offline session\n * @internal\n */\nexport type CreateOfflineSessionOptions = {\n id: string;\n userEntityRef: string;\n oidcClientId?: string;\n tokenHash: string;\n};\n\n/**\n * Database layer for managing offline sessions (refresh tokens)\n * @internal\n */\nexport class OfflineSessionDatabase {\n readonly #knex: Knex;\n readonly #tokenLifetimeSeconds: number;\n readonly #maxRotationLifetimeSeconds: number;\n readonly #maxTokensPerUser: number;\n\n static create(options: {\n knex: Knex;\n tokenLifetimeSeconds: number;\n maxRotationLifetimeSeconds: number;\n maxTokensPerUser: number;\n }) {\n return new OfflineSessionDatabase(\n options.knex,\n options.tokenLifetimeSeconds,\n options.maxRotationLifetimeSeconds,\n options.maxTokensPerUser,\n );\n }\n\n private constructor(\n knex: Knex,\n tokenLifetimeSeconds: number,\n maxRotationLifetimeSeconds: number,\n maxTokensPerUser: number,\n ) {\n this.#knex = knex;\n this.#tokenLifetimeSeconds = tokenLifetimeSeconds;\n this.#maxRotationLifetimeSeconds = maxRotationLifetimeSeconds;\n this.#maxTokensPerUser = maxTokensPerUser;\n }\n\n /**\n * Create a new offline session\n * Automatically enforces per-user and per-client limits\n */\n async createSession(\n options: CreateOfflineSessionOptions,\n ): Promise<OfflineSession> {\n const { id, userEntityRef, oidcClientId, tokenHash } = options;\n\n await this.#knex.transaction(async trx => {\n // Delete existing session for same user and OIDC client\n if (oidcClientId) {\n await trx<DbOfflineSessionRow>(TABLE_NAME)\n .where('oidc_client_id', oidcClientId)\n .andWhere('user_entity_ref', userEntityRef)\n .delete();\n }\n\n // Enforce per-user limit\n const userSessions = await trx<DbOfflineSessionRow>(TABLE_NAME)\n .where('user_entity_ref', userEntityRef)\n .select('id', 'last_used_at')\n .orderBy('last_used_at', 'asc')\n .orderBy('id', 'asc');\n\n const tokensToDelete = userSessions.length - (this.#maxTokensPerUser - 1);\n\n if (tokensToDelete > 0) {\n const idsToDelete = userSessions\n .slice(0, tokensToDelete)\n .map(s => s.id);\n\n await trx(TABLE_NAME).whereIn('id', idsToDelete).delete();\n }\n\n // Insert new session\n await trx<DbOfflineSessionRow>(TABLE_NAME).insert({\n id,\n user_entity_ref: userEntityRef,\n oidc_client_id: oidcClientId ?? null,\n token_hash: tokenHash,\n created_at: trx.fn.now(),\n last_used_at: trx.fn.now(),\n });\n });\n\n const session = await this.getSessionById(id);\n if (!session) {\n throw new Error('Failed to create session');\n }\n return session;\n }\n\n /**\n * Get a session by its ID without expiration filtering\n * Used for internal operations that need to check expiration explicitly\n */\n async getSessionById(id: string): Promise<OfflineSession | undefined> {\n const row = await this.#knex<DbOfflineSessionRow>(TABLE_NAME)\n .where('id', id)\n .first();\n\n if (!row) {\n return undefined;\n }\n\n return this.#mapRow(row);\n }\n\n /**\n * Get a session and rotate its token atomically\n * This prevents race conditions where multiple refresh requests use the same token\n */\n async getAndRotateToken(\n id: string,\n expectedTokenHash: string,\n newTokenHash: string,\n ): Promise<OfflineSession | undefined> {\n const now = DateTime.utc();\n const tokenLifetimeThreshold = now\n .minus({ seconds: this.#tokenLifetimeSeconds })\n .toJSDate();\n const maxRotationThreshold = now\n .minus({ seconds: this.#maxRotationLifetimeSeconds })\n .toJSDate();\n\n return await this.#knex.transaction(async trx => {\n // Lock the row and verify token hash matches\n const row = await trx<DbOfflineSessionRow>(TABLE_NAME)\n .where('id', id)\n .where('token_hash', expectedTokenHash)\n .where('last_used_at', '>=', tokenLifetimeThreshold)\n .where('created_at', '>=', maxRotationThreshold)\n .forUpdate()\n .first();\n\n if (!row) {\n return undefined;\n }\n\n // Update token hash atomically\n await trx<DbOfflineSessionRow>(TABLE_NAME).where('id', id).update({\n token_hash: newTokenHash,\n last_used_at: trx.fn.now(),\n });\n\n return this.#mapRow(row);\n });\n }\n\n /**\n * Delete a session by ID\n */\n async deleteSession(id: string): Promise<number> {\n return await this.#knex<DbOfflineSessionRow>(TABLE_NAME)\n .where('id', id)\n .delete();\n }\n\n /**\n * Delete all sessions for a user entity ref\n */\n async deleteSessionsByUserEntityRef(userEntityRef: string): Promise<number> {\n return await this.#knex<DbOfflineSessionRow>(TABLE_NAME)\n .where('user_entity_ref', userEntityRef)\n .delete();\n }\n\n /**\n * Cleanup expired sessions based on both time windows\n * - Short window: last_used_at + tokenLifetime\n * - Long window: created_at + maxRotationLifetime\n */\n async cleanupExpiredSessions(): Promise<number> {\n const now = DateTime.utc();\n const tokenLifetimeThreshold = now\n .minus({ seconds: this.#tokenLifetimeSeconds })\n .toJSDate();\n const maxRotationThreshold = now\n .minus({ seconds: this.#maxRotationLifetimeSeconds })\n .toJSDate();\n\n return await this.#knex<DbOfflineSessionRow>(TABLE_NAME)\n .where('last_used_at', '<', tokenLifetimeThreshold)\n .orWhere('created_at', '<', maxRotationThreshold)\n .delete();\n }\n\n /**\n * Check if a session is expired based on both time windows\n */\n isSessionExpired(session: OfflineSession): boolean {\n const now = DateTime.utc();\n const lastUsedExpiry = DateTime.fromJSDate(session.lastUsedAt).plus({\n seconds: this.#tokenLifetimeSeconds,\n });\n const createdExpiry = DateTime.fromJSDate(session.createdAt).plus({\n seconds: this.#maxRotationLifetimeSeconds,\n });\n\n return now > lastUsedExpiry || now > createdExpiry;\n }\n\n #mapRow(row: DbOfflineSessionRow): OfflineSession {\n return {\n id: row.id,\n userEntityRef: row.user_entity_ref,\n oidcClientId: row.oidc_client_id,\n tokenHash: row.token_hash,\n createdAt: new Date(row.created_at),\n lastUsedAt: new Date(row.last_used_at),\n };\n }\n}\n"],"names":["DateTime"],"mappings":"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"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
const SALT_LENGTH = 16;
|
|
6
|
+
const KEY_LENGTH = 64;
|
|
7
|
+
const SCRYPT_OPTIONS = { N: 16384, r: 8, p: 1 };
|
|
8
|
+
function scryptAsync(password, salt, keylen, options) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
crypto.scrypt(password, salt, keylen, options, (err, derivedKey) => {
|
|
11
|
+
if (err) reject(err);
|
|
12
|
+
else resolve(derivedKey);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
async function hashToken(token) {
|
|
17
|
+
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
18
|
+
const hash = await scryptAsync(token, salt, KEY_LENGTH, SCRYPT_OPTIONS);
|
|
19
|
+
return `${salt.toString("base64")}.${hash.toString("base64")}`;
|
|
20
|
+
}
|
|
21
|
+
async function generateRefreshToken(id) {
|
|
22
|
+
const randomPart = crypto.randomBytes(32).toString("base64url");
|
|
23
|
+
const token = `${id}.${randomPart}`;
|
|
24
|
+
const hash = await hashToken(token);
|
|
25
|
+
return { token, hash };
|
|
26
|
+
}
|
|
27
|
+
function getRefreshTokenId(token) {
|
|
28
|
+
if (!token || typeof token !== "string") {
|
|
29
|
+
throw new Error("Invalid refresh token format");
|
|
30
|
+
}
|
|
31
|
+
const parts = token.split(".");
|
|
32
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
33
|
+
throw new Error("Invalid refresh token format");
|
|
34
|
+
}
|
|
35
|
+
return parts[0];
|
|
36
|
+
}
|
|
37
|
+
async function verifyRefreshToken(token, storedHash) {
|
|
38
|
+
try {
|
|
39
|
+
const [saltBase64, hashBase64] = storedHash.split(".");
|
|
40
|
+
if (!saltBase64 || !hashBase64) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const salt = Buffer.from(saltBase64, "base64");
|
|
44
|
+
const storedHashBuffer = Buffer.from(hashBase64, "base64");
|
|
45
|
+
const computedHash = await scryptAsync(
|
|
46
|
+
token,
|
|
47
|
+
salt,
|
|
48
|
+
KEY_LENGTH,
|
|
49
|
+
SCRYPT_OPTIONS
|
|
50
|
+
);
|
|
51
|
+
return crypto.timingSafeEqual(storedHashBuffer, computedHash);
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
exports.generateRefreshToken = generateRefreshToken;
|
|
58
|
+
exports.getRefreshTokenId = getRefreshTokenId;
|
|
59
|
+
exports.verifyRefreshToken = verifyRefreshToken;
|
|
60
|
+
//# sourceMappingURL=refreshToken.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refreshToken.cjs.js","sources":["../../src/lib/refreshToken.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n randomBytes,\n scrypt,\n timingSafeEqual,\n ScryptOptions,\n} from 'node:crypto';\n\nconst SALT_LENGTH = 16;\nconst KEY_LENGTH = 64;\nconst SCRYPT_OPTIONS: ScryptOptions = { N: 16384, r: 8, p: 1 };\n\nfunction scryptAsync(\n password: string,\n salt: Buffer,\n keylen: number,\n options: ScryptOptions,\n): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n scrypt(password, salt, keylen, options, (err, derivedKey) => {\n if (err) reject(err);\n else resolve(derivedKey);\n });\n });\n}\n\n/**\n * Hash a token using scrypt\n * @internal\n */\nasync function hashToken(token: string): Promise<string> {\n const salt = randomBytes(SALT_LENGTH);\n const hash = await scryptAsync(token, salt, KEY_LENGTH, SCRYPT_OPTIONS);\n\n // Store salt + hash together\n return `${salt.toString('base64')}.${hash.toString('base64')}`;\n}\n\n/**\n * Generate a cryptographically secure refresh token with embedded session ID\n *\n * @param id - The session ID to embed in the token\n * @returns Object containing the token and its hash\n * @internal\n */\nexport async function generateRefreshToken(id: string): Promise<{\n token: string;\n hash: string;\n}> {\n // Generate 32 bytes of random data\n const randomPart = randomBytes(32).toString('base64url');\n\n // Format: <id>.<random_bytes>\n const token = `${id}.${randomPart}`;\n const hash = await hashToken(token);\n\n return { token, hash };\n}\n\n/**\n * Extract the session ID from a refresh token\n *\n * @param token - The refresh token\n * @returns The session ID\n * @throws Error if token format is invalid\n * @internal\n */\nexport function getRefreshTokenId(token: string): string {\n if (!token || typeof token !== 'string') {\n throw new Error('Invalid refresh token format');\n }\n\n const parts = token.split('.');\n if (parts.length !== 2 || !parts[0] || !parts[1]) {\n throw new Error('Invalid refresh token format');\n }\n\n return parts[0];\n}\n\n/**\n * Verify a refresh token against a stored hash\n *\n * @param token - The refresh token to verify\n * @param storedHash - The stored hash (salt.hash format)\n * @returns true if token is valid, false otherwise\n * @internal\n */\nexport async function verifyRefreshToken(\n token: string,\n storedHash: string,\n): Promise<boolean> {\n try {\n const [saltBase64, hashBase64] = storedHash.split('.');\n if (!saltBase64 || !hashBase64) {\n return false;\n }\n\n const salt = Buffer.from(saltBase64, 'base64');\n const storedHashBuffer = Buffer.from(hashBase64, 'base64');\n\n const computedHash = await scryptAsync(\n token,\n salt,\n KEY_LENGTH,\n SCRYPT_OPTIONS,\n );\n\n // Use timing-safe comparison to prevent timing attacks\n return timingSafeEqual(storedHashBuffer, computedHash);\n } catch {\n return false;\n }\n}\n"],"names":["scrypt","randomBytes","timingSafeEqual"],"mappings":";;;;AAuBA,MAAM,WAAA,GAAc,EAAA;AACpB,MAAM,UAAA,GAAa,EAAA;AACnB,MAAM,iBAAgC,EAAE,CAAA,EAAG,OAAO,CAAA,EAAG,CAAA,EAAG,GAAG,CAAA,EAAE;AAE7D,SAAS,WAAA,CACP,QAAA,EACA,IAAA,EACA,MAAA,EACA,OAAA,EACiB;AACjB,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,OAAA,EAAS,MAAA,KAAW;AACtC,IAAAA,aAAA,CAAO,UAAU,IAAA,EAAM,MAAA,EAAQ,OAAA,EAAS,CAAC,KAAK,UAAA,KAAe;AAC3D,MAAA,IAAI,GAAA,SAAY,GAAG,CAAA;AAAA,mBACN,UAAU,CAAA;AAAA,IACzB,CAAC,CAAA;AAAA,EACH,CAAC,CAAA;AACH;AAMA,eAAe,UAAU,KAAA,EAAgC;AACvD,EAAA,MAAM,IAAA,GAAOC,mBAAY,WAAW,CAAA;AACpC,EAAA,MAAM,OAAO,MAAM,WAAA,CAAY,KAAA,EAAO,IAAA,EAAM,YAAY,cAAc,CAAA;AAGtE,EAAA,OAAO,CAAA,EAAG,KAAK,QAAA,CAAS,QAAQ,CAAC,CAAA,CAAA,EAAI,IAAA,CAAK,QAAA,CAAS,QAAQ,CAAC,CAAA,CAAA;AAC9D;AASA,eAAsB,qBAAqB,EAAA,EAGxC;AAED,EAAA,MAAM,UAAA,GAAaA,kBAAA,CAAY,EAAE,CAAA,CAAE,SAAS,WAAW,CAAA;AAGvD,EAAA,MAAM,KAAA,GAAQ,CAAA,EAAG,EAAE,CAAA,CAAA,EAAI,UAAU,CAAA,CAAA;AACjC,EAAA,MAAM,IAAA,GAAO,MAAM,SAAA,CAAU,KAAK,CAAA;AAElC,EAAA,OAAO,EAAE,OAAO,IAAA,EAAK;AACvB;AAUO,SAAS,kBAAkB,KAAA,EAAuB;AACvD,EAAA,IAAI,CAAC,KAAA,IAAS,OAAO,KAAA,KAAU,QAAA,EAAU;AACvC,IAAA,MAAM,IAAI,MAAM,8BAA8B,CAAA;AAAA,EAChD;AAEA,EAAA,MAAM,KAAA,GAAQ,KAAA,CAAM,KAAA,CAAM,GAAG,CAAA;AAC7B,EAAA,IAAI,KAAA,CAAM,MAAA,KAAW,CAAA,IAAK,CAAC,KAAA,CAAM,CAAC,CAAA,IAAK,CAAC,KAAA,CAAM,CAAC,CAAA,EAAG;AAChD,IAAA,MAAM,IAAI,MAAM,8BAA8B,CAAA;AAAA,EAChD;AAEA,EAAA,OAAO,MAAM,CAAC,CAAA;AAChB;AAUA,eAAsB,kBAAA,CACpB,OACA,UAAA,EACkB;AAClB,EAAA,IAAI;AACF,IAAA,MAAM,CAAC,UAAA,EAAY,UAAU,CAAA,GAAI,UAAA,CAAW,MAAM,GAAG,CAAA;AACrD,IAAA,IAAI,CAAC,UAAA,IAAc,CAAC,UAAA,EAAY;AAC9B,MAAA,OAAO,KAAA;AAAA,IACT;AAEA,IAAA,MAAM,IAAA,GAAO,MAAA,CAAO,IAAA,CAAK,UAAA,EAAY,QAAQ,CAAA;AAC7C,IAAA,MAAM,gBAAA,GAAmB,MAAA,CAAO,IAAA,CAAK,UAAA,EAAY,QAAQ,CAAA;AAEzD,IAAA,MAAM,eAAe,MAAM,WAAA;AAAA,MACzB,KAAA;AAAA,MACA,IAAA;AAAA,MACA,UAAA;AAAA,MACA;AAAA,KACF;AAGA,IAAA,OAAOC,sBAAA,CAAgB,kBAAkB,YAAY,CAAA;AAAA,EACvD,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,KAAA;AAAA,EACT;AACF;;;;;;"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var errors = require('@backstage/errors');
|
|
4
|
+
var promises = require('node:dns/promises');
|
|
5
|
+
var ipaddr = require('ipaddr.js');
|
|
6
|
+
|
|
7
|
+
function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; }
|
|
8
|
+
|
|
9
|
+
var ipaddr__default = /*#__PURE__*/_interopDefaultCompat(ipaddr);
|
|
10
|
+
|
|
11
|
+
const FETCH_TIMEOUT_MS = 1e4;
|
|
12
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
13
|
+
const FORBIDDEN_AUTH_METHODS = [
|
|
14
|
+
"client_secret_basic",
|
|
15
|
+
"client_secret_post",
|
|
16
|
+
"client_secret_jwt"
|
|
17
|
+
];
|
|
18
|
+
function validateCimdUrl(clientId) {
|
|
19
|
+
if (/\/\.\.?(\/|$)/.test(clientId)) {
|
|
20
|
+
throw new errors.InputError(
|
|
21
|
+
"Invalid client_id: path must not contain dot segments"
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(clientId);
|
|
27
|
+
} catch {
|
|
28
|
+
throw new errors.InputError("Invalid client_id: not a valid URL");
|
|
29
|
+
}
|
|
30
|
+
const isHttps = url.protocol === "https:";
|
|
31
|
+
const isLocalHttp = url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1") && process.env.NODE_ENV === "development";
|
|
32
|
+
if (!isHttps && !isLocalHttp) {
|
|
33
|
+
throw new errors.InputError(
|
|
34
|
+
"Invalid client_id: must use HTTPS (or HTTP for localhost in development)"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (url.pathname === "" || url.pathname === "/") {
|
|
38
|
+
throw new errors.InputError("Invalid client_id: must have a path component");
|
|
39
|
+
}
|
|
40
|
+
if (url.hash) {
|
|
41
|
+
throw new errors.InputError("Invalid client_id: must not contain a fragment");
|
|
42
|
+
}
|
|
43
|
+
if (url.username || url.password) {
|
|
44
|
+
throw new errors.InputError("Invalid client_id: must not contain credentials");
|
|
45
|
+
}
|
|
46
|
+
if (url.search) {
|
|
47
|
+
throw new errors.InputError("Invalid client_id: must not contain a query string");
|
|
48
|
+
}
|
|
49
|
+
return url;
|
|
50
|
+
}
|
|
51
|
+
function isNonPublicIp(ip) {
|
|
52
|
+
try {
|
|
53
|
+
const addr = ipaddr__default.default.parse(ip);
|
|
54
|
+
const range = addr.range();
|
|
55
|
+
return range !== "unicast";
|
|
56
|
+
} catch {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function validateHostNotPrivate(hostname) {
|
|
61
|
+
try {
|
|
62
|
+
const addresses = await promises.lookup(hostname, { all: true });
|
|
63
|
+
const nonPublicAddr = addresses.find((addr) => isNonPublicIp(addr.address));
|
|
64
|
+
if (nonPublicAddr) {
|
|
65
|
+
throw new errors.InputError("Invalid client_id URL");
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (errors.isError(error) && error.name === "InputError") throw error;
|
|
69
|
+
throw new errors.InputError("Failed to fetch client metadata");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function validateMetadata(metadata, expectedClientId) {
|
|
73
|
+
if (metadata.client_id !== expectedClientId) {
|
|
74
|
+
throw new errors.InputError("Client ID mismatch in metadata document");
|
|
75
|
+
}
|
|
76
|
+
if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) {
|
|
77
|
+
throw new errors.InputError("Metadata must include at least one redirect_uri");
|
|
78
|
+
}
|
|
79
|
+
for (const uri of metadata.redirect_uris) {
|
|
80
|
+
if (!URL.canParse(uri)) {
|
|
81
|
+
throw new errors.InputError(`Invalid redirect_uri in metadata: ${uri}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (metadata.client_secret !== void 0 || metadata.client_secret_expires_at !== void 0) {
|
|
85
|
+
throw new errors.InputError("Client metadata must not contain client_secret");
|
|
86
|
+
}
|
|
87
|
+
if (metadata.token_endpoint_auth_method && FORBIDDEN_AUTH_METHODS.includes(metadata.token_endpoint_auth_method)) {
|
|
88
|
+
throw new errors.InputError("Client metadata uses forbidden auth method");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function fetchCimdMetadata(opts) {
|
|
92
|
+
const url = opts.validatedUrl ?? validateCimdUrl(opts.clientId);
|
|
93
|
+
const isLocalhostDev = (url.hostname === "localhost" || url.hostname === "127.0.0.1") && process.env.NODE_ENV === "development";
|
|
94
|
+
if (!isLocalhostDev) {
|
|
95
|
+
await validateHostNotPrivate(url.hostname);
|
|
96
|
+
}
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await fetch(url.toString(), {
|
|
100
|
+
method: "GET",
|
|
101
|
+
headers: { Accept: "application/json" },
|
|
102
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
103
|
+
});
|
|
104
|
+
} catch {
|
|
105
|
+
throw new errors.InputError("Failed to fetch client metadata");
|
|
106
|
+
}
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
throw new errors.InputError("Failed to fetch client metadata");
|
|
109
|
+
}
|
|
110
|
+
const contentLength = Number(response.headers.get("content-length"));
|
|
111
|
+
if (contentLength > MAX_RESPONSE_BYTES) {
|
|
112
|
+
throw new errors.InputError("Client metadata document too large");
|
|
113
|
+
}
|
|
114
|
+
let metadata;
|
|
115
|
+
try {
|
|
116
|
+
metadata = await response.json();
|
|
117
|
+
} catch {
|
|
118
|
+
throw new errors.InputError("Invalid client metadata document");
|
|
119
|
+
}
|
|
120
|
+
validateMetadata(metadata, opts.clientId);
|
|
121
|
+
return {
|
|
122
|
+
clientId: metadata.client_id,
|
|
123
|
+
clientName: metadata.client_name || metadata.client_id,
|
|
124
|
+
redirectUris: metadata.redirect_uris,
|
|
125
|
+
responseTypes: metadata.response_types || ["code"],
|
|
126
|
+
grantTypes: metadata.grant_types || ["authorization_code"],
|
|
127
|
+
scope: metadata.scope
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
exports.fetchCimdMetadata = fetchCimdMetadata;
|
|
132
|
+
exports.validateCimdUrl = validateCimdUrl;
|
|
133
|
+
//# sourceMappingURL=CimdClient.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CimdClient.cjs.js","sources":["../../src/service/CimdClient.ts"],"sourcesContent":["/*\n * Copyright 2025 The Backstage Authors\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { InputError, isError } from '@backstage/errors';\nimport { lookup } from 'node:dns/promises';\nimport ipaddr from 'ipaddr.js';\n\nconst FETCH_TIMEOUT_MS = 10000;\nconst MAX_RESPONSE_BYTES = 64 * 1024;\n\n/** Auth methods that require a client secret - forbidden for CIMD clients */\nconst FORBIDDEN_AUTH_METHODS = [\n 'client_secret_basic',\n 'client_secret_post',\n 'client_secret_jwt',\n];\n\n/**\n * Raw metadata document from a CIMD URL.\n * Note: client_secret fields are included for validation (must NOT be present).\n */\ninterface CimdMetadata {\n client_id: string;\n client_name?: string;\n redirect_uris: string[];\n response_types?: string[];\n grant_types?: string[];\n scope?: string;\n token_endpoint_auth_method?: string;\n client_secret?: string;\n client_secret_expires_at?: number;\n}\n\n/** Validated CIMD client info */\nexport interface CimdClientInfo {\n clientId: string;\n clientName: string;\n redirectUris: string[];\n responseTypes: string[];\n grantTypes: string[];\n scope?: string;\n}\n\n/**\n * Validates and parses a CIMD URL per the IETF draft specification.\n * Requires HTTPS for production, but allows HTTP for localhost (development).\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/\n * @throws InputError if the URL is invalid per the CIMD spec\n */\nexport function validateCimdUrl(clientId: string): URL {\n // Per IETF draft: MUST NOT contain single-dot or double-dot path segments\n // Check before URL parsing since the URL constructor normalizes these away\n if (/\\/\\.\\.?(\\/|$)/.test(clientId)) {\n throw new InputError(\n 'Invalid client_id: path must not contain dot segments',\n );\n }\n\n let url: URL;\n try {\n url = new URL(clientId);\n } catch {\n throw new InputError('Invalid client_id: not a valid URL');\n }\n\n const isHttps = url.protocol === 'https:';\n const isLocalHttp =\n url.protocol === 'http:' &&\n (url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&\n process.env.NODE_ENV === 'development';\n\n if (!isHttps && !isLocalHttp) {\n throw new InputError(\n 'Invalid client_id: must use HTTPS (or HTTP for localhost in development)',\n );\n }\n\n if (url.pathname === '' || url.pathname === '/') {\n throw new InputError('Invalid client_id: must have a path component');\n }\n\n if (url.hash) {\n throw new InputError('Invalid client_id: must not contain a fragment');\n }\n\n if (url.username || url.password) {\n throw new InputError('Invalid client_id: must not contain credentials');\n }\n\n // Per IETF draft: SHOULD NOT include a query string\n // We reject this for stricter compliance and security\n if (url.search) {\n throw new InputError('Invalid client_id: must not contain a query string');\n }\n\n return url;\n}\n\n/**\n * Checks if a client_id is a valid CIMD URL.\n * Requires HTTPS for production, but allows HTTP for localhost (development).\n */\nexport function isCimdUrl(clientId: string): boolean {\n try {\n validateCimdUrl(clientId);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * SSRF (Server-Side Request Forgery) Protection\n *\n * When fetching CIMD metadata from client-provided URLs, we must prevent\n * attackers from tricking Backstage into accessing internal resources.\n * For example, an attacker could provide a URL that resolves to:\n * - 127.0.0.1 (localhost services)\n * - 10.x.x.x, 172.16-31.x.x, 192.168.x.x (internal network)\n * - Cloud metadata endpoints (169.254.169.254)\n *\n * We use ipaddr.js to check if resolved IPs are in non-public ranges.\n * Only 'unicast' (public internet) addresses are allowed.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/\n * Section 5.1 - Security Considerations\n */\nfunction isNonPublicIp(ip: string): boolean {\n try {\n const addr = ipaddr.parse(ip);\n const range = addr.range();\n // Only allow public unicast addresses\n return range !== 'unicast';\n } catch {\n // If we can't parse the IP, treat it as non-public and block it\n return true;\n }\n}\n\nasync function validateHostNotPrivate(hostname: string): Promise<void> {\n try {\n const addresses = await lookup(hostname, { all: true });\n const nonPublicAddr = addresses.find(addr => isNonPublicIp(addr.address));\n if (nonPublicAddr) {\n throw new InputError('Invalid client_id URL');\n }\n } catch (error) {\n if (isError(error) && error.name === 'InputError') throw error;\n throw new InputError('Failed to fetch client metadata');\n }\n}\n\nfunction validateMetadata(\n metadata: CimdMetadata,\n expectedClientId: string,\n): void {\n if (metadata.client_id !== expectedClientId) {\n throw new InputError('Client ID mismatch in metadata document');\n }\n\n if (\n !Array.isArray(metadata.redirect_uris) ||\n metadata.redirect_uris.length === 0\n ) {\n throw new InputError('Metadata must include at least one redirect_uri');\n }\n\n for (const uri of metadata.redirect_uris) {\n if (!URL.canParse(uri)) {\n throw new InputError(`Invalid redirect_uri in metadata: ${uri}`);\n }\n }\n\n if (\n metadata.client_secret !== undefined ||\n metadata.client_secret_expires_at !== undefined\n ) {\n throw new InputError('Client metadata must not contain client_secret');\n }\n\n if (\n metadata.token_endpoint_auth_method &&\n FORBIDDEN_AUTH_METHODS.includes(metadata.token_endpoint_auth_method)\n ) {\n throw new InputError('Client metadata uses forbidden auth method');\n }\n}\n\n/**\n * Fetches and validates a CIMD metadata document.\n * @throws InputError if fetching or validation fails\n */\nexport async function fetchCimdMetadata(opts: {\n clientId: string;\n validatedUrl?: URL;\n}): Promise<CimdClientInfo> {\n const url = opts.validatedUrl ?? validateCimdUrl(opts.clientId);\n\n // Skip SSRF validation for localhost in development only\n const isLocalhostDev =\n (url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&\n process.env.NODE_ENV === 'development';\n if (!isLocalhostDev) {\n await validateHostNotPrivate(url.hostname);\n }\n\n let response: Response;\n try {\n response = await fetch(url.toString(), {\n method: 'GET',\n headers: { Accept: 'application/json' },\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n });\n } catch {\n throw new InputError('Failed to fetch client metadata');\n }\n\n if (!response.ok) {\n throw new InputError('Failed to fetch client metadata');\n }\n\n const contentLength = Number(response.headers.get('content-length'));\n if (contentLength > MAX_RESPONSE_BYTES) {\n throw new InputError('Client metadata document too large');\n }\n\n let metadata: CimdMetadata;\n try {\n metadata = await response.json();\n } catch {\n throw new InputError('Invalid client metadata document');\n }\n\n validateMetadata(metadata, opts.clientId);\n\n return {\n clientId: metadata.client_id,\n clientName: metadata.client_name || metadata.client_id,\n redirectUris: metadata.redirect_uris,\n responseTypes: metadata.response_types || ['code'],\n grantTypes: metadata.grant_types || ['authorization_code'],\n scope: metadata.scope,\n };\n}\n"],"names":["InputError","ipaddr","lookup","isError"],"mappings":"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"}
|