@idp.global/sdk 19.0.0 → 19.2.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 +32 -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_relyingparty/classes.authorityobserver.js +1 -1
- package/dist_ts_relyingparty/classes.errors.d.ts +9 -1
- package/dist_ts_relyingparty/classes.errors.js +11 -2
- package/dist_ts_relyingparty/classes.idpoidcprovider.d.ts +15 -0
- package/dist_ts_relyingparty/classes.idpoidcprovider.js +6 -2
- package/dist_ts_relyingparty/classes.relyingpartyroutes.d.ts +6 -0
- package/dist_ts_relyingparty/classes.relyingpartyroutes.js +19 -1
- package/dist_ts_relyingparty/classes.relyingpartysessions.d.ts +8 -3
- package/dist_ts_relyingparty/classes.relyingpartysessions.js +36 -13
- package/dist_ts_relyingparty/classes.rpmodels.d.ts +4 -2
- package/dist_ts_relyingparty/classes.rpmodels.js +9 -2
- package/dist_ts_relyingparty/helpers.cookies.d.ts +1 -1
- package/dist_ts_relyingparty/helpers.cookies.js +1 -1
- package/dist_ts_relyingparty/helpers.crypto.js +1 -1
- package/dist_ts_relyingparty/interfaces.d.ts +22 -3
- package/dist_ts_relyingparty/testing.d.ts +116 -0
- package/dist_ts_relyingparty/testing.js +266 -0
- package/dist_ts_server/classes.account-auth-service.js +1 -1
- package/dist_ts_server/classes.account-store.js +1 -1
- package/dist_ts_server/classes.idp-global-server-client.js +1 -1
- package/dist_ts_server/classes.oidc-introspection-client.js +1 -1
- package/dist_ts_server/classes.oidc-userinfo-client.js +1 -1
- package/package.json +7 -6
- package/readme.md +65 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts_relyingparty/classes.errors.ts +11 -0
- package/ts_relyingparty/classes.idpoidcprovider.ts +21 -1
- package/ts_relyingparty/classes.relyingpartyroutes.ts +19 -0
- package/ts_relyingparty/classes.relyingpartysessions.ts +39 -13
- package/ts_relyingparty/classes.rpmodels.ts +4 -0
- package/ts_relyingparty/interfaces.ts +24 -3
- package/ts_relyingparty/testing.ts +352 -0
package/readme.md
CHANGED
|
@@ -525,6 +525,25 @@ What it does:
|
|
|
525
525
|
releases the return path only to the browser holding the session that attempt
|
|
526
526
|
created. Return paths stay on the public origin and outside the login routes.
|
|
527
527
|
Expired attempts are swept when the next login starts.
|
|
528
|
+
- **Starting before discovery**: `provider.init()` runs discovery. An
|
|
529
|
+
application that must not depend on idp.global to start calls it in the
|
|
530
|
+
background until it succeeds. Until then, a login start fails and stores no
|
|
531
|
+
attempt, and introspection and refresh throw
|
|
532
|
+
`OidcAuthorityUnavailableError`, which fails closed.
|
|
533
|
+
- **Intents and prompts**: `sessions.startLogin(returnPath, { intent, prompt })`
|
|
534
|
+
is what an application calls from a login entry of its own (an invitation it
|
|
535
|
+
accepts, an account it links, a client it authorizes). `intent` is the
|
|
536
|
+
application's text of up to 4,096 characters, sealed into the login attempt
|
|
537
|
+
like the verifier; it never travels through the browser or the provider, and
|
|
538
|
+
`principals.admit(completion, { intent, loginAttemptId })` receives it when
|
|
539
|
+
exactly this login comes back. `prompt` (`login`, `none`, `consent`) goes to
|
|
540
|
+
the authorization endpoint: `login` makes the person authenticate again
|
|
541
|
+
before something long-lived is made. A refusal from `admit` may name where
|
|
542
|
+
in the application the browser goes
|
|
543
|
+
(`new RelyingPartyRefusal(code, status, { location: '/#link/<token>' })`);
|
|
544
|
+
the routes follow it only when it is a plain path on the public origin
|
|
545
|
+
outside the login routes, exactly as given, and otherwise answer as if it
|
|
546
|
+
named none.
|
|
528
547
|
- **Sessions**: an HttpOnly, `SameSite=Lax` cookie whose token is stored as a
|
|
529
548
|
hash, touched at most once a minute, with a CSRF token derived per request as
|
|
530
549
|
an HMAC of the cookie token. `GET /auth/session` answers the session and
|
|
@@ -532,7 +551,9 @@ What it does:
|
|
|
532
551
|
cancelled as soon as it passes `maxBodyBytes`. Every end of a session is one
|
|
533
552
|
conditional update; its provider tokens go back to idp.global within a
|
|
534
553
|
three-second budget afterwards, and so do the tokens of a login that failed
|
|
535
|
-
after the code exchange.
|
|
554
|
+
after the code exchange. `openSession({ principalId, maxAgeSeconds })` opens
|
|
555
|
+
a session the application authenticated itself, optionally shorter than
|
|
556
|
+
`sessionMaxAgeSeconds` (the cookie with it), and without provider tokens.
|
|
536
557
|
- **Provider tokens**: sealed with AES-256-GCM (`SecretBox`) and rotated shortly
|
|
537
558
|
before they expire, once per process and under a lock stored on the session,
|
|
538
559
|
so two replicas never spend one refresh token. A refused refresh ends the
|
|
@@ -679,19 +700,59 @@ for (const connection of await binding.connectionsOfSession(sessionId)) {
|
|
|
679
700
|
}
|
|
680
701
|
```
|
|
681
702
|
|
|
703
|
+
### Testing an Application
|
|
704
|
+
|
|
705
|
+
`@idp.global/sdk/relyingparty/testing` exports `RelyingPartyProviderDouble`, an
|
|
706
|
+
in-process idp.global implementing `IOidcProvider` for an application's own
|
|
707
|
+
tests; it belongs in test code only.
|
|
708
|
+
|
|
709
|
+
```ts
|
|
710
|
+
import { RelyingPartyProviderDouble } from '@idp.global/sdk/relyingparty/testing';
|
|
711
|
+
|
|
712
|
+
const idp = new RelyingPartyProviderDouble({ now: clock.now });
|
|
713
|
+
idp.addPersona({
|
|
714
|
+
subject: 'sub-owner',
|
|
715
|
+
displayName: 'Olga Owner',
|
|
716
|
+
email: 'olga@example.com',
|
|
717
|
+
organizations: [{ id: 'idp-org-1', name: 'Example GmbH', slug: 'example', roles: ['owner'] }],
|
|
718
|
+
});
|
|
719
|
+
idp.signInNextAs('sub-owner');
|
|
720
|
+
|
|
721
|
+
// the application's GET /auth/login answers with the authorization URL …
|
|
722
|
+
const callback = idp.callbackUrlFor(authorizationUrl, new URL('/auth/callback', publicOrigin));
|
|
723
|
+
// … and the browser comes back to the application's callback with it
|
|
724
|
+
|
|
725
|
+
idp.setOrganizationRoles('sub-owner', 'idp-org-1', ['viewer']); // what introspection answers from now on
|
|
726
|
+
idp.endLoginsOf('sub-owner'); // every token of the persona stops working
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Codes are single use and bound to their login's state and nonce; access tokens
|
|
730
|
+
introspect for their subject while they live; refresh tokens rotate, and a
|
|
731
|
+
superseded one coming back revokes its whole family, as idp.global's reuse
|
|
732
|
+
detection does. Every call is recorded (`authorizations`, `completions`,
|
|
733
|
+
`revocations`, `introspections`, `refreshes`), and each step can be made to
|
|
734
|
+
fail (`completionFailure`, `introspectionFailure`, `refreshFailure`,
|
|
735
|
+
`revocationFailure`) or held open (`onIntrospect`, `onRefresh`). With
|
|
736
|
+
`discoveryFailure` set, an `init` before one has succeeded fails with it, and
|
|
737
|
+
until an `init` succeeds the double answers as `IdpOidcProvider` does before
|
|
738
|
+
discovery. Once one has succeeded, discovery stays done, as with
|
|
739
|
+
`IdpOidcProvider`; `discoveries` counts the calls. The claims of a login are
|
|
740
|
+
those of its sign-in; introspection reads the persona as it is now.
|
|
741
|
+
|
|
682
742
|
### Relying-Party API Surface
|
|
683
743
|
|
|
684
744
|
| API | Description |
|
|
685
745
|
| --- | --- |
|
|
686
746
|
| `IdpOidcProvider` | Discovery, PKCE authorization URL, code exchange with UserInfo, refresh, revocation and organization introspection for idp.global. Implements `IOidcProvider`. `trustedCaPem` or `fetch` for an issuer under a private CA. |
|
|
687
747
|
| `createRelyingPartyModels()` | Binds the session and login-attempt models to one `SmartdataDb`, or to a function asked for it on every use, optionally under existing collection names. |
|
|
688
|
-
| `RelyingPartySessions<TPrincipal>` | Login start/complete/abandon/finalize, `openSession
|
|
748
|
+
| `RelyingPartySessions<TPrincipal>` | Login start (with an application intent and a prompt)/complete/abandon/finalize, `openSession` (with a shorter lifetime), `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. |
|
|
689
749
|
| `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
|
|
690
750
|
| `RelyingPartyRoutes` | `handle(ctx)` for `/auth/login`, `/auth/callback`, `/auth/finalize`, `/auth/session` and `/auth/logout`; `null` for anything else. Takes a typedserver route context. `onLogout` is awaited after a CSRF-checked logout ended the session. |
|
|
691
751
|
| `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
|
|
692
752
|
| `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
|
|
693
753
|
| `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
|
|
694
|
-
| `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. |
|
|
754
|
+
| `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. A `RelyingPartyRefusal` may name the application's `location` for the browser. |
|
|
755
|
+
| `RelyingPartyProviderDouble` (`/relyingparty/testing`) | An in-process idp.global for an application's tests: personas, live organization roles, single-use codes, rotating and reuse-detecting refresh tokens, recorded calls and injectable failures. |
|
|
695
756
|
|
|
696
757
|
## Runtime Notes
|
|
697
758
|
|
|
@@ -710,7 +771,7 @@ The SDK uses typed sockets and typed requests from `@api.global/*`, shared idp.g
|
|
|
710
771
|
pnpm test
|
|
711
772
|
```
|
|
712
773
|
|
|
713
|
-
The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, the exact backend JWT keyset and blocklist contracts, and the relying-party layer: its primitives, the OIDC provider against a fetch-level issuer double, sessions and two-replica token rotation on an in-memory NoSQL engine with an injected clock, the persisted shape, the authority observer, the HTTP routes, the actor scope and the
|
|
774
|
+
The test suite covers the server account store and auth service, browser transfer-token handling, typed administrative request names, the exact backend JWT keyset and blocklist contracts, and the relying-party layer: its primitives, the OIDC provider against a fetch-level issuer double, sessions and two-replica token rotation on an in-memory NoSQL engine with an injected clock, the persisted shape, the authority observer, the HTTP routes, login intents and prompts, the actor scope, the socket binding and the provider double.
|
|
714
775
|
|
|
715
776
|
## License and Legal Information
|
|
716
777
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -30,14 +30,25 @@ export class RelyingPartyError extends Error {
|
|
|
30
30
|
* The application refuses a verified provider login (a disabled account, a
|
|
31
31
|
* subject of another issuer, no organization it serves). Thrown from
|
|
32
32
|
* `IRelyingPartyPrincipals.admit`; it reaches the caller unchanged.
|
|
33
|
+
* `location` is where in the application the browser goes for this very
|
|
34
|
+
* refusal (a step it has to take, with a one-time token in the fragment);
|
|
35
|
+
* `RelyingPartyRoutes` follows it only when it is a plain path on the
|
|
36
|
+
* application's origin outside the login routes, and otherwise answers as if
|
|
37
|
+
* none was given.
|
|
33
38
|
*/
|
|
34
39
|
export class RelyingPartyRefusal extends Error {
|
|
40
|
+
public readonly location?: string;
|
|
41
|
+
|
|
35
42
|
constructor(
|
|
36
43
|
public readonly code: string,
|
|
37
44
|
public readonly status: number,
|
|
45
|
+
optionsArg: { location?: string } = {},
|
|
38
46
|
) {
|
|
39
47
|
super(code);
|
|
40
48
|
this.name = 'RelyingPartyRefusal';
|
|
49
|
+
if (optionsArg.location !== undefined) {
|
|
50
|
+
this.location = optionsArg.location;
|
|
51
|
+
}
|
|
41
52
|
}
|
|
42
53
|
}
|
|
43
54
|
|
|
@@ -5,10 +5,22 @@ import {
|
|
|
5
5
|
} from '../ts_server/classes.oidc-introspection-client.js';
|
|
6
6
|
import { createTrustedCaFetch, type TOidcFetch } from '../ts_server/helpers.trusted-ca-fetch.js';
|
|
7
7
|
|
|
8
|
+
/** The `prompt` values idp.global takes at its authorization endpoint. */
|
|
9
|
+
export type TOidcPrompt = 'none' | 'login' | 'consent';
|
|
10
|
+
|
|
11
|
+
export const OIDC_PROMPTS: readonly TOidcPrompt[] = ['none', 'login', 'consent'];
|
|
12
|
+
|
|
8
13
|
export interface IOidcAuthorizationOptions {
|
|
9
14
|
state: string;
|
|
10
15
|
nonce: string;
|
|
11
16
|
pkceVerifier: string;
|
|
17
|
+
/**
|
|
18
|
+
* What the provider is asked to do with a login it holds already: `login`
|
|
19
|
+
* asks the person to authenticate again (a fresh proof before something
|
|
20
|
+
* long-lived is made), `none` never shows a page, `consent` asks again.
|
|
21
|
+
* Absent: the provider decides.
|
|
22
|
+
*/
|
|
23
|
+
prompt?: TOidcPrompt;
|
|
12
24
|
}
|
|
13
25
|
|
|
14
26
|
export interface IOidcCompletionOptions extends IOidcAuthorizationOptions {
|
|
@@ -104,6 +116,11 @@ export interface IOidcProvider {
|
|
|
104
116
|
* keeps two replicas from spending one refresh token outlives it.
|
|
105
117
|
*/
|
|
106
118
|
readonly requestTimeoutSeconds: number;
|
|
119
|
+
/**
|
|
120
|
+
* Discovery. Until it has succeeded no authorization URL can be built, and
|
|
121
|
+
* introspection and refresh fail with `OidcAuthorityUnavailableError`, so an
|
|
122
|
+
* application may start without it and call it again until it succeeds.
|
|
123
|
+
*/
|
|
107
124
|
init(): Promise<void>;
|
|
108
125
|
buildAuthorizationUrl(optionsArg: IOidcAuthorizationOptions): Promise<URL>;
|
|
109
126
|
completeAuthorization(optionsArg: IOidcCompletionOptions): Promise<IOidcCompletion>;
|
|
@@ -300,6 +317,7 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
300
317
|
code_challenge_method: 'S256',
|
|
301
318
|
state: optionsArg.state,
|
|
302
319
|
nonce: optionsArg.nonce,
|
|
320
|
+
...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
|
|
303
321
|
});
|
|
304
322
|
}
|
|
305
323
|
|
|
@@ -367,7 +385,9 @@ export class IdpOidcProvider implements IOidcProvider {
|
|
|
367
385
|
organizationId: string;
|
|
368
386
|
}): Promise<IOidcOrganizationObservation> {
|
|
369
387
|
if (!this.#introspection) {
|
|
370
|
-
|
|
388
|
+
// before discovery the provider cannot be asked: that is an
|
|
389
|
+
// unavailable authority, which the caller fails closed on
|
|
390
|
+
throw new OidcAuthorityUnavailableError('unavailable', new Error('The identity provider is not initialized'));
|
|
371
391
|
}
|
|
372
392
|
let response: Awaited<ReturnType<OidcIntrospectionClient['introspect']>>;
|
|
373
393
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import { LoginCallbackError, RelyingPartyError, RelyingPartyRefusal } from './classes.errors.js';
|
|
3
3
|
import type { RelyingPartySessions } from './classes.relyingpartysessions.js';
|
|
4
|
+
import { sanitizeReturnPath } from './helpers.returnpath.js';
|
|
4
5
|
import type { IRelyingPartySession, IResolvedRelyingPartySession } from './interfaces.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -167,6 +168,20 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
167
168
|
}
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The location a refusal names itself, when it is a plain path on this
|
|
173
|
+
* origin outside the login routes exactly as given; anything the sanitizer
|
|
174
|
+
* would have to change is no location of this application.
|
|
175
|
+
*/
|
|
176
|
+
private refusalLocationOf(refusalArg: RelyingPartyRefusal): string | undefined {
|
|
177
|
+
const location = refusalArg.location;
|
|
178
|
+
if (typeof location !== 'string' || location.length === 0) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const sanitized = sanitizeReturnPath(location, this.#sessions.publicOrigin, this.#sessions.loginPathPrefix);
|
|
182
|
+
return sanitized === location ? sanitized : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
170
185
|
private async callback(contextArg: IRelyingPartyHttpContext): Promise<Response> {
|
|
171
186
|
// the callback is read from the public origin, never from a forwarded host
|
|
172
187
|
const callbackUrl = new URL(contextArg.url.pathname + contextArg.url.search, this.#sessions.publicOrigin);
|
|
@@ -186,6 +201,10 @@ export class RelyingPartyRoutes<TPrincipal extends object | undefined = undefine
|
|
|
186
201
|
const clearIntent = { 'Set-Cookie': this.#sessions.clearIntentCookie() };
|
|
187
202
|
if (error instanceof RelyingPartyRefusal) {
|
|
188
203
|
this.emit({ event: 'auth_callback_refused', code: error.code });
|
|
204
|
+
const own = this.refusalLocationOf(error);
|
|
205
|
+
if (own) {
|
|
206
|
+
return redirect(own, clearIntent);
|
|
207
|
+
}
|
|
189
208
|
const locations = this.#options.refusalLocations ?? {};
|
|
190
209
|
if (Object.hasOwn(locations, error.code)) {
|
|
191
210
|
return redirect(locations[error.code], clearIntent);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
RelyingPartyRefusal,
|
|
7
7
|
} from './classes.errors.js';
|
|
8
8
|
import {
|
|
9
|
+
OIDC_PROMPTS,
|
|
9
10
|
OidcCompletionError,
|
|
10
11
|
OidcRefreshRefusedError,
|
|
11
12
|
type IOidcCompletion,
|
|
@@ -22,7 +23,9 @@ import type {
|
|
|
22
23
|
IProviderToken,
|
|
23
24
|
IRelyingPartyEvent,
|
|
24
25
|
IRelyingPartyHooks,
|
|
26
|
+
IRelyingPartyAdmission,
|
|
25
27
|
IRelyingPartyLiveSession,
|
|
28
|
+
IRelyingPartyLoginOptions,
|
|
26
29
|
IRelyingPartyLoginStart,
|
|
27
30
|
IRelyingPartySession,
|
|
28
31
|
IRelyingPartySessionOpening,
|
|
@@ -41,6 +44,8 @@ const MAX_COOKIE_TOKEN_LENGTH = 256;
|
|
|
41
44
|
const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
42
45
|
/** A sign-in transaction has to survive an interactive login at the provider, not a working day. */
|
|
43
46
|
export const LOGIN_ATTEMPT_MAX_AGE_SECONDS = 10 * 60;
|
|
47
|
+
/** The longest intent a login carries (`IRelyingPartyLoginOptions.intent`), in characters. */
|
|
48
|
+
export const RELYING_PARTY_MAX_INTENT_LENGTH = 4_096;
|
|
44
49
|
/** Attempts are swept once they are this much older than their expiry. */
|
|
45
50
|
const LOGIN_ATTEMPT_SWEEP_GRACE_MS = 60 * 60 * 1_000;
|
|
46
51
|
/** How many of them one sign-in removes; the next sign-in takes the following page. */
|
|
@@ -141,7 +146,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
141
146
|
public readonly loginPathPrefix: string;
|
|
142
147
|
public readonly sessionMaxAgeSeconds: number;
|
|
143
148
|
readonly #models: IRelyingPartyModels;
|
|
144
|
-
readonly #admit:
|
|
149
|
+
readonly #admit: (completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission) => Promise<{ principalId: string }>;
|
|
145
150
|
/** the principal of a session when it may act right now, `null` otherwise */
|
|
146
151
|
readonly #activePrincipal: (principalIdArg: string) => Promise<{ principal: TPrincipal } | null>;
|
|
147
152
|
readonly #provider?: IOidcProvider;
|
|
@@ -195,7 +200,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
195
200
|
if ((typeof resolve === 'function') === (typeof isActive === 'function')) {
|
|
196
201
|
throw new Error('principals needs exactly one of isActive and resolve');
|
|
197
202
|
}
|
|
198
|
-
this.#admit = (completionArg) => principals.admit(completionArg);
|
|
203
|
+
this.#admit = (completionArg, admissionArg) => principals.admit(completionArg, admissionArg);
|
|
199
204
|
// choose the form by the same test the check above used, so a present but
|
|
200
205
|
// undefined `resolve` (a spread, a widened object) selects `isActive`
|
|
201
206
|
if (typeof resolve === 'function') {
|
|
@@ -243,16 +248,22 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
243
248
|
|
|
244
249
|
/**
|
|
245
250
|
* Opens a session the application authenticated itself (a local password
|
|
246
|
-
* login). It carries no provider tokens.
|
|
251
|
+
* login, a one-time link of its own). It carries no provider tokens.
|
|
252
|
+
* `maxAgeSeconds` makes it live shorter than `sessionMaxAgeSeconds`, the
|
|
253
|
+
* cookie with it; it is a whole number of seconds from 1 to that maximum.
|
|
247
254
|
*/
|
|
248
|
-
public async openSession(optionsArg: { principalId: string }): Promise<IRelyingPartySessionOpening> {
|
|
255
|
+
public async openSession(optionsArg: { principalId: string; maxAgeSeconds?: number }): Promise<IRelyingPartySessionOpening> {
|
|
249
256
|
if (typeof optionsArg?.principalId !== 'string' || optionsArg.principalId.length === 0) {
|
|
250
257
|
throw new Error('openSession needs a principal id');
|
|
251
258
|
}
|
|
259
|
+
const maxAgeSeconds = optionsArg.maxAgeSeconds ?? this.sessionMaxAgeSeconds;
|
|
260
|
+
if (!Number.isSafeInteger(maxAgeSeconds) || maxAgeSeconds < 1 || maxAgeSeconds > this.sessionMaxAgeSeconds) {
|
|
261
|
+
throw new RangeError(`openSession maxAgeSeconds must be a whole number from 1 to ${this.sessionMaxAgeSeconds}`);
|
|
262
|
+
}
|
|
252
263
|
const token = randomToken(32);
|
|
253
|
-
const session = this.newSessionDoc(token, optionsArg.principalId);
|
|
264
|
+
const session = this.newSessionDoc(token, optionsArg.principalId, maxAgeSeconds);
|
|
254
265
|
await session.save();
|
|
255
|
-
return this.opening(session, token);
|
|
266
|
+
return this.opening(session, token, maxAgeSeconds);
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
/** The live session behind the session cookie, or `null`. A read touches it at most once a minute. */
|
|
@@ -378,8 +389,15 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
378
389
|
* sealed next to it, and an intent cookie that binds the callback to this
|
|
379
390
|
* browser.
|
|
380
391
|
*/
|
|
381
|
-
public async startLogin(returnPathArg: unknown): Promise<IRelyingPartyLoginStart> {
|
|
392
|
+
public async startLogin(returnPathArg: unknown, optionsArg: IRelyingPartyLoginOptions = {}): Promise<IRelyingPartyLoginStart> {
|
|
382
393
|
const provider = this.requireProvider();
|
|
394
|
+
const { intent, prompt } = optionsArg;
|
|
395
|
+
if (intent !== undefined && (typeof intent !== 'string' || intent.length === 0 || intent.length > RELYING_PARTY_MAX_INTENT_LENGTH)) {
|
|
396
|
+
throw new RangeError(`a login intent is text of 1 to ${RELYING_PARTY_MAX_INTENT_LENGTH} characters`);
|
|
397
|
+
}
|
|
398
|
+
if (prompt !== undefined && !OIDC_PROMPTS.includes(prompt)) {
|
|
399
|
+
throw new RangeError(`a login prompt is one of ${OIDC_PROMPTS.join(', ')}`);
|
|
400
|
+
}
|
|
383
401
|
await this.sweepExpiredAttempts();
|
|
384
402
|
const state = randomToken(32);
|
|
385
403
|
const nonce = randomToken(32);
|
|
@@ -394,12 +412,17 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
394
412
|
attempt.sealedPkceVerifier = this.#secretBox.seal(pkceVerifier);
|
|
395
413
|
attempt.nonce = nonce;
|
|
396
414
|
attempt.returnPath = sanitizeReturnPath(returnPathArg, this.publicOrigin, this.loginPathPrefix);
|
|
415
|
+
if (intent !== undefined) {
|
|
416
|
+
attempt.sealedIntent = this.#secretBox.seal(intent);
|
|
417
|
+
}
|
|
397
418
|
attempt.createdAt = now;
|
|
398
419
|
attempt.expiresAt = now + LOGIN_ATTEMPT_MAX_AGE_SECONDS * 1_000;
|
|
420
|
+
// the URL first: a login the provider cannot start yet (before discovery) stores no attempt
|
|
421
|
+
const authorizationUrl = await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier, ...(prompt ? { prompt } : {}) });
|
|
399
422
|
await attempt.save();
|
|
400
423
|
|
|
401
424
|
return {
|
|
402
|
-
authorizationUrl
|
|
425
|
+
authorizationUrl,
|
|
403
426
|
intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
|
|
404
427
|
httpOnly: true,
|
|
405
428
|
secure: this.secureCookies,
|
|
@@ -456,7 +479,10 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
456
479
|
pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
|
|
457
480
|
});
|
|
458
481
|
stage = 'principal_admission';
|
|
459
|
-
const { principalId } = await this.#admit(completion
|
|
482
|
+
const { principalId } = await this.#admit(completion, {
|
|
483
|
+
loginAttemptId: attempt.id,
|
|
484
|
+
...(attempt.sealedIntent ? { intent: this.#secretBox.open(attempt.sealedIntent) } : {}),
|
|
485
|
+
});
|
|
460
486
|
stage = 'session_persistence';
|
|
461
487
|
return await this.createProviderSession(attempt, principalId, completion);
|
|
462
488
|
} catch (error) {
|
|
@@ -696,7 +722,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
696
722
|
return this.#provider;
|
|
697
723
|
}
|
|
698
724
|
|
|
699
|
-
private newSessionDoc(tokenArg: string, principalIdArg: string): RelyingPartySessionDoc {
|
|
725
|
+
private newSessionDoc(tokenArg: string, principalIdArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): RelyingPartySessionDoc {
|
|
700
726
|
const now = this.#now();
|
|
701
727
|
const session = new this.#models.Session();
|
|
702
728
|
session.id = 'ses_' + randomToken(18);
|
|
@@ -704,7 +730,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
704
730
|
session.userId = principalIdArg;
|
|
705
731
|
session.createdAt = now;
|
|
706
732
|
session.lastSeenAt = now;
|
|
707
|
-
session.expiresAt = now +
|
|
733
|
+
session.expiresAt = now + maxAgeSecondsArg * 1_000;
|
|
708
734
|
return session;
|
|
709
735
|
}
|
|
710
736
|
|
|
@@ -712,7 +738,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
712
738
|
return deriveCsrfToken(tokenArg, this.#csrfKey, this.#csrfLabel);
|
|
713
739
|
}
|
|
714
740
|
|
|
715
|
-
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string): IRelyingPartySessionOpening {
|
|
741
|
+
private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
|
|
716
742
|
return {
|
|
717
743
|
session: this.snapshot(sessionArg),
|
|
718
744
|
csrfToken: this.csrfTokenOf(tokenArg),
|
|
@@ -720,7 +746,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
|
|
|
720
746
|
httpOnly: true,
|
|
721
747
|
secure: this.secureCookies,
|
|
722
748
|
sameSite: 'Lax',
|
|
723
|
-
maxAgeSeconds:
|
|
749
|
+
maxAgeSeconds: maxAgeSecondsArg,
|
|
724
750
|
}),
|
|
725
751
|
};
|
|
726
752
|
}
|
|
@@ -86,6 +86,10 @@ export class RelyingPartyLoginAttemptDoc extends plugins.nosqldb.SmartDataDbDoc<
|
|
|
86
86
|
@plugins.nosqldb.svDb()
|
|
87
87
|
public returnPath: string = '/';
|
|
88
88
|
|
|
89
|
+
/** the application's intent for this login, sealed like the verifier (`IRelyingPartyLoginOptions.intent`); absent without one */
|
|
90
|
+
@plugins.nosqldb.svDb()
|
|
91
|
+
public sealedIntent?: string;
|
|
92
|
+
|
|
89
93
|
@plugins.nosqldb.svDb()
|
|
90
94
|
public createdAt: number = 0;
|
|
91
95
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcTokenHint } from './classes.idpoidcprovider.js';
|
|
1
|
+
import type { IOidcCompletion, TOidcCompletionFailureStage, TOidcPrompt, TOidcTokenHint } from './classes.idpoidcprovider.js';
|
|
2
2
|
|
|
3
3
|
/** A session as the application reads it; no token and no sealed value is part of it. */
|
|
4
4
|
export interface IRelyingPartySession {
|
|
@@ -35,6 +35,27 @@ export interface IRelyingPartySessionOpening {
|
|
|
35
35
|
sessionCookie: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* What `startLogin` carries besides the return path. `intent` is the
|
|
40
|
+
* application's own text — the invitation a login accepts, the account it
|
|
41
|
+
* links, the client it authorizes — sealed into the login attempt and handed
|
|
42
|
+
* to admission (`IRelyingPartyAdmission`) when exactly this login comes back;
|
|
43
|
+
* it never travels through the browser or the provider.
|
|
44
|
+
*/
|
|
45
|
+
export interface IRelyingPartyLoginOptions {
|
|
46
|
+
/** at most `RELYING_PARTY_MAX_INTENT_LENGTH` characters, not empty */
|
|
47
|
+
intent?: string;
|
|
48
|
+
prompt?: TOidcPrompt;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What admission learns about the login besides the provider's completion. */
|
|
52
|
+
export interface IRelyingPartyAdmission {
|
|
53
|
+
/** the intent `startLogin` was given for this login, opened from its seal; absent when there was none */
|
|
54
|
+
intent?: string;
|
|
55
|
+
/** the login attempt the session will name (`IRelyingPartySession.loginAttemptId`) */
|
|
56
|
+
loginAttemptId: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
/** The redirect that starts a provider login, and the cookie that binds it to this browser. */
|
|
39
60
|
export interface IRelyingPartyLoginStart {
|
|
40
61
|
authorizationUrl: URL;
|
|
@@ -66,7 +87,7 @@ export interface IAuthorityTokenSource {
|
|
|
66
87
|
* then handed back and the refusal reaches the caller unchanged.
|
|
67
88
|
*/
|
|
68
89
|
export interface IRelyingPartyPrincipals {
|
|
69
|
-
admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
|
|
90
|
+
admit(completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission): Promise<{ principalId: string }>;
|
|
70
91
|
/** Whether a principal may act right now; a session of an inactive principal resolves to nothing. */
|
|
71
92
|
isActive(principalIdArg: string): Promise<boolean>;
|
|
72
93
|
}
|
|
@@ -78,7 +99,7 @@ export interface IRelyingPartyPrincipals {
|
|
|
78
99
|
* `principal`. Give either `isActive` or `resolve`, never both.
|
|
79
100
|
*/
|
|
80
101
|
export interface IRelyingPartyPrincipalResolver<TPrincipal extends object | undefined> {
|
|
81
|
-
admit(completionArg: IOidcCompletion): Promise<{ principalId: string }>;
|
|
102
|
+
admit(completionArg: IOidcCompletion, admissionArg: IRelyingPartyAdmission): Promise<{ principalId: string }>;
|
|
82
103
|
/** The principal object when it may act right now, `null` otherwise; any answer that is not an object (including `false`) resolves the session to nothing. */
|
|
83
104
|
resolve(principalIdArg: string): Promise<TPrincipal | null>;
|
|
84
105
|
}
|