TimeTackle connects to Google Calendar and Outlook Calendar. For historical reasons those two installs had separate flows: different entry points, different callback handlers, slightly different error handling, and one of them used a full-page redirect while the other used a popup. They worked. They had also drifted apart in exactly the way that makes security reviews uncomfortable.
When we started preparing for SOC 2, OAuth was one of the first places I looked. This post covers how we folded both flows into one, and what we fixed on the way.
The constraint: don’t touch storage
The tempting move was a rewrite. We didn’t, for one reason: credentials. Every connected account in production had tokens stored in a particular shape, and the sync code that read them was battle-tested. Changing the storage model meant a data migration on the most sensitive table we have.
So the rule was: the new flow changes how tokens are obtained, not how they are stored. Backend credential storage and data models stay exactly as they are. That kept the change reviewable and the rollback trivial.
One flow, parameterised by provider
The shared flow is a popup. The web app opens a small window, the window goes through the provider’s consent screen, and the result comes back to the app with postMessage.
Everything that differs between providers lives in one config object:
type ProviderConfig = {
authorizeUrl: string;
tokenUrl: string;
scopes: string[]; // minimum needed for this integration
redirectUri: string; // must match the provider console exactly
extraParams?: Record<string, string>;
};
export const providers: Record<"google" | "microsoft", ProviderConfig> = {
google: {
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes: ["openid", "email", "https://www.googleapis.com/auth/calendar.readonly"],
redirectUri: `${env.API_ORIGIN}/oauth/callback`,
extraParams: { access_type: "offline", prompt: "consent" },
},
microsoft: {
authorizeUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
tokenUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/token",
scopes: ["openid", "email", "offline_access", "Calendars.Read"],
redirectUri: `${env.API_ORIGIN}/oauth/callback`,
},
};(The scopes above are illustrative; the point is that each integration asks for the least it needs, and the list lives in one reviewed place.)
What we hardened
Registered redirect URIs only
The old flows accepted a redirect parameter so the app could send users back to “wherever they came from”. That is an open redirect waiting to happen, and in an OAuth flow it can leak authorization codes. The new flow has exactly one callback URI per environment, registered with each provider. Where the user lands afterwards is stored server-side in the state record, not passed around in the URL.
State that means something
state is a random value tied to a server-side record: the user, the account, the provider, the PKCE verifier, the post-install destination, and an expiry of a few minutes. The callback rejects anything that doesn’t match a live record, and deletes the record on use so a code can’t be replayed.
export async function oauthCallback(req: Request, res: Response) {
const { code, state } = req.query as { code?: string; state?: string };
const pending = state ? await oauthStates.consume(state) : null; // one-shot
if (!code || !pending || pending.expiresAt < Date.now()) {
return res.status(400).send(renderPopupResult({ ok: false, reason: "invalid_state" }));
}
const cfg = providers[pending.provider];
const tokens = await exchangeCode(cfg, code, pending.codeVerifier);
const integrationId = await credentials.save(pending.accountId, pending.provider, tokens);
// The popup page posts only an id, and only to our own origin.
res.send(renderPopupResult({ ok: true, integrationId }, { targetOrigin: env.APP_ORIGIN }));
}Tokens out of logs
Request logging middleware had been capturing query strings and some response bodies. On an OAuth callback that means authorization codes, and on a token exchange it means refresh tokens. We added redaction at the logger level (not at each call site, which always misses one): known sensitive keys like code, access_token, refresh_token and id_token are replaced before anything is written.
Multiple Google accounts
A user signed into several Google accounts can consent with the wrong one, and the old flow would quietly connect it. The new callback checks the returned identity against what the user expected and returns a clear error to the popup instead of connecting the wrong calendar. It sounds minor, but it’s the kind of bug that produces very confusing support tickets.
The popup, done carefully
Popups have their own sharp edges:
- Check
event.originin the parent’s message listener. Anything canpostMessageto your window. - Handle the user closing the popup. Poll
popup.closedand resolve the install as cancelled, otherwise the UI waits forever. - Popup blockers. Open the window synchronously in the click handler, then navigate it, or the browser will block it.
Why it was worth it
Two flows became one, so every future fix lands once. The security properties are now visible in one file instead of spread across two histories. And because storage didn’t change, the new flow could ship behind a flag and roll back without touching a single stored credential.