@idp.global/sdk 19.2.0 → 19.3.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.
@@ -1,5 +1,6 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { RelyingPartyDatabaseUnavailableError } from './classes.errors.js';
3
+ import type { TRelyingPartyBearerClientKind } from './interfaces.js';
3
4
 
4
5
  /**
5
6
  * One browser session of a relying party. The persisted field names, their
@@ -62,7 +63,8 @@ export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<Relyi
62
63
  * against, the binding to the browser that started it, the sealed PKCE
63
64
  * verifier and the return path. Consumed exactly once by a conditional update.
64
65
  * Field names and indexes are the ones nevermind.finance stores under
65
- * `LoginAttemptDoc`.
66
+ * `LoginAttemptDoc`; the `bearer…` fields exist only on the attempt of a
67
+ * bearer login, whose binding token is never issued.
66
68
  */
67
69
  export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyLoginAttemptDoc, RelyingPartyLoginAttemptDoc> {
68
70
  @plugins.nosqldb.unI()
@@ -103,11 +105,98 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
103
105
  /** the session the callback created; finalize releases the return path against it */
104
106
  @plugins.nosqldb.svDb()
105
107
  public createdSessionId?: string | null;
108
+
109
+ /** how the bearer client of this login receives its outcome; absent on a cookie login */
110
+ @plugins.nosqldb.svDb()
111
+ public bearerClientKind?: TRelyingPartyBearerClientKind;
112
+
113
+ /** the return target the bearer client named, exactly */
114
+ @plugins.nosqldb.svDb()
115
+ public bearerReturnTarget?: string;
116
+
117
+ /** the bearer client's S256 code challenge; its hand-off is claimed with the matching verifier */
118
+ @plugins.nosqldb.svDb()
119
+ public bearerCodeChallenge?: string;
120
+
121
+ /** the bearer client's own `state`, handed back with the outcome */
122
+ @plugins.nosqldb.svDb()
123
+ public bearerClientState?: string;
124
+ }
125
+
126
+ /**
127
+ * The single-use code a bearer login hands its client. Only the hash of the
128
+ * code is stored; the code is bound to the client's S256 challenge, lives at
129
+ * most a minute and is consumed by one conditional update.
130
+ */
131
+ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerHandoffDoc, RelyingPartyBearerHandoffDoc> {
132
+ @plugins.nosqldb.unI()
133
+ public id: string = '';
134
+
135
+ /** sha256 of the code */
136
+ @plugins.nosqldb.unI()
137
+ public codeHash: string = '';
138
+
139
+ /** the session the login opened; the code is its only way out */
140
+ @plugins.nosqldb.svDb()
141
+ public sessionId: string = '';
142
+
143
+ @plugins.nosqldb.svDb()
144
+ public codeChallenge: string = '';
145
+
146
+ @plugins.nosqldb.svDb()
147
+ public createdAt: number = 0;
148
+
149
+ /** at most a minute after the callback; the sweep ends the session of a code nobody claimed by then */
150
+ @plugins.nosqldb.svDb()
151
+ @plugins.nosqldb.index()
152
+ public expiresAt: number = 0;
153
+
154
+ /** `null` until a claim consumes the code */
155
+ @plugins.nosqldb.svDb()
156
+ public consumedAt: number | null = null;
157
+ }
158
+
159
+ /**
160
+ * One generation of a bearer session's tokens: an access token and the
161
+ * refresh token that replaces both. Only hashes are stored. Exactly one
162
+ * generation of a session is current (`rotatedAt` `null`); a rotated one is
163
+ * kept until the session expires, so its refresh token coming back is
164
+ * recognized as reuse.
165
+ */
166
+ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerTokenDoc, RelyingPartyBearerTokenDoc> {
167
+ @plugins.nosqldb.unI()
168
+ public id: string = '';
169
+
170
+ @plugins.nosqldb.unI()
171
+ public accessTokenHash: string = '';
172
+
173
+ @plugins.nosqldb.unI()
174
+ public refreshTokenHash: string = '';
175
+
176
+ @plugins.nosqldb.svDb()
177
+ public sessionId: string = '';
178
+
179
+ @plugins.nosqldb.svDb()
180
+ public createdAt: number = 0;
181
+
182
+ @plugins.nosqldb.svDb()
183
+ public accessTokenExpiresAt: number = 0;
184
+
185
+ /** the session's end; the refresh token never outlives it, and the sweep removes the generation after it */
186
+ @plugins.nosqldb.svDb()
187
+ @plugins.nosqldb.index()
188
+ public expiresAt: number = 0;
189
+
190
+ /** `null` while this generation is current; when its refresh token was spent otherwise */
191
+ @plugins.nosqldb.svDb()
192
+ public rotatedAt: number | null = null;
106
193
  }
107
194
 
108
195
  export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
109
196
  sessions: 'IdpRelyingPartySession',
110
197
  loginAttempts: 'IdpRelyingPartyLoginAttempt',
198
+ bearerHandoffs: 'IdpRelyingPartyBearerHandoff',
199
+ bearerTokens: 'IdpRelyingPartyBearerToken',
111
200
  } as const;
112
201
 
113
202
  export interface IRelyingPartyModelsOptions {
@@ -125,22 +214,37 @@ export interface IRelyingPartyModelsOptions {
125
214
  collectionNames?: {
126
215
  sessions?: string;
127
216
  loginAttempts?: string;
217
+ bearerHandoffs?: string;
218
+ bearerTokens?: string;
128
219
  };
129
220
  }
130
221
 
131
- export interface IRelyingPartyModels {
222
+ /** The models bearer hand-off mode stores its codes and tokens in. */
223
+ export interface IRelyingPartyBearerModels {
224
+ readonly BearerHandoff: typeof RelyingPartyBearerHandoffDoc;
225
+ readonly BearerToken: typeof RelyingPartyBearerTokenDoc;
226
+ }
227
+
228
+ /**
229
+ * The models of the session layer. The bearer models are needed only when
230
+ * `RelyingPartySessions` runs with `bearer`; `createRelyingPartyModels`
231
+ * always binds them.
232
+ */
233
+ export interface IRelyingPartyModels extends Partial<IRelyingPartyBearerModels> {
132
234
  readonly Session: typeof RelyingPartySessionDoc;
133
235
  readonly LoginAttempt: typeof RelyingPartyLoginAttemptDoc;
134
236
  }
135
237
 
136
238
  /**
137
- * Binds the two relying-party models to one database. Each call declares its
239
+ * Binds the relying-party models to one database. Each call declares its
138
240
  * own model classes, so several databases — and several applications in one
139
241
  * process — never share a binding. A deferred `db` is resolved on every use,
140
242
  * the way `@Collection(() => db)` resolves it; a use before it answers a
141
243
  * `SmartdataDb` throws `RelyingPartyDatabaseUnavailableError`.
142
244
  */
143
- export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions): IRelyingPartyModels => {
245
+ export const createRelyingPartyModels = (
246
+ optionsArg: IRelyingPartyModelsOptions,
247
+ ): IRelyingPartyModels & IRelyingPartyBearerModels => {
144
248
  const dbOption = optionsArg.db;
145
249
  if (!(dbOption instanceof plugins.nosqldb.SmartdataDb) && typeof dbOption !== 'function') {
146
250
  throw new Error('createRelyingPartyModels needs a SmartdataDb of @lossless.org/client/nosqldb or a function answering one');
@@ -163,6 +267,10 @@ export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions)
163
267
  const sessions = optionsArg.collectionNames?.sessions ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.sessions;
164
268
  const loginAttempts = optionsArg.collectionNames?.loginAttempts
165
269
  ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.loginAttempts;
270
+ const bearerHandoffs = optionsArg.collectionNames?.bearerHandoffs
271
+ ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.bearerHandoffs;
272
+ const bearerTokens = optionsArg.collectionNames?.bearerTokens
273
+ ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.bearerTokens;
166
274
 
167
275
  @plugins.nosqldb.Collection(resolveDb, { collectionName: sessions })
168
276
  class BoundSessionDoc extends RelyingPartySessionDoc {}
@@ -170,5 +278,16 @@ export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions)
170
278
  @plugins.nosqldb.Collection(resolveDb, { collectionName: loginAttempts })
171
279
  class BoundLoginAttemptDoc extends RelyingPartyLoginAttemptDoc {}
172
280
 
173
- return { Session: BoundSessionDoc, LoginAttempt: BoundLoginAttemptDoc };
281
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: bearerHandoffs })
282
+ class BoundBearerHandoffDoc extends RelyingPartyBearerHandoffDoc {}
283
+
284
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: bearerTokens })
285
+ class BoundBearerTokenDoc extends RelyingPartyBearerTokenDoc {}
286
+
287
+ return {
288
+ Session: BoundSessionDoc,
289
+ LoginAttempt: BoundLoginAttemptDoc,
290
+ BearerHandoff: BoundBearerHandoffDoc,
291
+ BearerToken: BoundBearerTokenDoc,
292
+ };
174
293
  };
@@ -0,0 +1,260 @@
1
+ import { sha256, timingSafeEqualString } from './helpers.crypto.js';
2
+ import { sanitizeReturnPath } from './helpers.returnpath.js';
3
+ import type {
4
+ IRelyingPartyBearerDelivery,
5
+ IRelyingPartyBearerMessage,
6
+ IRelyingPartyBearerReturnTargets,
7
+ TRelyingPartyBearerClientKind,
8
+ } from './interfaces.js';
9
+
10
+ export const RELYING_PARTY_BEARER_CLIENT_KINDS: readonly TRelyingPartyBearerClientKind[] = Object.freeze([
11
+ 'web',
12
+ 'loopback',
13
+ 'custom_scheme',
14
+ 'popup',
15
+ ]);
16
+
17
+ /** The `type` of every message a popup delivery posts. */
18
+ export const RELYING_PARTY_BEARER_MESSAGE_TYPE = 'idp.global/relyingparty:bearer-handoff';
19
+
20
+ /** A return target is a location the application configured, not a place to park data. */
21
+ const MAX_RETURN_TARGET_LENGTH = 2_048;
22
+ /** BASE64URL of a SHA-256 digest, without padding (RFC 7636 §4.2). */
23
+ const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
24
+ /** RFC 7636 §4.1: 43 to 128 unreserved characters. */
25
+ const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
26
+ const CLIENT_STATE = /^[A-Za-z0-9._~-]{1,256}$/u;
27
+ const LOOPBACK_HOSTS: readonly string[] = ['127.0.0.1', '[::1]'];
28
+ /** A private-use scheme is a reverse domain name: it has a dot, and no web scheme has one (RFC 8252 §7.1). */
29
+ const CUSTOM_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/u;
30
+
31
+ export const isBearerClientKind = (valueArg: unknown): valueArg is TRelyingPartyBearerClientKind =>
32
+ typeof valueArg === 'string' && (RELYING_PARTY_BEARER_CLIENT_KINDS as readonly string[]).includes(valueArg);
33
+
34
+ export const isPkceChallenge = (valueArg: unknown): valueArg is string =>
35
+ typeof valueArg === 'string' && PKCE_CHALLENGE.test(valueArg);
36
+
37
+ export const isPkceVerifier = (valueArg: unknown): valueArg is string =>
38
+ typeof valueArg === 'string' && PKCE_VERIFIER.test(valueArg);
39
+
40
+ export const isBearerClientState = (valueArg: unknown): valueArg is string =>
41
+ typeof valueArg === 'string' && CLIENT_STATE.test(valueArg);
42
+
43
+ /** The S256 challenge of a verifier: BASE64URL(SHA-256(ASCII(verifier))). */
44
+ export const pkceChallengeOf = (verifierArg: string): string => sha256(verifierArg);
45
+
46
+ /** Whether a verifier answers a challenge under S256, compared in constant time. */
47
+ export const verifyPkceChallenge = (verifierArg: unknown, challengeArg: string): boolean =>
48
+ isPkceVerifier(verifierArg) && challengeArg.length > 0 && timingSafeEqualString(pkceChallengeOf(verifierArg), challengeArg);
49
+
50
+ const parseUrl = (valueArg: string): URL | null => {
51
+ try {
52
+ return new URL(valueArg);
53
+ } catch {
54
+ return null;
55
+ }
56
+ };
57
+
58
+ /** A URL as given, in its canonical form, with nothing that a delivery would have to merge into. */
59
+ const plainUrl = (valueArg: unknown): URL | null => {
60
+ if (typeof valueArg !== 'string' || valueArg.length === 0 || valueArg.length > MAX_RETURN_TARGET_LENGTH) {
61
+ return null;
62
+ }
63
+ const parsed = parseUrl(valueArg);
64
+ if (
65
+ !parsed
66
+ || parsed.href !== valueArg
67
+ || parsed.username !== ''
68
+ || parsed.password !== ''
69
+ || parsed.search !== ''
70
+ || parsed.hash !== ''
71
+ ) {
72
+ return null;
73
+ }
74
+ return parsed;
75
+ };
76
+
77
+ const configuredTarget = (
78
+ kindArg: TRelyingPartyBearerClientKind,
79
+ entryArg: unknown,
80
+ publicOriginArg: string,
81
+ loginPathPrefixArg: string,
82
+ ): boolean => {
83
+ // targets are compared exactly; an entry that reads like a wildcard is a mistake, said at once
84
+ if (typeof entryArg === 'string' && entryArg.includes('*')) {
85
+ return false;
86
+ }
87
+ if (kindArg === 'web') {
88
+ return typeof entryArg === 'string'
89
+ && !entryArg.includes('?')
90
+ && !entryArg.includes('#')
91
+ && sanitizeReturnPath(entryArg, publicOriginArg, loginPathPrefixArg) === entryArg;
92
+ }
93
+ if (kindArg === 'popup') {
94
+ const parsed = typeof entryArg === 'string' ? parseUrl(entryArg) : null;
95
+ return parsed !== null
96
+ && (parsed.protocol === 'https:' || parsed.protocol === 'http:')
97
+ && parsed.origin === entryArg;
98
+ }
99
+ const parsed = plainUrl(entryArg);
100
+ if (!parsed) {
101
+ return false;
102
+ }
103
+ if (kindArg === 'loopback') {
104
+ return parsed.protocol === 'http:' && LOOPBACK_HOSTS.includes(parsed.hostname) && parsed.port === '';
105
+ }
106
+ return CUSTOM_SCHEME.test(parsed.protocol);
107
+ };
108
+
109
+ /**
110
+ * The return targets of a deployment, checked once: every entry has to be
111
+ * the exact canonical form a client can name, or the configuration is
112
+ * refused. A target with a query or a fragment is refused too, because the
113
+ * delivery adds its own query, and so is one with a `*`: nothing is a wildcard.
114
+ */
115
+ export const normalizeBearerReturnTargets = (
116
+ targetsArg: IRelyingPartyBearerReturnTargets,
117
+ publicOriginArg: string,
118
+ loginPathPrefixArg: string,
119
+ ): Readonly<Required<IRelyingPartyBearerReturnTargets>> => {
120
+ if (!targetsArg || typeof targetsArg !== 'object') {
121
+ throw new Error('bearer.returnTargets must name the return targets per client kind');
122
+ }
123
+ for (const key of Object.keys(targetsArg)) {
124
+ if (!isBearerClientKind(key)) {
125
+ throw new Error(`bearer.returnTargets.${key} is no client kind; the kinds are ${RELYING_PARTY_BEARER_CLIENT_KINDS.join(', ')}`);
126
+ }
127
+ }
128
+ const normalized = {} as Record<TRelyingPartyBearerClientKind, readonly string[]>;
129
+ let any = false;
130
+ for (const kind of RELYING_PARTY_BEARER_CLIENT_KINDS) {
131
+ const entries: unknown = targetsArg[kind] ?? [];
132
+ if (!Array.isArray(entries)) {
133
+ throw new Error(`bearer.returnTargets.${kind} must be a list`);
134
+ }
135
+ for (const entry of entries) {
136
+ if (!configuredTarget(kind, entry, publicOriginArg, loginPathPrefixArg)) {
137
+ throw new Error(`bearer.returnTargets.${kind} has an entry that is not an exact ${kind} target: ${JSON.stringify(entry)}`);
138
+ }
139
+ }
140
+ normalized[kind] = Object.freeze([...entries] as string[]);
141
+ any ||= entries.length > 0;
142
+ }
143
+ if (!any) {
144
+ throw new Error('bearer.returnTargets names no return target');
145
+ }
146
+ return Object.freeze(normalized);
147
+ };
148
+
149
+ /**
150
+ * Whether a client may name this return target: an entry of its kind,
151
+ * exactly. A loopback target names its own port, which is the only part
152
+ * that is not compared; it has to be given in canonical form.
153
+ */
154
+ export const isBearerReturnTargetAllowed = (
155
+ targetsArg: Readonly<Required<IRelyingPartyBearerReturnTargets>>,
156
+ kindArg: TRelyingPartyBearerClientKind,
157
+ candidateArg: unknown,
158
+ ): boolean => {
159
+ if (typeof candidateArg !== 'string' || candidateArg.length === 0 || candidateArg.length > MAX_RETURN_TARGET_LENGTH) {
160
+ return false;
161
+ }
162
+ const entries = targetsArg[kindArg];
163
+ if (kindArg !== 'loopback') {
164
+ return entries.includes(candidateArg);
165
+ }
166
+ const parsed = plainUrl(candidateArg);
167
+ if (!parsed || parsed.protocol !== 'http:' || !LOOPBACK_HOSTS.includes(parsed.hostname) || parsed.port === '') {
168
+ return false;
169
+ }
170
+ return entries.includes(`http://${parsed.hostname}${parsed.pathname}`);
171
+ };
172
+
173
+ /** RFC 6750 §2.1: the scheme is matched case-insensitively, the token is a b64token. */
174
+ const AUTHORIZATION_BEARER = /^bearer +([A-Za-z0-9._~+/-]+=*)$/iu;
175
+
176
+ /** The token of an `Authorization: Bearer <token>` header, or `null`. */
177
+ export const parseBearerAuthorization = (headerArg: string | null | undefined): string | null => {
178
+ if (typeof headerArg !== 'string' || headerArg.length > MAX_RETURN_TARGET_LENGTH) {
179
+ return null;
180
+ }
181
+ return AUTHORIZATION_BEARER.exec(headerArg.trim())?.[1] ?? null;
182
+ };
183
+
184
+ /** What a delivery hands the client: the code, or why there is none. */
185
+ export type TRelyingPartyBearerOutcome = { code: string } | { error: string };
186
+
187
+ /** The location a redirect delivery sends the client to, with the outcome and its `state` in the query. */
188
+ export const bearerDeliveryLocation = (
189
+ deliveryArg: IRelyingPartyBearerDelivery,
190
+ outcomeArg: TRelyingPartyBearerOutcome,
191
+ publicOriginArg: string,
192
+ ): string => {
193
+ const location = deliveryArg.clientKind === 'web'
194
+ ? new URL(deliveryArg.returnTarget, publicOriginArg)
195
+ : new URL(deliveryArg.returnTarget);
196
+ if ('code' in outcomeArg) {
197
+ location.searchParams.set('code', outcomeArg.code);
198
+ } else {
199
+ location.searchParams.set('error', outcomeArg.error);
200
+ }
201
+ if (deliveryArg.state !== undefined) {
202
+ location.searchParams.set('state', deliveryArg.state);
203
+ }
204
+ return location.href;
205
+ };
206
+
207
+ /** The message a popup delivery posts. */
208
+ export const bearerDeliveryMessage = (
209
+ deliveryArg: IRelyingPartyBearerDelivery,
210
+ outcomeArg: TRelyingPartyBearerOutcome,
211
+ ): IRelyingPartyBearerMessage => ({
212
+ type: RELYING_PARTY_BEARER_MESSAGE_TYPE,
213
+ ...outcomeArg,
214
+ ...(deliveryArg.state !== undefined ? { state: deliveryArg.state } : {}),
215
+ });
216
+
217
+ /** JSON that stays inert inside an HTML element, whatever the strings in it hold. */
218
+ const inertJson = (valueArg: unknown): string =>
219
+ JSON.stringify(valueArg)
220
+ .replace(/</gu, '\\u003c')
221
+ .replace(/>/gu, '\\u003e')
222
+ .replace(/&/gu, '\\u0026')
223
+ .replace(/\u2028/gu, '\\u2028')
224
+ .replace(/\u2029/gu, '\\u2029');
225
+
226
+ /** The id of the element a popup page keeps its delivery in. */
227
+ export const RELYING_PARTY_BEARER_POPUP_DATA_ID = 'idp-rp-bearer-delivery';
228
+
229
+ /**
230
+ * The page of a popup delivery. Its one script, allowed by nonce alone,
231
+ * posts the message to `window.opener` with the registered origin as the
232
+ * target origin — never `*` — so a browser hands it to nobody else, and
233
+ * closes the window. The delivery is data in a JSON block, never script.
234
+ */
235
+ export const bearerPopupPage = (
236
+ deliveryArg: IRelyingPartyBearerDelivery,
237
+ outcomeArg: TRelyingPartyBearerOutcome,
238
+ nonceArg: string,
239
+ ): string => {
240
+ const data = inertJson({ targetOrigin: deliveryArg.returnTarget, message: bearerDeliveryMessage(deliveryArg, outcomeArg) });
241
+ const script = [
242
+ `var delivery = JSON.parse(document.getElementById('${RELYING_PARTY_BEARER_POPUP_DATA_ID}').textContent);`,
243
+ 'if (window.opener) {',
244
+ ' window.opener.postMessage(delivery.message, delivery.targetOrigin);',
245
+ ' window.close();',
246
+ '}',
247
+ ].join('\n');
248
+ return [
249
+ '<!doctype html>',
250
+ '<html lang="en"><head><meta charset="utf-8"><title>Sign-in</title></head><body>',
251
+ `<script type="application/json" id="${RELYING_PARTY_BEARER_POPUP_DATA_ID}">${data}</script>`,
252
+ `<script nonce="${nonceArg}">\n${script}\n</script>`,
253
+ '<p>The sign-in is complete. You can close this window.</p>',
254
+ '</body></html>',
255
+ ].join('\n');
256
+ };
257
+
258
+ /** The policy of a popup page: its nonce script and nothing else. */
259
+ export const bearerPopupContentSecurityPolicy = (nonceArg: string): string =>
260
+ `default-src 'none'; script-src 'nonce-${nonceArg}'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`;
@@ -3,6 +3,7 @@ export * from './classes.errors.js';
3
3
  export * from './helpers.cookies.js';
4
4
  export * from './helpers.crypto.js';
5
5
  export * from './helpers.returnpath.js';
6
+ export * from './helpers.bearer.js';
6
7
  export * from './classes.secretbox.js';
7
8
  export * from './classes.idpoidcprovider.js';
8
9
  export * from './classes.rpmodels.js';
@@ -62,6 +62,113 @@ export interface IRelyingPartyLoginStart {
62
62
  intentCookie: string;
63
63
  }
64
64
 
65
+ /**
66
+ * How a bearer client receives the outcome of its login:
67
+ *
68
+ * - `web`: a redirect to a same-origin path of the application
69
+ * - `loopback`: a redirect to `http://127.0.0.1:<port>/…` or `http://[::1]:<port>/…`, any port (RFC 8252)
70
+ * - `custom_scheme`: a redirect to a registered private-use URI scheme such as `com.example.app:/signed-in` (RFC 8252)
71
+ * - `popup`: a page that posts the outcome to `window.opener` at exactly one registered origin
72
+ */
73
+ export type TRelyingPartyBearerClientKind = 'web' | 'loopback' | 'custom_scheme' | 'popup';
74
+
75
+ /**
76
+ * The exact return targets a bearer client may name, per client kind. A kind
77
+ * without targets is not offered. Matching is by equality, never by prefix
78
+ * or pattern; a loopback target matches on any port.
79
+ */
80
+ export interface IRelyingPartyBearerReturnTargets {
81
+ /** same-origin paths such as `/signed-in`, without query or fragment */
82
+ web?: readonly string[];
83
+ /** `http://127.0.0.1/<path>` or `http://[::1]/<path>` without a port; the client names its own port */
84
+ loopback?: readonly string[];
85
+ /** whole URIs of a private-use scheme with a dot in it, such as `com.example.app:/signed-in`, without query or fragment */
86
+ custom_scheme?: readonly string[];
87
+ /** bare origins such as `https://workspace.example`; the popup posts to exactly the one its client named */
88
+ popup?: readonly string[];
89
+ }
90
+
91
+ /**
92
+ * Bearer hand-off mode: the application's own clients sign in through this
93
+ * relying party and receive its own access and refresh tokens instead of a
94
+ * cookie.
95
+ */
96
+ export interface IRelyingPartyBearerOptions {
97
+ returnTargets: IRelyingPartyBearerReturnTargets;
98
+ /** how long an access token lives, in seconds; 900 by default, never past its session */
99
+ accessTokenMaxAgeSeconds?: number;
100
+ }
101
+
102
+ /** What a bearer client sends to start its login. Every field is checked as untrusted input. */
103
+ export interface IRelyingPartyBearerLoginRequest {
104
+ clientKind: TRelyingPartyBearerClientKind;
105
+ /** one of the configured return targets of the kind, exactly; a loopback target with the client's port */
106
+ returnTarget: string;
107
+ /** BASE64URL(SHA-256(code verifier)), 43 characters */
108
+ codeChallenge: string;
109
+ /** only `S256` is accepted */
110
+ codeChallengeMethod: 'S256';
111
+ /** the client's own correlation value, 1 to 256 characters of `A-Z a-z 0-9 . _ ~ -`, handed back with the outcome */
112
+ state?: string;
113
+ }
114
+
115
+ /** The redirect that starts a bearer login. It sets no cookie. */
116
+ export interface IRelyingPartyBearerLoginStart {
117
+ authorizationUrl: URL;
118
+ }
119
+
120
+ /** Where the outcome of a bearer login goes, as its client named it at the start. */
121
+ export interface IRelyingPartyBearerDelivery {
122
+ clientKind: TRelyingPartyBearerClientKind;
123
+ returnTarget: string;
124
+ /** the client's `state`, when it gave one */
125
+ state?: string;
126
+ }
127
+
128
+ /**
129
+ * A bearer login that came back from the provider: its session is open, and
130
+ * its single-use code is what the client claims the tokens with. The code is
131
+ * no token; only its hash is stored.
132
+ */
133
+ export interface IRelyingPartyBearerHandoff {
134
+ session: IRelyingPartySession;
135
+ delivery: IRelyingPartyBearerDelivery;
136
+ code: string;
137
+ /** epoch milliseconds, at most `BEARER_HANDOFF_MAX_AGE_SECONDS` after the callback */
138
+ expiresAt: number;
139
+ }
140
+
141
+ /**
142
+ * The relying party's own tokens of a bearer session. The refresh token
143
+ * rotates on every use; a rotated one coming back ends the session.
144
+ */
145
+ export interface IRelyingPartyBearerTokens {
146
+ session: IRelyingPartySession;
147
+ /** epoch milliseconds on the relying party's clock; a client with another clock counts from it */
148
+ issuedAt: number;
149
+ accessToken: string;
150
+ /** epoch milliseconds */
151
+ accessTokenExpiresAt: number;
152
+ refreshToken: string;
153
+ /** epoch milliseconds: the end of the session, which no refresh extends */
154
+ refreshTokenExpiresAt: number;
155
+ }
156
+
157
+ /**
158
+ * What the popup page of a `popup` delivery posts to `window.opener`. The
159
+ * receiver accepts it only when `event.origin` is the relying party's public
160
+ * origin, `event.source` is the popup it opened, `type` is
161
+ * `RELYING_PARTY_BEARER_MESSAGE_TYPE` and `state` is its own.
162
+ */
163
+ export interface IRelyingPartyBearerMessage {
164
+ type: string;
165
+ /** the hand-off code; absent when the login failed */
166
+ code?: string;
167
+ /** why the login failed; absent when it succeeded */
168
+ error?: string;
169
+ state?: string;
170
+ }
171
+
65
172
  /** A provider access token usable right now, with the subject it belongs to. */
66
173
  export interface IProviderToken {
67
174
  accessToken: string;
@@ -123,7 +230,15 @@ export type TRelyingPartySessionEndReason =
123
230
  /** its access token is dead and there is nothing to rotate it with */
124
231
  | 'access_token_unusable'
125
232
  /** a rotation found it revoked by someone else */
126
- | 'revoked_elsewhere';
233
+ | 'revoked_elsewhere'
234
+ /** its bearer hand-off code expired before a client claimed it */
235
+ | 'handoff_unclaimed'
236
+ /** its bearer hand-off code was claimed with a verifier that does not match its challenge */
237
+ | 'handoff_verifier_mismatch'
238
+ /** its bearer hand-off code came back after it was claimed */
239
+ | 'handoff_code_reused'
240
+ /** a bearer refresh token that was already rotated came back: the whole family ends */
241
+ | 'refresh_token_reused';
127
242
 
128
243
  export type TRelyingPartyEventName =
129
244
  | 'idp_token_refresh_failed'
@@ -133,6 +248,10 @@ export type TRelyingPartyEventName =
133
248
  | 'oidc_token_revocation_failed'
134
249
  | 'oidc_token_revocation_timed_out'
135
250
  | 'login_attempt_sweep_failed'
251
+ /** expired bearer hand-offs and token generations could not be swept */
252
+ | 'bearer_sweep_failed'
253
+ /** a bearer hand-off code or refresh token came back after its use; `reason` names which, the session ended */
254
+ | 'bearer_credential_reused'
136
255
  /** an application hook threw; the step it belonged to ran on regardless */
137
256
  | 'hook_failed'
138
257
  /** a session whose opening failed could not be revoked; its provider tokens went back anyway */
@@ -12,6 +12,8 @@ import {
12
12
  type IOidcRefreshedTokens,
13
13
  type TOidcTokenHint,
14
14
  } from './classes.idpoidcprovider.js';
15
+ import { pkceChallengeOf, RELYING_PARTY_BEARER_POPUP_DATA_ID } from './helpers.bearer.js';
16
+ import type { IRelyingPartyBearerMessage } from './interfaces.js';
15
17
 
16
18
  /**
17
19
  * `@idp.global/sdk/relyingparty/testing`: an in-process idp.global for the
@@ -350,3 +352,61 @@ export class RelyingPartyProviderDouble implements IOidcProvider {
350
352
  return !tokenArg.revoked && (tokenArg.expiresAt === 0 || tokenArg.expiresAt > this.#now());
351
353
  }
352
354
  }
355
+
356
+ // ---- bearer clients ----
357
+
358
+ /** A PKCE pair as a bearer client makes one: the verifier it keeps and the S256 challenge it sends. */
359
+ export const createBearerPkcePair = (): { verifier: string; challenge: string } => {
360
+ const verifier = plugins.crypto.randomBytes(32).toString('base64url');
361
+ return { verifier, challenge: pkceChallengeOf(verifier) };
362
+ };
363
+
364
+ /** What a bearer delivery handed its client. */
365
+ export interface IBearerDeliveryRead {
366
+ /** a redirect: its location without the query; a popup page: the origin it posts to */
367
+ target: string;
368
+ /** the popup page's message as it would be posted; absent for a redirect */
369
+ message?: IRelyingPartyBearerMessage;
370
+ code?: string;
371
+ error?: string;
372
+ state?: string;
373
+ }
374
+
375
+ const POPUP_DATA = new RegExp(
376
+ `<script type="application/json" id="${RELYING_PARTY_BEARER_POPUP_DATA_ID}">([^<]*)</script>`,
377
+ 'u',
378
+ );
379
+
380
+ /**
381
+ * Reads the answer of a bearer login's callback the way its client would:
382
+ * the `code`, `error` and `state` of a redirect's location, or the message
383
+ * and target origin of a popup page. Anything else throws.
384
+ */
385
+ export const readBearerDelivery = async (responseArg: Response): Promise<IBearerDeliveryRead> => {
386
+ const location = responseArg.headers.get('location');
387
+ if (responseArg.status === 302 && location) {
388
+ const url = new URL(location);
389
+ const read: IBearerDeliveryRead = { target: '' };
390
+ for (const name of ['code', 'error', 'state'] as const) {
391
+ const value = url.searchParams.get(name);
392
+ if (value !== null) {
393
+ read[name] = value;
394
+ }
395
+ }
396
+ url.search = '';
397
+ read.target = url.href;
398
+ return read;
399
+ }
400
+ const data = responseArg.status === 200 ? POPUP_DATA.exec(await responseArg.text())?.[1] : undefined;
401
+ if (data === undefined) {
402
+ throw new Error(`readBearerDelivery: a response with status ${responseArg.status} is no bearer delivery`);
403
+ }
404
+ const delivery = JSON.parse(data) as { targetOrigin: string; message: IRelyingPartyBearerMessage };
405
+ return {
406
+ target: delivery.targetOrigin,
407
+ message: delivery.message,
408
+ ...(delivery.message.code !== undefined ? { code: delivery.message.code } : {}),
409
+ ...(delivery.message.error !== undefined ? { error: delivery.message.error } : {}),
410
+ ...(delivery.message.state !== undefined ? { state: delivery.message.state } : {}),
411
+ };
412
+ };