@clammet/convex-googly-auth
v1.0.1
Published
Google OIDC + optional anonymous identity component for Convex apps.
Downloads
1,399
Maintainers
Readme
convex-googly-auth
A Convex component providing the shared
"easy anonymous / Google account" identity scheme used by upgallery and
when: Google OIDC sign-in with server-held refresh tokens, plus optional
anonymous identities backed by a hashed bearer claim — with the
anonymous-credential-hijacking bug class fixed structurally, in one place.
Security model
- Google sign-in. Google issues the ID token; Convex verifies issuer,
audience, and signature via the app's
convex/auth.config.ts. Refresh tokens never leave the component'sauthSessionstable — the browser only holds an opaque HMAC-signed session token and exchanges it for fresh ID tokens through the refresh route. Every refresh rotates the session token (with a short grace window for racing tabs), so an exfiltrated token goes stale on the next refresh. Sessions expire after 180 days absolute / 60 days idle by default; configure viasessionAbsoluteTtlMs/sessionIdleTtlMsonnew GooglyAuth(...). Sign-in usesprompt=select_account; the callback re-runs the flow withprompt=consentonly when a refresh token is needed and none is stored. - Anonymous users (optional). Identified by a 256-bit hex claim in a cookie. The claim is a bearer secret; only its SHA-256 hash is stored.
- Structural hijack prevention. Credentials live in their own tables
(
googleCredentials,anonymousCredentials) pointing at anidentitiesrow. Upgrading an anonymous account to Google sign-in deletes the anonymous credential row, so a retired claim cannot remain a second, password-less way into the account — the "profile carrying both identifiers" shape behind the originalanonymousIdhijacking bugs is unrepresentable, and a defensive guard retires legacy dual-credential rows on sight. The regression suite inexample/convex/auth.test.tsandsrc/component/lib.test.tspins all of this down. - Apps never see credentials. The component hands the app an opaque
identityIdstring; the app keys its own profile table by it. In-place upgrades keep the sameidentityId, so app data does not move when a user signs in.
Installation
npm install @clammet/convex-googly-auth// convex/convex.config.ts
import { defineApp } from "convex/server";
import googlyAuth from "@clammet/convex-googly-auth/convex.config.js";
const app = defineApp();
app.use(googlyAuth);
export default app;// convex/auth.config.ts
export default {
providers: [
{
domain: "https://accounts.google.com",
applicationID: process.env.AUTH_GOOGLE_ID,
},
],
};Environment variables on the deployment: AUTH_GOOGLE_ID,
AUTH_GOOGLE_SECRET, SITE_URL (canonical web origin).
Server usage
// convex/lib/auth.ts — one instance for the whole app
import { GooglyAuth } from "@clammet/convex-googly-auth";
import { components } from "../_generated/api";
export const googly = new GooglyAuth(components.googlyAuth);
// Google-only app? Anonymous claims are then ignored server-side everywhere:
// export const googly = new GooglyAuth(components.googlyAuth, { anonymous: false });// convex/profiles.ts — the app owns its profile table
export const ensureCurrent = mutation({
args: { anonymousClaim: v.optional(v.string()) },
handler: async (ctx, args) => {
const result = await googly.ensureIdentity(ctx, args);
// result.identityId — key your profile row by this (index it!)
// result.identity — the verified Google UserIdentity, or null
// result.upgraded — anonymous identity gained Google sign-in in place
// result.mergedFromId — an anonymous identity was absorbed into this
// one; move or alias your rows keyed by it
...
},
});
// In queries/mutations that need the caller:
const identityId = await googly.resolveIdentity(ctx, {
anonymousClaim: args.anonymousClaim,
});// convex/http.ts
const http = httpRouter();
googly.registerRoutes(http, {
// All optional; defaults read AUTH_GOOGLE_ID / AUTH_GOOGLE_SECRET / SITE_URL.
allowedOrigins: ["https://alt.example.com"],
// Or check a table of custom domains:
// isAllowedOrigin: async (ctx, origin) => { ... },
});
export default http;Routes mounted (prefix configurable via pathPrefix): GET /auth/google/start,
GET /auth/google/callback, POST /auth/refresh, POST /auth/sign-out.
The callback redirects to {origin}/auth/callback on the web app.
Optionally add a cron calling googly.cleanupExpiredSessions(ctx).
React usage
// src/lib/authClient.ts
import { createGooglyAuthClient } from "@clammet/convex-googly-auth/react";
export const authClient = createGooglyAuthClient({
convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL,
googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
storagePrefix: "myapp", // localStorage keys + claim cookie name
// anonymous: false, // must match the server-side option
});// main.tsx
<authClient.GoogleAuthProvider>
<ConvexProviderWithAuth client={convex} useAuth={authClient.useConvexGooglyAuth}>
<App />
</ConvexProviderWithAuth>
</authClient.GoogleAuthProvider>authClient.useGoogleAuth()→{ isLoading, isAuthenticated, signIn, signOut }authClient.useAnonymousClaim()→ the claim to pass to Convex functions (null in Google-only mode)- On your
/auth/callbackpage callauthClient.handleAuthCallback()once and navigate to the returnedredirect(seeexample/src/App.tsx) - After a signed-in
ensuresucceeds, callauthClient.clearAnonymousClaim()
The example/ directory is a complete working app; run tests with
npm test. Consumers can register the component in their own convex-test
suites via @clammet/convex-googly-auth/test (see
example/convex/setup.test.ts).
Migrating upgallery / when onto this component
Both apps keep their profile tables and authorization logic; only the credential columns move into the component.
- Add
identityId: v.string()(indexed) to the profile table; keep app-only fields (display name, roles, timezone, ...). - Backfill: for each profile row, insert component rows —
googleSubject→ agoogleCredentialsrow, liveanonymousClaimHash/anonymousId→ ananonymousCredentialsrow (hashwhen's plaintext ids with SHA-256 → base64url first; skip any anonymous credential on a row that also has a Google subject — those are exactly the hijackable legacy rows). Store the returned identity id inidentityId. - Replace
getCurrentProfile-style helpers withgoogly.resolveIdentity(...)+ aby_identityIdprofile lookup, and the profile-bootstrap/merge mutations withgoogly.ensureIdentity(...), handlingmergedFromIdwith the app's existing merge strategy (upgallery: alias rows; when:moveProfileData). - Replace the hand-rolled
/auth/*HTTP routes withgoogly.registerRoutes(http, ...)(upgallery: pass anisAllowedOrigincallback that checksgalleryHosts), and the frontendgoogleAuth.tsx/ claim helpers withcreateGooglyAuthClient.
Notes:
- Signing keys are derived from
AUTH_GOOGLE_SECRETplus asigningNamespace(default"googly-auth"), so existing sessions and in-flight OAuth states are invalidated at migration; users just sign in again. when's localStorage UUIDanonymousIds are not valid claims (claims are 64-hex). Either accept that pre-migration anonymous visitors get fresh identities, or do a one-time in-app exchange: the server looks up the legacy id, attaches a fresh claim (which the client stores) to the same identity, then deletes the legacy id.- Requires
convex >= 1.43.
Repository layout
src/component/— the component: schema (identities, credentials, sessions) and functions. Has its own_generated/.src/client/— app-sideGooglyAuthclass and HTTP route registration (auth + env access happen in the app, not the component).src/react/— browser client factory.src/test.ts—convex-testregistration helper (exported as@clammet/convex-googly-auth/test).example/— working example app + the regression test suite.
Development
npm install --ignore-scripts # prepare runs a build; skip on first install
npm run build # or build:codegen with a configured deployment
npm run check