@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
|
@@ -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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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var errors = require('@backstage/errors');
|
|
4
|
+
|
|
5
|
+
class OidcError extends errors.CustomErrorBase {
|
|
6
|
+
name = "OidcError";
|
|
7
|
+
body;
|
|
8
|
+
statusCode;
|
|
9
|
+
constructor(errorCode, errorDescription, statusCode, cause) {
|
|
10
|
+
super(`${errorCode}, ${errorDescription}`, cause);
|
|
11
|
+
this.statusCode = statusCode;
|
|
12
|
+
this.body = {
|
|
13
|
+
error: errorCode,
|
|
14
|
+
error_description: errorDescription
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
static isOidcError(error) {
|
|
18
|
+
return errors.isError(error) && error.name === "OidcError";
|
|
19
|
+
}
|
|
20
|
+
static fromError(error) {
|
|
21
|
+
if (OidcError.isOidcError(error)) {
|
|
22
|
+
return error;
|
|
23
|
+
}
|
|
24
|
+
if (!errors.isError(error)) {
|
|
25
|
+
return new OidcError("server_error", "Unknown error", 500, error);
|
|
26
|
+
}
|
|
27
|
+
const errorMessage = error.message || "Unknown error";
|
|
28
|
+
switch (error.name) {
|
|
29
|
+
case "InputError":
|
|
30
|
+
return new OidcError("invalid_request", errorMessage, 400, error);
|
|
31
|
+
case "AuthenticationError":
|
|
32
|
+
return new OidcError("invalid_client", errorMessage, 401, error);
|
|
33
|
+
case "NotAllowedError":
|
|
34
|
+
return new OidcError("access_denied", errorMessage, 403, error);
|
|
35
|
+
case "NotFoundError":
|
|
36
|
+
return new OidcError("invalid_request", errorMessage, 400, error);
|
|
37
|
+
default:
|
|
38
|
+
return new OidcError("server_error", errorMessage, 500, error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
static middleware(logger) {
|
|
42
|
+
return (err, _req, res, next) => {
|
|
43
|
+
if (OidcError.isOidcError(err)) {
|
|
44
|
+
logger[err.statusCode >= 500 ? "error" : "info"](
|
|
45
|
+
`OIDC Request failed with status ${err.statusCode}: ${err.body.error} - ${err.body.error_description}`,
|
|
46
|
+
err.cause
|
|
47
|
+
);
|
|
48
|
+
res.status(err.statusCode).json(err.body);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
next(err);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
exports.OidcError = OidcError;
|
|
57
|
+
//# sourceMappingURL=OidcError.cjs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OidcError.cjs.js","sources":["../../src/service/OidcError.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 */\nimport { CustomErrorBase, isError } from '@backstage/errors';\nimport { Request, Response, NextFunction } from 'express';\nimport { LoggerService } from '@backstage/backend-plugin-api';\n\nexport class OidcError extends CustomErrorBase {\n name = 'OidcError';\n\n readonly body: { error: string; error_description: string };\n readonly statusCode: number;\n\n constructor(\n errorCode: string,\n errorDescription: string,\n statusCode: number,\n cause?: Error | unknown,\n ) {\n super(`${errorCode}, ${errorDescription}`, cause);\n this.statusCode = statusCode;\n this.body = {\n error: errorCode,\n error_description: errorDescription,\n };\n }\n\n static isOidcError(error: unknown): error is OidcError {\n return isError(error) && error.name === 'OidcError';\n }\n\n static fromError(error: unknown): OidcError {\n if (OidcError.isOidcError(error)) {\n return error;\n }\n\n if (!isError(error)) {\n return new OidcError('server_error', 'Unknown error', 500, error);\n }\n\n const errorMessage = error.message || 'Unknown error';\n\n switch (error.name) {\n case 'InputError':\n return new OidcError('invalid_request', errorMessage, 400, error);\n case 'AuthenticationError':\n return new OidcError('invalid_client', errorMessage, 401, error);\n case 'NotAllowedError':\n return new OidcError('access_denied', errorMessage, 403, error);\n case 'NotFoundError':\n return new OidcError('invalid_request', errorMessage, 400, error);\n default:\n return new OidcError('server_error', errorMessage, 500, error);\n }\n }\n\n static middleware(\n logger: LoggerService,\n ): (err: unknown, _req: Request, res: Response, next: NextFunction) => void {\n return (\n err: unknown,\n _req: Request,\n res: Response,\n next: NextFunction,\n ): void => {\n if (OidcError.isOidcError(err)) {\n logger[err.statusCode >= 500 ? 'error' : 'info'](\n `OIDC Request failed with status ${err.statusCode}: ${err.body.error} - ${err.body.error_description}`,\n err.cause,\n );\n res.status(err.statusCode).json(err.body);\n return;\n }\n next(err);\n };\n }\n}\n"],"names":["CustomErrorBase","isError"],"mappings":";;;;AAmBO,MAAM,kBAAkBA,sBAAA,CAAgB;AAAA,EAC7C,IAAA,GAAO,WAAA;AAAA,EAEE,IAAA;AAAA,EACA,UAAA;AAAA,EAET,WAAA,CACE,SAAA,EACA,gBAAA,EACA,UAAA,EACA,KAAA,EACA;AACA,IAAA,KAAA,CAAM,CAAA,EAAG,SAAS,CAAA,EAAA,EAAK,gBAAgB,IAAI,KAAK,CAAA;AAChD,IAAA,IAAA,CAAK,UAAA,GAAa,UAAA;AAClB,IAAA,IAAA,CAAK,IAAA,GAAO;AAAA,MACV,KAAA,EAAO,SAAA;AAAA,MACP,iBAAA,EAAmB;AAAA,KACrB;AAAA,EACF;AAAA,EAEA,OAAO,YAAY,KAAA,EAAoC;AACrD,IAAA,OAAOC,cAAA,CAAQ,KAAK,CAAA,IAAK,KAAA,CAAM,IAAA,KAAS,WAAA;AAAA,EAC1C;AAAA,EAEA,OAAO,UAAU,KAAA,EAA2B;AAC1C,IAAA,IAAI,SAAA,CAAU,WAAA,CAAY,KAAK,CAAA,EAAG;AAChC,MAAA,OAAO,KAAA;AAAA,IACT;AAEA,IAAA,IAAI,CAACA,cAAA,CAAQ,KAAK,CAAA,EAAG;AACnB,MAAA,OAAO,IAAI,SAAA,CAAU,cAAA,EAAgB,eAAA,EAAiB,KAAK,KAAK,CAAA;AAAA,IAClE;AAEA,IAAA,MAAM,YAAA,GAAe,MAAM,OAAA,IAAW,eAAA;AAEtC,IAAA,QAAQ,MAAM,IAAA;AAAM,MAClB,KAAK,YAAA;AACH,QAAA,OAAO,IAAI,SAAA,CAAU,iBAAA,EAAmB,YAAA,EAAc,KAAK,KAAK,CAAA;AAAA,MAClE,KAAK,qBAAA;AACH,QAAA,OAAO,IAAI,SAAA,CAAU,gBAAA,EAAkB,YAAA,EAAc,KAAK,KAAK,CAAA;AAAA,MACjE,KAAK,iBAAA;AACH,QAAA,OAAO,IAAI,SAAA,CAAU,eAAA,EAAiB,YAAA,EAAc,KAAK,KAAK,CAAA;AAAA,MAChE,KAAK,eAAA;AACH,QAAA,OAAO,IAAI,SAAA,CAAU,iBAAA,EAAmB,YAAA,EAAc,KAAK,KAAK,CAAA;AAAA,MAClE;AACE,QAAA,OAAO,IAAI,SAAA,CAAU,cAAA,EAAgB,YAAA,EAAc,KAAK,KAAK,CAAA;AAAA;AACjE,EACF;AAAA,EAEA,OAAO,WACL,MAAA,EAC0E;AAC1E,IAAA,OAAO,CACL,GAAA,EACA,IAAA,EACA,GAAA,EACA,IAAA,KACS;AACT,MAAA,IAAI,SAAA,CAAU,WAAA,CAAY,GAAG,CAAA,EAAG;AAC9B,QAAA,MAAA,CAAO,GAAA,CAAI,UAAA,IAAc,GAAA,GAAM,OAAA,GAAU,MAAM,CAAA;AAAA,UAC7C,CAAA,gCAAA,EAAmC,GAAA,CAAI,UAAU,CAAA,EAAA,EAAK,GAAA,CAAI,KAAK,KAAK,CAAA,GAAA,EAAM,GAAA,CAAI,IAAA,CAAK,iBAAiB,CAAA,CAAA;AAAA,UACpG,GAAA,CAAI;AAAA,SACN;AACA,QAAA,GAAA,CAAI,OAAO,GAAA,CAAI,UAAU,CAAA,CAAE,IAAA,CAAK,IAAI,IAAI,CAAA;AACxC,QAAA;AAAA,MACF;AACA,MAAA,IAAA,CAAK,GAAG,CAAA;AAAA,IACV,CAAA;AAAA,EACF;AACF;;;;"}
|