Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ ENABLE_METRICS=false
# publicly reachable: without it /metrics serves the full registry to anyone.
# Send it as `X-Metrics-Key` or `Authorization: Bearer <key>`.
# METRICS_API_KEY=

# --- Native Google sign-in on Android (managed, AUTH_MODE=clerk) ---
# The web OAuth client the app's Google picker requests tokens for, and the
# Android clients allowed to request them (comma-separated: debug and Play).
# GOOGLE_WEB_CLIENT_ID=
# GOOGLE_ANDROID_CLIENT_IDS=
5 changes: 5 additions & 0 deletions backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ const BaseEnvSchema = z.object({
CLERK_PUBLISHABLE_KEY: z.string().optional(),
CLERK_SECRET_KEY: z.string().optional(),
CLERK_WEBHOOK_SECRET: z.string().optional(),
// Native Google sign-in on Android. Clerk refuses the picker's ID tokens
// because their `azp` is the Android client, so the backend verifies them.
GOOGLE_WEB_CLIENT_ID: z.string().optional(),
// Comma-separated: the debug and Play-signed builds have separate clients.
GOOGLE_ANDROID_CLIENT_IDS: z.string().optional(),

POSTHOG_KEY: z.string().optional(),
POSTHOG_HOST: z.url("POSTHOG_HOST must be a valid URL").optional(),
Expand Down
132 changes: 132 additions & 0 deletions backend/src/lib/auth/google-native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { ClerkClient } from "elysia-clerk";

const GOOGLE_CERTS_URL = "https://www.googleapis.com/oauth2/v3/certs";
const GOOGLE_ISSUERS = new Set(["accounts.google.com", "https://accounts.google.com"]);
const CLOCK_SKEW_SECONDS = 60;

type GoogleKey = JsonWebKey & { kid?: string };

let cachedKeys: { keys: GoogleKey[]; expiresAt: number } | null = null;

async function getGoogleKey(kid: string): Promise<GoogleKey | undefined> {
const find = () => cachedKeys?.keys.find((key) => key.kid === kid);
if (cachedKeys && cachedKeys.expiresAt > Date.now() && find()) {
return find();
}

const response = await fetch(GOOGLE_CERTS_URL, { signal: AbortSignal.timeout(5000) });
if (!response.ok) {
throw new Error(`Google certificates request failed: ${response.status}`);
}
const maxAge = Number(
/max-age=(\d+)/.exec(response.headers.get("cache-control") ?? "")?.[1] ?? 3600,
);
const { keys } = (await response.json()) as { keys: GoogleKey[] };
cachedKeys = { keys, expiresAt: Date.now() + maxAge * 1000 };

return find();
}

export function resetGoogleKeyCache(): void {
cachedKeys = null;
}

function decodeSegment(segment: string): unknown {
return JSON.parse(Buffer.from(segment, "base64url").toString("utf8"));
}

export interface GoogleIdentity {
sub: string;
email: string;
}

/**
* Returns the identity only for a Google-signed token minted for our web
* client at the request of one of our Android clients, with a verified email.
*/
export async function verifyGoogleIdToken(
idToken: string,
{ webClientId, androidClientIds }: { webClientId: string; androidClientIds: string[] },
): Promise<GoogleIdentity | null> {
const [headerPart, payloadPart, signaturePart] = idToken.split(".");
if (!headerPart || !payloadPart || !signaturePart) {
return null;
}

let header: { alg?: string; kid?: string };
let payload: Record<string, unknown>;
try {
header = decodeSegment(headerPart) as typeof header;
payload = decodeSegment(payloadPart) as typeof payload;
} catch {
return null;
}
if (header.alg !== "RS256" || !header.kid) {
return null;
}

const jwk = await getGoogleKey(header.kid);
if (!jwk) {
return null;
}
const key = await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
const signed = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
Buffer.from(signaturePart, "base64url"),
new TextEncoder().encode(`${headerPart}.${payloadPart}`),
);
if (!signed) {
return null;
}

const now = Math.floor(Date.now() / 1000);
const { iss, aud, azp, exp, email_verified, sub, email } = payload;
if (
typeof iss !== "string" ||
!GOOGLE_ISSUERS.has(iss) ||
aud !== webClientId ||
typeof azp !== "string" ||
!androidClientIds.includes(azp) ||
typeof exp !== "number" ||
exp + CLOCK_SKEW_SECONDS < now ||
email_verified !== true ||
typeof sub !== "string" ||
typeof email !== "string"
) {
return null;
}

return { sub, email };
}

/**
* The Clerk user who linked this exact Google account through Clerk's own
* OAuth. Email only narrows the search: an address on a Clerk user can be
* admin-asserted, so it never proves ownership on its own.
*/
export async function findClerkUserForGoogle(
clerk: Pick<ClerkClient, "users">,
identity: GoogleIdentity,
): Promise<string | null> {
const { data } = await clerk.users.getUserList({
emailAddress: [identity.email],
limit: 20,
});
const owner = data.find((user) =>
user.externalAccounts.some(
(account) =>
account.provider === "oauth_google" &&
account.providerUserId === identity.sub &&
account.verification?.status === "verified",
),
);

return owner?.id ?? null;
}
2 changes: 2 additions & 0 deletions backend/src/middleware/clerk-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ const AUTH_EXEMPT_PATHS = new Set([
"/api/billing/webhook",
// Public: the pricing page has to know what it may sell before sign-in.
"/api/billing/capabilities",
// Public: proves who the caller is with a Google ID token instead.
"/api/auth/google-native",
"/api/docs",
"/api/docs/json",
"/",
Expand Down
1 change: 1 addition & 0 deletions backend/src/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ const SENSITIVE_AUTH_PATHS = new Set([
"/api/auth/login",
"/api/auth/register",
"/api/auth/change-password",
"/api/auth/google-native",
]);

export function isSensitiveAuthRequest(request: Request): boolean {
Expand Down
56 changes: 56 additions & 0 deletions backend/src/modules/auth/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Database } from "bun:sqlite";
import type { ClerkClient } from "elysia-clerk";
import { Elysia } from "elysia";
import {
type UserRow,
Expand Down Expand Up @@ -27,6 +28,10 @@ import {
readSessionTokenFromRequest,
} from "../../lib/auth/session";
import { publishUserSyncEvent } from "../../lib/sync/eventBus";
import {
findClerkUserForGoogle,
verifyGoogleIdToken,
} from "../../lib/auth/google-native";
import { getConfig } from "../../config";
import { captureProductEvent } from "../../lib/analytics/product-analytics";
import { AuthSchemas } from "./schemas";
Expand Down Expand Up @@ -696,6 +701,57 @@ export const authRoutes = new Elysia({ prefix: "/api/auth" })
tags: ["Auth"],
},
},
)
.post(
"/google-native",
async (context) => {
const config = getConfig();
const { clerk } = context as unknown as { clerk?: ClerkClient };
const androidClientIds = (config.GOOGLE_ANDROID_CLIENT_IDS ?? "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
if (
config.AUTH_MODE !== "clerk" ||
!clerk ||
!config.GOOGLE_WEB_CLIENT_ID ||
androidClientIds.length === 0
) {
context.set.status = 404;
throw new NotFoundError("Not found");
}

const identity = await verifyGoogleIdToken(context.body.idToken, {
webClientId: config.GOOGLE_WEB_CLIENT_ID,
androidClientIds,
});
if (!identity) {
context.set.status = 401;
throw new AuthenticationError("Google sign-in could not be verified.");
}

// No linked account: the app signs up through Clerk's web OAuth.
const clerkUserId = await findClerkUserForGoogle(clerk, identity);
if (!clerkUserId) {
return { ticket: null };
}

const { token } = await clerk.signInTokens.createSignInToken({
userId: clerkUserId,
expiresInSeconds: 60,
});
logger.info({ clerkUserId }, "[google-native] Issued sign-in ticket");

return { ticket: token };
},
{
body: AuthSchemas.googleNative,
response: AuthSchemas.googleNativeResponse,
detail: {
summary: "Exchange an Android Google ID token for a Clerk sign-in ticket",
tags: ["Auth"],
},
},
);

export function isSessionFresh(lastUsedAt: string): boolean {
Expand Down
8 changes: 8 additions & 0 deletions backend/src/modules/auth/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ export const AuthSchemas = {

successResponse: SuccessResponseSchema,

googleNative: t.Object({
idToken: t.String({ minLength: 1, maxLength: 4096 }),
}),

googleNativeResponse: t.Object({
ticket: t.Nullable(t.String()),
}),

authResponse: t.Object({
success: t.Boolean(),
message: t.Optional(t.String()),
Expand Down
Loading
Loading