@better-auth/sso 1.4.7-beta.2 → 1.4.7-beta.4
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/.turbo/turbo-build.log +7 -7
- package/dist/client.d.mts +1 -1
- package/dist/{index-BWvN4yrs.d.mts → index-GoyGoP_a.d.mts} +390 -21
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +559 -63
- package/package.json +4 -4
- package/src/authn-request-store.ts +76 -0
- package/src/authn-request.test.ts +99 -0
- package/src/index.ts +46 -7
- package/src/oidc/discovery.test.ts +823 -0
- package/src/oidc/discovery.ts +355 -0
- package/src/oidc/errors.ts +86 -0
- package/src/oidc/index.ts +31 -0
- package/src/oidc/types.ts +210 -0
- package/src/oidc.test.ts +0 -164
- package/src/routes/sso.ts +415 -96
- package/src/saml.test.ts +781 -48
- package/src/types.ts +81 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery Pipeline
|
|
3
|
+
*
|
|
4
|
+
* Implements OIDC discovery document fetching, validation, and hydration.
|
|
5
|
+
* This module is used both at provider registration time (to persist validated config)
|
|
6
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
7
|
+
*
|
|
8
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { betterFetch } from "@better-fetch/fetch";
|
|
12
|
+
import type {
|
|
13
|
+
DiscoverOIDCConfigParams,
|
|
14
|
+
HydratedOIDCConfig,
|
|
15
|
+
OIDCDiscoveryDocument,
|
|
16
|
+
} from "./types";
|
|
17
|
+
import { DiscoveryError, REQUIRED_DISCOVERY_FIELDS } from "./types";
|
|
18
|
+
|
|
19
|
+
/** Default timeout for discovery requests (10 seconds) */
|
|
20
|
+
const DEFAULT_DISCOVERY_TIMEOUT = 10000;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Main entry point: Discover and hydrate OIDC configuration from an issuer.
|
|
24
|
+
*
|
|
25
|
+
* This function:
|
|
26
|
+
* 1. Computes the discovery URL from the issuer
|
|
27
|
+
* 2. Validates the discovery URL (stub for now)
|
|
28
|
+
* 3. Fetches the discovery document
|
|
29
|
+
* 4. Validates the discovery document (issuer match + required fields)
|
|
30
|
+
* 5. Normalizes URLs (stub for now)
|
|
31
|
+
* 6. Selects token endpoint auth method
|
|
32
|
+
* 7. Merges with existing config (existing values take precedence)
|
|
33
|
+
*
|
|
34
|
+
* @param params - Discovery parameters
|
|
35
|
+
* @returns Hydrated OIDC configuration ready for persistence
|
|
36
|
+
* @throws DiscoveryError on any failure
|
|
37
|
+
*/
|
|
38
|
+
export async function discoverOIDCConfig(
|
|
39
|
+
params: DiscoverOIDCConfigParams,
|
|
40
|
+
): Promise<HydratedOIDCConfig> {
|
|
41
|
+
const {
|
|
42
|
+
issuer,
|
|
43
|
+
existingConfig,
|
|
44
|
+
timeout = DEFAULT_DISCOVERY_TIMEOUT,
|
|
45
|
+
} = params;
|
|
46
|
+
|
|
47
|
+
const discoveryUrl =
|
|
48
|
+
params.discoveryEndpoint ||
|
|
49
|
+
existingConfig?.discoveryEndpoint ||
|
|
50
|
+
computeDiscoveryUrl(issuer);
|
|
51
|
+
|
|
52
|
+
validateDiscoveryUrl(discoveryUrl);
|
|
53
|
+
|
|
54
|
+
const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
|
|
55
|
+
|
|
56
|
+
validateDiscoveryDocument(discoveryDoc, issuer);
|
|
57
|
+
|
|
58
|
+
const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer);
|
|
59
|
+
|
|
60
|
+
const tokenEndpointAuth = selectTokenEndpointAuthMethod(
|
|
61
|
+
normalizedDoc,
|
|
62
|
+
existingConfig?.tokenEndpointAuthentication,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const hydratedConfig: HydratedOIDCConfig = {
|
|
66
|
+
issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
|
|
67
|
+
discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
|
|
68
|
+
authorizationEndpoint:
|
|
69
|
+
existingConfig?.authorizationEndpoint ??
|
|
70
|
+
normalizedDoc.authorization_endpoint,
|
|
71
|
+
tokenEndpoint:
|
|
72
|
+
existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
|
|
73
|
+
jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
|
|
74
|
+
userInfoEndpoint:
|
|
75
|
+
existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
|
|
76
|
+
tokenEndpointAuthentication:
|
|
77
|
+
existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
|
|
78
|
+
scopesSupported:
|
|
79
|
+
existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return hydratedConfig;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compute the discovery URL from an issuer URL.
|
|
87
|
+
*
|
|
88
|
+
* Per OIDC Discovery spec, the discovery document is located at:
|
|
89
|
+
* <issuer>/.well-known/openid-configuration
|
|
90
|
+
*
|
|
91
|
+
* Handles trailing slashes correctly.
|
|
92
|
+
*/
|
|
93
|
+
export function computeDiscoveryUrl(issuer: string): string {
|
|
94
|
+
const baseUrl = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer;
|
|
95
|
+
return `${baseUrl}/.well-known/openid-configuration`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Validate a discovery URL before fetching.
|
|
100
|
+
*
|
|
101
|
+
* @param url - The discovery URL to validate
|
|
102
|
+
* @throws DiscoveryError if URL is invalid
|
|
103
|
+
*/
|
|
104
|
+
export function validateDiscoveryUrl(url: string): void {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = new URL(url);
|
|
107
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
108
|
+
throw new DiscoveryError(
|
|
109
|
+
"discovery_invalid_url",
|
|
110
|
+
`Discovery URL must use HTTP or HTTPS protocol: ${url}`,
|
|
111
|
+
{ url, protocol: parsed.protocol },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof DiscoveryError) {
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
throw new DiscoveryError(
|
|
119
|
+
"discovery_invalid_url",
|
|
120
|
+
`Invalid discovery URL: ${url}`,
|
|
121
|
+
{ url },
|
|
122
|
+
{ cause: error },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fetch the OIDC discovery document from the IdP.
|
|
129
|
+
*
|
|
130
|
+
* @param url - The discovery endpoint URL
|
|
131
|
+
* @param timeout - Request timeout in milliseconds
|
|
132
|
+
* @returns The parsed discovery document
|
|
133
|
+
* @throws DiscoveryError on network errors, timeouts, or invalid responses
|
|
134
|
+
*/
|
|
135
|
+
export async function fetchDiscoveryDocument(
|
|
136
|
+
url: string,
|
|
137
|
+
timeout: number = DEFAULT_DISCOVERY_TIMEOUT,
|
|
138
|
+
): Promise<OIDCDiscoveryDocument> {
|
|
139
|
+
try {
|
|
140
|
+
const response = await betterFetch<OIDCDiscoveryDocument>(url, {
|
|
141
|
+
method: "GET",
|
|
142
|
+
timeout,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (response.error) {
|
|
146
|
+
const { status } = response.error;
|
|
147
|
+
|
|
148
|
+
if (status === 404) {
|
|
149
|
+
throw new DiscoveryError(
|
|
150
|
+
"discovery_not_found",
|
|
151
|
+
"Discovery endpoint not found",
|
|
152
|
+
{
|
|
153
|
+
url,
|
|
154
|
+
status,
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (status === 408) {
|
|
160
|
+
throw new DiscoveryError(
|
|
161
|
+
"discovery_timeout",
|
|
162
|
+
"Discovery request timed out",
|
|
163
|
+
{
|
|
164
|
+
url,
|
|
165
|
+
timeout,
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
throw new DiscoveryError(
|
|
171
|
+
"discovery_unexpected_error",
|
|
172
|
+
`Unexpected discovery error: ${response.error.statusText}`,
|
|
173
|
+
{ url, ...response.error },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!response.data) {
|
|
178
|
+
throw new DiscoveryError(
|
|
179
|
+
"discovery_invalid_json",
|
|
180
|
+
"Discovery endpoint returned an empty response",
|
|
181
|
+
{ url },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const data = response.data as OIDCDiscoveryDocument | string;
|
|
186
|
+
if (typeof data === "string") {
|
|
187
|
+
throw new DiscoveryError(
|
|
188
|
+
"discovery_invalid_json",
|
|
189
|
+
"Discovery endpoint returned invalid JSON",
|
|
190
|
+
{ url, bodyPreview: data.slice(0, 200) },
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return data;
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error instanceof DiscoveryError) {
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// betterFetch throws AbortError on timeout (not returned as response.error)
|
|
201
|
+
// Check error.name since message varies by runtime
|
|
202
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
203
|
+
throw new DiscoveryError(
|
|
204
|
+
"discovery_timeout",
|
|
205
|
+
"Discovery request timed out",
|
|
206
|
+
{
|
|
207
|
+
url,
|
|
208
|
+
timeout,
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
throw new DiscoveryError(
|
|
214
|
+
"discovery_unexpected_error",
|
|
215
|
+
`Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`,
|
|
216
|
+
{ url },
|
|
217
|
+
{ cause: error },
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Validate a discovery document.
|
|
224
|
+
*
|
|
225
|
+
* Checks:
|
|
226
|
+
* 1. All required fields are present
|
|
227
|
+
* 2. Issuer matches the configured issuer (case-sensitive, exact match)
|
|
228
|
+
*
|
|
229
|
+
* Invariant: If this function returns without throwing, the document is safe
|
|
230
|
+
* to use for hydrating OIDC config (required fields present, issuer matches
|
|
231
|
+
* configured value, basic structural sanity verified).
|
|
232
|
+
*
|
|
233
|
+
* @param doc - The discovery document to validate
|
|
234
|
+
* @param configuredIssuer - The expected issuer value
|
|
235
|
+
* @throws DiscoveryError if validation fails
|
|
236
|
+
*/
|
|
237
|
+
export function validateDiscoveryDocument(
|
|
238
|
+
doc: OIDCDiscoveryDocument,
|
|
239
|
+
configuredIssuer: string,
|
|
240
|
+
): void {
|
|
241
|
+
const missingFields: string[] = [];
|
|
242
|
+
|
|
243
|
+
for (const field of REQUIRED_DISCOVERY_FIELDS) {
|
|
244
|
+
if (!doc[field]) {
|
|
245
|
+
missingFields.push(field);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (missingFields.length > 0) {
|
|
250
|
+
throw new DiscoveryError(
|
|
251
|
+
"discovery_incomplete",
|
|
252
|
+
`Discovery document is missing required fields: ${missingFields.join(", ")}`,
|
|
253
|
+
{ missingFields },
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const discoveredIssuer = doc.issuer.endsWith("/")
|
|
258
|
+
? doc.issuer.slice(0, -1)
|
|
259
|
+
: doc.issuer;
|
|
260
|
+
const expectedIssuer = configuredIssuer.endsWith("/")
|
|
261
|
+
? configuredIssuer.slice(0, -1)
|
|
262
|
+
: configuredIssuer;
|
|
263
|
+
|
|
264
|
+
if (discoveredIssuer !== expectedIssuer) {
|
|
265
|
+
throw new DiscoveryError(
|
|
266
|
+
"issuer_mismatch",
|
|
267
|
+
`Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`,
|
|
268
|
+
{
|
|
269
|
+
discovered: doc.issuer,
|
|
270
|
+
configured: configuredIssuer,
|
|
271
|
+
},
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Normalize URLs in the discovery document.
|
|
278
|
+
*
|
|
279
|
+
* @param doc - The discovery document
|
|
280
|
+
* @param _issuerBase - The base issuer URL
|
|
281
|
+
* @returns The normalized discovery document
|
|
282
|
+
*/
|
|
283
|
+
export function normalizeDiscoveryUrls(
|
|
284
|
+
doc: OIDCDiscoveryDocument,
|
|
285
|
+
_issuerBase: string,
|
|
286
|
+
): OIDCDiscoveryDocument {
|
|
287
|
+
return doc;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Normalize a single URL endpoint.
|
|
292
|
+
*
|
|
293
|
+
* @param endpoint - The endpoint URL to normalize
|
|
294
|
+
* @param _issuerBase - The base issuer URL
|
|
295
|
+
* @returns The normalized endpoint URL
|
|
296
|
+
*/
|
|
297
|
+
export function normalizeUrl(endpoint: string, _issuerBase: string): string {
|
|
298
|
+
return endpoint;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Select the token endpoint authentication method.
|
|
303
|
+
*
|
|
304
|
+
* @param doc - The discovery document
|
|
305
|
+
* @param existing - Existing authentication method from config
|
|
306
|
+
* @returns The selected authentication method
|
|
307
|
+
*/
|
|
308
|
+
export function selectTokenEndpointAuthMethod(
|
|
309
|
+
doc: OIDCDiscoveryDocument,
|
|
310
|
+
existing?: "client_secret_basic" | "client_secret_post",
|
|
311
|
+
): "client_secret_basic" | "client_secret_post" {
|
|
312
|
+
if (existing) {
|
|
313
|
+
return existing;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const supported = doc.token_endpoint_auth_methods_supported;
|
|
317
|
+
|
|
318
|
+
if (!supported || supported.length === 0) {
|
|
319
|
+
return "client_secret_basic";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (supported.includes("client_secret_basic")) {
|
|
323
|
+
return "client_secret_basic";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (supported.includes("client_secret_post")) {
|
|
327
|
+
return "client_secret_post";
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return "client_secret_basic";
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Check if a provider configuration needs runtime discovery.
|
|
335
|
+
*
|
|
336
|
+
* Returns true if we need discovery at runtime to complete the token exchange
|
|
337
|
+
* and validation. Specifically checks for:
|
|
338
|
+
* - `tokenEndpoint` - required for exchanging authorization code for tokens
|
|
339
|
+
* - `jwksEndpoint` - required for validating ID token signatures
|
|
340
|
+
*
|
|
341
|
+
* Note: `authorizationEndpoint` is handled separately in the sign-in flow,
|
|
342
|
+
* so it's not checked here.
|
|
343
|
+
*
|
|
344
|
+
* @param config - Partial OIDC config from the provider
|
|
345
|
+
* @returns true if runtime discovery should be performed
|
|
346
|
+
*/
|
|
347
|
+
export function needsRuntimeDiscovery(
|
|
348
|
+
config: Partial<HydratedOIDCConfig> | undefined,
|
|
349
|
+
): boolean {
|
|
350
|
+
if (!config) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return !config.tokenEndpoint || !config.jwksEndpoint;
|
|
355
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery Error Mapping
|
|
3
|
+
*
|
|
4
|
+
* Maps DiscoveryError codes to appropriate APIError responses.
|
|
5
|
+
* Used at the boundary between the discovery pipeline and HTTP handlers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { APIError } from "better-auth/api";
|
|
9
|
+
import type { DiscoveryError } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Maps a DiscoveryError to an appropriate APIError for HTTP responses.
|
|
13
|
+
*
|
|
14
|
+
* Error code mapping:
|
|
15
|
+
* - discovery_invalid_url → 400 BAD_REQUEST
|
|
16
|
+
* - discovery_not_found → 400 BAD_REQUEST
|
|
17
|
+
* - discovery_invalid_json → 400 BAD_REQUEST
|
|
18
|
+
* - discovery_incomplete → 400 BAD_REQUEST
|
|
19
|
+
* - issuer_mismatch → 400 BAD_REQUEST
|
|
20
|
+
* - unsupported_token_auth_method → 400 BAD_REQUEST
|
|
21
|
+
* - discovery_timeout → 502 BAD_GATEWAY
|
|
22
|
+
* - discovery_unexpected_error → 502 BAD_GATEWAY
|
|
23
|
+
*
|
|
24
|
+
* @param error - The DiscoveryError to map
|
|
25
|
+
* @returns An APIError with appropriate status and message
|
|
26
|
+
*/
|
|
27
|
+
export function mapDiscoveryErrorToAPIError(error: DiscoveryError): APIError {
|
|
28
|
+
switch (error.code) {
|
|
29
|
+
case "discovery_timeout":
|
|
30
|
+
return new APIError("BAD_GATEWAY", {
|
|
31
|
+
message: `OIDC discovery timed out: ${error.message}`,
|
|
32
|
+
code: error.code,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
case "discovery_unexpected_error":
|
|
36
|
+
return new APIError("BAD_GATEWAY", {
|
|
37
|
+
message: `OIDC discovery failed: ${error.message}`,
|
|
38
|
+
code: error.code,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
case "discovery_not_found":
|
|
42
|
+
return new APIError("BAD_REQUEST", {
|
|
43
|
+
message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
|
|
44
|
+
code: error.code,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
case "discovery_invalid_url":
|
|
48
|
+
return new APIError("BAD_REQUEST", {
|
|
49
|
+
message: `Invalid OIDC discovery URL: ${error.message}`,
|
|
50
|
+
code: error.code,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
case "discovery_invalid_json":
|
|
54
|
+
return new APIError("BAD_REQUEST", {
|
|
55
|
+
message: `OIDC discovery returned invalid data: ${error.message}`,
|
|
56
|
+
code: error.code,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
case "discovery_incomplete":
|
|
60
|
+
return new APIError("BAD_REQUEST", {
|
|
61
|
+
message: `OIDC discovery document is missing required fields: ${error.message}`,
|
|
62
|
+
code: error.code,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
case "issuer_mismatch":
|
|
66
|
+
return new APIError("BAD_REQUEST", {
|
|
67
|
+
message: `OIDC issuer mismatch: ${error.message}`,
|
|
68
|
+
code: error.code,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
case "unsupported_token_auth_method":
|
|
72
|
+
return new APIError("BAD_REQUEST", {
|
|
73
|
+
message: `Incompatible OIDC provider: ${error.message}`,
|
|
74
|
+
code: error.code,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
default: {
|
|
78
|
+
// Exhaustive check - TypeScript will error if we miss a case
|
|
79
|
+
const _exhaustiveCheck: never = error.code;
|
|
80
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
81
|
+
message: `Unexpected discovery error: ${error.message}`,
|
|
82
|
+
code: "discovery_unexpected_error",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery Module
|
|
3
|
+
*
|
|
4
|
+
* This module provides OIDC discovery document fetching, validation, and hydration.
|
|
5
|
+
* It is used both at provider registration time (to persist validated config)
|
|
6
|
+
* and at runtime (to hydrate legacy providers that are missing metadata).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
computeDiscoveryUrl,
|
|
11
|
+
discoverOIDCConfig,
|
|
12
|
+
fetchDiscoveryDocument,
|
|
13
|
+
needsRuntimeDiscovery,
|
|
14
|
+
normalizeDiscoveryUrls,
|
|
15
|
+
normalizeUrl,
|
|
16
|
+
selectTokenEndpointAuthMethod,
|
|
17
|
+
validateDiscoveryDocument,
|
|
18
|
+
validateDiscoveryUrl,
|
|
19
|
+
} from "./discovery";
|
|
20
|
+
|
|
21
|
+
export { mapDiscoveryErrorToAPIError } from "./errors";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
type DiscoverOIDCConfigParams,
|
|
25
|
+
DiscoveryError,
|
|
26
|
+
type DiscoveryErrorCode,
|
|
27
|
+
type HydratedOIDCConfig,
|
|
28
|
+
type OIDCDiscoveryDocument,
|
|
29
|
+
REQUIRED_DISCOVERY_FIELDS,
|
|
30
|
+
type RequiredDiscoveryField,
|
|
31
|
+
} from "./types";
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC Discovery Types
|
|
3
|
+
*
|
|
4
|
+
* Types for the OIDC discovery document and hydrated configuration.
|
|
5
|
+
* Based on OpenID Connect Discovery 1.0 specification.
|
|
6
|
+
*
|
|
7
|
+
* @see https://openid.net/specs/openid-connect-discovery-1_0.html
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Raw OIDC Discovery Document as returned by the IdP's
|
|
12
|
+
* .well-known/openid-configuration endpoint.
|
|
13
|
+
*
|
|
14
|
+
* Required fields for Better Auth's OIDC support:
|
|
15
|
+
* - issuer
|
|
16
|
+
* - authorization_endpoint
|
|
17
|
+
* - token_endpoint
|
|
18
|
+
* - jwks_uri (required for ID token validation)
|
|
19
|
+
*
|
|
20
|
+
*/
|
|
21
|
+
export interface OIDCDiscoveryDocument {
|
|
22
|
+
/** REQUIRED. URL using the https scheme that the OP asserts as its Issuer Identifier. */
|
|
23
|
+
issuer: string;
|
|
24
|
+
|
|
25
|
+
/** REQUIRED. URL of the OP's OAuth 2.0 Authorization Endpoint. */
|
|
26
|
+
authorization_endpoint: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* REQUIRED (spec says "unless only implicit flow is used").
|
|
30
|
+
* URL of the OP's OAuth 2.0 Token Endpoint.
|
|
31
|
+
* We only support authorization code flow.
|
|
32
|
+
*/
|
|
33
|
+
token_endpoint: string;
|
|
34
|
+
|
|
35
|
+
/** REQUIRED. URL of the OP's JSON Web Key Set document for ID token validation. */
|
|
36
|
+
jwks_uri: string;
|
|
37
|
+
|
|
38
|
+
/** RECOMMENDED. URL of the OP's UserInfo Endpoint. */
|
|
39
|
+
userinfo_endpoint?: string;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* OPTIONAL. JSON array containing a list of Client Authentication methods
|
|
43
|
+
* supported by this Token Endpoint.
|
|
44
|
+
* Default: ["client_secret_basic"]
|
|
45
|
+
*/
|
|
46
|
+
token_endpoint_auth_methods_supported?: string[];
|
|
47
|
+
|
|
48
|
+
/** OPTIONAL. JSON array containing a list of the OAuth 2.0 scope values that this server supports. */
|
|
49
|
+
scopes_supported?: string[];
|
|
50
|
+
|
|
51
|
+
/** OPTIONAL. JSON array containing a list of the OAuth 2.0 response_type values that this OP supports. */
|
|
52
|
+
response_types_supported?: string[];
|
|
53
|
+
|
|
54
|
+
/** OPTIONAL. JSON array containing a list of the Subject Identifier types that this OP supports. */
|
|
55
|
+
subject_types_supported?: string[];
|
|
56
|
+
|
|
57
|
+
/** OPTIONAL. JSON array containing a list of the JWS signing algorithms supported by the OP. */
|
|
58
|
+
id_token_signing_alg_values_supported?: string[];
|
|
59
|
+
|
|
60
|
+
/** OPTIONAL. JSON array containing a list of the claim names that the OP may supply values for. */
|
|
61
|
+
claims_supported?: string[];
|
|
62
|
+
|
|
63
|
+
/** OPTIONAL. URL of a page containing human-readable information about the OP. */
|
|
64
|
+
service_documentation?: string;
|
|
65
|
+
|
|
66
|
+
/** OPTIONAL. Boolean value specifying whether the OP supports use of the claims parameter. */
|
|
67
|
+
claims_parameter_supported?: boolean;
|
|
68
|
+
|
|
69
|
+
/** OPTIONAL. Boolean value specifying whether the OP supports use of the request parameter. */
|
|
70
|
+
request_parameter_supported?: boolean;
|
|
71
|
+
|
|
72
|
+
/** OPTIONAL. Boolean value specifying whether the OP supports use of the request_uri parameter. */
|
|
73
|
+
request_uri_parameter_supported?: boolean;
|
|
74
|
+
|
|
75
|
+
/** OPTIONAL. Boolean value specifying whether the OP requires any request_uri values to be pre-registered. */
|
|
76
|
+
require_request_uri_registration?: boolean;
|
|
77
|
+
|
|
78
|
+
/** OPTIONAL. URL of the OP's end session endpoint. */
|
|
79
|
+
end_session_endpoint?: string;
|
|
80
|
+
|
|
81
|
+
/** OPTIONAL. URL of the OP's revocation endpoint. */
|
|
82
|
+
revocation_endpoint?: string;
|
|
83
|
+
|
|
84
|
+
/** OPTIONAL. URL of the OP's introspection endpoint. */
|
|
85
|
+
introspection_endpoint?: string;
|
|
86
|
+
|
|
87
|
+
/** OPTIONAL. JSON array of PKCE code challenge methods supported (e.g., "S256", "plain"). */
|
|
88
|
+
code_challenge_methods_supported?: string[];
|
|
89
|
+
|
|
90
|
+
/** Allow additional fields from the discovery document */
|
|
91
|
+
[key: string]: unknown;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Error codes for OIDC discovery operations.
|
|
96
|
+
*/
|
|
97
|
+
export type DiscoveryErrorCode =
|
|
98
|
+
/** Request to discovery endpoint timed out */
|
|
99
|
+
| "discovery_timeout"
|
|
100
|
+
/** Discovery endpoint returned 404 or similar */
|
|
101
|
+
| "discovery_not_found"
|
|
102
|
+
/** Discovery endpoint returned invalid JSON */
|
|
103
|
+
| "discovery_invalid_json"
|
|
104
|
+
/** Discovery URL is invalid or malformed */
|
|
105
|
+
| "discovery_invalid_url"
|
|
106
|
+
/** Discovery document issuer doesn't match configured issuer */
|
|
107
|
+
| "issuer_mismatch"
|
|
108
|
+
/** Discovery document is missing required fields */
|
|
109
|
+
| "discovery_incomplete"
|
|
110
|
+
/** IdP only advertises token auth methods that Better Auth doesn't currently support */
|
|
111
|
+
| "unsupported_token_auth_method"
|
|
112
|
+
/** Catch-all for unexpected errors */
|
|
113
|
+
| "discovery_unexpected_error";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Custom error class for OIDC discovery failures.
|
|
117
|
+
* Can be caught and mapped to APIError at the edge.
|
|
118
|
+
*/
|
|
119
|
+
export class DiscoveryError extends Error {
|
|
120
|
+
public readonly code: DiscoveryErrorCode;
|
|
121
|
+
public readonly details?: Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
constructor(
|
|
124
|
+
code: DiscoveryErrorCode,
|
|
125
|
+
message: string,
|
|
126
|
+
details?: Record<string, unknown>,
|
|
127
|
+
options?: { cause?: unknown },
|
|
128
|
+
) {
|
|
129
|
+
super(message, options);
|
|
130
|
+
this.name = "DiscoveryError";
|
|
131
|
+
this.code = code;
|
|
132
|
+
this.details = details;
|
|
133
|
+
|
|
134
|
+
// Maintains proper stack trace for where the error was thrown
|
|
135
|
+
if (Error.captureStackTrace) {
|
|
136
|
+
Error.captureStackTrace(this, DiscoveryError);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Hydrated OIDC configuration after discovery.
|
|
143
|
+
* This is the normalized shape that gets persisted to the database
|
|
144
|
+
* or merged into provider config at runtime.
|
|
145
|
+
*
|
|
146
|
+
* Field names are camelCase to match Better Auth conventions.
|
|
147
|
+
*/
|
|
148
|
+
export interface HydratedOIDCConfig {
|
|
149
|
+
/** The issuer URL (validated to match configured issuer) */
|
|
150
|
+
issuer: string;
|
|
151
|
+
|
|
152
|
+
/** The discovery endpoint URL */
|
|
153
|
+
discoveryEndpoint: string;
|
|
154
|
+
|
|
155
|
+
/** URL of the authorization endpoint */
|
|
156
|
+
authorizationEndpoint: string;
|
|
157
|
+
|
|
158
|
+
/** URL of the token endpoint */
|
|
159
|
+
tokenEndpoint: string;
|
|
160
|
+
|
|
161
|
+
/** URL of the JWKS endpoint */
|
|
162
|
+
jwksEndpoint: string;
|
|
163
|
+
|
|
164
|
+
/** URL of the userinfo endpoint (optional) */
|
|
165
|
+
userInfoEndpoint?: string;
|
|
166
|
+
|
|
167
|
+
/** Token endpoint authentication method */
|
|
168
|
+
tokenEndpointAuthentication?: "client_secret_basic" | "client_secret_post";
|
|
169
|
+
|
|
170
|
+
/** Scopes supported by the IdP */
|
|
171
|
+
scopesSupported?: string[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parameters for the discoverOIDCConfig function.
|
|
176
|
+
*/
|
|
177
|
+
export interface DiscoverOIDCConfigParams {
|
|
178
|
+
/** The issuer URL to discover configuration from */
|
|
179
|
+
issuer: string;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Optional existing configuration.
|
|
183
|
+
* Values provided here will override discovered values.
|
|
184
|
+
*/
|
|
185
|
+
existingConfig?: Partial<HydratedOIDCConfig>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Optional custom discovery endpoint URL.
|
|
189
|
+
* If not provided, defaults to <issuer>/.well-known/openid-configuration
|
|
190
|
+
*/
|
|
191
|
+
discoveryEndpoint?: string;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Optional timeout in milliseconds for the discovery request.
|
|
195
|
+
* @default 10000 (10 seconds)
|
|
196
|
+
*/
|
|
197
|
+
timeout?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Required fields that must be present in a valid discovery document.
|
|
202
|
+
*/
|
|
203
|
+
export const REQUIRED_DISCOVERY_FIELDS = [
|
|
204
|
+
"issuer",
|
|
205
|
+
"authorization_endpoint",
|
|
206
|
+
"token_endpoint",
|
|
207
|
+
"jwks_uri",
|
|
208
|
+
] as const;
|
|
209
|
+
|
|
210
|
+
export type RequiredDiscoveryField = (typeof REQUIRED_DISCOVERY_FIELDS)[number];
|