@ticketlayer/backstage 4.0.0 → 4.1.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,332 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuthSession = exports.BackstageAuth = exports.ACCESS_TOKEN_TYPE = exports.REFRESH_TOKEN_TYPE = exports.TOKEN_EXCHANGE_GRANT = exports.jwkThumbprint = exports.dpopProof = exports.dpopHtu = exports.createDpopKey = exports.pkceChallenge = exports.pkce = void 0;
4
+ exports.createAuth = createAuth;
5
+ const client_1 = require("../client");
6
+ const dpop_1 = require("./dpop");
7
+ const encoding_1 = require("./encoding");
8
+ const pkce_1 = require("./pkce");
9
+ var pkce_2 = require("./pkce");
10
+ Object.defineProperty(exports, "pkce", { enumerable: true, get: function () { return pkce_2.pkce; } });
11
+ Object.defineProperty(exports, "pkceChallenge", { enumerable: true, get: function () { return pkce_2.pkceChallenge; } });
12
+ var dpop_2 = require("./dpop");
13
+ Object.defineProperty(exports, "createDpopKey", { enumerable: true, get: function () { return dpop_2.createDpopKey; } });
14
+ Object.defineProperty(exports, "dpopHtu", { enumerable: true, get: function () { return dpop_2.dpopHtu; } });
15
+ Object.defineProperty(exports, "dpopProof", { enumerable: true, get: function () { return dpop_2.dpopProof; } });
16
+ Object.defineProperty(exports, "jwkThumbprint", { enumerable: true, get: function () { return dpop_2.jwkThumbprint; } });
17
+ exports.TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange';
18
+ exports.REFRESH_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:refresh_token';
19
+ exports.ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
20
+ function assertServerSide() {
21
+ const g = globalThis;
22
+ if (typeof g.window !== 'undefined' && typeof g.document !== 'undefined') {
23
+ throw new Error('clientSecret is for server use only (the shell BFF). Never ship a client secret to a browser; public clients use PKCE and DPoP.');
24
+ }
25
+ }
26
+ const formEncode = (value) => encodeURIComponent(value).replace(/%20/g, '+');
27
+ function organisationsFrom(idToken) {
28
+ if (!idToken)
29
+ return undefined;
30
+ const claim = (0, encoding_1.decodeJwtPayload)(idToken)?.organisations;
31
+ if (!Array.isArray(claim))
32
+ return undefined;
33
+ return claim
34
+ .filter((o) => !!o && typeof o === 'object' && typeof o.slug === 'string')
35
+ .map((o) => ({ id: String(o.id), slug: o.slug, name: String(o.name) }));
36
+ }
37
+ function toTokenSet(body, previous) {
38
+ const accessToken = body.access_token;
39
+ if (typeof accessToken !== 'string' || !accessToken) {
40
+ throw new client_1.BackstageAPIError('The token endpoint answered no access token', 'INVALID_TOKEN_RESPONSE', 502);
41
+ }
42
+ const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 0;
43
+ const idToken = typeof body.id_token === 'string' ? body.id_token : undefined;
44
+ let organisation;
45
+ if ('org_slug' in body) {
46
+ organisation =
47
+ typeof body.org_slug === 'string'
48
+ ? { slug: body.org_slug, name: typeof body.org_name === 'string' ? body.org_name : null }
49
+ : null;
50
+ }
51
+ else {
52
+ organisation = previous?.organisation ?? null;
53
+ }
54
+ const tokens = {
55
+ accessToken,
56
+ tokenType: typeof body.token_type === 'string' ? body.token_type : 'Bearer',
57
+ expiresIn,
58
+ expiresAt: Date.now() + expiresIn * 1000,
59
+ organisation,
60
+ };
61
+ const refreshToken = typeof body.refresh_token === 'string' ? body.refresh_token : previous?.refreshToken;
62
+ if (refreshToken)
63
+ tokens.refreshToken = refreshToken;
64
+ if (typeof body.scope === 'string')
65
+ tokens.scope = body.scope;
66
+ if (idToken)
67
+ tokens.idToken = idToken;
68
+ const organisations = organisationsFrom(idToken) ?? previous?.organisations;
69
+ if (organisations)
70
+ tokens.organisations = organisations;
71
+ return tokens;
72
+ }
73
+ class BackstageAuth {
74
+ constructor(config) {
75
+ this.pkce = pkce_1.pkce;
76
+ this.pkceChallenge = pkce_1.pkceChallenge;
77
+ this.createDpopKey = dpop_1.createDpopKey;
78
+ this.dpopProof = dpop_1.dpopProof;
79
+ if (!config || !config.issuerUrl)
80
+ throw new Error('createAuth needs issuerUrl (https://auth.<domain>)');
81
+ this.issuerUrl = config.issuerUrl.replace(/\/$/, '');
82
+ this.tokenEndpoint = config.tokenEndpoint ?? `${this.issuerUrl}/oauth/token`;
83
+ this.clientId = config.clientId;
84
+ this.debugConfig =
85
+ config.debug === true
86
+ ? { logRequests: true, logResponses: true, logErrors: true }
87
+ : config.debug && typeof config.debug === 'object'
88
+ ? config.debug
89
+ : null;
90
+ this.logger = this.debugConfig
91
+ ? this.debugConfig.logger ?? {
92
+ log: (...args) => console.log('[BackstageSDK]', ...args),
93
+ error: (...args) => console.error('[BackstageSDK]', ...args),
94
+ }
95
+ : null;
96
+ this.issuer = new client_1.BackstageClient({
97
+ baseUrl: this.issuerUrl,
98
+ authMode: 'bearer',
99
+ debug: config.debug,
100
+ headers: config.headers,
101
+ credentials: config.credentials,
102
+ });
103
+ }
104
+ async start(input) {
105
+ const answer = (await this.issuer.identity.start({
106
+ client_id: this.clientIdOf(input.clientId),
107
+ code_challenge: input.codeChallenge,
108
+ code_challenge_method: 'S256',
109
+ email: input.email,
110
+ redirect_uri: input.redirectUri,
111
+ scope: input.scope,
112
+ dpop_jkt: input.dpop?.thumbprint,
113
+ organisation: input.organisation,
114
+ }));
115
+ const result = {
116
+ method: answer.method,
117
+ authSession: answer.authSession,
118
+ alternatives: answer.alternatives ?? [],
119
+ expiresIn: answer.expiresIn,
120
+ };
121
+ if (answer.redirectUrl)
122
+ result.redirectUrl = answer.redirectUrl;
123
+ return result;
124
+ }
125
+ async password(input) {
126
+ return this.step(await this.issuer.identity.password({ authSession: input.authSession, password: input.password }));
127
+ }
128
+ async sendEmailCode(input) {
129
+ const answer = await this.issuer.identity.emailCode({ authSession: input.authSession });
130
+ if (answer.status !== 'code_sent') {
131
+ throw new client_1.BackstageAPIError('Expected code_sent from /auth/email-code', 'UNEXPECTED_RESPONSE', 502);
132
+ }
133
+ return { status: 'code_sent' };
134
+ }
135
+ async emailCode(input) {
136
+ if (!input.code)
137
+ throw new Error('emailCode needs the code; use sendEmailCode to have one sent');
138
+ return this.step(await this.issuer.identity.emailCode({ authSession: input.authSession, code: input.code }));
139
+ }
140
+ async mfa(input) {
141
+ return this.step(await this.issuer.identity.mfa({ authSession: input.authSession, code: input.code }));
142
+ }
143
+ step(answer) {
144
+ if (answer.status === 'code')
145
+ return { status: 'code', code: answer.code, redirectUri: answer.redirectUri };
146
+ if (answer.status === 'mfa_required') {
147
+ return { status: 'mfa_required', authSession: answer.authSession, factors: [...answer.factors] };
148
+ }
149
+ throw new client_1.BackstageAPIError(`Unexpected sign-in answer: ${String(answer.status)}`, 'UNEXPECTED_RESPONSE', 502);
150
+ }
151
+ async exchange(input) {
152
+ const params = { code: input.code, code_verifier: input.codeVerifier };
153
+ if (input.redirectUri)
154
+ params.redirect_uri = input.redirectUri;
155
+ return this.token('authorization_code', params, input);
156
+ }
157
+ async refresh(input) {
158
+ return this.token('refresh_token', { refresh_token: input.refreshToken }, input, {
159
+ refreshToken: input.refreshToken,
160
+ });
161
+ }
162
+ async selectOrganisation(input) {
163
+ const subject = input.refreshToken ?? input.accessToken;
164
+ if (!subject)
165
+ throw new Error('selectOrganisation needs a refreshToken or an accessToken');
166
+ return this.token(exports.TOKEN_EXCHANGE_GRANT, {
167
+ subject_token: subject,
168
+ subject_token_type: input.refreshToken ? exports.REFRESH_TOKEN_TYPE : exports.ACCESS_TOKEN_TYPE,
169
+ requested_token_type: exports.ACCESS_TOKEN_TYPE,
170
+ organisation_slug: input.slug,
171
+ }, input);
172
+ }
173
+ async token(grantType, params, credentials, previous) {
174
+ const clientId = this.clientIdOf(credentials.clientId);
175
+ const headers = {
176
+ 'Content-Type': 'application/x-www-form-urlencoded',
177
+ Accept: 'application/json',
178
+ };
179
+ const form = new URLSearchParams({ grant_type: grantType, ...params });
180
+ if (credentials.clientSecret) {
181
+ assertServerSide();
182
+ headers.Authorization = `Basic ${btoa(`${formEncode(clientId)}:${formEncode(credentials.clientSecret)}`)}`;
183
+ }
184
+ else {
185
+ form.set('client_id', clientId);
186
+ }
187
+ const send = async (nonce) => {
188
+ if (credentials.dpop) {
189
+ headers.DPoP = await credentials.dpop.proof({ method: 'POST', url: this.tokenEndpoint, nonce });
190
+ }
191
+ if (this.debugConfig?.logRequests)
192
+ this.logger?.log('→', 'POST', this.tokenEndpoint, { grant_type: grantType });
193
+ return fetch(this.tokenEndpoint, { method: 'POST', headers, body: form.toString() });
194
+ };
195
+ let response = await send();
196
+ let body = (await response.json().catch(() => ({})));
197
+ const nonce = response.headers?.get?.('DPoP-Nonce');
198
+ if (!response.ok && body.error === 'use_dpop_nonce' && nonce && credentials.dpop) {
199
+ response = await send(nonce);
200
+ body = (await response.json().catch(() => ({})));
201
+ }
202
+ if (!response.ok) {
203
+ const code = typeof body.error === 'string' ? body.error : 'HTTP_ERROR';
204
+ const message = typeof body.error_description === 'string' ? body.error_description : `Token request failed (${code})`;
205
+ if (this.debugConfig?.logErrors)
206
+ this.logger?.error('✗', 'POST', this.tokenEndpoint, { status: response.status, code });
207
+ throw new client_1.BackstageAPIError(message, code, response.status, {
208
+ requestId: response.headers?.get?.('x-request-id') ?? undefined,
209
+ });
210
+ }
211
+ if (this.debugConfig?.logResponses) {
212
+ this.logger?.log('←', 'POST', this.tokenEndpoint, { status: response.status, grant_type: grantType });
213
+ }
214
+ return toTokenSet(body, previous);
215
+ }
216
+ clientIdOf(clientId) {
217
+ const id = clientId ?? this.clientId;
218
+ if (!id)
219
+ throw new Error('No clientId: pass one to the call or to createAuth');
220
+ return id;
221
+ }
222
+ createSession(options) {
223
+ return new AuthSession(this, options);
224
+ }
225
+ createClient(options) {
226
+ return this.createSession(options).client;
227
+ }
228
+ }
229
+ exports.BackstageAuth = BackstageAuth;
230
+ class AuthSession {
231
+ constructor(auth, options) {
232
+ this.auth = auth;
233
+ this.options = options;
234
+ this.ended = false;
235
+ if (options.clientSecret)
236
+ assertServerSide();
237
+ this.current = { ...options.tokens };
238
+ this.leewayMs = (options.refreshLeewaySeconds ?? 30) * 1000;
239
+ const { tokens: _tokens, clientId: _clientId, clientSecret: _clientSecret, dpop, onTokens: _onTokens, onSessionEnded: _onSessionEnded, refreshLeewaySeconds: _leeway, organisationSlug, ...config } = options;
240
+ this.client = new client_1.BackstageClient({
241
+ ...config,
242
+ authMode: 'bearer',
243
+ accessToken: () => {
244
+ this.refreshAhead();
245
+ return this.current.accessToken;
246
+ },
247
+ organisationSlug: organisationSlug ?? (() => this.current.organisation?.slug ?? undefined),
248
+ onAuthFailure: () => this.refresh(),
249
+ dpop,
250
+ });
251
+ }
252
+ get tokens() {
253
+ return { ...this.current };
254
+ }
255
+ get organisation() {
256
+ return this.current.organisation ?? null;
257
+ }
258
+ get hasEnded() {
259
+ return this.ended;
260
+ }
261
+ refresh() {
262
+ if (this.ended)
263
+ return Promise.resolve(false);
264
+ if (!this.pending) {
265
+ this.pending = this.runRefresh().finally(() => {
266
+ this.pending = undefined;
267
+ });
268
+ }
269
+ return this.pending;
270
+ }
271
+ async selectOrganisation(slug) {
272
+ while (this.pending)
273
+ await this.pending.catch(() => false);
274
+ const run = (async () => {
275
+ const next = await this.auth.selectOrganisation({
276
+ slug,
277
+ refreshToken: this.current.refreshToken,
278
+ accessToken: this.current.refreshToken ? undefined : this.current.accessToken,
279
+ clientId: this.options.clientId,
280
+ clientSecret: this.options.clientSecret,
281
+ dpop: this.options.dpop,
282
+ });
283
+ await this.adopt(next);
284
+ return next;
285
+ })();
286
+ this.pending = run.then(() => true, () => false);
287
+ try {
288
+ return await run;
289
+ }
290
+ finally {
291
+ this.pending = undefined;
292
+ }
293
+ }
294
+ refreshAhead() {
295
+ if (this.ended || this.pending || !this.current.refreshToken)
296
+ return;
297
+ const { accessToken, expiresAt } = this.current;
298
+ const due = !accessToken || (typeof expiresAt === 'number' && expiresAt - Date.now() < this.leewayMs);
299
+ if (due)
300
+ void this.refresh();
301
+ }
302
+ async runRefresh() {
303
+ const refreshToken = this.current.refreshToken;
304
+ if (!refreshToken)
305
+ return false;
306
+ try {
307
+ const next = await this.auth.refresh({
308
+ refreshToken,
309
+ clientId: this.options.clientId,
310
+ clientSecret: this.options.clientSecret,
311
+ dpop: this.options.dpop,
312
+ });
313
+ await this.adopt({ ...next, organisation: next.organisation ?? this.current.organisation ?? null });
314
+ return true;
315
+ }
316
+ catch (error) {
317
+ if (error instanceof client_1.BackstageAPIError && error.statusCode >= 400 && error.statusCode < 500) {
318
+ this.ended = true;
319
+ await this.options.onSessionEnded?.(error);
320
+ }
321
+ return false;
322
+ }
323
+ }
324
+ async adopt(next) {
325
+ this.current = { ...next };
326
+ await this.options.onTokens?.({ ...next });
327
+ }
328
+ }
329
+ exports.AuthSession = AuthSession;
330
+ function createAuth(config) {
331
+ return new BackstageAuth(config);
332
+ }
@@ -0,0 +1,8 @@
1
+ export interface PkcePair {
2
+ verifier: string;
3
+ challenge: string;
4
+ method: 'S256';
5
+ }
6
+ export declare function pkceChallenge(verifier: string): Promise<string>;
7
+ export declare function pkce(): Promise<PkcePair>;
8
+ //# sourceMappingURL=pkce.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pkce.d.ts","sourceRoot":"","sources":["../../src/auth/pkce.ts"],"names":[],"mappings":"AAGA,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;CAChB;AAGD,wBAAsB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKrE;AAMD,wBAAsB,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,CAG9C"}
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pkceChallenge = pkceChallenge;
4
+ exports.pkce = pkce;
5
+ const encoding_1 = require("./encoding");
6
+ async function pkceChallenge(verifier) {
7
+ if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier)) {
8
+ throw new Error('A PKCE verifier is 43 to 128 characters of [A-Za-z0-9-._~] (RFC 7636 4.1)');
9
+ }
10
+ return (0, encoding_1.base64url)(await (0, encoding_1.sha256)(verifier));
11
+ }
12
+ async function pkce() {
13
+ const verifier = (0, encoding_1.base64url)((0, encoding_1.randomBytes)(32));
14
+ return { verifier, challenge: await pkceChallenge(verifier), method: 'S256' };
15
+ }
package/dist/client.d.ts CHANGED
@@ -66,6 +66,15 @@ export interface BackstageClientConfig {
66
66
  refreshEndpoint?: string;
67
67
  organisationSlug?: string | (() => string | undefined);
68
68
  headers?: Record<string, string>;
69
+ dpop?: DpopSigner;
70
+ credentials?: RequestCredentials;
71
+ }
72
+ export interface DpopSigner {
73
+ proof(input: {
74
+ method: string;
75
+ url: string;
76
+ accessToken?: string;
77
+ }): Promise<string>;
69
78
  }
70
79
  export declare const API_DATED_VERSION = "2026-06-13";
71
80
  export declare const SDK_CLIENT_HEADER: string;
@@ -79,6 +88,8 @@ export declare class BackstageClient {
79
88
  private onAuthFailure?;
80
89
  private refreshEndpoint?;
81
90
  private organisationSlug?;
91
+ private dpop?;
92
+ private credentials?;
82
93
  private debugConfig;
83
94
  private logger;
84
95
  constructor(config: BackstageClientConfig);
@@ -92,6 +103,7 @@ export declare class BackstageClient {
92
103
  private resolveOrgSlug;
93
104
  private getOrgFromSubdomain;
94
105
  private attemptTokenRefresh;
106
+ private authorise;
95
107
  private request;
96
108
  identity: {
97
109
  getSignInInteraction: (uid: string) => Promise<{