@better-auth/oauth-provider 1.7.0-beta.5 → 1.7.0-beta.7
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/{client-assertion-DmT1B6_6.mjs → client-assertion-CctbJywV.mjs} +88 -64
- package/dist/client-resource.d.mts +17 -2
- package/dist/client-resource.mjs +45 -25
- package/dist/client.d.mts +1 -1
- package/dist/client.mjs +3 -13
- package/dist/index.d.mts +100 -17
- package/dist/index.mjs +1239 -1699
- package/dist/introspect-BXNvkz8S.mjs +2119 -0
- package/dist/{oauth-BXrYl5x6.d.mts → oauth-CPWY2Few.d.mts} +836 -33
- package/dist/{oauth-DU6NeviY.d.mts → oauth-CqOygaZd.d.mts} +265 -148
- package/dist/resource-challenge-B-cqv4ur.mjs +63 -0
- package/dist/rolldown-runtime-wcPFST8Q.mjs +13 -0
- package/dist/signed-query-CFv2jNMT.mjs +44 -0
- package/dist/{utils-D2dLqo7f.mjs → utils-Baq6atYN.mjs} +310 -68
- package/dist/{version-B1ZiRmxj.mjs → version-DkFgXWfN.mjs} +1 -1
- package/package.json +7 -8
- package/dist/mcp-CYnz-MXn.mjs +0 -56
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isAPIError } from "better-auth/api";
|
|
2
|
+
import { APIError as APIError$1 } from "better-call";
|
|
3
|
+
import { DPOP_SIGNING_ALGORITHMS } from "better-auth/oauth2";
|
|
4
|
+
//#region src/resource-challenge.ts
|
|
5
|
+
const DPOP_CHALLENGE_ERRORS = new Set(["invalid_dpop_proof"]);
|
|
6
|
+
function quoteAuthParam(value) {
|
|
7
|
+
return value.replace(/[\r\n]+/g, " ").replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
8
|
+
}
|
|
9
|
+
function extractDpopError(error) {
|
|
10
|
+
const body = error.body;
|
|
11
|
+
return {
|
|
12
|
+
errorCode: typeof body?.error === "string" ? body.error : void 0,
|
|
13
|
+
description: typeof body?.error_description === "string" ? body.error_description : typeof body?.message === "string" ? body.message : error.message
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function isDpopChallengeError(error) {
|
|
17
|
+
const { errorCode, description } = extractDpopError(error);
|
|
18
|
+
return !!errorCode && (DPOP_CHALLENGE_ERRORS.has(errorCode) || errorCode === "invalid_token" && description.includes("DPoP"));
|
|
19
|
+
}
|
|
20
|
+
function buildDpopChallenge(error, opts) {
|
|
21
|
+
const { errorCode, description } = extractDpopError(error);
|
|
22
|
+
const algorithms = opts?.dpopSigningAlgorithms ?? DPOP_SIGNING_ALGORITHMS;
|
|
23
|
+
return [
|
|
24
|
+
`DPoP error="${quoteAuthParam(errorCode ?? "invalid_dpop_proof")}"`,
|
|
25
|
+
`error_description="${quoteAuthParam(description)}"`,
|
|
26
|
+
`algs="${quoteAuthParam(algorithms.join(" "))}"`
|
|
27
|
+
].join(", ");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Raise an OAuth resource-server challenge for a failed access-token request.
|
|
31
|
+
*
|
|
32
|
+
* Missing/invalid bearer credentials are reported with RFC 6750 plus the RFC
|
|
33
|
+
* 9728 `resource_metadata` pointer. DPoP-bound-token failures are reported with
|
|
34
|
+
* RFC 9449's `DPoP` challenge so clients know which proof algorithms to use.
|
|
35
|
+
* Non-URL resources (for example a `urn:` or a client id) resolve their
|
|
36
|
+
* metadata URL through `resourceMetadataMappings`.
|
|
37
|
+
*
|
|
38
|
+
* @internal
|
|
39
|
+
*/
|
|
40
|
+
function raiseResourceServerChallenge(error, resource, opts) {
|
|
41
|
+
if (isAPIError(error) && error.status === "UNAUTHORIZED") {
|
|
42
|
+
if (isDpopChallengeError(error)) throw new APIError$1("UNAUTHORIZED", { message: error.message }, { "WWW-Authenticate": buildDpopChallenge(error, opts) });
|
|
43
|
+
const wwwAuthenticateValue = (Array.isArray(resource) ? resource : [resource]).map((value) => {
|
|
44
|
+
const url = URL.canParse?.(value) ? new URL(value) : null;
|
|
45
|
+
if (url && url.origin !== "null") {
|
|
46
|
+
const resourcePath = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
|
|
47
|
+
let challenge = `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${resourcePath}${url.search}"`;
|
|
48
|
+
if (opts?.scope) challenge += `, scope="${quoteAuthParam(opts.scope)}"`;
|
|
49
|
+
return challenge;
|
|
50
|
+
}
|
|
51
|
+
const resourceMetadata = opts?.resourceMetadataMappings?.[value];
|
|
52
|
+
if (!resourceMetadata) throw new APIError$1("INTERNAL_SERVER_ERROR", { message: `missing resource_metadata mapping for ${value}` });
|
|
53
|
+
let challenge = `Bearer resource_metadata="${resourceMetadata}"`;
|
|
54
|
+
if (opts?.scope) challenge += `, scope="${quoteAuthParam(opts.scope)}"`;
|
|
55
|
+
return challenge;
|
|
56
|
+
}).join(", ");
|
|
57
|
+
throw new APIError$1("UNAUTHORIZED", { message: error.message }, { "WWW-Authenticate": wwwAuthenticateValue });
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof Error) throw error;
|
|
60
|
+
throw new Error(error);
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
export { raiseResourceServerChallenge as t };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll as t };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/signed-query.ts
|
|
2
|
+
const signedQueryIssuedAtParam = "ba_iat";
|
|
3
|
+
const postLoginClearedParam = "ba_pl";
|
|
4
|
+
const signedQueryParameterNameParam = "ba_param";
|
|
5
|
+
function canonicalizeOAuthQueryParams(params) {
|
|
6
|
+
const canonicalParams = new URLSearchParams();
|
|
7
|
+
const entries = [...params.entries()].sort(([keyA, valueA], [keyB, valueB]) => {
|
|
8
|
+
if (keyA < keyB) return -1;
|
|
9
|
+
if (keyA > keyB) return 1;
|
|
10
|
+
if (valueA < valueB) return -1;
|
|
11
|
+
if (valueA > valueB) return 1;
|
|
12
|
+
return 0;
|
|
13
|
+
});
|
|
14
|
+
for (const [key, value] of entries) canonicalParams.append(key, value);
|
|
15
|
+
return canonicalParams;
|
|
16
|
+
}
|
|
17
|
+
function setSignedOAuthQueryParameterNames(params) {
|
|
18
|
+
params.delete(signedQueryParameterNameParam);
|
|
19
|
+
const signedParameterNames = [...new Set([...params.keys(), signedQueryParameterNameParam])].sort();
|
|
20
|
+
for (const parameterName of signedParameterNames) params.append(signedQueryParameterNameParam, parameterName);
|
|
21
|
+
}
|
|
22
|
+
function getSignedOAuthQueryParameterNames(params) {
|
|
23
|
+
const signedParameterNames = params.getAll(signedQueryParameterNameParam);
|
|
24
|
+
if (!signedParameterNames.length) return;
|
|
25
|
+
return new Set(signedParameterNames);
|
|
26
|
+
}
|
|
27
|
+
function buildSignedOAuthQuery(search) {
|
|
28
|
+
const params = new URLSearchParams(search);
|
|
29
|
+
if (!params.has("sig")) return;
|
|
30
|
+
const signedParameterNames = getSignedOAuthQueryParameterNames(params);
|
|
31
|
+
if (!signedParameterNames) return;
|
|
32
|
+
const signedParams = new URLSearchParams();
|
|
33
|
+
for (const [key, value] of params.entries()) if (key === "sig" || key === signedQueryParameterNameParam || signedParameterNames.has(key)) signedParams.append(key, value);
|
|
34
|
+
return signedParams.toString();
|
|
35
|
+
}
|
|
36
|
+
function getSignedQueryIssuedAt(oauthQuery) {
|
|
37
|
+
const raw = new URLSearchParams(oauthQuery).get(signedQueryIssuedAtParam);
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
const issuedAt = Number(raw);
|
|
40
|
+
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return null;
|
|
41
|
+
return new Date(issuedAt);
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
export { setSignedOAuthQueryParameterNames as a, postLoginClearedParam as i, canonicalizeOAuthQueryParams as n, signedQueryIssuedAtParam as o, getSignedQueryIssuedAt as r, buildSignedOAuthQuery as t };
|
|
@@ -1,10 +1,262 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { decodeBasicCredentials } from "@better-auth/core/oauth2";
|
|
1
|
+
import { n as canonicalizeOAuthQueryParams } from "./signed-query-CFv2jNMT.mjs";
|
|
3
2
|
import { constantTimeEqual, makeSignature, symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto";
|
|
3
|
+
import { APIError } from "better-call";
|
|
4
|
+
import { logger } from "@better-auth/core/env";
|
|
4
5
|
import { BetterAuthError } from "@better-auth/core/error";
|
|
6
|
+
import { CLIENT_ASSERTION_TYPE, decodeBasicCredentials } from "@better-auth/core/oauth2";
|
|
5
7
|
import { base64Url } from "@better-auth/utils/base64";
|
|
6
8
|
import { createHash } from "@better-auth/utils/hash";
|
|
9
|
+
//#region src/extensions.ts
|
|
10
|
+
const DEFAULT_GRANT_TYPES = [
|
|
11
|
+
"authorization_code",
|
|
12
|
+
"client_credentials",
|
|
13
|
+
"refresh_token"
|
|
14
|
+
];
|
|
15
|
+
const BUILT_IN_CONFIDENTIAL_AUTH_METHODS = [
|
|
16
|
+
"client_secret_basic",
|
|
17
|
+
"client_secret_post",
|
|
18
|
+
"private_key_jwt"
|
|
19
|
+
];
|
|
20
|
+
const RESERVED_TOKEN_ENDPOINT_AUTH_METHODS = ["none", ...BUILT_IN_CONFIDENTIAL_AUTH_METHODS];
|
|
21
|
+
const RESERVED_TOKEN_ENDPOINT_AUTH_METHOD_SET = new Set(RESERVED_TOKEN_ENDPOINT_AUTH_METHODS);
|
|
22
|
+
function assertNonEmptyExtensionValue(name, value) {
|
|
23
|
+
if (value.trim().length > 0) return;
|
|
24
|
+
throw new BetterAuthError(`OAuth Provider extension ${name} cannot be empty`);
|
|
25
|
+
}
|
|
26
|
+
function assertAbsoluteUri(name, value) {
|
|
27
|
+
assertNonEmptyExtensionValue(name, value);
|
|
28
|
+
let url;
|
|
29
|
+
try {
|
|
30
|
+
url = new URL(value);
|
|
31
|
+
} catch {
|
|
32
|
+
url = void 0;
|
|
33
|
+
}
|
|
34
|
+
if (url?.protocol) return;
|
|
35
|
+
throw new BetterAuthError(`OAuth Provider extension ${name} must be an absolute URI: ${value}`);
|
|
36
|
+
}
|
|
37
|
+
function assertExtensionGrantType(grantType) {
|
|
38
|
+
assertAbsoluteUri("grant type", grantType);
|
|
39
|
+
}
|
|
40
|
+
function assertExtensionTokenEndpointAuthMethod(method) {
|
|
41
|
+
assertNonEmptyExtensionValue("token_endpoint_auth_method", method);
|
|
42
|
+
if (!RESERVED_TOKEN_ENDPOINT_AUTH_METHOD_SET.has(method)) return;
|
|
43
|
+
throw new BetterAuthError(`OAuth Provider extension token_endpoint_auth_method is reserved: ${method}`);
|
|
44
|
+
}
|
|
45
|
+
function assertExtensionClientAssertionType(assertionType) {
|
|
46
|
+
assertAbsoluteUri("client_assertion_type", assertionType);
|
|
47
|
+
if (assertionType !== CLIENT_ASSERTION_TYPE) return;
|
|
48
|
+
throw new BetterAuthError(`OAuth Provider extension client_assertion_type is reserved: ${assertionType}`);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validates one extension's dispatched keys (grant types, auth methods,
|
|
52
|
+
* assertion types) and returns them for the cross-extension disjointness check.
|
|
53
|
+
* Throws on a non-absolute grant/assertion URI, a reserved auth-method name, or
|
|
54
|
+
* an empty assertion-type list.
|
|
55
|
+
*/
|
|
56
|
+
function collectExtensionKeys(extension) {
|
|
57
|
+
const grantTypes = Object.keys(extension.grants ?? {});
|
|
58
|
+
for (const grantType of grantTypes) assertExtensionGrantType(grantType);
|
|
59
|
+
const authMethods = [];
|
|
60
|
+
const assertionTypes = [];
|
|
61
|
+
for (const [method, strategy] of Object.entries(extension.clientAuthentication ?? {})) {
|
|
62
|
+
assertExtensionTokenEndpointAuthMethod(method);
|
|
63
|
+
authMethods.push(method);
|
|
64
|
+
const methodAssertionTypes = strategy.assertionTypes ?? [method];
|
|
65
|
+
if (methodAssertionTypes.length === 0) throw new BetterAuthError(`OAuth Provider extension client_assertion_type list cannot be empty for ${method}`);
|
|
66
|
+
for (const assertionType of methodAssertionTypes) {
|
|
67
|
+
assertExtensionClientAssertionType(assertionType);
|
|
68
|
+
assertionTypes.push(assertionType);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
grantTypes,
|
|
73
|
+
authMethods,
|
|
74
|
+
assertionTypes
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function assertNoDuplicateAcrossExtensions(label, values) {
|
|
78
|
+
const seen = /* @__PURE__ */ new Set();
|
|
79
|
+
for (const value of values) {
|
|
80
|
+
if (seen.has(value)) throw new BetterAuthError(`OAuth Provider extensions register ${label} "${value}" more than once. Extension contributions must be disjoint.`);
|
|
81
|
+
seen.add(value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Validates every extension and rejects two extensions registering the same
|
|
86
|
+
* grant type, auth method, or assertion type: otherwise the first would win and
|
|
87
|
+
* the second be silently unreachable. Runs at setup over the whole list;
|
|
88
|
+
* extensions number in the single digits, so a full re-scan per registration is
|
|
89
|
+
* cheaper than the bookkeeping to cache it.
|
|
90
|
+
*/
|
|
91
|
+
function validateOAuthProviderExtensions(extensions) {
|
|
92
|
+
const keys = (extensions ?? []).map(collectExtensionKeys);
|
|
93
|
+
assertNoDuplicateAcrossExtensions("grant type", keys.flatMap((k) => k.grantTypes));
|
|
94
|
+
assertNoDuplicateAcrossExtensions("token_endpoint_auth_method", keys.flatMap((k) => k.authMethods));
|
|
95
|
+
assertNoDuplicateAcrossExtensions("client_assertion_type", keys.flatMap((k) => k.assertionTypes));
|
|
96
|
+
}
|
|
97
|
+
function getOAuthProviderExtensions(opts) {
|
|
98
|
+
return opts.extensions ?? [];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Flattens the client-id discovery sources contributed by every registered
|
|
102
|
+
* extension into a single ordered list. `getClient()` consults them in order;
|
|
103
|
+
* the metadata endpoints merge their `discoveryMetadata`.
|
|
104
|
+
*/
|
|
105
|
+
function getClientDiscoveries(opts) {
|
|
106
|
+
return getOAuthProviderExtensions(opts).flatMap((extension) => {
|
|
107
|
+
const discovery = extension.clientDiscovery;
|
|
108
|
+
if (!discovery) return [];
|
|
109
|
+
return Array.isArray(discovery) ? discovery : [discovery];
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Registers an {@link OAuthProviderExtension} with the OAuth Provider plugin
|
|
114
|
+
* from a companion plugin's `init()` hook. An extension can add token grants,
|
|
115
|
+
* assertion-based client authentication methods, additive discovery metadata,
|
|
116
|
+
* access-token / ID-token / UserInfo claims, and client-id discovery, without
|
|
117
|
+
* forking provider core.
|
|
118
|
+
*
|
|
119
|
+
* Call this once, at `init()` time. It is idempotent in the same `extension`
|
|
120
|
+
* object, so re-running a plugin's `init()` (for example when one plugin factory
|
|
121
|
+
* result is shared across two `betterAuth()` instances) does not register it
|
|
122
|
+
* twice. It throws if the oauth-provider plugin is not installed, if a grant
|
|
123
|
+
* type or assertion type is not an absolute URI, if a client authentication
|
|
124
|
+
* method reuses a built-in name, or if the extension registers a grant type,
|
|
125
|
+
* auth method, or assertion type that another extension already registered
|
|
126
|
+
* (contributions must be disjoint).
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* ```ts
|
|
130
|
+
* init(ctx) {
|
|
131
|
+
* extendOAuthProvider(ctx, {
|
|
132
|
+
* grants: { "urn:example:grant": async ({ provider }) => provider.issueTokens(...) },
|
|
133
|
+
* });
|
|
134
|
+
* }
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
function extendOAuthProvider(ctx, extension) {
|
|
138
|
+
const provider = ctx.getPlugin("oauth-provider");
|
|
139
|
+
if (!provider) throw new BetterAuthError("extendOAuthProvider requires the oauth-provider plugin.");
|
|
140
|
+
const existing = provider.options.extensions ?? [];
|
|
141
|
+
if (existing.includes(extension)) return;
|
|
142
|
+
const extensions = [...existing, extension];
|
|
143
|
+
validateOAuthProviderExtensions(extensions);
|
|
144
|
+
provider.options.extensions = extensions;
|
|
145
|
+
}
|
|
146
|
+
function getExtensionGrantTypes(opts) {
|
|
147
|
+
return getOAuthProviderExtensions(opts).flatMap((extension) => Object.keys(extension.grants ?? {}));
|
|
148
|
+
}
|
|
149
|
+
function getSupportedGrantTypes(opts) {
|
|
150
|
+
return Array.from(new Set([...opts.grantTypes ?? DEFAULT_GRANT_TYPES, ...getExtensionGrantTypes(opts)]));
|
|
151
|
+
}
|
|
152
|
+
function getExtensionGrantHandler(opts, grantType) {
|
|
153
|
+
for (const extension of getOAuthProviderExtensions(opts)) {
|
|
154
|
+
const handler = extension.grants?.[grantType];
|
|
155
|
+
if (handler) return handler;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function getExtensionTokenEndpointAuthMethods(opts) {
|
|
159
|
+
return getOAuthProviderExtensions(opts).flatMap((extension) => Object.keys(extension.clientAuthentication ?? {}));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Confidential and extension client-authentication methods the provider
|
|
163
|
+
* supports. Pass `includeNone` to prepend `"none"` for the token endpoint and
|
|
164
|
+
* DCR, where public clients are allowed; the introspection and revocation
|
|
165
|
+
* endpoints, which never accept public clients, omit it (the default).
|
|
166
|
+
*/
|
|
167
|
+
function getSupportedAuthMethods(opts, settings) {
|
|
168
|
+
return Array.from(new Set([
|
|
169
|
+
...settings?.includeNone ? ["none"] : [],
|
|
170
|
+
...BUILT_IN_CONFIDENTIAL_AUTH_METHODS,
|
|
171
|
+
...getExtensionTokenEndpointAuthMethods(opts)
|
|
172
|
+
]));
|
|
173
|
+
}
|
|
174
|
+
function isExtensionTokenEndpointAuthMethod(opts, method) {
|
|
175
|
+
return method ? getExtensionTokenEndpointAuthMethods(opts).includes(method) : false;
|
|
176
|
+
}
|
|
177
|
+
function getExtensionClientAuthenticationStrategy(opts, assertionType) {
|
|
178
|
+
if (assertionType === CLIENT_ASSERTION_TYPE) return void 0;
|
|
179
|
+
for (const extension of getOAuthProviderExtensions(opts)) {
|
|
180
|
+
const strategies = extension.clientAuthentication ?? {};
|
|
181
|
+
for (const [method, strategy] of Object.entries(strategies)) if ((strategy.assertionTypes ?? [method]).includes(assertionType)) return {
|
|
182
|
+
method,
|
|
183
|
+
strategy
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Merges each registered extension's `metadata()` contribution into `document`,
|
|
189
|
+
* first-wins: the provider owns every key it already wrote, so an extension can
|
|
190
|
+
* add fields but never override core. Each contributor sees the base `document`,
|
|
191
|
+
* not the running accumulation, so contributions stay order-independent.
|
|
192
|
+
*/
|
|
193
|
+
function applyOAuthProviderMetadataExtensions(ctx, opts, type, document) {
|
|
194
|
+
const next = { ...document };
|
|
195
|
+
for (const extension of getOAuthProviderExtensions(opts)) {
|
|
196
|
+
const contribution = extension.metadata?.({
|
|
197
|
+
ctx,
|
|
198
|
+
opts,
|
|
199
|
+
type,
|
|
200
|
+
document
|
|
201
|
+
});
|
|
202
|
+
for (const [key, value] of Object.entries(contribution ?? {})) if (!(key in next)) next[key] = value;
|
|
203
|
+
}
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
206
|
+
async function collectClaims(opts, run) {
|
|
207
|
+
const claims = {};
|
|
208
|
+
for (const extension of getOAuthProviderExtensions(opts)) {
|
|
209
|
+
const contribution = await run(extension) ?? {};
|
|
210
|
+
for (const [key, value] of Object.entries(contribution)) {
|
|
211
|
+
if (key in claims) {
|
|
212
|
+
logger.warn(`oauth-provider: two extensions contributed the claim "${key}"; keeping the first-registered value.`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
claims[key] = value;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return claims;
|
|
219
|
+
}
|
|
220
|
+
function collectExtensionAccessTokenClaims(opts, input) {
|
|
221
|
+
return collectClaims(opts, (extension) => extension.claims?.accessToken?.(input));
|
|
222
|
+
}
|
|
223
|
+
function collectExtensionIdTokenClaims(opts, input) {
|
|
224
|
+
return collectClaims(opts, (extension) => extension.claims?.idToken?.(input));
|
|
225
|
+
}
|
|
226
|
+
function collectExtensionUserInfoClaims(opts, input) {
|
|
227
|
+
return collectClaims(opts, (extension) => extension.claims?.userInfo?.(input));
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether any registered extension contributes UserInfo claims. Lets the
|
|
231
|
+
* UserInfo endpoint skip loading the client when nothing needs it.
|
|
232
|
+
*/
|
|
233
|
+
function hasUserInfoClaimExtension(opts) {
|
|
234
|
+
return getOAuthProviderExtensions(opts).some((extension) => extension.claims?.userInfo);
|
|
235
|
+
}
|
|
236
|
+
//#endregion
|
|
7
237
|
//#region src/utils/index.ts
|
|
238
|
+
/**
|
|
239
|
+
* Extracts the credentials from an `Authorization: Bearer <token>` header.
|
|
240
|
+
*
|
|
241
|
+
* Returns `undefined` when the header is absent or carries a non-Bearer scheme,
|
|
242
|
+
* leaving the caller to decide whether that is an error. Throws an
|
|
243
|
+
* `invalid_request` `APIError` when the Bearer scheme is present but the
|
|
244
|
+
* credentials are missing or the header carries extra parts. The scheme match
|
|
245
|
+
* is case-insensitive and the credentials are the single token after it.
|
|
246
|
+
*
|
|
247
|
+
* @see https://datatracker.ietf.org/doc/html/rfc6750#section-2.1
|
|
248
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7235#section-2.1
|
|
249
|
+
*/
|
|
250
|
+
function parseBearerToken(authorization) {
|
|
251
|
+
if (!authorization) return void 0;
|
|
252
|
+
const [scheme, credentials, ...extraParts] = authorization.trim().split(/\s+/);
|
|
253
|
+
if (scheme?.toLowerCase() !== "bearer") return void 0;
|
|
254
|
+
if (!credentials || extraParts.length > 0) throw new APIError("BAD_REQUEST", {
|
|
255
|
+
error: "invalid_request",
|
|
256
|
+
error_description: "Malformed Bearer Authorization header"
|
|
257
|
+
});
|
|
258
|
+
return credentials;
|
|
259
|
+
}
|
|
8
260
|
var TTLCache = class {
|
|
9
261
|
cache = /* @__PURE__ */ new Map();
|
|
10
262
|
constructor() {}
|
|
@@ -91,39 +343,15 @@ function toAudienceClaim(audience) {
|
|
|
91
343
|
if (!audience?.length) return void 0;
|
|
92
344
|
return audience.length === 1 ? audience.at(0) : audience;
|
|
93
345
|
}
|
|
94
|
-
/**
|
|
95
|
-
* Checks the resource parameter, if provided,
|
|
96
|
-
* and returns either a valid audience or a tagged validation error.
|
|
97
|
-
*/
|
|
98
|
-
async function checkResource(ctx, opts, resource, scopes) {
|
|
99
|
-
const normalizedResource = toResourceList(resource);
|
|
100
|
-
const audience = normalizedResource ? [...normalizedResource] : void 0;
|
|
101
|
-
if (audience) {
|
|
102
|
-
const hasOpenId = scopes.includes("openid");
|
|
103
|
-
const baseUrl = ctx.context.baseURL;
|
|
104
|
-
const userInfoEndpoint = `${baseUrl}/oauth2/userinfo`;
|
|
105
|
-
if (hasOpenId && !audience.includes(userInfoEndpoint)) audience.push(userInfoEndpoint);
|
|
106
|
-
const filteredValidAudiences = opts.validAudiences?.filter((aud) => aud.length);
|
|
107
|
-
const validAudiences = new Set(filteredValidAudiences?.length ? filteredValidAudiences : [baseUrl]);
|
|
108
|
-
if (hasOpenId) validAudiences.add(userInfoEndpoint);
|
|
109
|
-
for (const aud of audience) if (!validAudiences.has(aud)) return {
|
|
110
|
-
success: false,
|
|
111
|
-
error: "invalid_resource"
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
return {
|
|
115
|
-
success: true,
|
|
116
|
-
audience: toAudienceClaim(audience)
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
346
|
const cachedTrustedClients = new TTLCache();
|
|
120
347
|
async function verifyOAuthQueryParams(oauth_query, secret) {
|
|
121
348
|
const queryParams = new URLSearchParams(oauth_query);
|
|
122
349
|
const sig = queryParams.get("sig");
|
|
350
|
+
const sigs = queryParams.getAll("sig");
|
|
123
351
|
const exp = Number(queryParams.get("exp"));
|
|
124
352
|
queryParams.delete("sig");
|
|
125
|
-
const verifySig = await makeSignature(queryParams.toString(), secret);
|
|
126
|
-
return !!sig && constantTimeEqual(sig, verifySig) && /* @__PURE__ */ new Date(exp * 1e3) >= /* @__PURE__ */ new Date();
|
|
353
|
+
const verifySig = await makeSignature(canonicalizeOAuthQueryParams(queryParams).toString(), secret);
|
|
354
|
+
return sigs.length === 1 && !!sig && constantTimeEqual(sig, verifySig) && /* @__PURE__ */ new Date(exp * 1e3) >= /* @__PURE__ */ new Date();
|
|
127
355
|
}
|
|
128
356
|
/**
|
|
129
357
|
* Get a client by ID, checking trusted clients first, then database
|
|
@@ -138,7 +366,7 @@ async function getClient(ctx, options, clientId) {
|
|
|
138
366
|
value: clientId
|
|
139
367
|
}]
|
|
140
368
|
});
|
|
141
|
-
const discoveries =
|
|
369
|
+
const discoveries = getClientDiscoveries(options);
|
|
142
370
|
for (const discovery of discoveries) {
|
|
143
371
|
if (!discovery.matches(clientId)) continue;
|
|
144
372
|
const resolved = await discovery.resolve(ctx, clientId, dbClient);
|
|
@@ -151,24 +379,14 @@ async function getClient(ctx, options, clientId) {
|
|
|
151
379
|
return dbClient;
|
|
152
380
|
}
|
|
153
381
|
/**
|
|
154
|
-
*
|
|
155
|
-
* {@link ClientDiscovery}, an array of them, or `undefined`.
|
|
156
|
-
*
|
|
157
|
-
* @internal
|
|
158
|
-
*/
|
|
159
|
-
function toClientDiscoveryArray(discovery) {
|
|
160
|
-
if (!discovery) return [];
|
|
161
|
-
return Array.isArray(discovery) ? discovery : [discovery];
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Merge `discoveryMetadata` from every configured {@link ClientDiscovery}
|
|
382
|
+
* Merge `discoveryMetadata` from every contributed {@link ClientDiscovery}
|
|
165
383
|
* into a single object. Entries are spread in order; later entries override
|
|
166
384
|
* earlier ones on key collisions.
|
|
167
385
|
*
|
|
168
386
|
* @internal
|
|
169
387
|
*/
|
|
170
|
-
function mergeDiscoveryMetadata(
|
|
171
|
-
return
|
|
388
|
+
function mergeDiscoveryMetadata(discoveries) {
|
|
389
|
+
return discoveries.reduce((acc, d) => ({
|
|
172
390
|
...acc,
|
|
173
391
|
...d.discoveryMetadata ?? {}
|
|
174
392
|
}), {});
|
|
@@ -302,13 +520,16 @@ function clientAllowsGrant(client, grantType) {
|
|
|
302
520
|
return allowedGrants.includes(grantType);
|
|
303
521
|
}
|
|
304
522
|
/**
|
|
305
|
-
*
|
|
306
|
-
* and
|
|
523
|
+
* Resolves the registered client by id and authorizes it: existence, disabled
|
|
524
|
+
* state, registered auth method, requested scopes, and grant type. The record is
|
|
525
|
+
* always resolved here via `getClient`, so a client-auth strategy proves the
|
|
526
|
+
* caller controls `clientId` but never supplies the record. `preVerified` marks
|
|
527
|
+
* that an assertion already proved control, so the client-secret check is skipped.
|
|
307
528
|
*
|
|
308
529
|
* @internal
|
|
309
530
|
*/
|
|
310
|
-
async function validateClientCredentials(ctx, options, clientId, clientSecret, scopes,
|
|
311
|
-
const client =
|
|
531
|
+
async function validateClientCredentials(ctx, options, clientId, clientSecret, scopes, preVerified, grantType, authMethod) {
|
|
532
|
+
const client = await getClient(ctx, options, clientId);
|
|
312
533
|
if (!client) throw new APIError("BAD_REQUEST", {
|
|
313
534
|
error_description: "missing client",
|
|
314
535
|
error: "invalid_client"
|
|
@@ -317,11 +538,18 @@ async function validateClientCredentials(ctx, options, clientId, clientSecret, s
|
|
|
317
538
|
error_description: "client is disabled",
|
|
318
539
|
error: "invalid_client"
|
|
319
540
|
});
|
|
320
|
-
if (
|
|
321
|
-
|
|
541
|
+
if (preVerified && authMethod) {
|
|
542
|
+
const registeredAuthMethod = client.tokenEndpointAuthMethod ?? "client_secret_basic";
|
|
543
|
+
if (registeredAuthMethod !== authMethod) throw new APIError("BAD_REQUEST", {
|
|
544
|
+
error_description: `client registered for ${registeredAuthMethod} cannot use ${authMethod}`,
|
|
545
|
+
error: "invalid_client"
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
if ((client.tokenEndpointAuthMethod === "private_key_jwt" || isExtensionTokenEndpointAuthMethod(options, client.tokenEndpointAuthMethod)) && !preVerified) throw new APIError("BAD_REQUEST", {
|
|
549
|
+
error_description: `client registered for ${client.tokenEndpointAuthMethod} must use client_assertion`,
|
|
322
550
|
error: "invalid_client"
|
|
323
551
|
});
|
|
324
|
-
if (!
|
|
552
|
+
if (!preVerified) {
|
|
325
553
|
if (!client.public && !clientSecret) throw new APIError("BAD_REQUEST", {
|
|
326
554
|
error_description: "client secret must be provided",
|
|
327
555
|
error: "invalid_client"
|
|
@@ -362,8 +590,10 @@ function parseClientMetadata(metadata) {
|
|
|
362
590
|
function destructureCredentials(credentials) {
|
|
363
591
|
return {
|
|
364
592
|
clientId: credentials?.clientId,
|
|
365
|
-
clientSecret: credentials?.
|
|
366
|
-
|
|
593
|
+
clientSecret: credentials?.kind === "client_secret" ? credentials.clientSecret : void 0,
|
|
594
|
+
preVerified: credentials?.kind === "pre_verified",
|
|
595
|
+
authMethod: credentials?.method,
|
|
596
|
+
confirmation: credentials?.kind === "pre_verified" ? credentials.confirmation : void 0
|
|
367
597
|
};
|
|
368
598
|
}
|
|
369
599
|
/**
|
|
@@ -378,32 +608,53 @@ async function extractClientCredentials(ctx, opts, expectedAudience) {
|
|
|
378
608
|
error_description: "client_assertion and client_assertion_type must both be provided",
|
|
379
609
|
error: "invalid_client"
|
|
380
610
|
});
|
|
381
|
-
if (body.client_secret || authorization
|
|
611
|
+
if (body.client_secret || authorization && BASIC_SCHEME_PREFIX.test(authorization)) throw new APIError("BAD_REQUEST", {
|
|
382
612
|
error_description: "client_assertion cannot be combined with client_secret or Basic auth",
|
|
383
613
|
error: "invalid_client"
|
|
384
614
|
});
|
|
385
|
-
const
|
|
386
|
-
const
|
|
615
|
+
const assertion = body.client_assertion;
|
|
616
|
+
const assertionType = body.client_assertion_type;
|
|
617
|
+
const extensionStrategy = getExtensionClientAuthenticationStrategy(opts, assertionType);
|
|
618
|
+
if (extensionStrategy) {
|
|
619
|
+
const result = await extensionStrategy.strategy.authenticate({
|
|
620
|
+
ctx,
|
|
621
|
+
opts,
|
|
622
|
+
assertion,
|
|
623
|
+
assertionType,
|
|
624
|
+
clientId: body.client_id,
|
|
625
|
+
expectedAudience
|
|
626
|
+
});
|
|
627
|
+
return {
|
|
628
|
+
kind: "pre_verified",
|
|
629
|
+
method: extensionStrategy.method,
|
|
630
|
+
clientId: result.clientId,
|
|
631
|
+
confirmation: result.confirmation
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
const { verifyClientAssertion: verify } = await import("./client-assertion-CctbJywV.mjs").then((n) => n.t);
|
|
387
635
|
return {
|
|
636
|
+
kind: "pre_verified",
|
|
388
637
|
method: "private_key_jwt",
|
|
389
|
-
clientId:
|
|
390
|
-
client: result.client
|
|
638
|
+
clientId: (await verify(ctx, opts, assertion, assertionType, body.client_id, expectedAudience)).clientId
|
|
391
639
|
};
|
|
392
640
|
}
|
|
393
|
-
if (authorization
|
|
641
|
+
if (authorization && BASIC_SCHEME_PREFIX.test(authorization)) {
|
|
394
642
|
const res = basicToClientCredentials(authorization);
|
|
395
643
|
if (res) return {
|
|
644
|
+
kind: "client_secret",
|
|
396
645
|
method: "client_secret_basic",
|
|
397
646
|
clientId: res.client_id,
|
|
398
647
|
clientSecret: res.client_secret
|
|
399
648
|
};
|
|
400
649
|
}
|
|
401
650
|
if (body.client_id && body.client_secret) return {
|
|
651
|
+
kind: "client_secret",
|
|
402
652
|
method: "client_secret_post",
|
|
403
653
|
clientId: body.client_id,
|
|
404
654
|
clientSecret: body.client_secret
|
|
405
655
|
};
|
|
406
656
|
if (body.client_id) return {
|
|
657
|
+
kind: "public",
|
|
407
658
|
method: "none",
|
|
408
659
|
clientId: body.client_id
|
|
409
660
|
};
|
|
@@ -462,15 +713,6 @@ function searchParamsToQuery(params) {
|
|
|
462
713
|
}
|
|
463
714
|
return result;
|
|
464
715
|
}
|
|
465
|
-
const signedQueryIssuedAtParam = "ba_iat";
|
|
466
|
-
const postLoginClearedParam = "ba_pl";
|
|
467
|
-
function getSignedQueryIssuedAt(oauthQuery) {
|
|
468
|
-
const raw = new URLSearchParams(oauthQuery).get(signedQueryIssuedAtParam);
|
|
469
|
-
if (!raw) return null;
|
|
470
|
-
const issuedAt = Number(raw);
|
|
471
|
-
if (!Number.isFinite(issuedAt) || issuedAt <= 0) return null;
|
|
472
|
-
return new Date(issuedAt);
|
|
473
|
-
}
|
|
474
716
|
function isSessionFreshForSignedQuery(sessionCreatedAt, signedQueryIssuedAt) {
|
|
475
717
|
if (!signedQueryIssuedAt) return false;
|
|
476
718
|
const normalized = normalizeTimestampValue(sessionCreatedAt);
|
|
@@ -519,4 +761,4 @@ function isPKCERequired(client, requestedScopes) {
|
|
|
519
761
|
return false;
|
|
520
762
|
}
|
|
521
763
|
//#endregion
|
|
522
|
-
export {
|
|
764
|
+
export { collectExtensionUserInfoClaims as A, toAudienceClaim as C, applyOAuthProviderMetadataExtensions as D, verifyOAuthQueryParams as E, getSupportedGrantTypes as F, hasUserInfoClaimExtension as I, isExtensionTokenEndpointAuthMethod as L, getClientDiscoveries as M, getExtensionGrantHandler as N, collectExtensionAccessTokenClaims as O, getSupportedAuthMethods as P, validateOAuthProviderExtensions as R, storeToken as S, validateClientCredentials as T, removePromptFromQuery as _, getClient as a, searchParamsToQuery as b, getStoredToken as c, mergeDiscoveryMetadata as d, normalizeTimestampValue as f, removeMaxAgeFromQuery as g, parsePrompt as h, extractClientCredentials as i, extendOAuthProvider as j, collectExtensionIdTokenClaims as k, isPKCERequired as l, parseClientMetadata as m, decryptStoredClientSecret as n, getJwtPlugin as o, parseBearerToken as p, destructureCredentials as r, getOAuthProviderPlugin as s, clientAllowsGrant as t, isSessionFreshForSignedQuery as u, resolveSessionAuthTime as v, toResourceList as w, storeClientSecret as x, resolveSubjectIdentifier as y };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/oauth-provider",
|
|
3
|
-
"version": "1.7.0-beta.
|
|
3
|
+
"version": "1.7.0-beta.7",
|
|
4
4
|
"description": "An oauth provider plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -61,18 +61,17 @@
|
|
|
61
61
|
"zod": "^4.3.6"
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
|
-
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
65
64
|
"listhen": "^1.9.0",
|
|
66
65
|
"tsdown": "0.21.1",
|
|
67
|
-
"@better-auth/core": "1.7.0-beta.
|
|
68
|
-
"better-auth": "1.7.0-beta.
|
|
66
|
+
"@better-auth/core": "1.7.0-beta.7",
|
|
67
|
+
"better-auth": "1.7.0-beta.7"
|
|
69
68
|
},
|
|
70
69
|
"peerDependencies": {
|
|
71
|
-
"@better-auth/utils": "0.4.
|
|
72
|
-
"@better-fetch/fetch": "1.
|
|
70
|
+
"@better-auth/utils": "0.4.2",
|
|
71
|
+
"@better-fetch/fetch": "1.3.1",
|
|
73
72
|
"better-call": "1.3.6",
|
|
74
|
-
"@better-auth/core": "^1.7.0-beta.
|
|
75
|
-
"better-auth": "^1.7.0-beta.
|
|
73
|
+
"@better-auth/core": "^1.7.0-beta.7",
|
|
74
|
+
"better-auth": "^1.7.0-beta.7"
|
|
76
75
|
},
|
|
77
76
|
"scripts": {
|
|
78
77
|
"build": "tsdown",
|