One OAuth Flow for Google and Outlook, and the Security Gaps We Closed Along the Way

July 8, 2025 (1y ago)

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.

Sequence diagram: web app opens popup, API redirects to provider with state and PKCE, provider returns code to callback, API validates state and redirect, exchanges code, stores tokens, and posts a token-free message back to the app.
The only thing that crosses back to the web app is an integration id. Tokens never leave the server.

Everything that differs between providers lives in one config object:

oauth-providers.ts
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.

oauth-callback.ts
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.origin in the parent’s message listener. Anything can postMessage to your window.
  • Handle the user closing the popup. Poll popup.closed and 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.