@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.
@@ -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;;;;"}