hazo_auth 10.0.0 โ†’ 10.2.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 (52) hide show
  1. package/README.md +119 -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/hazo_connect_instance.server.ts +16 -0
  6. package/cli-src/lib/schema/sqlite_schema.ts +16 -0
  7. package/cli-src/lib/services/google_token_service.ts +408 -0
  8. package/cli-src/lib/services/index.ts +1 -1
  9. package/dist/client.d.ts +1 -0
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +2 -0
  12. package/dist/components/layouts/google_token_test/index.d.ts +6 -0
  13. package/dist/components/layouts/google_token_test/index.d.ts.map +1 -0
  14. package/dist/components/layouts/google_token_test/index.js +74 -0
  15. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
  16. package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
  17. package/dist/components/ui/button.d.ts +1 -1
  18. package/dist/components/ui/input-otp.d.ts +2 -2
  19. package/dist/index.d.ts +1 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +2 -0
  22. package/dist/lib/auth/nextauth_config.d.ts +2 -0
  23. package/dist/lib/auth/nextauth_config.d.ts.map +1 -1
  24. package/dist/lib/auth/nextauth_config.js +39 -1
  25. package/dist/lib/auth/request_google_scopes.d.ts +10 -0
  26. package/dist/lib/auth/request_google_scopes.d.ts.map +1 -0
  27. package/dist/lib/auth/request_google_scopes.js +13 -0
  28. package/dist/lib/hazo_connect_instance.server.d.ts +4 -0
  29. package/dist/lib/hazo_connect_instance.server.d.ts.map +1 -1
  30. package/dist/lib/hazo_connect_instance.server.js +15 -0
  31. package/dist/lib/schema/sqlite_schema.d.ts +1 -1
  32. package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
  33. package/dist/lib/schema/sqlite_schema.js +16 -0
  34. package/dist/lib/services/google_token_service.d.ts +48 -0
  35. package/dist/lib/services/google_token_service.d.ts.map +1 -0
  36. package/dist/lib/services/google_token_service.js +319 -0
  37. package/dist/lib/services/index.d.ts +1 -0
  38. package/dist/lib/services/index.d.ts.map +1 -1
  39. package/dist/lib/services/index.js +1 -0
  40. package/dist/server/routes/google_token.d.ts +13 -0
  41. package/dist/server/routes/google_token.d.ts.map +1 -0
  42. package/dist/server/routes/google_token.js +66 -0
  43. package/dist/server/routes/index.d.ts +1 -0
  44. package/dist/server/routes/index.d.ts.map +1 -1
  45. package/dist/server/routes/index.js +2 -0
  46. package/dist/server/routes/me.d.ts.map +1 -1
  47. package/dist/server/routes/me.js +3 -0
  48. package/dist/server/routes/user_management_users.d.ts +1 -1
  49. package/dist/server-lib.d.ts +1 -1
  50. package/dist/server-lib.d.ts.map +1 -1
  51. package/dist/server-lib.js +1 -1
  52. package/package.json +11 -7
package/README.md CHANGED
@@ -2,6 +2,83 @@
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.2.0 ๐Ÿงช
6
+
7
+ **Test-friendly hazo_connect injection + autotest/middleware fixes.**
8
+
9
+ - **`set_hazo_connect_instance(adapter)` / `reset_hazo_connect_instance()`** from `hazo_auth/server-lib` โ€” inject a pre-built adapter into both the hazo_auth singleton cache and the underlying hazo_connect singleton (companion to hazo_connect 3.6.0 FR-001). Call `set_*` in `beforeAll` and `reset_*` in `afterAll` to swap in a test SQLite adapter without touching the production config:
10
+
11
+ ```typescript
12
+ import { set_hazo_connect_instance, reset_hazo_connect_instance } from "hazo_auth/server-lib";
13
+
14
+ beforeAll(() => set_hazo_connect_instance(testAdapter));
15
+ afterAll(() => reset_hazo_connect_instance());
16
+ ```
17
+
18
+ - **Middleware no longer redirects `/api/` requests to the login page** โ€” protected API routes return JSON `401`/`403` and the login redirect is now built from the request's own origin (fixes `Failed to fetch` on any non-default dev/prod port).
19
+ - **`/api/hazo_auth/me`** now exposes `legal_acceptance` at the top level; **`/api/hazo_auth/health`** now returns a top-level `ok` boolean.
20
+ - Browser autotest scenarios corrected for browser `fetch` semantics (opaque redirects, `*_default.jpg` images, admin-gated `401`/`403`, current `relationships` body shape).
21
+
22
+ Requires `hazo_connect ^3.6.0`.
23
+
24
+ ### What's New in v10.1.0 ๐Ÿš€
25
+
26
+ **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.
27
+
28
+ **New Features:**
29
+ - **`hazo_google_oauth_tokens` table** (migration 021) โ€” Encrypted AES-256-GCM storage for Google OAuth tokens with scope tracking
30
+ - **Token Service Exports** from `hazo_auth/server-lib`:
31
+ - `store_google_oauth_token(user_id, tokens, scopes)` โ€” Save OAuth tokens after user grants new scopes
32
+ - `getGoogleToken(user_id, opts?)` โ€” Get a fresh access token (refreshes if expired)
33
+ - `revoke_google_oauth_token(user_id)` โ€” Permanently revoke stored token and forget scopes
34
+ - `get_google_token_status(user_id)` โ€” Check connection status, scopes, and expiry
35
+ - **Client Helper** โ€” `requestGoogleScopes(scopes, opts?)` from `hazo_auth/client` triggers Google consent prompt for incremental scopes
36
+ - **HTTP Routes:**
37
+ - `GET /api/hazo_auth/google/token` โ€” Returns `{ connected, scopes, expires_at }`
38
+ - `DELETE /api/hazo_auth/google/token` โ€” Revoke stored token without signing user out
39
+ - **NextAuth Config Updates** โ€” Captures `refresh_token` and adds `include_granted_scopes: true` parameter for incremental scope flow
40
+ - **Route Handler Exports** from `hazo_auth/server/routes`:
41
+ - `googleTokenGET` โ€” implements GET status endpoint
42
+ - `googleTokenDELETE` โ€” implements DELETE revoke endpoint
43
+
44
+ **Setup:**
45
+ ```bash
46
+ # 1. Run the migration
47
+ npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql
48
+
49
+ # 2. Set encryption environment variables
50
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
51
+ HAZO_AUTH_OAUTH_KEY_V1=$(openssl rand -base64 32)
52
+
53
+ # 3. Install optional peer (for token encryption)
54
+ npm install hazo_secure
55
+ ```
56
+
57
+ **Client Usage:**
58
+ ```tsx
59
+ import { requestGoogleScopes } from "hazo_auth/client";
60
+
61
+ <button onClick={() => requestGoogleScopes([
62
+ "https://www.googleapis.com/auth/analytics.readonly"
63
+ ])}>
64
+ Connect Google Analytics
65
+ </button>
66
+ ```
67
+
68
+ **Server Usage:**
69
+ ```ts
70
+ import { getGoogleToken } from "hazo_auth/server-lib";
71
+
72
+ const result = await getGoogleToken(userId, {
73
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"]
74
+ });
75
+ if (result.ok) {
76
+ // use result.access_token to call Google APIs
77
+ }
78
+ ```
79
+
80
+ See [Google API Access (Incremental Scopes)](#google-api-access-incremental-scopes) below for full details.
81
+
5
82
  ### What's New in v9.1.1 ๐Ÿ”ง
6
83
 
7
84
  **Dev-server noise fixes for Next.js 16 + Turbopack**
@@ -1251,6 +1328,48 @@ Google OAuth adds one new dependency:
1251
1328
  - Check logs for `invitation_table_missing` warnings
1252
1329
  - If using custom paths, set `create_firm_url` to your app's create firm page URL
1253
1330
 
1331
+ ### Google API Access (Incremental Scopes)
1332
+
1333
+ `hazo_auth` v10.1+ supports granting additional Google API scopes (Analytics, Sheets, etc.) without a separate OAuth app.
1334
+
1335
+ **Setup:**
1336
+ 1. Run the migration: `npm run migrate -- migrations/021_hazo_google_oauth_tokens.sql`
1337
+ 2. Set encryption env vars:
1338
+ ```bash
1339
+ HAZO_AUTH_OAUTH_KEY_CURRENT=v1
1340
+ HAZO_AUTH_OAUTH_KEY_V1=$(openssl rand -base64 32)
1341
+ ```
1342
+ 3. Install the optional peer: `npm install hazo_secure`
1343
+
1344
+ **Usage:**
1345
+
1346
+ ```tsx
1347
+ // Client component โ€” triggers Google consent for extra scopes
1348
+ import { requestGoogleScopes } from "hazo_auth/client";
1349
+
1350
+ <button onClick={() => requestGoogleScopes([
1351
+ "https://www.googleapis.com/auth/analytics.readonly"
1352
+ ])}>
1353
+ Connect Analytics
1354
+ </button>
1355
+ ```
1356
+
1357
+ ```ts
1358
+ // Server action / API route โ€” get a fresh access token
1359
+ import { getGoogleToken } from "hazo_auth/server-lib";
1360
+
1361
+ const result = await getGoogleToken(userId, {
1362
+ scopes: ["https://www.googleapis.com/auth/analytics.readonly"]
1363
+ });
1364
+ if (result.ok) {
1365
+ // use result.access_token to call Google APIs
1366
+ }
1367
+ ```
1368
+
1369
+ 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.
1370
+
1371
+ **Status endpoint:** `GET /api/hazo_auth/google/token` returns `{ connected, scopes, expires_at }`.
1372
+
1254
1373
  ---
1255
1374
 
1256
1375
  ## 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
+ }
@@ -8,6 +8,7 @@ import "server-only";
8
8
  // section: imports
9
9
  import type { HazoConnectAdapter } from "hazo_connect";
10
10
  import { getHazoConnectSingleton } from "hazo_connect/nextjs/setup";
11
+ import { setHazoConnectSingleton, resetHazoConnectSingleton } from "hazo_connect/testing";
11
12
  import { create_sqlite_hazo_connect_server, get_hazo_connect_config_options } from "./hazo_connect_setup.server.js";
12
13
  import { initializeAdminService, getSqliteAdminService } from "hazo_connect/server";
13
14
  import { create_app_logger } from "./app_logger.js";
@@ -32,6 +33,8 @@ let isInitialized = false;
32
33
  * @returns The singleton HazoConnectAdapter instance
33
34
  */
34
35
  export function get_hazo_connect_instance(): HazoConnectAdapter {
36
+ // Honor test injection or cached fallback first
37
+ if (hazoConnectInstance) return hazoConnectInstance;
35
38
  // Use the new singleton API from hazo_connect
36
39
  // This automatically handles:
37
40
  // - Instance reuse
@@ -102,3 +105,16 @@ export function get_hazo_connect_instance(): HazoConnectAdapter {
102
105
  }
103
106
  }
104
107
 
108
+ /** Inject a connect adapter for tests (mirrors set_hazo_connect_instance pattern). */
109
+ export function set_hazo_connect_instance(adapter: HazoConnectAdapter): void {
110
+ hazoConnectInstance = adapter;
111
+ setHazoConnectSingleton(adapter);
112
+ }
113
+
114
+ /** Reset the injected adapter (call in afterAll). */
115
+ export function reset_hazo_connect_instance(): void {
116
+ hazoConnectInstance = null;
117
+ isInitialized = false;
118
+ resetHazoConnectSingleton();
119
+ }
120
+
@@ -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
  `;