@idp.global/sdk 19.0.0 → 19.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import * as plugins from './plugins.js';
2
+ import { OidcCompletionError, OidcRefreshRefusedError, } from './classes.idpoidcprovider.js';
3
+ const randomToken = () => plugins.crypto.randomBytes(18).toString('base64url');
4
+ /**
5
+ * The provider as `IOidcProvider` sees it. A test adds personas, names the one
6
+ * the next login signs in as (`signInNextAs`), follows the authorization URL
7
+ * back with `callbackUrlFor`, and changes what idp.global reports live
8
+ * (`setOrganizationRoles`, `endLoginsOf`). Codes are single use and bound to
9
+ * their login's state; access tokens introspect for their subject while they
10
+ * live; refresh tokens rotate, and a superseded one coming back revokes its
11
+ * whole family, as idp.global's reuse detection does. Every call is recorded,
12
+ * and each step can be made to fail.
13
+ */
14
+ export class RelyingPartyProviderDouble {
15
+ requestTimeoutSeconds;
16
+ issuer;
17
+ accessTokenLifetimeSeconds;
18
+ authorizations = [];
19
+ completions = [];
20
+ revocations = [];
21
+ introspections = [];
22
+ refreshes = [];
23
+ /** when set, the completion of a login fails with it (an `OidcCompletionError` names its stage) */
24
+ completionFailure;
25
+ /** when set, every introspection fails with it (an `OidcAuthorityUnavailableError` for an unreachable provider) */
26
+ introspectionFailure;
27
+ /** when set, every refresh fails with it */
28
+ refreshFailure;
29
+ /** when set, every revocation fails with it, after it was recorded */
30
+ revocationFailure;
31
+ /** runs inside an introspection before it answers: a test holds one open with it */
32
+ onIntrospect;
33
+ /** runs inside a refresh before it answers: a test races a logout with it */
34
+ onRefresh;
35
+ #now;
36
+ #personas = new Map();
37
+ #codes = new Map();
38
+ #tokens = new Map();
39
+ #next;
40
+ constructor(optionsArg = {}) {
41
+ this.issuer = new URL(optionsArg.issuer ?? 'https://idp.test').origin;
42
+ this.accessTokenLifetimeSeconds = optionsArg.accessTokenLifetimeSeconds ?? 3_600;
43
+ this.requestTimeoutSeconds = optionsArg.requestTimeoutSeconds ?? 10;
44
+ this.#now = optionsArg.now ?? (() => Date.now());
45
+ }
46
+ // ---- personas ----
47
+ /** Adds a persona, or replaces the one with its subject. */
48
+ addPersona(personaArg) {
49
+ this.#personas.set(personaArg.subject, {
50
+ ...personaArg,
51
+ organizations: (personaArg.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
52
+ });
53
+ }
54
+ persona(subjectArg) {
55
+ return this.#personas.get(subjectArg);
56
+ }
57
+ /** The persona the next authorization signs in as; a persona given whole is added first. */
58
+ signInNextAs(personaArg) {
59
+ if (typeof personaArg !== 'string') {
60
+ this.addPersona(personaArg);
61
+ }
62
+ const subject = typeof personaArg === 'string' ? personaArg : personaArg.subject;
63
+ if (!this.#personas.has(subject)) {
64
+ throw new Error(`RelyingPartyProviderDouble: no persona ${subject}; addPersona first`);
65
+ }
66
+ this.#next = subject;
67
+ }
68
+ /**
69
+ * What idp.global reports for the persona in an organization from now on:
70
+ * its roles, or `null` when the persona is no member any more. Introspection
71
+ * reads it at once; the claims of a login are those of its sign-in.
72
+ */
73
+ setOrganizationRoles(subjectArg, organizationIdArg, rolesArg) {
74
+ const persona = this.requirePersona(subjectArg);
75
+ const organizations = (persona.organizations ?? []).filter((entryArg) => entryArg.id !== organizationIdArg);
76
+ if (rolesArg) {
77
+ const known = persona.organizations?.find((entryArg) => entryArg.id === organizationIdArg);
78
+ organizations.push({
79
+ id: organizationIdArg,
80
+ name: known?.name ?? organizationIdArg,
81
+ slug: known?.slug ?? organizationIdArg,
82
+ roles: [...rolesArg],
83
+ });
84
+ }
85
+ persona.organizations = organizations;
86
+ }
87
+ /** Ends every login of the persona at the provider: its tokens neither introspect nor rotate any more. */
88
+ endLoginsOf(subjectArg) {
89
+ for (const token of this.#tokens.values()) {
90
+ if (token.subject === subjectArg) {
91
+ token.revoked = true;
92
+ }
93
+ }
94
+ }
95
+ /** Where the provider would send the browser back after the login this authorization URL started. */
96
+ callbackUrlFor(authorizationUrlArg, redirectUriArg) {
97
+ const callback = new URL(redirectUriArg);
98
+ callback.searchParams.set('code', authorizationUrlArg.searchParams.get('code') ?? '');
99
+ callback.searchParams.set('state', authorizationUrlArg.searchParams.get('state') ?? '');
100
+ return callback;
101
+ }
102
+ // ---- IOidcProvider ----
103
+ async init() { }
104
+ async buildAuthorizationUrl(optionsArg) {
105
+ if (!this.#next) {
106
+ throw new Error('RelyingPartyProviderDouble: name the persona this login signs in as with signInNextAs');
107
+ }
108
+ this.authorizations.push({ ...optionsArg });
109
+ const code = 'code_' + randomToken();
110
+ this.#codes.set(code, { state: optionsArg.state, nonce: optionsArg.nonce, subject: this.#next });
111
+ const url = new URL('/oauth/authorize', this.issuer);
112
+ url.searchParams.set('response_type', 'code');
113
+ url.searchParams.set('state', optionsArg.state);
114
+ url.searchParams.set('nonce', optionsArg.nonce);
115
+ if (optionsArg.prompt) {
116
+ url.searchParams.set('prompt', optionsArg.prompt);
117
+ }
118
+ // what the double hands back at once; a real provider answers it after the login page
119
+ url.searchParams.set('code', code);
120
+ return url;
121
+ }
122
+ async completeAuthorization(optionsArg) {
123
+ const code = optionsArg.callbackUrl.searchParams.get('code') ?? '';
124
+ const issued = this.#codes.get(code);
125
+ this.#codes.delete(code);
126
+ if (!issued || issued.state !== optionsArg.state) {
127
+ throw new OidcCompletionError('token_exchange', new Error('unknown, used or foreign code'));
128
+ }
129
+ if (issued.nonce !== optionsArg.nonce) {
130
+ throw new OidcCompletionError('token_validation', new Error('nonce mismatch'));
131
+ }
132
+ this.completions.push({ subject: issued.subject, state: issued.state });
133
+ if (this.completionFailure) {
134
+ throw this.completionFailure;
135
+ }
136
+ const persona = this.requirePersona(issued.subject);
137
+ const family = randomToken();
138
+ const access = this.issue('access', issued.subject, family);
139
+ const refresh = this.issue('refresh', issued.subject, family);
140
+ return {
141
+ subject: persona.subject,
142
+ accessToken: access.token,
143
+ refreshToken: refresh.token,
144
+ accessTokenExpiresAt: access.expiresAt,
145
+ ...(persona.email !== undefined ? { email: persona.email } : {}),
146
+ emailVerified: persona.emailVerified ?? true,
147
+ ...(persona.displayName !== undefined ? { displayName: persona.displayName } : {}),
148
+ organizations: (persona.organizations ?? []).map((entryArg) => ({ ...entryArg, roles: [...entryArg.roles] })),
149
+ };
150
+ }
151
+ async revokeToken(tokenArg, hintArg) {
152
+ this.revocations.push({ token: tokenArg, hint: hintArg });
153
+ if (this.revocationFailure) {
154
+ throw this.revocationFailure;
155
+ }
156
+ const token = this.#tokens.get(tokenArg);
157
+ if (token) {
158
+ token.revoked = true;
159
+ }
160
+ }
161
+ async introspectOrganization(optionsArg) {
162
+ this.introspections.push({ ...optionsArg });
163
+ await this.onIntrospect?.({ ...optionsArg });
164
+ if (this.introspectionFailure) {
165
+ throw this.introspectionFailure;
166
+ }
167
+ const token = this.#tokens.get(optionsArg.accessToken);
168
+ const inactive = { active: false, roles: [], expiresAt: 0 };
169
+ if (!token || token.kind !== 'access' || !this.isLive(token) || token.subject !== optionsArg.subject) {
170
+ return inactive;
171
+ }
172
+ const membership = this.#personas.get(token.subject)?.organizations?.find((entryArg) => entryArg.id === optionsArg.organizationId);
173
+ if (!membership) {
174
+ return inactive;
175
+ }
176
+ return { active: true, roles: [...membership.roles].sort(), expiresAt: token.expiresAt };
177
+ }
178
+ async refreshTokens(refreshTokenArg) {
179
+ this.refreshes.push(refreshTokenArg);
180
+ await this.onRefresh?.(refreshTokenArg);
181
+ if (this.refreshFailure) {
182
+ throw this.refreshFailure;
183
+ }
184
+ const token = this.#tokens.get(refreshTokenArg);
185
+ if (!token || token.kind !== 'refresh' || token.revoked) {
186
+ throw new OidcRefreshRefusedError(new Error('invalid_grant'));
187
+ }
188
+ if (token.used) {
189
+ // reuse of a superseded refresh token: the family dies
190
+ for (const entry of this.#tokens.values()) {
191
+ if (entry.family === token.family) {
192
+ entry.revoked = true;
193
+ }
194
+ }
195
+ throw new OidcRefreshRefusedError(new Error('invalid_grant'));
196
+ }
197
+ token.used = true;
198
+ const access = this.issue('access', token.subject, token.family);
199
+ const refresh = this.issue('refresh', token.subject, token.family);
200
+ return { accessToken: access.token, refreshToken: refresh.token, accessTokenExpiresAt: access.expiresAt };
201
+ }
202
+ // ---- internals ----
203
+ requirePersona(subjectArg) {
204
+ const persona = this.#personas.get(subjectArg);
205
+ if (!persona) {
206
+ throw new Error(`RelyingPartyProviderDouble: no persona ${subjectArg}`);
207
+ }
208
+ return persona;
209
+ }
210
+ issue(kindArg, subjectArg, familyArg) {
211
+ const token = (kindArg === 'access' ? 'at_' : 'rt_') + randomToken();
212
+ const expiresAt = kindArg === 'access' && this.accessTokenLifetimeSeconds > 0
213
+ ? this.#now() + this.accessTokenLifetimeSeconds * 1_000
214
+ : 0;
215
+ this.#tokens.set(token, { subject: subjectArg, family: familyArg, expiresAt, kind: kindArg, revoked: false, used: false });
216
+ return { token, expiresAt };
217
+ }
218
+ isLive(tokenArg) {
219
+ return !tokenArg.revoked && (tokenArg.expiresAt === 0 || tokenArg.expiresAt > this.#now());
220
+ }
221
+ }
222
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVzdGluZy5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uL3RzX3JlbHlpbmdwYXJ0eS90ZXN0aW5nLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBSyxPQUFPLE1BQU0sY0FBYyxDQUFDO0FBQ3hDLE9BQU8sRUFDTCxtQkFBbUIsRUFDbkIsdUJBQXVCLEdBU3hCLE1BQU0sOEJBQThCLENBQUM7QUFnRHRDLE1BQU0sV0FBVyxHQUFHLEdBQVcsRUFBRSxDQUFDLE9BQU8sQ0FBQyxNQUFNLENBQUMsV0FBVyxDQUFDLEVBQUUsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxXQUFXLENBQUMsQ0FBQztBQUV2Rjs7Ozs7Ozs7O0dBU0c7QUFDSCxNQUFNLE9BQU8sMEJBQTBCO0lBQ3JCLHFCQUFxQixDQUFTO0lBQzlCLE1BQU0sQ0FBUztJQUN4QiwwQkFBMEIsQ0FBUztJQUUxQixjQUFjLEdBQWdDLEVBQUUsQ0FBQztJQUNqRCxXQUFXLEdBQThDLEVBQUUsQ0FBQztJQUM1RCxXQUFXLEdBQW1ELEVBQUUsQ0FBQztJQUNqRSxjQUFjLEdBQTRFLEVBQUUsQ0FBQztJQUM3RixTQUFTLEdBQWEsRUFBRSxDQUFDO0lBRXpDLG1HQUFtRztJQUM1RixpQkFBaUIsQ0FBUztJQUNqQyxtSEFBbUg7SUFDNUcsb0JBQW9CLENBQVM7SUFDcEMsNENBQTRDO0lBQ3JDLGNBQWMsQ0FBUztJQUM5QixzRUFBc0U7SUFDL0QsaUJBQWlCLENBQVM7SUFDakMsb0ZBQW9GO0lBQzdFLFlBQVksQ0FBbUc7SUFDdEgsNkVBQTZFO0lBQ3RFLFNBQVMsQ0FBOEM7SUFFckQsSUFBSSxDQUFlO0lBQ25CLFNBQVMsR0FBRyxJQUFJLEdBQUcsRUFBa0MsQ0FBQztJQUN0RCxNQUFNLEdBQUcsSUFBSSxHQUFHLEVBQXVCLENBQUM7SUFDeEMsT0FBTyxHQUFHLElBQUksR0FBRyxFQUF3QixDQUFDO0lBQ25ELEtBQUssQ0FBVTtJQUVmLFlBQVksYUFBcUMsRUFBRTtRQUNqRCxJ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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idp.global/sdk",
3
- "version": "19.0.0",
3
+ "version": "19.1.0",
4
4
  "private": false,
5
5
  "description": "Reusable browser and server SDK for idp.global authentication.",
6
6
  "type": "module",
@@ -8,7 +8,8 @@
8
8
  ".": "./dist_ts/index.js",
9
9
  "./browser": "./dist_ts_browser/index.js",
10
10
  "./server": "./dist_ts_server/index.js",
11
- "./relyingparty": "./dist_ts_relyingparty/index.js"
11
+ "./relyingparty": "./dist_ts_relyingparty/index.js",
12
+ "./relyingparty/testing": "./dist_ts_relyingparty/testing.js"
12
13
  },
13
14
  "main": "dist_ts/index.js",
14
15
  "typings": "dist_ts/index.d.ts",
@@ -18,7 +19,7 @@
18
19
  "@api.global/typedrequest": "^8.2.0",
19
20
  "@api.global/typedrequest-interfaces": "^7.1.0",
20
21
  "@api.global/typedsocket": "^8.7.0",
21
- "@idp.global/interfaces": "^29.1.0",
22
+ "@idp.global/interfaces": "^30.0.0",
22
23
  "@push.rocks/smartjson": "^6.0.1",
23
24
  "@push.rocks/smartjwt": "^2.7.0",
24
25
  "@push.rocks/smartpromise": "^4.2.4",
package/readme.md CHANGED
@@ -525,6 +525,20 @@ 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
+ - **Intents and prompts**: `sessions.startLogin(returnPath, { intent, prompt })`
529
+ is what an application calls from a login entry of its own (an invitation it
530
+ accepts, an account it links, a client it authorizes). `intent` is the
531
+ application's text of up to 4,096 characters, sealed into the login attempt
532
+ like the verifier; it never travels through the browser or the provider, and
533
+ `principals.admit(completion, { intent, loginAttemptId })` receives it when
534
+ exactly this login comes back. `prompt` (`login`, `none`, `consent`) goes to
535
+ the authorization endpoint: `login` makes the person authenticate again
536
+ before something long-lived is made. A refusal from `admit` may name where
537
+ in the application the browser goes
538
+ (`new RelyingPartyRefusal(code, status, { location: '/#link/<token>' })`);
539
+ the routes follow it only when it is a plain path on the public origin
540
+ outside the login routes, exactly as given, and otherwise answer as if it
541
+ named none.
528
542
  - **Sessions**: an HttpOnly, `SameSite=Lax` cookie whose token is stored as a
529
543
  hash, touched at most once a minute, with a CSRF token derived per request as
530
544
  an HMAC of the cookie token. `GET /auth/session` answers the session and
@@ -532,7 +546,9 @@ What it does:
532
546
  cancelled as soon as it passes `maxBodyBytes`. Every end of a session is one
533
547
  conditional update; its provider tokens go back to idp.global within a
534
548
  three-second budget afterwards, and so do the tokens of a login that failed
535
- after the code exchange.
549
+ after the code exchange. `openSession({ principalId, maxAgeSeconds })` opens
550
+ a session the application authenticated itself, optionally shorter than
551
+ `sessionMaxAgeSeconds` (the cookie with it), and without provider tokens.
536
552
  - **Provider tokens**: sealed with AES-256-GCM (`SecretBox`) and rotated shortly
537
553
  before they expire, once per process and under a lock stored on the session,
538
554
  so two replicas never spend one refresh token. A refused refresh ends the
@@ -679,19 +695,55 @@ for (const connection of await binding.connectionsOfSession(sessionId)) {
679
695
  }
680
696
  ```
681
697
 
698
+ ### Testing an Application
699
+
700
+ `@idp.global/sdk/relyingparty/testing` exports `RelyingPartyProviderDouble`, an
701
+ in-process idp.global implementing `IOidcProvider` for an application's own
702
+ tests; it belongs in test code only.
703
+
704
+ ```ts
705
+ import { RelyingPartyProviderDouble } from '@idp.global/sdk/relyingparty/testing';
706
+
707
+ const idp = new RelyingPartyProviderDouble({ now: clock.now });
708
+ idp.addPersona({
709
+ subject: 'sub-owner',
710
+ displayName: 'Olga Owner',
711
+ email: 'olga@example.com',
712
+ organizations: [{ id: 'idp-org-1', name: 'Example GmbH', slug: 'example', roles: ['owner'] }],
713
+ });
714
+ idp.signInNextAs('sub-owner');
715
+
716
+ // the application's GET /auth/login answers with the authorization URL …
717
+ const callback = idp.callbackUrlFor(authorizationUrl, new URL('/auth/callback', publicOrigin));
718
+ // … and the browser comes back to the application's callback with it
719
+
720
+ idp.setOrganizationRoles('sub-owner', 'idp-org-1', ['viewer']); // what introspection answers from now on
721
+ idp.endLoginsOf('sub-owner'); // every token of the persona stops working
722
+ ```
723
+
724
+ Codes are single use and bound to their login's state and nonce; access tokens
725
+ introspect for their subject while they live; refresh tokens rotate, and a
726
+ superseded one coming back revokes its whole family, as idp.global's reuse
727
+ detection does. Every call is recorded (`authorizations`, `completions`,
728
+ `revocations`, `introspections`, `refreshes`), and each step can be made to
729
+ fail (`completionFailure`, `introspectionFailure`, `refreshFailure`,
730
+ `revocationFailure`) or held open (`onIntrospect`, `onRefresh`). The claims of a
731
+ login are those of its sign-in; introspection reads the persona as it is now.
732
+
682
733
  ### Relying-Party API Surface
683
734
 
684
735
  | API | Description |
685
736
  | --- | --- |
686
737
  | `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
738
  | `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`, `resolveCookieSession`, `revalidateSession`, `revalidateLiveSession`, `isSessionLive`, `validateCsrf`, `logout`/`endSession`, `endSessionsOfPrincipal`, `liveProviderToken`. Principals give `isActive` or `resolve` (`IRelyingPartyPrincipals`, `IRelyingPartyPrincipalResolver<TPrincipal>`); with `resolve`, live sessions carry `principal`. |
739
+ | `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
740
  | `AuthorityObserver` | Live organization authority with opt-in positive and negative reuse. |
690
741
  | `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
742
  | `createActorScope()` | AsyncLocalStorage actor context with a TypedRouter request scope. |
692
743
  | `RelyingPartySocketBinding` | TypedSocket browser registration, per-request re-validation and session tags; takes a connection wrapper or its peer. |
693
744
  | `SecretBox`, `deriveCsrfToken()`, `decodeSecretKey()`, `parseCookies()`, `serializeCookie()`, `sanitizeReturnPath()` | The primitives underneath. |
694
- | `RelyingPartyError`, `RelyingPartyRefusal`, `LoginCallbackError`, `AuthorityTokenError`, `AuthorityRefusedError`, `RelyingPartyDatabaseUnavailableError` | Errors with stable codes; none carries token material. |
745
+ | `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. |
746
+ | `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
747
 
696
748
  ## Runtime Notes
697
749
 
@@ -710,7 +762,7 @@ The SDK uses typed sockets and typed requests from `@api.global/*`, shared idp.g
710
762
  pnpm test
711
763
  ```
712
764
 
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 socket binding.
765
+ 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
766
 
715
767
  ## License and Legal Information
716
768
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@idp.global/sdk',
6
- version: '19.0.0',
6
+ version: '19.1.0',
7
7
  description: 'Reusable browser and server SDK for idp.global authentication.'
8
8
  }
@@ -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 {
@@ -300,6 +312,7 @@ export class IdpOidcProvider implements IOidcProvider {
300
312
  code_challenge_method: 'S256',
301
313
  state: optionsArg.state,
302
314
  nonce: optionsArg.nonce,
315
+ ...(optionsArg.prompt ? { prompt: optionsArg.prompt } : {}),
303
316
  });
304
317
  }
305
318
 
@@ -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: TRelyingPartyPrincipals<TPrincipal>['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,15 @@ 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;
399
420
  await attempt.save();
400
421
 
401
422
  return {
402
- authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier }),
423
+ authorizationUrl: await provider.buildAuthorizationUrl({ state, nonce, pkceVerifier, ...(prompt ? { prompt } : {}) }),
403
424
  intentCookie: serializeCookie(this.intentCookieName, bindingToken, {
404
425
  httpOnly: true,
405
426
  secure: this.secureCookies,
@@ -456,7 +477,10 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
456
477
  pkceVerifier: this.#secretBox.open(attempt.sealedPkceVerifier),
457
478
  });
458
479
  stage = 'principal_admission';
459
- const { principalId } = await this.#admit(completion);
480
+ const { principalId } = await this.#admit(completion, {
481
+ loginAttemptId: attempt.id,
482
+ ...(attempt.sealedIntent ? { intent: this.#secretBox.open(attempt.sealedIntent) } : {}),
483
+ });
460
484
  stage = 'session_persistence';
461
485
  return await this.createProviderSession(attempt, principalId, completion);
462
486
  } catch (error) {
@@ -696,7 +720,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
696
720
  return this.#provider;
697
721
  }
698
722
 
699
- private newSessionDoc(tokenArg: string, principalIdArg: string): RelyingPartySessionDoc {
723
+ private newSessionDoc(tokenArg: string, principalIdArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): RelyingPartySessionDoc {
700
724
  const now = this.#now();
701
725
  const session = new this.#models.Session();
702
726
  session.id = 'ses_' + randomToken(18);
@@ -704,7 +728,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
704
728
  session.userId = principalIdArg;
705
729
  session.createdAt = now;
706
730
  session.lastSeenAt = now;
707
- session.expiresAt = now + this.sessionMaxAgeSeconds * 1_000;
731
+ session.expiresAt = now + maxAgeSecondsArg * 1_000;
708
732
  return session;
709
733
  }
710
734
 
@@ -712,7 +736,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
712
736
  return deriveCsrfToken(tokenArg, this.#csrfKey, this.#csrfLabel);
713
737
  }
714
738
 
715
- private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string): IRelyingPartySessionOpening {
739
+ private opening(sessionArg: RelyingPartySessionDoc, tokenArg: string, maxAgeSecondsArg: number = this.sessionMaxAgeSeconds): IRelyingPartySessionOpening {
716
740
  return {
717
741
  session: this.snapshot(sessionArg),
718
742
  csrfToken: this.csrfTokenOf(tokenArg),
@@ -720,7 +744,7 @@ export class RelyingPartySessions<TPrincipal extends object | undefined = undefi
720
744
  httpOnly: true,
721
745
  secure: this.secureCookies,
722
746
  sameSite: 'Lax',
723
- maxAgeSeconds: this.sessionMaxAgeSeconds,
747
+ maxAgeSeconds: maxAgeSecondsArg,
724
748
  }),
725
749
  };
726
750
  }
@@ -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