@backstage/plugin-auth-backend 0.26.0 → 0.27.0-next.1
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 +27 -0
- package/config.d.ts +31 -0
- 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/identity/StaticKeyStore.cjs.js +2 -2
- package/dist/identity/StaticKeyStore.cjs.js.map +1 -1
- package/dist/lib/refreshToken.cjs.js +60 -0
- package/dist/lib/refreshToken.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 +215 -142
- package/dist/service/OidcRouter.cjs.js.map +1 -1
- package/dist/service/OidcService.cjs.js +98 -20
- package/dist/service/OidcService.cjs.js.map +1 -1
- package/dist/service/router.cjs.js +2 -1
- package/dist/service/router.cjs.js.map +1 -1
- package/migrations/20251020000000_offline_sessions.js +78 -0
- package/package.json +16 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @backstage/plugin-auth-backend
|
|
2
2
|
|
|
3
|
+
## 0.27.0-next.1
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @backstage/plugin-catalog-node@2.0.0-next.1
|
|
13
|
+
- @backstage/backend-plugin-api@1.7.0-next.1
|
|
14
|
+
- @backstage/plugin-auth-node@0.6.13-next.1
|
|
15
|
+
|
|
16
|
+
## 0.26.1-next.0
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- 7455dae: Use node prefix on native imports
|
|
21
|
+
- Updated dependencies
|
|
22
|
+
- @backstage/plugin-catalog-node@1.21.0-next.0
|
|
23
|
+
- @backstage/backend-plugin-api@1.7.0-next.0
|
|
24
|
+
- @backstage/plugin-auth-node@0.6.12-next.0
|
|
25
|
+
- @backstage/catalog-model@1.7.6
|
|
26
|
+
- @backstage/config@1.3.6
|
|
27
|
+
- @backstage/errors@1.2.7
|
|
28
|
+
- @backstage/types@1.2.2
|
|
29
|
+
|
|
3
30
|
## 0.26.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
package/config.d.ts
CHANGED
|
@@ -99,6 +99,37 @@ export interface Config {
|
|
|
99
99
|
*/
|
|
100
100
|
backstageTokenExpiration?: HumanDuration | string;
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Configuration for refresh tokens (offline access)
|
|
104
|
+
* @visibility backend
|
|
105
|
+
*/
|
|
106
|
+
experimentalRefreshToken?: {
|
|
107
|
+
/**
|
|
108
|
+
* Whether to enable refresh tokens
|
|
109
|
+
* @default false
|
|
110
|
+
* @visibility backend
|
|
111
|
+
*/
|
|
112
|
+
enabled?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Token lifetime before rotation required
|
|
115
|
+
* @default '30 days'
|
|
116
|
+
* @visibility backend
|
|
117
|
+
*/
|
|
118
|
+
tokenLifetime?: HumanDuration | string;
|
|
119
|
+
/**
|
|
120
|
+
* Maximum session lifetime across all rotations
|
|
121
|
+
* @default '1 year'
|
|
122
|
+
* @visibility backend
|
|
123
|
+
*/
|
|
124
|
+
maxRotationLifetime?: HumanDuration | string;
|
|
125
|
+
/**
|
|
126
|
+
* Maximum number of refresh tokens per user
|
|
127
|
+
* @default 20
|
|
128
|
+
* @visibility backend
|
|
129
|
+
*/
|
|
130
|
+
maxTokensPerUser?: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
102
133
|
/**
|
|
103
134
|
* Additional app origins to allow for authenticating
|
|
104
135
|
*/
|
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":";;;;AAmBA,MAAM,UAAA,GAAa,kBAAA;AAuCZ,MAAM,sBAAA,CAAuB;AAAA,EACzB,KAAA;AAAA,EACA,qBAAA;AAAA,EACA,2BAAA;AAAA,EACA,iBAAA;AAAA,EAET,OAAO,OAAO,OAAA,EAKX;AACD,IAAA,OAAO,IAAI,sBAAA;AAAA,MACT,OAAA,CAAQ,IAAA;AAAA,MACR,OAAA,CAAQ,oBAAA;AAAA,MACR,OAAA,CAAQ,0BAAA;AAAA,MACR,OAAA,CAAQ;AAAA,KACV;AAAA,EACF;AAAA,EAEQ,WAAA,CACN,IAAA,EACA,oBAAA,EACA,0BAAA,EACA,gBAAA,EACA;AACA,IAAA,IAAA,CAAK,KAAA,GAAQ,IAAA;AACb,IAAA,IAAA,CAAK,qBAAA,GAAwB,oBAAA;AAC7B,IAAA,IAAA,CAAK,2BAAA,GAA8B,0BAAA;AACnC,IAAA,IAAA,CAAK,iBAAA,GAAoB,gBAAA;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,cACJ,OAAA,EACyB;AACzB,IAAA,MAAM,EAAE,EAAA,EAAI,aAAA,EAAe,YAAA,EAAc,WAAU,GAAI,OAAA;AAEvD,IAAA,MAAM,IAAA,CAAK,KAAA,CAAM,WAAA,CAAY,OAAM,GAAA,KAAO;AAExC,MAAA,IAAI,YAAA,EAAc;AAChB,QAAA,MAAM,GAAA,CAAyB,UAAU,CAAA,CACtC,KAAA,CAAM,gBAAA,EAAkB,YAAY,CAAA,CACpC,QAAA,CAAS,iBAAA,EAAmB,aAAa,CAAA,CACzC,MAAA,EAAO;AAAA,MACZ;AAGA,MAAA,MAAM,eAAe,MAAM,GAAA,CAAyB,UAAU,CAAA,CAC3D,KAAA,CAAM,mBAAmB,aAAa,CAAA,CACtC,OAAO,IAAA,EAAM,cAAc,EAC3B,OAAA,CAAQ,cAAA,EAAgB,KAAK,CAAA,CAC7B,OAAA,CAAQ,MAAM,KAAK,CAAA;AAEtB,MAAA,MAAM,cAAA,GAAiB,YAAA,CAAa,MAAA,IAAU,IAAA,CAAK,iBAAA,GAAoB,CAAA,CAAA;AAEvE,MAAA,IAAI,iBAAiB,CAAA,EAAG;AACtB,QAAA,MAAM,WAAA,GAAc,aACjB,KAAA,CAAM,CAAA,EAAG,cAAc,CAAA,CACvB,GAAA,CAAI,CAAA,CAAA,KAAK,CAAA,CAAE,EAAE,CAAA;AAEhB,QAAA,MAAM,IAAI,UAAU,CAAA,CAAE,QAAQ,IAAA,EAAM,WAAW,EAAE,MAAA,EAAO;AAAA,MAC1D;AAGA,MAAA,MAAM,GAAA,CAAyB,UAAU,CAAA,CAAE,MAAA,CAAO;AAAA,QAChD,EAAA;AAAA,QACA,eAAA,EAAiB,aAAA;AAAA,QACjB,gBAAgB,YAAA,IAAgB,IAAA;AAAA,QAChC,UAAA,EAAY,SAAA;AAAA,QACZ,UAAA,EAAY,GAAA,CAAI,EAAA,CAAG,GAAA,EAAI;AAAA,QACvB,YAAA,EAAc,GAAA,CAAI,EAAA,CAAG,GAAA;AAAI,OAC1B,CAAA;AAAA,IACH,CAAC,CAAA;AAED,IAAA,MAAM,OAAA,GAAU,MAAM,IAAA,CAAK,cAAA,CAAe,EAAE,CAAA;AAC5C,IAAA,IAAI,CAAC,OAAA,EAAS;AACZ,MAAA,MAAM,IAAI,MAAM,0BAA0B,CAAA;AAAA,IAC5C;AACA,IAAA,OAAO,OAAA;AAAA,EACT;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,eAAe,EAAA,EAAiD;AACpE,IAAA,MAAM,GAAA,GAAM,MAAM,IAAA,CAAK,KAAA,CAA2B,UAAU,EACzD,KAAA,CAAM,IAAA,EAAM,EAAE,CAAA,CACd,KAAA,EAAM;AAET,IAAA,IAAI,CAAC,GAAA,EAAK;AACR,MAAA,OAAO,MAAA;AAAA,IACT;AAEA,IAAA,OAAO,IAAA,CAAK,QAAQ,GAAG,CAAA;AAAA,EACzB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,iBAAA,CACJ,EAAA,EACA,iBAAA,EACA,YAAA,EACqC;AACrC,IAAA,MAAM,GAAA,GAAMA,eAAS,GAAA,EAAI;AACzB,IAAA,MAAM,sBAAA,GAAyB,IAC5B,KAAA,CAAM,EAAE,SAAS,IAAA,CAAK,qBAAA,EAAuB,CAAA,CAC7C,QAAA,EAAS;AACZ,IAAA,MAAM,oBAAA,GAAuB,IAC1B,KAAA,CAAM,EAAE,SAAS,IAAA,CAAK,2BAAA,EAA6B,CAAA,CACnD,QAAA,EAAS;AAEZ,IAAA,OAAO,MAAM,IAAA,CAAK,KAAA,CAAM,WAAA,CAAY,OAAM,GAAA,KAAO;AAE/C,MAAA,MAAM,GAAA,GAAM,MAAM,GAAA,CAAyB,UAAU,CAAA,CAClD,MAAM,IAAA,EAAM,EAAE,CAAA,CACd,KAAA,CAAM,YAAA,EAAc,iBAAiB,EACrC,KAAA,CAAM,cAAA,EAAgB,IAAA,EAAM,sBAAsB,CAAA,CAClD,KAAA,CAAM,YAAA,EAAc,IAAA,EAAM,oBAAoB,CAAA,CAC9C,SAAA,EAAU,CACV,KAAA,EAAM;AAET,MAAA,IAAI,CAAC,GAAA,EAAK;AACR,QAAA,OAAO,MAAA;AAAA,MACT;AAGA,MAAA,MAAM,IAAyB,UAAU,CAAA,CAAE,MAAM,IAAA,EAAM,EAAE,EAAE,MAAA,CAAO;AAAA,QAChE,UAAA,EAAY,YAAA;AAAA,QACZ,YAAA,EAAc,GAAA,CAAI,EAAA,CAAG,GAAA;AAAI,OAC1B,CAAA;AAED,MAAA,OAAO,IAAA,CAAK,QAAQ,GAAG,CAAA;AAAA,IACzB,CAAC,CAAA;AAAA,EACH;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,cAAc,EAAA,EAA6B;AAC/C,IAAA,OAAO,MAAM,KAAK,KAAA,CAA2B,UAAU,EACpD,KAAA,CAAM,IAAA,EAAM,EAAE,CAAA,CACd,MAAA,EAAO;AAAA,EACZ;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,8BAA8B,aAAA,EAAwC;AAC1E,IAAA,OAAO,MAAM,KAAK,KAAA,CAA2B,UAAU,EACpD,KAAA,CAAM,iBAAA,EAAmB,aAAa,CAAA,CACtC,MAAA,EAAO;AAAA,EACZ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,sBAAA,GAA0C;AAC9C,IAAA,MAAM,GAAA,GAAMA,eAAS,GAAA,EAAI;AACzB,IAAA,MAAM,sBAAA,GAAyB,IAC5B,KAAA,CAAM,EAAE,SAAS,IAAA,CAAK,qBAAA,EAAuB,CAAA,CAC7C,QAAA,EAAS;AACZ,IAAA,MAAM,oBAAA,GAAuB,IAC1B,KAAA,CAAM,EAAE,SAAS,IAAA,CAAK,2BAAA,EAA6B,CAAA,CACnD,QAAA,EAAS;AAEZ,IAAA,OAAO,MAAM,IAAA,CAAK,KAAA,CAA2B,UAAU,EACpD,KAAA,CAAM,cAAA,EAAgB,GAAA,EAAK,sBAAsB,EACjD,OAAA,CAAQ,YAAA,EAAc,GAAA,EAAK,oBAAoB,EAC/C,MAAA,EAAO;AAAA,EACZ;AAAA;AAAA;AAAA;AAAA,EAKA,iBAAiB,OAAA,EAAkC;AACjD,IAAA,MAAM,GAAA,GAAMA,eAAS,GAAA,EAAI;AACzB,IAAA,MAAM,iBAAiBA,cAAA,CAAS,UAAA,CAAW,OAAA,CAAQ,UAAU,EAAE,IAAA,CAAK;AAAA,MAClE,SAAS,IAAA,CAAK;AAAA,KACf,CAAA;AACD,IAAA,MAAM,gBAAgBA,cAAA,CAAS,UAAA,CAAW,OAAA,CAAQ,SAAS,EAAE,IAAA,CAAK;AAAA,MAChE,SAAS,IAAA,CAAK;AAAA,KACf,CAAA;AAED,IAAA,OAAO,GAAA,GAAM,kBAAkB,GAAA,GAAM,aAAA;AAAA,EACvC;AAAA,EAEA,QAAQ,GAAA,EAA0C;AAChD,IAAA,OAAO;AAAA,MACL,IAAI,GAAA,CAAI,EAAA;AAAA,MACR,eAAe,GAAA,CAAI,eAAA;AAAA,MACnB,cAAc,GAAA,CAAI,cAAA;AAAA,MAClB,WAAW,GAAA,CAAI,UAAA;AAAA,MACf,SAAA,EAAW,IAAI,IAAA,CAAK,GAAA,CAAI,UAAU,CAAA;AAAA,MAClC,UAAA,EAAY,IAAI,IAAA,CAAK,GAAA,CAAI,YAAY;AAAA,KACvC;AAAA,EACF;AACF;;;;"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var jose = require('jose');
|
|
4
|
-
var
|
|
4
|
+
var node_fs = require('node:fs');
|
|
5
5
|
|
|
6
6
|
const DEFAULT_ALGORITHM = "ES256";
|
|
7
7
|
class StaticKeyStore {
|
|
@@ -78,7 +78,7 @@ class StaticKeyStore {
|
|
|
78
78
|
return this.loadKeyFromFile(path, keyId, algorithm, jose.importPKCS8);
|
|
79
79
|
}
|
|
80
80
|
static async loadKeyFromFile(path, keyId, algorithm, importer) {
|
|
81
|
-
const content = await
|
|
81
|
+
const content = await node_fs.promises.readFile(path, { encoding: "utf8", flag: "r" });
|
|
82
82
|
const key = await importer(content, algorithm);
|
|
83
83
|
const jwk = await jose.exportJWK(key);
|
|
84
84
|
jwk.kid = keyId;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"StaticKeyStore.cjs.js","sources":["../../src/identity/StaticKeyStore.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 */\nimport { AnyJWK, KeyStore, StoredKey } from './types';\nimport { exportJWK, importPKCS8, importSPKI, JWK } from 'jose';\nimport { KeyLike } from 'jose';\nimport { promises as fs } from 'fs';\nimport { Config } from '@backstage/config';\n\nexport type KeyPair = {\n publicKey: JWK;\n privateKey: JWK;\n};\n\nexport type StaticKeyConfig = {\n publicKeyFile: string;\n privateKeyFile: string;\n keyId: string;\n algorithm: string;\n};\n\nconst DEFAULT_ALGORITHM = 'ES256';\n\n/**\n * Key store that loads predefined public/private key pairs from disk\n *\n * The private key should be represented using the PKCS#8 format,\n * while the public key should be in the SPKI format.\n *\n * @remarks\n *\n * You can generate a public and private key pair, using\n * openssl:\n *\n * Generate a private key using the ES256 algorithm\n * ```sh\n * openssl ecparam -name prime256v1 -genkey -out private.ec.key\n * ```\n * Convert it to PKCS#8 format\n * ```sh\n * openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key\n * ```\n * Extract the public key\n * ```sh\n * openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key\n * ```\n *\n * Provide the paths to private.key and public.key as the respective\n * private and public key paths in the StaticKeyStore.create(...) method.\n */\nexport class StaticKeyStore implements KeyStore {\n private readonly keyPairs: KeyPair[];\n private readonly createdAt: Date;\n\n private constructor(keyPairs: KeyPair[]) {\n if (keyPairs.length === 0) {\n throw new Error('Should provide at least one key pair');\n }\n\n this.keyPairs = keyPairs;\n this.createdAt = new Date();\n }\n\n public static async fromConfig(config: Config): Promise<StaticKeyStore> {\n const keyConfigs = config\n .getConfigArray('auth.keyStore.static.keys')\n .map(c => {\n const staticKeyConfig: StaticKeyConfig = {\n publicKeyFile: c.getString('publicKeyFile'),\n privateKeyFile: c.getString('privateKeyFile'),\n keyId: c.getString('keyId'),\n algorithm: c.getOptionalString('algorithm') ?? DEFAULT_ALGORITHM,\n };\n\n return staticKeyConfig;\n });\n\n const keyPairs = await Promise.all(\n keyConfigs.map(async k => await this.loadKeyPair(k)),\n );\n\n return new StaticKeyStore(keyPairs);\n }\n\n addKey(_key: AnyJWK): Promise<void> {\n throw new Error('Cannot add keys to the static key store');\n }\n\n listKeys(): Promise<{ items: StoredKey[] }> {\n const keys = this.keyPairs.map(k => this.keyPairToStoredKey(k));\n return Promise.resolve({ items: keys });\n }\n\n getPrivateKey(keyId: string): JWK {\n const keyPair = this.keyPairs.find(k => k.publicKey.kid === keyId);\n if (keyPair === undefined) {\n throw new Error(`Could not find key with keyId: ${keyId}`);\n }\n\n return keyPair.privateKey;\n }\n\n removeKeys(_kids: string[]): Promise<void> {\n throw new Error('Cannot remove keys from the static key store');\n }\n\n private keyPairToStoredKey(keyPair: KeyPair): StoredKey {\n const publicKey = {\n ...keyPair.publicKey,\n use: 'sig',\n };\n\n return {\n key: publicKey as AnyJWK,\n createdAt: this.createdAt,\n };\n }\n\n private static async loadKeyPair(options: StaticKeyConfig): Promise<KeyPair> {\n const algorithm = options.algorithm;\n const keyId = options.keyId;\n const publicKey = await this.loadPublicKeyFromFile(\n options.publicKeyFile,\n keyId,\n algorithm,\n );\n const privateKey = await this.loadPrivateKeyFromFile(\n options.privateKeyFile,\n keyId,\n algorithm,\n );\n\n return { publicKey, privateKey };\n }\n\n private static async loadPublicKeyFromFile(\n path: string,\n keyId: string,\n algorithm: string,\n ): Promise<JWK> {\n return this.loadKeyFromFile(path, keyId, algorithm, importSPKI);\n }\n\n private static async loadPrivateKeyFromFile(\n path: string,\n keyId: string,\n algorithm: string,\n ): Promise<JWK> {\n return this.loadKeyFromFile(path, keyId, algorithm, importPKCS8);\n }\n\n private static async loadKeyFromFile(\n path: string,\n keyId: string,\n algorithm: string,\n importer: (content: string, algorithm: string) => Promise<KeyLike>,\n ): Promise<JWK> {\n const content = await fs.readFile(path, { encoding: 'utf8', flag: 'r' });\n const key = await importer(content, algorithm);\n const jwk = await exportJWK(key);\n jwk.kid = keyId;\n jwk.alg = algorithm;\n\n return jwk;\n }\n}\n"],"names":["importSPKI","importPKCS8","fs","exportJWK"],"mappings":"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
|
|
1
|
+
{"version":3,"file":"StaticKeyStore.cjs.js","sources":["../../src/identity/StaticKeyStore.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 */\nimport { AnyJWK, KeyStore, StoredKey } from './types';\nimport { exportJWK, importPKCS8, importSPKI, JWK } from 'jose';\nimport { KeyLike } from 'jose';\nimport { promises as fs } from 'node:fs';\nimport { Config } from '@backstage/config';\n\nexport type KeyPair = {\n publicKey: JWK;\n privateKey: JWK;\n};\n\nexport type StaticKeyConfig = {\n publicKeyFile: string;\n privateKeyFile: string;\n keyId: string;\n algorithm: string;\n};\n\nconst DEFAULT_ALGORITHM = 'ES256';\n\n/**\n * Key store that loads predefined public/private key pairs from disk\n *\n * The private key should be represented using the PKCS#8 format,\n * while the public key should be in the SPKI format.\n *\n * @remarks\n *\n * You can generate a public and private key pair, using\n * openssl:\n *\n * Generate a private key using the ES256 algorithm\n * ```sh\n * openssl ecparam -name prime256v1 -genkey -out private.ec.key\n * ```\n * Convert it to PKCS#8 format\n * ```sh\n * openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key\n * ```\n * Extract the public key\n * ```sh\n * openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key\n * ```\n *\n * Provide the paths to private.key and public.key as the respective\n * private and public key paths in the StaticKeyStore.create(...) method.\n */\nexport class StaticKeyStore implements KeyStore {\n private readonly keyPairs: KeyPair[];\n private readonly createdAt: Date;\n\n private constructor(keyPairs: KeyPair[]) {\n if (keyPairs.length === 0) {\n throw new Error('Should provide at least one key pair');\n }\n\n this.keyPairs = keyPairs;\n this.createdAt = new Date();\n }\n\n public static async fromConfig(config: Config): Promise<StaticKeyStore> {\n const keyConfigs = config\n .getConfigArray('auth.keyStore.static.keys')\n .map(c => {\n const staticKeyConfig: StaticKeyConfig = {\n publicKeyFile: c.getString('publicKeyFile'),\n privateKeyFile: c.getString('privateKeyFile'),\n keyId: c.getString('keyId'),\n algorithm: c.getOptionalString('algorithm') ?? DEFAULT_ALGORITHM,\n };\n\n return staticKeyConfig;\n });\n\n const keyPairs = await Promise.all(\n keyConfigs.map(async k => await this.loadKeyPair(k)),\n );\n\n return new StaticKeyStore(keyPairs);\n }\n\n addKey(_key: AnyJWK): Promise<void> {\n throw new Error('Cannot add keys to the static key store');\n }\n\n listKeys(): Promise<{ items: StoredKey[] }> {\n const keys = this.keyPairs.map(k => this.keyPairToStoredKey(k));\n return Promise.resolve({ items: keys });\n }\n\n getPrivateKey(keyId: string): JWK {\n const keyPair = this.keyPairs.find(k => k.publicKey.kid === keyId);\n if (keyPair === undefined) {\n throw new Error(`Could not find key with keyId: ${keyId}`);\n }\n\n return keyPair.privateKey;\n }\n\n removeKeys(_kids: string[]): Promise<void> {\n throw new Error('Cannot remove keys from the static key store');\n }\n\n private keyPairToStoredKey(keyPair: KeyPair): StoredKey {\n const publicKey = {\n ...keyPair.publicKey,\n use: 'sig',\n };\n\n return {\n key: publicKey as AnyJWK,\n createdAt: this.createdAt,\n };\n }\n\n private static async loadKeyPair(options: StaticKeyConfig): Promise<KeyPair> {\n const algorithm = options.algorithm;\n const keyId = options.keyId;\n const publicKey = await this.loadPublicKeyFromFile(\n options.publicKeyFile,\n keyId,\n algorithm,\n );\n const privateKey = await this.loadPrivateKeyFromFile(\n options.privateKeyFile,\n keyId,\n algorithm,\n );\n\n return { publicKey, privateKey };\n }\n\n private static async loadPublicKeyFromFile(\n path: string,\n keyId: string,\n algorithm: string,\n ): Promise<JWK> {\n return this.loadKeyFromFile(path, keyId, algorithm, importSPKI);\n }\n\n private static async loadPrivateKeyFromFile(\n path: string,\n keyId: string,\n algorithm: string,\n ): Promise<JWK> {\n return this.loadKeyFromFile(path, keyId, algorithm, importPKCS8);\n }\n\n private static async loadKeyFromFile(\n path: string,\n keyId: string,\n algorithm: string,\n importer: (content: string, algorithm: string) => Promise<KeyLike>,\n ): Promise<JWK> {\n const content = await fs.readFile(path, { encoding: 'utf8', flag: 'r' });\n const key = await importer(content, algorithm);\n const jwk = await exportJWK(key);\n jwk.kid = keyId;\n jwk.alg = algorithm;\n\n return jwk;\n }\n}\n"],"names":["importSPKI","importPKCS8","fs","exportJWK"],"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,177 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var errors = require('@backstage/errors');
|
|
4
|
+
var config = require('@backstage/config');
|
|
5
|
+
var types = require('@backstage/types');
|
|
6
|
+
var uuid = require('uuid');
|
|
7
|
+
var OfflineSessionDatabase = require('../database/OfflineSessionDatabase.cjs.js');
|
|
8
|
+
var refreshToken = require('../lib/refreshToken.cjs.js');
|
|
9
|
+
|
|
10
|
+
class OfflineAccessService {
|
|
11
|
+
#offlineSessionDb;
|
|
12
|
+
#logger;
|
|
13
|
+
static async create(options) {
|
|
14
|
+
const { config: config$1, database, logger, lifecycle } = options;
|
|
15
|
+
const tokenLifetime = config$1.has(
|
|
16
|
+
"auth.experimentalRefreshToken.tokenLifetime"
|
|
17
|
+
) ? config.readDurationFromConfig(config$1, {
|
|
18
|
+
key: "auth.experimentalRefreshToken.tokenLifetime"
|
|
19
|
+
}) : { days: 30 };
|
|
20
|
+
const maxRotationLifetime = config$1.has(
|
|
21
|
+
"auth.experimentalRefreshToken.maxRotationLifetime"
|
|
22
|
+
) ? config.readDurationFromConfig(config$1, {
|
|
23
|
+
key: "auth.experimentalRefreshToken.maxRotationLifetime"
|
|
24
|
+
}) : { years: 1 };
|
|
25
|
+
const tokenLifetimeSeconds = Math.floor(
|
|
26
|
+
types.durationToMilliseconds(tokenLifetime) / 1e3
|
|
27
|
+
);
|
|
28
|
+
const maxRotationLifetimeSeconds = Math.floor(
|
|
29
|
+
types.durationToMilliseconds(maxRotationLifetime) / 1e3
|
|
30
|
+
);
|
|
31
|
+
if (tokenLifetimeSeconds <= 0) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"auth.experimentalRefreshToken.tokenLifetime must be a positive duration"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
if (maxRotationLifetimeSeconds <= 0) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
"auth.experimentalRefreshToken.maxRotationLifetime must be a positive duration"
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (maxRotationLifetimeSeconds <= tokenLifetimeSeconds) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"auth.experimentalRefreshToken.maxRotationLifetime must be greater than tokenLifetime"
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
const maxTokensPerUser = config$1.getOptionalNumber(
|
|
47
|
+
"auth.experimentalRefreshToken.maxTokensPerUser"
|
|
48
|
+
) ?? 20;
|
|
49
|
+
if (maxTokensPerUser <= 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"auth.experimentalRefreshToken.maxTokensPerUser must be a positive number"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const knex = await database.getClient();
|
|
55
|
+
if (knex.client.config.client.includes("sqlite") || knex.client.config.client.includes("better-sqlite")) {
|
|
56
|
+
logger.warn(
|
|
57
|
+
"Refresh tokens are enabled with SQLite, which does not support row-level locking. Concurrent token rotation may not be fully protected against race conditions. Use PostgreSQL for production deployments."
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const offlineSessionDb = OfflineSessionDatabase.OfflineSessionDatabase.create({
|
|
61
|
+
knex,
|
|
62
|
+
tokenLifetimeSeconds,
|
|
63
|
+
maxRotationLifetimeSeconds,
|
|
64
|
+
maxTokensPerUser
|
|
65
|
+
});
|
|
66
|
+
const cleanupIntervalMs = 60 * 60 * 1e3;
|
|
67
|
+
const cleanupInterval = setInterval(async () => {
|
|
68
|
+
try {
|
|
69
|
+
const deleted = await offlineSessionDb.cleanupExpiredSessions();
|
|
70
|
+
if (deleted > 0) {
|
|
71
|
+
logger.info(`Cleaned up ${deleted} expired offline sessions`);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
logger.error("Failed to cleanup expired offline sessions", error);
|
|
75
|
+
}
|
|
76
|
+
}, cleanupIntervalMs);
|
|
77
|
+
cleanupInterval.unref();
|
|
78
|
+
lifecycle.addShutdownHook(() => {
|
|
79
|
+
clearInterval(cleanupInterval);
|
|
80
|
+
});
|
|
81
|
+
return new OfflineAccessService(offlineSessionDb, logger);
|
|
82
|
+
}
|
|
83
|
+
constructor(offlineSessionDb, logger) {
|
|
84
|
+
this.#offlineSessionDb = offlineSessionDb;
|
|
85
|
+
this.#logger = logger;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Issue a new refresh token for a user
|
|
89
|
+
*/
|
|
90
|
+
async issueRefreshToken(options) {
|
|
91
|
+
const { userEntityRef, oidcClientId } = options;
|
|
92
|
+
const sessionId = uuid.v4();
|
|
93
|
+
const { token, hash } = await refreshToken.generateRefreshToken(sessionId);
|
|
94
|
+
await this.#offlineSessionDb.createSession({
|
|
95
|
+
id: sessionId,
|
|
96
|
+
userEntityRef,
|
|
97
|
+
oidcClientId,
|
|
98
|
+
tokenHash: hash
|
|
99
|
+
});
|
|
100
|
+
this.#logger.debug(
|
|
101
|
+
`Issued refresh token for user ${userEntityRef} with session ${sessionId}`
|
|
102
|
+
);
|
|
103
|
+
return token;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Refresh an access token using a refresh token
|
|
107
|
+
*/
|
|
108
|
+
async refreshAccessToken(options) {
|
|
109
|
+
const { refreshToken: refreshToken$1, tokenIssuer, clientId } = options;
|
|
110
|
+
let sessionId;
|
|
111
|
+
try {
|
|
112
|
+
sessionId = refreshToken.getRefreshTokenId(refreshToken$1);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.#logger.debug("Failed to extract refresh token ID", error);
|
|
115
|
+
throw new errors.AuthenticationError("Invalid refresh token format");
|
|
116
|
+
}
|
|
117
|
+
const session = await this.#offlineSessionDb.getSessionById(sessionId);
|
|
118
|
+
if (!session) {
|
|
119
|
+
throw new errors.AuthenticationError("Invalid refresh token");
|
|
120
|
+
}
|
|
121
|
+
if (this.#offlineSessionDb.isSessionExpired(session)) {
|
|
122
|
+
await this.#offlineSessionDb.deleteSession(sessionId);
|
|
123
|
+
throw new errors.AuthenticationError("Invalid refresh token");
|
|
124
|
+
}
|
|
125
|
+
if (clientId && session.oidcClientId && clientId !== session.oidcClientId) {
|
|
126
|
+
throw new errors.AuthenticationError(
|
|
127
|
+
"Refresh token was not issued to this client"
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const isValid = await refreshToken.verifyRefreshToken(refreshToken$1, session.tokenHash);
|
|
131
|
+
if (!isValid) {
|
|
132
|
+
throw new errors.AuthenticationError("Invalid refresh token");
|
|
133
|
+
}
|
|
134
|
+
const { token: newRefreshToken, hash: newHash } = await refreshToken.generateRefreshToken(sessionId);
|
|
135
|
+
const rotatedSession = await this.#offlineSessionDb.getAndRotateToken(
|
|
136
|
+
sessionId,
|
|
137
|
+
session.tokenHash,
|
|
138
|
+
newHash
|
|
139
|
+
);
|
|
140
|
+
if (!rotatedSession) {
|
|
141
|
+
throw new errors.AuthenticationError("Invalid refresh token");
|
|
142
|
+
}
|
|
143
|
+
const { token: accessToken } = await tokenIssuer.issueToken({
|
|
144
|
+
claims: {
|
|
145
|
+
sub: rotatedSession.userEntityRef
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this.#logger.debug(
|
|
149
|
+
`Refreshed access token for user ${session.userEntityRef} with session ${sessionId}`
|
|
150
|
+
);
|
|
151
|
+
return { accessToken, refreshToken: newRefreshToken };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Revoke a refresh token
|
|
155
|
+
*/
|
|
156
|
+
async revokeRefreshToken(refreshToken$1) {
|
|
157
|
+
try {
|
|
158
|
+
const sessionId = refreshToken.getRefreshTokenId(refreshToken$1);
|
|
159
|
+
await this.#offlineSessionDb.deleteSession(sessionId);
|
|
160
|
+
this.#logger.debug(`Revoked refresh token with session ${sessionId}`);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.#logger.debug("Failed to revoke refresh token", error);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Revoke all refresh tokens for a user
|
|
167
|
+
*/
|
|
168
|
+
async revokeRefreshTokensByUserEntityRef(userEntityRef) {
|
|
169
|
+
const deletedCount = await this.#offlineSessionDb.deleteSessionsByUserEntityRef(userEntityRef);
|
|
170
|
+
this.#logger.debug(
|
|
171
|
+
`Revoked ${deletedCount} refresh tokens for user ${userEntityRef}`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
exports.OfflineAccessService = OfflineAccessService;
|
|
177
|
+
//# sourceMappingURL=OfflineAccessService.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OfflineAccessService.cjs.js","sources":["../../src/service/OfflineAccessService.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 DatabaseService,\n LifecycleService,\n LoggerService,\n RootConfigService,\n} from '@backstage/backend-plugin-api';\nimport { AuthenticationError } from '@backstage/errors';\nimport { readDurationFromConfig } from '@backstage/config';\nimport { durationToMilliseconds } from '@backstage/types';\nimport { v4 as uuid } from 'uuid';\nimport { OfflineSessionDatabase } from '../database/OfflineSessionDatabase';\nimport {\n generateRefreshToken,\n getRefreshTokenId,\n verifyRefreshToken,\n} from '../lib/refreshToken';\nimport { TokenIssuer } from '../identity/types';\n\n/**\n * Service for managing offline access (refresh tokens)\n * @internal\n */\nexport class OfflineAccessService {\n readonly #offlineSessionDb: OfflineSessionDatabase;\n readonly #logger: LoggerService;\n\n static async create(options: {\n config: RootConfigService;\n database: DatabaseService;\n logger: LoggerService;\n lifecycle: LifecycleService;\n }): Promise<OfflineAccessService> {\n const { config, database, logger, lifecycle } = options;\n\n const tokenLifetime = config.has(\n 'auth.experimentalRefreshToken.tokenLifetime',\n )\n ? readDurationFromConfig(config, {\n key: 'auth.experimentalRefreshToken.tokenLifetime',\n })\n : { days: 30 };\n\n const maxRotationLifetime = config.has(\n 'auth.experimentalRefreshToken.maxRotationLifetime',\n )\n ? readDurationFromConfig(config, {\n key: 'auth.experimentalRefreshToken.maxRotationLifetime',\n })\n : { years: 1 };\n\n const tokenLifetimeSeconds = Math.floor(\n durationToMilliseconds(tokenLifetime) / 1000,\n );\n const maxRotationLifetimeSeconds = Math.floor(\n durationToMilliseconds(maxRotationLifetime) / 1000,\n );\n\n if (tokenLifetimeSeconds <= 0) {\n throw new Error(\n 'auth.experimentalRefreshToken.tokenLifetime must be a positive duration',\n );\n }\n if (maxRotationLifetimeSeconds <= 0) {\n throw new Error(\n 'auth.experimentalRefreshToken.maxRotationLifetime must be a positive duration',\n );\n }\n if (maxRotationLifetimeSeconds <= tokenLifetimeSeconds) {\n throw new Error(\n 'auth.experimentalRefreshToken.maxRotationLifetime must be greater than tokenLifetime',\n );\n }\n\n const maxTokensPerUser =\n config.getOptionalNumber(\n 'auth.experimentalRefreshToken.maxTokensPerUser',\n ) ?? 20;\n\n if (maxTokensPerUser <= 0) {\n throw new Error(\n 'auth.experimentalRefreshToken.maxTokensPerUser must be a positive number',\n );\n }\n\n const knex = await database.getClient();\n\n if (\n knex.client.config.client.includes('sqlite') ||\n knex.client.config.client.includes('better-sqlite')\n ) {\n logger.warn(\n 'Refresh tokens are enabled with SQLite, which does not support row-level locking. ' +\n 'Concurrent token rotation may not be fully protected against race conditions. ' +\n 'Use PostgreSQL for production deployments.',\n );\n }\n\n const offlineSessionDb = OfflineSessionDatabase.create({\n knex,\n tokenLifetimeSeconds,\n maxRotationLifetimeSeconds,\n maxTokensPerUser,\n });\n\n const cleanupIntervalMs = 60 * 60 * 1000;\n const cleanupInterval = setInterval(async () => {\n try {\n const deleted = await offlineSessionDb.cleanupExpiredSessions();\n if (deleted > 0) {\n logger.info(`Cleaned up ${deleted} expired offline sessions`);\n }\n } catch (error) {\n logger.error('Failed to cleanup expired offline sessions', error);\n }\n }, cleanupIntervalMs);\n cleanupInterval.unref();\n\n lifecycle.addShutdownHook(() => {\n clearInterval(cleanupInterval);\n });\n\n return new OfflineAccessService(offlineSessionDb, logger);\n }\n\n private constructor(\n offlineSessionDb: OfflineSessionDatabase,\n logger: LoggerService,\n ) {\n this.#offlineSessionDb = offlineSessionDb;\n this.#logger = logger;\n }\n\n /**\n * Issue a new refresh token for a user\n */\n async issueRefreshToken(options: {\n userEntityRef: string;\n oidcClientId?: string;\n }): Promise<string> {\n const { userEntityRef, oidcClientId } = options;\n\n const sessionId = uuid();\n const { token, hash } = await generateRefreshToken(sessionId);\n\n await this.#offlineSessionDb.createSession({\n id: sessionId,\n userEntityRef,\n oidcClientId,\n tokenHash: hash,\n });\n\n this.#logger.debug(\n `Issued refresh token for user ${userEntityRef} with session ${sessionId}`,\n );\n\n return token;\n }\n\n /**\n * Refresh an access token using a refresh token\n */\n async refreshAccessToken(options: {\n refreshToken: string;\n tokenIssuer: TokenIssuer;\n clientId?: string;\n }): Promise<{ accessToken: string; refreshToken: string }> {\n const { refreshToken, tokenIssuer, clientId } = options;\n\n let sessionId: string;\n try {\n sessionId = getRefreshTokenId(refreshToken);\n } catch (error) {\n this.#logger.debug('Failed to extract refresh token ID', error);\n throw new AuthenticationError('Invalid refresh token format');\n }\n\n const session = await this.#offlineSessionDb.getSessionById(sessionId);\n if (!session) {\n throw new AuthenticationError('Invalid refresh token');\n }\n\n if (this.#offlineSessionDb.isSessionExpired(session)) {\n await this.#offlineSessionDb.deleteSession(sessionId);\n throw new AuthenticationError('Invalid refresh token');\n }\n\n if (clientId && session.oidcClientId && clientId !== session.oidcClientId) {\n throw new AuthenticationError(\n 'Refresh token was not issued to this client',\n );\n }\n\n // Verify the caller actually holds a valid token, not just the session ID\n const isValid = await verifyRefreshToken(refreshToken, session.tokenHash);\n if (!isValid) {\n throw new AuthenticationError('Invalid refresh token');\n }\n\n const { token: newRefreshToken, hash: newHash } =\n await generateRefreshToken(sessionId);\n\n // Atomically swap the hash so a concurrent request with the same token fails\n const rotatedSession = await this.#offlineSessionDb.getAndRotateToken(\n sessionId,\n session.tokenHash,\n newHash,\n );\n\n if (!rotatedSession) {\n throw new AuthenticationError('Invalid refresh token');\n }\n\n const { token: accessToken } = await tokenIssuer.issueToken({\n claims: {\n sub: rotatedSession.userEntityRef,\n },\n });\n\n this.#logger.debug(\n `Refreshed access token for user ${session.userEntityRef} with session ${sessionId}`,\n );\n\n return { accessToken, refreshToken: newRefreshToken };\n }\n\n /**\n * Revoke a refresh token\n */\n async revokeRefreshToken(refreshToken: string): Promise<void> {\n try {\n const sessionId = getRefreshTokenId(refreshToken);\n await this.#offlineSessionDb.deleteSession(sessionId);\n this.#logger.debug(`Revoked refresh token with session ${sessionId}`);\n } catch (error) {\n // Ignore errors when revoking - token may already be invalid\n this.#logger.debug('Failed to revoke refresh token', error);\n }\n }\n\n /**\n * Revoke all refresh tokens for a user\n */\n async revokeRefreshTokensByUserEntityRef(\n userEntityRef: string,\n ): Promise<void> {\n const deletedCount =\n await this.#offlineSessionDb.deleteSessionsByUserEntityRef(userEntityRef);\n this.#logger.debug(\n `Revoked ${deletedCount} refresh tokens for user ${userEntityRef}`,\n );\n }\n}\n"],"names":["config","readDurationFromConfig","durationToMilliseconds","OfflineSessionDatabase","uuid","generateRefreshToken","refreshToken","getRefreshTokenId","AuthenticationError","verifyRefreshToken"],"mappings":"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"}
|