@udibo/oauth2 0.15.1 → 0.15.2
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/identity.d.ts +22 -0
- package/dist/adapters/hono/identity.js +32 -8
- package/dist/server/authorization-server.d.ts +2 -1
- package/dist/server/authorization-server.js +3 -1
- package/dist/server/resource-server.d.ts +28 -3
- package/dist/server/resource-server.js +19 -5
- package/dist/utils/_body.d.ts +15 -0
- package/dist/utils/_body.js +21 -0
- package/package.json +1 -1
|
@@ -88,6 +88,23 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
|
|
|
88
88
|
* outer middleware already terminates CSRF.
|
|
89
89
|
*/
|
|
90
90
|
csrf?: HonoIdentityCsrfOptions | false;
|
|
91
|
+
/**
|
|
92
|
+
* The most bytes a route reads from a request body. Every route reads its
|
|
93
|
+
* body before anyone is authenticated, so the cap bounds what an anonymous
|
|
94
|
+
* request can make the server buffer. Bytes are counted as they arrive, so a
|
|
95
|
+
* chunked body or an understated `Content-Length` cannot get past it. A
|
|
96
|
+
* larger body is refused with `413` `{ error: "invalid_request" }` before
|
|
97
|
+
* the service is called.
|
|
98
|
+
*
|
|
99
|
+
* When a middleware mounted before these routes has already read the body
|
|
100
|
+
* (for example with `c.req.parseBody()`), the routes read Hono's parsed copy
|
|
101
|
+
* and the cap does not apply, so that middleware needs its own limit.
|
|
102
|
+
*
|
|
103
|
+
* @default 65536
|
|
104
|
+
* @throws {RangeError} From {@linkcode honoIdentityRoutes} when it is not a
|
|
105
|
+
* positive integer.
|
|
106
|
+
*/
|
|
107
|
+
maxBodyBytes?: number;
|
|
91
108
|
/**
|
|
92
109
|
* Called after `signup`/`signin` authenticates a user. Your app owns what
|
|
93
110
|
* happens next — create your session and return a `Response` (e.g. a redirect
|
|
@@ -112,7 +129,12 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
|
|
|
112
129
|
* Every mounted route sits behind the same-origin guard described in
|
|
113
130
|
* {@link HonoIdentityOptions.csrf}, which is on unless you pass `csrf: false`.
|
|
114
131
|
*
|
|
132
|
+
* Every route reads at most {@link HonoIdentityOptions.maxBodyBytes} of its
|
|
133
|
+
* body and answers a larger one with `413`.
|
|
134
|
+
*
|
|
115
135
|
* Passwordless, MFA, and social are intentionally not mounted — see the module
|
|
116
136
|
* doc for why and where to hand-wire each.
|
|
137
|
+
*
|
|
138
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
117
139
|
*/
|
|
118
140
|
export declare function honoIdentityRoutes<User extends IdentityUser>(service: IdentityService<User>, options?: HonoIdentityOptions<User>): Hono;
|
|
@@ -41,12 +41,16 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { Hono } from "hono";
|
|
43
43
|
import { identityErrorStatus, isIdentityError, } from "../../identity/errors.js";
|
|
44
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../../utils/_body.js";
|
|
44
45
|
function handle(fn) {
|
|
45
46
|
return async (c) => {
|
|
46
47
|
try {
|
|
47
48
|
return await fn(c);
|
|
48
49
|
}
|
|
49
50
|
catch (error) {
|
|
51
|
+
if (error instanceof BodyTooLargeError) {
|
|
52
|
+
return c.json({ error: "invalid_request" }, 413);
|
|
53
|
+
}
|
|
50
54
|
if (!isIdentityError(error))
|
|
51
55
|
throw error;
|
|
52
56
|
if (error.retryAfterMs !== undefined) {
|
|
@@ -57,11 +61,24 @@ function handle(fn) {
|
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
const STRING_FIELDS = new Set(["password", "identifier", "email", "token"]);
|
|
60
|
-
|
|
64
|
+
class BodyTooLargeError extends Error {
|
|
65
|
+
}
|
|
66
|
+
async function boundedBody(c, maxBytes) {
|
|
67
|
+
if (c.req.raw.bodyUsed)
|
|
68
|
+
return c.req;
|
|
69
|
+
const bytes = await readBoundedBody(c.req.raw, maxBytes).catch(() => new Uint8Array());
|
|
70
|
+
if (!bytes)
|
|
71
|
+
throw new BodyTooLargeError();
|
|
72
|
+
return new Response(bytes, {
|
|
73
|
+
headers: { "content-type": c.req.header("content-type") ?? "" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function readBody(c, maxBytes) {
|
|
61
77
|
const contentType = c.req.header("content-type") ?? "";
|
|
78
|
+
const source = await boundedBody(c, maxBytes);
|
|
62
79
|
if (contentType.includes("application/json")) {
|
|
63
80
|
try {
|
|
64
|
-
const body = await
|
|
81
|
+
const body = await source.json();
|
|
65
82
|
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
66
83
|
return {};
|
|
67
84
|
}
|
|
@@ -79,7 +96,7 @@ async function readBody(c) {
|
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
98
|
try {
|
|
82
|
-
const form = await
|
|
99
|
+
const form = await source.formData();
|
|
83
100
|
const out = {};
|
|
84
101
|
for (const [key, value] of form)
|
|
85
102
|
out[key] = String(value);
|
|
@@ -135,10 +152,17 @@ function sameOriginGuard(options) {
|
|
|
135
152
|
* Every mounted route sits behind the same-origin guard described in
|
|
136
153
|
* {@link HonoIdentityOptions.csrf}, which is on unless you pass `csrf: false`.
|
|
137
154
|
*
|
|
155
|
+
* Every route reads at most {@link HonoIdentityOptions.maxBodyBytes} of its
|
|
156
|
+
* body and answers a larger one with `413`.
|
|
157
|
+
*
|
|
138
158
|
* Passwordless, MFA, and social are intentionally not mounted — see the module
|
|
139
159
|
* doc for why and where to hand-wire each.
|
|
160
|
+
*
|
|
161
|
+
* @throws {RangeError} If `maxBodyBytes` is not a positive integer.
|
|
140
162
|
*/
|
|
141
163
|
export function honoIdentityRoutes(service, options = {}) {
|
|
164
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
165
|
+
assertMaxBodyBytes(maxBodyBytes, "maxBodyBytes");
|
|
142
166
|
const app = new Hono();
|
|
143
167
|
const paths = {
|
|
144
168
|
signUp: "/signup",
|
|
@@ -154,7 +178,7 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
154
178
|
}
|
|
155
179
|
if (onAuthenticated) {
|
|
156
180
|
app.post(paths.signUp, handle(async (c) => {
|
|
157
|
-
const { password, ...profile } = await readBody(c);
|
|
181
|
+
const { password, ...profile } = await readBody(c, maxBodyBytes);
|
|
158
182
|
if (typeof password !== "string" || !password) {
|
|
159
183
|
return c.json({ error: "invalid_request" }, 400);
|
|
160
184
|
}
|
|
@@ -162,7 +186,7 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
162
186
|
return await onAuthenticated(c, user, "signUp");
|
|
163
187
|
}));
|
|
164
188
|
app.post(paths.signIn, handle(async (c) => {
|
|
165
|
-
const body = await readBody(c);
|
|
189
|
+
const body = await readBody(c, maxBodyBytes);
|
|
166
190
|
const user = await service.signIn({
|
|
167
191
|
identifier: String(body.identifier ?? ""),
|
|
168
192
|
password: String(body.password ?? ""),
|
|
@@ -173,12 +197,12 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
173
197
|
}));
|
|
174
198
|
}
|
|
175
199
|
app.post(paths.requestPasswordReset, handle(async (c) => {
|
|
176
|
-
const body = await readBody(c);
|
|
200
|
+
const body = await readBody(c, maxBodyBytes);
|
|
177
201
|
await service.requestPasswordReset(String(body.email ?? ""));
|
|
178
202
|
return c.json({ ok: true });
|
|
179
203
|
}));
|
|
180
204
|
app.post(paths.resetPassword, handle(async (c) => {
|
|
181
|
-
const body = await readBody(c);
|
|
205
|
+
const body = await readBody(c, maxBodyBytes);
|
|
182
206
|
const result = await service.resetPassword({
|
|
183
207
|
token: String(body.token ?? ""),
|
|
184
208
|
password: String(body.password ?? ""),
|
|
@@ -188,7 +212,7 @@ export function honoIdentityRoutes(service, options = {}) {
|
|
|
188
212
|
: c.json({ error: "invalid_token" }, 400);
|
|
189
213
|
}));
|
|
190
214
|
app.post(paths.verifyEmail, handle(async (c) => {
|
|
191
|
-
const body = await readBody(c);
|
|
215
|
+
const body = await readBody(c, maxBodyBytes);
|
|
192
216
|
const result = await service.verifyEmail(String(body.token ?? ""));
|
|
193
217
|
if (result.status === "success") {
|
|
194
218
|
return c.json({ ok: true, userId: result.userId });
|
|
@@ -444,7 +444,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
444
444
|
*
|
|
445
445
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
446
446
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
447
|
-
* @throws {RangeError} If `maxBodyBytes` is not a
|
|
447
|
+
* @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
|
|
448
|
+
* positive integer.
|
|
448
449
|
*/
|
|
449
450
|
constructor(options: AuthorizationServerOptions<Client, User, S>);
|
|
450
451
|
/**
|
|
@@ -197,7 +197,8 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
197
197
|
*
|
|
198
198
|
* @throws {Error} If any grant's map key does not equal its `grantType`.
|
|
199
199
|
* @throws {Error} If `requireOidc` is set without `signingKeys`.
|
|
200
|
-
* @throws {RangeError} If `maxBodyBytes` is not a
|
|
200
|
+
* @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
|
|
201
|
+
* positive integer.
|
|
201
202
|
*/
|
|
202
203
|
constructor(options) {
|
|
203
204
|
super({
|
|
@@ -205,6 +206,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
205
206
|
Scope: options.Scope,
|
|
206
207
|
realm: options.realm,
|
|
207
208
|
clockSkewSeconds: options.clockSkewSeconds,
|
|
209
|
+
maxTokenBodyBytes: options.maxTokenBodyBytes,
|
|
208
210
|
errorFormat: options.errorFormat,
|
|
209
211
|
throwOnError: options.throwOnError,
|
|
210
212
|
});
|
|
@@ -55,6 +55,27 @@ export interface ResourceServerOptions<Client extends ClientInterface, User, S e
|
|
|
55
55
|
* matter which reader is in play.
|
|
56
56
|
*/
|
|
57
57
|
clockSkewSeconds?: number;
|
|
58
|
+
/**
|
|
59
|
+
* The most bytes {@linkcode ResourceServer.getAccessToken} reads from a
|
|
60
|
+
* form-encoded `POST` body while looking for an RFC 6750 §2.2
|
|
61
|
+
* `access_token`, which it does only when no bearer `Authorization` header
|
|
62
|
+
* is sent. That read happens before the caller is authenticated, so the cap
|
|
63
|
+
* bounds what an anonymous request can make the server buffer, including
|
|
64
|
+
* through `protect()` middleware mounted ahead of a route's own body limit.
|
|
65
|
+
* Bytes are counted as they arrive, so a chunked body or an understated
|
|
66
|
+
* `Content-Length` cannot get past it.
|
|
67
|
+
*
|
|
68
|
+
* A larger body is not searched: the request is treated as carrying no
|
|
69
|
+
* token (`authenticate` throws {@linkcode AccessDeniedError}, a `401`), and
|
|
70
|
+
* the body is left for the route. A client that sends a bigger form body
|
|
71
|
+
* should send its token in the `Authorization` header instead, as RFC 6750
|
|
72
|
+
* §2.1 recommends.
|
|
73
|
+
*
|
|
74
|
+
* @default 16384
|
|
75
|
+
* @throws {RangeError} From the constructor when it is not a positive
|
|
76
|
+
* integer.
|
|
77
|
+
*/
|
|
78
|
+
maxTokenBodyBytes?: number;
|
|
58
79
|
/**
|
|
59
80
|
* Format for error responses.
|
|
60
81
|
* - `"oauth2"` (default): Standard OAuth2 error format with `error`,
|
|
@@ -162,7 +183,8 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
|
|
|
162
183
|
* Creates a resource server from the given {@linkcode ResourceServerOptions}.
|
|
163
184
|
*
|
|
164
185
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
165
|
-
* overflows when converted to milliseconds
|
|
186
|
+
* overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
|
|
187
|
+
* not a positive integer.
|
|
166
188
|
*/
|
|
167
189
|
constructor(options: ResourceServerOptions<Client, User, S>);
|
|
168
190
|
/**
|
|
@@ -239,8 +261,11 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
|
|
|
239
261
|
handleAuthError(error: unknown): Response;
|
|
240
262
|
/**
|
|
241
263
|
* Extracts an access token from a request.
|
|
242
|
-
* Looks in the Authorization header (Bearer token) first,
|
|
243
|
-
*
|
|
264
|
+
* Looks in the Authorization header (Bearer token) first, then in the body
|
|
265
|
+
* of a form-encoded `POST` (RFC 6750 §2.2), read from a clone so the
|
|
266
|
+
* original body stays readable. A body over
|
|
267
|
+
* {@linkcode ResourceServerOptions.maxTokenBodyBytes} is not searched, and
|
|
268
|
+
* no more of it is read than the cap.
|
|
244
269
|
*/
|
|
245
270
|
getAccessToken(request: Request): Promise<string | null>;
|
|
246
271
|
/**
|
|
@@ -10,6 +10,7 @@ import { BasicScope } from "../models/scope.js";
|
|
|
10
10
|
import { AccessDeniedError, InsufficientPermissionsError, InsufficientScopeError, InvalidTokenError, toOAuth2Error, } from "../errors.js";
|
|
11
11
|
import { authorizationFromClaims, } from "../models/authorization.js";
|
|
12
12
|
import { assertClockSkewSeconds } from "./_clock-skew.js";
|
|
13
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_TOKEN_BODY_BYTES, peekBoundedBody, } from "../utils/_body.js";
|
|
13
14
|
/** Bearer token regex pattern. */
|
|
14
15
|
export const BEARER_TOKEN = /^ *(?:[Bb][Ee][Aa][Rr][Ee][Rr]) +([\w-.~+/]+=*) *$/;
|
|
15
16
|
const FAILURE_LABELS = {
|
|
@@ -80,6 +81,7 @@ export class ResourceServer {
|
|
|
80
81
|
/** Resolves the per-request {@link ResourceServerContext}. */
|
|
81
82
|
resolveContext;
|
|
82
83
|
#clockSkewSeconds = 0;
|
|
84
|
+
#maxTokenBodyBytes;
|
|
83
85
|
/**
|
|
84
86
|
* Leeway in seconds applied to the access token's expiry. Defaults to `0`.
|
|
85
87
|
* May be reassigned to any non-negative finite number with a finite
|
|
@@ -102,13 +104,17 @@ export class ResourceServer {
|
|
|
102
104
|
* Creates a resource server from the given {@linkcode ResourceServerOptions}.
|
|
103
105
|
*
|
|
104
106
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
105
|
-
* overflows when converted to milliseconds
|
|
107
|
+
* overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
|
|
108
|
+
* not a positive integer.
|
|
106
109
|
*/
|
|
107
110
|
constructor(options) {
|
|
108
111
|
this.Scope =
|
|
109
112
|
options.Scope ?? BasicScope;
|
|
110
113
|
this.realm = options.realm ?? "Service";
|
|
111
114
|
this.clockSkewSeconds = options.clockSkewSeconds ?? 0;
|
|
115
|
+
this.#maxTokenBodyBytes =
|
|
116
|
+
options.maxTokenBodyBytes ?? DEFAULT_MAX_TOKEN_BODY_BYTES;
|
|
117
|
+
assertMaxBodyBytes(this.#maxTokenBodyBytes, "maxTokenBodyBytes");
|
|
112
118
|
this.errorFormat = options.errorFormat ?? "oauth2";
|
|
113
119
|
this.throwOnError = options.throwOnError ?? false;
|
|
114
120
|
this.resolveContext = options.resolve;
|
|
@@ -252,8 +258,11 @@ export class ResourceServer {
|
|
|
252
258
|
}
|
|
253
259
|
/**
|
|
254
260
|
* Extracts an access token from a request.
|
|
255
|
-
* Looks in the Authorization header (Bearer token) first,
|
|
256
|
-
*
|
|
261
|
+
* Looks in the Authorization header (Bearer token) first, then in the body
|
|
262
|
+
* of a form-encoded `POST` (RFC 6750 §2.2), read from a clone so the
|
|
263
|
+
* original body stays readable. A body over
|
|
264
|
+
* {@linkcode ResourceServerOptions.maxTokenBodyBytes} is not searched, and
|
|
265
|
+
* no more of it is read than the cap.
|
|
257
266
|
*/
|
|
258
267
|
async getAccessToken(request) {
|
|
259
268
|
const authorization = request.headers.get("authorization");
|
|
@@ -266,13 +275,18 @@ export class ResourceServer {
|
|
|
266
275
|
if (request.method === "POST" &&
|
|
267
276
|
contentType?.includes("application/x-www-form-urlencoded")) {
|
|
268
277
|
try {
|
|
269
|
-
const
|
|
278
|
+
const bytes = await peekBoundedBody(request, this.#maxTokenBodyBytes);
|
|
279
|
+
if (!bytes)
|
|
280
|
+
return null;
|
|
281
|
+
const body = await new Response(bytes, {
|
|
282
|
+
headers: { "content-type": contentType },
|
|
283
|
+
}).formData();
|
|
270
284
|
const accessToken = body.get("access_token");
|
|
271
285
|
if (typeof accessToken === "string")
|
|
272
286
|
return accessToken;
|
|
273
287
|
}
|
|
274
288
|
catch {
|
|
275
|
-
|
|
289
|
+
return null;
|
|
276
290
|
}
|
|
277
291
|
}
|
|
278
292
|
return null;
|
package/dist/utils/_body.d.ts
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* the 8–16 KiB header limits common to servers and proxies.
|
|
12
12
|
*/
|
|
13
13
|
export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
14
|
+
/**
|
|
15
|
+
* The default cap on a form body searched for an RFC 6750 §2.2
|
|
16
|
+
* `access_token`: 16 KiB, Node's default limit on a request's whole header
|
|
17
|
+
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
|
+
*/
|
|
19
|
+
export declare const DEFAULT_MAX_TOKEN_BODY_BYTES: number;
|
|
14
20
|
/**
|
|
15
21
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
16
22
|
* safe integer.
|
|
@@ -27,3 +33,12 @@ export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string)
|
|
|
27
33
|
export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
|
|
28
34
|
keepReadable?: boolean;
|
|
29
35
|
}): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Reads at most `maxBytes` of `request`'s body from a clone and returns
|
|
38
|
+
* `null` once it exceeds `maxBytes`, cancelling only the clone. The original
|
|
39
|
+
* stays readable either way, holding no more than what the clone pulled, so
|
|
40
|
+
* the caller's own handling of the body — or of its absence — is unchanged.
|
|
41
|
+
* A `Content-Length` over the cap returns `null` without reading. Rejects if
|
|
42
|
+
* the body was already read.
|
|
43
|
+
*/
|
|
44
|
+
export declare function peekBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer> | null>;
|
package/dist/utils/_body.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* the 8–16 KiB header limits common to servers and proxies.
|
|
12
12
|
*/
|
|
13
13
|
export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
14
|
+
/**
|
|
15
|
+
* The default cap on a form body searched for an RFC 6750 §2.2
|
|
16
|
+
* `access_token`: 16 KiB, Node's default limit on a request's whole header
|
|
17
|
+
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_MAX_TOKEN_BODY_BYTES = 16 * 1024;
|
|
14
20
|
/**
|
|
15
21
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
16
22
|
* safe integer.
|
|
@@ -41,6 +47,21 @@ export async function readBoundedBody(request, maxBytes, options = {}) {
|
|
|
41
47
|
request.body.cancel().catch(() => { });
|
|
42
48
|
return bytes;
|
|
43
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Reads at most `maxBytes` of `request`'s body from a clone and returns
|
|
52
|
+
* `null` once it exceeds `maxBytes`, cancelling only the clone. The original
|
|
53
|
+
* stays readable either way, holding no more than what the clone pulled, so
|
|
54
|
+
* the caller's own handling of the body — or of its absence — is unchanged.
|
|
55
|
+
* A `Content-Length` over the cap returns `null` without reading. Rejects if
|
|
56
|
+
* the body was already read.
|
|
57
|
+
*/
|
|
58
|
+
export async function peekBoundedBody(request, maxBytes) {
|
|
59
|
+
if (!request.body)
|
|
60
|
+
return new Uint8Array();
|
|
61
|
+
if (Number(request.headers.get("content-length")) > maxBytes)
|
|
62
|
+
return null;
|
|
63
|
+
return await readUpTo(request.clone().body, maxBytes);
|
|
64
|
+
}
|
|
44
65
|
async function readUpTo(body, maxBytes) {
|
|
45
66
|
const reader = body.getReader();
|
|
46
67
|
const chunks = [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
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",
|