@better-auth/core 1.6.15 → 1.6.17
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/api/index.d.mts +3 -0
- package/dist/api/index.mjs +36 -0
- package/dist/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +82 -0
- package/dist/db/adapter/index.d.mts +51 -1
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/type.d.mts +15 -0
- package/dist/env/env-impl.mjs +1 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/verify.d.mts +29 -6
- package/dist/oauth2/verify.mjs +112 -12
- package/dist/social-providers/facebook.mjs +35 -2
- package/dist/social-providers/google.d.mts +6 -1
- package/dist/social-providers/google.mjs +5 -0
- package/dist/social-providers/index.d.mts +2 -2
- package/dist/social-providers/index.mjs +2 -2
- package/dist/social-providers/microsoft-entra-id.d.mts +1 -1
- package/dist/social-providers/microsoft-entra-id.mjs +13 -1
- package/dist/social-providers/paypal.d.mts +2 -1
- package/dist/social-providers/paypal.mjs +38 -4
- package/dist/social-providers/reddit.mjs +4 -3
- package/dist/social-providers/wechat.mjs +1 -1
- package/dist/types/context.d.mts +16 -0
- package/dist/types/init-options.d.mts +29 -0
- package/dist/utils/host.mjs +4 -0
- package/dist/utils/url.mjs +4 -3
- package/package.json +5 -5
- package/src/api/index.ts +45 -0
- package/src/db/adapter/factory.ts +152 -0
- package/src/db/adapter/index.ts +51 -0
- package/src/db/adapter/types.ts +1 -0
- package/src/db/type.ts +15 -0
- package/src/env/env-impl.ts +1 -2
- package/src/oauth2/verify.ts +211 -41
- package/src/social-providers/facebook.ts +75 -2
- package/src/social-providers/google.ts +27 -1
- package/src/social-providers/microsoft-entra-id.ts +40 -1
- package/src/social-providers/paypal.ts +91 -4
- package/src/social-providers/reddit.ts +7 -3
- package/src/social-providers/wechat.ts +8 -1
- package/src/types/context.ts +17 -0
- package/src/types/init-options.ts +26 -0
- package/src/utils/host.ts +15 -0
- package/src/utils/url.ts +10 -4
package/dist/api/index.d.mts
CHANGED
|
@@ -272,6 +272,9 @@ declare const createAuthMiddleware: {
|
|
|
272
272
|
type EndpointHandler<Path extends string, Options extends EndpointOptions, R> = (context: EndpointContext<Path, Options, AuthContext>) => Promise<R>;
|
|
273
273
|
declare function createAuthEndpoint<Path extends string, Options extends EndpointOptions, R>(path: Path, options: Options, handler: EndpointHandler<Path, Options, R>): StrictEndpoint<Path, Options, R>;
|
|
274
274
|
declare function createAuthEndpoint<Path extends string, Options extends EndpointOptions, R>(options: Options, handler: EndpointHandler<Path, Options, R>): StrictEndpoint<Path, Options, R>;
|
|
275
|
+
declare namespace createAuthEndpoint {
|
|
276
|
+
var serverOnly: <Path extends string, Options extends EndpointOptions, R>(options: Options, handler: EndpointHandler<Path, Options, R>) => StrictEndpoint<Path, Options, R>;
|
|
277
|
+
}
|
|
275
278
|
type AuthEndpoint<Path extends string, Opts extends EndpointOptions, R> = ReturnType<typeof createAuthEndpoint<Path, Opts, R>>;
|
|
276
279
|
type AuthMiddleware = ReturnType<typeof createAuthMiddleware>;
|
|
277
280
|
//#endregion
|
package/dist/api/index.mjs
CHANGED
|
@@ -52,5 +52,41 @@ function createAuthEndpoint(pathOrOptions, handlerOrOptions, handlerOrNever) {
|
|
|
52
52
|
use: [...options?.use || [], ...use]
|
|
53
53
|
}, wrapped);
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Set `metadata.SERVER_ONLY` while preserving any existing metadata
|
|
57
|
+
* (`$Infer`, `openapi`, ...).
|
|
58
|
+
*/
|
|
59
|
+
function withServerOnly(options) {
|
|
60
|
+
return {
|
|
61
|
+
...options,
|
|
62
|
+
metadata: {
|
|
63
|
+
...options.metadata,
|
|
64
|
+
SERVER_ONLY: true
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Declare a **server-only** endpoint.
|
|
70
|
+
*
|
|
71
|
+
* The endpoint is callable through `auth.api.*` from trusted server code but is
|
|
72
|
+
* never registered on the HTTP router and never emitted into the OpenAPI
|
|
73
|
+
* schema. It takes no path because it has no URL to be reached at.
|
|
74
|
+
*
|
|
75
|
+
* Prefer this over the path-less `createAuthEndpoint({ ... }, handler)` form.
|
|
76
|
+
* Setting `metadata.SERVER_ONLY` makes the intent explicit at the call site and
|
|
77
|
+
* keeps the endpoint off the HTTP surface even if a path is later added by
|
|
78
|
+
* mistake: better-call's router skips an endpoint when its path is missing *or*
|
|
79
|
+
* when `SERVER_ONLY` is set, so the two together are defense in depth. Relying
|
|
80
|
+
* on path omission alone is invisible and one keystroke away from exposure.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* viewBackupCodes: createAuthEndpoint.serverOnly(
|
|
85
|
+
* { method: "POST", body: schema },
|
|
86
|
+
* async (ctx) => { ... },
|
|
87
|
+
* )
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
createAuthEndpoint.serverOnly = (options, handler) => createAuthEndpoint(withServerOnly(options), handler);
|
|
55
91
|
//#endregion
|
|
56
92
|
export { createAuthEndpoint, createAuthMiddleware, optionsMiddleware };
|
package/dist/context/global.mjs
CHANGED
|
@@ -58,6 +58,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
58
58
|
else if (method === "delete" && !config.debugLogs.delete) return;
|
|
59
59
|
else if (method === "deleteMany" && !config.debugLogs.deleteMany) return;
|
|
60
60
|
else if (method === "consumeOne" && !config.debugLogs.consumeOne) return;
|
|
61
|
+
else if (method === "incrementOne" && !config.debugLogs.incrementOne) return;
|
|
61
62
|
else if (method === "count" && !config.debugLogs.count) return;
|
|
62
63
|
}
|
|
63
64
|
logger.info(`[${config.adapterName}]`, ...args);
|
|
@@ -738,6 +739,87 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
738
739
|
});
|
|
739
740
|
return transformed;
|
|
740
741
|
},
|
|
742
|
+
incrementOne: async ({ model: unsafeModel, where: unsafeWhere, increment: unsafeIncrement, set: unsafeSet }) => {
|
|
743
|
+
transactionId++;
|
|
744
|
+
const thisTransactionId = transactionId;
|
|
745
|
+
const model = getModelName(unsafeModel);
|
|
746
|
+
const where = transformWhereClause({
|
|
747
|
+
model: unsafeModel,
|
|
748
|
+
where: unsafeWhere,
|
|
749
|
+
action: "incrementOne"
|
|
750
|
+
});
|
|
751
|
+
unsafeModel = getDefaultModelName(unsafeModel);
|
|
752
|
+
debugLog({ method: "incrementOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 3)}`, `${formatMethod("incrementOne")} ${formatAction("IncrementOne")}:`, {
|
|
753
|
+
model,
|
|
754
|
+
where,
|
|
755
|
+
increment: unsafeIncrement,
|
|
756
|
+
set: unsafeSet
|
|
757
|
+
});
|
|
758
|
+
let res;
|
|
759
|
+
let resultNeedsOutputTransform = true;
|
|
760
|
+
if (adapterInstance.incrementOne) {
|
|
761
|
+
const mappedKeys = config.mapKeysTransformInput ?? {};
|
|
762
|
+
const increment = {};
|
|
763
|
+
for (const [field, delta] of Object.entries(unsafeIncrement)) increment[mappedKeys[field] || getFieldName({
|
|
764
|
+
model: unsafeModel,
|
|
765
|
+
field
|
|
766
|
+
})] = delta;
|
|
767
|
+
let set;
|
|
768
|
+
if (unsafeSet && !config.disableTransformInput) set = await transformInput(unsafeSet, unsafeModel, "update");
|
|
769
|
+
else set = unsafeSet;
|
|
770
|
+
res = await withSpan(`db incrementOne ${model}`, {
|
|
771
|
+
[ATTR_DB_OPERATION_NAME]: "incrementOne",
|
|
772
|
+
[ATTR_DB_COLLECTION_NAME]: model
|
|
773
|
+
}, () => adapterInstance.incrementOne({
|
|
774
|
+
model,
|
|
775
|
+
where,
|
|
776
|
+
increment,
|
|
777
|
+
set
|
|
778
|
+
}));
|
|
779
|
+
} else {
|
|
780
|
+
res = await withSpan(`db incrementOne ${model}`, {
|
|
781
|
+
[ATTR_DB_OPERATION_NAME]: "incrementOne",
|
|
782
|
+
[ATTR_DB_COLLECTION_NAME]: model
|
|
783
|
+
}, () => adapter.transaction(async (trx) => {
|
|
784
|
+
const target = (await trx.findMany({
|
|
785
|
+
model: unsafeModel,
|
|
786
|
+
where: unsafeWhere,
|
|
787
|
+
limit: 1
|
|
788
|
+
}))[0];
|
|
789
|
+
if (!target) return null;
|
|
790
|
+
const nextValues = { ...unsafeSet ?? {} };
|
|
791
|
+
for (const [field, delta] of Object.entries(unsafeIncrement)) nextValues[field] = (typeof target[field] === "number" ? target[field] : 0) + delta;
|
|
792
|
+
const updated = await trx.updateMany({
|
|
793
|
+
model: unsafeModel,
|
|
794
|
+
where: [...unsafeWhere, {
|
|
795
|
+
field: "id",
|
|
796
|
+
value: target.id,
|
|
797
|
+
operator: "eq",
|
|
798
|
+
connector: "AND",
|
|
799
|
+
mode: "sensitive"
|
|
800
|
+
}],
|
|
801
|
+
update: nextValues
|
|
802
|
+
});
|
|
803
|
+
if (typeof updated !== "number") throw new BetterAuthError(`Adapter "${config.adapterId}" returned a non-numeric value from updateMany during the incrementOne fallback. Return the number of updated rows, or implement a native incrementOne for atomic guarded counter updates.`);
|
|
804
|
+
return updated > 0 ? {
|
|
805
|
+
...target,
|
|
806
|
+
...nextValues
|
|
807
|
+
} : null;
|
|
808
|
+
}));
|
|
809
|
+
resultNeedsOutputTransform = false;
|
|
810
|
+
}
|
|
811
|
+
debugLog({ method: "incrementOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(2, 3)}`, `${formatMethod("incrementOne")} ${formatAction("DB Result")}:`, {
|
|
812
|
+
model,
|
|
813
|
+
data: res
|
|
814
|
+
});
|
|
815
|
+
let transformed = res;
|
|
816
|
+
if (!config.disableTransformOutput && resultNeedsOutputTransform && res) transformed = await transformOutput(res, unsafeModel, void 0, void 0);
|
|
817
|
+
debugLog({ method: "incrementOne" }, `${formatTransactionId(thisTransactionId)} ${formatStep(3, 3)}`, `${formatMethod("incrementOne")} ${formatAction("Parsed Result")}:`, {
|
|
818
|
+
model,
|
|
819
|
+
data: transformed
|
|
820
|
+
});
|
|
821
|
+
return transformed;
|
|
822
|
+
},
|
|
741
823
|
count: async ({ model: unsafeModel, where: unsafeWhere }) => {
|
|
742
824
|
transactionId++;
|
|
743
825
|
const thisTransactionId = transactionId;
|
|
@@ -23,6 +23,7 @@ type DBAdapterDebugLogOption = boolean | {
|
|
|
23
23
|
delete?: boolean | undefined;
|
|
24
24
|
deleteMany?: boolean | undefined;
|
|
25
25
|
consumeOne?: boolean | undefined;
|
|
26
|
+
incrementOne?: boolean | undefined;
|
|
26
27
|
count?: boolean | undefined;
|
|
27
28
|
} | {
|
|
28
29
|
/**
|
|
@@ -198,7 +199,7 @@ interface DBAdapterFactoryConfig<Options extends BetterAuthOptions = BetterAuthO
|
|
|
198
199
|
/**
|
|
199
200
|
* The action which was called from the adapter.
|
|
200
201
|
*/
|
|
201
|
-
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
|
|
202
|
+
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "incrementOne" | "count";
|
|
202
203
|
/**
|
|
203
204
|
* The model name.
|
|
204
205
|
*/
|
|
@@ -436,6 +437,36 @@ type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
436
437
|
model: string;
|
|
437
438
|
where: Where[];
|
|
438
439
|
}) => Promise<T | null>;
|
|
440
|
+
/**
|
|
441
|
+
* Atomically apply signed numeric deltas to a single row matching the where
|
|
442
|
+
* clause. For each entry in `increment`, the operation applies
|
|
443
|
+
* `field = field + delta` in one atomic step; a negative delta decrements.
|
|
444
|
+
*
|
|
445
|
+
* The `where` clause is both the selector AND the guard: comparison
|
|
446
|
+
* operators are honored, so passing `{ field: "remaining", operator: "gt",
|
|
447
|
+
* value: 0 }` only mutates the row while `remaining` is still above zero.
|
|
448
|
+
* When the guard matches no row, the operation makes no change and returns
|
|
449
|
+
* `null`.
|
|
450
|
+
*
|
|
451
|
+
* The optional `set` map assigns absolute values to fields in the same
|
|
452
|
+
* atomic operation, alongside the increments.
|
|
453
|
+
*
|
|
454
|
+
* Returns the updated row, or `null` when the guard matched no row. Under
|
|
455
|
+
* concurrent invocation against the same row, this is the race-safe
|
|
456
|
+
* primitive for guarded counter updates (e.g. decrementing a remaining-uses
|
|
457
|
+
* counter only while it is still positive).
|
|
458
|
+
*
|
|
459
|
+
* Always defined on the factory-wrapped adapter. When the underlying
|
|
460
|
+
* `CustomAdapter` does not implement `incrementOne`, the factory provides a
|
|
461
|
+
* fallback that wraps `findMany + updateMany` in `transaction(...)` and
|
|
462
|
+
* re-applies the where clause as a compare-and-swap guard on the update.
|
|
463
|
+
*/
|
|
464
|
+
incrementOne: <T>(data: {
|
|
465
|
+
model: string;
|
|
466
|
+
where: Where[];
|
|
467
|
+
increment: Record<string, number>;
|
|
468
|
+
set?: Record<string, unknown> | undefined;
|
|
469
|
+
}) => Promise<T | null>;
|
|
439
470
|
/**
|
|
440
471
|
* Execute multiple operations in a transaction.
|
|
441
472
|
* If the adapter doesn't support transactions, operations will be executed sequentially.
|
|
@@ -530,6 +561,25 @@ interface CustomAdapter {
|
|
|
530
561
|
model: string;
|
|
531
562
|
where: CleanedWhere[];
|
|
532
563
|
}) => Promise<T | null>;
|
|
564
|
+
/**
|
|
565
|
+
* Optional native atomic guarded counter mutation. Applies
|
|
566
|
+
* `field = field + delta` for each entry in `increment` (negative deltas
|
|
567
|
+
* decrement), with `where` acting as both selector and guard and `set`
|
|
568
|
+
* assigning absolute values in the same operation. Returns the updated row,
|
|
569
|
+
* or `null` when the guard matched no row.
|
|
570
|
+
*
|
|
571
|
+
* Implementing this natively (e.g. `UPDATE ... SET n = n + $delta WHERE ...
|
|
572
|
+
* RETURNING *`) gives one round trip and the strongest race-safety
|
|
573
|
+
* guarantee. When omitted, the adapter factory provides a transaction-based
|
|
574
|
+
* fallback over `findMany + updateMany`. TODO(increment-one-required):
|
|
575
|
+
* tighten to required in the next minor on `next`.
|
|
576
|
+
*/
|
|
577
|
+
incrementOne?: <T>(data: {
|
|
578
|
+
model: string;
|
|
579
|
+
where: CleanedWhere[];
|
|
580
|
+
increment: Record<string, number>;
|
|
581
|
+
set?: Record<string, unknown> | undefined;
|
|
582
|
+
}) => Promise<T | null>;
|
|
533
583
|
count: ({
|
|
534
584
|
model,
|
|
535
585
|
where
|
|
@@ -94,7 +94,7 @@ type AdapterFactoryCustomizeAdapterCreator = (config: {
|
|
|
94
94
|
}: {
|
|
95
95
|
where: W;
|
|
96
96
|
model: string;
|
|
97
|
-
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "count";
|
|
97
|
+
action: "create" | "update" | "findOne" | "findMany" | "updateMany" | "delete" | "deleteMany" | "consumeOne" | "incrementOne" | "count";
|
|
98
98
|
}) => W extends undefined ? undefined : CleanedWhere[];
|
|
99
99
|
}) => CustomAdapter;
|
|
100
100
|
type AdapterTestDebugLogs = {
|
package/dist/db/type.d.mts
CHANGED
|
@@ -153,6 +153,21 @@ interface SecondaryStorage {
|
|
|
153
153
|
* security-sensitive consume paths.
|
|
154
154
|
*/
|
|
155
155
|
getAndDelete?: (key: string) => Awaitable<unknown>;
|
|
156
|
+
/**
|
|
157
|
+
* Atomically increment the counter at `key` by one, returning the
|
|
158
|
+
* post-increment value.
|
|
159
|
+
*
|
|
160
|
+
* When the key is absent, it is created with a value of `1` and the given
|
|
161
|
+
* `ttl` (in SECONDS). The TTL is applied only on creation; later increments
|
|
162
|
+
* never extend it, so the counter expires a fixed window after it was first
|
|
163
|
+
* created.
|
|
164
|
+
*
|
|
165
|
+
* This is optional for backwards compatibility with existing secondary
|
|
166
|
+
* storage implementations. TODO(secondary-storage-increment-required): make
|
|
167
|
+
* this required for secondary-storage-backed rate limiting in the next minor
|
|
168
|
+
* on `next`.
|
|
169
|
+
*/
|
|
170
|
+
increment?: (key: string, ttl: number) => Awaitable<number>;
|
|
156
171
|
set: (
|
|
157
172
|
/**
|
|
158
173
|
* Key to store
|
package/dist/env/env-impl.mjs
CHANGED
|
@@ -27,7 +27,7 @@ const env = new Proxy(_envShim, {
|
|
|
27
27
|
function toBoolean(val) {
|
|
28
28
|
return val ? val !== "false" : false;
|
|
29
29
|
}
|
|
30
|
-
const nodeENV =
|
|
30
|
+
const nodeENV = env.NODE_ENV ?? "";
|
|
31
31
|
/** Detect if `NODE_ENV` environment variable is `production` */
|
|
32
32
|
const isProduction = nodeENV === "production";
|
|
33
33
|
/** Detect if `NODE_ENV` environment variable is `dev` or `development` */
|
|
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
|
|
|
2
2
|
import { getOpenTelemetryAPI } from "./api.mjs";
|
|
3
3
|
//#region src/instrumentation/tracer.ts
|
|
4
4
|
const INSTRUMENTATION_SCOPE = "better-auth";
|
|
5
|
-
const INSTRUMENTATION_VERSION = "1.6.
|
|
5
|
+
const INSTRUMENTATION_VERSION = "1.6.17";
|
|
6
6
|
/**
|
|
7
7
|
* Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
|
|
8
8
|
* callbacks). These are APIErrors with 3xx status codes and should not be
|
package/dist/oauth2/verify.d.mts
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { JSONWebKeySet, JWTPayload, JWTVerifyOptions } from "jose";
|
|
2
2
|
|
|
3
3
|
//#region src/oauth2/verify.d.ts
|
|
4
|
+
type JwksFetchOptions = {
|
|
5
|
+
/** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
6
|
+
/**
|
|
7
|
+
* Stable object to cache the result of a function `jwksFetch` under,
|
|
8
|
+
* with the same TTL and kid-miss refetch rules as string sources.
|
|
9
|
+
* Without it, a function source is fetched on every verification.
|
|
10
|
+
*/
|
|
11
|
+
jwksCacheKey?: object;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
4
16
|
interface VerifyAccessTokenRemote {
|
|
5
17
|
/** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
|
|
6
18
|
introspectUrl: string;
|
|
@@ -14,19 +26,30 @@ interface VerifyAccessTokenRemote {
|
|
|
14
26
|
* is also still active.
|
|
15
27
|
*/
|
|
16
28
|
force?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Accept introspection responses that omit the `aud` claim even when a
|
|
31
|
+
* required `audience` is configured in `verifyOptions`.
|
|
32
|
+
*
|
|
33
|
+
* By default verification fails closed: if you configure an `audience` and
|
|
34
|
+
* the introspection response has no `aud` (or a mismatching one), the token
|
|
35
|
+
* is rejected. Some authorization servers legitimately omit `aud` from
|
|
36
|
+
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
|
|
37
|
+
* this if you trust the issuer to bind the token to this resource through
|
|
38
|
+
* another mechanism, as it skips the audience check in that case.
|
|
39
|
+
*
|
|
40
|
+
* @default false
|
|
41
|
+
*/
|
|
42
|
+
allowMissingAudience?: boolean;
|
|
17
43
|
}
|
|
18
44
|
/**
|
|
19
45
|
* Performs local verification of an access token for your APIs.
|
|
20
46
|
*
|
|
21
47
|
* Can also be configured for remote verification.
|
|
22
48
|
*/
|
|
23
|
-
declare function verifyJwsAccessToken(token: string, opts: {
|
|
24
|
-
/**
|
|
25
|
-
verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
49
|
+
declare function verifyJwsAccessToken(token: string, opts: JwksFetchOptions & {
|
|
50
|
+
/** Verify options */verifyOptions: JWTVerifyOptions & Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
26
51
|
}): Promise<JWTPayload>;
|
|
27
|
-
declare function getJwks(token: string, opts:
|
|
28
|
-
/** Jwks url or promise of a Jwks */jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
29
|
-
}): Promise<JSONWebKeySet>;
|
|
52
|
+
declare function getJwks(token: string, opts: JwksFetchOptions): Promise<JSONWebKeySet>;
|
|
30
53
|
/**
|
|
31
54
|
* Performs local verification of an access token for your API.
|
|
32
55
|
*
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -11,8 +11,46 @@ const joseInfrastructureErrorCodes = new Set([
|
|
|
11
11
|
function isJoseInfrastructureError(error) {
|
|
12
12
|
return joseInfrastructureErrorCodes.has(error.code);
|
|
13
13
|
}
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
const jwksCache = /* @__PURE__ */ new Map();
|
|
18
|
+
/**
|
|
19
|
+
* Cache for function jwks sources, keyed by a caller-provided stable object.
|
|
20
|
+
* Entries are released with their key, so per-request keys cannot accumulate.
|
|
21
|
+
*/
|
|
22
|
+
const functionJwksCache = /* @__PURE__ */ new WeakMap();
|
|
23
|
+
/**
|
|
24
|
+
* How long a cached JWKS is trusted before it is refetched
|
|
25
|
+
*
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
const JWKS_CACHE_TTL_MS = 300 * 1e3;
|
|
29
|
+
const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1e3;
|
|
30
|
+
/**
|
|
31
|
+
* Returns the cached key set when it is within the TTL. When the token carries
|
|
32
|
+
* `kid`, the cached set must contain that key id; without `kid`, key selection
|
|
33
|
+
* is deferred to JOSE because RFC 7515 makes the header parameter optional.
|
|
34
|
+
*/
|
|
35
|
+
function getFreshJwksWithKid(cached, kid) {
|
|
36
|
+
if (!cached) return void 0;
|
|
37
|
+
if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return void 0;
|
|
38
|
+
if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) return;
|
|
39
|
+
return cached.jwks;
|
|
40
|
+
}
|
|
41
|
+
function shouldRefetchCachedJwksWithoutKid(error, resolved) {
|
|
42
|
+
if (!(resolved.fromCache && !resolved.kid && (error instanceof errors.JWKSNoMatchingKey || error instanceof errors.JWSSignatureVerificationFailed))) return false;
|
|
43
|
+
if (!resolved.noKidRefetchedAt) return true;
|
|
44
|
+
return Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS;
|
|
45
|
+
}
|
|
46
|
+
async function fetchJwks(jwksFetch) {
|
|
47
|
+
const jwks = typeof jwksFetch === "string" ? await betterFetch(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
|
|
48
|
+
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
|
|
49
|
+
return res.data;
|
|
50
|
+
}) : await jwksFetch();
|
|
51
|
+
if (!jwks) throw new Error("No jwks found");
|
|
52
|
+
return jwks;
|
|
53
|
+
}
|
|
16
54
|
/**
|
|
17
55
|
* Performs local verification of an access token for your APIs.
|
|
18
56
|
*
|
|
@@ -20,7 +58,17 @@ let jwks;
|
|
|
20
58
|
*/
|
|
21
59
|
async function verifyJwsAccessToken(token, opts) {
|
|
22
60
|
try {
|
|
23
|
-
const
|
|
61
|
+
const resolved = await getJwksForVerification(token, opts);
|
|
62
|
+
let jwt;
|
|
63
|
+
try {
|
|
64
|
+
jwt = await jwtVerify(token, createLocalJWKSet(resolved.jwks), opts.verifyOptions);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (shouldRefetchCachedJwksWithoutKid(error, resolved)) jwt = await jwtVerify(token, createLocalJWKSet((await getJwksForVerification(token, {
|
|
67
|
+
...opts,
|
|
68
|
+
forceRefresh: true
|
|
69
|
+
})).jwks), opts.verifyOptions);
|
|
70
|
+
else throw error;
|
|
71
|
+
}
|
|
24
72
|
if (jwt.payload.azp) jwt.payload.client_id = jwt.payload.azp;
|
|
25
73
|
return jwt.payload;
|
|
26
74
|
} catch (error) {
|
|
@@ -29,6 +77,9 @@ async function verifyJwsAccessToken(token, opts) {
|
|
|
29
77
|
}
|
|
30
78
|
}
|
|
31
79
|
async function getJwks(token, opts) {
|
|
80
|
+
return (await getJwksForVerification(token, opts)).jwks;
|
|
81
|
+
}
|
|
82
|
+
async function getJwksForVerification(token, opts) {
|
|
32
83
|
let jwtHeaders;
|
|
33
84
|
try {
|
|
34
85
|
jwtHeaders = decodeProtectedHeader(token);
|
|
@@ -36,15 +87,63 @@ async function getJwks(token, opts) {
|
|
|
36
87
|
if (error instanceof Error) throw error;
|
|
37
88
|
throw new Error(error);
|
|
38
89
|
}
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
90
|
+
const kid = jwtHeaders.kid;
|
|
91
|
+
if (typeof opts.jwksFetch !== "string") {
|
|
92
|
+
const cacheKey = opts.jwksCacheKey;
|
|
93
|
+
if (!cacheKey) {
|
|
94
|
+
const jwks = await opts.jwksFetch();
|
|
95
|
+
if (!jwks) throw new Error("No jwks found");
|
|
96
|
+
return {
|
|
97
|
+
jwks,
|
|
98
|
+
fromCache: false,
|
|
99
|
+
kid
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const cached = functionJwksCache.get(cacheKey);
|
|
103
|
+
const cachedJwks = opts.forceRefresh ? void 0 : getFreshJwksWithKid(cached, kid);
|
|
104
|
+
if (cachedJwks) return {
|
|
105
|
+
jwks: cachedJwks,
|
|
106
|
+
fromCache: true,
|
|
107
|
+
kid,
|
|
108
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt
|
|
109
|
+
};
|
|
110
|
+
const jwks = await opts.jwksFetch();
|
|
45
111
|
if (!jwks) throw new Error("No jwks found");
|
|
112
|
+
const fetchedAt = Date.now();
|
|
113
|
+
functionJwksCache.set(cacheKey, {
|
|
114
|
+
jwks,
|
|
115
|
+
fetchedAt,
|
|
116
|
+
...opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
jwks,
|
|
120
|
+
fromCache: false,
|
|
121
|
+
kid
|
|
122
|
+
};
|
|
46
123
|
}
|
|
47
|
-
|
|
124
|
+
const cacheKey = opts.jwksFetch;
|
|
125
|
+
const cached = jwksCache.get(cacheKey);
|
|
126
|
+
const cachedJwks = opts.forceRefresh ? void 0 : getFreshJwksWithKid(cached, kid);
|
|
127
|
+
if (!cachedJwks) {
|
|
128
|
+
const jwks = await fetchJwks(opts.jwksFetch);
|
|
129
|
+
const fetchedAt = Date.now();
|
|
130
|
+
jwksCache.set(cacheKey, {
|
|
131
|
+
jwks,
|
|
132
|
+
fetchedAt,
|
|
133
|
+
...opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}
|
|
134
|
+
});
|
|
135
|
+
return {
|
|
136
|
+
jwks,
|
|
137
|
+
fromCache: false,
|
|
138
|
+
kid
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
jwks: cachedJwks,
|
|
143
|
+
fromCache: true,
|
|
144
|
+
kid,
|
|
145
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt
|
|
146
|
+
};
|
|
48
147
|
}
|
|
49
148
|
/**
|
|
50
149
|
* Performs local verification of an access token for your API.
|
|
@@ -85,8 +184,9 @@ async function verifyAccessToken(token, opts) {
|
|
|
85
184
|
if (!introspect.active) throw new APIError("UNAUTHORIZED", { message: "token inactive" });
|
|
86
185
|
try {
|
|
87
186
|
const unsecuredJwt = new UnsecuredJWT(introspect).encode();
|
|
88
|
-
const { audience: _audience, ...
|
|
89
|
-
|
|
187
|
+
const { audience: _audience, ...verifyOptionsNoAudience } = opts.verifyOptions;
|
|
188
|
+
const skipAudience = !introspect.aud && opts.remoteVerify.allowMissingAudience === true;
|
|
189
|
+
payload = UnsecuredJWT.decode(unsecuredJwt, skipAudience ? verifyOptionsNoAudience : opts.verifyOptions).payload;
|
|
90
190
|
} catch (error) {
|
|
91
191
|
throw new Error(error);
|
|
92
192
|
}
|
|
@@ -7,6 +7,34 @@ import { validateAuthorizationCode } from "../oauth2/validate-authorization-code
|
|
|
7
7
|
import { betterFetch } from "@better-fetch/fetch";
|
|
8
8
|
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
|
|
9
9
|
//#region src/social-providers/facebook.ts
|
|
10
|
+
/**
|
|
11
|
+
* Validate an opaque Facebook access token against the configured app.
|
|
12
|
+
*
|
|
13
|
+
* Facebook access tokens are not audience-bound at the Graph `/me` endpoint: a
|
|
14
|
+
* token minted for any Facebook app returns that app's profile. Without this
|
|
15
|
+
* check, a token issued to an unrelated app could be presented to this
|
|
16
|
+
* app's direct sign-in path and accepted as proof of identity. We call the
|
|
17
|
+
* `debug_token` endpoint and require the token to be valid, bound to one of the
|
|
18
|
+
* configured client ids, and tied to a user.
|
|
19
|
+
*
|
|
20
|
+
* @see https://developers.facebook.com/docs/facebook-login/guides/access-tokens/debugging
|
|
21
|
+
*
|
|
22
|
+
* @returns the inspected token's `user_id` when the token is valid and bound to
|
|
23
|
+
* the configured app, otherwise `null`.
|
|
24
|
+
*/
|
|
25
|
+
async function verifyFacebookAccessToken(accessToken, options) {
|
|
26
|
+
const primaryClientId = getPrimaryClientId(options.clientId);
|
|
27
|
+
if (!primaryClientId || !options.clientSecret) return null;
|
|
28
|
+
const clientIds = Array.isArray(options.clientId) ? options.clientId : [options.clientId];
|
|
29
|
+
const { data, error } = await betterFetch("https://graph.facebook.com/debug_token", { query: {
|
|
30
|
+
input_token: accessToken,
|
|
31
|
+
access_token: `${primaryClientId}|${options.clientSecret}`
|
|
32
|
+
} });
|
|
33
|
+
if (error || !data?.data) return null;
|
|
34
|
+
const { is_valid, app_id, user_id } = data.data;
|
|
35
|
+
if (is_valid !== true || !app_id || !clientIds.includes(app_id) || !user_id) return null;
|
|
36
|
+
return user_id;
|
|
37
|
+
}
|
|
10
38
|
const facebook = (options) => {
|
|
11
39
|
return {
|
|
12
40
|
id: "facebook",
|
|
@@ -52,7 +80,7 @@ const facebook = (options) => {
|
|
|
52
80
|
} catch {
|
|
53
81
|
return false;
|
|
54
82
|
}
|
|
55
|
-
return
|
|
83
|
+
return await verifyFacebookAccessToken(token, options) !== null;
|
|
56
84
|
},
|
|
57
85
|
refreshAccessToken: options.refreshAccessToken ? options.refreshAccessToken : async (refreshToken) => {
|
|
58
86
|
return refreshAccessToken({
|
|
@@ -93,6 +121,10 @@ const facebook = (options) => {
|
|
|
93
121
|
data: profile
|
|
94
122
|
};
|
|
95
123
|
}
|
|
124
|
+
const accessToken = token.accessToken;
|
|
125
|
+
if (!accessToken) return null;
|
|
126
|
+
const tokenUserId = await verifyFacebookAccessToken(accessToken, options);
|
|
127
|
+
if (!tokenUserId) return null;
|
|
96
128
|
const { data: profile, error } = await betterFetch("https://graph.facebook.com/me?fields=" + [
|
|
97
129
|
"id",
|
|
98
130
|
"name",
|
|
@@ -101,9 +133,10 @@ const facebook = (options) => {
|
|
|
101
133
|
...options?.fields || []
|
|
102
134
|
].join(","), { auth: {
|
|
103
135
|
type: "Bearer",
|
|
104
|
-
token:
|
|
136
|
+
token: accessToken
|
|
105
137
|
} });
|
|
106
138
|
if (error) return null;
|
|
139
|
+
if (profile.id !== tokenUserId) return null;
|
|
107
140
|
const userMap = await options.mapProfileToUser?.(profile);
|
|
108
141
|
return {
|
|
109
142
|
user: {
|
|
@@ -37,7 +37,12 @@ interface GoogleOptions extends ProviderOptions<GoogleProfile> {
|
|
|
37
37
|
*/
|
|
38
38
|
display?: ("page" | "popup" | "touch" | "wap") | undefined;
|
|
39
39
|
/**
|
|
40
|
-
* The hosted domain
|
|
40
|
+
* The hosted domain (Google Workspace) the user must belong to.
|
|
41
|
+
*
|
|
42
|
+
* This is sent to Google as the `hd` authorization hint and, when set, is
|
|
43
|
+
* also enforced against the `hd` claim of the returned id token/profile.
|
|
44
|
+
* Sign-in is rejected when the claim is missing or does not match, so this
|
|
45
|
+
* can be used to restrict sign-in to a Workspace domain.
|
|
41
46
|
*/
|
|
42
47
|
hd?: string | undefined;
|
|
43
48
|
}
|
|
@@ -73,6 +73,7 @@ const google = (options) => {
|
|
|
73
73
|
maxTokenAge: "1h"
|
|
74
74
|
});
|
|
75
75
|
if (nonce && jwtClaims.nonce !== nonce) return false;
|
|
76
|
+
if (options.hd && jwtClaims.hd !== options.hd) return false;
|
|
76
77
|
return true;
|
|
77
78
|
} catch {
|
|
78
79
|
return false;
|
|
@@ -82,6 +83,10 @@ const google = (options) => {
|
|
|
82
83
|
if (options.getUserInfo) return options.getUserInfo(token);
|
|
83
84
|
if (!token.idToken) return null;
|
|
84
85
|
const user = decodeJwt(token.idToken);
|
|
86
|
+
if (options.hd && user.hd !== options.hd) {
|
|
87
|
+
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not match the configured "hd" option "${options.hd}".`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
85
90
|
const userMap = await options.mapProfileToUser?.(user);
|
|
86
91
|
return {
|
|
87
92
|
user: {
|
|
@@ -28,7 +28,7 @@ import { KakaoOptions, KakaoProfile, kakao } from "./kakao.mjs";
|
|
|
28
28
|
import { NaverOptions, NaverProfile, naver } from "./naver.mjs";
|
|
29
29
|
import { LineIdTokenPayload, LineOptions, LineUserInfo, line } from "./line.mjs";
|
|
30
30
|
import { PaybinOptions, PaybinProfile, paybin } from "./paybin.mjs";
|
|
31
|
-
import { PayPalOptions, PayPalProfile, PayPalTokenResponse, paypal } from "./paypal.mjs";
|
|
31
|
+
import { PayPalOptions, PayPalProfile, PayPalTokenResponse, getPayPalPublicKey, paypal } from "./paypal.mjs";
|
|
32
32
|
import { PolarOptions, PolarProfile, polar } from "./polar.mjs";
|
|
33
33
|
import { RailwayOptions, RailwayProfile, railway } from "./railway.mjs";
|
|
34
34
|
import { VercelOptions, VercelProfile, vercel } from "./vercel.mjs";
|
|
@@ -1831,4 +1831,4 @@ type SocialProviders = { [K in SocialProviderList[number]]?: AwaitableFunction<P
|
|
|
1831
1831
|
}> };
|
|
1832
1832
|
type SocialProviderList = typeof socialProviderList;
|
|
1833
1833
|
//#endregion
|
|
1834
|
-
export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
1834
|
+
export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
@@ -18,7 +18,7 @@ import { getMicrosoftPublicKey, microsoft } from "./microsoft-entra-id.mjs";
|
|
|
18
18
|
import { naver } from "./naver.mjs";
|
|
19
19
|
import { notion } from "./notion.mjs";
|
|
20
20
|
import { paybin } from "./paybin.mjs";
|
|
21
|
-
import { paypal } from "./paypal.mjs";
|
|
21
|
+
import { getPayPalPublicKey, paypal } from "./paypal.mjs";
|
|
22
22
|
import { polar } from "./polar.mjs";
|
|
23
23
|
import { railway } from "./railway.mjs";
|
|
24
24
|
import { reddit } from "./reddit.mjs";
|
|
@@ -75,4 +75,4 @@ const socialProviders = {
|
|
|
75
75
|
const socialProviderList = Object.keys(socialProviders);
|
|
76
76
|
const SocialProviderListEnum = z.enum(socialProviderList).or(z.string());
|
|
77
77
|
//#endregion
|
|
78
|
-
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
78
|
+
export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
|
|
@@ -151,7 +151,7 @@ declare const microsoft: (options: MicrosoftOptions) => {
|
|
|
151
151
|
user?: {
|
|
152
152
|
name?: {
|
|
153
153
|
firstName?: string;
|
|
154
|
-
lastName
|
|
154
|
+
lastName?: string;
|
|
155
155
|
};
|
|
156
156
|
email?: string;
|
|
157
157
|
} | undefined;
|