OAuth (Google)
The oauth module covers both ends of a social login: the button the user clicks (<GoogleSignIn>) and the callback route that swaps the provider credential for a session from your API (useOAuthCallback).
Why wrap @react-oauth/google?
Two pains. First, Google's success payload arrives as { credential?: string } — optional, so every call site has to check for the empty case, and onError receives no argument at all. Second, if the SDK declared @react-oauth/google as a peer dep, every app installing tempest-react-sdk would pay for it, including the ones with no social login. The wrapper solves both: it normalises success/failure into OAuthCredential/OAuthError, and you pass GoogleLogin through the component prop.
Install the Google SDK
@react-oauth/google belongs to your app, not to the SDK:
npm install @react-oauth/google
The button: <GoogleSignIn>
A complete, copy-pasteable program:
import { GoogleLogin, GoogleOAuthProvider } from "@react-oauth/google";
import { GoogleSignIn } from "tempest-react-sdk";
export function LoginPage() {
return (
<GoogleOAuthProvider clientId={import.meta.env.VITE_GOOGLE_CLIENT_ID}>
<GoogleSignIn
component={GoogleLogin}
locale="en-US"
text="continue_with"
onSuccess={async ({ idToken }) => {
await fetch("/api/auth/google", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ id_token: idToken }),
});
}}
onError={(error) => console.error(error.message)}
/>
</GoogleOAuthProvider>
);
}
Piece by piece:
GoogleOAuthProvider(from@react-oauth/google) loads Google's script and injects theclientId. It wraps around, usually at the top of the tree or just on the login page.component={GoogleLogin}is the dependency injection — that is how the SDK renders Google's official button without depending on it.onSuccessreceives an already-validatedOAuthCredential: if Google returns a response with nocredential, the wrapper callsonErrorand does not callonSuccess. You never deal with an emptyidToken.onErrorreceives anOAuthErrorwithproviderandmessagefilled in.
Props
| Prop | Type | Description |
|---|---|---|
component |
(props) => ReactNode |
Required. The GoogleLogin imported by your app. |
onSuccess |
(c: OAuthCredential) => void \| Promise |
Required. Receives { idToken, provider, raw }. |
onError |
(e: OAuthError) => void |
Normalised error (including the "no credential" case). |
locale |
string |
Button language (e.g. "pt-BR"). Defaults to the browser locale. |
theme |
"filled_blue" \| "filled_black" \| "outline" |
Visual theme passed through to Google. |
text |
"signin_with" \| "signup_with" \| "continue_with" \| "signin" |
Button copy. |
shape |
"rectangular" \| "pill" \| "circle" \| "square" |
Button shape. |
size |
"large" \| "medium" \| "small" |
Size. |
disableOneTap |
boolean |
Turns off the "One Tap" auto-prompt. Default false (One Tap on). |
width |
number |
Width in px — Google's button is fixed-width by default. |
className |
string |
Applied to the wrapper. |
style |
CSSProperties |
Applied to the wrapper. |
One Tap ships enabled
disableOneTap defaults to false, so the SDK passes useOneTap: true to Google. If you don't want the prompt popping up on its own in the corner (common on pages that aren't the login screen), pass disableOneTap.
What reaches onSuccess / onError
interface OAuthCredential {
idToken: string; // provider JWT — send it to your backend to validate
provider: string; // "google"
raw?: unknown; // raw response from the provider SDK
}
interface OAuthError {
provider: string;
code?: string;
message: string;
raw?: unknown;
}
Never trust the idToken on the client
The idToken is a JWT signed by Google, but signature validation must happen on your backend (against Google's public keys), checking aud (your clientId) and iss. Decoding it on the client with decodeJWT is fine for UI (showing a name or picture) — never for authorisation.
The callback route: useOAuthCallback
When the flow is a redirect (the provider comes back to /callback?code=…&state=…) instead of a popup, you need to swap that code for a session — exactly once, and carrying the state along.
With a tempest-fastapi-sdk backend, the flow is:
- A link points at
GET /auth/oauth/google/login(a navigation, not afetch). The backend mints thestate, stores it in anHttpOnlycookie and redirects to Google. - Google sends the user back to your app's callback route with
?code=…&state=…. - The app forwards
codeandstatetoGET /auth/oauth/google/callback. The backend compares thestatewith the cookie (hmac.compare_digest, single use, expires afterAUTH_OAUTH_STATE_TTL_SECONDS), redeems thecodeand returns the same JWT pair asPOST /auth/login.
Step 3 is exchangeOAuthCallback:
import {
ErrorState,
Spinner,
createApiClient,
exchangeOAuthCallback,
useNavigate,
useOAuthCallback,
} from "tempest-react-sdk";
const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL });
interface LoginResponse {
user_id: string;
access_token: string | null;
refresh_token: string | null;
}
export function OAuthCallbackPage() {
const navigate = useNavigate();
const { loading, error } = useOAuthCallback<LoginResponse>({
exchange: () => exchangeOAuthCallback<LoginResponse>(api, "google"),
onSuccess: () => navigate("/dashboard", { replace: true }),
onError: () => navigate("/login?error=oauth", { replace: true }),
});
if (loading) return <Spinner />;
if (error) return <ErrorState description="We could not finish signing you in." />;
return null;
}
The result: while the exchange is in flight you get the spinner; on resolve the hook calls onSuccess and you redirect; on failure onError sends the user back to login with an error flag.
What exchangeOAuthCallback(api, provider, options?) does for you:
- Reads
codeandstatewithreadOAuthCallbackParamsand throwsOAuthCallbackErrorbefore any request when the provider senterror(reason: "provider_error"), whenstateis missing ("missing_state") orcodeis missing ("missing_code"). - Sends both to
{prefix}/oauth/{provider}/callbackwithcredentials: "include"— thestatecookie is the other half of the comparison. - Uses
skipAuthandretry: false: there is no session yet, a401there is a forged or expired callback (not a token to refresh), and the backend clears thestatecookie on the first attempt. - Removes
codeandstatefrom the address bar withhistory.replaceStateas soon as it reads them (even when it refuses the callback), keeping the other params, the hash and the router'shistory.state. The code then stays out of the history, bookmarks and the next request'sReferer.
| Option | Default | What it is for |
|---|---|---|
prefix |
"/auth" |
The prefix given to make_auth_router on the backend. |
search |
window.location.search |
Where to read the provider's redirect from. |
signal |
— | Aborts the exchange. |
scrubUrl |
true when reading window.location |
false keeps code/state in the URL. With an explicit search the default is false. |
Why the state cannot be left behind
Without it, an attacker sends the victim to /callback?code=<a code from the attacker's account> and the victim ends up signed in to the attacker's account (login CSRF) — everything they type afterwards lands there. The old recipe read only the code, so no backend could check the state. Now the reader returns both or throws.
Trade-off: code and state travel in a GET query
The make_auth_router contract is GET /oauth/{provider}/callback?code=…&state=… — the same URL the provider would hit if it redirected straight to the backend — so the helper does not change the method. The cost: the whole URL lands in the access log of the backend and of any proxy on the way. The risk is bounded (the code is single-use, redeemed within seconds and only valid together with the state cookie), but a log holding a not-yet-redeemed code is a log holding a short-lived credential. If your logs are shared, configure the server or proxy to mask code and state on that route.
A backend that is not tempest-fastapi-sdk
Use readOAuthCallbackParams() inside your exchange and send code and state to your route. Comparing the state with the value bound to the browser that started the flow is the backend's job: the browser cannot read an HttpOnly cookie, so a client-side check would prove nothing.
Runs once, even under StrictMode
In development React 18/19 mounts, unmounts and remounts every component on purpose. The hook fires exchange once per logical mount — OAuth codes are single-use — and delivers the result to the mount that remains: it only discards it if the component is really unmounted when the response lands. The same holds for a parent re-render with inline onSuccess/onError during the request; the hook always calls their latest version. Up to 0.76.0, both cases left the hook "pending" forever.
Return value
| Field | Type | Meaning |
|---|---|---|
loading |
boolean |
true while exchange is pending |
data |
T \| null |
Resolved value, on success |
error |
unknown |
Rejection reason, on failure |
status |
"pending" \| "success" \| "error" |
Aggregated state |
exchange only has to be a Promise
The hook knows nothing about OAuth — it runs one async function once and exposes the state. It fits any "callback becomes a session" exchange: magic links, corporate SSO, email confirmation.
Closing the loop with auth
The natural pair is createAuthStore: onSuccess stores the session, and from then on AuthGuard and createApiClient see a logged-in user.
import {
createApiClient,
createAuthStore,
exchangeOAuthCallback,
useNavigate,
useOAuthCallback,
} from "tempest-react-sdk";
interface User {
id: string;
}
interface LoginResponse {
user_id: string;
access_token: string;
}
const useAuthStore = createAuthStore<User>();
const api = createApiClient({ baseURL: import.meta.env.VITE_API_URL });
export function useGoogleCallback() {
const navigate = useNavigate();
return useOAuthCallback<LoginResponse>({
exchange: () => exchangeOAuthCallback<LoginResponse>(api, "google"),
onSuccess: ({ user_id, access_token }) => {
useAuthStore.getState().setSession({ user: { id: user_id }, token: access_token });
navigate("/dashboard", { replace: true });
},
});
}
Recap
@react-oauth/googleis your app's dependency; the SDK only receivesGoogleLoginthrough thecomponentprop.<GoogleSignIn>normalises success and failure —onSuccessonly runs with anidTokenpresent.- One Tap ships enabled; turn it off with
disableOneTapwhere it doesn't belong. - Validating the
idTokenis always the backend's job. useOAuthCallbackruns the exchange exactly once (StrictMode-proof) and returnsloading/data/error/status.exchangeOAuthCallbackcarriescodeandstateto the backend callback; withoutstate, it refuses before sending.- It pairs with
authto store the session androutingto redirect.