@better-auth/core 1.7.0-beta.0 → 1.7.0-beta.10
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 +44 -1
- package/dist/api/index.mjs +69 -4
- package/dist/context/global.mjs +1 -1
- package/dist/context/transaction.d.mts +7 -4
- package/dist/context/transaction.mjs +6 -3
- package/dist/db/adapter/factory.mjs +91 -3
- package/dist/db/adapter/get-id-field.mjs +1 -1
- package/dist/db/adapter/index.d.mts +87 -3
- package/dist/db/adapter/types.d.mts +1 -1
- package/dist/db/get-tables.mjs +2 -1
- package/dist/db/type.d.mts +17 -0
- package/dist/env/env-impl.mjs +1 -1
- package/dist/error/codes.d.mts +1 -0
- package/dist/error/codes.mjs +1 -0
- package/dist/error/index.d.mts +7 -0
- package/dist/index.d.mts +2 -2
- package/dist/instrumentation/api.mjs +12 -0
- package/dist/instrumentation/noop.mjs +42 -0
- package/dist/instrumentation/pure.index.d.mts +7 -0
- package/dist/instrumentation/pure.index.mjs +7 -0
- package/dist/instrumentation/tracer.mjs +6 -3
- package/dist/oauth2/authorization-params.d.mts +12 -0
- package/dist/oauth2/authorization-params.mjs +12 -0
- package/dist/oauth2/basic-credentials.d.mts +30 -0
- package/dist/oauth2/basic-credentials.mjs +64 -0
- package/dist/oauth2/client-assertion.d.mts +38 -22
- package/dist/oauth2/client-assertion.mjs +63 -28
- package/dist/oauth2/client-credentials-token.d.mts +19 -40
- package/dist/oauth2/client-credentials-token.mjs +20 -31
- package/dist/oauth2/create-authorization-url.d.mts +11 -1
- package/dist/oauth2/create-authorization-url.mjs +27 -7
- package/dist/oauth2/dpop.d.mts +142 -0
- package/dist/oauth2/dpop.mjs +246 -0
- package/dist/oauth2/index.d.mts +14 -9
- package/dist/oauth2/index.mjs +12 -8
- package/dist/oauth2/oauth-provider.d.mts +150 -10
- package/dist/oauth2/refresh-access-token.d.mts +20 -40
- package/dist/oauth2/refresh-access-token.mjs +38 -36
- package/dist/oauth2/reject-redirects.mjs +65 -0
- package/dist/oauth2/token-endpoint-auth.d.mts +17 -0
- package/dist/oauth2/token-endpoint-auth.mjs +89 -0
- package/dist/oauth2/utils.d.mts +23 -1
- package/dist/oauth2/utils.mjs +48 -2
- package/dist/oauth2/validate-authorization-code.d.mts +17 -52
- package/dist/oauth2/validate-authorization-code.mjs +28 -34
- package/dist/oauth2/verify-id-token.d.mts +27 -0
- package/dist/oauth2/verify-id-token.mjs +62 -0
- package/dist/oauth2/verify.d.mts +88 -15
- package/dist/oauth2/verify.mjs +203 -25
- package/dist/social-providers/apple.d.mts +16 -4
- package/dist/social-providers/apple.mjs +17 -24
- package/dist/social-providers/atlassian.d.mts +5 -1
- package/dist/social-providers/atlassian.mjs +6 -3
- package/dist/social-providers/cognito.d.mts +27 -3
- package/dist/social-providers/cognito.mjs +15 -25
- package/dist/social-providers/discord.d.mts +7 -3
- package/dist/social-providers/discord.mjs +16 -3
- package/dist/social-providers/dropbox.d.mts +5 -1
- package/dist/social-providers/dropbox.mjs +5 -4
- package/dist/social-providers/facebook.d.mts +24 -5
- package/dist/social-providers/facebook.mjs +54 -20
- package/dist/social-providers/figma.d.mts +5 -1
- package/dist/social-providers/figma.mjs +4 -3
- package/dist/social-providers/github.d.mts +6 -2
- package/dist/social-providers/github.mjs +5 -4
- package/dist/social-providers/gitlab.d.mts +5 -1
- package/dist/social-providers/gitlab.mjs +3 -2
- package/dist/social-providers/google.d.mts +53 -5
- package/dist/social-providers/google.mjs +69 -25
- package/dist/social-providers/huggingface.d.mts +5 -1
- package/dist/social-providers/huggingface.mjs +3 -2
- package/dist/social-providers/index.d.mts +221 -45
- package/dist/social-providers/index.mjs +2 -2
- package/dist/social-providers/kakao.d.mts +5 -1
- package/dist/social-providers/kakao.mjs +3 -2
- package/dist/social-providers/kick.d.mts +5 -1
- package/dist/social-providers/kick.mjs +3 -2
- package/dist/social-providers/line.d.mts +8 -2
- package/dist/social-providers/line.mjs +5 -6
- package/dist/social-providers/linear.d.mts +5 -1
- package/dist/social-providers/linear.mjs +3 -2
- package/dist/social-providers/linkedin.d.mts +7 -3
- package/dist/social-providers/linkedin.mjs +4 -3
- package/dist/social-providers/microsoft-entra-id.d.mts +41 -7
- package/dist/social-providers/microsoft-entra-id.mjs +37 -28
- package/dist/social-providers/naver.d.mts +5 -1
- package/dist/social-providers/naver.mjs +3 -2
- package/dist/social-providers/notion.d.mts +5 -1
- package/dist/social-providers/notion.mjs +5 -2
- package/dist/social-providers/paybin.d.mts +5 -1
- package/dist/social-providers/paybin.mjs +4 -3
- package/dist/social-providers/paypal.d.mts +6 -2
- package/dist/social-providers/paypal.mjs +19 -14
- package/dist/social-providers/polar.d.mts +5 -1
- package/dist/social-providers/polar.mjs +3 -2
- package/dist/social-providers/railway.d.mts +5 -1
- package/dist/social-providers/railway.mjs +3 -2
- package/dist/social-providers/reddit.d.mts +5 -1
- package/dist/social-providers/reddit.mjs +7 -5
- package/dist/social-providers/roblox.d.mts +6 -2
- package/dist/social-providers/roblox.mjs +12 -2
- package/dist/social-providers/salesforce.d.mts +5 -1
- package/dist/social-providers/salesforce.mjs +4 -3
- package/dist/social-providers/slack.d.mts +6 -2
- package/dist/social-providers/slack.mjs +11 -8
- package/dist/social-providers/spotify.d.mts +5 -1
- package/dist/social-providers/spotify.mjs +3 -2
- package/dist/social-providers/tiktok.d.mts +5 -1
- package/dist/social-providers/tiktok.mjs +14 -2
- package/dist/social-providers/twitch.d.mts +5 -1
- package/dist/social-providers/twitch.mjs +3 -2
- package/dist/social-providers/twitter.d.mts +4 -1
- package/dist/social-providers/twitter.mjs +2 -1
- package/dist/social-providers/vercel.d.mts +5 -1
- package/dist/social-providers/vercel.mjs +3 -2
- package/dist/social-providers/vk.d.mts +5 -1
- package/dist/social-providers/vk.mjs +3 -2
- package/dist/social-providers/wechat.d.mts +5 -1
- package/dist/social-providers/wechat.mjs +8 -2
- package/dist/social-providers/zoom.d.mts +5 -1
- package/dist/social-providers/zoom.mjs +10 -17
- package/dist/types/context.d.mts +55 -6
- package/dist/types/index.d.mts +1 -1
- package/dist/types/init-options.d.mts +182 -12
- package/dist/types/plugin-client.d.mts +12 -2
- package/dist/utils/async.d.mts +22 -0
- package/dist/utils/async.mjs +32 -0
- package/dist/utils/host.d.mts +147 -0
- package/dist/utils/host.mjs +298 -0
- package/dist/utils/ip.d.mts +28 -5
- package/dist/utils/ip.mjs +118 -4
- package/dist/utils/is-api-error.d.mts +6 -0
- package/dist/utils/is-api-error.mjs +8 -0
- package/dist/utils/redirect-uri.d.mts +20 -0
- package/dist/utils/redirect-uri.mjs +48 -0
- package/dist/utils/string.d.mts +5 -1
- package/dist/utils/string.mjs +20 -1
- package/dist/utils/url.d.mts +18 -1
- package/dist/utils/url.mjs +34 -4
- package/package.json +26 -16
- package/src/api/index.ts +121 -5
- package/src/context/transaction.ts +45 -12
- package/src/db/adapter/factory.ts +193 -7
- package/src/db/adapter/get-id-field.ts +2 -2
- package/src/db/adapter/index.ts +85 -2
- package/src/db/adapter/types.ts +2 -0
- package/src/db/get-tables.ts +9 -1
- package/src/db/schema/account.ts +4 -1
- package/src/db/schema/user.ts +3 -0
- package/src/db/type.ts +17 -0
- package/src/env/env-impl.ts +1 -2
- package/src/error/codes.ts +1 -0
- package/src/error/index.ts +9 -0
- package/src/instrumentation/api.ts +17 -0
- package/src/instrumentation/noop.ts +74 -0
- package/src/instrumentation/pure.index.ts +31 -0
- package/src/instrumentation/tracer.ts +8 -3
- package/src/oauth2/authorization-params.ts +28 -0
- package/src/oauth2/basic-credentials.ts +87 -0
- package/src/oauth2/client-assertion.ts +131 -58
- package/src/oauth2/client-credentials-token.ts +50 -74
- package/src/oauth2/create-authorization-url.ts +33 -7
- package/src/oauth2/dpop.ts +568 -0
- package/src/oauth2/index.ts +81 -11
- package/src/oauth2/oauth-provider.ts +159 -11
- package/src/oauth2/refresh-access-token.ts +82 -83
- package/src/oauth2/reject-redirects.ts +75 -0
- package/src/oauth2/token-endpoint-auth.ts +221 -0
- package/src/oauth2/utils.ts +72 -5
- package/src/oauth2/validate-authorization-code.ts +69 -89
- package/src/oauth2/verify-id-token.ts +115 -0
- package/src/oauth2/verify.ts +409 -78
- package/src/social-providers/apple.ts +30 -40
- package/src/social-providers/atlassian.ts +8 -1
- package/src/social-providers/cognito.ts +36 -39
- package/src/social-providers/discord.ts +22 -18
- package/src/social-providers/dropbox.ts +7 -5
- package/src/social-providers/facebook.ts +108 -52
- package/src/social-providers/figma.ts +8 -1
- package/src/social-providers/github.ts +5 -3
- package/src/social-providers/gitlab.ts +2 -0
- package/src/social-providers/google.ts +141 -39
- package/src/social-providers/huggingface.ts +8 -1
- package/src/social-providers/kakao.ts +2 -1
- package/src/social-providers/kick.ts +8 -1
- package/src/social-providers/line.ts +25 -27
- package/src/social-providers/linear.ts +8 -1
- package/src/social-providers/linkedin.ts +5 -3
- package/src/social-providers/microsoft-entra-id.ts +100 -55
- package/src/social-providers/naver.ts +2 -1
- package/src/social-providers/notion.ts +8 -1
- package/src/social-providers/paybin.ts +2 -0
- package/src/social-providers/paypal.ts +28 -17
- package/src/social-providers/polar.ts +8 -1
- package/src/social-providers/railway.ts +8 -1
- package/src/social-providers/reddit.ts +9 -4
- package/src/social-providers/roblox.ts +16 -11
- package/src/social-providers/salesforce.ts +8 -1
- package/src/social-providers/slack.ts +15 -9
- package/src/social-providers/spotify.ts +8 -1
- package/src/social-providers/tiktok.ts +22 -9
- package/src/social-providers/twitch.ts +2 -1
- package/src/social-providers/twitter.ts +1 -0
- package/src/social-providers/vercel.ts +8 -1
- package/src/social-providers/vk.ts +8 -1
- package/src/social-providers/wechat.ts +17 -2
- package/src/social-providers/zoom.ts +15 -19
- package/src/types/context.ts +57 -5
- package/src/types/index.ts +7 -0
- package/src/types/init-options.ts +204 -14
- package/src/types/plugin-client.ts +16 -2
- package/src/utils/async.ts +53 -0
- package/src/utils/host.ts +425 -0
- package/src/utils/ip.ts +197 -13
- package/src/utils/is-api-error.ts +10 -0
- package/src/utils/redirect-uri.ts +54 -0
- package/src/utils/string.ts +37 -0
- package/src/utils/url.ts +38 -4
package/src/oauth2/verify.ts
CHANGED
|
@@ -1,21 +1,135 @@
|
|
|
1
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
2
1
|
import { APIError } from "better-call";
|
|
3
2
|
import type {
|
|
4
3
|
JSONWebKeySet,
|
|
5
4
|
JWTPayload,
|
|
6
5
|
JWTVerifyOptions,
|
|
6
|
+
JWTVerifyResult,
|
|
7
7
|
ProtectedHeaderParameters,
|
|
8
8
|
} from "jose";
|
|
9
9
|
import {
|
|
10
10
|
createLocalJWKSet,
|
|
11
11
|
decodeProtectedHeader,
|
|
12
|
+
errors as joseErrors,
|
|
12
13
|
jwtVerify,
|
|
13
14
|
UnsecuredJWT,
|
|
14
15
|
} from "jose";
|
|
15
16
|
import { logger } from "../env";
|
|
17
|
+
import type { DpopReplayStore } from "./dpop";
|
|
18
|
+
import {
|
|
19
|
+
createInMemoryDpopReplayStore,
|
|
20
|
+
enforceDpopBinding,
|
|
21
|
+
getDpopJktFromPayload,
|
|
22
|
+
isDpopBindingError,
|
|
23
|
+
parseAccessTokenAuthorization,
|
|
24
|
+
} from "./dpop";
|
|
25
|
+
import { fetchRefusingRedirects } from "./reject-redirects";
|
|
26
|
+
|
|
27
|
+
const joseInfrastructureErrorCodes = new Set([
|
|
28
|
+
joseErrors.JWKSTimeout.code,
|
|
29
|
+
joseErrors.JWKSInvalid.code,
|
|
30
|
+
joseErrors.JWKSMultipleMatchingKeys.code,
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function isJoseInfrastructureError(error: joseErrors.JOSEError) {
|
|
34
|
+
return joseInfrastructureErrorCodes.has(error.code);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface JwksCacheEntry {
|
|
38
|
+
jwks: JSONWebKeySet;
|
|
39
|
+
fetchedAt: number;
|
|
40
|
+
noKidRefetchedAt?: number | undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type JwksFetchOptions = {
|
|
44
|
+
/** Jwks url or promise of a Jwks */
|
|
45
|
+
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
46
|
+
/**
|
|
47
|
+
* Stable object to cache the result of a function `jwksFetch` under,
|
|
48
|
+
* with the same TTL and kid-miss refetch rules as string sources.
|
|
49
|
+
* Without it, a function source is fetched on every verification.
|
|
50
|
+
*/
|
|
51
|
+
jwksCacheKey?: object;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type ResolvedJwks = {
|
|
55
|
+
jwks: JSONWebKeySet;
|
|
56
|
+
fromCache: boolean;
|
|
57
|
+
kid: string | undefined;
|
|
58
|
+
noKidRefetchedAt?: number | undefined;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
export const jwksCache = new Map<string, JwksCacheEntry>();
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Cache for function jwks sources, keyed by a caller-provided stable object.
|
|
68
|
+
* Entries are released with their key, so per-request keys cannot accumulate.
|
|
69
|
+
*/
|
|
70
|
+
const functionJwksCache = new WeakMap<object, JwksCacheEntry>();
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How long a cached JWKS is trusted before it is refetched
|
|
74
|
+
*
|
|
75
|
+
* @internal
|
|
76
|
+
*/
|
|
77
|
+
const JWKS_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
78
|
+
const JWKS_NO_KID_REFETCH_COOLDOWN_MS = 30 * 1000;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns the cached key set when it is within the TTL. When the token carries
|
|
82
|
+
* `kid`, the cached set must contain that key id; without `kid`, key selection
|
|
83
|
+
* is deferred to JOSE because RFC 7515 makes the header parameter optional.
|
|
84
|
+
*/
|
|
85
|
+
function getFreshJwksWithKid(
|
|
86
|
+
cached: JwksCacheEntry | undefined,
|
|
87
|
+
kid: string | undefined,
|
|
88
|
+
): JSONWebKeySet | undefined {
|
|
89
|
+
if (!cached) return undefined;
|
|
90
|
+
if (Date.now() - cached.fetchedAt >= JWKS_CACHE_TTL_MS) return undefined;
|
|
91
|
+
if (kid && !cached.jwks.keys.some((jwk) => jwk.kid === kid)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
return cached.jwks;
|
|
95
|
+
}
|
|
16
96
|
|
|
17
|
-
|
|
18
|
-
|
|
97
|
+
function shouldRefetchCachedJwksWithoutKid(
|
|
98
|
+
error: unknown,
|
|
99
|
+
resolved: ResolvedJwks,
|
|
100
|
+
) {
|
|
101
|
+
const isRetryableNoKidFailure =
|
|
102
|
+
resolved.fromCache &&
|
|
103
|
+
!resolved.kid &&
|
|
104
|
+
(error instanceof joseErrors.JWKSNoMatchingKey ||
|
|
105
|
+
error instanceof joseErrors.JWSSignatureVerificationFailed);
|
|
106
|
+
if (!isRetryableNoKidFailure) return false;
|
|
107
|
+
if (!resolved.noKidRefetchedAt) return true;
|
|
108
|
+
return (
|
|
109
|
+
Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fetchJwks(
|
|
114
|
+
jwksFetch: JwksFetchOptions["jwksFetch"],
|
|
115
|
+
): Promise<JSONWebKeySet> {
|
|
116
|
+
const jwks =
|
|
117
|
+
typeof jwksFetch === "string"
|
|
118
|
+
? await fetchRefusingRedirects<JSONWebKeySet>(jwksFetch, {
|
|
119
|
+
headers: {
|
|
120
|
+
Accept: "application/json",
|
|
121
|
+
},
|
|
122
|
+
}).then(async (res) => {
|
|
123
|
+
if (res.error)
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Jwks failed: ${res.error.message ?? res.error.statusText}`,
|
|
126
|
+
);
|
|
127
|
+
return res.data;
|
|
128
|
+
})
|
|
129
|
+
: await jwksFetch();
|
|
130
|
+
if (!jwks) throw new Error("No jwks found");
|
|
131
|
+
return jwks;
|
|
132
|
+
}
|
|
19
133
|
|
|
20
134
|
export interface VerifyAccessTokenRemote {
|
|
21
135
|
/** Full url of the introspect endpoint. Should end with `/oauth2/introspect` */
|
|
@@ -30,8 +144,81 @@ export interface VerifyAccessTokenRemote {
|
|
|
30
144
|
* is also still active.
|
|
31
145
|
*/
|
|
32
146
|
force?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Accept introspection responses that omit the `aud` claim even when a
|
|
149
|
+
* required `audience` is configured in `verifyOptions`.
|
|
150
|
+
*
|
|
151
|
+
* By default verification fails closed: if you configure an `audience` and
|
|
152
|
+
* the introspection response has no `aud` (or a mismatching one), the token
|
|
153
|
+
* is rejected. Some authorization servers legitimately omit `aud` from
|
|
154
|
+
* introspection responses (it is OPTIONAL per RFC 7662 §2.2); only enable
|
|
155
|
+
* this if you trust the issuer to bind the token to this resource through
|
|
156
|
+
* another mechanism, as it skips the audience check in that case.
|
|
157
|
+
*
|
|
158
|
+
* @default false
|
|
159
|
+
*/
|
|
160
|
+
allowMissingAudience?: boolean;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface VerifyAccessTokenOptions {
|
|
164
|
+
/** Verify options */
|
|
165
|
+
verifyOptions: JWTVerifyOptions &
|
|
166
|
+
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
167
|
+
/** Scopes to additionally verify. Token must include all but not exact. */
|
|
168
|
+
scopes?: string[];
|
|
169
|
+
/** Required to verify access token locally */
|
|
170
|
+
jwksUrl?: string;
|
|
171
|
+
/** If provided, can verify a token remotely */
|
|
172
|
+
remoteVerify?: VerifyAccessTokenRemote;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface VerifyAccessTokenRequestOptions
|
|
176
|
+
extends VerifyAccessTokenOptions {
|
|
177
|
+
dpop?: {
|
|
178
|
+
proofMaxAgeSeconds?: number;
|
|
179
|
+
/**
|
|
180
|
+
* Store used to reject replayed DPoP proof `jti` values.
|
|
181
|
+
*
|
|
182
|
+
* Defaults to a process-local in-memory store, which is only safe for a
|
|
183
|
+
* single-instance deployment: it shares no state across instances and
|
|
184
|
+
* resets on cold start, so a captured proof can be replayed against
|
|
185
|
+
* another instance within the proof's lifetime. Supply a shared,
|
|
186
|
+
* persistent store (for example one backed by your database) for any
|
|
187
|
+
* multi-instance or serverless resource server.
|
|
188
|
+
*/
|
|
189
|
+
replayStore?: DpopReplayStore;
|
|
190
|
+
signingAlgorithms?: readonly string[];
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface ResourceRequestInput {
|
|
195
|
+
authorizationHeader: string | null | undefined;
|
|
196
|
+
dpopProofJwt?: string | null | undefined;
|
|
197
|
+
method: string;
|
|
198
|
+
url: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Builds a {@link ResourceRequestInput} from a standard `Request`, reading the
|
|
203
|
+
* `Authorization` and `DPoP` headers and the request method and URL. Resource
|
|
204
|
+
* servers share this so every entry point maps the wire request the same way.
|
|
205
|
+
*/
|
|
206
|
+
export function requestToResourceInput(request: Request): ResourceRequestInput {
|
|
207
|
+
return {
|
|
208
|
+
authorizationHeader: request.headers.get("authorization"),
|
|
209
|
+
dpopProofJwt: request.headers.get("dpop"),
|
|
210
|
+
method: request.method,
|
|
211
|
+
url: request.url,
|
|
212
|
+
};
|
|
33
213
|
}
|
|
34
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Process-local, single-instance replay store. See the warning on
|
|
217
|
+
* {@link VerifyAccessTokenRequestOptions.dpop.replayStore}; multi-instance
|
|
218
|
+
* resource servers must pass their own shared store.
|
|
219
|
+
*/
|
|
220
|
+
const defaultDpopReplayStore = createInMemoryDpopReplayStore();
|
|
221
|
+
|
|
35
222
|
/**
|
|
36
223
|
* Performs local verification of an access token for your APIs.
|
|
37
224
|
*
|
|
@@ -39,21 +226,36 @@ export interface VerifyAccessTokenRemote {
|
|
|
39
226
|
*/
|
|
40
227
|
export async function verifyJwsAccessToken(
|
|
41
228
|
token: string,
|
|
42
|
-
opts: {
|
|
43
|
-
/** Jwks url or promise of a Jwks */
|
|
44
|
-
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
229
|
+
opts: JwksFetchOptions & {
|
|
45
230
|
/** Verify options */
|
|
46
231
|
verifyOptions: JWTVerifyOptions &
|
|
47
232
|
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
48
233
|
},
|
|
49
234
|
) {
|
|
50
235
|
try {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
236
|
+
const resolved = await getJwksForVerification(token, opts);
|
|
237
|
+
let jwt: JWTVerifyResult<JWTPayload>;
|
|
238
|
+
try {
|
|
239
|
+
jwt = await jwtVerify<JWTPayload>(
|
|
240
|
+
token,
|
|
241
|
+
createLocalJWKSet(resolved.jwks),
|
|
242
|
+
opts.verifyOptions,
|
|
243
|
+
);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (shouldRefetchCachedJwksWithoutKid(error, resolved)) {
|
|
246
|
+
const refreshed = await getJwksForVerification(token, {
|
|
247
|
+
...opts,
|
|
248
|
+
forceRefresh: true,
|
|
249
|
+
});
|
|
250
|
+
jwt = await jwtVerify<JWTPayload>(
|
|
251
|
+
token,
|
|
252
|
+
createLocalJWKSet(refreshed.jwks),
|
|
253
|
+
opts.verifyOptions,
|
|
254
|
+
);
|
|
255
|
+
} else {
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
57
259
|
// Return the JWT payload in introspection format
|
|
58
260
|
// https://datatracker.ietf.org/doc/html/rfc7662#section-2.2
|
|
59
261
|
if (jwt.payload.azp) {
|
|
@@ -66,12 +268,13 @@ export async function verifyJwsAccessToken(
|
|
|
66
268
|
}
|
|
67
269
|
}
|
|
68
270
|
|
|
69
|
-
export async function getJwks(
|
|
271
|
+
export async function getJwks(token: string, opts: JwksFetchOptions) {
|
|
272
|
+
return (await getJwksForVerification(token, opts)).jwks;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function getJwksForVerification(
|
|
70
276
|
token: string,
|
|
71
|
-
opts: {
|
|
72
|
-
/** Jwks url or promise of a Jwks */
|
|
73
|
-
jwksFetch: string | (() => Promise<JSONWebKeySet | undefined>);
|
|
74
|
-
},
|
|
277
|
+
opts: JwksFetchOptions & { forceRefresh?: boolean },
|
|
75
278
|
) {
|
|
76
279
|
// Attempt to decode the token and find a matching kid in jwks
|
|
77
280
|
let jwtHeaders: ProtectedHeaderParameters | undefined;
|
|
@@ -82,48 +285,70 @@ export async function getJwks(
|
|
|
82
285
|
throw new Error(error as unknown as string);
|
|
83
286
|
}
|
|
84
287
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
288
|
+
const kid = jwtHeaders.kid;
|
|
289
|
+
|
|
290
|
+
// Function sources have no usable identity of their own (callers pass
|
|
291
|
+
// fresh closures per request), so they are cached only under a stable
|
|
292
|
+
// caller-provided key object.
|
|
293
|
+
if (typeof opts.jwksFetch !== "string") {
|
|
294
|
+
const cacheKey = opts.jwksCacheKey;
|
|
295
|
+
if (!cacheKey) {
|
|
296
|
+
const jwks = await opts.jwksFetch();
|
|
297
|
+
if (!jwks) throw new Error("No jwks found");
|
|
298
|
+
return { jwks, fromCache: false, kid };
|
|
299
|
+
}
|
|
300
|
+
const cached = functionJwksCache.get(cacheKey);
|
|
301
|
+
const cachedJwks = opts.forceRefresh
|
|
302
|
+
? undefined
|
|
303
|
+
: getFreshJwksWithKid(cached, kid);
|
|
304
|
+
if (cachedJwks) {
|
|
305
|
+
return {
|
|
306
|
+
jwks: cachedJwks,
|
|
307
|
+
fromCache: true,
|
|
308
|
+
kid,
|
|
309
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
const jwks = await opts.jwksFetch();
|
|
103
313
|
if (!jwks) throw new Error("No jwks found");
|
|
314
|
+
const fetchedAt = Date.now();
|
|
315
|
+
functionJwksCache.set(cacheKey, {
|
|
316
|
+
jwks,
|
|
317
|
+
fetchedAt,
|
|
318
|
+
...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
|
|
319
|
+
});
|
|
320
|
+
return { jwks, fromCache: false, kid };
|
|
104
321
|
}
|
|
105
322
|
|
|
106
|
-
|
|
323
|
+
// The cache is scoped to `cacheKey`, so a token is only ever matched
|
|
324
|
+
// against the key set published by its own source.
|
|
325
|
+
const cacheKey = opts.jwksFetch;
|
|
326
|
+
const cached = jwksCache.get(cacheKey);
|
|
327
|
+
const cachedJwks = opts.forceRefresh
|
|
328
|
+
? undefined
|
|
329
|
+
: getFreshJwksWithKid(cached, kid);
|
|
330
|
+
if (!cachedJwks) {
|
|
331
|
+
const jwks = await fetchJwks(opts.jwksFetch);
|
|
332
|
+
const fetchedAt = Date.now();
|
|
333
|
+
jwksCache.set(cacheKey, {
|
|
334
|
+
jwks,
|
|
335
|
+
fetchedAt,
|
|
336
|
+
...(opts.forceRefresh && !kid ? { noKidRefetchedAt: fetchedAt } : {}),
|
|
337
|
+
});
|
|
338
|
+
return { jwks, fromCache: false, kid };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
jwks: cachedJwks,
|
|
343
|
+
fromCache: true,
|
|
344
|
+
kid,
|
|
345
|
+
noKidRefetchedAt: cached?.noKidRefetchedAt,
|
|
346
|
+
};
|
|
107
347
|
}
|
|
108
348
|
|
|
109
|
-
|
|
110
|
-
* Performs local verification of an access token for your API.
|
|
111
|
-
*
|
|
112
|
-
* Can also be configured for remote verification.
|
|
113
|
-
*/
|
|
114
|
-
export async function verifyAccessToken(
|
|
349
|
+
async function verifyAccessTokenPayload(
|
|
115
350
|
token: string,
|
|
116
|
-
opts:
|
|
117
|
-
/** Verify options */
|
|
118
|
-
verifyOptions: JWTVerifyOptions &
|
|
119
|
-
Required<Pick<JWTVerifyOptions, "audience" | "issuer">>;
|
|
120
|
-
/** Scopes to additionally verify. Token must include all but not exact. */
|
|
121
|
-
scopes?: string[];
|
|
122
|
-
/** Required to verify access token locally */
|
|
123
|
-
jwksUrl?: string;
|
|
124
|
-
/** If provided, can verify a token remotely */
|
|
125
|
-
remoteVerify?: VerifyAccessTokenRemote;
|
|
126
|
-
},
|
|
351
|
+
opts: VerifyAccessTokenOptions,
|
|
127
352
|
) {
|
|
128
353
|
let payload: JWTPayload | undefined;
|
|
129
354
|
// Locally verify
|
|
@@ -137,13 +362,16 @@ export async function verifyAccessToken(
|
|
|
137
362
|
if (error instanceof Error) {
|
|
138
363
|
if (error.name === "TypeError" || error.name === "JWSInvalid") {
|
|
139
364
|
// likely an opaque token (continue)
|
|
140
|
-
} else if (error
|
|
365
|
+
} else if (error instanceof joseErrors.JWTExpired) {
|
|
141
366
|
throw new APIError("UNAUTHORIZED", {
|
|
142
367
|
message: "token expired",
|
|
143
368
|
});
|
|
144
|
-
} else if (error
|
|
369
|
+
} else if (error instanceof joseErrors.JOSEError) {
|
|
370
|
+
if (isJoseInfrastructureError(error)) {
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
145
373
|
throw new APIError("UNAUTHORIZED", {
|
|
146
|
-
message: "token
|
|
374
|
+
message: "invalid access token",
|
|
147
375
|
});
|
|
148
376
|
} else {
|
|
149
377
|
throw error;
|
|
@@ -156,23 +384,24 @@ export async function verifyAccessToken(
|
|
|
156
384
|
|
|
157
385
|
// Remote verify
|
|
158
386
|
if (opts?.remoteVerify) {
|
|
159
|
-
const { data: introspect, error: introspectError } =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
387
|
+
const { data: introspect, error: introspectError } =
|
|
388
|
+
await fetchRefusingRedirects<
|
|
389
|
+
JWTPayload & {
|
|
390
|
+
active: boolean;
|
|
391
|
+
}
|
|
392
|
+
>(opts.remoteVerify.introspectUrl, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers: {
|
|
395
|
+
Accept: "application/json",
|
|
396
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
397
|
+
},
|
|
398
|
+
body: new URLSearchParams({
|
|
399
|
+
client_id: opts.remoteVerify.clientId,
|
|
400
|
+
client_secret: opts.remoteVerify.clientSecret,
|
|
401
|
+
token,
|
|
402
|
+
token_type_hint: "access_token",
|
|
403
|
+
}).toString(),
|
|
404
|
+
});
|
|
176
405
|
if (introspectError)
|
|
177
406
|
logger.error(
|
|
178
407
|
`Introspection failed: ${introspectError.message ?? introspectError.statusText}`,
|
|
@@ -185,13 +414,23 @@ export async function verifyAccessToken(
|
|
|
185
414
|
throw new APIError("UNAUTHORIZED", {
|
|
186
415
|
message: "token inactive",
|
|
187
416
|
});
|
|
188
|
-
// Verifies payload using verify options (token valid through introspect)
|
|
417
|
+
// Verifies payload using verify options (token valid through introspect).
|
|
418
|
+
// Audience is enforced by default: when `verifyOptions.audience` is set
|
|
419
|
+
// but the introspection response omits `aud` (or it mismatches),
|
|
420
|
+
// `UnsecuredJWT.decode` throws and the token is rejected. Otherwise a
|
|
421
|
+
// token issued for a different resource/client on the same issuer would
|
|
422
|
+
// also pass. Only drop the audience check when the caller has explicitly
|
|
423
|
+
// opted in via `remoteVerify.allowMissingAudience`.
|
|
189
424
|
try {
|
|
190
425
|
const unsecuredJwt = new UnsecuredJWT(introspect).encode();
|
|
191
|
-
const { audience: _audience, ...
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
426
|
+
const { audience: _audience, ...verifyOptionsNoAudience } =
|
|
427
|
+
opts.verifyOptions;
|
|
428
|
+
const skipAudience =
|
|
429
|
+
!introspect.aud && opts.remoteVerify.allowMissingAudience === true;
|
|
430
|
+
const verify = UnsecuredJWT.decode(
|
|
431
|
+
unsecuredJwt,
|
|
432
|
+
skipAudience ? verifyOptionsNoAudience : opts.verifyOptions,
|
|
433
|
+
);
|
|
195
434
|
payload = verify.payload;
|
|
196
435
|
} catch (error) {
|
|
197
436
|
throw new Error(error as unknown as string);
|
|
@@ -219,3 +458,95 @@ export async function verifyAccessToken(
|
|
|
219
458
|
|
|
220
459
|
return payload;
|
|
221
460
|
}
|
|
461
|
+
|
|
462
|
+
function throwDpopUnauthorized(
|
|
463
|
+
message: string,
|
|
464
|
+
error?: "invalid_dpop_proof" | "invalid_token",
|
|
465
|
+
): never {
|
|
466
|
+
throw new APIError(
|
|
467
|
+
"UNAUTHORIZED",
|
|
468
|
+
error
|
|
469
|
+
? {
|
|
470
|
+
message,
|
|
471
|
+
error,
|
|
472
|
+
error_description: message,
|
|
473
|
+
}
|
|
474
|
+
: { message },
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Performs local verification of a bearer access token for your API.
|
|
480
|
+
*
|
|
481
|
+
* Can also be configured for remote verification. DPoP-bound access tokens
|
|
482
|
+
* require {@link verifyAccessTokenRequest}, because sender-constraining cannot
|
|
483
|
+
* be verified without the HTTP method, URL, Authorization scheme, DPoP proof,
|
|
484
|
+
* and access-token hash. This function rejects DPoP-bound tokens; reach for it
|
|
485
|
+
* only when you hold a raw token string and intentionally accept bearer tokens
|
|
486
|
+
* alone.
|
|
487
|
+
*/
|
|
488
|
+
export async function verifyBearerToken(
|
|
489
|
+
token: string,
|
|
490
|
+
opts: VerifyAccessTokenOptions,
|
|
491
|
+
) {
|
|
492
|
+
const payload = await verifyAccessTokenPayload(token, opts);
|
|
493
|
+
if (getDpopJktFromPayload(payload)) {
|
|
494
|
+
throwDpopUnauthorized(
|
|
495
|
+
"DPoP-bound access token requires verifyAccessTokenRequest",
|
|
496
|
+
"invalid_token",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
return payload;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Verifies an HTTP resource request carrying an OAuth access token. This is the
|
|
504
|
+
* recommended resource-server entry point: it handles both bearer and
|
|
505
|
+
* DPoP-bound tokens, the bearer case being the request with no DPoP proof.
|
|
506
|
+
*
|
|
507
|
+
* It performs the same token validation as {@link verifyBearerToken}, then adds
|
|
508
|
+
* the RFC 9449 sender-constraint checks that need request context: authorization
|
|
509
|
+
* scheme, method, URL, DPoP proof, `ath`, and `cnf.jkt` binding.
|
|
510
|
+
*/
|
|
511
|
+
export async function verifyAccessTokenRequest(
|
|
512
|
+
request: ResourceRequestInput,
|
|
513
|
+
opts: VerifyAccessTokenRequestOptions,
|
|
514
|
+
) {
|
|
515
|
+
const authorization = parseAccessTokenAuthorization(
|
|
516
|
+
request.authorizationHeader,
|
|
517
|
+
);
|
|
518
|
+
if (!authorization?.token) {
|
|
519
|
+
throwDpopUnauthorized("missing authorization header");
|
|
520
|
+
}
|
|
521
|
+
// RFC 6750 §2.1 / RFC 9449 §7.1: an access token is presented with the
|
|
522
|
+
// `Bearer` or `DPoP` scheme. Reject a scheme-less or unknown-scheme value
|
|
523
|
+
// rather than accept a bare token.
|
|
524
|
+
if (authorization.scheme === "Unknown") {
|
|
525
|
+
throwDpopUnauthorized(
|
|
526
|
+
"authorization scheme must be Bearer or DPoP",
|
|
527
|
+
"invalid_token",
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const payload = await verifyAccessTokenPayload(authorization.token, opts);
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
await enforceDpopBinding({
|
|
535
|
+
payload,
|
|
536
|
+
authorization,
|
|
537
|
+
proofJwt: request.dpopProofJwt,
|
|
538
|
+
method: request.method,
|
|
539
|
+
url: request.url,
|
|
540
|
+
replayStore: opts.dpop?.replayStore ?? defaultDpopReplayStore,
|
|
541
|
+
proofMaxAgeSeconds: opts.dpop?.proofMaxAgeSeconds,
|
|
542
|
+
signingAlgorithms: opts.dpop?.signingAlgorithms,
|
|
543
|
+
});
|
|
544
|
+
} catch (error) {
|
|
545
|
+
if (isDpopBindingError(error)) {
|
|
546
|
+
throwDpopUnauthorized(error.message, error.code);
|
|
547
|
+
}
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return payload;
|
|
552
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { betterFetch } from "@better-fetch/fetch";
|
|
2
2
|
|
|
3
|
-
import { decodeJwt,
|
|
4
|
-
import {
|
|
3
|
+
import { decodeJwt, importJWK } from "jose";
|
|
4
|
+
import { logger } from "../env";
|
|
5
|
+
import { APIError, BetterAuthError } from "../error";
|
|
5
6
|
import type { OAuthProvider, ProviderOptions } from "../oauth2";
|
|
6
7
|
import {
|
|
7
8
|
createAuthorizationURL,
|
|
9
|
+
getPrimaryClientId,
|
|
8
10
|
refreshAccessToken,
|
|
9
11
|
validateAuthorizationCode,
|
|
10
12
|
} from "../oauth2";
|
|
@@ -20,7 +22,7 @@ export interface AppleProfile {
|
|
|
20
22
|
* The email address is either the user's real email address or the proxy
|
|
21
23
|
* address, depending on their status private email relay service.
|
|
22
24
|
*/
|
|
23
|
-
email
|
|
25
|
+
email?: string;
|
|
24
26
|
/**
|
|
25
27
|
* A string or Boolean value that indicates whether the service verifies
|
|
26
28
|
* the email. The value can either be a string ("true" or "false") or a
|
|
@@ -70,7 +72,7 @@ export interface AppleNonConformUser {
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export interface AppleOptions extends ProviderOptions<AppleProfile> {
|
|
73
|
-
clientId: string;
|
|
75
|
+
clientId: string | string[];
|
|
74
76
|
appBundleIdentifier?: string | undefined;
|
|
75
77
|
audience?: (string | string[]) | undefined;
|
|
76
78
|
}
|
|
@@ -80,7 +82,18 @@ export const apple = (options: AppleOptions) => {
|
|
|
80
82
|
return {
|
|
81
83
|
id: "apple",
|
|
82
84
|
name: "Apple",
|
|
83
|
-
async createAuthorizationURL({
|
|
85
|
+
async createAuthorizationURL({
|
|
86
|
+
state,
|
|
87
|
+
scopes,
|
|
88
|
+
redirectURI,
|
|
89
|
+
additionalParams,
|
|
90
|
+
}) {
|
|
91
|
+
if (!getPrimaryClientId(options.clientId) || !options.clientSecret) {
|
|
92
|
+
logger.error(
|
|
93
|
+
"Client ID and client secret are required for Apple. Make sure to provide them in the options.",
|
|
94
|
+
);
|
|
95
|
+
throw new BetterAuthError("CLIENT_ID_AND_SECRET_REQUIRED");
|
|
96
|
+
}
|
|
84
97
|
const _scope = options.disableDefaultScope ? [] : ["email", "name"];
|
|
85
98
|
if (options.scope) _scope.push(...options.scope);
|
|
86
99
|
if (scopes) _scope.push(...scopes);
|
|
@@ -93,6 +106,7 @@ export const apple = (options: AppleOptions) => {
|
|
|
93
106
|
redirectURI,
|
|
94
107
|
responseMode: "form_post",
|
|
95
108
|
responseType: "code id_token",
|
|
109
|
+
additionalParams,
|
|
96
110
|
});
|
|
97
111
|
return url;
|
|
98
112
|
},
|
|
@@ -105,41 +119,17 @@ export const apple = (options: AppleOptions) => {
|
|
|
105
119
|
tokenEndpoint,
|
|
106
120
|
});
|
|
107
121
|
},
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const publicKey = await getApplePublicKey(kid);
|
|
120
|
-
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
|
|
121
|
-
algorithms: [jwtAlg],
|
|
122
|
-
issuer: "https://appleid.apple.com",
|
|
123
|
-
audience:
|
|
124
|
-
options.audience && options.audience.length
|
|
125
|
-
? options.audience
|
|
126
|
-
: options.appBundleIdentifier
|
|
127
|
-
? options.appBundleIdentifier
|
|
128
|
-
: options.clientId,
|
|
129
|
-
maxTokenAge: "1h",
|
|
130
|
-
});
|
|
131
|
-
["email_verified", "is_private_email"].forEach((field) => {
|
|
132
|
-
if (jwtClaims[field] !== undefined) {
|
|
133
|
-
jwtClaims[field] = Boolean(jwtClaims[field]);
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
if (nonce && jwtClaims.nonce !== nonce) {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
return !!jwtClaims;
|
|
140
|
-
} catch {
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
122
|
+
idToken: {
|
|
123
|
+
jwks: (header) => getApplePublicKey(header.kid!),
|
|
124
|
+
issuer: "https://appleid.apple.com",
|
|
125
|
+
audience:
|
|
126
|
+
options.audience && options.audience.length
|
|
127
|
+
? options.audience
|
|
128
|
+
: options.appBundleIdentifier
|
|
129
|
+
? options.appBundleIdentifier
|
|
130
|
+
: options.clientId,
|
|
131
|
+
maxTokenAge: "1h",
|
|
132
|
+
nonceComparison: "exact-or-sha256",
|
|
143
133
|
},
|
|
144
134
|
refreshAccessToken: options.refreshAccessToken
|
|
145
135
|
? options.refreshAccessToken
|