Google iOS-OAuth-Client lehnt `rebreak://...`-Schemes mit `invalid_request` ab. Reverse-Client-ID-Format ist required. Empirisch verifiziert 2026-05-28 (siehe memory). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
208 lines
7.5 KiB
TypeScript
208 lines
7.5 KiB
TypeScript
/**
|
|
* Google Identity Platform — OAuth2 PKCE utilities.
|
|
*
|
|
* Scope: https://mail.google.com/ (full IMAP access, XOAUTH2)
|
|
* Public Client / No Client-Secret — PKCE (S256) same as MS-OAuth.
|
|
*
|
|
* Google-Specifics vs. Microsoft:
|
|
* - Token endpoint: accounts.google.com (not login.microsoftonline.com)
|
|
* - refresh_token kommt NUR beim ersten Consent zurück. Daher: prompt=consent
|
|
* + access_type=offline PFLICHT bei jedem init. Ohne das: kein refresh_token
|
|
* → Connection lebt nur 1h (access_token TTL).
|
|
* - Google rotiert refresh_tokens NICHT bei Nutzung (anders als MS). Ein einmal
|
|
* ausgestelltes refresh_token gilt bis es manuell revoked wird.
|
|
* - Email kommt aus id_token Claim `email` (immer gesetzt wenn openid-Scope vorhanden).
|
|
* - IMAP: imap.gmail.com:993, XOAUTH2 via ImapFlow { user, accessToken }.
|
|
*
|
|
* See: https://developers.google.com/identity/protocols/oauth2/native-app
|
|
*/
|
|
|
|
import { createHash, randomBytes } from "crypto";
|
|
|
|
const GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
|
|
export const GOOGLE_AUTH_BASE = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
|
|
/**
|
|
* Scopes angefragt.
|
|
* https://mail.google.com/ = full IMAP access (READ + DELETE + EXPUNGE).
|
|
* openid + email = id_token mit email-Claim für User-Identifikation.
|
|
*/
|
|
export const GOOGLE_OAUTH_SCOPES = [
|
|
"https://mail.google.com/",
|
|
"openid",
|
|
"email",
|
|
].join(" ");
|
|
|
|
/**
|
|
* Redirect-URI für Google iOS-OAuth-Client.
|
|
*
|
|
* Google verlangt für iOS-Client-Type eines von zwei Formaten:
|
|
* a) Reverse-Client-ID: `com.googleusercontent.apps.<id>:/oauth2redirect` (gewählt)
|
|
* b) Bundle-ID: `org.rebreak.app:/oauth2redirect`
|
|
*
|
|
* Custom-Schemes wie `rebreak://...` (Microsoft-Pattern) werden mit
|
|
* `invalid_request — doesn't comply with OAuth 2.0 policy` rejected.
|
|
* Empirisch verifiziert 2026-05-28.
|
|
*
|
|
* iOS-App muss diesen URL-Scheme in CFBundleURLTypes registrieren.
|
|
*/
|
|
export const GOOGLE_REDIRECT_URI =
|
|
"com.googleusercontent.apps.864178840836-rerbjn5pl31ocd83b2qqpbc8gs2o8ego:/oauth2redirect";
|
|
|
|
// ── PKCE Helpers ──────────────────────────────────────────────────────────────
|
|
// Identisch zu ms-oauth.ts — ausgelagert weil beide Provider PKCE S256 nutzen.
|
|
|
|
/** Generates a cryptographically random code_verifier (43-128 chars, URL-safe). */
|
|
export function generateCodeVerifier(): string {
|
|
return randomBytes(96).toString("base64url");
|
|
}
|
|
|
|
/** Computes S256 code_challenge from the verifier. */
|
|
export function computeCodeChallenge(verifier: string): string {
|
|
return createHash("sha256").update(verifier).digest("base64url");
|
|
}
|
|
|
|
/** Generates a random state ID for CSRF protection (hex, 32 chars = 128 bits). */
|
|
export function generateStateId(): string {
|
|
return randomBytes(16).toString("hex");
|
|
}
|
|
|
|
// ── Token Exchange ─────────────────────────────────────────────────────────────
|
|
|
|
export interface GoogleTokenResponse {
|
|
access_token: string;
|
|
/**
|
|
* KRITISCH: Google gibt refresh_token NUR beim ersten Consent zurück
|
|
* (prompt=consent in init). Bei nachfolgendem Re-Connect ohne prompt=consent:
|
|
* refresh_token fehlt in der Response. Daher immer prompt=consent verwenden.
|
|
*/
|
|
refresh_token?: string;
|
|
/** Sekunden bis access_token abläuft (typisch 3600 für Google). */
|
|
expires_in: number;
|
|
scope: string;
|
|
token_type: string;
|
|
/** JWT mit user-claims (email, sub). Kommt weil openid-Scope angefragt. */
|
|
id_token?: string;
|
|
}
|
|
|
|
/**
|
|
* Exchanges an authorization_code for tokens.
|
|
* Wirft wenn: HTTP-Fehler, access_token fehlt, oder refresh_token fehlt
|
|
* (letzteres ist ein Config-Fehler — prompt=consent + access_type=offline vergessen).
|
|
*/
|
|
export async function exchangeCodeForTokens(params: {
|
|
clientId: string;
|
|
code: string;
|
|
codeVerifier: string;
|
|
}): Promise<GoogleTokenResponse & { refresh_token: string }> {
|
|
const body = new URLSearchParams({
|
|
grant_type: "authorization_code",
|
|
client_id: params.clientId,
|
|
code: params.code,
|
|
redirect_uri: GOOGLE_REDIRECT_URI,
|
|
code_verifier: params.codeVerifier,
|
|
});
|
|
|
|
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text().catch(() => "unknown error");
|
|
throw new Error(`Google token exchange failed (${res.status}): ${errText}`);
|
|
}
|
|
|
|
const data = await res.json() as GoogleTokenResponse;
|
|
|
|
if (!data.access_token) {
|
|
throw new Error("Google token response missing access_token");
|
|
}
|
|
|
|
if (!data.refresh_token) {
|
|
// Passiert wenn init NICHT mit prompt=consent aufgerufen wurde.
|
|
// Oder wenn der User die App-Permission schon mal erteilt hat und
|
|
// prompt=consent übersprungen wurde. Lösung: User muss in Google
|
|
// Account-Settings die App-Berechtigung revoken und neu connecten.
|
|
throw new Error(
|
|
"Google token response missing refresh_token. " +
|
|
"Ensure init uses prompt=consent and access_type=offline. " +
|
|
"If user previously granted access, they must revoke and re-authorize.",
|
|
);
|
|
}
|
|
|
|
return data as GoogleTokenResponse & { refresh_token: string };
|
|
}
|
|
|
|
/**
|
|
* Refreshes a Google access_token using a refresh_token.
|
|
*
|
|
* Google-Spezifik: Google rotiert refresh_tokens NICHT bei normalem Einsatz.
|
|
* Das ausgestellte refresh_token ist langlebig. Ausnahmen:
|
|
* - User revoked App-Permission in Google-Account-Settings
|
|
* - 6-Monate Inaktivität (Google's policy)
|
|
* - Max. 50 refresh_tokens pro Client+User (älteste werden invalidiert)
|
|
*
|
|
* Im Gegensatz zu MS: response.refresh_token kann absent sein — dann den
|
|
* bestehenden behalten (kein Rotations-Problem wie bei MS AADSTS70043).
|
|
*/
|
|
export async function refreshGoogleTokens(params: {
|
|
clientId: string;
|
|
refreshToken: string;
|
|
}): Promise<{
|
|
access_token: string;
|
|
/** Google gibt bei Refresh keinen neuen refresh_token — Original behalten. */
|
|
refresh_token: string;
|
|
expires_in: number;
|
|
}> {
|
|
const body = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
client_id: params.clientId,
|
|
refresh_token: params.refreshToken,
|
|
});
|
|
|
|
const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: body.toString(),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errText = await res.text().catch(() => "unknown error");
|
|
throw new Error(`Google token refresh failed (${res.status}): ${errText}`);
|
|
}
|
|
|
|
const data = await res.json() as GoogleTokenResponse;
|
|
|
|
if (!data.access_token) {
|
|
throw new Error("Google refresh response missing access_token");
|
|
}
|
|
|
|
return {
|
|
access_token: data.access_token,
|
|
// Google gibt bei Refresh keinen neuen refresh_token — behalte Original.
|
|
refresh_token: params.refreshToken,
|
|
expires_in: data.expires_in,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extracts the email claim from a Google ID-token (JWT).
|
|
* Google garantiert `email`-Claim wenn openid+email scope vorhanden.
|
|
* Keine Signatur-Verifikation nötig (direkt vom Token-Endpoint über HTTPS empfangen).
|
|
*/
|
|
export function extractEmailFromIdToken(idToken: string): string | null {
|
|
try {
|
|
const [, payloadB64] = idToken.split(".");
|
|
if (!payloadB64) return null;
|
|
const payload = JSON.parse(
|
|
Buffer.from(payloadB64, "base64url").toString("utf8"),
|
|
);
|
|
// Google setzt `email` direkt — kein preferred_username-Fallback nötig.
|
|
return payload.email ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|