|
44 | 44 | * nothing else: no token content, no claim value, no subject or email, and |
45 | 45 | * nothing that depends on whether an account exists. The refusal happens |
46 | 46 | * before any user lookup, so the answer is the same for every account. |
47 | | - * - SAML: the vendor discards the SAML cause inside the package (a bare |
48 | | - * `catch` around `parseLoginResponse`), so it is not readable here; that half |
49 | | - * waits on the vendor. |
| 47 | + * - SAML causes: the vendor discards the SAML cause inside the package (a |
| 48 | + * bare `catch` around `parseLoginResponse`), so it is not readable here; that |
| 49 | + * half waits on the vendor. Where a SAML refusal LANDS is this module's, |
| 50 | + * below. |
50 | 51 | * |
51 | 52 | * The values are lowercase `error_description` tokens in the vendor's own |
52 | 53 | * dialect for that slot (`token_not_verified`, `id_token_subject_missing`), not |
53 | 54 | * ADR-0112 `error.code` members: a redirect query is not one of that |
54 | 55 | * vocabulary's doors. |
| 56 | + * |
| 57 | + * ## SAML: where a refusal lands |
| 58 | + * |
| 59 | + * The same scope wraps the SAML ACS (`POST /sso/saml2/sp/acs/:providerId`), |
| 60 | + * for a different gap. `@better-auth/sso` (1.7.12, `processSAMLResponse`) |
| 61 | + * sends nearly every SAML refusal to the flow's error URL: the sign-in's |
| 62 | + * `errorCallbackURL`, else the platform error page (`onAPIError.errorURL`), |
| 63 | + * as the OIDC callback does. Two do not: |
| 64 | + * |
| 65 | + * - an account refusal answered by better-auth's `handleOAuthUserInfo` |
| 66 | + * (`account_not_linked`, `signup_disabled`, `unable_to_link_account`, |
| 67 | + * `unable_to_create_user`, `email_not_verified`, |
| 68 | + * `unable_to_create_session`) is redirected to the SUCCESS `callbackURL` |
| 69 | + * with only `?error=`, so the user lands on the signed-in page, not |
| 70 | + * signed in; |
| 71 | + * - a refusal thrown before the vendor reads the relay state (a response |
| 72 | + * over `maxResponseSize`) lands on the provider's fallback, the app origin |
| 73 | + * for a provider the Register SAML Provider bridge stored. |
| 74 | + * |
| 75 | + * The vendor computes the error URL from the relay state and offers no hook |
| 76 | + * on it, so {@link readSamlAcsLanding} reads the same relay-state row through |
| 77 | + * the vendor's own `internalAdapter` BEFORE the vendor consumes it, and |
| 78 | + * applies the vendor's own redirect safety rule to it. When the vendor's answer |
| 79 | + * is then a redirect carrying `error=` that is not the success URL, it is |
| 80 | + * re-based onto that error URL with its `error` and `error_description` as |
| 81 | + * the vendor wrote them, built the way the vendor builds every other SAML |
| 82 | + * refusal. A refusal already on the error URL comes out byte-identical; a |
| 83 | + * success, an IdP-initiated response (no relay state), and a relay state the |
| 84 | + * vendor itself cannot read are left as the vendor answered them. |
55 | 85 | */ |
56 | 86 |
|
57 | 87 | import { AsyncLocalStorage } from 'node:async_hooks'; |
@@ -295,3 +325,188 @@ export async function runSsoOidcCallbackScope( |
295 | 325 | headers.set('location', withDescription(location, classified.cause)); |
296 | 326 | return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); |
297 | 327 | } |
| 328 | + |
| 329 | +// ── SAML: where a refusal lands ───────────────────────────────────────────── |
| 330 | + |
| 331 | +const SAML_ACS_PATH_PREFIX = '/sso/saml2/sp/acs/'; |
| 332 | + |
| 333 | +/** |
| 334 | + * The provider id of a SAML ACS request (`/sso/saml2/sp/acs/:providerId`, the |
| 335 | + * endpoint path as `betterAuthEndpointPath` answers it), or `undefined` for |
| 336 | + * any other path. |
| 337 | + */ |
| 338 | +export function ssoSamlAcsProviderId(endpointPath: string | undefined): string | undefined { |
| 339 | + if (!endpointPath?.startsWith(SAML_ACS_PATH_PREFIX)) return undefined; |
| 340 | + const providerId = endpointPath.slice(SAML_ACS_PATH_PREFIX.length); |
| 341 | + if (!providerId || providerId.includes('/')) return undefined; |
| 342 | + try { |
| 343 | + return decodeURIComponent(providerId); |
| 344 | + } catch { |
| 345 | + return providerId; |
| 346 | + } |
| 347 | +} |
| 348 | + |
| 349 | +/** One SSO callback request that runs inside the refusal scope. */ |
| 350 | +export type SsoCallbackRequest = |
| 351 | + | { protocol: 'oidc'; providerId: string } |
| 352 | + | { protocol: 'saml'; providerId: string; request: Request }; |
| 353 | + |
| 354 | +/** |
| 355 | + * The SSO callback a request is, if any: the OIDC callback, or a `POST` to the |
| 356 | + * SAML ACS (the IdP's assertion; a `GET` there carries no assertion). |
| 357 | + */ |
| 358 | +export function ssoCallbackOf(endpointPath: string | undefined, request: Request): SsoCallbackRequest | undefined { |
| 359 | + const oidc = ssoOidcCallbackProviderId(endpointPath); |
| 360 | + if (oidc !== undefined) return { protocol: 'oidc', providerId: oidc }; |
| 361 | + const saml = ssoSamlAcsProviderId(endpointPath); |
| 362 | + if (saml !== undefined && request.method === 'POST') return { protocol: 'saml', providerId: saml, request }; |
| 363 | + return undefined; |
| 364 | +} |
| 365 | + |
| 366 | +/** The members of better-auth's endpoint context the SAML landing reads. */ |
| 367 | +export interface SamlLandingContext { |
| 368 | + baseURL: string; |
| 369 | + options: { onAPIError?: { errorURL?: string } }; |
| 370 | + isTrustedOrigin: (url: string, settings?: { allowRelativePaths: boolean }) => boolean; |
| 371 | + internalAdapter: { findVerificationValue: (identifier: string) => Promise<{ value: string } | null | undefined> }; |
| 372 | +} |
| 373 | + |
| 374 | +/** Where one SAML sign-in lands: its success URL, and the error URL for every refusal. */ |
| 375 | +export interface SamlAcsLanding { |
| 376 | + success: string; |
| 377 | + error: string; |
| 378 | +} |
| 379 | + |
| 380 | +/** The `RelayState` an ACS `POST` carries, form-encoded or JSON (the two media types the vendor admits). */ |
| 381 | +async function relayStateOf(request: Request): Promise<string | undefined> { |
| 382 | + const text = await request.clone().text(); |
| 383 | + const type = request.headers.get('content-type') ?? ''; |
| 384 | + let relayState: unknown; |
| 385 | + if (type.includes('application/json')) { |
| 386 | + try { |
| 387 | + relayState = (JSON.parse(text) as { RelayState?: unknown } | null)?.RelayState; |
| 388 | + } catch { |
| 389 | + return undefined; |
| 390 | + } |
| 391 | + } else { |
| 392 | + relayState = new URLSearchParams(text).get('RelayState'); |
| 393 | + } |
| 394 | + return typeof relayState === 'string' && relayState ? relayState : undefined; |
| 395 | +} |
| 396 | + |
| 397 | +/** |
| 398 | + * The vendor's redirect safety rule for a SAML redirect target |
| 399 | + * (`@better-auth/sso` `getSafeRedirectCandidate`): a same-origin path that does |
| 400 | + * not start `//` or `/\`, or an absolute URL on the app origin or a trusted |
| 401 | + * origin; never the ACS itself. |
| 402 | + */ |
| 403 | +function safeSamlTarget( |
| 404 | + url: string, |
| 405 | + acsPathname: string, |
| 406 | + appOrigin: string, |
| 407 | + isTrustedOrigin: SamlLandingContext['isTrustedOrigin'], |
| 408 | +): string | undefined { |
| 409 | + if (url.startsWith('/')) { |
| 410 | + if (/^\/(?:\/|\\|%2f|%5c)/i.test(url)) return undefined; |
| 411 | + try { |
| 412 | + const absolute = new URL(url, appOrigin); |
| 413 | + return absolute.origin !== appOrigin || absolute.pathname === acsPathname ? undefined : url; |
| 414 | + } catch { |
| 415 | + return undefined; |
| 416 | + } |
| 417 | + } |
| 418 | + let absolute: URL; |
| 419 | + try { |
| 420 | + absolute = new URL(url); |
| 421 | + } catch { |
| 422 | + return undefined; |
| 423 | + } |
| 424 | + if (absolute.origin !== appOrigin && !isTrustedOrigin(url, { allowRelativePaths: false })) return undefined; |
| 425 | + if (absolute.origin === appOrigin && absolute.pathname === acsPathname) return undefined; |
| 426 | + return url; |
| 427 | +} |
| 428 | + |
| 429 | +/** |
| 430 | + * Where this ACS request's refusals land, read BEFORE the vendor runs: the |
| 431 | + * relay-state row the vendor wrote at `/sign-in/sso` (`auth-state:<RelayState>`, |
| 432 | + * read through the vendor's own `internalAdapter`, which the vendor deletes |
| 433 | + * once it has read it), with the same default and the same safety rule the |
| 434 | + * vendor applies to it. `undefined` when there is no relay state, the row is |
| 435 | + * gone or unreadable, or the error URL is not a safe target: the vendor then |
| 436 | + * has no error URL either, and its answer stands. |
| 437 | + */ |
| 438 | +export async function readSamlAcsLanding( |
| 439 | + context: SamlLandingContext, |
| 440 | + providerId: string, |
| 441 | + request: Request, |
| 442 | +): Promise<SamlAcsLanding | undefined> { |
| 443 | + const relayState = await relayStateOf(request); |
| 444 | + if (!relayState) return undefined; |
| 445 | + const row = await context.internalAdapter.findVerificationValue(`auth-state:${relayState}`); |
| 446 | + if (!row?.value) return undefined; |
| 447 | + let state: { callbackURL?: unknown; errorURL?: unknown }; |
| 448 | + try { |
| 449 | + state = JSON.parse(row.value); |
| 450 | + } catch { |
| 451 | + return undefined; |
| 452 | + } |
| 453 | + if (typeof state?.callbackURL !== 'string') return undefined; |
| 454 | + const errorURL = |
| 455 | + typeof state.errorURL === 'string' && state.errorURL |
| 456 | + ? state.errorURL |
| 457 | + : context.options.onAPIError?.errorURL || `${context.baseURL}/error`; |
| 458 | + const appOrigin = new URL(context.baseURL).origin; |
| 459 | + const acsPathname = new URL(`${context.baseURL}${SAML_ACS_PATH_PREFIX}${providerId}`).pathname; |
| 460 | + const error = safeSamlTarget(errorURL, acsPathname, appOrigin, context.isTrustedOrigin); |
| 461 | + return error ? { success: state.callbackURL, error } : undefined; |
| 462 | +} |
| 463 | + |
| 464 | +/** |
| 465 | + * The vendor's refusal `location`, re-based onto `target` with its `error` and |
| 466 | + * `error_description` kept as written, built as the vendor builds every other |
| 467 | + * SAML refusal (`buildSAMLRedirectUrl`: query values set on the target, a |
| 468 | + * relative target kept relative). |
| 469 | + */ |
| 470 | +function rebaseSamlRefusal(location: string, target: string): string { |
| 471 | + const from = new URL(location, 'http://better-auth.local'); |
| 472 | + const url = new URL(target, 'http://better-auth.local'); |
| 473 | + for (const key of ['error', 'error_description']) { |
| 474 | + const value = from.searchParams.get(key); |
| 475 | + if (value !== null) url.searchParams.set(key, value); |
| 476 | + } |
| 477 | + return target.startsWith('/') && !target.startsWith('//') ? `${url.pathname}${url.search}${url.hash}` : url.toString(); |
| 478 | +} |
| 479 | + |
| 480 | +/** |
| 481 | + * Put one SAML ACS answer on its landing: a redirect that carries `error=` and |
| 482 | + * is not the success URL is re-based onto the error URL. Every other answer is |
| 483 | + * returned untouched. |
| 484 | + */ |
| 485 | +export function landSamlRefusal(response: Response, landing: SamlAcsLanding): Response { |
| 486 | + const location = response.headers.get('location'); |
| 487 | + if (!location || location === landing.success) return response; |
| 488 | + if (!new URL(location, 'http://better-auth.local').searchParams.has('error')) return response; |
| 489 | + const landed = rebaseSamlRefusal(location, landing.error); |
| 490 | + if (landed === location) return response; |
| 491 | + const headers = new Headers(response.headers); |
| 492 | + headers.set('location', landed); |
| 493 | + return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); |
| 494 | +} |
| 495 | + |
| 496 | +/** |
| 497 | + * Run one SSO callback request inside the refusal scope: the OIDC callback |
| 498 | + * through {@link runSsoOidcCallbackScope}, the SAML ACS with its landing read |
| 499 | + * before the vendor runs and applied to its answer. |
| 500 | + */ |
| 501 | +export async function runSsoCallbackScope( |
| 502 | + callback: SsoCallbackRequest, |
| 503 | + host: SsoRefusalHostLogger | undefined, |
| 504 | + readSamlLanding: (saml: Extract<SsoCallbackRequest, { protocol: 'saml' }>) => Promise<SamlAcsLanding | undefined>, |
| 505 | + run: () => Promise<Response>, |
| 506 | +): Promise<Response> { |
| 507 | + if (callback.protocol === 'oidc') return runSsoOidcCallbackScope(callback.providerId, host, run); |
| 508 | + // A landing that cannot be read leaves the vendor's answer as it is. |
| 509 | + const landing = await readSamlLanding(callback).catch(() => undefined); |
| 510 | + const response = await run(); |
| 511 | + return landing ? landSamlRefusal(response, landing) : response; |
| 512 | +} |
0 commit comments