Skip to content

Commit b065fad

Browse files
fix(plugin-auth): a refused SAML sign-in lands on the error URL, as OIDC does (#31231)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XGDSuSNY1BXB5hKz8ADFAn2)_ Fixes #30966 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:SAML 登录被拒时(典型是邮箱属于一个尚未关联该 IdP 的已有账号,`account_not_linked`),不再跳回「登录成功」页,而是跳到登录时给的 `errorCallbackURL`;没给就跳到平台登录页,与 OIDC 以及 SAML 其它拒绝一致。响应过大被拒时原来跳到站点根,也一并改为错误页。`error=`、`error_description` 原样保留。 - **为什么改**:用户会看到一个看似登录了、其实没登录的页面(cloud#3666 用 Okta / Entra 实测)。原因在上游 `@better-auth/sso`:账号类拒绝被发往成功地址。 - **风险与回滚**:只改 SAML ACS 拒绝时的跳转目标,不放行任何登录;成功登录、IdP 发起的登录(无 relay state)不受影响。回滚即还原本 PR,无数据迁移。 - **先看**:`sso-oidc-refusal-cause.ts` 末尾的 SAML 一节(风险在 `readSamlAcsLanding` 的安全规则与 `landSamlRefusal` 的成功判定);`auth-manager-request-routing.ts`(同一作用域挂载点);`sso-saml-refusal-landing.test.ts`(真实 ACS 端到端)。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-auth exec vitest run src/sso-saml-refusal-landing.test.ts`,预期 15 个用例全绿(含成功对照与 takeover 落到错误页)。 - **需要你做的**:无,席位落地。 ## What changes `@better-auth/sso` 1.7.12 `processSAMLResponse` sends nearly every SAML refusal to the flow's error URL (`samlErrorRedirectUrl`: the sign-in's `errorCallbackURL`, else `onAPIError.errorURL`, which plugin-auth points at the Console login page). Two refusals do not: 1. An account refusal from better-auth's `handleOAuthUserInfo` comes back as `result.error`, and the vendor answers `ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error }))`: the SUCCESS `callbackURL` with only `?error=`. Values: `account_not_linked` (the takeover case on the card), `signup_disabled`, `unable_to_link_account`, `unable_to_create_user`, `email_not_verified`, `unable_to_create_session`. 2. A response over `maxResponseSize` is refused before the vendor reads the relay state, so it lands on the provider's fallback candidates. For a provider stored by the Register SAML Provider bridge (whose `callbackUrl` is the ACS itself) that is the app origin `/`. The cause is upstream, so the fix wraps the ACS in plugin-auth at the seam PR #31194 uses for the OIDC callback, as the seat ruled. There is no vendored patch. - **One scope for both callbacks.** `auth-manager-request-routing.ts` now asks `ssoCallbackOf(endpointPath, request)`, which answers the OIDC callback (unchanged) or a `POST` to `/sso/saml2/sp/acs/:providerId`. Both run through the one host member `AuthManager.runSsoCallback` (renamed from `runSsoOidcCallback`) and `runSsoCallbackScope` in `sso-oidc-refusal-cause.ts`. The OIDC branch calls the existing `runSsoOidcCallbackScope` unchanged. The file keeps its name and gains a "SAML: where a refusal lands" header section. - **Where the error URL comes from.** The vendor computes `samlErrorRedirectUrl` from the relay state and offers no hook on it, and it deletes the relay-state row once read. So before the vendor runs, `readSamlAcsLanding` reads the same row (`auth-state:RELAY_STATE`) through the vendor's own `internalAdapter.findVerificationValue`, which does not consume it. It applies the vendor's default (`onAPIError.errorURL`, else `${baseURL}/error`) and the vendor's redirect safety rule (`getSafeRedirectCandidate`: a same-origin path not starting `//` or `/\`, or an absolute URL on the app origin or a trusted origin, never the ACS itself). No relay state, no row, an unreadable row, an unsafe target, or a throw while reading means no landing, and the vendor's answer stands. - **The rewrite.** `landSamlRefusal` re-bases a redirect whose `Location` carries `error=` and is not the success URL onto the error URL, keeping `error` and `error_description` as the vendor wrote them. It builds the URL as the vendor's `buildSAMLRedirectUrl` does: values set on the target, and a relative target kept relative. A refusal already on the error URL comes out byte-identical; status and every other header are kept. `error=` is never changed (seat ruling). - **Not here:** the SAML refusal causes in `error_description` (#31168, waiting on upstream). Neither field's value changes, so this PR and #31168 stay separable. No `packages/spec`, ADR-0112, or package export change (`index.ts` still exports only the OIDC cause constants). - **Docs:** `content/docs/permissions/sso.mdx` "SAML sign-in flow" gains step 5 (where a refusal lands) and names the optional `errorCallbackURL`. - **Changeset:** `@objectstack/plugin-auth` patch, `.changeset/30966-saml-refusal-lands-on-error-url.md`. ### Every SAML ACS refusal, and where it lands Measured with this PR's rig on `origin/main` `66bf5b4a` (before) and on the head (after), sign-in with `errorCallbackURL` given: | Refusal | Before | After | |---|---|---| | `account_not_linked` (takeover) | `callbackURL?error=account_not_linked` | `errorCallbackURL?error=account_not_linked` | | same, no `errorCallbackURL` | `callbackURL?error=account_not_linked` | Console login page `?error=account_not_linked` | | other `handleOAuthUserInfo` results (`signup_disabled`, `unable_to_link_account`, `unable_to_create_user`, `email_not_verified`, `unable_to_create_session`) | `callbackURL` (same vendor line, read in source) | `errorCallbackURL` (same rule; not driven end to end) | | response over `maxResponseSize` | app origin `/?error=saml_error&…` | `errorCallbackURL?error=saml_error&…` | | signature / parse failure (`saml_error`) | `errorCallbackURL` | unchanged | | no assertion (`saml_no_assertion`) | `errorCallbackURL` | unchanged | | unknown or expired request ID, audience, recipient, replay, `invalid_state` variants, `resolveUser` / `validateUserInfo` refusals (APIError with a code, e.g. plugin-auth's own implicit-link gate) | error URL (vendor source; the request-ID case measured) | unchanged | | relay state the vendor cannot read | vendor fallback (no error URL known) | unchanged | | provider not found (404), unverified domain (401), `POST` without `SAMLResponse` (400) | JSON answers, not redirects | unchanged | | IdP-initiated response (no `RelayState`) | vendor candidates | unchanged (no relay state to read) | ## Tests New `packages/plugins/plugin-auth/src/sso-saml-refusal-landing.test.ts`, 15 cases. - **End of the chain:** a real `AuthManager` with the real `sso()` plugin over the in-memory engine, driven `/sign-in/sso` → IdP → `POST` ACS. The IdP is played with the `samlify` the installed `@better-auth/sso` resolves (loaded through `createRequire` from the vendor's own location, so no new dependency). It reads the vendor's SP metadata and AuthnRequest ID and answers a Response signed with an RSA key and a self-signed certificate generated in the test (a small DER builder checked by `X509Certificate`), so each refusal comes from the vendor's real pipeline. - CONTROL: a new account signs in, 302 to `callbackURL`, session cookie set. - Takeover: 302 to `errorCallbackURL` with `error=account_not_linked`, no session. - Takeover with no `errorCallbackURL`: the Console login page, the same page the vendor's own `saml_error` refusal lands on in the same sign-in shape. - Takeover with a relative `errorCallbackURL`: `/oops?from=sso&error=account_not_linked`. - Oversized response: `errorCallbackURL`, `error=saml_error`, description kept. - Tampered assertion (already on the error URL): unchanged. - **Units:** the path and method scope; reading the landing from a form or JSON body, the default, the absent or unreadable row; the safety rule (untrusted origin, trusted origin, `//`, `/\`, the ACS itself, a relative path); the rewrite keeps headers and both params, leaves a success alone even when its own URL carries `error=`, leaves an already-landed refusal and a non-redirect identical; no landing, or a throw while reading it, leaves the vendor answer. Red first on `origin/main` `66bf5b4a` (first version of the file, CONTROL + takeover): 1 failed, 1 passed; the takeover case received `base: http://localhost:3000/after-sign-in` for `sign-in-failed`. <details><summary>Ablations (each through scripts/ablation-replace.mjs, restored with blob == HEAD 9e4e9e5 and `git diff HEAD` empty)</summary> The test imports the module from `src/` by relative path, so no `dist/` rebuild sits between a mutation and its reading. 1. The scope no longer wraps the SAML ACS (`ssoCallbackOf` matches method `ABLATED-1` instead of `POST`): 5 failed / 10 passed (the four end-to-end refusal cases and the scope unit). 2. The safety rule off (`readSamlAcsLanding` takes the raw error URL): 1 failed / 14 passed ("applies the vendor redirect safety rule"). 3. The success guard off (`location === landing.success` never true): 1 failed / 14 passed ("leaves a success alone, even one whose own URL carries error="). </details> ## Acceptance notes - Reading: "as the OIDC callback does" also covers the sign-in with no `errorCallbackURL`. The OIDC callback then lands on `onAPIError.errorURL` (the Console login page), and so does every other SAML refusal; this PR sends the account refusals there too instead of to `callbackURL`. The card's literal "when one is given" case is the first end-to-end refusal test. - The landing reads one vendor-internal spelling, the relay-state identifier `auth-state:RELAY_STATE` (better-auth `getAuthStateVerificationIdentifier`, not exported). A vendor bump that renames it makes `readSamlAcsLanding` find no row, and the end-to-end takeover cases go red (ablation 1 is the same shape), so it cannot fall back silently. plugin-auth always runs better-auth with a database, so the relay state is always database-stored (`storeStateStrategy` is never set here). - The safety rule is a copy of the vendor's (`getSafeRedirectCandidate` and `isSafeSAMLRedirectPath` are not exported); it only ever narrows: an error URL it refuses leaves the vendor's answer as is. - No new log line: the SAML cause log line belongs to #31168. - Clause-② measured: the only answer that changes is the `Location` of a refused ACS `POST` (302 either way). Nothing is admitted, no status, body, `error=` or `error_description` changes, and no published type or export changes. So `no`, as the claim read it. - Upstream: the one-line fix would be `callbackUrl` → `errorUrl` in that `result.error` redirect, plus reading the relay state before the size check. Not filed here. ## Verification All local readings are at head `ffdd6e0f`. - `pnpm --filter @objectstack/plugin-auth test` (after `pnpm --filter @objectstack/plugin-auth build`, under the verify lock): Test Files 236 passed (236), Tests 4198 passed | 10 skipped (4208). - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0 (includes `check:test-typecheck`, debt unchanged). - `npx eslint --no-inline-config packages/plugins/plugin-auth`: exit 0. - Derived gates (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, 95 families) after a full `pnpm build` (119 packages, exit 0): 95 derived, 95 run, every one `:: exit 0`; `--ran` reconciliation: 95 run, 0 NOT-MEASURED, 0 UNRUN. `check-plugin-teardown-shape --self-test` ran after fetching its fixture commit. Human Minimum: 1 n/a (no spec), 2 yes, 3 yes, 4 yes, 5 yes, 6 n/a, 7 yes, 8 yes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5mjnQAcpdnyqK2Xi6y --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mjnQAcpdnyqK2Xi6y)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30f23fe commit b065fad

6 files changed

Lines changed: 639 additions & 20 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a refused SAML sign-in lands on the error URL, as OIDC does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The SAML ACS (`POST /sso/saml2/sp/acs/:providerId`) used to redirect an account refusal (`account_not_linked`, `signup_disabled`, `unable_to_link_account`, `unable_to_create_user`, `email_not_verified`, `unable_to_create_session`) to the sign-in's success `callbackURL` with only `?error=`, so the user landed on the signed-in page without being signed in. A response over the size limit was sent to the app origin. Every SAML refusal now lands where the OIDC callback sends one: the sign-in's `errorCallbackURL`, or the platform's sign-in page when none was given. `error=` and `error_description` are unchanged. A successful sign-in still lands on `callbackURL`.

‎content/docs/permissions/sso.mdx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -351,12 +351,17 @@ configure on the IdP side:
351351
### SAML sign-in flow
352352

353353
1. User enters their work email (or clicks the enterprise-login button).
354-
2. Client calls `POST /api/v1/auth/sign-in/sso` with `{ email, callbackURL }`.
354+
2. Client calls `POST /api/v1/auth/sign-in/sso` with `{ email, callbackURL }`
355+
(and optionally `errorCallbackURL`).
355356
3. The email **domain** is matched to the registered provider; the response is a
356357
redirect to the IdP's `entryPoint` carrying a signed `SAMLRequest`.
357358
4. The IdP authenticates the user and POSTs a SAML assertion back to the SP ACS
358359
URL; `@better-auth/sso` (samlify) validates the signature/timestamp, then
359360
creates a session and redirects to `callbackURL`.
361+
5. A refused sign-in redirects to `errorCallbackURL`, or to the platform's
362+
sign-in page when none was given, with `error=` naming the refusal: for
363+
example `account_not_linked` when the email belongs to an existing account
364+
this provider is not linked to. It never lands on `callbackURL`, as with OIDC.
360365

361366
> Signature, timestamp, and replay validation are handled by `@better-auth/sso`
362367
> (samlify). You only supply the IdP's signing certificate at registration time.

‎packages/plugins/plugin-auth/src/auth-manager-request-routing.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { clientAddressStateEntries } from './request-client-address.js';
6767
import { SESSION_ERASURE_PATHS } from './session-tombstone.js';
6868
import { envelopeVendorAdminRefusal } from './vendor-admin-refusal-envelope.js';
6969
import { OAUTH_REVOKE_PATH } from './mcp-token-revocation.js';
70-
import { ssoOidcCallbackProviderId } from './sso-oidc-refusal-cause.js';
70+
import { ssoCallbackOf } from './sso-oidc-refusal-cause.js';
7171

7272
/**
7373
* The `AuthManager` members the functions below read — every one of them, and
@@ -86,11 +86,11 @@ export interface RequestRoutingHost {
8686
resolveClientAddress: AuthManager['resolveClientAddress'];
8787
runSubjectErasureAtomically: AuthManager['runSubjectErasureAtomically'];
8888
answerOAuthTokenRevocation: AuthManager['answerOAuthTokenRevocation'];
89-
runSsoOidcCallback: AuthManager['runSsoOidcCallback'];
89+
runSsoCallback: AuthManager['runSsoCallback'];
9090
}
9191

9292
/** The body of `AuthManager#handleRequest`; the method's docblock there is its contract. */
93-
export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation' | 'runSsoOidcCallback'>, request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
93+
export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation' | 'runSsoCallback'>, request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
9494
// ADR-0069 D7 — the client address this request's sign-in audit stamps,
9595
// established ONCE, here, by the same trust rule the D5 allow-list judges
9696
// with (`resolveClientAddress`). Read before any rewrite below, and carried
@@ -212,9 +212,10 @@ export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEn
212212
// longer sign in. Nothing tells the operator, and there is no way back.
213213
const endpointPath = this.betterAuthEndpointPath(request);
214214

215-
// An enterprise OIDC callback runs inside the refusal scope that puts the
216-
// cause of a refused ID token on the redirect (`sso-oidc-refusal-cause.ts`).
217-
const ssoOidcProviderId = ssoOidcCallbackProviderId(endpointPath);
215+
// An enterprise SSO callback runs inside the refusal scope
216+
// (`sso-oidc-refusal-cause.ts`): the OIDC callback puts the cause of a refused
217+
// ID token on the redirect, and a refused SAML sign-in lands on the error URL.
218+
const ssoCallback = ssoCallbackOf(endpointPath, request);
218219

219220
// [#3653 / #14522] A SCIM protocol request (`/scim/v2/*`) runs inside
220221
// `scimRequestScope`, which is what makes the adapter's `transaction`
@@ -236,8 +237,8 @@ export async function handleRequest(this: Pick<RequestRoutingHost, 'betterAuthEn
236237
const { scimRequestScope } = await import('./scim-connection-service.js');
237238
return scimRequestScope.run({ scim: true }, runHandler);
238239
}
239-
: ssoOidcProviderId !== undefined
240-
? (): Promise<Response> => this.runSsoOidcCallback(ssoOidcProviderId, runHandler)
240+
: ssoCallback !== undefined
241+
? (): Promise<Response> => this.runSsoCallback(ssoCallback, runHandler)
241242
: runHandler;
242243
// [objectstack-ai/cloud#3606, ruling A] `/oauth2/revoke` is answered a
243244
// second time, AFTER the provider: the provider authenticates the client

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ import { resolvePasswordHasher } from './auth-manager-password-hasher.js';
250250
// after-hook (cut 12) and before-hook (cut 13) are now imported there, by the
251251
// two arrows that moved with it.
252252
import { createAuthInstance, type AuthInstanceHost } from './auth-manager-auth-instance.js';
253-
import { runSsoOidcCallbackScope } from './sso-oidc-refusal-cause.js';
253+
import { readSamlAcsLanding, runSsoCallbackScope, type SamlLandingContext, type SsoCallbackRequest } from './sso-oidc-refusal-cause.js';
254254
// [#25224 cut 16] The body of `buildPluginList`; the method of the same name
255255
// below hands it the members it reads (`PluginListHost`). The organization
256256
// plugin's factory (cut 15) is now imported there, by the arrow that moved
@@ -3939,24 +3939,36 @@ export class AuthManager {
39393939
*/
39403940
handleRequest(request: Request, transport?: { remoteAddress?: string }): Promise<Response> {
39413941
// [#25224 cut 10] The body moved to `auth-manager-request-routing.ts` (see `RequestRoutingHost`).
3942-
const host: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation' | 'runSsoOidcCallback'> = {
3942+
const host: Pick<RequestRoutingHost, 'betterAuthEndpointPath' | 'getOrCreateAuth' | 'resolveClientAddress' | 'runSubjectErasureAtomically' | 'answerOAuthTokenRevocation' | 'runSsoCallback'> = {
39433943
betterAuthEndpointPath: (...a) => this.betterAuthEndpointPath(...a),
39443944
getOrCreateAuth: (...a) => this.getOrCreateAuth(...a),
39453945
resolveClientAddress: (...a) => this.resolveClientAddress(...a),
39463946
runSubjectErasureAtomically: (...a) => this.runSubjectErasureAtomically(...a),
39473947
answerOAuthTokenRevocation: (...a) => this.answerOAuthTokenRevocation(...a),
3948-
runSsoOidcCallback: (...a) => this.runSsoOidcCallback(...a),
3948+
runSsoCallback: (...a) => this.runSsoCallback(...a),
39493949
};
39503950
return handleRequest.call(host, request, transport);
39513951
}
39523952

39533953
/**
3954-
* Run one enterprise OIDC callback (`/sso/callback/:providerId`) so its
3955-
* refusal names its cause: the redirect's `error_description` and one `warn`
3956-
* line on this manager's logger (`sso-oidc-refusal-cause.ts`).
3954+
* Run one enterprise SSO callback inside the refusal scope
3955+
* (`sso-oidc-refusal-cause.ts`): an OIDC callback (`/sso/callback/:providerId`)
3956+
* whose refusal names its cause on the redirect's `error_description` and in
3957+
* one `warn` line on this manager's logger, and a SAML ACS
3958+
* (`/sso/saml2/sp/acs/:providerId`) whose refusal lands on the sign-in's error
3959+
* URL, read from the relay state before better-auth consumes it.
39573960
*/
3958-
private runSsoOidcCallback(providerId: string, run: () => Promise<Response>): Promise<Response> {
3959-
return runSsoOidcCallbackScope(providerId, this.config.logger, run);
3961+
private runSsoCallback(callback: SsoCallbackRequest, run: () => Promise<Response>): Promise<Response> {
3962+
return runSsoCallbackScope(
3963+
callback,
3964+
this.config.logger,
3965+
async (saml) => {
3966+
const auth: any = await this.getOrCreateAuth();
3967+
const context = (await auth.$context) as SamlLandingContext;
3968+
return readSamlAcsLanding(context, saml.providerId, saml.request);
3969+
},
3970+
run,
3971+
);
39603972
}
39613973

39623974
/**

‎packages/plugins/plugin-auth/src/sso-oidc-refusal-cause.ts‎

Lines changed: 218 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,44 @@
4444
* nothing else: no token content, no claim value, no subject or email, and
4545
* nothing that depends on whether an account exists. The refusal happens
4646
* 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.
5051
*
5152
* The values are lowercase `error_description` tokens in the vendor's own
5253
* dialect for that slot (`token_not_verified`, `id_token_subject_missing`), not
5354
* ADR-0112 `error.code` members: a redirect query is not one of that
5455
* 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.
5585
*/
5686

5787
import { AsyncLocalStorage } from 'node:async_hooks';
@@ -295,3 +325,188 @@ export async function runSsoOidcCallbackScope(
295325
headers.set('location', withDescription(location, classified.cause));
296326
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
297327
}
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

Comments
 (0)