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
@@ -12,6 +12,8 @@ export type NextAuthCallbackAccount = {
12
12
  access_token?: string;
13
13
  id_token?: string;
14
14
  expires_at?: number;
15
+ refresh_token?: string;
16
+ scope?: string;
15
17
  };
16
18
  export type NextAuthCallbackProfile = {
17
19
  sub?: string;
@@ -1 +1 @@
1
- {"version":3,"file":"nextauth_config.d.ts","sourceRoot":"","sources":["../../../src/lib/auth/nextauth_config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAW,MAAM,WAAW,CAAC;AAatD,MAAM,MAAM,oBAAoB,GAAG;IACjC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAGF;;;;GAIG;AACH,wBAAgB,mBAAmB,IAAI,WAAW,CAkOjD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAW7C"}
1
+ {"version":3,"file":"nextauth_config.d.ts","sourceRoot":"","sources":["../../../src/lib/auth/nextauth_config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAW,MAAM,WAAW,CAAC;AActD,MAAM,MAAM,oBAAoB,GAAG;IACjC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,uBAAuB,GAAG;IACpC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B,CAAC;AAGF;;;;GAIG;AACH,wBAAgB,mBAAmB,IAAI,WAAW,CAwQjD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAW7C"}
@@ -7,6 +7,7 @@ import { get_oauth_config } from "../oauth_config.server.js";
7
7
  import { handle_google_oauth_login } from "../services/oauth_service.js";
8
8
  import { get_hazo_connect_instance } from "../hazo_connect_instance.server.js";
9
9
  import { create_app_logger } from "../app_logger.js";
10
+ import { store_google_oauth_token, GoogleTokenStorageUnconfigured } from "../services/google_token_service.js";
10
11
  // section: config
11
12
  /**
12
13
  * Gets NextAuth.js configuration with enabled OAuth providers
@@ -29,6 +30,7 @@ export function get_nextauth_config() {
29
30
  prompt: "consent",
30
31
  access_type: "offline",
31
32
  response_type: "code",
33
+ include_granted_scopes: "true",
32
34
  },
33
35
  },
34
36
  }));
@@ -79,7 +81,7 @@ export function get_nextauth_config() {
79
81
  * Sign-in callback - handle user creation/linking for Google OAuth
80
82
  */
81
83
  async signIn({ account, profile, user, }) {
82
- var _a;
84
+ var _a, _b, _c;
83
85
  const logger = create_app_logger();
84
86
  if ((account === null || account === void 0 ? void 0 : account.provider) === "google" && profile) {
85
87
  try {
@@ -113,6 +115,42 @@ export function get_nextauth_config() {
113
115
  });
114
116
  // Store user_id in account for the JWT callback to pick up
115
117
  account.hazo_user_id = result.user_id;
118
+ // Capture refresh token when extra scopes were granted
119
+ const BASE_SCOPES = ["openid", "email", "profile"];
120
+ const granted_scopes = ((_b = account.scope) !== null && _b !== void 0 ? _b : "").split(" ").filter(Boolean);
121
+ const has_extra_scopes = granted_scopes.some(s => !BASE_SCOPES.includes(s));
122
+ if (account.refresh_token && has_extra_scopes) {
123
+ try {
124
+ await store_google_oauth_token({
125
+ user_id: result.user_id,
126
+ refresh_token: account.refresh_token,
127
+ access_token: account.access_token,
128
+ scopes: (_c = account.scope) !== null && _c !== void 0 ? _c : "",
129
+ expires_at: account.expires_at
130
+ ? new Date(account.expires_at * 1000).toISOString()
131
+ : undefined,
132
+ });
133
+ logger.info("nextauth_google_token_stored", {
134
+ user_id: result.user_id,
135
+ scopes: account.scope,
136
+ });
137
+ }
138
+ catch (tokenError) {
139
+ if (tokenError instanceof GoogleTokenStorageUnconfigured) {
140
+ logger.error("nextauth_google_token_storage_unconfigured", {
141
+ user_id: result.user_id,
142
+ error: tokenError.message,
143
+ });
144
+ return "/api/auth/error?error=GoogleTokenStorageUnconfigured";
145
+ }
146
+ const errorMsg = tokenError instanceof Error ? tokenError.message : String(tokenError);
147
+ logger.error("nextauth_google_token_store_failed", {
148
+ user_id: result.user_id,
149
+ error: errorMsg,
150
+ });
151
+ // Non-fatal: continue sign-in even if token storage fails
152
+ }
153
+ }
116
154
  return true;
117
155
  }
118
156
  catch (error) {
@@ -0,0 +1,10 @@
1
+ import { signIn } from "next-auth/react";
2
+ /**
3
+ * Triggers an incremental Google OAuth consent flow to grant additional scopes.
4
+ * Call from a client component when the user needs to grant extra Google API access
5
+ * (e.g. analytics, sheets). On completion, hazo_auth stores the refresh token.
6
+ */
7
+ export declare function requestGoogleScopes(scopes: string[], opts?: {
8
+ callbackUrl?: string;
9
+ }): ReturnType<typeof signIn>;
10
+ //# sourceMappingURL=request_google_scopes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request_google_scopes.d.ts","sourceRoot":"","sources":["../../../src/lib/auth/request_google_scopes.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EAAE,EAChB,IAAI,CAAC,EAAE;IAAE,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9B,UAAU,CAAC,OAAO,MAAM,CAAC,CAS3B"}
@@ -0,0 +1,13 @@
1
+ // file_description: client helper to trigger incremental Google OAuth scope consent
2
+ "use client";
3
+ import { signIn } from "next-auth/react";
4
+ /**
5
+ * Triggers an incremental Google OAuth consent flow to grant additional scopes.
6
+ * Call from a client component when the user needs to grant extra Google API access
7
+ * (e.g. analytics, sheets). On completion, hazo_auth stores the refresh token.
8
+ */
9
+ export function requestGoogleScopes(scopes, opts) {
10
+ var _a;
11
+ const scope = Array.from(new Set(["openid", "email", "profile", ...scopes])).join(" ");
12
+ return signIn("google", { callbackUrl: (_a = opts === null || opts === void 0 ? void 0 : opts.callbackUrl) !== null && _a !== void 0 ? _a : "/" }, { scope, access_type: "offline", include_granted_scopes: "true", prompt: "consent" });
13
+ }
@@ -15,4 +15,8 @@ import type { HazoConnectAdapter } from "hazo_connect";
15
15
  * @returns The singleton HazoConnectAdapter instance
16
16
  */
17
17
  export declare function get_hazo_connect_instance(): HazoConnectAdapter;
18
+ /** Inject a connect adapter for tests (mirrors set_hazo_connect_instance pattern). */
19
+ export declare function set_hazo_connect_instance(adapter: HazoConnectAdapter): void;
20
+ /** Reset the injected adapter (call in afterAll). */
21
+ export declare function reset_hazo_connect_instance(): void;
18
22
  //# sourceMappingURL=hazo_connect_instance.server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hazo_connect_instance.server.d.ts","sourceRoot":"","sources":["../../src/lib/hazo_connect_instance.server.ts"],"names":[],"mappings":"AAEA,OAAO,aAAa,CAAC;AAMrB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAWvD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,IAAI,kBAAkB,CAqE9D"}
1
+ {"version":3,"file":"hazo_connect_instance.server.d.ts","sourceRoot":"","sources":["../../src/lib/hazo_connect_instance.server.ts"],"names":[],"mappings":"AAEA,OAAO,aAAa,CAAC;AAMrB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAYvD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,yBAAyB,IAAI,kBAAkB,CAuE9D;AAED,sFAAsF;AACtF,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,kBAAkB,GAAG,IAAI,CAG3E;AAED,qDAAqD;AACrD,wBAAgB,2BAA2B,IAAI,IAAI,CAIlD"}
@@ -2,6 +2,7 @@
2
2
  // section: server-only-guard
3
3
  import "server-only";
4
4
  import { getHazoConnectSingleton } from "hazo_connect/nextjs/setup";
5
+ import { setHazoConnectSingleton, resetHazoConnectSingleton } from "hazo_connect/testing";
5
6
  import { create_sqlite_hazo_connect_server, get_hazo_connect_config_options } from "./hazo_connect_setup.server.js";
6
7
  import { initializeAdminService, getSqliteAdminService } from "hazo_connect/server";
7
8
  import { create_app_logger } from "./app_logger.js";
@@ -24,6 +25,9 @@ let isInitialized = false;
24
25
  * @returns The singleton HazoConnectAdapter instance
25
26
  */
26
27
  export function get_hazo_connect_instance() {
28
+ // Honor test injection or cached fallback first
29
+ if (hazoConnectInstance)
30
+ return hazoConnectInstance;
27
31
  // Use the new singleton API from hazo_connect
28
32
  // This automatically handles:
29
33
  // - Instance reuse
@@ -89,3 +93,14 @@ export function get_hazo_connect_instance() {
89
93
  return hazoConnectInstance;
90
94
  }
91
95
  }
96
+ /** Inject a connect adapter for tests (mirrors set_hazo_connect_instance pattern). */
97
+ export function set_hazo_connect_instance(adapter) {
98
+ hazoConnectInstance = adapter;
99
+ setHazoConnectSingleton(adapter);
100
+ }
101
+ /** Reset the injected adapter (call in afterAll). */
102
+ export function reset_hazo_connect_instance() {
103
+ hazoConnectInstance = null;
104
+ isInitialized = false;
105
+ resetHazoConnectSingleton();
106
+ }
@@ -1,2 +1,2 @@
1
- export declare const SQLITE_SCHEMA = "\n-- hazo_auth canonical SQLite schema\n-- This schema creates all tables required by hazo_auth\n\n-- Users table (from migration 011)\nCREATE TABLE IF NOT EXISTS hazo_users (\n id TEXT PRIMARY KEY,\n email_address TEXT NOT NULL UNIQUE,\n password_hash TEXT,\n name TEXT,\n email_verified BOOLEAN DEFAULT false,\n login_attempts INTEGER DEFAULT 0,\n last_logon TEXT,\n profile_picture_url TEXT,\n profile_source TEXT CHECK(profile_source IN ('gravatar', 'custom', 'predefined')),\n mfa_secret TEXT,\n url_on_logon TEXT,\n google_id TEXT UNIQUE,\n auth_providers TEXT DEFAULT 'email',\n user_type TEXT,\n app_user_data TEXT,\n status TEXT DEFAULT 'ACTIVE' CHECK(status IN ('PENDING', 'ACTIVE', 'BLOCKED')),\n managed_by_user_id TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,\n pin_hash TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_users_email ON hazo_users(email_address);\nCREATE INDEX IF NOT EXISTS idx_hazo_users_google_id ON hazo_users(google_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_users_status ON hazo_users(status);\n\n-- Refresh tokens table\n-- Note: runtime (token_service.ts) writes token_hash (argon2-hashed value) on\n-- insert and verifies via argon2.verify(token_hash, plaintext_token) on read.\n-- The plaintext \"token\" column is retained nullable for legacy compatibility\n-- but new writes only set token_hash.\nCREATE TABLE IF NOT EXISTS hazo_refresh_tokens (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n token TEXT UNIQUE,\n token_hash TEXT,\n token_type TEXT DEFAULT 'refresh',\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_user ON hazo_refresh_tokens(user_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_token ON hazo_refresh_tokens(token);\nCREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_token_hash ON hazo_refresh_tokens(token_hash);\n\n-- Roles table\nCREATE TABLE IF NOT EXISTS hazo_roles (\n id TEXT PRIMARY KEY,\n role_name TEXT NOT NULL UNIQUE,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n-- Permissions table\nCREATE TABLE IF NOT EXISTS hazo_permissions (\n id TEXT PRIMARY KEY,\n permission_name TEXT NOT NULL UNIQUE,\n description TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n-- Role-permission assignments (composite PK, no id column)\nCREATE TABLE IF NOT EXISTS hazo_role_permissions (\n role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,\n permission_id TEXT NOT NULL REFERENCES hazo_permissions(id) ON DELETE CASCADE,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY (role_id, permission_id)\n);\n\n-- Unified scopes table (hierarchical multi-tenancy)\nCREATE TABLE IF NOT EXISTS hazo_scopes (\n id TEXT PRIMARY KEY,\n parent_id TEXT REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n name TEXT NOT NULL,\n level TEXT NOT NULL,\n logo_url TEXT,\n primary_color TEXT,\n secondary_color TEXT,\n tagline TEXT,\n slug TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);\nCREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);\n\n-- Default system scope (for non-multi-tenancy mode)\nINSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)\nVALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));\n\n-- User-scope assignments (membership model with scope-specific roles)\nCREATE TABLE IF NOT EXISTS hazo_user_scopes (\n user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,\n status TEXT DEFAULT 'ACTIVE' CHECK (status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'DEPARTED')),\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY (user_id, scope_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_scope ON hazo_user_scopes(scope_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_root ON hazo_user_scopes(root_scope_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_role ON hazo_user_scopes(role_id);\n\n-- Invitations table\nCREATE TABLE IF NOT EXISTS hazo_invitations (\n id TEXT PRIMARY KEY,\n email_address TEXT NOT NULL,\n token TEXT NOT NULL UNIQUE,\n scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,\n invited_by TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,\n status TEXT NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED')),\n expires_at TEXT NOT NULL,\n accepted_at TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_email ON hazo_invitations(email_address);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_token ON hazo_invitations(token);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_scope ON hazo_invitations(scope_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_status ON hazo_invitations(status);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_expires ON hazo_invitations(expires_at);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_users_managed_by ON hazo_users(managed_by_user_id);\n\n-- Relationships table\nCREATE TABLE IF NOT EXISTS hazo_user_relationships (\n id TEXT PRIMARY KEY,\n parent_user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n child_user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n relationship_type TEXT NOT NULL DEFAULT 'parent',\n can_view_progress BOOLEAN DEFAULT true,\n can_edit_profile BOOLEAN DEFAULT true,\n can_delete BOOLEAN DEFAULT false,\n is_self BOOLEAN DEFAULT false,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n UNIQUE(parent_user_id, child_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_parent ON hazo_user_relationships(parent_user_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relationships(child_user_id);\n\n-- Firm admin role (for firm creators)\nINSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)\nVALUES (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'));\n";
1
+ export declare const SQLITE_SCHEMA = "\n-- hazo_auth canonical SQLite schema\n-- This schema creates all tables required by hazo_auth\n\n-- Users table (from migration 011)\nCREATE TABLE IF NOT EXISTS hazo_users (\n id TEXT PRIMARY KEY,\n email_address TEXT NOT NULL UNIQUE,\n password_hash TEXT,\n name TEXT,\n email_verified BOOLEAN DEFAULT false,\n login_attempts INTEGER DEFAULT 0,\n last_logon TEXT,\n profile_picture_url TEXT,\n profile_source TEXT CHECK(profile_source IN ('gravatar', 'custom', 'predefined')),\n mfa_secret TEXT,\n url_on_logon TEXT,\n google_id TEXT UNIQUE,\n auth_providers TEXT DEFAULT 'email',\n user_type TEXT,\n app_user_data TEXT,\n status TEXT DEFAULT 'ACTIVE' CHECK(status IN ('PENDING', 'ACTIVE', 'BLOCKED')),\n managed_by_user_id TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,\n pin_hash TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_users_email ON hazo_users(email_address);\nCREATE INDEX IF NOT EXISTS idx_hazo_users_google_id ON hazo_users(google_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_users_status ON hazo_users(status);\n\n-- Refresh tokens table\n-- Note: runtime (token_service.ts) writes token_hash (argon2-hashed value) on\n-- insert and verifies via argon2.verify(token_hash, plaintext_token) on read.\n-- The plaintext \"token\" column is retained nullable for legacy compatibility\n-- but new writes only set token_hash.\nCREATE TABLE IF NOT EXISTS hazo_refresh_tokens (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n token TEXT UNIQUE,\n token_hash TEXT,\n token_type TEXT DEFAULT 'refresh',\n expires_at TEXT NOT NULL,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_user ON hazo_refresh_tokens(user_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_token ON hazo_refresh_tokens(token);\nCREATE INDEX IF NOT EXISTS idx_hazo_refresh_tokens_token_hash ON hazo_refresh_tokens(token_hash);\n\n-- Roles table\nCREATE TABLE IF NOT EXISTS hazo_roles (\n id TEXT PRIMARY KEY,\n role_name TEXT NOT NULL UNIQUE,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n-- Permissions table\nCREATE TABLE IF NOT EXISTS hazo_permissions (\n id TEXT PRIMARY KEY,\n permission_name TEXT NOT NULL UNIQUE,\n description TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\n-- Role-permission assignments (composite PK, no id column)\nCREATE TABLE IF NOT EXISTS hazo_role_permissions (\n role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,\n permission_id TEXT NOT NULL REFERENCES hazo_permissions(id) ON DELETE CASCADE,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY (role_id, permission_id)\n);\n\n-- Unified scopes table (hierarchical multi-tenancy)\nCREATE TABLE IF NOT EXISTS hazo_scopes (\n id TEXT PRIMARY KEY,\n parent_id TEXT REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n name TEXT NOT NULL,\n level TEXT NOT NULL,\n logo_url TEXT,\n primary_color TEXT,\n secondary_color TEXT,\n tagline TEXT,\n slug TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);\nCREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);\n\n-- Default system scope (for non-multi-tenancy mode)\nINSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)\nVALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));\n\n-- User-scope assignments (membership model with scope-specific roles)\nCREATE TABLE IF NOT EXISTS hazo_user_scopes (\n user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,\n status TEXT DEFAULT 'ACTIVE' CHECK (status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'DEPARTED')),\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now')),\n PRIMARY KEY (user_id, scope_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_scope ON hazo_user_scopes(scope_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_root ON hazo_user_scopes(root_scope_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_user_scopes_role ON hazo_user_scopes(role_id);\n\n-- Invitations table\nCREATE TABLE IF NOT EXISTS hazo_invitations (\n id TEXT PRIMARY KEY,\n email_address TEXT NOT NULL,\n token TEXT NOT NULL UNIQUE,\n scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n root_scope_id TEXT NOT NULL REFERENCES hazo_scopes(id) ON DELETE CASCADE,\n role_id TEXT NOT NULL REFERENCES hazo_roles(id) ON DELETE CASCADE,\n invited_by TEXT REFERENCES hazo_users(id) ON DELETE SET NULL,\n status TEXT NOT NULL DEFAULT 'PENDING' CHECK(status IN ('PENDING', 'ACCEPTED', 'EXPIRED', 'REVOKED')),\n expires_at TEXT NOT NULL,\n accepted_at TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now'))\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_email ON hazo_invitations(email_address);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_token ON hazo_invitations(token);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_scope ON hazo_invitations(scope_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_status ON hazo_invitations(status);\nCREATE INDEX IF NOT EXISTS idx_hazo_invitations_expires ON hazo_invitations(expires_at);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_users_managed_by ON hazo_users(managed_by_user_id);\n\n-- Relationships table\nCREATE TABLE IF NOT EXISTS hazo_user_relationships (\n id TEXT PRIMARY KEY,\n parent_user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n child_user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n relationship_type TEXT NOT NULL DEFAULT 'parent',\n can_view_progress BOOLEAN DEFAULT true,\n can_edit_profile BOOLEAN DEFAULT true,\n can_delete BOOLEAN DEFAULT false,\n is_self BOOLEAN DEFAULT false,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n UNIQUE(parent_user_id, child_user_id)\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_parent ON hazo_user_relationships(parent_user_id);\nCREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relationships(child_user_id);\n\n-- Firm admin role (for firm creators)\nINSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)\nVALUES (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'));\n\n-- Google OAuth tokens (encrypted refresh/access tokens for extra-scope API access)\nCREATE TABLE IF NOT EXISTS hazo_google_oauth_tokens (\n id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,\n provider TEXT NOT NULL DEFAULT 'google',\n refresh_token_enc TEXT NOT NULL,\n access_token_enc TEXT,\n scopes TEXT NOT NULL DEFAULT '',\n expires_at TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now')),\n changed_at TEXT NOT NULL DEFAULT (datetime('now')),\n UNIQUE(user_id, provider)\n);\n\nCREATE INDEX IF NOT EXISTS idx_hazo_google_oauth_tokens_user ON hazo_google_oauth_tokens(user_id);\n";
2
2
  //# sourceMappingURL=sqlite_schema.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite_schema.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/sqlite_schema.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,aAAa,ogOA+JzB,CAAC"}
1
+ {"version":3,"file":"sqlite_schema.d.ts","sourceRoot":"","sources":["../../../src/lib/schema/sqlite_schema.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,aAAa,uoPA+KzB,CAAC"}
@@ -160,4 +160,20 @@ CREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relati
160
160
  -- Firm admin role (for firm creators)
161
161
  INSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)
162
162
  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'));
163
+
164
+ -- Google OAuth tokens (encrypted refresh/access tokens for extra-scope API access)
165
+ CREATE TABLE IF NOT EXISTS hazo_google_oauth_tokens (
166
+ id TEXT PRIMARY KEY,
167
+ user_id TEXT NOT NULL REFERENCES hazo_users(id) ON DELETE CASCADE,
168
+ provider TEXT NOT NULL DEFAULT 'google',
169
+ refresh_token_enc TEXT NOT NULL,
170
+ access_token_enc TEXT,
171
+ scopes TEXT NOT NULL DEFAULT '',
172
+ expires_at TEXT,
173
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
174
+ changed_at TEXT NOT NULL DEFAULT (datetime('now')),
175
+ UNIQUE(user_id, provider)
176
+ );
177
+
178
+ CREATE INDEX IF NOT EXISTS idx_hazo_google_oauth_tokens_user ON hazo_google_oauth_tokens(user_id);
163
179
  `;
@@ -0,0 +1,48 @@
1
+ export declare class GoogleTokenStorageUnconfigured extends Error {
2
+ constructor();
3
+ }
4
+ export type StoreGoogleOAuthTokenParams = {
5
+ user_id: string;
6
+ refresh_token: string;
7
+ access_token?: string;
8
+ scopes: string;
9
+ expires_at?: string;
10
+ };
11
+ export type GoogleTokenResult = {
12
+ ok: true;
13
+ access_token: string;
14
+ scopes: string;
15
+ } | {
16
+ ok: false;
17
+ error: "not_connected" | "reconsent_required" | "refresh_failed" | "unconfigured";
18
+ };
19
+ export type GoogleTokenStatus = {
20
+ connected: boolean;
21
+ scopes: string;
22
+ expires_at: string | null;
23
+ };
24
+ /**
25
+ * Stores (inserts or updates) Google OAuth tokens for a user, encrypting sensitive fields.
26
+ * Throws GoogleTokenStorageUnconfigured if hazo_secure/crypto is unavailable or keys are missing.
27
+ */
28
+ export declare function store_google_oauth_token(params: StoreGoogleOAuthTokenParams): Promise<void>;
29
+ /**
30
+ * Returns a valid access token for the user, refreshing via Google if needed.
31
+ * Returns a typed error result instead of throwing for expected failure modes.
32
+ */
33
+ export declare function getGoogleToken(userId: string, opts?: {
34
+ scopes?: string[];
35
+ }): Promise<GoogleTokenResult>;
36
+ /**
37
+ * Revokes the user's Google OAuth tokens — best-effort remote revocation, then deletes DB row.
38
+ */
39
+ export declare function revoke_google_oauth_token(userId: string): Promise<{
40
+ ok: boolean;
41
+ error?: string;
42
+ }>;
43
+ /**
44
+ * Returns connection status and scope information for a user's Google OAuth connection.
45
+ * Does not decrypt or refresh tokens.
46
+ */
47
+ export declare function get_google_token_status(userId: string): Promise<GoogleTokenStatus>;
48
+ //# sourceMappingURL=google_token_service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google_token_service.d.ts","sourceRoot":"","sources":["../../../src/lib/services/google_token_service.ts"],"names":[],"mappings":"AAUA,qBAAa,8BAA+B,SAAQ,KAAK;;CAOxD;AAID,MAAM,MAAM,2BAA2B,GAAG;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GACzB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,eAAe,GAAG,oBAAoB,GAAG,gBAAgB,GAAG,cAAc,CAAA;CAAE,CAAC;AAErG,MAAM,MAAM,iBAAiB,GAAG;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,CAAC;AA6CF;;;GAGG;AACH,wBAAsB,wBAAwB,CAC5C,MAAM,EAAE,2BAA2B,GAClC,OAAO,CAAC,IAAI,CAAC,CA0Ef;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAC3B,OAAO,CAAC,iBAAiB,CAAC,CA6I5B;AAID;;GAEG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA6D1C;AAID;;;GAGG;AACH,wBAAsB,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAexF"}
@@ -0,0 +1,319 @@
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
+ // section: errors
9
+ export class GoogleTokenStorageUnconfigured extends Error {
10
+ constructor() {
11
+ super("Google token storage is not configured. Set HAZO_AUTH_OAUTH_KEY_CURRENT and HAZO_AUTH_OAUTH_KEY_<ID> env vars.");
12
+ this.name = "GoogleTokenStorageUnconfigured";
13
+ }
14
+ }
15
+ // section: cache
16
+ const access_token_cache = new Map();
17
+ // section: helpers
18
+ function makeKeyProvider(LookupKeyProvider) {
19
+ return new LookupKeyProvider((name) => {
20
+ if (name === "current")
21
+ return process.env.HAZO_AUTH_OAUTH_KEY_CURRENT;
22
+ if (name.startsWith("key_")) {
23
+ const keyId = name.slice(4).toUpperCase();
24
+ return process.env[`HAZO_AUTH_OAUTH_KEY_${keyId}`];
25
+ }
26
+ return undefined;
27
+ });
28
+ }
29
+ async function load_crypto_module() {
30
+ const cryptoModule = await optional_import("hazo_secure/crypto");
31
+ if (!cryptoModule)
32
+ throw new GoogleTokenStorageUnconfigured();
33
+ return cryptoModule;
34
+ }
35
+ // section: store
36
+ /**
37
+ * Stores (inserts or updates) Google OAuth tokens for a user, encrypting sensitive fields.
38
+ * Throws GoogleTokenStorageUnconfigured if hazo_secure/crypto is unavailable or keys are missing.
39
+ */
40
+ export async function store_google_oauth_token(params) {
41
+ var _a, _b;
42
+ const logger = create_app_logger();
43
+ const { encryptField, decryptField: _decryptField, aadFor, LookupKeyProvider } = await load_crypto_module();
44
+ const keys = makeKeyProvider(LookupKeyProvider);
45
+ // Force-fail early if no key is configured
46
+ try {
47
+ await keys.current();
48
+ }
49
+ catch (err) {
50
+ const msg = err instanceof Error ? err.message : String(err);
51
+ logger.error("google_token_service_key_provider_failed", {
52
+ filename: "google_token_service.ts",
53
+ error: msg,
54
+ });
55
+ throw new GoogleTokenStorageUnconfigured();
56
+ }
57
+ const refresh_enc = await encryptField(params.refresh_token, {
58
+ keys,
59
+ aad: aadFor("hazo_google_oauth_tokens", params.user_id, "refresh_token"),
60
+ });
61
+ const refresh_token_enc = JSON.stringify(refresh_enc);
62
+ let access_token_enc = null;
63
+ if (params.access_token) {
64
+ const access_enc = await encryptField(params.access_token, {
65
+ keys,
66
+ aad: aadFor("hazo_google_oauth_tokens", params.user_id, "access_token"),
67
+ });
68
+ access_token_enc = JSON.stringify(access_enc);
69
+ }
70
+ const adapter = get_hazo_connect_instance();
71
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
72
+ const existing_rows = (await service.findBy({
73
+ user_id: params.user_id,
74
+ provider: "google",
75
+ }));
76
+ const now = new Date().toISOString();
77
+ if (existing_rows.length > 0) {
78
+ const existing = existing_rows[0];
79
+ await service.updateById(existing.id, {
80
+ refresh_token_enc,
81
+ access_token_enc,
82
+ scopes: params.scopes,
83
+ expires_at: (_a = params.expires_at) !== null && _a !== void 0 ? _a : null,
84
+ changed_at: now,
85
+ });
86
+ logger.info("google_token_service_token_updated", {
87
+ filename: "google_token_service.ts",
88
+ user_id: params.user_id,
89
+ });
90
+ }
91
+ else {
92
+ await service.insert({
93
+ id: randomUUID(),
94
+ user_id: params.user_id,
95
+ provider: "google",
96
+ refresh_token_enc,
97
+ access_token_enc,
98
+ scopes: params.scopes,
99
+ expires_at: (_b = params.expires_at) !== null && _b !== void 0 ? _b : null,
100
+ created_at: now,
101
+ changed_at: now,
102
+ });
103
+ logger.info("google_token_service_token_stored", {
104
+ filename: "google_token_service.ts",
105
+ user_id: params.user_id,
106
+ });
107
+ }
108
+ }
109
+ // section: get
110
+ /**
111
+ * Returns a valid access token for the user, refreshing via Google if needed.
112
+ * Returns a typed error result instead of throwing for expected failure modes.
113
+ */
114
+ export async function getGoogleToken(userId, opts) {
115
+ var _a, _b;
116
+ const logger = create_app_logger();
117
+ // Check in-memory cache first
118
+ const cached = access_token_cache.get(userId);
119
+ if (cached) {
120
+ const still_valid = new Date(cached.expires_at).getTime() - 60000 > Date.now();
121
+ if (still_valid) {
122
+ logger.info("google_token_service_cache_hit", {
123
+ filename: "google_token_service.ts",
124
+ user_id: userId,
125
+ });
126
+ // We need scopes from DB even on cache hit when scope check is requested
127
+ if (!((_a = opts === null || opts === void 0 ? void 0 : opts.scopes) === null || _a === void 0 ? void 0 : _a.length)) {
128
+ // No scope check needed — return cached token (scopes unknown from cache alone)
129
+ // Fall through to DB to get scopes for the result shape
130
+ }
131
+ }
132
+ }
133
+ const adapter = get_hazo_connect_instance();
134
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
135
+ const rows = (await service.findBy({ user_id: userId, provider: "google" }));
136
+ if (rows.length === 0) {
137
+ return { ok: false, error: "not_connected" };
138
+ }
139
+ const row = rows[0];
140
+ // Scope check
141
+ if ((_b = opts === null || opts === void 0 ? void 0 : opts.scopes) === null || _b === void 0 ? void 0 : _b.length) {
142
+ const stored_scopes = row.scopes.split(" ").filter(Boolean);
143
+ const missing = opts.scopes.some((s) => !stored_scopes.includes(s));
144
+ if (missing) {
145
+ return { ok: false, error: "reconsent_required" };
146
+ }
147
+ }
148
+ // Return cached token now that we have the row (scope check passed)
149
+ if (cached) {
150
+ const still_valid = new Date(cached.expires_at).getTime() - 60000 > Date.now();
151
+ if (still_valid) {
152
+ return { ok: true, access_token: cached.token, scopes: row.scopes };
153
+ }
154
+ }
155
+ // Need to refresh — load crypto
156
+ let cryptoModule;
157
+ try {
158
+ cryptoModule = await load_crypto_module();
159
+ }
160
+ catch (_c) {
161
+ return { ok: false, error: "unconfigured" };
162
+ }
163
+ const { decryptField, aadFor, LookupKeyProvider } = cryptoModule;
164
+ const keys = makeKeyProvider(LookupKeyProvider);
165
+ let decrypted_refresh;
166
+ try {
167
+ decrypted_refresh = await decryptField(JSON.parse(row.refresh_token_enc), {
168
+ keys,
169
+ aad: aadFor("hazo_google_oauth_tokens", userId, "refresh_token"),
170
+ });
171
+ }
172
+ catch (err) {
173
+ const msg = err instanceof Error ? err.message : String(err);
174
+ logger.error("google_token_service_decrypt_failed", {
175
+ filename: "google_token_service.ts",
176
+ user_id: userId,
177
+ error: msg,
178
+ });
179
+ return { ok: false, error: "refresh_failed" };
180
+ }
181
+ // Call Google token refresh endpoint
182
+ let resp;
183
+ try {
184
+ resp = await fetch("https://oauth2.googleapis.com/token", {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
187
+ body: new URLSearchParams({
188
+ grant_type: "refresh_token",
189
+ refresh_token: decrypted_refresh,
190
+ client_id: process.env.HAZO_AUTH_GOOGLE_CLIENT_ID,
191
+ client_secret: process.env.HAZO_AUTH_GOOGLE_CLIENT_SECRET,
192
+ }),
193
+ });
194
+ }
195
+ catch (err) {
196
+ const msg = err instanceof Error ? err.message : String(err);
197
+ logger.error("google_token_service_refresh_fetch_failed", {
198
+ filename: "google_token_service.ts",
199
+ user_id: userId,
200
+ error: msg,
201
+ });
202
+ return { ok: false, error: "refresh_failed" };
203
+ }
204
+ if (!resp.ok) {
205
+ logger.warn("google_token_service_refresh_rejected", {
206
+ filename: "google_token_service.ts",
207
+ user_id: userId,
208
+ status: resp.status,
209
+ });
210
+ return { ok: false, error: "refresh_failed" };
211
+ }
212
+ const data = await resp.json();
213
+ // Persist new access token (encrypted)
214
+ const new_expires_at = new Date(Date.now() + data.expires_in * 1000).toISOString();
215
+ try {
216
+ const { encryptField } = cryptoModule;
217
+ const access_enc = await encryptField(data.access_token, {
218
+ keys,
219
+ aad: aadFor("hazo_google_oauth_tokens", userId, "access_token"),
220
+ });
221
+ const access_token_enc = JSON.stringify(access_enc);
222
+ await service.updateById(row.id, {
223
+ access_token_enc,
224
+ expires_at: new_expires_at,
225
+ changed_at: new Date().toISOString(),
226
+ });
227
+ }
228
+ catch (err) {
229
+ const msg = err instanceof Error ? err.message : String(err);
230
+ logger.warn("google_token_service_persist_access_token_failed", {
231
+ filename: "google_token_service.ts",
232
+ user_id: userId,
233
+ error: msg,
234
+ note: "Access token refreshed successfully but could not be persisted",
235
+ });
236
+ }
237
+ // Update in-memory cache
238
+ access_token_cache.set(userId, { token: data.access_token, expires_at: new_expires_at });
239
+ logger.info("google_token_service_token_refreshed", {
240
+ filename: "google_token_service.ts",
241
+ user_id: userId,
242
+ });
243
+ return { ok: true, access_token: data.access_token, scopes: row.scopes };
244
+ }
245
+ // section: revoke
246
+ /**
247
+ * Revokes the user's Google OAuth tokens — best-effort remote revocation, then deletes DB row.
248
+ */
249
+ export async function revoke_google_oauth_token(userId) {
250
+ const logger = create_app_logger();
251
+ const adapter = get_hazo_connect_instance();
252
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
253
+ const rows = (await service.findBy({ user_id: userId, provider: "google" }));
254
+ if (rows.length === 0) {
255
+ return { ok: false, error: "not_connected" };
256
+ }
257
+ const row = rows[0];
258
+ // Attempt remote revocation — best effort
259
+ try {
260
+ const { decryptField, aadFor, LookupKeyProvider } = await load_crypto_module();
261
+ const keys = makeKeyProvider(LookupKeyProvider);
262
+ const decrypted_refresh = await decryptField(JSON.parse(row.refresh_token_enc), {
263
+ keys,
264
+ aad: aadFor("hazo_google_oauth_tokens", userId, "refresh_token"),
265
+ });
266
+ const revoke_resp = await fetch(`https://oauth2.googleapis.com/revoke?token=${encodeURIComponent(decrypted_refresh)}`, { method: "POST" });
267
+ if (!revoke_resp.ok) {
268
+ logger.warn("google_token_service_revoke_remote_failed", {
269
+ filename: "google_token_service.ts",
270
+ user_id: userId,
271
+ status: revoke_resp.status,
272
+ note: "Google revocation returned non-OK status; proceeding to delete local record",
273
+ });
274
+ }
275
+ else {
276
+ logger.info("google_token_service_revoke_remote_ok", {
277
+ filename: "google_token_service.ts",
278
+ user_id: userId,
279
+ });
280
+ }
281
+ }
282
+ catch (err) {
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ logger.warn("google_token_service_revoke_remote_error", {
285
+ filename: "google_token_service.ts",
286
+ user_id: userId,
287
+ error: msg,
288
+ note: "Remote revocation failed; proceeding to delete local record",
289
+ });
290
+ }
291
+ // Always delete the local row
292
+ await service.deleteById(row.id);
293
+ access_token_cache.delete(userId);
294
+ logger.info("google_token_service_revoked", {
295
+ filename: "google_token_service.ts",
296
+ user_id: userId,
297
+ });
298
+ return { ok: true };
299
+ }
300
+ // section: status
301
+ /**
302
+ * Returns connection status and scope information for a user's Google OAuth connection.
303
+ * Does not decrypt or refresh tokens.
304
+ */
305
+ export async function get_google_token_status(userId) {
306
+ var _a;
307
+ const adapter = get_hazo_connect_instance();
308
+ const service = createCrudService(adapter, "hazo_google_oauth_tokens");
309
+ const rows = (await service.findBy({ user_id: userId, provider: "google" }));
310
+ if (rows.length === 0) {
311
+ return { connected: false, scopes: "", expires_at: null };
312
+ }
313
+ const row = rows[0];
314
+ return {
315
+ connected: true,
316
+ scopes: row.scopes,
317
+ expires_at: (_a = row.expires_at) !== null && _a !== void 0 ? _a : null,
318
+ };
319
+ }
@@ -18,4 +18,5 @@ export * from "./scope_service.js";
18
18
  export * from "./user_scope_service.js";
19
19
  export * from "./oauth_service.js";
20
20
  export * from "./branding_service.js";
21
+ export * from "./google_token_service.js";
21
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/services/index.ts"],"names":[],"mappings":"AAEA,cAAc,iBAAiB,CAAC;AAChC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,iBAAiB,CAAC;AAChC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,kCAAkC,CAAC;AACjD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iCAAiC,CAAC;AAChD,cAAc,wBAAwB,CAAC;AACvC,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lib/services/index.ts"],"names":[],"mappings":"AAEA,cAAc,iBAAiB,CAAC;AAChC,cAAc,8BAA8B,CAAC;AAC7C,cAAc,iBAAiB,CAAC;AAChC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,0BAA0B,CAAC;AACzC,cAAc,kCAAkC,CAAC;AACjD,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iCAAiC,CAAC;AAChD,cAAc,wBAAwB,CAAC;AACvC,cAAc,iBAAiB,CAAC;AAChC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,6BAA6B,CAAC;AAC5C,cAAc,iBAAiB,CAAC;AAChC,cAAc,sBAAsB,CAAC;AACrC,cAAc,iBAAiB,CAAC;AAChC,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC"}
@@ -20,3 +20,4 @@ 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
+ export * from "./google_token_service.js";
@@ -0,0 +1,13 @@
1
+ import type { NextRequest } from "next/server";
2
+ /**
3
+ * GET /api/hazo_auth/google/token
4
+ * Returns the current Google OAuth token status for the authenticated user.
5
+ */
6
+ export declare function GET(request: NextRequest): Promise<Response>;
7
+ /**
8
+ * DELETE /api/hazo_auth/google/token
9
+ * Revokes the stored Google OAuth token for the authenticated user.
10
+ * Does NOT sign the user out.
11
+ */
12
+ export declare function DELETE(request: NextRequest): Promise<Response>;
13
+ //# sourceMappingURL=google_token.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google_token.d.ts","sourceRoot":"","sources":["../../../src/server/routes/google_token.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAS/C;;;GAGG;AACH,wBAAsB,GAAG,CAAC,OAAO,EAAE,WAAW,qBAuB7C;AAGD;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,OAAO,EAAE,WAAW,qBAuBhD"}