@ticketlayer/backstage 3.1.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
@@ -12,14 +12,15 @@ type CreateTagDefinitionRequest = components['schemas']['CreateTagDefinitionRequ
12
12
  type CreateUserInvitationRequest = components['schemas']['CreateUserInvitationRequest'];
13
13
  type CreateWebhookEndpoint = components['schemas']['CreateWebhookEndpoint'];
14
14
  type GrantSalesChannelAccessRequest = components['schemas']['GrantSalesChannelAccessRequest'];
15
- type LoginRequest = components['schemas']['LoginRequest'];
16
- type RedeemInvitationRequest = components['schemas']['RedeemInvitationRequest'];
17
- type RefreshTokenRequest = components['schemas']['RefreshTokenRequest'];
18
15
  type SavePaymentMethodRequest = components['schemas']['SavePaymentMethodRequest'];
19
16
  type SignInInteractionLogin = components['schemas']['SignInInteractionLogin'];
20
17
  type SignInInteractionOrganisation = components['schemas']['SignInInteractionOrganisation'];
21
18
  type SignUpRequest = components['schemas']['SignUpRequest'];
22
19
  type StaffUnlockRequest = components['schemas']['StaffUnlockRequest'];
20
+ type StartSignInRequest = components['schemas']['StartSignInRequest'];
21
+ type SubmitSignInEmailCodeRequest = components['schemas']['SubmitSignInEmailCodeRequest'];
22
+ type SubmitSignInMfaRequest = components['schemas']['SubmitSignInMfaRequest'];
23
+ type SubmitSignInPasswordRequest = components['schemas']['SubmitSignInPasswordRequest'];
23
24
  type UpdateAutoTopupRequest = components['schemas']['UpdateAutoTopupRequest'];
24
25
  type UpdateCodeRequest = components['schemas']['UpdateCodeRequest'];
25
26
  type UpdateIdentityProviderRequest = components['schemas']['UpdateIdentityProviderRequest'];
@@ -65,6 +66,15 @@ export interface BackstageClientConfig {
65
66
  refreshEndpoint?: string;
66
67
  organisationSlug?: string | (() => string | undefined);
67
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>;
68
78
  }
69
79
  export declare const API_DATED_VERSION = "2026-06-13";
70
80
  export declare const SDK_CLIENT_HEADER: string;
@@ -76,8 +86,10 @@ export declare class BackstageClient {
76
86
  private headers;
77
87
  private onTokenRefresh?;
78
88
  private onAuthFailure?;
79
- private refreshEndpoint;
89
+ private refreshEndpoint?;
80
90
  private organisationSlug?;
91
+ private dpop?;
92
+ private credentials?;
81
93
  private debugConfig;
82
94
  private logger;
83
95
  constructor(config: BackstageClientConfig);
@@ -91,6 +103,7 @@ export declare class BackstageClient {
91
103
  private resolveOrgSlug;
92
104
  private getOrgFromSubdomain;
93
105
  private attemptTokenRefresh;
106
+ private authorise;
94
107
  private request;
95
108
  identity: {
96
109
  getSignInInteraction: (uid: string) => Promise<{
@@ -130,6 +143,41 @@ export declare class BackstageClient {
130
143
  status: "continue";
131
144
  redirectTo: string;
132
145
  }>;
146
+ start: (request: StartSignInRequest) => Promise<{
147
+ authSession: string;
148
+ method: "password";
149
+ alternatives: "email_code"[];
150
+ expiresIn: number;
151
+ }>;
152
+ password: (request: SubmitSignInPasswordRequest) => Promise<{
153
+ status: "code";
154
+ code: string;
155
+ redirectUri: string;
156
+ } | {
157
+ status: "mfa_required";
158
+ authSession: string;
159
+ factors: ("totp" | "email")[];
160
+ }>;
161
+ emailCode: (request: SubmitSignInEmailCodeRequest) => Promise<{
162
+ status: "code";
163
+ code: string;
164
+ redirectUri: string;
165
+ } | {
166
+ status: "mfa_required";
167
+ authSession: string;
168
+ factors: ("totp" | "email")[];
169
+ } | {
170
+ status: "code_sent";
171
+ }>;
172
+ mfa: (request: SubmitSignInMfaRequest) => Promise<{
173
+ status: "code";
174
+ code: string;
175
+ redirectUri: string;
176
+ } | {
177
+ status: "mfa_required";
178
+ authSession: string;
179
+ factors: ("totp" | "email")[];
180
+ }>;
133
181
  };
134
182
  identityProviders: {
135
183
  discover: (options?: {
@@ -235,32 +283,6 @@ export declare class BackstageClient {
235
283
  versions: components["schemas"]["ApiVersion"][];
236
284
  }>;
237
285
  };
238
- authentication: {
239
- login: (request: LoginRequest) => Promise<{
240
- accessToken: string;
241
- refreshToken: string;
242
- user: {
243
- id: string;
244
- email: string;
245
- firstName?: string;
246
- lastName?: string;
247
- };
248
- }>;
249
- refresh: (request: RefreshTokenRequest) => Promise<{
250
- accessToken: string;
251
- refreshToken: string;
252
- }>;
253
- redeem: (request: RedeemInvitationRequest) => Promise<{
254
- accessToken: string;
255
- refreshToken: string;
256
- user: {
257
- id: string;
258
- email: string;
259
- firstName?: string;
260
- lastName?: string;
261
- };
262
- }>;
263
- };
264
286
  signup: {
265
287
  signUp: (request: SignUpRequest) => Promise<{
266
288
  organisation: {