@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.
- package/changelog.md +58 -0
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts_browser/classes.idpclient.js +1 -1
- package/dist_ts_browser/classes.idprequests.js +1 -1
- package/dist_ts_browser/classes.relyingpartybearerclient.d.ts +276 -0
- package/dist_ts_browser/classes.relyingpartybearerclient.js +591 -0
- package/dist_ts_browser/index.d.ts +1 -0
- package/dist_ts_browser/index.js +2 -1
- package/dist_ts_browser/plugins.js +1 -1
- package/dist_ts_browser/secret-delivery.js +1 -1
- package/dist_ts_relyingparty/classes.authorityobserver.d.ts +23 -0
- package/dist_ts_relyingparty/classes.authorityobserver.js +68 -13
- package/dist_ts_relyingparty/classes.errors.d.ts +11 -1
- package/dist_ts_relyingparty/classes.errors.js +6 -1
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +74 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +289 -14
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +209 -7
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +734 -87
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +71 -8
- package/dist_ts_relyingparty/classes.rpmodels.js +224 -10
- package/dist_ts_relyingparty/contract.bearer.d.ts +82 -0
- package/dist_ts_relyingparty/contract.bearer.js +9 -0
- package/dist_ts_relyingparty/helpers.bearer.d.ts +48 -0
- package/dist_ts_relyingparty/helpers.bearer.js +199 -0
- package/dist_ts_relyingparty/index.d.ts +1 -0
- package/dist_ts_relyingparty/index.js +2 -1
- package/dist_ts_relyingparty/interfaces.d.ts +122 -2
- package/dist_ts_relyingparty/testing.d.ts +22 -0
- package/dist_ts_relyingparty/testing.js +42 -1
- package/package.json +9 -11
- package/readme.md +353 -8
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_browser/classes.relyingpartybearerclient.ts +821 -0
- package/ts_browser/index.ts +1 -0
- package/ts_relyingparty/classes.authorityobserver.ts +80 -12
- package/ts_relyingparty/classes.errors.ts +16 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +340 -16
- package/ts_relyingparty/classes.relyingpartysessions.ts +907 -94
- package/ts_relyingparty/classes.rpmodels.ts +131 -9
- package/ts_relyingparty/contract.bearer.ts +99 -0
- package/ts_relyingparty/helpers.bearer.ts +259 -0
- package/ts_relyingparty/index.ts +1 -0
- package/ts_relyingparty/interfaces.ts +144 -2
- 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
|
|
6
|
-
* types
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
|
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 = (
|
|
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
|
-
|
|
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'`;
|
package/ts_relyingparty/index.ts
CHANGED
|
@@ -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';
|