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