src/lib/google-oauth.ts
Rama main · réplica de lectura del repositorio original.
// Signing in with Google without any of Google's code ever running here.
//
// The browser builds the authorization URL itself and performs a top-level
// navigation to it. Nothing of Google's is fetched, injected or executed on this
// site at any point - not on the sign-in page, not on the callback - so a
// signed-out visitor who never presses the button is never disclosed to Google at
// all. Going to Google is the visitor's own instruction, and it is the only
// contact there is.
//
// A navigation rather than a popup or a form post, deliberately. A popup is
// subject to blockers and to the browser's transient-activation budget; a form
// submission would be governed by `form-action 'self'`, which this app's CSP sets
// and which nothing here widens. A top-level navigation is governed by none of
// the fetch directives, which is why removing `accounts.google.com` from the CSP
// costs this flow nothing.
//
// Two values are minted per attempt and have to survive the round trip through
// Google: a `state`, which proves the callback belongs to a sign-in this browser
// actually started, and a PKCE verifier, whose SHA-256 is committed to in the
// authorization URL and which the API later presents to Google's token endpoint.
// Google returns only a single-use code; it is this app's server that redeems it,
// so nothing identifying anyone passes through the browser.
import { JOIN_CHANNEL_SLUG } from "@/lib/api/config";
import { sanitizeRedirectPath } from "@/lib/auth-redirect";
// The OAuth 2.0 authorization endpoint, from Google's OpenID discovery document
// at https://accounts.google.com/.well-known/openid-configuration. The same
// document is what establishes PKCE support for this endpoint - it advertises
// `"code_challenge_methods_supported": ["plain", "S256"]` - since neither of
// Google's web-flow guides mentions PKCE at all. Google's own JavaScript SDK for
// this flow cannot do PKCE; building the URL by hand is what makes it available.
const AUTHORIZATION_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
// Where Google returns the visitor. The API derives the same path from its own
// `FRONTEND_URL` and refuses to exchange a code against any other `redirect_uri`,
// which is what turns a misconfigured deployment into this app's own error rather
// than a `redirect_uri_mismatch` from Google that names neither setting.
export const GOOGLE_CALLBACK_PATH = "/auth/google/callback";
const PENDING_ATTEMPTS_KEY = "opendiving:google-sign-in-attempts";
// Long enough for a slow first sign-in, which is the one that most needs to work:
// an account chooser, a password, a second factor and a consent screen, any of
// which can stall on a phone being found. An expired entry means no exchange at
// all (see `consumeGoogleAttempt`), so a bound of "a few minutes" would fail a
// sign-in Google had already approved. It is still an order of magnitude under
// the day `lib/auth-redirect.ts` allows itself - that file's reason for a long
// window, a link read from an inbox at an unknown later time, has no analogue
// here.
const ATTEMPT_TTL_MS = 30 * 60 * 1000;
// What one in-flight sign-in needs to remember about itself.
export interface PendingGoogleAttempt {
// The PKCE verifier. RFC 7636 §4.1 shape - 43 to 128 characters from the
// unreserved set - because the API validates the field to exactly that and
// answers 422 to anything else. Base64url over 32 random bytes qualifies;
// standard base64 would not, since `+` and `/` are outside the set.
codeVerifier: string;
// Where this attempt was headed, sanitized when it was stored. It rides inside
// the per-attempt record rather than in the magic link's single
// `opendiving:post-auth-redirect` slot for the reason the map exists at all: a
// lone key would let a second tab overwrite the first tab's destination, and
// the first tab would then sign in perfectly and land on the wrong page with
// nothing anywhere reporting a problem.
redirectTo: string | null;
// The join link or invite link this attempt started from, if it started on
// `/join`: the slug or token the API resolved, sent back with the code so the API
// can admit the visitor by it. It rides here because this flow leaves the tab and `state` itself stays
// an opaque nonce - this record is already the session-bound place the round
// trip keeps what it needs.
via: string | null;
expiresAt: number;
}
// Keyed by `state`, not a single current-attempt record. `localStorage` is shared
// across tabs, so one slot would mean the second of two concurrent sign-ins
// overwrote the first, and whichever callback returned first would then fail its
// `state` check and show an error over a sign-in Google had approved. A map lets
// two tabs each consume their own entry.
type PendingAttempts = Record<string, PendingGoogleAttempt>;
// `{origin}/auth/google/callback`, built from the origin the browser is actually
// on - never from `SITE_URL`. That origin is by definition the one Google will
// see and the one an operator has to register, so deriving it from a setting
// would introduce a second value free to disagree with reality. The same string
// is sent to Google on the way out and to this app's API on the way back, from
// this one derivation, so the two cannot drift.
export function googleRedirectUri(): string {
return `${window.location.origin}${GOOGLE_CALLBACK_PATH}`;
}
// Base64url, per RFC 4648 §5: the `+/` of standard base64 become `-_` and the
// padding goes. Used for both random tokens and the challenge digest, which is
// what keeps the verifier inside the unreserved set the API enforces.
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// 32 random bytes, which base64url renders as 43 unpadded characters - the exact
// minimum RFC 7636 §4.1 sets for a verifier, and ample entropy for a `state`.
function randomToken(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
}
// `crypto.subtle` exists only in a secure context, which is not a constraint
// here: Google accepts a redirect URI only over HTTPS or on localhost, and both
// are secure contexts. A deployment that could not satisfy that cannot register a
// redirect URI and so cannot offer Google sign-in under any design.
async function codeChallenge(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return base64UrlEncode(new Uint8Array(digest));
}
// Anything that is not the shape written below reads as absent rather than being
// repaired - a half-parsed attempt cannot complete a sign-in, and an entry left
// by an older build would otherwise sit there being rejected forever.
function asAttempt(value: unknown): PendingGoogleAttempt | null {
if (typeof value !== "object" || value === null) return null;
const { codeVerifier, redirectTo, via, expiresAt } =
value as Partial<PendingGoogleAttempt>;
if (typeof codeVerifier !== "string" || typeof expiresAt !== "number") {
return null;
}
return {
codeVerifier,
// Sanitized again on the way out, not only on the way in: the value has sat
// in storage that anything else at this browser could have rewritten.
redirectTo: sanitizeRedirectPath(redirectTo),
// Absent on an entry an older build wrote, and dropped rather than sent if it
// is not a slug or token at all - the API would refuse the whole exchange over
// it. An invite-link token fits the slug's shape, so it passes.
via: typeof via === "string" && JOIN_CHANNEL_SLUG.test(via) ? via : null,
expiresAt,
};
}
function readAttempts(): PendingAttempts {
let raw: string | null;
try {
raw = window.localStorage.getItem(PENDING_ATTEMPTS_KEY);
} catch {
// Storage can be unavailable (Safari private mode, storage disabled, quota).
return {};
}
if (!raw) return {};
try {
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
return {};
}
const attempts: PendingAttempts = {};
for (const [state, value] of Object.entries(parsed)) {
const attempt = asAttempt(value);
if (attempt) attempts[state] = attempt;
}
return attempts;
} catch {
return {};
}
}
// Expired entries are dropped here rather than on read, so the map cannot grow
// without bound: every write is also a sweep, and a visitor who abandons attempts
// forever still only ever stores the ones from the last half hour.
function writeAttempts(attempts: PendingAttempts): void {
const live = Object.entries(attempts).filter(
([, attempt]) => attempt.expiresAt > Date.now(),
);
try {
if (live.length === 0) {
window.localStorage.removeItem(PENDING_ATTEMPTS_KEY);
return;
}
window.localStorage.setItem(
PENDING_ATTEMPTS_KEY,
JSON.stringify(Object.fromEntries(live)),
);
} catch {
// As above. A lost attempt fails its own `state` check on return and asks the
// visitor to try again, which is the right outcome for a browser that cannot
// remember what it started.
}
}
interface BeginGoogleSignIn {
clientId: string;
// Where the visitor was headed before they were asked to sign in, if anywhere.
redirectTo?: string | null;
// The join link's slug or invite-link token, when the button is on `/join`.
via?: string | null;
}
/**
* Mints an attempt, stores it, and returns the URL to navigate to.
*
* `access_type` is deliberately never set to `offline`, so Google issues no
* refresh token and this app is never handed a Google credential it could store.
* `scope` and `prompt` are the ones Google's own sign-in script requests.
*
* There is no `nonce`. A nonce binds an ID token to the request that asked for
* it, and the threat it answers is replay of a token that travelled through the
* browser. Here the ID token never touches the browser: it goes straight from
* Google's token endpoint to this app's API over TLS, in exchange for a
* single-use code that cannot be redeemed without both the client secret and the
* verifier above. Google does not require one for this flow either - OpenID
* Connect Core marks `nonce` optional for the authorization code flow and
* required only for the implicit flow, and Google enforces it exactly there.
*/
export async function beginGoogleSignIn({
clientId,
redirectTo,
via,
}: BeginGoogleSignIn): Promise<string> {
const state = randomToken();
const codeVerifier = randomToken();
const challenge = await codeChallenge(codeVerifier);
writeAttempts({
...readAttempts(),
[state]: {
codeVerifier,
redirectTo: sanitizeRedirectPath(redirectTo),
via: via ?? null,
expiresAt: Date.now() + ATTEMPT_TTL_MS,
},
});
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: googleRedirectUri(),
response_type: "code",
scope: "openid email profile",
state,
code_challenge: challenge,
code_challenge_method: "S256",
prompt: "select_account",
});
return `${AUTHORIZATION_ENDPOINT}?${params}`;
}
/**
* Reads and removes the attempt a callback's `state` names, or `null` when there
* isn't one.
*
* `null` is the answer for a `state` this browser never issued, one it has
* already used, and one that expired - and the caller must perform no exchange in
* any of those cases. Only this attempt is removed; entries belonging to other
* tabs are written back untouched.
*
* This is not the guard against exchanging a code twice. Google's codes are
* single-use and React Strict Mode invokes effects twice in development, so the
* callback needs a latch that survives its own remount - consuming the entry
* before the request would make the second mount look like a `state` that was
* never issued, and report a failure over a sign-in that had just succeeded.
*/
export function consumeGoogleAttempt(
state: string | null | undefined,
): PendingGoogleAttempt | null {
if (!state) return null;
const attempts = readAttempts();
// Own entries only: `?state=__proto__` would otherwise read `Object.prototype`,
// whose missing `expiresAt` slips past the expiry check below. Not `Object.hasOwn`,
// which the `es2020` lib this compiles against does not declare.
const attempt = Object.prototype.hasOwnProperty.call(attempts, state)
? attempts[state]
: undefined;
delete attempts[state];
writeAttempts(attempts);
if (!attempt || attempt.expiresAt <= Date.now()) return null;
return attempt;
}