hazo_auth 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +100 -0
  2. package/SETUP_CHECKLIST.md +18 -0
  3. package/cli-src/lib/auth/nextauth_config.ts +41 -0
  4. package/cli-src/lib/auth/request_google_scopes.ts +23 -0
  5. package/cli-src/lib/schema/sqlite_schema.ts +16 -0
  6. package/cli-src/lib/services/google_token_service.ts +408 -0
  7. package/cli-src/lib/services/index.ts +1 -1
  8. package/dist/client.d.ts +1 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +2 -0
  11. package/dist/components/layouts/google_token_test/index.d.ts +6 -0
  12. package/dist/components/layouts/google_token_test/index.d.ts.map +1 -0
  13. package/dist/components/layouts/google_token_test/index.js +74 -0
  14. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  15. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
  16. package/dist/components/ui/button.d.ts +1 -1
  17. package/dist/components/ui/input-otp.d.ts +2 -2
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/lib/auth/nextauth_config.d.ts +2 -0
  22. package/dist/lib/auth/nextauth_config.d.ts.map +1 -1
  23. package/dist/lib/auth/nextauth_config.js +39 -1
  24. package/dist/lib/auth/request_google_scopes.d.ts +10 -0
  25. package/dist/lib/auth/request_google_scopes.d.ts.map +1 -0
  26. package/dist/lib/auth/request_google_scopes.js +13 -0
  27. package/dist/lib/schema/sqlite_schema.d.ts +1 -1
  28. package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
  29. package/dist/lib/schema/sqlite_schema.js +16 -0
  30. package/dist/lib/services/google_token_service.d.ts +48 -0
  31. package/dist/lib/services/google_token_service.d.ts.map +1 -0
  32. package/dist/lib/services/google_token_service.js +319 -0
  33. package/dist/lib/services/index.d.ts +1 -0
  34. package/dist/lib/services/index.d.ts.map +1 -1
  35. package/dist/lib/services/index.js +1 -0
  36. package/dist/server/routes/google_token.d.ts +13 -0
  37. package/dist/server/routes/google_token.d.ts.map +1 -0
  38. package/dist/server/routes/google_token.js +66 -0
  39. package/dist/server/routes/index.d.ts +1 -0
  40. package/dist/server/routes/index.d.ts.map +1 -1
  41. package/dist/server/routes/index.js +2 -0
  42. package/dist/server/routes/user_management_users.d.ts +1 -1
  43. package/package.json +9 -5
package/README.md CHANGED
@@ -2,6 +2,64 @@
2
2
 
3
3
  A reusable authentication UI component package powered by Next.js, TailwindCSS, and shadcn. It integrates `hazo_config` for configuration management and `hazo_connect` for data access, enabling future components to stay aligned with platform conventions.
4
4
 
5
+ ### What's New in v10.1.0 🚀
6
+
7
+ **Incremental Google OAuth Scopes & Server-Side Token Access** — Grant additional Google API scopes (Analytics, Sheets, Drive, etc.) without creating a separate OAuth app. Store encrypted tokens server-side and access them programmatically.
8
+
9
+ **New Features:**
10
+ - **`hazo_google_oauth_tokens` table** (migration 021) — Encrypted AES-256-GCM storage for Google OAuth tokens with scope tracking
11
+ - **Token Service Exports** from `hazo_auth/server-lib`:
12
+ - `store_google_oauth_token(user_id, tokens, scopes)` — Save OAuth tokens after user grants new scopes
13
+ - `getGoogleToken(user_id, opts?)` — Get a fresh access token (refreshes if expired)
14
+ - `revoke_google_oauth_token(user_id)` — Permanently revoke stored token and forget scopes
15
+ - `get_google_token_status(user_id)` — Check connection status, scopes, and expiry
16
+ - **Client Helper** — `requestGoogleScopes(scopes, opts?)` from `hazo_auth/client` triggers Google consent prompt for incremental scopes
17
+ - **HTTP Routes:**
18
+ - `GET /api/hazo_auth/google/token` — Returns `{ connected, scopes, expires_at }`
19
+ - `DELETE /api/hazo_auth/google/token` — Revoke stored token without signing user out
20
+ - **NextAuth Config Updates** — Captures `refresh_token` and adds `include_granted_scopes: true` parameter for incremental scope flow
21
+ - **Route Handler Exports** from `hazo_auth/server/routes`:
22
+ - `googleTokenGET` — implements GET status endpoint
23
+ - `googleTokenDELETE` — implements DELETE revoke endpoint
24
+
25
+ **Setup:**
26
+ ```bash
27
+ # 1. Run the migration
28
+ npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql
29
+
30
+ # 2. Set encryption environment variables
31
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
32
+ HAZO_AUTH_OAUTH_KEY_V1=$(openssl rand -base64 32)
33
+
34
+ # 3. Install optional peer (for token encryption)
35
+ npm install hazo_secure
36
+ ```
37
+
38
+ **Client Usage:**
39
+ ```tsx
40
+ import { requestGoogleScopes } from "hazo_auth/client";
41
+
42
+ <button onClick={() => requestGoogleScopes([
43
+ "https://www.googleapis.com/auth/analytics.readonly"
44
+ ])}>
45
+ Connect Google Analytics
46
+ </button>
47
+ ```
48
+
49
+ **Server Usage:**
50
+ ```ts
51
+ import { getGoogleToken } from "hazo_auth/server-lib";
52
+
53
+ const result = await getGoogleToken(userId, {
54
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"]
55
+ });
56
+ if (result.ok) {
57
+ // use result.access_token to call Google APIs
58
+ }
59
+ ```
60
+
61
+ See [Google API Access (Incremental Scopes)](#google-api-access-incremental-scopes) below for full details.
62
+
5
63
  ### What's New in v9.1.1 🔧
6
64
 
7
65
  **Dev-server noise fixes for Next.js 16 + Turbopack**
@@ -1251,6 +1309,48 @@ Google OAuth adds one new dependency:
1251
1309
  - Check logs for `invitation_table_missing` warnings
1252
1310
  - If using custom paths, set `create_firm_url` to your app's create firm page URL
1253
1311
 
1312
+ ### Google API Access (Incremental Scopes)
1313
+
1314
+ `hazo_auth` v10.1+ supports granting additional Google API scopes (Analytics, Sheets, etc.) without a separate OAuth app.
1315
+
1316
+ **Setup:**
1317
+ 1. Run the migration: `npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql`
1318
+ 2. Set encryption env vars:
1319
+ ```bash
1320
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
1321
+ HAZO_AUTH_OAUTH_KEY_V1=$(openssl rand -base64 32)
1322
+ ```
1323
+ 3. Install the optional peer: `npm install hazo_secure`
1324
+
1325
+ **Usage:**
1326
+
1327
+ ```tsx
1328
+ // Client component — triggers Google consent for extra scopes
1329
+ import { requestGoogleScopes } from "hazo_auth/client";
1330
+
1331
+ <button onClick={() => requestGoogleScopes([
1332
+ "https://www.googleapis.com/auth/analytics.readonly"
1333
+ ])}>
1334
+ Connect Analytics
1335
+ </button>
1336
+ ```
1337
+
1338
+ ```ts
1339
+ // Server action / API route — get a fresh access token
1340
+ import { getGoogleToken } from "hazo_auth/server-lib";
1341
+
1342
+ const result = await getGoogleToken(userId, {
1343
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"]
1344
+ });
1345
+ if (result.ok) {
1346
+ // use result.access_token to call Google APIs
1347
+ }
1348
+ ```
1349
+
1350
+ The incremental scope token is stored and revoked independently of the user's sign-in session. `DELETE /api/hazo_auth/google/token` removes the stored token without signing the user out.
1351
+
1352
+ **Status endpoint:** `GET /api/hazo_auth/google/token` returns `{ connected, scopes, expires_at }`.
1353
+
1254
1354
  ---
1255
1355
 
1256
1356
  ## Using Components
@@ -1133,6 +1133,24 @@ ls app/api/hazo_auth/set_password/route.ts
1133
1133
  - [ ] OAuth API routes created (`[...nextauth]`, `oauth/google/callback`, `set_password`)
1134
1134
  - [ ] Post-login redirect configured (if not using invitations, set `skip_invitation_check = true`)
1135
1135
 
1136
+ #### Google API Token Storage (optional — required for `getGoogleToken`)
1137
+
1138
+ Only needed if you use `requestGoogleScopes` / `getGoogleToken` for Google API access beyond sign-in:
1139
+
1140
+ 1. Install optional peer: `npm install hazo_secure`
1141
+ 2. Run migration: `npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql`
1142
+ 3. Set env vars:
1143
+ ```env
1144
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
1145
+ HAZO_AUTH_OAUTH_KEY_V1=<base64-32-bytes from: openssl rand -base64 32>
1146
+ ```
1147
+ 4. Wire the new routes in your Next.js app (same pattern as other hazo_auth routes):
1148
+ ```ts
1149
+ // app/api/hazo_auth/google/token/route.ts
1150
+ export { GET as googleTokenGET, DELETE as googleTokenDELETE } from "hazo_auth/server/routes";
1151
+ export const dynamic = "force-dynamic";
1152
+ ```
1153
+
1136
1154
  ---
1137
1155
 
1138
1156
  ## Phase 4: API Routes
@@ -11,6 +11,7 @@ import { get_oauth_config } from "../oauth_config.server.js";
11
11
  import { handle_google_oauth_login } from "../services/oauth_service.js";
12
12
  import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
13
13
  import { create_app_logger } from "../app_logger.js";
14
+ import { store_google_oauth_token, GoogleTokenStorageUnconfigured } from "../services/google_token_service.js";
14
15
 
15
16
  // section: types
16
17
  export type NextAuthCallbackUser = {
@@ -27,6 +28,8 @@ export type NextAuthCallbackAccount = {
27
28
  access_token?: string;
28
29
  id_token?: string;
29
30
  expires_at?: number;
31
+ refresh_token?: string;
32
+ scope?: string;
30
33
  };
31
34
 
32
35
  export type NextAuthCallbackProfile = {
@@ -62,6 +65,7 @@ export function get_nextauth_config(): AuthOptions {
62
65
  prompt: "consent",
63
66
  access_type: "offline",
64
67
  response_type: "code",
68
+ include_granted_scopes: "true",
65
69
  },
66
70
  },
67
71
  })
@@ -170,6 +174,43 @@ export function get_nextauth_config(): AuthOptions {
170
174
  // Store user_id in account for the JWT callback to pick up
171
175
  (account as Record<string, unknown>).hazo_user_id = result.user_id;
172
176
 
177
+ // Capture refresh token when extra scopes were granted
178
+ const BASE_SCOPES = ["openid", "email", "profile"];
179
+ const granted_scopes = (account.scope ?? "").split(" ").filter(Boolean);
180
+ const has_extra_scopes = granted_scopes.some(s => !BASE_SCOPES.includes(s));
181
+
182
+ if (account.refresh_token && has_extra_scopes) {
183
+ try {
184
+ await store_google_oauth_token({
185
+ user_id: result.user_id!,
186
+ refresh_token: account.refresh_token,
187
+ access_token: account.access_token,
188
+ scopes: account.scope ?? "",
189
+ expires_at: account.expires_at
190
+ ? new Date(account.expires_at * 1000).toISOString()
191
+ : undefined,
192
+ });
193
+ logger.info("nextauth_google_token_stored", {
194
+ user_id: result.user_id,
195
+ scopes: account.scope,
196
+ });
197
+ } catch (tokenError) {
198
+ if (tokenError instanceof GoogleTokenStorageUnconfigured) {
199
+ logger.error("nextauth_google_token_storage_unconfigured", {
200
+ user_id: result.user_id,
201
+ error: tokenError.message,
202
+ });
203
+ return "/api/auth/error?error=GoogleTokenStorageUnconfigured";
204
+ }
205
+ const errorMsg = tokenError instanceof Error ? tokenError.message : String(tokenError);
206
+ logger.error("nextauth_google_token_store_failed", {
207
+ user_id: result.user_id,
208
+ error: errorMsg,
209
+ });
210
+ // Non-fatal: continue sign-in even if token storage fails
211
+ }
212
+ }
213
+
173
214
  return true;
174
215
  } catch (error) {
175
216
  const errorMessage = error instanceof Error ? error.message : "Unknown error";
@@ -0,0 +1,23 @@
1
+ // file_description: client helper to trigger incremental Google OAuth scope consent
2
+ "use client";
3
+
4
+ import { signIn } from "next-auth/react";
5
+
6
+ /**
7
+ * Triggers an incremental Google OAuth consent flow to grant additional scopes.
8
+ * Call from a client component when the user needs to grant extra Google API access
9
+ * (e.g. analytics, sheets). On completion, hazo_auth stores the refresh token.
10
+ */
11
+ export function requestGoogleScopes(
12
+ scopes: string[],
13
+ opts?: { callbackUrl?: string }
14
+ ): ReturnType<typeof signIn> {
15
+ const scope = Array.from(
16
+ new Set(["openid", "email", "profile", ...scopes])
17
+ ).join(" ");
18
+ return signIn(
19
+ "google",
20
+ { callbackUrl: opts?.callbackUrl ?? "/" },
21
+ { scope, access_type: "offline", include_granted_scopes: "true", prompt: "consent" }
22
+ );
23
+ }
@@ -161,4 +161,20 @@ CREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relati
161
161
  -- Firm admin role (for firm creators)
162
162
  INSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)
163
163
  VALUES (lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' || substr(hex(randomblob(2)),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))), 'firm_admin', datetime('now'), datetime('now'));
164
+
165
+ -- Google OAuth tokens (encrypted refresh/access tokens for extra-scope API access)
166
+ CREATE TABLE IF NOT EXISTS hazo_google_oauth_tokens (
167
+ id TEXT PRIMARY KEY,
168
+ user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
169
+ provider TEXT NOT NULL DEFAULT 'google',
170
+ refresh_token_enc TEXT NOT NULL,
171
+ access_token_enc TEXT,
172
+ scopes TEXT NOT NULL DEFAULT '',
173
+ expires_at TEXT,
174
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
175
+ changed_at TEXT NOT NULL DEFAULT (datetime('now')),
176
+ UNIQUE(user_id, provider)
177
+ );
178
+
179
+ CREATE INDEX IF NOT EXISTS idx_hazo_google_oauth_tokens_user ON hazo_google_oauth_tokens(user_id);
164
180
  `;
@@ -0,0 +1,408 @@
1
+ // file_description: stores, retrieves, refreshes, and revokes Google OAuth tokens with encrypted persistence
2
+ // section: imports
3
+ import { createCrudService } from "hazo_connect/server";
4
+ import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
5
+ import { create_app_logger } from "../app_logger.js";
6
+ import { optional_import } from "hazo_core";
7
+ import { randomUUID } from "crypto";
8
+
9
+ // section: errors
10
+
11
+ export class GoogleTokenStorageUnconfigured extends Error {
12
+ constructor() {
13
+ super(
14
+ "Google token storage is not configured. Set HAZO_AUTH_OAUTH_KEY_CURRENT and HAZO_AUTH_OAUTH_KEY_<ID> env vars."
15
+ );
16
+ this.name = "GoogleTokenStorageUnconfigured";
17
+ }
18
+ }
19
+
20
+ // section: types
21
+
22
+ export type StoreGoogleOAuthTokenParams = {
23
+ user_id: string;
24
+ refresh_token: string;
25
+ access_token?: string;
26
+ scopes: string; // space-delimited
27
+ expires_at?: string; // ISO string for access token expiry
28
+ };
29
+
30
+ export type GoogleTokenResult =
31
+ | { ok: true; access_token: string; scopes: string }
32
+ | { ok: false; error: "not_connected" | "reconsent_required" | "refresh_failed" | "unconfigured" };
33
+
34
+ export type GoogleTokenStatus = {
35
+ connected: boolean;
36
+ scopes: string;
37
+ expires_at: string | null;
38
+ };
39
+
40
+ // section: internal-types
41
+
42
+ type GoogleOAuthRow = {
43
+ id: string;
44
+ user_id: string;
45
+ provider: string;
46
+ refresh_token_enc: string;
47
+ access_token_enc: string | null;
48
+ scopes: string;
49
+ expires_at: string | null;
50
+ created_at: string;
51
+ changed_at: string;
52
+ };
53
+
54
+ // section: cache
55
+
56
+ const access_token_cache = new Map<string, { token: string; expires_at: string }>();
57
+
58
+ // section: helpers
59
+
60
+ function makeKeyProvider(
61
+ LookupKeyProvider: new (lookup: (name: string) => string | undefined) => unknown
62
+ ) {
63
+ return new LookupKeyProvider((name: string) => {
64
+ if (name === "current") return process.env.HAZO_AUTH_OAUTH_KEY_CURRENT;
65
+ if (name.startsWith("key_")) {
66
+ const keyId = name.slice(4).toUpperCase();
67
+ return process.env[`HAZO_AUTH_OAUTH_KEY_${keyId}`];
68
+ }
69
+ return undefined;
70
+ });
71
+ }
72
+
73
+ async function load_crypto_module() {
74
+ const cryptoModule = await optional_import<typeof import("hazo_secure/crypto")>(
75
+ "hazo_secure/crypto"
76
+ );
77
+ if (!cryptoModule) throw new GoogleTokenStorageUnconfigured();
78
+ return cryptoModule;
79
+ }
80
+
81
+ // section: store
82
+
83
+ /**
84
+ * Stores (inserts or updates) Google OAuth tokens for a user, encrypting sensitive fields.
85
+ * Throws GoogleTokenStorageUnconfigured if hazo_secure/crypto is unavailable or keys are missing.
86
+ */
87
+ export async function store_google_oauth_token(
88
+ params: StoreGoogleOAuthTokenParams
89
+ ): Promise<void> {
90
+ const logger = create_app_logger();
91
+
92
+ const { encryptField, decryptField: _decryptField, aadFor, LookupKeyProvider } = await load_crypto_module();
93
+
94
+ const keys = makeKeyProvider(LookupKeyProvider as new (lookup: (name: string) => string | undefined) => unknown) as Parameters<typeof encryptField>[1]["keys"];
95
+
96
+ // Force-fail early if no key is configured
97
+ try {
98
+ await (keys as unknown as { current(): Promise<unknown> }).current();
99
+ } catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ logger.error("google_token_service_key_provider_failed", {
102
+ filename: "google_token_service.ts",
103
+ error: msg,
104
+ });
105
+ throw new GoogleTokenStorageUnconfigured();
106
+ }
107
+
108
+ const refresh_enc = await encryptField(params.refresh_token, {
109
+ keys,
110
+ aad: aadFor("hazo_google_oauth_tokens", params.user_id, "refresh_token"),
111
+ });
112
+ const refresh_token_enc = JSON.stringify(refresh_enc);
113
+
114
+ let access_token_enc: string | null = null;
115
+ if (params.access_token) {
116
+ const access_enc = await encryptField(params.access_token, {
117
+ keys,
118
+ aad: aadFor("hazo_google_oauth_tokens", params.user_id, "access_token"),
119
+ });
120
+ access_token_enc = JSON.stringify(access_enc);
121
+ }
122
+
123
+ const adapter = get_hazo_connect_instance();
124
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
125
+
126
+ const existing_rows = (await service.findBy({
127
+ user_id: params.user_id,
128
+ provider: "google",
129
+ })) as GoogleOAuthRow[];
130
+
131
+ const now = new Date().toISOString();
132
+
133
+ if (existing_rows.length > 0) {
134
+ const existing = existing_rows[0];
135
+ await service.updateById(existing.id, {
136
+ refresh_token_enc,
137
+ access_token_enc,
138
+ scopes: params.scopes,
139
+ expires_at: params.expires_at ?? null,
140
+ changed_at: now,
141
+ });
142
+ logger.info("google_token_service_token_updated", {
143
+ filename: "google_token_service.ts",
144
+ user_id: params.user_id,
145
+ });
146
+ } else {
147
+ await service.insert({
148
+ id: randomUUID(),
149
+ user_id: params.user_id,
150
+ provider: "google",
151
+ refresh_token_enc,
152
+ access_token_enc,
153
+ scopes: params.scopes,
154
+ expires_at: params.expires_at ?? null,
155
+ created_at: now,
156
+ changed_at: now,
157
+ });
158
+ logger.info("google_token_service_token_stored", {
159
+ filename: "google_token_service.ts",
160
+ user_id: params.user_id,
161
+ });
162
+ }
163
+ }
164
+
165
+ // section: get
166
+
167
+ /**
168
+ * Returns a valid access token for the user, refreshing via Google if needed.
169
+ * Returns a typed error result instead of throwing for expected failure modes.
170
+ */
171
+ export async function getGoogleToken(
172
+ userId: string,
173
+ opts?: { scopes?: string[] }
174
+ ): Promise<GoogleTokenResult> {
175
+ const logger = create_app_logger();
176
+
177
+ // Check in-memory cache first
178
+ const cached = access_token_cache.get(userId);
179
+ if (cached) {
180
+ const still_valid = new Date(cached.expires_at).getTime() - 60000 > Date.now();
181
+ if (still_valid) {
182
+ logger.info("google_token_service_cache_hit", {
183
+ filename: "google_token_service.ts",
184
+ user_id: userId,
185
+ });
186
+ // We need scopes from DB even on cache hit when scope check is requested
187
+ if (!opts?.scopes?.length) {
188
+ // No scope check needed — return cached token (scopes unknown from cache alone)
189
+ // Fall through to DB to get scopes for the result shape
190
+ }
191
+ }
192
+ }
193
+
194
+ const adapter = get_hazo_connect_instance();
195
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
196
+
197
+ const rows = (await service.findBy({ user_id: userId, provider: "google" })) as GoogleOAuthRow[];
198
+ if (rows.length === 0) {
199
+ return { ok: false, error: "not_connected" };
200
+ }
201
+
202
+ const row = rows[0];
203
+
204
+ // Scope check
205
+ if (opts?.scopes?.length) {
206
+ const stored_scopes = row.scopes.split(" ").filter(Boolean);
207
+ const missing = opts.scopes.some((s) => !stored_scopes.includes(s));
208
+ if (missing) {
209
+ return { ok: false, error: "reconsent_required" };
210
+ }
211
+ }
212
+
213
+ // Return cached token now that we have the row (scope check passed)
214
+ if (cached) {
215
+ const still_valid = new Date(cached.expires_at).getTime() - 60000 > Date.now();
216
+ if (still_valid) {
217
+ return { ok: true, access_token: cached.token, scopes: row.scopes };
218
+ }
219
+ }
220
+
221
+ // Need to refresh — load crypto
222
+ let cryptoModule: Awaited<ReturnType<typeof load_crypto_module>>;
223
+ try {
224
+ cryptoModule = await load_crypto_module();
225
+ } catch {
226
+ return { ok: false, error: "unconfigured" };
227
+ }
228
+
229
+ const { decryptField, aadFor, LookupKeyProvider } = cryptoModule;
230
+ const keys = makeKeyProvider(LookupKeyProvider as new (lookup: (name: string) => string | undefined) => unknown) as Parameters<typeof cryptoModule.encryptField>[1]["keys"];
231
+
232
+ let decrypted_refresh: string;
233
+ try {
234
+ decrypted_refresh = await decryptField(JSON.parse(row.refresh_token_enc), {
235
+ keys,
236
+ aad: aadFor("hazo_google_oauth_tokens", userId, "refresh_token"),
237
+ });
238
+ } catch (err) {
239
+ const msg = err instanceof Error ? err.message : String(err);
240
+ logger.error("google_token_service_decrypt_failed", {
241
+ filename: "google_token_service.ts",
242
+ user_id: userId,
243
+ error: msg,
244
+ });
245
+ return { ok: false, error: "refresh_failed" };
246
+ }
247
+
248
+ // Call Google token refresh endpoint
249
+ let resp: Response;
250
+ try {
251
+ resp = await fetch("https://oauth2.googleapis.com/token", {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
254
+ body: new URLSearchParams({
255
+ grant_type: "refresh_token",
256
+ refresh_token: decrypted_refresh,
257
+ client_id: process.env.HAZO_AUTH_GOOGLE_CLIENT_ID!,
258
+ client_secret: process.env.HAZO_AUTH_GOOGLE_CLIENT_SECRET!,
259
+ }),
260
+ });
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ logger.error("google_token_service_refresh_fetch_failed", {
264
+ filename: "google_token_service.ts",
265
+ user_id: userId,
266
+ error: msg,
267
+ });
268
+ return { ok: false, error: "refresh_failed" };
269
+ }
270
+
271
+ if (!resp.ok) {
272
+ logger.warn("google_token_service_refresh_rejected", {
273
+ filename: "google_token_service.ts",
274
+ user_id: userId,
275
+ status: resp.status,
276
+ });
277
+ return { ok: false, error: "refresh_failed" };
278
+ }
279
+
280
+ const data = await resp.json() as { access_token: string; expires_in: number };
281
+
282
+ // Persist new access token (encrypted)
283
+ const new_expires_at = new Date(Date.now() + data.expires_in * 1000).toISOString();
284
+ try {
285
+ const { encryptField } = cryptoModule;
286
+ const access_enc = await encryptField(data.access_token, {
287
+ keys,
288
+ aad: aadFor("hazo_google_oauth_tokens", userId, "access_token"),
289
+ });
290
+ const access_token_enc = JSON.stringify(access_enc);
291
+ await service.updateById(row.id, {
292
+ access_token_enc,
293
+ expires_at: new_expires_at,
294
+ changed_at: new Date().toISOString(),
295
+ });
296
+ } catch (err) {
297
+ const msg = err instanceof Error ? err.message : String(err);
298
+ logger.warn("google_token_service_persist_access_token_failed", {
299
+ filename: "google_token_service.ts",
300
+ user_id: userId,
301
+ error: msg,
302
+ note: "Access token refreshed successfully but could not be persisted",
303
+ });
304
+ }
305
+
306
+ // Update in-memory cache
307
+ access_token_cache.set(userId, { token: data.access_token, expires_at: new_expires_at });
308
+
309
+ logger.info("google_token_service_token_refreshed", {
310
+ filename: "google_token_service.ts",
311
+ user_id: userId,
312
+ });
313
+
314
+ return { ok: true, access_token: data.access_token, scopes: row.scopes };
315
+ }
316
+
317
+ // section: revoke
318
+
319
+ /**
320
+ * Revokes the user's Google OAuth tokens — best-effort remote revocation, then deletes DB row.
321
+ */
322
+ export async function revoke_google_oauth_token(
323
+ userId: string
324
+ ): Promise<{ ok: boolean; error?: string }> {
325
+ const logger = create_app_logger();
326
+
327
+ const adapter = get_hazo_connect_instance();
328
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
329
+
330
+ const rows = (await service.findBy({ user_id: userId, provider: "google" })) as GoogleOAuthRow[];
331
+ if (rows.length === 0) {
332
+ return { ok: false, error: "not_connected" };
333
+ }
334
+
335
+ const row = rows[0];
336
+
337
+ // Attempt remote revocation — best effort
338
+ try {
339
+ const { decryptField, aadFor, LookupKeyProvider } = await load_crypto_module();
340
+ const keys = makeKeyProvider(LookupKeyProvider as new (lookup: (name: string) => string | undefined) => unknown) as Parameters<typeof decryptField>[1]["keys"];
341
+
342
+ const decrypted_refresh = await decryptField(JSON.parse(row.refresh_token_enc), {
343
+ keys,
344
+ aad: aadFor("hazo_google_oauth_tokens", userId, "refresh_token"),
345
+ });
346
+
347
+ const revoke_resp = await fetch(
348
+ `https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(decrypted_refresh)}`,
349
+ { method: "POST" }
350
+ );
351
+
352
+ if (!revoke_resp.ok) {
353
+ logger.warn("google_token_service_revoke_remote_failed", {
354
+ filename: "google_token_service.ts",
355
+ user_id: userId,
356
+ status: revoke_resp.status,
357
+ note: "Google revocation returned non-OK status; proceeding to delete local record",
358
+ });
359
+ } else {
360
+ logger.info("google_token_service_revoke_remote_ok", {
361
+ filename: "google_token_service.ts",
362
+ user_id: userId,
363
+ });
364
+ }
365
+ } catch (err) {
366
+ const msg = err instanceof Error ? err.message : String(err);
367
+ logger.warn("google_token_service_revoke_remote_error", {
368
+ filename: "google_token_service.ts",
369
+ user_id: userId,
370
+ error: msg,
371
+ note: "Remote revocation failed; proceeding to delete local record",
372
+ });
373
+ }
374
+
375
+ // Always delete the local row
376
+ await service.deleteById(row.id);
377
+ access_token_cache.delete(userId);
378
+
379
+ logger.info("google_token_service_revoked", {
380
+ filename: "google_token_service.ts",
381
+ user_id: userId,
382
+ });
383
+
384
+ return { ok: true };
385
+ }
386
+
387
+ // section: status
388
+
389
+ /**
390
+ * Returns connection status and scope information for a user's Google OAuth connection.
391
+ * Does not decrypt or refresh tokens.
392
+ */
393
+ export async function get_google_token_status(userId: string): Promise<GoogleTokenStatus> {
394
+ const adapter = get_hazo_connect_instance();
395
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
396
+
397
+ const rows = (await service.findBy({ user_id: userId, provider: "google" })) as GoogleOAuthRow[];
398
+ if (rows.length === 0) {
399
+ return { connected: false, scopes: "", expires_at: null };
400
+ }
401
+
402
+ const row = rows[0];
403
+ return {
404
+ connected: true,
405
+ scopes: row.scopes,
406
+ expires_at: row.expires_at ?? null,
407
+ };
408
+ }
@@ -20,5 +20,5 @@ export * from "./scope_service.js";
20
20
  export * from "./user_scope_service.js";
21
21
  export * from "./oauth_service.js";
22
22
  export * from "./branding_service.js";
23
-
23
+ export * from "./google_token_service.js";
24
24
 
package/dist/client.d.ts CHANGED
@@ -9,4 +9,5 @@ export { use_firm_branding, use_current_user_branding } from "./components/layou
9
9
  export type { FirmBranding, UseFirmBrandingOptions, UseFirmBrandingResult } from "./components/layouts/shared/hooks/use_firm_branding";
10
10
  export { HAZO_AUTH_PERMISSIONS, ALL_ADMIN_PERMISSIONS, GLOBAL_ADMIN_PERMISSION } from "./lib/constants.js";
11
11
  export * from "./components/layouts/shared/utils/validation.js";
12
+ export { requestGoogleScopes } from "./lib/auth/request_google_scopes.js";
12
13
  //# sourceMappingURL=client.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAYA,cAAc,oBAAoB,CAAC;AAInC,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AAInI,OAAO,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAIpD,cAAc,uBAAuB,CAAC;AAItC,OAAO,EAAE,eAAe,EAAE,2BAA2B,EAAE,MAAM,mDAAmD,CAAC;AACjH,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC3G,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iDAAiD,CAAC;AAC7G,OAAO,EAAE,iBAAiB,EAAE,yBAAyB,EAAE,MAAM,qDAAqD,CAAC;AACnH,YAAY,EAAE,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,qDAAqD,CAAC;AAGvI,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAIxG,cAAc,8CAA8C,CAAC"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAYA,cAAc,oBAAoB,CAAC;AAInC,OAAO,EAAE,mBAAmB,EAAE,cAAc,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AAInI,OAAO,EAAE,EAAE,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAIpD,cAAc,uBAAuB,CAAC;AAItC,OAAO,EAAE,eAAe,EAAE,2BAA2B,EAAE,MAAM,mDAAmD,CAAC;AACjH,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC3G,YAAY,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,iDAAiD,CAAC;AAC7G,OAAO,EAAE,iBAAiB,EAAE,yBAAyB,EAAE,MAAM,qDAAqD,CAAC;AACnH,YAAY,EAAE,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,qDAAqD,CAAC;AAGvI,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAC;AAIxG,cAAc,8CAA8C,CAAC;AAG7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC"}
package/dist/client.js CHANGED
@@ -29,3 +29,5 @@ export { HAZO_AUTH_PERMISSIONS, ALL_ADMIN_PERMISSIONS, GLOBAL_ADMIN_PERMISSION }
29
29
  // section: validation_exports
30
30
  // Client-side validation utilities
31
31
  export * from "./components/layouts/shared/utils/validation.js";
32
+ // section: google_oauth_exports
33
+ export { requestGoogleScopes } from "./lib/auth/request_google_scopes.js";