@idp.global/sdk 19.2.1 → 19.4.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.
Files changed (44) hide show
  1. package/changelog.md +58 -0
  2. package/dist_ts/00_commitinfo_data.js +1 -1
  3. package/dist_ts_browser/classes.idpclient.js +1 -1
  4. package/dist_ts_browser/classes.idprequests.js +1 -1
  5. package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +276 -0
  6. package/dist_ts_browser/classes.relyingpartybearerclient.js +591 -0
  7. package/dist_ts_browser/index.d.ts +1 -0
  8. package/dist_ts_browser/index.js +2 -1
  9. package/dist_ts_browser/plugins.js +1 -1
  10. package/dist_ts_browser/secret-delivery.js +1 -1
  11. package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
  12. package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
  13. package/dist_ts_relyingparty/classes.errors.d.ts +11 -1
  14. package/dist_ts_relyingparty/classes.errors.js +6 -1
  15. package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +74 -2
  16. package/dist_ts_relyingparty/classes.relyingpartyroutes.js +289 -14
  17. package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +209 -7
  18. package/dist_ts_relyingparty/classes.relyingpartysessions.js +734 -87
  19. package/dist_ts_relyingparty/classes.rpmodels.d.ts +71 -8
  20. package/dist_ts_relyingparty/classes.rpmodels.js +224 -10
  21. package/dist_ts_relyingparty/contract.bearer.d.ts +82 -0
  22. package/dist_ts_relyingparty/contract.bearer.js +9 -0
  23. package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
  24. package/dist_ts_relyingparty/helpers.bearer.js +199 -0
  25. package/dist_ts_relyingparty/index.d.ts +1 -0
  26. package/dist_ts_relyingparty/index.js +2 -1
  27. package/dist_ts_relyingparty/interfaces.d.ts +122 -2
  28. package/dist_ts_relyingparty/testing.d.ts +22 -0
  29. package/dist_ts_relyingparty/testing.js +42 -1
  30. package/package.json +9 -11
  31. package/readme.md +353 -8
  32. package/ts/00_commitinfo_data.ts +1 -1
  33. package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
  34. package/ts_browser/index.ts +1 -0
  35. package/ts_relyingparty/classes.authorityobserver.ts +80 -12
  36. package/ts_relyingparty/classes.errors.ts +16 -1
  37. package/ts_relyingparty/classes.relyingpartyroutes.ts +340 -16
  38. package/ts_relyingparty/classes.relyingpartysessions.ts +907 -94
  39. package/ts_relyingparty/classes.rpmodels.ts +131 -9
  40. package/ts_relyingparty/contract.bearer.ts +99 -0
  41. package/ts_relyingparty/helpers.bearer.ts +259 -0
  42. package/ts_relyingparty/index.ts +1 -0
  43. package/ts_relyingparty/interfaces.ts +144 -2
  44. package/ts_relyingparty/testing.ts +60 -0
@@ -1,11 +1,13 @@
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
- * One browser session of a relying party. The persisted field names, their
6
- * types and the index declarations are the ones nevermind.finance has stored
7
- * under `SessionDoc` since before this module existed, so its collection is
8
- * read and written unchanged.
6
+ * One browser session of a relying party. The persisted field names and their
7
+ * types are the ones nevermind.finance has stored under `SessionDoc` since
8
+ * before this module existed, so its collection is read and written
9
+ * unchanged; its indexes are those and the `expiresAt` index the sweep of
10
+ * ended sessions reads.
9
11
  */
10
12
  export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartySessionDoc, RelyingPartySessionDoc> {
11
13
  @plugins.nosqldb.unI()
@@ -26,7 +28,9 @@ export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<Relyi
26
28
  @plugins.nosqldb.svDb()
27
29
  public lastSeenAt: number = 0;
28
30
 
31
+ /** a revocation lowers it to `revokedAt` when that is earlier, so the sweep finds every ended session by it */
29
32
  @plugins.nosqldb.svDb()
33
+ @plugins.nosqldb.index()
30
34
  public expiresAt: number = 0;
31
35
 
32
36
  /** absent while the session is valid */
@@ -62,7 +66,8 @@ export class RelyingPartySessionDoc extends plugins.nosqldb.SmartDataDbDoc<Relyi
62
66
  * against, the binding to the browser that started it, the sealed PKCE
63
67
  * verifier and the return path. Consumed exactly once by a conditional update.
64
68
  * Field names and indexes are the ones nevermind.finance stores under
65
- * `LoginAttemptDoc`.
69
+ * `LoginAttemptDoc`; the `bearer…` fields exist only on the attempt of a
70
+ * bearer login, whose binding token is never issued.
66
71
  */
67
72
  export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyLoginAttemptDoc, RelyingPartyLoginAttemptDoc> {
68
73
  @plugins.nosqldb.unI()
@@ -103,11 +108,98 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
103
108
  /** the session the callback created; finalize releases the return path against it */
104
109
  @plugins.nosqldb.svDb()
105
110
  public createdSessionId?: string | null;
111
+
112
+ /** how the bearer client of this login receives its outcome; absent on a cookie login */
113
+ @plugins.nosqldb.svDb()
114
+ public bearerClientKind?: TRelyingPartyBearerClientKind;
115
+
116
+ /** the return target the bearer client named, exactly */
117
+ @plugins.nosqldb.svDb()
118
+ public bearerReturnTarget?: string;
119
+
120
+ /** the bearer client's S256 code challenge; its hand-off is claimed with the matching verifier */
121
+ @plugins.nosqldb.svDb()
122
+ public bearerCodeChallenge?: string;
123
+
124
+ /** the bearer client's own `state`, handed back with the outcome */
125
+ @plugins.nosqldb.svDb()
126
+ public bearerClientState?: string;
127
+ }
128
+
129
+ /**
130
+ * The single-use code a bearer login hands its client. Only the hash of the
131
+ * code is stored; the code is bound to the client's S256 challenge, lives at
132
+ * most a minute and is consumed by one conditional update.
133
+ */
134
+ export class RelyingPartyBearerHandoffDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerHandoffDoc, RelyingPartyBearerHandoffDoc> {
135
+ @plugins.nosqldb.unI()
136
+ public id: string = '';
137
+
138
+ /** sha256 of the code */
139
+ @plugins.nosqldb.unI()
140
+ public codeHash: string = '';
141
+
142
+ /** the session the login opened; the code is its only way out */
143
+ @plugins.nosqldb.svDb()
144
+ public sessionId: string = '';
145
+
146
+ @plugins.nosqldb.svDb()
147
+ public codeChallenge: string = '';
148
+
149
+ @plugins.nosqldb.svDb()
150
+ public createdAt: number = 0;
151
+
152
+ /** at most a minute after the callback; the sweep ends the session of a code nobody claimed by then */
153
+ @plugins.nosqldb.svDb()
154
+ @plugins.nosqldb.index()
155
+ public expiresAt: number = 0;
156
+
157
+ /** `null` until a claim consumes the code */
158
+ @plugins.nosqldb.svDb()
159
+ public consumedAt: number | null = null;
160
+ }
161
+
162
+ /**
163
+ * One generation of a bearer session's tokens: an access token and the
164
+ * refresh token that replaces both. Only hashes are stored. Exactly one
165
+ * generation of a session is current (`rotatedAt` `null`); a rotated one is
166
+ * kept until the session expires, so its refresh token coming back is
167
+ * recognized as reuse.
168
+ */
169
+ export class RelyingPartyBearerTokenDoc extends plugins.nosqldb.SmartDataDbDoc<RelyingPartyBearerTokenDoc, RelyingPartyBearerTokenDoc> {
170
+ @plugins.nosqldb.unI()
171
+ public id: string = '';
172
+
173
+ @plugins.nosqldb.unI()
174
+ public accessTokenHash: string = '';
175
+
176
+ @plugins.nosqldb.unI()
177
+ public refreshTokenHash: string = '';
178
+
179
+ @plugins.nosqldb.svDb()
180
+ public sessionId: string = '';
181
+
182
+ @plugins.nosqldb.svDb()
183
+ public createdAt: number = 0;
184
+
185
+ @plugins.nosqldb.svDb()
186
+ public accessTokenExpiresAt: number = 0;
187
+
188
+ /** the session's end; the refresh token never outlives it, and the sweep removes the generation after it */
189
+ @plugins.nosqldb.svDb()
190
+ @plugins.nosqldb.index()
191
+ public expiresAt: number = 0;
192
+
193
+ /** `null` while this generation is current; when its refresh token was spent otherwise */
194
+ @plugins.nosqldb.svDb()
195
+ public rotatedAt: number | null = null;
106
196
  }
107
197
 
108
198
  export const RELYING_PARTY_DEFAULT_COLLECTION_NAMES = {
109
199
  sessions: 'IdpRelyingPartySession',
110
200
  loginAttempts: 'IdpRelyingPartyLoginAttempt',
201
+ bearerHandoffs: 'IdpRelyingPartyBearerHandoff',
202
+ bearerTokens: 'IdpRelyingPartyBearerToken',
111
203
  } as const;
112
204
 
113
205
  export interface IRelyingPartyModelsOptions {
@@ -125,22 +217,37 @@ export interface IRelyingPartyModelsOptions {
125
217
  collectionNames?: {
126
218
  sessions?: string;
127
219
  loginAttempts?: string;
220
+ bearerHandoffs?: string;
221
+ bearerTokens?: string;
128
222
  };
129
223
  }
130
224
 
131
- export interface IRelyingPartyModels {
225
+ /** The models bearer hand-off mode stores its codes and tokens in. */
226
+ export interface IRelyingPartyBearerModels {
227
+ readonly BearerHandoff: typeof RelyingPartyBearerHandoffDoc;
228
+ readonly BearerToken: typeof RelyingPartyBearerTokenDoc;
229
+ }
230
+
231
+ /**
232
+ * The models of the session layer. The bearer models are needed only when
233
+ * `RelyingPartySessions` runs with `bearer`; `createRelyingPartyModels`
234
+ * always binds them.
235
+ */
236
+ export interface IRelyingPartyModels extends Partial<IRelyingPartyBearerModels> {
132
237
  readonly Session: typeof RelyingPartySessionDoc;
133
238
  readonly LoginAttempt: typeof RelyingPartyLoginAttemptDoc;
134
239
  }
135
240
 
136
241
  /**
137
- * Binds the two relying-party models to one database. Each call declares its
242
+ * Binds the relying-party models to one database. Each call declares its
138
243
  * own model classes, so several databases — and several applications in one
139
244
  * process — never share a binding. A deferred `db` is resolved on every use,
140
245
  * the way `@Collection(() => db)` resolves it; a use before it answers a
141
246
  * `SmartdataDb` throws `RelyingPartyDatabaseUnavailableError`.
142
247
  */
143
- export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions): IRelyingPartyModels => {
248
+ export const createRelyingPartyModels = (
249
+ optionsArg: IRelyingPartyModelsOptions,
250
+ ): IRelyingPartyModels & IRelyingPartyBearerModels => {
144
251
  const dbOption = optionsArg.db;
145
252
  if (!(dbOption instanceof plugins.nosqldb.SmartdataDb) && typeof dbOption !== 'function') {
146
253
  throw new Error('createRelyingPartyModels needs a SmartdataDb of @lossless.org/client/nosqldb or a function answering one');
@@ -163,6 +270,10 @@ export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions)
163
270
  const sessions = optionsArg.collectionNames?.sessions ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.sessions;
164
271
  const loginAttempts = optionsArg.collectionNames?.loginAttempts
165
272
  ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.loginAttempts;
273
+ const bearerHandoffs = optionsArg.collectionNames?.bearerHandoffs
274
+ ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.bearerHandoffs;
275
+ const bearerTokens = optionsArg.collectionNames?.bearerTokens
276
+ ?? RELYING_PARTY_DEFAULT_COLLECTION_NAMES.bearerTokens;
166
277
 
167
278
  @plugins.nosqldb.Collection(resolveDb, { collectionName: sessions })
168
279
  class BoundSessionDoc extends RelyingPartySessionDoc {}
@@ -170,5 +281,16 @@ export const createRelyingPartyModels = (optionsArg: IRelyingPartyModelsOptions)
170
281
  @plugins.nosqldb.Collection(resolveDb, { collectionName: loginAttempts })
171
282
  class BoundLoginAttemptDoc extends RelyingPartyLoginAttemptDoc {}
172
283
 
173
- return { Session: BoundSessionDoc, LoginAttempt: BoundLoginAttemptDoc };
284
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: bearerHandoffs })
285
+ class BoundBearerHandoffDoc extends RelyingPartyBearerHandoffDoc {}
286
+
287
+ @plugins.nosqldb.Collection(resolveDb, { collectionName: bearerTokens })
288
+ class BoundBearerTokenDoc extends RelyingPartyBearerTokenDoc {}
289
+
290
+ return {
291
+ Session: BoundSessionDoc,
292
+ LoginAttempt: BoundLoginAttemptDoc,
293
+ BearerHandoff: BoundBearerHandoffDoc,
294
+ BearerToken: BoundBearerTokenDoc,
295
+ };
174
296
  };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The wire contract of the bearer hand-off routes: what a bearer client sends
3
+ * and what it is answered. This module imports nothing, so the browser client
4
+ * (`@idp.global/sdk/browser`) shares it with the routes without reaching any
5
+ * server code; keep it that way.
6
+ */
7
+
8
+ /** The `type` of every message a popup delivery posts. */
9
+ export const RELYING_PARTY_BEARER_MESSAGE_TYPE = 'idp.global/relyingparty:bearer-handoff';
10
+
11
+ /**
12
+ * How a bearer client receives the outcome of its login:
13
+ *
14
+ * - `web`: a redirect to a same-origin path of the application
15
+ * - `loopback`: a redirect to `http://127.0.0.1:<port>/…` or `http://[::1]:<port>/…`, any port (RFC 8252)
16
+ * - `custom_scheme`: a redirect to a registered private-use URI scheme such as `com.example.app:/signed-in` (RFC 8252)
17
+ * - `popup`: a page that posts the outcome to `window.opener` at exactly one registered origin
18
+ */
19
+ export type TRelyingPartyBearerClientKind = 'web' | 'loopback' | 'custom_scheme' | 'popup';
20
+
21
+ /**
22
+ * What the popup page of a `popup` delivery posts to `window.opener`. The
23
+ * receiver accepts it only when `event.origin` is the relying party's public
24
+ * origin, `event.source` is the popup it opened, `type` is
25
+ * `RELYING_PARTY_BEARER_MESSAGE_TYPE` and `state` is its own.
26
+ */
27
+ export interface IRelyingPartyBearerMessage {
28
+ type: string;
29
+ /** the hand-off code; absent when the login failed */
30
+ code?: string;
31
+ /** why the login failed (`TRelyingPartyBearerDeliveryError`); absent when it succeeded */
32
+ error?: string;
33
+ state?: string;
34
+ }
35
+
36
+ /**
37
+ * Why a bearer login delivered no code, as the `error` of its redirect or
38
+ * popup message: `access_denied` when the provider came back without one,
39
+ * `authentication_failed` when the login could not be completed, or the code
40
+ * of the application's own refusal (`RelyingPartyRefusal.code`).
41
+ */
42
+ export type TRelyingPartyBearerDeliveryError = 'access_denied' | 'authentication_failed' | (string & {});
43
+
44
+ /** The session a bearer answer names. */
45
+ export interface IRelyingPartyBearerSessionInfo {
46
+ sessionId: string;
47
+ principalId: string;
48
+ /** epoch milliseconds: the end of the session, which no refresh extends */
49
+ expiresAt: number;
50
+ }
51
+
52
+ /** What `POST <prefix>bearer/claim` and `POST <prefix>bearer/refresh` answer with status 200. */
53
+ export interface IRelyingPartyBearerTokenAnswer {
54
+ tokenType: 'Bearer';
55
+ accessToken: string;
56
+ /** epoch milliseconds on the relying party's clock */
57
+ accessTokenExpiresAt: number;
58
+ /** seconds the access token lives from the answer, for a client whose clock differs */
59
+ expiresIn: number;
60
+ /** spent by the next refresh; it never comes back after that */
61
+ refreshToken: string;
62
+ /** epoch milliseconds: the end of the session */
63
+ refreshTokenExpiresAt: number;
64
+ session: IRelyingPartyBearerSessionInfo;
65
+ }
66
+
67
+ /** What `GET <prefix>bearer/session` answers with status 200; `null` for any token that is not live. */
68
+ export interface IRelyingPartyBearerSessionAnswer {
69
+ session: IRelyingPartyBearerSessionInfo | null;
70
+ }
71
+
72
+ /** What `POST <prefix>bearer/logout` answers with status 200; `true` only when this call ended a live session. */
73
+ export interface IRelyingPartyBearerLogoutAnswer {
74
+ endedLiveSession: boolean;
75
+ }
76
+
77
+ /**
78
+ * The `error` of a failed bearer route, answered as `{ error }`:
79
+ *
80
+ * - `invalid_bearer_request` (400): a login start or claim with a missing or malformed parameter
81
+ * - `return_target_not_allowed` (400): a login start naming a return target not configured for its kind
82
+ * - `invalid_bearer_handoff` (400): a claim with a code that is unknown, expired, already claimed or of the wrong verifier
83
+ * - `invalid_bearer_grant` (400): a refresh token that is unknown, spent, expired or of a session that is over
84
+ * - `authority_unavailable` (503): a refresh that could not reach the provider; nothing was spent, retry it
85
+ * - `authentication_unavailable` (503): any other failure on the relying party's side
86
+ * - `forbidden` (403): a CORS preflight from an origin that is no popup return target
87
+ */
88
+ export type TRelyingPartyBearerErrorCode =
89
+ | 'invalid_bearer_request'
90
+ | 'return_target_not_allowed'
91
+ | 'invalid_bearer_handoff'
92
+ | 'invalid_bearer_grant'
93
+ | 'authority_unavailable'
94
+ | 'authentication_unavailable'
95
+ | 'forbidden';
96
+
97
+ export interface IRelyingPartyBearerErrorAnswer {
98
+ error: TRelyingPartyBearerErrorCode;
99
+ }
@@ -0,0 +1,259 @@
1
+ import {
2
+ RELYING_PARTY_BEARER_MESSAGE_TYPE,
3
+ type IRelyingPartyBearerMessage,
4
+ type TRelyingPartyBearerClientKind,
5
+ } from './contract.bearer.js';
6
+ import { sha256, timingSafeEqualString } from './helpers.crypto.js';
7
+ import { sanitizeReturnPath } from './helpers.returnpath.js';
8
+ import type { IRelyingPartyBearerDelivery, IRelyingPartyBearerReturnTargets } from './interfaces.js';
9
+
10
+ export { RELYING_PARTY_BEARER_MESSAGE_TYPE };
11
+
12
+ export const RELYING_PARTY_BEARER_CLIENT_KINDS: readonly TRelyingPartyBearerClientKind[] = Object.freeze([
13
+ 'web',
14
+ 'loopback',
15
+ 'custom_scheme',
16
+ 'popup',
17
+ ]);
18
+
19
+ /** A return target is a location the application configured, not a place to park data. */
20
+ const MAX_RETURN_TARGET_LENGTH = 2_048;
21
+ /** BASE64URL of a SHA-256 digest, without padding (RFC 7636 §4.2). */
22
+ const PKCE_CHALLENGE = /^[A-Za-z0-9_-]{43}$/u;
23
+ /** RFC 7636 §4.1: 43 to 128 unreserved characters. */
24
+ const PKCE_VERIFIER = /^[A-Za-z0-9._~-]{43,128}$/u;
25
+ const CLIENT_STATE = /^[A-Za-z0-9._~-]{1,256}$/u;
26
+ const LOOPBACK_HOSTS: readonly string[] = ['127.0.0.1', '[::1]'];
27
+ /** A private-use scheme is a reverse domain name: it has a dot, and no web scheme has one (RFC 8252 §7.1). */
28
+ const CUSTOM_SCHEME = /^[a-z][a-z0-9+-]*(\.[a-z0-9+-]+)+:$/u;
29
+
30
+ export const isBearerClientKind = (valueArg: unknown): valueArg is TRelyingPartyBearerClientKind =>
31
+ typeof valueArg === 'string' && (RELYING_PARTY_BEARER_CLIENT_KINDS as readonly string[]).includes(valueArg);
32
+
33
+ export const isPkceChallenge = (valueArg: unknown): valueArg is string =>
34
+ typeof valueArg === 'string' && PKCE_CHALLENGE.test(valueArg);
35
+
36
+ export const isPkceVerifier = (valueArg: unknown): valueArg is string =>
37
+ typeof valueArg === 'string' && PKCE_VERIFIER.test(valueArg);
38
+
39
+ export const isBearerClientState = (valueArg: unknown): valueArg is string =>
40
+ typeof valueArg === 'string' && CLIENT_STATE.test(valueArg);
41
+
42
+ /** The S256 challenge of a verifier: BASE64URL(SHA-256(ASCII(verifier))). */
43
+ export const pkceChallengeOf = (verifierArg: string): string => sha256(verifierArg);
44
+
45
+ /** Whether a verifier answers a challenge under S256, compared in constant time. */
46
+ export const verifyPkceChallenge = (verifierArg: unknown, challengeArg: string): boolean =>
47
+ isPkceVerifier(verifierArg) && challengeArg.length > 0 && timingSafeEqualString(pkceChallengeOf(verifierArg), challengeArg);
48
+
49
+ const parseUrl = (valueArg: string): URL | null => {
50
+ try {
51
+ return new URL(valueArg);
52
+ } catch {
53
+ return null;
54
+ }
55
+ };
56
+
57
+ /** A URL as given, in its canonical form, with nothing that a delivery would have to merge into. */
58
+ const plainUrl = (valueArg: unknown): URL | null => {
59
+ if (typeof valueArg !== 'string' || valueArg.length === 0 || valueArg.length > MAX_RETURN_TARGET_LENGTH) {
60
+ return null;
61
+ }
62
+ const parsed = parseUrl(valueArg);
63
+ if (
64
+ !parsed
65
+ || parsed.href !== valueArg
66
+ || parsed.username !== ''
67
+ || parsed.password !== ''
68
+ || parsed.search !== ''
69
+ || parsed.hash !== ''
70
+ ) {
71
+ return null;
72
+ }
73
+ return parsed;
74
+ };
75
+
76
+ const configuredTarget = (
77
+ kindArg: TRelyingPartyBearerClientKind,
78
+ entryArg: unknown,
79
+ publicOriginArg: string,
80
+ loginPathPrefixArg: string,
81
+ ): boolean => {
82
+ // targets are compared exactly; an entry that reads like a wildcard is a mistake, said at once
83
+ if (typeof entryArg === 'string' && entryArg.includes('*')) {
84
+ return false;
85
+ }
86
+ if (kindArg === 'web') {
87
+ return typeof entryArg === 'string'
88
+ && !entryArg.includes('?')
89
+ && !entryArg.includes('#')
90
+ && sanitizeReturnPath(entryArg, publicOriginArg, loginPathPrefixArg) === entryArg;
91
+ }
92
+ if (kindArg === 'popup') {
93
+ const parsed = typeof entryArg === 'string' ? parseUrl(entryArg) : null;
94
+ return parsed !== null
95
+ && (parsed.protocol === 'https:' || parsed.protocol === 'http:')
96
+ && parsed.origin === entryArg;
97
+ }
98
+ const parsed = plainUrl(entryArg);
99
+ if (!parsed) {
100
+ return false;
101
+ }
102
+ if (kindArg === 'loopback') {
103
+ return parsed.protocol === 'http:' && LOOPBACK_HOSTS.includes(parsed.hostname) && parsed.port === '';
104
+ }
105
+ return CUSTOM_SCHEME.test(parsed.protocol);
106
+ };
107
+
108
+ /**
109
+ * The return targets of a deployment, checked once: every entry has to be
110
+ * the exact canonical form a client can name, or the configuration is
111
+ * refused. A target with a query or a fragment is refused too, because the
112
+ * delivery adds its own query, and so is one with a `*`: nothing is a wildcard.
113
+ */
114
+ export const normalizeBearerReturnTargets = (
115
+ targetsArg: IRelyingPartyBearerReturnTargets,
116
+ publicOriginArg: string,
117
+ loginPathPrefixArg: string,
118
+ ): Readonly<Required<IRelyingPartyBearerReturnTargets>> => {
119
+ if (!targetsArg || typeof targetsArg !== 'object') {
120
+ throw new Error('bearer.returnTargets must name the return targets per client kind');
121
+ }
122
+ for (const key of Object.keys(targetsArg)) {
123
+ if (!isBearerClientKind(key)) {
124
+ throw new Error(`bearer.returnTargets.${key} is no client kind; the kinds are ${RELYING_PARTY_BEARER_CLIENT_KINDS.join(', ')}`);
125
+ }
126
+ }
127
+ const normalized = {} as Record<TRelyingPartyBearerClientKind, readonly string[]>;
128
+ let any = false;
129
+ for (const kind of RELYING_PARTY_BEARER_CLIENT_KINDS) {
130
+ const entries: unknown = targetsArg[kind] ?? [];
131
+ if (!Array.isArray(entries)) {
132
+ throw new Error(`bearer.returnTargets.${kind} must be a list`);
133
+ }
134
+ for (const entry of entries) {
135
+ if (!configuredTarget(kind, entry, publicOriginArg, loginPathPrefixArg)) {
136
+ throw new Error(`bearer.returnTargets.${kind} has an entry that is not an exact ${kind} target: ${JSON.stringify(entry)}`);
137
+ }
138
+ }
139
+ normalized[kind] = Object.freeze([...entries] as string[]);
140
+ any ||= entries.length > 0;
141
+ }
142
+ if (!any) {
143
+ throw new Error('bearer.returnTargets names no return target');
144
+ }
145
+ return Object.freeze(normalized);
146
+ };
147
+
148
+ /**
149
+ * Whether a client may name this return target: an entry of its kind,
150
+ * exactly. A loopback target names its own port, which is the only part
151
+ * that is not compared; it has to be given in canonical form.
152
+ */
153
+ export const isBearerReturnTargetAllowed = (
154
+ targetsArg: Readonly<Required<IRelyingPartyBearerReturnTargets>>,
155
+ kindArg: TRelyingPartyBearerClientKind,
156
+ candidateArg: unknown,
157
+ ): boolean => {
158
+ if (typeof candidateArg !== 'string' || candidateArg.length === 0 || candidateArg.length > MAX_RETURN_TARGET_LENGTH) {
159
+ return false;
160
+ }
161
+ const entries = targetsArg[kindArg];
162
+ if (kindArg !== 'loopback') {
163
+ return entries.includes(candidateArg);
164
+ }
165
+ const parsed = plainUrl(candidateArg);
166
+ if (!parsed || parsed.protocol !== 'http:' || !LOOPBACK_HOSTS.includes(parsed.hostname) || parsed.port === '') {
167
+ return false;
168
+ }
169
+ return entries.includes(`http://${parsed.hostname}${parsed.pathname}`);
170
+ };
171
+
172
+ /** RFC 6750 §2.1: the scheme is matched case-insensitively, the token is a b64token. */
173
+ const AUTHORIZATION_BEARER = /^bearer +([A-Za-z0-9._~+/-]+=*)$/iu;
174
+
175
+ /** The token of an `Authorization: Bearer <token>` header, or `null`. */
176
+ export const parseBearerAuthorization = (headerArg: string | null | undefined): string | null => {
177
+ if (typeof headerArg !== 'string' || headerArg.length > MAX_RETURN_TARGET_LENGTH) {
178
+ return null;
179
+ }
180
+ return AUTHORIZATION_BEARER.exec(headerArg.trim())?.[1] ?? null;
181
+ };
182
+
183
+ /** What a delivery hands the client: the code, or why there is none. */
184
+ export type TRelyingPartyBearerOutcome = { code: string } | { error: string };
185
+
186
+ /** The location a redirect delivery sends the client to, with the outcome and its `state` in the query. */
187
+ export const bearerDeliveryLocation = (
188
+ deliveryArg: IRelyingPartyBearerDelivery,
189
+ outcomeArg: TRelyingPartyBearerOutcome,
190
+ publicOriginArg: string,
191
+ ): string => {
192
+ const location = deliveryArg.clientKind === 'web'
193
+ ? new URL(deliveryArg.returnTarget, publicOriginArg)
194
+ : new URL(deliveryArg.returnTarget);
195
+ if ('code' in outcomeArg) {
196
+ location.searchParams.set('code', outcomeArg.code);
197
+ } else {
198
+ location.searchParams.set('error', outcomeArg.error);
199
+ }
200
+ if (deliveryArg.state !== undefined) {
201
+ location.searchParams.set('state', deliveryArg.state);
202
+ }
203
+ return location.href;
204
+ };
205
+
206
+ /** The message a popup delivery posts. */
207
+ export const bearerDeliveryMessage = (
208
+ deliveryArg: IRelyingPartyBearerDelivery,
209
+ outcomeArg: TRelyingPartyBearerOutcome,
210
+ ): IRelyingPartyBearerMessage => ({
211
+ type: RELYING_PARTY_BEARER_MESSAGE_TYPE,
212
+ ...outcomeArg,
213
+ ...(deliveryArg.state !== undefined ? { state: deliveryArg.state } : {}),
214
+ });
215
+
216
+ /** JSON that stays inert inside an HTML element, whatever the strings in it hold. */
217
+ const inertJson = (valueArg: unknown): string =>
218
+ JSON.stringify(valueArg)
219
+ .replace(/</gu, '\\u003c')
220
+ .replace(/>/gu, '\\u003e')
221
+ .replace(/&/gu, '\\u0026')
222
+ .replace(/\u2028/gu, '\\u2028')
223
+ .replace(/\u2029/gu, '\\u2029');
224
+
225
+ /** The id of the element a popup page keeps its delivery in. */
226
+ export const RELYING_PARTY_BEARER_POPUP_DATA_ID = 'idp-rp-bearer-delivery';
227
+
228
+ /**
229
+ * The page of a popup delivery. Its one script, allowed by nonce alone,
230
+ * posts the message to `window.opener` with the registered origin as the
231
+ * target origin — never `*` — so a browser hands it to nobody else, and
232
+ * closes the window. The delivery is data in a JSON block, never script.
233
+ */
234
+ export const bearerPopupPage = (
235
+ deliveryArg: IRelyingPartyBearerDelivery,
236
+ outcomeArg: TRelyingPartyBearerOutcome,
237
+ nonceArg: string,
238
+ ): string => {
239
+ const data = inertJson({ targetOrigin: deliveryArg.returnTarget, message: bearerDeliveryMessage(deliveryArg, outcomeArg) });
240
+ const script = [
241
+ `var delivery = JSON.parse(document.getElementById('${RELYING_PARTY_BEARER_POPUP_DATA_ID}').textContent);`,
242
+ 'if (window.opener) {',
243
+ ' window.opener.postMessage(delivery.message, delivery.targetOrigin);',
244
+ ' window.close();',
245
+ '}',
246
+ ].join('\n');
247
+ return [
248
+ '<!doctype html>',
249
+ '<html lang="en"><head><meta charset="utf-8"><title>Sign-in</title></head><body>',
250
+ `<script type="application/json" id="${RELYING_PARTY_BEARER_POPUP_DATA_ID}">${data}</script>`,
251
+ `<script nonce="${nonceArg}">\n${script}\n</script>`,
252
+ '<p>The sign-in is complete. You can close this window.</p>',
253
+ '</body></html>',
254
+ ].join('\n');
255
+ };
256
+
257
+ /** The policy of a popup page: its nonce script and nothing else. */
258
+ export const bearerPopupContentSecurityPolicy = (nonceArg: string): string =>
259
+ `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';