hazo_auth 9.1.1 → 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.
- package/README.md +124 -6
- package/SETUP_CHECKLIST.md +24 -16
- package/cli-src/cli/init_users.ts +40 -48
- package/cli-src/lib/auth/auth_types.ts +0 -2
- package/cli-src/lib/auth/hazo_get_auth.server.ts +31 -25
- package/cli-src/lib/auth/hazo_get_tenant_auth.server.ts +9 -13
- package/cli-src/lib/auth/nextauth_config.ts +41 -0
- package/cli-src/lib/auth/request_google_scopes.ts +23 -0
- package/cli-src/lib/constants.ts +2 -0
- package/cli-src/lib/profile_pic_menu_config.server.ts +4 -3
- package/cli-src/lib/schema/sqlite_schema.ts +16 -4
- package/cli-src/lib/scope_hierarchy_config.server.ts +1 -9
- package/cli-src/lib/services/google_token_service.ts +408 -0
- package/cli-src/lib/services/index.ts +1 -1
- package/cli-src/lib/services/invitation_service.ts +1 -1
- package/cli-src/lib/services/scope_service.ts +2 -76
- package/cli-src/lib/services/user_scope_service.ts +7 -61
- package/dist/cli/init_users.d.ts.map +1 -1
- package/dist/cli/init_users.js +42 -42
- package/dist/client.d.ts +2 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3 -1
- package/dist/components/layouts/google_token_test/index.d.ts +6 -0
- package/dist/components/layouts/google_token_test/index.d.ts.map +1 -0
- package/dist/components/layouts/google_token_test/index.js +74 -0
- package/dist/components/layouts/shared/components/profile_pic_menu.d.ts.map +1 -1
- package/dist/components/layouts/shared/components/profile_pic_menu.js +7 -1
- package/dist/components/layouts/shared/components/sidebar_layout_wrapper.d.ts.map +1 -1
- package/dist/components/layouts/shared/components/sidebar_layout_wrapper.js +2 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/lib/auth/auth_types.d.ts +0 -2
- package/dist/lib/auth/auth_types.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_auth.server.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_auth.server.js +27 -19
- package/dist/lib/auth/hazo_get_tenant_auth.server.d.ts.map +1 -1
- package/dist/lib/auth/hazo_get_tenant_auth.server.js +10 -10
- package/dist/lib/auth/nextauth_config.d.ts +2 -0
- package/dist/lib/auth/nextauth_config.d.ts.map +1 -1
- package/dist/lib/auth/nextauth_config.js +39 -1
- package/dist/lib/auth/request_google_scopes.d.ts +10 -0
- package/dist/lib/auth/request_google_scopes.d.ts.map +1 -0
- package/dist/lib/auth/request_google_scopes.js +13 -0
- package/dist/lib/constants.d.ts +1 -0
- package/dist/lib/constants.d.ts.map +1 -1
- package/dist/lib/constants.js +1 -0
- package/dist/lib/profile_pic_menu_config.server.d.ts +2 -1
- package/dist/lib/profile_pic_menu_config.server.d.ts.map +1 -1
- package/dist/lib/profile_pic_menu_config.server.js +1 -1
- package/dist/lib/schema/sqlite_schema.d.ts +1 -1
- package/dist/lib/schema/sqlite_schema.d.ts.map +1 -1
- package/dist/lib/schema/sqlite_schema.js +16 -4
- package/dist/lib/scope_hierarchy_config.server.d.ts +0 -2
- package/dist/lib/scope_hierarchy_config.server.d.ts.map +1 -1
- package/dist/lib/scope_hierarchy_config.server.js +1 -3
- package/dist/lib/services/google_token_service.d.ts +48 -0
- package/dist/lib/services/google_token_service.d.ts.map +1 -0
- package/dist/lib/services/google_token_service.js +319 -0
- package/dist/lib/services/index.d.ts +1 -0
- package/dist/lib/services/index.d.ts.map +1 -1
- package/dist/lib/services/index.js +1 -0
- package/dist/lib/services/invitation_service.d.ts +1 -1
- package/dist/lib/services/invitation_service.js +1 -1
- package/dist/lib/services/scope_service.d.ts +1 -14
- package/dist/lib/services/scope_service.d.ts.map +1 -1
- package/dist/lib/services/scope_service.js +2 -67
- package/dist/lib/services/user_scope_service.d.ts +5 -12
- package/dist/lib/services/user_scope_service.d.ts.map +1 -1
- package/dist/lib/services/user_scope_service.js +8 -45
- package/dist/server/routes/google_token.d.ts +13 -0
- package/dist/server/routes/google_token.d.ts.map +1 -0
- package/dist/server/routes/google_token.js +66 -0
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +2 -0
- package/dist/server/routes/invitations.d.ts +1 -1
- package/dist/server/routes/invitations.d.ts.map +1 -1
- package/dist/server/routes/invitations.js +12 -11
- package/dist/server/routes/user_management_users.d.ts +1 -1
- package/package.json +17 -13
|
@@ -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
|
+
}
|
package/cli-src/lib/constants.ts
CHANGED
|
@@ -8,13 +8,14 @@ import { get_config_value, get_config_boolean, get_config_array } from "./config
|
|
|
8
8
|
// section: types
|
|
9
9
|
// Note: These types are also used in client components, but TypeScript types are erased at runtime
|
|
10
10
|
// so importing from a server file is safe for type-only imports
|
|
11
|
-
export type MenuItemType = "info" | "link" | "separator";
|
|
11
|
+
export type MenuItemType = "info" | "link" | "separator" | "action";
|
|
12
12
|
|
|
13
13
|
export type ProfilePicMenuMenuItem = {
|
|
14
14
|
type: MenuItemType;
|
|
15
|
-
label?: string; // For info and
|
|
15
|
+
label?: string; // For info, link, and action types
|
|
16
16
|
value?: string; // For info type (e.g., user name, email)
|
|
17
17
|
href?: string; // For link type
|
|
18
|
+
onSelect?: () => void; // For action type (client-side callback, not serialisable via INI config)
|
|
18
19
|
order: number; // Ordering within type group
|
|
19
20
|
id: string; // Unique identifier for the item
|
|
20
21
|
};
|
|
@@ -50,7 +51,7 @@ function parse_custom_menu_items(items_string: string[]): ProfilePicMenuMenuItem
|
|
|
50
51
|
|
|
51
52
|
const type = parts[0] as MenuItemType;
|
|
52
53
|
if (type !== "info" && type !== "link" && type !== "separator") {
|
|
53
|
-
return; // Invalid type,
|
|
54
|
+
return; // Invalid type or action (action items carry callbacks, not expressible in INI)
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
if (type === "separator") {
|
|
@@ -97,10 +97,6 @@ CREATE INDEX IF NOT EXISTS idx_hazo_scopes_parent ON hazo_scopes(parent_id);
|
|
|
97
97
|
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_level ON hazo_scopes(level);
|
|
98
98
|
CREATE INDEX IF NOT EXISTS idx_hazo_scopes_slug ON hazo_scopes(slug);
|
|
99
99
|
|
|
100
|
-
-- Super admin scope
|
|
101
|
-
INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
|
|
102
|
-
VALUES ('00000000-0000-0000-0000-000000000000', NULL, 'Super Admin', 'system', datetime('now'), datetime('now'));
|
|
103
|
-
|
|
104
100
|
-- Default system scope (for non-multi-tenancy mode)
|
|
105
101
|
INSERT OR IGNORE INTO hazo_scopes (id, parent_id, name, level, created_at, changed_at)
|
|
106
102
|
VALUES ('00000000-0000-0000-0000-000000000001', NULL, 'System', 'default', datetime('now'), datetime('now'));
|
|
@@ -165,4 +161,20 @@ CREATE INDEX IF NOT EXISTS idx_hazo_user_relationships_child ON hazo_user_relati
|
|
|
165
161
|
-- Firm admin role (for firm creators)
|
|
166
162
|
INSERT OR IGNORE INTO hazo_roles (id, role_name, created_at, changed_at)
|
|
167
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);
|
|
168
180
|
`;
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
get_config_number,
|
|
9
9
|
get_config_boolean,
|
|
10
10
|
} from "./config/config_loader.server.js";
|
|
11
|
-
import {
|
|
11
|
+
import { DEFAULT_SYSTEM_SCOPE_ID } from "./services/scope_service.js";
|
|
12
12
|
|
|
13
13
|
// section: types
|
|
14
14
|
|
|
@@ -23,8 +23,6 @@ export type ScopeHierarchyConfig = {
|
|
|
23
23
|
scope_cache_ttl_minutes: number;
|
|
24
24
|
/** Maximum entries in scope cache (default: 5000) */
|
|
25
25
|
scope_cache_max_entries: number;
|
|
26
|
-
/** Super admin scope ID */
|
|
27
|
-
super_admin_scope_id: string;
|
|
28
26
|
/** Default system scope ID (for non-multi-tenancy mode) */
|
|
29
27
|
default_system_scope_id: string;
|
|
30
28
|
};
|
|
@@ -57,11 +55,6 @@ export function get_scope_hierarchy_config(): ScopeHierarchyConfig {
|
|
|
57
55
|
);
|
|
58
56
|
|
|
59
57
|
// Scope IDs (with defaults)
|
|
60
|
-
const super_admin_scope_id = get_config_value(
|
|
61
|
-
SECTION_NAME,
|
|
62
|
-
"super_admin_scope_id",
|
|
63
|
-
SUPER_ADMIN_SCOPE_ID,
|
|
64
|
-
);
|
|
65
58
|
const default_system_scope_id = get_config_value(
|
|
66
59
|
SECTION_NAME,
|
|
67
60
|
"default_system_scope_id",
|
|
@@ -72,7 +65,6 @@ export function get_scope_hierarchy_config(): ScopeHierarchyConfig {
|
|
|
72
65
|
enable_hrbac,
|
|
73
66
|
scope_cache_ttl_minutes,
|
|
74
67
|
scope_cache_max_entries,
|
|
75
|
-
super_admin_scope_id,
|
|
76
68
|
default_system_scope_id,
|
|
77
69
|
};
|
|
78
70
|
}
|
|
@@ -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
|
+
}
|
|
@@ -7,12 +7,6 @@ import { sanitize_error_for_user } from "../utils/error_sanitizer.js";
|
|
|
7
7
|
|
|
8
8
|
// section: constants
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
* Super admin scope ID - special UUID for system-level administrators
|
|
12
|
-
* Users assigned to this scope have global access
|
|
13
|
-
*/
|
|
14
|
-
export const SUPER_ADMIN_SCOPE_ID = "00000000-0000-0000-0000-000000000000";
|
|
15
|
-
|
|
16
10
|
/**
|
|
17
11
|
* Default system scope ID - for non-multi-tenancy mode
|
|
18
12
|
* All users are assigned to this scope when multi-tenancy is disabled
|
|
@@ -119,13 +113,6 @@ export function has_branding(scope: ScopeRecord): boolean {
|
|
|
119
113
|
return !!(scope.logo_url || scope.primary_color || scope.secondary_color || scope.tagline);
|
|
120
114
|
}
|
|
121
115
|
|
|
122
|
-
/**
|
|
123
|
-
* Checks if the given scope_id is the super admin scope
|
|
124
|
-
*/
|
|
125
|
-
export function is_super_admin_scope(scope_id: string): boolean {
|
|
126
|
-
return scope_id === SUPER_ADMIN_SCOPE_ID;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
116
|
/**
|
|
130
117
|
* Checks if the given scope_id is the default system scope
|
|
131
118
|
*/
|
|
@@ -134,10 +121,10 @@ export function is_default_system_scope(scope_id: string): boolean {
|
|
|
134
121
|
}
|
|
135
122
|
|
|
136
123
|
/**
|
|
137
|
-
* Checks if the given scope_id is a system scope (
|
|
124
|
+
* Checks if the given scope_id is a system scope (default system)
|
|
138
125
|
*/
|
|
139
126
|
export function is_system_scope(scope_id: string): boolean {
|
|
140
|
-
return
|
|
127
|
+
return is_default_system_scope(scope_id);
|
|
141
128
|
}
|
|
142
129
|
|
|
143
130
|
// section: crud operations
|
|
@@ -781,67 +768,6 @@ export async function get_scope_tree(
|
|
|
781
768
|
}
|
|
782
769
|
}
|
|
783
770
|
|
|
784
|
-
/**
|
|
785
|
-
* Ensures the super admin scope exists
|
|
786
|
-
*/
|
|
787
|
-
export async function ensure_super_admin_scope(
|
|
788
|
-
adapter: HazoConnectAdapter,
|
|
789
|
-
): Promise<ScopeServiceResult> {
|
|
790
|
-
try {
|
|
791
|
-
// Check if already exists
|
|
792
|
-
const existing = await get_scope_by_id(adapter, SUPER_ADMIN_SCOPE_ID);
|
|
793
|
-
if (existing.success && existing.scope) {
|
|
794
|
-
return existing;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// Create it
|
|
798
|
-
const scope_service = createCrudService(adapter, "hazo_scopes");
|
|
799
|
-
const now = new Date().toISOString();
|
|
800
|
-
|
|
801
|
-
const inserted = await scope_service.insert({
|
|
802
|
-
id: SUPER_ADMIN_SCOPE_ID,
|
|
803
|
-
name: "Super Admin",
|
|
804
|
-
level: "system",
|
|
805
|
-
parent_id: null,
|
|
806
|
-
logo_url: null,
|
|
807
|
-
primary_color: null,
|
|
808
|
-
secondary_color: null,
|
|
809
|
-
tagline: null,
|
|
810
|
-
created_at: now,
|
|
811
|
-
changed_at: now,
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
if (!Array.isArray(inserted) || inserted.length === 0) {
|
|
815
|
-
return {
|
|
816
|
-
success: false,
|
|
817
|
-
error: "Failed to create super admin scope",
|
|
818
|
-
};
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
return {
|
|
822
|
-
success: true,
|
|
823
|
-
scope: normalize_scope_record(inserted[0] as Record<string, unknown>),
|
|
824
|
-
};
|
|
825
|
-
} catch (error) {
|
|
826
|
-
const logger = create_app_logger();
|
|
827
|
-
const error_message = sanitize_error_for_user(error, {
|
|
828
|
-
logToConsole: true,
|
|
829
|
-
logToLogger: true,
|
|
830
|
-
logger,
|
|
831
|
-
context: {
|
|
832
|
-
filename: "scope_service.ts",
|
|
833
|
-
line_number: 0,
|
|
834
|
-
operation: "ensure_super_admin_scope",
|
|
835
|
-
},
|
|
836
|
-
});
|
|
837
|
-
|
|
838
|
-
return {
|
|
839
|
-
success: false,
|
|
840
|
-
error: error_message,
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
845
771
|
/**
|
|
846
772
|
* Ensures the default system scope exists
|
|
847
773
|
*/
|