diff --git a/README.md b/README.md index c107ae3..2ce7dd7 100644 --- a/README.md +++ b/README.md @@ -229,13 +229,29 @@ Use `state` to preserve data across the authentication redirect: ``` +> **Security:** `state` round-trips through the OAuth redirect as plaintext in +> the URL and is **not** integrity protected — treat anything read from it as +> untrusted input. Before navigating to a `returnTo`-style value, validate it +> against your own origin so an attacker cannot smuggle a `javascript:` URI or +> an off-site open-redirect target. + ```jsx // Retrieve it in onRedirectCallback { - if (state?.returnTo) { - window.location.href = state.returnTo; + if (typeof state?.returnTo !== "string") return; + let url; + try { + url = new URL(state.returnTo, window.location.origin); + } catch { + return; // malformed URL — ignore + } + // Only navigate to a same-origin destination. Use the parsed absolute + // URL, not a value rebuilt from url.pathname (a "//evil.com" pathname + // would redirect off-site). + if (url.origin === window.location.origin) { + window.location.href = url.href; } }} > diff --git a/package-lock.json b/package-lock.json index c3789da..515100d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.16.1", "license": "MIT", "dependencies": { - "@workos-inc/authkit-js": "0.20.0" + "@workos-inc/authkit-js": "^0.20.1" }, "devDependencies": { "@types/react": "18.3.3", @@ -794,9 +794,9 @@ } }, "node_modules/@workos-inc/authkit-js": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@workos-inc/authkit-js/-/authkit-js-0.20.0.tgz", - "integrity": "sha512-8+sw8eH1+XCt3NDoIB2bKKbHvq1N7CVFJeyq1uVrUWG83GCIbNNrFwWrocVNfeQJyhKsVVuDpRcp03iSVsK5Gg==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@workos-inc/authkit-js/-/authkit-js-0.20.1.tgz", + "integrity": "sha512-ThIC/LrN2I9QbdJFCLmCNW6YAwOMmO7IYDz+KKN+gV9lUpY6grRU7dxfb6tjt0vNK8adQ9mSpNRoRQQFuEDGRQ==", "license": "MIT" }, "node_modules/acorn": { diff --git a/package.json b/package.json index cc91092..73a282b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,6 @@ "react": ">=17" }, "dependencies": { - "@workos-inc/authkit-js": "0.20.0" + "@workos-inc/authkit-js": "^0.20.1" } } diff --git a/src/provider.tsx b/src/provider.tsx index 1f61575..1e18ba1 100644 --- a/src/provider.tsx +++ b/src/provider.tsx @@ -69,6 +69,11 @@ export function AuthKitProvider(props: AuthKitProviderProps) { React.useEffect(() => { function initialize() { + // createClient() cannot be cancelled once started, so a superseded + // initialization (props changed mid-flight) may settle after this effect + // has been cleaned up. Ignore those stale results so they cannot clobber + // the current initialization's state. + let cancelled = false; const timeoutId = setTimeout(() => { createClient(clientId, { apiHostname, @@ -80,23 +85,34 @@ export function AuthKitProvider(props: AuthKitProviderProps) { onRefresh: handleRefresh, onRefreshFailure, refreshBufferInterval, - }).then(async (client) => { - const user = client.getUser(); - setClient({ - getAccessToken: client.getAccessToken.bind(client), - getUser: client.getUser.bind(client), - signIn: client.signIn.bind(client), - signUp: client.signUp.bind(client), - signOut: client.signOut.bind(client), - switchToOrganization: client.switchToOrganization.bind(client), - getSignInUrl: client.getSignInUrl.bind(client), - getSignUpUrl: client.getSignUpUrl.bind(client), + }) + .then(async (client) => { + if (cancelled) return; + const user = client.getUser(); + setClient({ + getAccessToken: client.getAccessToken.bind(client), + getUser: client.getUser.bind(client), + signIn: client.signIn.bind(client), + signUp: client.signUp.bind(client), + signOut: client.signOut.bind(client), + switchToOrganization: client.switchToOrganization.bind(client), + getSignInUrl: client.getSignInUrl.bind(client), + getSignUpUrl: client.getSignUpUrl.bind(client), + }); + setState((prev) => ({ ...prev, isLoading: false, user })); + }) + .catch((error) => { + if (cancelled) return; + // Never leave the app wedged on `isLoading: true` if client + // initialization rejects (e.g. a crafted callback URL). Surface the + // error and settle into an unauthenticated state. + console.error(error); + setState((prev) => ({ ...prev, isLoading: false, user: null })); }); - setState((prev) => ({ ...prev, isLoading: false, user })); - }); }); return () => { + cancelled = true; clearTimeout(timeoutId); }; }