diff --git a/.changeset/admin-banned-oauth-error-callback.md b/.changeset/admin-banned-oauth-error-callback.md new file mode 100644 index 0000000000..90c34729e1 --- /dev/null +++ b/.changeset/admin-banned-oauth-error-callback.md @@ -0,0 +1,6 @@ +--- +"@better-auth/sso": patch +"better-auth": patch +--- + +Banned users signing in with an OAuth provider now redirect to the `errorCallbackURL` passed to `signIn.social`, with `?error=BANNED_USER&error_description=` in the query string. Previously the redirect went to the auth server's default error page with `?error=banned`, which broke multi-domain deployments where the auth host and frontend host differ. The `oauth-proxy`, SSO OIDC, and SAML callbacks now also redirect hook rejections to the error URL (previously returned JSON 403), and `oauth-proxy` URL-encodes the `error` query value across all its redirects. diff --git a/packages/better-auth/src/api/routes/callback.ts b/packages/better-auth/src/api/routes/callback.ts index f8d51622a7..e3870e168f 100644 --- a/packages/better-auth/src/api/routes/callback.ts +++ b/packages/better-auth/src/api/routes/callback.ts @@ -9,6 +9,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account"; import { parseState } from "../../oauth2/state"; import { setTokenUtil } from "../../oauth2/utils"; import { HIDE_METADATA } from "../../utils/hide-metadata"; +import { isAPIError } from "../../utils/is-api-error"; const schema = z.object({ code: z.string().optional(), @@ -253,20 +254,28 @@ export const callbackOAuth = createAuthEndpoint( ...tokens, scope: tokens.scopes?.join(","), }; - const result = await handleOAuthUserInfo(c, { - userInfo: { - ...userInfo, - id: providerAccountId, - email: userInfo.email, - name: userInfo.name || "", - }, - account: accountData, - callbackURL, - disableSignUp: - (provider.disableImplicitSignUp && !requestSignUp) || - provider.options?.disableSignUp, - overrideUserInfo: provider.options?.overrideUserInfoOnSignIn, - }); + let result: Awaited>; + try { + result = await handleOAuthUserInfo(c, { + userInfo: { + ...userInfo, + id: providerAccountId, + email: userInfo.email, + name: userInfo.name || "", + }, + account: accountData, + callbackURL, + disableSignUp: + (provider.disableImplicitSignUp && !requestSignUp) || + provider.options?.disableSignUp, + overrideUserInfo: provider.options?.overrideUserInfoOnSignIn, + }); + } catch (e) { + if (isAPIError(e) && e.body?.code) { + return redirectOnError(e.body.code, e.body.message); + } + throw e; + } if (result.error) { c.context.logger.error(result.error.split(" ").join("_")); return redirectOnError(result.error.split(" ").join("_")); diff --git a/packages/better-auth/src/plugins/admin/admin.test.ts b/packages/better-auth/src/plugins/admin/admin.test.ts index e946de5a90..16a0ec75f2 100644 --- a/packages/better-auth/src/plugins/admin/admin.test.ts +++ b/packages/better-auth/src/plugins/admin/admin.test.ts @@ -1,5 +1,6 @@ import { BetterAuthError } from "@better-auth/core/error"; import type { GoogleProfile } from "@better-auth/core/social-providers"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { @@ -75,6 +76,7 @@ describe("Admin plugin", async () => { customFetchImpl, } = await getTestInstance( { + trustedOrigins: ["https://frontend.example.com"], plugins: [ admin({ bannedUserMessage: "Custom banned user message", @@ -620,7 +622,44 @@ describe("Admin plugin", async () => { }, }); expect(errorLocation).toBeDefined(); - expect(errorLocation).toContain("error=banned"); + expect(errorLocation).toContain("error=BANNED_USER"); + }); + + it("should redirect banned social sign-in to a cross-origin errorCallbackURL", async () => { + const errorCallbackURL = "https://frontend.example.com/auth-error"; + const headers = new Headers(); + const res = await client.signIn.social( + { + provider: "google", + errorCallbackURL, + }, + { + throw: true, + onSuccess: cookieSetter(headers), + }, + ); + const state = new URL(res.url!).searchParams.get("state"); + let errorLocation: string | null = null; + await client.$fetch("/callback/google", { + query: { + state, + code: "test", + }, + headers, + method: "GET", + onError(context) { + expect(context.response.status).toBe(302); + errorLocation = context.response.headers.get("location"); + }, + }); + expect(errorLocation).toBeTruthy(); + const url = new URL(errorLocation!); + expect(url.origin).toBe("https://frontend.example.com"); + expect(`${url.origin}${url.pathname}`).toBe(errorCallbackURL); + expect(url.searchParams.get("error")).toBe("BANNED_USER"); + expect(url.searchParams.get("error_description")).toBe( + "Custom banned user message", + ); }); it("should change banned user message", async () => { @@ -1935,3 +1974,67 @@ describe("edge cases: userId validation", async () => { ).rejects.toThrow("user not found"); }); }); + +describe("Admin plugin id-token sign-in", async () => { + const googleKeyPair = await generateKeyPair("RS256"); + const googleJwk = await exportJWK(googleKeyPair.publicKey); + const googleKid = "test-admin-google-kid"; + googleJwk.kid = googleKid; + googleJwk.alg = "RS256"; + googleJwk.use = "sig"; + + const clientId = "admin-idtoken-client.googleusercontent.com"; + + const signIdToken = (email: string) => + new SignJWT({ + email, + email_verified: true, + name: "Banned ID Token User", + sub: "banned-google-sub", + }) + .setProtectedHeader({ alg: "RS256", kid: googleKid }) + .setIssuedAt() + .setIssuer("https://accounts.google.com") + .setAudience(clientId) + .setExpirationTime("1h") + .sign(googleKeyPair.privateKey); + + beforeAll(() => { + server.use( + http.get("https://www.googleapis.com/oauth2/v3/certs", () => + HttpResponse.json({ keys: [googleJwk] }), + ), + ); + }); + + it("should return BANNED_USER 403 JSON for id-token sign-in by banned user", async () => { + const { client } = await getTestInstance( + { + plugins: [admin({ bannedUserMessage: "Custom banned user message" })], + socialProviders: { + google: { clientId, clientSecret: "test-secret" }, + }, + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { ...user, emailVerified: true, banned: true }, + }), + }, + }, + }, + }, + { disableTestUser: true }, + ); + + const idToken = await signIdToken("banned-idtoken@test.com"); + const res = await client.signIn.social({ + provider: "google", + idToken: { token: idToken }, + }); + + expect(res.error?.status).toBe(403); + expect(res.error?.code).toBe("BANNED_USER"); + expect(res.error?.message).toBe("Custom banned user message"); + }); +}); diff --git a/packages/better-auth/src/plugins/admin/admin.ts b/packages/better-auth/src/plugins/admin/admin.ts index 7fe5bcee7d..1032ee26f6 100644 --- a/packages/better-auth/src/plugins/admin/admin.ts +++ b/packages/better-auth/src/plugins/admin/admin.ts @@ -113,19 +113,6 @@ export const admin = (options?: O | undefined) => { return; } - if ( - ctx && - (ctx.path.startsWith("/callback") || - ctx.path.startsWith("/oauth2/callback")) - ) { - const redirectURI = - ctx.context.options.onAPIError?.errorURL || - `${ctx.context.baseURL}/error`; - throw ctx.redirect( - `${redirectURI}?error=banned&error_description=${opts.bannedUserMessage}`, - ); - } - throw APIError.from("FORBIDDEN", { message: opts.bannedUserMessage, code: "BANNED_USER", diff --git a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts index b2995aedad..d9dc7e8452 100644 --- a/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts +++ b/packages/better-auth/src/plugins/generic-oauth/generic-oauth.test.ts @@ -1,5 +1,6 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { runWithEndpointContext } from "@better-auth/core/context"; +import { APIError } from "@better-auth/core/error"; import { betterFetch } from "@better-fetch/fetch"; import { OAuth2Server } from "oauth2-mock-server"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; @@ -486,6 +487,82 @@ describe("oauth2", async () => { ); }); + it("should redirect to cross-origin errorCallbackURL when a session hook throws APIError", async () => { + server.service.once("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "hook-reject@test.com", + name: "Hook Reject User", + sub: "hook-reject", + picture: "https://test.com/picture.png", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + const { customFetchImpl, cookieSetter } = await getTestInstance( + { + trustedOrigins: ["https://frontend.example.com"], + plugins: [ + genericOAuth({ + config: [ + { + providerId: "test-hook-reject", + discoveryUrl: `http://localhost:${port}/.well-known/openid-configuration`, + clientId, + clientSecret, + pkce: true, + }, + ], + }), + ], + databaseHooks: { + user: { + create: { + before: async (user) => ({ + data: { ...user, emailVerified: true }, + }), + }, + }, + session: { + create: { + before: async () => { + throw APIError.from("FORBIDDEN", { + code: "HOOK_REJECTED", + message: "Session hook rejected this user", + }); + }, + }, + }, + }, + }, + { disableTestUser: true }, + ); + const authClient = createAuthClient({ + plugins: [genericOAuthClient()], + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + const headers = new Headers(); + const res = await authClient.signIn.oauth2({ + providerId: "test-hook-reject", + callbackURL: "https://frontend.example.com/dashboard", + errorCallbackURL: "https://frontend.example.com/auth-error", + fetchOptions: { onSuccess: cookieSetter(headers) }, + }); + const { callbackURL } = await simulateOAuthFlow( + res.data?.url || "", + headers, + customFetchImpl, + ); + const url = new URL(callbackURL); + expect(url.origin).toBe("https://frontend.example.com"); + expect(url.pathname).toBe("/auth-error"); + expect(url.searchParams.get("error")).toBe("HOOK_REJECTED"); + expect(url.searchParams.get("error_description")).toBe( + "Session hook rejected this user", + ); + }); + it("should create user when sign ups are disabled and sign up is requested", async () => { server.service.once("beforeUserinfo", (userInfoResponse) => { userInfoResponse.body = { diff --git a/packages/better-auth/src/plugins/generic-oauth/routes.ts b/packages/better-auth/src/plugins/generic-oauth/routes.ts index 77c11f048d..8a2c19ec66 100644 --- a/packages/better-auth/src/plugins/generic-oauth/routes.ts +++ b/packages/better-auth/src/plugins/generic-oauth/routes.ts @@ -17,6 +17,7 @@ import { generateState, parseState } from "../../oauth2/state"; import { setTokenUtil } from "../../oauth2/utils"; import type { User } from "../../types"; import { HIDE_METADATA } from "../../utils"; +import { isAPIError } from "../../utils/is-api-error"; import { GENERIC_OAUTH_ERROR_CODES } from "./error-codes"; import type { GenericOAuthOptions } from "./types"; @@ -315,17 +316,15 @@ export const oAuth2Callback = (options: GenericOAuthOptions) => } = parsedState; const code = ctx.query.code; - function redirectOnError(error: string) { + function redirectOnError(error: string, description?: string) { const defaultErrorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`; - let url = errorURL || defaultErrorURL; - if (url.includes("?")) { - url = `${url}&error=${encodeURIComponent(error)}`; - } else { - url = `${url}?error=${encodeURIComponent(error)}`; - } - throw ctx.redirect(url); + const baseURL = errorURL || defaultErrorURL; + const params = new URLSearchParams({ error }); + if (description) params.set("error_description", description); + const sep = baseURL.includes("?") ? "&" : "?"; + throw ctx.redirect(`${baseURL}${sep}${params.toString()}`); } let finalTokenUrl = providerConfig.tokenUrl; @@ -514,20 +513,28 @@ export const oAuth2Callback = (options: GenericOAuthOptions) => throw ctx.redirect(toRedirectTo); } - const result = await handleOAuthUserInfo(ctx, { - userInfo, - account: { - providerId: providerConfig.providerId, - accountId: userInfo.id, - ...tokens, - scope: tokens.scopes?.join(","), - }, - callbackURL: callbackURL, - disableSignUp: - (providerConfig.disableImplicitSignUp && !requestSignUp) || - providerConfig.disableSignUp, - overrideUserInfo: providerConfig.overrideUserInfo, - }); + let result: Awaited>; + try { + result = await handleOAuthUserInfo(ctx, { + userInfo, + account: { + providerId: providerConfig.providerId, + accountId: userInfo.id, + ...tokens, + scope: tokens.scopes?.join(","), + }, + callbackURL: callbackURL, + disableSignUp: + (providerConfig.disableImplicitSignUp && !requestSignUp) || + providerConfig.disableSignUp, + overrideUserInfo: providerConfig.overrideUserInfo, + }); + } catch (e) { + if (isAPIError(e) && e.body?.code) { + return redirectOnError(e.body.code, e.body.message); + } + throw e; + } if (result.error) { return redirectOnError(result.error.split(" ").join("_")); diff --git a/packages/better-auth/src/plugins/oauth-proxy/index.ts b/packages/better-auth/src/plugins/oauth-proxy/index.ts index 674187d322..d2f2d0c4a7 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/index.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/index.ts @@ -19,6 +19,7 @@ import { handleOAuthUserInfo } from "../../oauth2/link-account"; import type { StateData } from "../../state"; import { parseGenericState } from "../../state"; import type { Account, User } from "../../types"; +import { isAPIError } from "../../utils/is-api-error"; import { getOrigin } from "../../utils/url"; import { PACKAGE_VERSION } from "../../version"; import { @@ -242,12 +243,20 @@ export const oAuthProxy = (opts?: O) => { ctx.context.logger.warn("Failed to clean up OAuth state", e); } - const result = await handleOAuthUserInfo(ctx, { - userInfo: payload.userInfo, - account: payload.account, - callbackURL: payload.callbackURL, - disableSignUp: payload.disableSignUp, - }); + let result: Awaited>; + try { + result = await handleOAuthUserInfo(ctx, { + userInfo: payload.userInfo, + account: payload.account, + callbackURL: payload.callbackURL, + disableSignUp: payload.disableSignUp, + }); + } catch (e) { + if (isAPIError(e) && e.body?.code) { + throw redirectOnError(ctx, errorURL, e.body.code, e.body.message); + } + throw e; + } if (result.error || !result.data) { ctx.context.logger.error( "Failed to create user or session", diff --git a/packages/better-auth/src/plugins/oauth-proxy/utils.ts b/packages/better-auth/src/plugins/oauth-proxy/utils.ts index e1a8f9e388..787694ac12 100644 --- a/packages/better-auth/src/plugins/oauth-proxy/utils.ts +++ b/packages/better-auth/src/plugins/oauth-proxy/utils.ts @@ -79,7 +79,10 @@ export function redirectOnError( ctx: GenericEndpointContext, errorURL: string, error: string, + description?: string, ): never { + const params = new URLSearchParams({ error }); + if (description) params.set("error_description", description); const sep = errorURL.includes("?") ? "&" : "?"; - throw ctx.redirect(`${errorURL}${sep}error=${error}`); + throw ctx.redirect(`${errorURL}${sep}${params.toString()}`); } diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index 8b90a1f340..56376454e9 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -1,3 +1,4 @@ +import { APIError } from "@better-auth/core/error"; import { betterFetch } from "@better-fetch/fetch"; import { createAuthClient } from "better-auth/client"; import { organization } from "better-auth/plugins"; @@ -1837,3 +1838,125 @@ describe("SSO OIDC UserInfo endpoint sub claim mapping", async () => { }); }); }); + +describe("SSO OIDC hook rejection redirect", async () => { + const hookServer = new OAuth2Server(); + + beforeAll(async () => { + await hookServer.issuer.keys.generate("RS256"); + await hookServer.start(8090, "localhost"); + }); + + afterAll(async () => { + await hookServer.stop().catch(() => {}); + }); + + hookServer.service.on("beforeUserinfo", (userInfoResponse) => { + userInfoResponse.body = { + email: "rejected@test.com", + name: "Rejected User", + sub: "rejected-sub", + email_verified: true, + }; + userInfoResponse.statusCode = 200; + }); + + hookServer.service.on("beforeTokenSigning", (token) => { + token.payload.email = "rejected@test.com"; + token.payload.email_verified = true; + }); + + const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = + await getTestInstance({ + trustedOrigins: ["http://localhost:8090", "https://frontend.example.com"], + plugins: [sso()], + databaseHooks: { + session: { + create: { + before: async (session, ctx) => { + if (!ctx) return; + const user = await ctx.context.internalAdapter.findUserById( + session.userId, + ); + if (user?.email === "rejected@test.com") { + throw APIError.from("FORBIDDEN", { + code: "HOOK_REJECTED", + message: "SSO hook rejected this user", + }); + } + }, + }, + }, + }, + }); + + const authClient = createAuthClient({ + plugins: [ssoClient()], + baseURL: "http://localhost:3000", + fetchOptions: { customFetchImpl }, + }); + + it("should redirect to cross-origin errorCallbackURL when a session hook throws APIError", async () => { + const { headers: adminHeaders } = await signInWithTestUser(); + await auth.api.registerSSOProvider({ + body: { + issuer: hookServer.issuer.url!, + domain: "hook-reject.com", + providerId: "hook-reject", + oidcConfig: { + clientId: "test", + clientSecret: "test", + authorizationEndpoint: `${hookServer.issuer.url}/authorize`, + tokenEndpoint: `${hookServer.issuer.url}/token`, + jwksEndpoint: `${hookServer.issuer.url}/jwks`, + discoveryEndpoint: `${hookServer.issuer.url}/.well-known/openid-configuration`, + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + }, + }, + }, + headers: adminHeaders, + }); + + const signInHeaders = new Headers(); + const res = await authClient.signIn.sso({ + providerId: "hook-reject", + callbackURL: "https://frontend.example.com/dashboard", + errorCallbackURL: "https://frontend.example.com/auth-error", + fetchOptions: { + throw: true, + onSuccess: cookieSetter(signInHeaders), + }, + }); + + let location: string | null = null; + await betterFetch(res.url, { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + let callbackURL = ""; + await betterFetch(location!, { + method: "GET", + customFetchImpl, + headers: signInHeaders, + onError(context) { + callbackURL = context.response.headers.get("location") || ""; + }, + }); + + const url = new URL(callbackURL); + expect(url.origin).toBe("https://frontend.example.com"); + expect(url.pathname).toBe("/auth-error"); + expect(url.searchParams.get("error")).toBe("HOOK_REJECTED"); + expect(url.searchParams.get("error_description")).toBe( + "SSO hook rejected this user", + ); + }); +}); diff --git a/packages/sso/src/routes/saml-pipeline.ts b/packages/sso/src/routes/saml-pipeline.ts index 5812dbc846..9f4e2ded38 100644 --- a/packages/sso/src/routes/saml-pipeline.ts +++ b/packages/sso/src/routes/saml-pipeline.ts @@ -1,3 +1,4 @@ +import { isAPIError } from "@better-auth/core/utils/is-api-error"; import type { User } from "better-auth"; import { APIError } from "better-auth/api"; import { setSessionCookie } from "better-auth/cookies"; @@ -427,24 +428,36 @@ export async function processSAMLResponse( relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL; + const errorUrl = relayState?.errorURL || samlRedirectUrl; - const result = await handleOAuthUserInfo(ctx, { - userInfo: { - email: userInfo.email as string, - name: (userInfo.name || userInfo.email) as string, - id: userInfo.id as string, - emailVerified: Boolean(userInfo.emailVerified), - }, - account: { - providerId, - accountId: userInfo.id as string, - accessToken: "", - refreshToken: "", - }, - callbackURL: callbackUrl, - disableSignUp: options?.disableImplicitSignUp, - isTrustedProvider, - }); + let result: Awaited>; + try { + result = await handleOAuthUserInfo(ctx, { + userInfo: { + email: userInfo.email as string, + name: (userInfo.name || userInfo.email) as string, + id: userInfo.id as string, + emailVerified: Boolean(userInfo.emailVerified), + }, + account: { + providerId, + accountId: userInfo.id as string, + accessToken: "", + refreshToken: "", + }, + callbackURL: callbackUrl, + disableSignUp: options?.disableImplicitSignUp, + isTrustedProvider, + }); + } catch (e) { + if (isAPIError(e) && e.body?.code) { + const params = new URLSearchParams({ error: e.body.code }); + if (e.body.message) params.set("error_description", e.body.message); + const sep = errorUrl.includes("?") ? "&" : "?"; + throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`); + } + throw e; + } if (result.error) { throw ctx.redirect( diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index 0cf6ccdddf..cf546dc4bb 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -1,3 +1,4 @@ +import { isAPIError } from "@better-auth/core/utils/is-api-error"; import { BetterFetchError, betterFetch } from "@better-fetch/fetch"; import { createAuthorizationURL, @@ -1654,31 +1655,43 @@ async function handleOIDCCallback( (provider as { domainVerified?: boolean }).domainVerified === true && validateEmailDomain(userInfo.email, provider.domain); - const linked = await handleOAuthUserInfo(ctx, { - userInfo: { - email: userInfo.email, - name: userInfo.name || "", - id: userInfo.id, - image: userInfo.image, - emailVerified: options?.trustEmailVerified - ? userInfo.emailVerified || false - : false, - }, - account: { - idToken: tokenResponse.idToken, - accessToken: tokenResponse.accessToken, - refreshToken: tokenResponse.refreshToken, - accountId: userInfo.id, - providerId: provider.providerId, - accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt, - refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt, - scope: tokenResponse.scopes?.join(","), - }, - callbackURL, - disableSignUp: options?.disableImplicitSignUp && !requestSignUp, - overrideUserInfo: config.overrideUserInfo, - isTrustedProvider, - }); + let linked: Awaited>; + try { + linked = await handleOAuthUserInfo(ctx, { + userInfo: { + email: userInfo.email, + name: userInfo.name || "", + id: userInfo.id, + image: userInfo.image, + emailVerified: options?.trustEmailVerified + ? userInfo.emailVerified || false + : false, + }, + account: { + idToken: tokenResponse.idToken, + accessToken: tokenResponse.accessToken, + refreshToken: tokenResponse.refreshToken, + accountId: userInfo.id, + providerId: provider.providerId, + accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt, + refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt, + scope: tokenResponse.scopes?.join(","), + }, + callbackURL, + disableSignUp: options?.disableImplicitSignUp && !requestSignUp, + overrideUserInfo: config.overrideUserInfo, + isTrustedProvider, + }); + } catch (e) { + if (isAPIError(e) && e.body?.code) { + const baseURL = errorURL || callbackURL; + const params = new URLSearchParams({ error: e.body.code }); + if (e.body.message) params.set("error_description", e.body.message); + const sep = baseURL.includes("?") ? "&" : "?"; + throw ctx.redirect(`${baseURL}${sep}${params.toString()}`); + } + throw e; + } if (linked.error) { throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`); }