@udibo/oauth2 0.31.0 → 0.32.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/dist/adapters/hono/bff/bff.d.ts +14 -1
- package/dist/adapters/hono/bff/bff.js +33 -3
- package/dist/client/direct-client.d.ts +10 -0
- package/dist/client/direct-client.js +4 -0
- package/dist/client/storage.d.ts +19 -0
- package/dist/testing/contract/auth-request-storage.d.ts +5 -0
- package/dist/testing/contract/auth-request-storage.js +13 -0
- package/package.json +1 -1
|
@@ -526,7 +526,20 @@ export interface HonoBffOptions {
|
|
|
526
526
|
* `auth_time` but no particular age. A token response without an ID token
|
|
527
527
|
* is not checked, unless {@link HonoBffOptions.forwardedParams} names
|
|
528
528
|
* `scope`: the browser could then leave out `openid` to skip the check, so
|
|
529
|
-
* a missing ID token fails with `login_required` too.
|
|
529
|
+
* a missing ID token fails with `login_required` too.
|
|
530
|
+
*
|
|
531
|
+
* Both rules follow the sign-in, not the instance that finishes it.
|
|
532
|
+
* `/auth/login` records the `max_age` it sent, and whether it forwarded
|
|
533
|
+
* `scope`, on the pending authorization request
|
|
534
|
+
* ({@link AuthRequestRecord.maxAge}). `/auth/callback` checks against that
|
|
535
|
+
* record, so a login-only instance that pins `max_age` for a step-up route
|
|
536
|
+
* is enforced by a callback another instance serves. An instance that pins
|
|
537
|
+
* `max_age` does not check a sign-in that carried none. A record without
|
|
538
|
+
* the field, written by an earlier release or kept by an
|
|
539
|
+
* {@link HonoBffOptions.authRequestStorage} that drops fields it does not
|
|
540
|
+
* know, is checked against the callback instance's own options instead.
|
|
541
|
+
*
|
|
542
|
+
* A pinned `scope` is
|
|
530
543
|
* accepted only when
|
|
531
544
|
* {@link scope} is unset; setting both would leave which one applies
|
|
532
545
|
* ambiguous. Either configuration supplies the scope the login flow records
|
|
@@ -168,6 +168,30 @@ function pinnedMaxAge(fixed) {
|
|
|
168
168
|
}
|
|
169
169
|
return strictest;
|
|
170
170
|
}
|
|
171
|
+
function recordingAuthenticationControls(storage, controls) {
|
|
172
|
+
return {
|
|
173
|
+
get: storage.get.bind(storage),
|
|
174
|
+
set: (state, value) => storage.set(state, { ...value, ...controls }),
|
|
175
|
+
delete: storage.delete.bind(storage),
|
|
176
|
+
clear: storage.clear.bind(storage),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function recordedAuthenticationControls(recorded) {
|
|
180
|
+
const { maxAge } = recorded;
|
|
181
|
+
const scopeForwarded = recorded.scopeForwarded !== false;
|
|
182
|
+
if (maxAge === null)
|
|
183
|
+
return { maxAge: undefined, scopeForwarded };
|
|
184
|
+
if (typeof maxAge === "number" &&
|
|
185
|
+
Number.isSafeInteger(maxAge) &&
|
|
186
|
+
maxAge >= 0) {
|
|
187
|
+
return { maxAge, scopeForwarded };
|
|
188
|
+
}
|
|
189
|
+
throw new CallbackRefusal({
|
|
190
|
+
error: "login_required",
|
|
191
|
+
error_description: "the pending authorization request recorded a max_age that is not a " +
|
|
192
|
+
"non-negative integer, so the ID token's auth_time could not be checked",
|
|
193
|
+
});
|
|
194
|
+
}
|
|
171
195
|
class CallbackRefusal extends Error {
|
|
172
196
|
refusal;
|
|
173
197
|
constructor(refusal) {
|
|
@@ -724,7 +748,10 @@ export class HonoBff {
|
|
|
724
748
|
prompt: requested.prompt,
|
|
725
749
|
extraParams: requested.extraParams,
|
|
726
750
|
redirectUri: this.#derivedRedirectUri(c, origin),
|
|
727
|
-
authRequestStorage: this.#authRequestStorage(c),
|
|
751
|
+
authRequestStorage: recordingAuthenticationControls(this.#authRequestStorage(c), {
|
|
752
|
+
maxAge: this.#extraAuthorizeParams.maxAge ?? null,
|
|
753
|
+
scopeForwarded: this.#extraAuthorizeParams.scopeForwarded,
|
|
754
|
+
}),
|
|
728
755
|
});
|
|
729
756
|
this.#writeLoginStateHashes(c, [
|
|
730
757
|
...this.#loginStateHashes(c),
|
|
@@ -776,15 +803,18 @@ export class HonoBff {
|
|
|
776
803
|
this.#writeLoginStateHashes(c, pendingStates.filter((_, index) => index !== matched));
|
|
777
804
|
try {
|
|
778
805
|
const origin = this.#options.resolveOrigin?.(c);
|
|
779
|
-
const
|
|
806
|
+
const exchanged = await this.#client.exchangeAuthorizationCode(code, state, {
|
|
780
807
|
origin,
|
|
781
808
|
redirectUri: this.#derivedRedirectUri(c, origin),
|
|
782
809
|
authRequestStorage: this.#authRequestStorage(c),
|
|
783
810
|
});
|
|
811
|
+
const { tokens, refreshToken, returnTo, raw } = exchanged;
|
|
784
812
|
let user = null;
|
|
785
813
|
let sid;
|
|
786
814
|
const idToken = raw.id_token;
|
|
787
|
-
const { maxAge, scopeForwarded } =
|
|
815
|
+
const { maxAge, scopeForwarded } = exchanged.maxAge === undefined
|
|
816
|
+
? this.#extraAuthorizeParams
|
|
817
|
+
: recordedAuthenticationControls(exchanged);
|
|
788
818
|
if (maxAge !== undefined && (idToken || scopeForwarded)) {
|
|
789
819
|
let claims;
|
|
790
820
|
try {
|
|
@@ -210,6 +210,16 @@ export interface ExchangeResult {
|
|
|
210
210
|
refreshToken?: string;
|
|
211
211
|
/** `returnTo` recorded at authorize time, for the code exchange. */
|
|
212
212
|
returnTo?: string;
|
|
213
|
+
/**
|
|
214
|
+
* {@link AuthRequestRecord.maxAge} as recorded at authorize time, for the
|
|
215
|
+
* code exchange. Absent when the record carries none.
|
|
216
|
+
*/
|
|
217
|
+
maxAge?: number | null;
|
|
218
|
+
/**
|
|
219
|
+
* {@link AuthRequestRecord.scopeForwarded} as recorded at authorize time,
|
|
220
|
+
* for the code exchange. Absent when the record carries none.
|
|
221
|
+
*/
|
|
222
|
+
scopeForwarded?: boolean;
|
|
213
223
|
/** The raw token-endpoint response. */
|
|
214
224
|
raw: TokenResponse;
|
|
215
225
|
}
|
|
@@ -565,6 +565,10 @@ export class DirectClient extends OAuth2ClientBase {
|
|
|
565
565
|
tokens: responseToBundle(raw),
|
|
566
566
|
refreshToken: raw.refresh_token,
|
|
567
567
|
returnTo: record.returnTo,
|
|
568
|
+
...(record.maxAge === undefined ? {} : { maxAge: record.maxAge }),
|
|
569
|
+
...(record.scopeForwarded === undefined
|
|
570
|
+
? {}
|
|
571
|
+
: { scopeForwarded: record.scopeForwarded }),
|
|
568
572
|
raw,
|
|
569
573
|
};
|
|
570
574
|
}
|
package/dist/client/storage.d.ts
CHANGED
|
@@ -64,6 +64,25 @@ export interface AuthRequestRecord {
|
|
|
64
64
|
returnTo?: string;
|
|
65
65
|
/** Scope requested for this authorization, space-delimited. */
|
|
66
66
|
scope?: string;
|
|
67
|
+
/**
|
|
68
|
+
* The `max_age` the authorize request carried, in seconds, or `null` when
|
|
69
|
+
* it carried none. `HonoBff` records it at `/auth/login` and checks the ID
|
|
70
|
+
* token's `auth_time` against it at `/auth/callback`, whichever instance
|
|
71
|
+
* serves either one. Absent on a record from a release that did not record
|
|
72
|
+
* it; the callback then falls back to its own pinned `max_age`. A storage
|
|
73
|
+
* must return `null` as `null`, not as an absent field, and a number as a
|
|
74
|
+
* number: the callback refuses any other value with `login_required`.
|
|
75
|
+
*/
|
|
76
|
+
maxAge?: number | null;
|
|
77
|
+
/**
|
|
78
|
+
* Whether the browser chose this request's `scope` (`HonoBff`'s
|
|
79
|
+
* `forwardedParams` names `scope`). When it did and {@link maxAge} is set,
|
|
80
|
+
* the callback refuses a token response without an ID token, because a
|
|
81
|
+
* scope without `openid` would otherwise skip the `auth_time` check.
|
|
82
|
+
* Recorded alongside {@link maxAge}; the callback reads any value other than
|
|
83
|
+
* `false` as `true`.
|
|
84
|
+
*/
|
|
85
|
+
scopeForwarded?: boolean;
|
|
67
86
|
/** When the request was created (epoch ms), used for TTL expiry. */
|
|
68
87
|
createdAt: number;
|
|
69
88
|
}
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* concurrent callers taking one record. This suite races eight of them on one
|
|
10
10
|
* record.
|
|
11
11
|
*
|
|
12
|
+
* Every field of a record must come back as it was stored, `maxAge: null`
|
|
13
|
+
* included: a store that keeps only the fields it knows makes `HonoBff` check
|
|
14
|
+
* a sign-in against the callback instance's options instead of the
|
|
15
|
+
* `max_age` the sign-in was started with.
|
|
16
|
+
*
|
|
12
17
|
* @example Verify a Redis-backed store
|
|
13
18
|
* ```ts
|
|
14
19
|
* import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
|
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* concurrent callers taking one record. This suite races eight of them on one
|
|
10
10
|
* record.
|
|
11
11
|
*
|
|
12
|
+
* Every field of a record must come back as it was stored, `maxAge: null`
|
|
13
|
+
* included: a store that keeps only the fields it knows makes `HonoBff` check
|
|
14
|
+
* a sign-in against the callback instance's options instead of the
|
|
15
|
+
* `max_age` the sign-in was started with.
|
|
16
|
+
*
|
|
12
17
|
* @example Verify a Redis-backed store
|
|
13
18
|
* ```ts
|
|
14
19
|
* import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
|
|
@@ -45,6 +50,8 @@ function record(overrides = {}) {
|
|
|
45
50
|
codeVerifier: "verifier-1",
|
|
46
51
|
returnTo: "/dashboard",
|
|
47
52
|
scope: "openid profile",
|
|
53
|
+
maxAge: 300,
|
|
54
|
+
scopeForwarded: true,
|
|
48
55
|
createdAt: Date.now(),
|
|
49
56
|
...overrides,
|
|
50
57
|
};
|
|
@@ -75,6 +82,12 @@ export function runAuthRequestStorageContractTests(options) {
|
|
|
75
82
|
await store.set("state-1", stored);
|
|
76
83
|
expect(await store.get("state-1")).toStrictEqual(stored);
|
|
77
84
|
});
|
|
85
|
+
it("returns a recorded maxAge of null as null, not as an absent field", async () => {
|
|
86
|
+
const stored = record({ maxAge: null, scopeForwarded: false });
|
|
87
|
+
await store.set("state-1", stored);
|
|
88
|
+
expect(await store.get("state-1"), "null records a sign-in that carried no max_age; an absent field " +
|
|
89
|
+
"makes the callback fall back to its own pinned max_age").toStrictEqual(stored);
|
|
90
|
+
});
|
|
78
91
|
it("keeps records under different states independent", async () => {
|
|
79
92
|
await store.set("state-1", record({ codeVerifier: "verifier-1" }));
|
|
80
93
|
await store.set("state-2", record({ codeVerifier: "verifier-2" }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authorization-server",
|