@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.
- package/README.md +156 -2
- package/dist/auth/dpop.d.ts +27 -0
- package/dist/auth/dpop.d.ts.map +1 -0
- package/dist/auth/dpop.js +51 -0
- package/dist/auth/encoding.d.ts +8 -0
- package/dist/auth/encoding.d.ts.map +1 -0
- package/dist/auth/encoding.js +53 -0
- package/dist/auth/index.d.ts +152 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +332 -0
- package/dist/auth/pkce.d.ts +8 -0
- package/dist/auth/pkce.d.ts.map +1 -0
- package/dist/auth/pkce.js +15 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +70 -14
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,8 +40,8 @@ are `BackstageAPIError` with `code` and `statusCode`.
|
|
|
40
40
|
|
|
41
41
|
Bearer mode has no default refresh endpoint. It used to POST `refreshToken`
|
|
42
42
|
to `/v1/auth/refresh`, which the API has removed; staff tokens are refreshed
|
|
43
|
-
at the identity issuer's `/oauth/token
|
|
44
|
-
that
|
|
43
|
+
at the identity issuer's `/oauth/token`, and `createAuth(...).createClient()`
|
|
44
|
+
(below) builds a client that does that by itself. On a 401 the client asks `onAuthFailure` if you gave one
|
|
45
45
|
(return `true` to retry the request, reading the token again), otherwise
|
|
46
46
|
POSTs `refreshToken` to `refreshEndpoint` if you set both (and hands the new
|
|
47
47
|
pair to `onTokenRefresh`), and otherwise throws `BackstageAPIError` with code
|
|
@@ -49,6 +49,160 @@ pair to `onTokenRefresh`), and otherwise throws `BackstageAPIError` with code
|
|
|
49
49
|
behind an `accessToken` resolver needs none of these. Cookie mode still
|
|
50
50
|
defaults `refreshEndpoint` to the host app's own `/api/oauth/refresh`.
|
|
51
51
|
|
|
52
|
+
## Signing in (first-party apps)
|
|
53
|
+
|
|
54
|
+
Ticketlayer's own apps (the Backstage shell, the packaged box office on the
|
|
55
|
+
desktop and on ChromeOS) sign a person in with one flow, in the shape of the
|
|
56
|
+
IETF draft "OAuth 2.0 for First-Party Applications": the app starts a sign-in
|
|
57
|
+
with its client id and a PKCE challenge, the person gives a password or an
|
|
58
|
+
emailed code (and a second factor when they have one), and the last step
|
|
59
|
+
answers a **one-time authorization code**, which the app redeems at the
|
|
60
|
+
issuer's `/oauth/token`. Tokens only ever come from the token endpoint, so
|
|
61
|
+
refresh rotation, revocation and DPoP binding work as for every other OAuth
|
|
62
|
+
client. The steps refuse any client that is not first party: a third party
|
|
63
|
+
uses the ordinary authorization code flow and the issuer's own pages.
|
|
64
|
+
|
|
65
|
+
The issuer is its own host, separate from the API (`https://auth.staging.t9r.dev`
|
|
66
|
+
beside `https://api.staging.t9r.dev`), so `createAuth` takes its own URL.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { createAuth } from '@ticketlayer/backstage';
|
|
70
|
+
|
|
71
|
+
const auth = createAuth({
|
|
72
|
+
issuerUrl: 'https://auth.staging.t9r.dev',
|
|
73
|
+
clientId: 'backstage-boxoffice-desktop', // or per call
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const pkce = await auth.pkce(); // { verifier, challenge, method: 'S256' }
|
|
77
|
+
const dpop = await auth.createDpopKey(); // non-extractable ECDSA P-256 (WebCrypto)
|
|
78
|
+
|
|
79
|
+
const step = await auth.start({ email, codeChallenge: pkce.challenge, dpop });
|
|
80
|
+
// { method: 'password', alternatives: ['email_code'], authSession, expiresIn }
|
|
81
|
+
|
|
82
|
+
let result = await auth.password({ authSession: step.authSession, password });
|
|
83
|
+
// or: await auth.sendEmailCode({ authSession }); then auth.emailCode({ authSession, code })
|
|
84
|
+
// -> { status: 'code', code, redirectUri } | { status: 'mfa_required', authSession, factors }
|
|
85
|
+
if (result.status === 'mfa_required') {
|
|
86
|
+
result = await auth.mfa({ authSession: result.authSession, code: totp });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const tokens = await auth.exchange({
|
|
90
|
+
code: result.code,
|
|
91
|
+
codeVerifier: pkce.verifier,
|
|
92
|
+
redirectUri: result.redirectUri,
|
|
93
|
+
dpop, // binds the tokens to the key
|
|
94
|
+
});
|
|
95
|
+
// { accessToken, tokenType: 'DPoP', expiresIn, expiresAt, refreshToken, organisation, ... }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **`start`** answers the same whether or not the address has an account.
|
|
99
|
+
Pass `organisation: '<slug>'` to pin the first token to one organisation;
|
|
100
|
+
without it the token is pinned to the person's only organisation, or to
|
|
101
|
+
none when they have several. `method: 'sso'` with a `redirectUrl` is in the
|
|
102
|
+
type for when the issuer does home-realm discovery; it does not answer it
|
|
103
|
+
yet.
|
|
104
|
+
- **A 401 from a step is the answer** (`INVALID_CREDENTIALS`,
|
|
105
|
+
`INVALID_SECOND_FACTOR`, `INVALID_AUTH_SESSION` when the ten-minute sign-in
|
|
106
|
+
has expired), thrown as `BackstageAPIError` with that code; it is never
|
|
107
|
+
treated as an expired session. Too many attempts is `SIGN_IN_RATE_LIMITED`
|
|
108
|
+
(429).
|
|
109
|
+
- **Token endpoint errors** are `BackstageAPIError` with the OAuth error as
|
|
110
|
+
`code` (`invalid_grant`, `invalid_client`, ...).
|
|
111
|
+
|
|
112
|
+
### A client that refreshes by itself
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
const session = auth.createSession({
|
|
116
|
+
baseUrl: 'https://api.staging.t9r.dev',
|
|
117
|
+
tokens, // or just { refreshToken } from the keychain
|
|
118
|
+
dpop, // exactly when the tokens are DPoP-bound
|
|
119
|
+
onTokens: (t) => keychain.set('refresh', t.refreshToken),
|
|
120
|
+
onSessionEnded: () => showSignIn(),
|
|
121
|
+
});
|
|
122
|
+
const client = session.client; // or auth.createClient({...}) for the client alone
|
|
123
|
+
|
|
124
|
+
await client.events.list();
|
|
125
|
+
await session.selectOrganisation('acme-events'); // RFC 8693 token exchange; the client follows
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The client is in bearer mode. Its token provider refreshes through
|
|
129
|
+
`auth.refresh` ahead of expiry (30 seconds by default) and on a 401, one
|
|
130
|
+
refresh at a time however many requests are waiting, and hands every new
|
|
131
|
+
token set to `onTokens`: the refresh token rotates on every use, so keep the
|
|
132
|
+
newest. With `dpop` every API call goes as `Authorization: DPoP` with a
|
|
133
|
+
fresh proof, which is how the API accepts a bound token. `X-Ticketlayer-Org`
|
|
134
|
+
is the organisation the tokens are pinned to unless you pass
|
|
135
|
+
`organisationSlug`. When the issuer refuses the refresh token (revoked,
|
|
136
|
+
expired or reused), `onSessionEnded` is called once and requests fail with
|
|
137
|
+
`SESSION_EXPIRED`.
|
|
138
|
+
|
|
139
|
+
`auth.refresh({ refreshToken, dpop })` and
|
|
140
|
+
`auth.selectOrganisation({ slug, refreshToken, dpop })` are there to call
|
|
141
|
+
directly too.
|
|
142
|
+
|
|
143
|
+
### DPoP
|
|
144
|
+
|
|
145
|
+
`createDpopKey()` makes an ECDSA P-256 pair whose private key is not
|
|
146
|
+
extractable: script can sign with it and never read it, so a token bound to it
|
|
147
|
+
is useless to whoever copies the token alone. Keep the `CryptoKeyPair` itself
|
|
148
|
+
(IndexedDB keeps it non-extractable) and pass it back as
|
|
149
|
+
`createDpopKey({ keyPair })` on the next launch. `dpopProof({ key, method, url,
|
|
150
|
+
accessToken?, nonce? })` makes one proof (`typ: dpop+jwt`, `alg: ES256`, the
|
|
151
|
+
public `jwk`; `htm`, `htu` without query or fragment, `iat`, a fresh `jti`,
|
|
152
|
+
and `ath` when an access token goes with it). A `use_dpop_nonce` answer from
|
|
153
|
+
the token endpoint is retried once with the nonce.
|
|
154
|
+
|
|
155
|
+
Where the password goes differs by host: an Electron app passes it over the
|
|
156
|
+
preload bridge and signs in from the main process; the isolated web app calls
|
|
157
|
+
the issuer itself (its origin is allowed on `/v1/auth/*` and `/oauth/token`
|
|
158
|
+
only). The shell's sign-in page calls the steps from the browser with
|
|
159
|
+
`createAuth({ ..., credentials: 'include' })`, so the issuer also leaves its
|
|
160
|
+
own login session, and redeems the code on its server.
|
|
161
|
+
|
|
162
|
+
### The shell's server: `clientSecret`
|
|
163
|
+
|
|
164
|
+
`backstage-shell` is a confidential client. Its server redeems the code and
|
|
165
|
+
refreshes with its secret:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
const tokens = await auth.exchange({ code, codeVerifier, redirectUri, clientSecret: process.env.SHELL_CLIENT_SECRET });
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The secret goes as HTTP Basic (`client_secret_basic`). **It is for server code
|
|
172
|
+
only and must never ship to a browser**, an Electron renderer or anything else
|
|
173
|
+
a person can open: a public client has no secret and proves itself with PKCE
|
|
174
|
+
and DPoP. The SDK refuses a `clientSecret` wherever `window` and `document`
|
|
175
|
+
exist.
|
|
176
|
+
|
|
177
|
+
### Nothing secret is logged
|
|
178
|
+
|
|
179
|
+
With `debug` on, the client prints `[redacted]` for the body and the answer of
|
|
180
|
+
every `/v1/auth/*` step, masks secret keys (`password`, `authSession`, tokens,
|
|
181
|
+
`client_secret`, `key`, `secret`) in every other body it logs, and the token
|
|
182
|
+
calls log only the grant type and the status. `test/auth-redaction.test.js`
|
|
183
|
+
runs a whole sign-in with every log on and fails if a password, code, handle,
|
|
184
|
+
verifier, secret or token appears.
|
|
185
|
+
|
|
186
|
+
### A smoke run against a real issuer
|
|
187
|
+
|
|
188
|
+
`scripts/smoke-auth.mjs` drives `start`, `password` (and `mfa`), `exchange`
|
|
189
|
+
with a DPoP-bound code, one API call and a refresh against a running issuer,
|
|
190
|
+
printing outcomes and never a secret:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
npm run build
|
|
194
|
+
TL_ISSUER_URL=https://auth.staging.t9r.dev TL_API_URL=https://api.staging.t9r.dev \
|
|
195
|
+
TL_EMAIL=you@example.com TL_PASSWORD=... TL_ORG=your-org \
|
|
196
|
+
node scripts/smoke-auth.mjs
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Against a local API (`API_SURFACE=all`, `IDENTITY_ISSUER=http://127.0.0.1:3501`,
|
|
200
|
+
migrations applied and a person with a password in `identity_service.people`)
|
|
201
|
+
point `TL_ISSUER_URL` at it and leave `TL_API_URL` unset. Name `TL_ORG` when the
|
|
202
|
+
person belongs to more than one organisation: the token is otherwise pinned to
|
|
203
|
+
none, and the issuer does not yet tell a first-party client which
|
|
204
|
+
organisations there are to choose from.
|
|
205
|
+
|
|
52
206
|
## Develop
|
|
53
207
|
|
|
54
208
|
```bash
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface DpopPublicJwk {
|
|
2
|
+
kty: 'EC';
|
|
3
|
+
crv: 'P-256';
|
|
4
|
+
x: string;
|
|
5
|
+
y: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DpopProofInput {
|
|
8
|
+
method: string;
|
|
9
|
+
url: string;
|
|
10
|
+
accessToken?: string;
|
|
11
|
+
nonce?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface DpopKey {
|
|
14
|
+
readonly keyPair: CryptoKeyPair;
|
|
15
|
+
readonly publicJwk: DpopPublicJwk;
|
|
16
|
+
readonly thumbprint: string;
|
|
17
|
+
proof(input: DpopProofInput): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
export declare function dpopHtu(url: string): string;
|
|
20
|
+
export declare function jwkThumbprint(jwk: DpopPublicJwk): Promise<string>;
|
|
21
|
+
export declare function createDpopKey(options?: {
|
|
22
|
+
keyPair?: CryptoKeyPair;
|
|
23
|
+
}): Promise<DpopKey>;
|
|
24
|
+
export declare function dpopProof(input: DpopProofInput & {
|
|
25
|
+
key: Pick<DpopKey, 'keyPair' | 'publicJwk'>;
|
|
26
|
+
}): Promise<string>;
|
|
27
|
+
//# sourceMappingURL=dpop.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dpop.d.ts","sourceRoot":"","sources":["../../src/auth/dpop.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,IAAI,CAAC;IACV,GAAG,EAAE,OAAO,CAAC;IACb,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED,MAAM,WAAW,cAAc;IAE7B,MAAM,EAAE,MAAM,CAAC;IAEf,GAAG,EAAE,MAAM,CAAC;IAEZ,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,OAAO;IAEtB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAElC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,KAAK,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAC/C;AAKD,wBAAgB,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAG3C;AAGD,wBAAsB,aAAa,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,MAAM,CAAC,CAEvE;AAMD,wBAAsB,aAAa,CAAC,OAAO,GAAE;IAAE,OAAO,CAAC,EAAE,aAAa,CAAA;CAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAqB/F;AAQD,wBAAsB,SAAS,CAAC,KAAK,EAAE,cAAc,GAAG;IAAE,GAAG,EAAE,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,WAAW,CAAC,CAAA;CAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAoBxH"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dpopHtu = dpopHtu;
|
|
4
|
+
exports.jwkThumbprint = jwkThumbprint;
|
|
5
|
+
exports.createDpopKey = createDpopKey;
|
|
6
|
+
exports.dpopProof = dpopProof;
|
|
7
|
+
const encoding_1 = require("./encoding");
|
|
8
|
+
const ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256' };
|
|
9
|
+
function dpopHtu(url) {
|
|
10
|
+
const parsed = new URL(url);
|
|
11
|
+
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
12
|
+
}
|
|
13
|
+
async function jwkThumbprint(jwk) {
|
|
14
|
+
return (0, encoding_1.base64url)(await (0, encoding_1.sha256)(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y })));
|
|
15
|
+
}
|
|
16
|
+
async function createDpopKey(options = {}) {
|
|
17
|
+
const subtle = (0, encoding_1.webCrypto)().subtle;
|
|
18
|
+
const keyPair = options.keyPair ?? (await subtle.generateKey(ALGORITHM, false, ['sign', 'verify']));
|
|
19
|
+
if (keyPair.privateKey.extractable) {
|
|
20
|
+
throw new Error('A DPoP private key must not be extractable');
|
|
21
|
+
}
|
|
22
|
+
const exported = await subtle.exportKey('jwk', keyPair.publicKey);
|
|
23
|
+
if (exported.kty !== 'EC' || exported.crv !== 'P-256' || !exported.x || !exported.y) {
|
|
24
|
+
throw new Error('A DPoP key must be an ECDSA P-256 key');
|
|
25
|
+
}
|
|
26
|
+
const publicJwk = { kty: 'EC', crv: 'P-256', x: exported.x, y: exported.y };
|
|
27
|
+
const thumbprint = await jwkThumbprint(publicJwk);
|
|
28
|
+
const key = {
|
|
29
|
+
keyPair,
|
|
30
|
+
publicJwk,
|
|
31
|
+
thumbprint,
|
|
32
|
+
proof: (input) => dpopProof({ key, ...input }),
|
|
33
|
+
};
|
|
34
|
+
return key;
|
|
35
|
+
}
|
|
36
|
+
async function dpopProof(input) {
|
|
37
|
+
const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: input.key.publicJwk };
|
|
38
|
+
const payload = {
|
|
39
|
+
jti: (0, encoding_1.base64url)((0, encoding_1.randomBytes)(16)),
|
|
40
|
+
htm: input.method.toUpperCase(),
|
|
41
|
+
htu: dpopHtu(input.url),
|
|
42
|
+
iat: Math.floor(Date.now() / 1000),
|
|
43
|
+
};
|
|
44
|
+
if (input.accessToken)
|
|
45
|
+
payload.ath = (0, encoding_1.base64url)(await (0, encoding_1.sha256)(input.accessToken));
|
|
46
|
+
if (input.nonce)
|
|
47
|
+
payload.nonce = input.nonce;
|
|
48
|
+
const signingInput = `${(0, encoding_1.base64url)((0, encoding_1.utf8)(JSON.stringify(header)))}.${(0, encoding_1.base64url)((0, encoding_1.utf8)(JSON.stringify(payload)))}`;
|
|
49
|
+
const signature = await (0, encoding_1.webCrypto)().subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, input.key.keyPair.privateKey, (0, encoding_1.utf8)(signingInput));
|
|
50
|
+
return `${signingInput}.${(0, encoding_1.base64url)(signature)}`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function webCrypto(): Crypto;
|
|
2
|
+
export declare function base64url(bytes: ArrayBuffer | Uint8Array): string;
|
|
3
|
+
export declare function base64urlDecode(value: string): Uint8Array;
|
|
4
|
+
export declare const utf8: (value: string) => Uint8Array<ArrayBuffer>;
|
|
5
|
+
export declare function randomBytes(length: number): Uint8Array<ArrayBuffer>;
|
|
6
|
+
export declare function sha256(value: string): Promise<Uint8Array>;
|
|
7
|
+
export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | undefined;
|
|
8
|
+
//# sourceMappingURL=encoding.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"encoding.d.ts","sourceRoot":"","sources":["../../src/auth/encoding.ts"],"names":[],"mappings":"AAOA,wBAAgB,SAAS,IAAI,MAAM,CAQlC;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,WAAW,GAAG,UAAU,GAAG,MAAM,CAKjE;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,UAAU,CAMzD;AAED,eAAO,MAAM,IAAI,GAAI,OAAO,MAAM,KAAG,UAAU,CAAC,WAAW,CAA+D,CAAC;AAE3H,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAInE;AAED,wBAAsB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAE/D;AAGD,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CASjF"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.utf8 = void 0;
|
|
4
|
+
exports.webCrypto = webCrypto;
|
|
5
|
+
exports.base64url = base64url;
|
|
6
|
+
exports.base64urlDecode = base64urlDecode;
|
|
7
|
+
exports.randomBytes = randomBytes;
|
|
8
|
+
exports.sha256 = sha256;
|
|
9
|
+
exports.decodeJwtPayload = decodeJwtPayload;
|
|
10
|
+
function webCrypto() {
|
|
11
|
+
const c = globalThis.crypto;
|
|
12
|
+
if (!c || !c.subtle || typeof c.getRandomValues !== 'function') {
|
|
13
|
+
throw new Error('@ticketlayer/backstage auth needs WebCrypto (globalThis.crypto.subtle): a browser, or Node 20 or later');
|
|
14
|
+
}
|
|
15
|
+
return c;
|
|
16
|
+
}
|
|
17
|
+
function base64url(bytes) {
|
|
18
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
19
|
+
let binary = '';
|
|
20
|
+
for (let i = 0; i < view.length; i += 1)
|
|
21
|
+
binary += String.fromCharCode(view[i]);
|
|
22
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
23
|
+
}
|
|
24
|
+
function base64urlDecode(value) {
|
|
25
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4);
|
|
26
|
+
const binary = atob(padded);
|
|
27
|
+
const out = new Uint8Array(binary.length);
|
|
28
|
+
for (let i = 0; i < binary.length; i += 1)
|
|
29
|
+
out[i] = binary.charCodeAt(i);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
const utf8 = (value) => new TextEncoder().encode(value);
|
|
33
|
+
exports.utf8 = utf8;
|
|
34
|
+
function randomBytes(length) {
|
|
35
|
+
const bytes = new Uint8Array(new ArrayBuffer(length));
|
|
36
|
+
webCrypto().getRandomValues(bytes);
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
async function sha256(value) {
|
|
40
|
+
return new Uint8Array(await webCrypto().subtle.digest('SHA-256', (0, exports.utf8)(value)));
|
|
41
|
+
}
|
|
42
|
+
function decodeJwtPayload(jwt) {
|
|
43
|
+
const part = jwt.split('.')[1];
|
|
44
|
+
if (!part)
|
|
45
|
+
return undefined;
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(new TextDecoder().decode(base64urlDecode(part)));
|
|
48
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { BackstageAPIError, BackstageClient, type BackstageClientConfig, type DebugConfig } from '../client';
|
|
2
|
+
import { createDpopKey, dpopProof, type DpopKey } from './dpop';
|
|
3
|
+
import { pkce, pkceChallenge } from './pkce';
|
|
4
|
+
export { pkce, pkceChallenge, type PkcePair } from './pkce';
|
|
5
|
+
export { createDpopKey, dpopHtu, dpopProof, jwkThumbprint, type DpopKey, type DpopProofInput, type DpopPublicJwk, } from './dpop';
|
|
6
|
+
export declare const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
7
|
+
export declare const REFRESH_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:refresh_token";
|
|
8
|
+
export declare const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
|
|
9
|
+
export interface AuthConfig {
|
|
10
|
+
issuerUrl: string;
|
|
11
|
+
clientId?: string;
|
|
12
|
+
tokenEndpoint?: string;
|
|
13
|
+
credentials?: RequestCredentials;
|
|
14
|
+
debug?: boolean | DebugConfig;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export type SignInMethod = 'password' | 'email_code' | 'sso';
|
|
18
|
+
export type SecondFactor = 'totp' | 'email';
|
|
19
|
+
export interface StartInput {
|
|
20
|
+
email: string;
|
|
21
|
+
clientId?: string;
|
|
22
|
+
codeChallenge: string;
|
|
23
|
+
dpop?: Pick<DpopKey, 'thumbprint'>;
|
|
24
|
+
redirectUri?: string;
|
|
25
|
+
scope?: string;
|
|
26
|
+
organisation?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface StartResult {
|
|
29
|
+
method: SignInMethod;
|
|
30
|
+
authSession: string;
|
|
31
|
+
alternatives: SignInMethod[];
|
|
32
|
+
expiresIn: number;
|
|
33
|
+
redirectUrl?: string;
|
|
34
|
+
}
|
|
35
|
+
export type StepResult = {
|
|
36
|
+
status: 'code';
|
|
37
|
+
code: string;
|
|
38
|
+
redirectUri: string;
|
|
39
|
+
} | {
|
|
40
|
+
status: 'mfa_required';
|
|
41
|
+
authSession: string;
|
|
42
|
+
factors: SecondFactor[];
|
|
43
|
+
};
|
|
44
|
+
export interface ClientCredentials {
|
|
45
|
+
clientId?: string;
|
|
46
|
+
clientSecret?: string;
|
|
47
|
+
dpop?: DpopKey;
|
|
48
|
+
}
|
|
49
|
+
export interface ExchangeInput extends ClientCredentials {
|
|
50
|
+
code: string;
|
|
51
|
+
codeVerifier: string;
|
|
52
|
+
redirectUri?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface RefreshInput extends ClientCredentials {
|
|
55
|
+
refreshToken: string;
|
|
56
|
+
}
|
|
57
|
+
export interface SelectOrganisationInput extends ClientCredentials {
|
|
58
|
+
slug: string;
|
|
59
|
+
refreshToken?: string;
|
|
60
|
+
accessToken?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface OrganisationRef {
|
|
63
|
+
slug: string;
|
|
64
|
+
name: string | null;
|
|
65
|
+
}
|
|
66
|
+
export interface TokenSet {
|
|
67
|
+
accessToken: string;
|
|
68
|
+
tokenType: string;
|
|
69
|
+
expiresIn: number;
|
|
70
|
+
expiresAt: number;
|
|
71
|
+
refreshToken?: string;
|
|
72
|
+
scope?: string;
|
|
73
|
+
idToken?: string;
|
|
74
|
+
organisation: OrganisationRef | null;
|
|
75
|
+
organisations?: {
|
|
76
|
+
id: string;
|
|
77
|
+
slug: string;
|
|
78
|
+
name: string;
|
|
79
|
+
}[];
|
|
80
|
+
}
|
|
81
|
+
export type StoredTokens = Partial<TokenSet> & {
|
|
82
|
+
accessToken?: string;
|
|
83
|
+
refreshToken?: string;
|
|
84
|
+
};
|
|
85
|
+
export interface SessionOptions extends Omit<BackstageClientConfig, 'authMode' | 'accessToken' | 'refreshToken' | 'onAuthFailure' | 'onTokenRefresh' | 'refreshEndpoint' | 'dpop'> {
|
|
86
|
+
tokens: StoredTokens;
|
|
87
|
+
clientId?: string;
|
|
88
|
+
clientSecret?: string;
|
|
89
|
+
dpop?: DpopKey;
|
|
90
|
+
onTokens?: (tokens: TokenSet) => void | Promise<void>;
|
|
91
|
+
onSessionEnded?: (error: BackstageAPIError) => void | Promise<void>;
|
|
92
|
+
refreshLeewaySeconds?: number;
|
|
93
|
+
}
|
|
94
|
+
export declare class BackstageAuth {
|
|
95
|
+
readonly issuerUrl: string;
|
|
96
|
+
readonly tokenEndpoint: string;
|
|
97
|
+
private readonly clientId?;
|
|
98
|
+
private readonly issuer;
|
|
99
|
+
private readonly logger;
|
|
100
|
+
private readonly debugConfig;
|
|
101
|
+
constructor(config: AuthConfig);
|
|
102
|
+
pkce: typeof pkce;
|
|
103
|
+
pkceChallenge: typeof pkceChallenge;
|
|
104
|
+
createDpopKey: typeof createDpopKey;
|
|
105
|
+
dpopProof: typeof dpopProof;
|
|
106
|
+
start(input: StartInput): Promise<StartResult>;
|
|
107
|
+
password(input: {
|
|
108
|
+
authSession: string;
|
|
109
|
+
password: string;
|
|
110
|
+
}): Promise<StepResult>;
|
|
111
|
+
sendEmailCode(input: {
|
|
112
|
+
authSession: string;
|
|
113
|
+
}): Promise<{
|
|
114
|
+
status: 'code_sent';
|
|
115
|
+
}>;
|
|
116
|
+
emailCode(input: {
|
|
117
|
+
authSession: string;
|
|
118
|
+
code: string;
|
|
119
|
+
}): Promise<StepResult>;
|
|
120
|
+
mfa(input: {
|
|
121
|
+
authSession: string;
|
|
122
|
+
code: string;
|
|
123
|
+
}): Promise<StepResult>;
|
|
124
|
+
private step;
|
|
125
|
+
exchange(input: ExchangeInput): Promise<TokenSet>;
|
|
126
|
+
refresh(input: RefreshInput): Promise<TokenSet>;
|
|
127
|
+
selectOrganisation(input: SelectOrganisationInput): Promise<TokenSet>;
|
|
128
|
+
private token;
|
|
129
|
+
private clientIdOf;
|
|
130
|
+
createSession(options: SessionOptions): AuthSession;
|
|
131
|
+
createClient(options: SessionOptions): BackstageClient;
|
|
132
|
+
}
|
|
133
|
+
export declare class AuthSession {
|
|
134
|
+
private readonly auth;
|
|
135
|
+
private readonly options;
|
|
136
|
+
readonly client: BackstageClient;
|
|
137
|
+
private current;
|
|
138
|
+
private pending?;
|
|
139
|
+
private ended;
|
|
140
|
+
private readonly leewayMs;
|
|
141
|
+
constructor(auth: BackstageAuth, options: SessionOptions);
|
|
142
|
+
get tokens(): StoredTokens;
|
|
143
|
+
get organisation(): OrganisationRef | null;
|
|
144
|
+
get hasEnded(): boolean;
|
|
145
|
+
refresh(): Promise<boolean>;
|
|
146
|
+
selectOrganisation(slug: string): Promise<TokenSet>;
|
|
147
|
+
private refreshAhead;
|
|
148
|
+
private runRefresh;
|
|
149
|
+
private adopt;
|
|
150
|
+
}
|
|
151
|
+
export declare function createAuth(config: AuthConfig): BackstageAuth;
|
|
152
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"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"}
|