@aglyn/plugins-outreach 1.0.0-beta.143
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/LICENSE +201 -0
- package/README.md +93 -0
- package/package.json +52 -0
- package/src/index.d.ts +22 -0
- package/src/index.js +29 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/compliance-section.d.ts +20 -0
- package/src/lib/components/compliance-section.js +291 -0
- package/src/lib/components/compliance-section.js.map +1 -0
- package/src/lib/components/enroll-dialog.d.ts +36 -0
- package/src/lib/components/enroll-dialog.js +656 -0
- package/src/lib/components/enroll-dialog.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +28 -0
- package/src/lib/components/enrollments-table.js +375 -0
- package/src/lib/components/enrollments-table.js.map +1 -0
- package/src/lib/components/mailbox-card.d.ts +46 -0
- package/src/lib/components/mailbox-card.js +517 -0
- package/src/lib/components/mailbox-card.js.map +1 -0
- package/src/lib/components/mailboxes-section.d.ts +24 -0
- package/src/lib/components/mailboxes-section.js +258 -0
- package/src/lib/components/mailboxes-section.js.map +1 -0
- package/src/lib/components/outreach-console-page.d.ts +17 -0
- package/src/lib/components/outreach-console-page.js +81 -0
- package/src/lib/components/outreach-console-page.js.map +1 -0
- package/src/lib/components/outreach-console-sections.d.ts +43 -0
- package/src/lib/components/outreach-console-sections.js +55 -0
- package/src/lib/components/outreach-console-sections.js.map +1 -0
- package/src/lib/components/outreach-ui.d.ts +51 -0
- package/src/lib/components/outreach-ui.js +171 -0
- package/src/lib/components/outreach-ui.js.map +1 -0
- package/src/lib/components/send-window-fields.d.ts +25 -0
- package/src/lib/components/send-window-fields.js +178 -0
- package/src/lib/components/send-window-fields.js.map +1 -0
- package/src/lib/components/sequence-detail.d.ts +54 -0
- package/src/lib/components/sequence-detail.js +435 -0
- package/src/lib/components/sequence-detail.js.map +1 -0
- package/src/lib/components/sequence-editor.d.ts +37 -0
- package/src/lib/components/sequence-editor.js +485 -0
- package/src/lib/components/sequence-editor.js.map +1 -0
- package/src/lib/components/sequence-mailbox-picker.d.ts +44 -0
- package/src/lib/components/sequence-mailbox-picker.js +137 -0
- package/src/lib/components/sequence-mailbox-picker.js.map +1 -0
- package/src/lib/components/sequence-preview.d.ts +27 -0
- package/src/lib/components/sequence-preview.js +146 -0
- package/src/lib/components/sequence-preview.js.map +1 -0
- package/src/lib/components/sequence-step-card.d.ts +31 -0
- package/src/lib/components/sequence-step-card.js +326 -0
- package/src/lib/components/sequence-step-card.js.map +1 -0
- package/src/lib/components/sequences-section.d.ts +40 -0
- package/src/lib/components/sequences-section.js +310 -0
- package/src/lib/components/sequences-section.js.map +1 -0
- package/src/lib/components/use-outreach-api.d.ts +38 -0
- package/src/lib/components/use-outreach-api.js +139 -0
- package/src/lib/components/use-outreach-api.js.map +1 -0
- package/src/lib/components/use-outreach-crm.d.ts +50 -0
- package/src/lib/components/use-outreach-crm.js +200 -0
- package/src/lib/components/use-outreach-crm.js.map +1 -0
- package/src/lib/components/use-outreach-data.d.ts +55 -0
- package/src/lib/components/use-outreach-data.js +199 -0
- package/src/lib/components/use-outreach-data.js.map +1 -0
- package/src/lib/components/use-outreach-mailbox-api.d.ts +30 -0
- package/src/lib/components/use-outreach-mailbox-api.js +101 -0
- package/src/lib/components/use-outreach-mailbox-api.js.map +1 -0
- package/src/lib/components/use-outreach-mailboxes.d.ts +22 -0
- package/src/lib/components/use-outreach-mailboxes.js +68 -0
- package/src/lib/components/use-outreach-mailboxes.js.map +1 -0
- package/src/lib/components/use-outreach-settings.d.ts +17 -0
- package/src/lib/components/use-outreach-settings.js +64 -0
- package/src/lib/components/use-outreach-settings.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +68 -0
- package/src/lib/constants/api-routes.js +57 -0
- package/src/lib/constants/api-routes.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +46 -0
- package/src/lib/constants/bundle-common.js +55 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/constants/runtime-jobs.d.ts +24 -0
- package/src/lib/constants/runtime-jobs.js +24 -0
- package/src/lib/constants/runtime-jobs.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +38 -0
- package/src/lib/declarations.console-server.js +99 -0
- package/src/lib/declarations.console-server.js.map +1 -0
- package/src/lib/engine/compose.d.ts +86 -0
- package/src/lib/engine/compose.js +212 -0
- package/src/lib/engine/compose.js.map +1 -0
- package/src/lib/engine/delivery-status.d.ts +53 -0
- package/src/lib/engine/delivery-status.js +184 -0
- package/src/lib/engine/delivery-status.js.map +1 -0
- package/src/lib/engine/do-not-contact.d.ts +18 -0
- package/src/lib/engine/do-not-contact.js +34 -0
- package/src/lib/engine/do-not-contact.js.map +1 -0
- package/src/lib/engine/enrollment-state.d.ts +156 -0
- package/src/lib/engine/enrollment-state.js +303 -0
- package/src/lib/engine/enrollment-state.js.map +1 -0
- package/src/lib/engine/gates.d.ts +125 -0
- package/src/lib/engine/gates.js +232 -0
- package/src/lib/engine/gates.js.map +1 -0
- package/src/lib/engine/index.d.ts +28 -0
- package/src/lib/engine/index.js +33 -0
- package/src/lib/engine/index.js.map +1 -0
- package/src/lib/engine/mailbox-health.d.ts +49 -0
- package/src/lib/engine/mailbox-health.js +83 -0
- package/src/lib/engine/mailbox-health.js.map +1 -0
- package/src/lib/engine/opt-out-intent.d.ts +41 -0
- package/src/lib/engine/opt-out-intent.js +220 -0
- package/src/lib/engine/opt-out-intent.js.map +1 -0
- package/src/lib/engine/recipient-country.d.ts +41 -0
- package/src/lib/engine/recipient-country.js +171 -0
- package/src/lib/engine/recipient-country.js.map +1 -0
- package/src/lib/engine/schedule.d.ts +70 -0
- package/src/lib/engine/schedule.js +115 -0
- package/src/lib/engine/schedule.js.map +1 -0
- package/src/lib/engine/sending-capacity.d.ts +96 -0
- package/src/lib/engine/sending-capacity.js +172 -0
- package/src/lib/engine/sending-capacity.js.map +1 -0
- package/src/lib/engine/sequence-validation.d.ts +90 -0
- package/src/lib/engine/sequence-validation.js +291 -0
- package/src/lib/engine/sequence-validation.js.map +1 -0
- package/src/lib/engine/thread-classification.d.ts +78 -0
- package/src/lib/engine/thread-classification.js +224 -0
- package/src/lib/engine/thread-classification.js.map +1 -0
- package/src/lib/engine/thread-message.d.ts +80 -0
- package/src/lib/engine/thread-message.js +143 -0
- package/src/lib/engine/thread-message.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +81 -0
- package/src/lib/enrollment/enroll-people.js +250 -0
- package/src/lib/enrollment/enroll-people.js.map +1 -0
- package/src/lib/enrollment/gate-lookups.d.ts +32 -0
- package/src/lib/enrollment/gate-lookups.js +154 -0
- package/src/lib/enrollment/gate-lookups.js.map +1 -0
- package/src/lib/mailboxes/mailbox-api.d.ts +126 -0
- package/src/lib/mailboxes/mailbox-api.js +66 -0
- package/src/lib/mailboxes/mailbox-api.js.map +1 -0
- package/src/lib/mailboxes/mailbox-credentials.d.ts +95 -0
- package/src/lib/mailboxes/mailbox-credentials.js +102 -0
- package/src/lib/mailboxes/mailbox-credentials.js.map +1 -0
- package/src/lib/mailboxes/mailbox-erasure.d.ts +69 -0
- package/src/lib/mailboxes/mailbox-erasure.js +148 -0
- package/src/lib/mailboxes/mailbox-erasure.js.map +1 -0
- package/src/lib/mailboxes/mailbox-gate.d.ts +75 -0
- package/src/lib/mailboxes/mailbox-gate.js +98 -0
- package/src/lib/mailboxes/mailbox-gate.js.map +1 -0
- package/src/lib/mailboxes/mailbox-revoke.d.ts +59 -0
- package/src/lib/mailboxes/mailbox-revoke.js +41 -0
- package/src/lib/mailboxes/mailbox-revoke.js.map +1 -0
- package/src/lib/mailboxes/mailbox-routes.d.ts +119 -0
- package/src/lib/mailboxes/mailbox-routes.js +605 -0
- package/src/lib/mailboxes/mailbox-routes.js.map +1 -0
- package/src/lib/mailboxes/mailbox-settings.d.ts +110 -0
- package/src/lib/mailboxes/mailbox-settings.js +212 -0
- package/src/lib/mailboxes/mailbox-settings.js.map +1 -0
- package/src/lib/mailboxes/mailbox-transport.d.ts +64 -0
- package/src/lib/mailboxes/mailbox-transport.js +94 -0
- package/src/lib/mailboxes/mailbox-transport.js.map +1 -0
- package/src/lib/mailboxes/oauth-redirect.d.ts +41 -0
- package/src/lib/mailboxes/oauth-redirect.js +63 -0
- package/src/lib/mailboxes/oauth-redirect.js.map +1 -0
- package/src/lib/mailboxes/oauth-state.d.ts +152 -0
- package/src/lib/mailboxes/oauth-state.js +212 -0
- package/src/lib/mailboxes/oauth-state.js.map +1 -0
- package/src/lib/mailboxes/outreach-config.d.ts +64 -0
- package/src/lib/mailboxes/outreach-config.js +77 -0
- package/src/lib/mailboxes/outreach-config.js.map +1 -0
- package/src/lib/mailboxes/register-mailbox-routes.d.ts +47 -0
- package/src/lib/mailboxes/register-mailbox-routes.js +135 -0
- package/src/lib/mailboxes/register-mailbox-routes.js.map +1 -0
- package/src/lib/model/compliance-settings.d.ts +78 -0
- package/src/lib/model/compliance-settings.js +155 -0
- package/src/lib/model/compliance-settings.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +210 -0
- package/src/lib/model/outreach-api.js +28 -0
- package/src/lib/model/outreach-api.js.map +1 -0
- package/src/lib/model/outreach.types.d.ts +563 -0
- package/src/lib/model/outreach.types.js +225 -0
- package/src/lib/model/outreach.types.js.map +1 -0
- package/src/lib/model/sequence-draft.d.ts +84 -0
- package/src/lib/model/sequence-draft.js +239 -0
- package/src/lib/model/sequence-draft.js.map +1 -0
- package/src/lib/model/step-preview.d.ts +45 -0
- package/src/lib/model/step-preview.js +98 -0
- package/src/lib/model/step-preview.js.map +1 -0
- package/src/lib/model/stored-records.d.ts +23 -0
- package/src/lib/model/stored-records.js +84 -0
- package/src/lib/model/stored-records.js.map +1 -0
- package/src/lib/plugin.d.ts +46 -0
- package/src/lib/plugin.js +86 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/routes/enroll-routes.d.ts +64 -0
- package/src/lib/routes/enroll-routes.js +350 -0
- package/src/lib/routes/enroll-routes.js.map +1 -0
- package/src/lib/routes/enrollment-routes.d.ts +19 -0
- package/src/lib/routes/enrollment-routes.js +171 -0
- package/src/lib/routes/enrollment-routes.js.map +1 -0
- package/src/lib/routes/platform-deps.d.ts +51 -0
- package/src/lib/routes/platform-deps.js +66 -0
- package/src/lib/routes/platform-deps.js.map +1 -0
- package/src/lib/routes/preview-routes.d.ts +33 -0
- package/src/lib/routes/preview-routes.js +117 -0
- package/src/lib/routes/preview-routes.js.map +1 -0
- package/src/lib/routes/register-routes.d.ts +22 -0
- package/src/lib/routes/register-routes.js +92 -0
- package/src/lib/routes/register-routes.js.map +1 -0
- package/src/lib/routes/route-deps.d.ts +49 -0
- package/src/lib/routes/route-deps.js +27 -0
- package/src/lib/routes/route-deps.js.map +1 -0
- package/src/lib/routes/route-gate.d.ts +94 -0
- package/src/lib/routes/route-gate.js +87 -0
- package/src/lib/routes/route-gate.js.map +1 -0
- package/src/lib/routes/route-http.d.ts +32 -0
- package/src/lib/routes/route-http.js +62 -0
- package/src/lib/routes/route-http.js.map +1 -0
- package/src/lib/routes/sequence-routes.d.ts +32 -0
- package/src/lib/routes/sequence-routes.js +359 -0
- package/src/lib/routes/sequence-routes.js.map +1 -0
- package/src/lib/routes/settings-routes.d.ts +30 -0
- package/src/lib/routes/settings-routes.js +73 -0
- package/src/lib/routes/settings-routes.js.map +1 -0
- package/src/lib/runtime/enrollment-events.d.ts +86 -0
- package/src/lib/runtime/enrollment-events.js +141 -0
- package/src/lib/runtime/enrollment-events.js.map +1 -0
- package/src/lib/runtime/fixtures/fake-gmail.d.ts +121 -0
- package/src/lib/runtime/fixtures/fake-gmail.js +312 -0
- package/src/lib/runtime/fixtures/fake-gmail.js.map +1 -0
- package/src/lib/runtime/mailbox-health-store.d.ts +47 -0
- package/src/lib/runtime/mailbox-health-store.js +168 -0
- package/src/lib/runtime/mailbox-health-store.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +39 -0
- package/src/lib/runtime/person-erasure.js +57 -0
- package/src/lib/runtime/person-erasure.js.map +1 -0
- package/src/lib/runtime/platform-runtime-deps.d.ts +29 -0
- package/src/lib/runtime/platform-runtime-deps.js +98 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -0
- package/src/lib/runtime/runtime-deps.d.ts +77 -0
- package/src/lib/runtime/runtime-deps.js +18 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -0
- package/src/lib/runtime/send-job.d.ts +42 -0
- package/src/lib/runtime/send-job.js +780 -0
- package/src/lib/runtime/send-job.js.map +1 -0
- package/src/lib/runtime/sync-job.d.ts +41 -0
- package/src/lib/runtime/sync-job.js +480 -0
- package/src/lib/runtime/sync-job.js.map +1 -0
- package/src/lib/runtime/timeline.d.ts +67 -0
- package/src/lib/runtime/timeline.js +93 -0
- package/src/lib/runtime/timeline.js.map +1 -0
- package/src/lib/runtime/unsubscribe-link.d.ts +75 -0
- package/src/lib/runtime/unsubscribe-link.js +121 -0
- package/src/lib/runtime/unsubscribe-link.js.map +1 -0
- package/src/lib/runtime/unsubscribe-route.d.ts +24 -0
- package/src/lib/runtime/unsubscribe-route.js +103 -0
- package/src/lib/runtime/unsubscribe-route.js.map +1 -0
- package/src/lib/server.d.ts +53 -0
- package/src/lib/server.js +94 -0
- package/src/lib/server.js.map +1 -0
- package/src/lib/storage/compliance-settings-store.d.ts +44 -0
- package/src/lib/storage/compliance-settings-store.js +78 -0
- package/src/lib/storage/compliance-settings-store.js.map +1 -0
- package/src/lib/storage/do-not-contact-store.d.ts +60 -0
- package/src/lib/storage/do-not-contact-store.js +168 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -0
- package/src/lib/storage/outreach-records.d.ts +33 -0
- package/src/lib/storage/outreach-records.js +35 -0
- package/src/lib/storage/outreach-records.js.map +1 -0
- package/src/lib/subprocessors.d.ts +48 -0
- package/src/lib/subprocessors.js +70 -0
- package/src/lib/subprocessors.js.map +1 -0
- package/src/lib/transport/gmail-client.d.ts +145 -0
- package/src/lib/transport/gmail-client.js +229 -0
- package/src/lib/transport/gmail-client.js.map +1 -0
- package/src/lib/transport/gmail-errors.d.ts +84 -0
- package/src/lib/transport/gmail-errors.js +138 -0
- package/src/lib/transport/gmail-errors.js.map +1 -0
- package/src/lib/transport/google-oauth.d.ts +138 -0
- package/src/lib/transport/google-oauth.js +247 -0
- package/src/lib/transport/google-oauth.js.map +1 -0
- package/src/lib/transport/http.d.ts +53 -0
- package/src/lib/transport/http.js +79 -0
- package/src/lib/transport/http.js.map +1 -0
- package/src/lib/transport/rfc5322.d.ts +116 -0
- package/src/lib/transport/rfc5322.js +330 -0
- package/src/lib/transport/rfc5322.js.map +1 -0
- package/src/lib/transport/send-message.d.ts +69 -0
- package/src/lib/transport/send-message.js +77 -0
- package/src/lib/transport/send-message.js.map +1 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { SecretBoxError } from "@aglyn/shared-util-tools/secret-box";
|
|
18
|
+
import { createGmailClient } from "../transport/gmail-client.js";
|
|
19
|
+
import { mailboxCredentialsRef, mailboxRef, openMailboxRefreshToken, readMailboxCredentials, sealMailboxRefreshToken } from "./mailbox-credentials.js";
|
|
20
|
+
import { readOutreachGoogleConfig } from "./outreach-config.js";
|
|
21
|
+
export async function openOutreachMailboxClient(firestore, input, deps = {}) {
|
|
22
|
+
var _deps_readConfig;
|
|
23
|
+
const config = ((_deps_readConfig = deps.readConfig) != null ? _deps_readConfig : readOutreachGoogleConfig)();
|
|
24
|
+
if (!config.configured) return {
|
|
25
|
+
ok: false,
|
|
26
|
+
reason: 'not-configured'
|
|
27
|
+
};
|
|
28
|
+
const snapshot = await mailboxCredentialsRef(firestore, input.mailboxId).get();
|
|
29
|
+
const credential = readMailboxCredentials(snapshot.exists ? snapshot.data() : null);
|
|
30
|
+
if (!credential || credential.mailboxId !== input.mailboxId) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: 'no-credential'
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
let opened;
|
|
37
|
+
try {
|
|
38
|
+
opened = openMailboxRefreshToken(credential, config.config.keyring);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (error instanceof SecretBoxError) return {
|
|
41
|
+
ok: false,
|
|
42
|
+
reason: 'sealed-token-unreadable'
|
|
43
|
+
};
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (opened.needsReseal) {
|
|
47
|
+
await resealMailboxRefreshToken(firestore, credential, opened.refreshToken, config.config.keyring, deps.now);
|
|
48
|
+
}
|
|
49
|
+
const client = createGmailClient(_extends({}, deps, {
|
|
50
|
+
clientId: config.config.clientId,
|
|
51
|
+
clientSecret: config.config.clientSecret,
|
|
52
|
+
refreshToken: opened.refreshToken
|
|
53
|
+
}));
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
client,
|
|
57
|
+
credential
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Seals a token again under the current key, only if the stored value is
|
|
62
|
+
* still the one that was opened: a reconnect that landed in between wrote a
|
|
63
|
+
* newer grant, and that one wins. Best-effort — a failure leaves the old
|
|
64
|
+
* seal, which still opens.
|
|
65
|
+
*/ async function resealMailboxRefreshToken(firestore, credential, refreshToken, keyring, now) {
|
|
66
|
+
const ref = mailboxCredentialsRef(firestore, credential.mailboxId);
|
|
67
|
+
try {
|
|
68
|
+
await firestore.runTransaction(async (tx)=>{
|
|
69
|
+
const current = await tx.get(ref);
|
|
70
|
+
if (current.get('sealedRefreshToken') !== credential.sealedRefreshToken) return;
|
|
71
|
+
tx.update(ref, _extends({}, sealMailboxRefreshToken(refreshToken, credential.mailboxId, keyring), {
|
|
72
|
+
updatedAtMs: (now != null ? now : Date.now)()
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error('[outreach] resealing a mailbox token failed', error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Marks a mailbox as needing a reconnect: the provider refused its grant, or
|
|
81
|
+
* its stored token cannot be opened. Nothing sends from it until the member
|
|
82
|
+
* connects it again, which returns it to where it was.
|
|
83
|
+
*/ export async function markOutreachMailboxReconnectRequired(firestore, input) {
|
|
84
|
+
await mailboxRef(firestore, input.orgId, input.mailboxId).update({
|
|
85
|
+
status: 'reconnect_required',
|
|
86
|
+
'health.lastErrorAtMs': input.nowMs,
|
|
87
|
+
'health.lastErrorCode': input.errorCode,
|
|
88
|
+
updatedAtMs: input.nowMs
|
|
89
|
+
}).catch((error)=>{
|
|
90
|
+
console.error('[outreach] marking a mailbox reconnect_required failed', error);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
//# sourceMappingURL=mailbox-transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/mailbox-transport.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { SecretBoxError } from '@aglyn/shared-util-tools/secret-box'\nimport { createGmailClient, type GmailClient } from '../transport/gmail-client'\nimport type { TransportDeps } from '../transport/http'\nimport {\n mailboxCredentialsRef,\n mailboxRef,\n openMailboxRefreshToken,\n readMailboxCredentials,\n sealMailboxRefreshToken,\n type OutreachGoogleMailboxCredentials,\n} from './mailbox-credentials'\nimport {\n readOutreachGoogleConfig,\n type OutreachGoogleConfigResult,\n} from './outreach-config'\n\n/**\n * A CONNECTED MAILBOX, OPENED FOR SENDING (AGL-2978).\n *\n * The door the send and sync runtime, and the panel's test send, reach a\n * mailbox's Gmail through: the stored credential read, its refresh token\n * opened with the deployment's key, and a transport client built on it. The\n * token never leaves this function except inside the client.\n *\n * A token opened under an older key of the keyring is sealed again under the\n * current one on the way through, so a key rotation completes itself as\n * mailboxes are used rather than needing a migration.\n */\n\nexport type OpenedOutreachMailbox =\n | { ok: true; client: GmailClient; credential: OutreachGoogleMailboxCredentials }\n | {\n ok: false\n /**\n * - `not-configured` — this deployment has no Outreach client or key.\n * - `no-credential` — nothing is stored for the mailbox.\n * - `sealed-token-unreadable` — the stored token does not open with\n * this deployment's key (rotated away, or altered). The mailbox needs\n * connecting again.\n */\n reason: 'not-configured' | 'no-credential' | 'sealed-token-unreadable'\n }\n\nexport interface OpenMailboxDeps extends TransportDeps {\n readConfig?: () => OutreachGoogleConfigResult\n}\n\nexport async function openOutreachMailboxClient(\n firestore: FirebaseFirestore.Firestore,\n input: { mailboxId: string },\n deps: OpenMailboxDeps = {},\n): Promise<OpenedOutreachMailbox> {\n const config = (deps.readConfig ?? readOutreachGoogleConfig)()\n if (!config.configured) return { ok: false, reason: 'not-configured' }\n const snapshot = await mailboxCredentialsRef(firestore, input.mailboxId).get()\n const credential = readMailboxCredentials(snapshot.exists ? snapshot.data() : null)\n if (!credential || credential.mailboxId !== input.mailboxId) {\n return { ok: false, reason: 'no-credential' }\n }\n let opened: { refreshToken: string; needsReseal: boolean }\n try {\n opened = openMailboxRefreshToken(credential, config.config.keyring)\n } catch (error) {\n if (error instanceof SecretBoxError) return { ok: false, reason: 'sealed-token-unreadable' }\n throw error\n }\n if (opened.needsReseal) {\n await resealMailboxRefreshToken(firestore, credential, opened.refreshToken, config.config.keyring, deps.now)\n }\n const client = createGmailClient({\n ...deps,\n clientId: config.config.clientId,\n clientSecret: config.config.clientSecret,\n refreshToken: opened.refreshToken,\n })\n return { ok: true, client, credential }\n}\n\n/**\n * Seals a token again under the current key, only if the stored value is\n * still the one that was opened: a reconnect that landed in between wrote a\n * newer grant, and that one wins. Best-effort — a failure leaves the old\n * seal, which still opens.\n */\nasync function resealMailboxRefreshToken(\n firestore: FirebaseFirestore.Firestore,\n credential: OutreachGoogleMailboxCredentials,\n refreshToken: string,\n keyring: Parameters<typeof sealMailboxRefreshToken>[2],\n now: (() => number) | undefined,\n): Promise<void> {\n const ref = mailboxCredentialsRef(firestore, credential.mailboxId)\n try {\n await firestore.runTransaction(async (tx) => {\n const current = await tx.get(ref)\n if (current.get('sealedRefreshToken') !== credential.sealedRefreshToken) return\n tx.update(ref, {\n ...sealMailboxRefreshToken(refreshToken, credential.mailboxId, keyring),\n updatedAtMs: (now ?? Date.now)(),\n })\n })\n } catch (error) {\n console.error('[outreach] resealing a mailbox token failed', error)\n }\n}\n\n/**\n * Marks a mailbox as needing a reconnect: the provider refused its grant, or\n * its stored token cannot be opened. Nothing sends from it until the member\n * connects it again, which returns it to where it was.\n */\nexport async function markOutreachMailboxReconnectRequired(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; mailboxId: string; errorCode: string; nowMs: number },\n): Promise<void> {\n await mailboxRef(firestore, input.orgId, input.mailboxId)\n .update({\n status: 'reconnect_required',\n 'health.lastErrorAtMs': input.nowMs,\n 'health.lastErrorCode': input.errorCode,\n updatedAtMs: input.nowMs,\n })\n .catch((error: unknown) => {\n console.error('[outreach] marking a mailbox reconnect_required failed', error)\n })\n}\n"],"names":["SecretBoxError","createGmailClient","mailboxCredentialsRef","mailboxRef","openMailboxRefreshToken","readMailboxCredentials","sealMailboxRefreshToken","readOutreachGoogleConfig","openOutreachMailboxClient","firestore","input","deps","config","readConfig","configured","ok","reason","snapshot","mailboxId","get","credential","exists","data","opened","keyring","error","needsReseal","resealMailboxRefreshToken","refreshToken","now","client","clientId","clientSecret","ref","runTransaction","tx","current","sealedRefreshToken","update","updatedAtMs","Date","console","markOutreachMailboxReconnectRequired","orgId","status","nowMs","errorCode","catch"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,cAAc,QAAQ,sCAAqC;AACpE,SAASC,iBAAiB,QAA0B,+BAA2B;AAE/E,SACEC,qBAAqB,EACrBC,UAAU,EACVC,uBAAuB,EACvBC,sBAAsB,EACtBC,uBAAuB,QAElB,2BAAuB;AAC9B,SACEC,wBAAwB,QAEnB,uBAAmB;AAiC1B,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,KAA4B,EAC5BC,OAAwB,CAAC,CAAC;QAEVA;IAAhB,MAAMC,SAAS,EAACD,mBAAAA,KAAKE,UAAU,YAAfF,mBAAmBJ;IACnC,IAAI,CAACK,OAAOE,UAAU,EAAE,OAAO;QAAEC,IAAI;QAAOC,QAAQ;IAAiB;IACrE,MAAMC,WAAW,MAAMf,sBAAsBO,WAAWC,MAAMQ,SAAS,EAAEC,GAAG;IAC5E,MAAMC,aAAaf,uBAAuBY,SAASI,MAAM,GAAGJ,SAASK,IAAI,KAAK;IAC9E,IAAI,CAACF,cAAcA,WAAWF,SAAS,KAAKR,MAAMQ,SAAS,EAAE;QAC3D,OAAO;YAAEH,IAAI;YAAOC,QAAQ;QAAgB;IAC9C;IACA,IAAIO;IACJ,IAAI;QACFA,SAASnB,wBAAwBgB,YAAYR,OAAOA,MAAM,CAACY,OAAO;IACpE,EAAE,OAAOC,OAAO;QACd,IAAIA,iBAAiBzB,gBAAgB,OAAO;YAAEe,IAAI;YAAOC,QAAQ;QAA0B;QAC3F,MAAMS;IACR;IACA,IAAIF,OAAOG,WAAW,EAAE;QACtB,MAAMC,0BAA0BlB,WAAWW,YAAYG,OAAOK,YAAY,EAAEhB,OAAOA,MAAM,CAACY,OAAO,EAAEb,KAAKkB,GAAG;IAC7G;IACA,MAAMC,SAAS7B,kBAAkB,aAC5BU;QACHoB,UAAUnB,OAAOA,MAAM,CAACmB,QAAQ;QAChCC,cAAcpB,OAAOA,MAAM,CAACoB,YAAY;QACxCJ,cAAcL,OAAOK,YAAY;;IAEnC,OAAO;QAAEb,IAAI;QAAMe;QAAQV;IAAW;AACxC;AAEA;;;;;CAKC,GACD,eAAeO,0BACblB,SAAsC,EACtCW,UAA4C,EAC5CQ,YAAoB,EACpBJ,OAAsD,EACtDK,GAA+B;IAE/B,MAAMI,MAAM/B,sBAAsBO,WAAWW,WAAWF,SAAS;IACjE,IAAI;QACF,MAAMT,UAAUyB,cAAc,CAAC,OAAOC;YACpC,MAAMC,UAAU,MAAMD,GAAGhB,GAAG,CAACc;YAC7B,IAAIG,QAAQjB,GAAG,CAAC,0BAA0BC,WAAWiB,kBAAkB,EAAE;YACzEF,GAAGG,MAAM,CAACL,KAAK,aACV3B,wBAAwBsB,cAAcR,WAAWF,SAAS,EAAEM;gBAC/De,aAAa,CAACV,cAAAA,MAAOW,KAAKX,GAAG;;QAEjC;IACF,EAAE,OAAOJ,OAAO;QACdgB,QAAQhB,KAAK,CAAC,+CAA+CA;IAC/D;AACF;AAEA;;;;CAIC,GACD,OAAO,eAAeiB,qCACpBjC,SAAsC,EACtCC,KAA6E;IAE7E,MAAMP,WAAWM,WAAWC,MAAMiC,KAAK,EAAEjC,MAAMQ,SAAS,EACrDoB,MAAM,CAAC;QACNM,QAAQ;QACR,wBAAwBlC,MAAMmC,KAAK;QACnC,wBAAwBnC,MAAMoC,SAAS;QACvCP,aAAa7B,MAAMmC,KAAK;IAC1B,GACCE,KAAK,CAAC,CAACtB;QACNgB,QAAQhB,KAAK,CAAC,0DAA0DA;IAC1E;AACJ"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* WHERE GOOGLE SENDS A REP BACK TO (AGL-2978).
|
|
19
|
+
*
|
|
20
|
+
* Google redirects only to an address registered on the OAuth client, matched
|
|
21
|
+
* exactly, so the redirect cannot follow whichever host a rep happened to open
|
|
22
|
+
* the console on: it is the deployment's canonical console origin,
|
|
23
|
+
* `NEXT_PUBLIC_CONSOLE_URL`, plus the callback route. That is the one address
|
|
24
|
+
* an operator registers.
|
|
25
|
+
*
|
|
26
|
+
* Local development is the one exception, and never in production: a request
|
|
27
|
+
* from `http://localhost` or `127.0.0.1` is sent back to itself, so a developer
|
|
28
|
+
* registering their local address on a test client is not bounced to the
|
|
29
|
+
* deployed console after consenting. Anything else a request claims about its
|
|
30
|
+
* origin is ignored — the host Google redirects to is where the authorization
|
|
31
|
+
* code arrives, and a request header does not get to choose it.
|
|
32
|
+
*/
|
|
33
|
+
/** The callback's path on the console. */
|
|
34
|
+
export declare const OUTREACH_OAUTH_CALLBACK_PATH: string;
|
|
35
|
+
/** The canonical console origin, or `null` when it is unset or not http(s). */
|
|
36
|
+
export declare function canonicalConsoleOrigin(): string | null;
|
|
37
|
+
/**
|
|
38
|
+
* The redirect address for a connect started by `requestUrl`, or `null` when
|
|
39
|
+
* the deployment names no console origin to register.
|
|
40
|
+
*/
|
|
41
|
+
export declare function outreachOAuthRedirectUri(requestUrl: string): string | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
|
|
17
|
+
/**
|
|
18
|
+
* WHERE GOOGLE SENDS A REP BACK TO (AGL-2978).
|
|
19
|
+
*
|
|
20
|
+
* Google redirects only to an address registered on the OAuth client, matched
|
|
21
|
+
* exactly, so the redirect cannot follow whichever host a rep happened to open
|
|
22
|
+
* the console on: it is the deployment's canonical console origin,
|
|
23
|
+
* `NEXT_PUBLIC_CONSOLE_URL`, plus the callback route. That is the one address
|
|
24
|
+
* an operator registers.
|
|
25
|
+
*
|
|
26
|
+
* Local development is the one exception, and never in production: a request
|
|
27
|
+
* from `http://localhost` or `127.0.0.1` is sent back to itself, so a developer
|
|
28
|
+
* registering their local address on a test client is not bounced to the
|
|
29
|
+
* deployed console after consenting. Anything else a request claims about its
|
|
30
|
+
* origin is ignored — the host Google redirects to is where the authorization
|
|
31
|
+
* code arrives, and a request header does not get to choose it.
|
|
32
|
+
*/ /** The callback's path on the console. */ export const OUTREACH_OAUTH_CALLBACK_PATH = `/api/${OUTREACH_API_ROUTES.mailboxesOAuthCallback}`;
|
|
33
|
+
const stripTrailingSlash = (value)=>value.replace(/\/+$/, '');
|
|
34
|
+
/** The canonical console origin, or `null` when it is unset or not http(s). */ export function canonicalConsoleOrigin() {
|
|
35
|
+
var _process_env_NEXT_PUBLIC_CONSOLE_URL;
|
|
36
|
+
const raw = stripTrailingSlash(String((_process_env_NEXT_PUBLIC_CONSOLE_URL = process.env.NEXT_PUBLIC_CONSOLE_URL) != null ? _process_env_NEXT_PUBLIC_CONSOLE_URL : '').trim());
|
|
37
|
+
if (!raw) return null;
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(raw);
|
|
40
|
+
return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null;
|
|
41
|
+
} catch (unused) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The redirect address for a connect started by `requestUrl`, or `null` when
|
|
47
|
+
* the deployment names no console origin to register.
|
|
48
|
+
*/ export function outreachOAuthRedirectUri(requestUrl) {
|
|
49
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
50
|
+
try {
|
|
51
|
+
const url = new URL(requestUrl);
|
|
52
|
+
if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) {
|
|
53
|
+
return `${url.origin}${OUTREACH_OAUTH_CALLBACK_PATH}`;
|
|
54
|
+
}
|
|
55
|
+
} catch (unused) {
|
|
56
|
+
// Not a URL: fall through to the canonical origin.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const origin = canonicalConsoleOrigin();
|
|
60
|
+
return origin ? `${origin}${OUTREACH_OAUTH_CALLBACK_PATH}` : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
//# sourceMappingURL=oauth-redirect.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/oauth-redirect.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\n\n/**\n * WHERE GOOGLE SENDS A REP BACK TO (AGL-2978).\n *\n * Google redirects only to an address registered on the OAuth client, matched\n * exactly, so the redirect cannot follow whichever host a rep happened to open\n * the console on: it is the deployment's canonical console origin,\n * `NEXT_PUBLIC_CONSOLE_URL`, plus the callback route. That is the one address\n * an operator registers.\n *\n * Local development is the one exception, and never in production: a request\n * from `http://localhost` or `127.0.0.1` is sent back to itself, so a developer\n * registering their local address on a test client is not bounced to the\n * deployed console after consenting. Anything else a request claims about its\n * origin is ignored — the host Google redirects to is where the authorization\n * code arrives, and a request header does not get to choose it.\n */\n\n/** The callback's path on the console. */\nexport const OUTREACH_OAUTH_CALLBACK_PATH = `/api/${OUTREACH_API_ROUTES.mailboxesOAuthCallback}`\n\nconst stripTrailingSlash = (value: string) => value.replace(/\\/+$/, '')\n\n/** The canonical console origin, or `null` when it is unset or not http(s). */\nexport function canonicalConsoleOrigin(): string | null {\n const raw = stripTrailingSlash(String(process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim())\n if (!raw) return null\n try {\n const url = new URL(raw)\n return url.protocol === 'https:' || url.protocol === 'http:' ? url.origin : null\n } catch {\n return null\n }\n}\n\n/**\n * The redirect address for a connect started by `requestUrl`, or `null` when\n * the deployment names no console origin to register.\n */\nexport function outreachOAuthRedirectUri(requestUrl: string): string | null {\n if (process.env['NODE_ENV'] !== 'production') {\n try {\n const url = new URL(requestUrl)\n if (url.protocol === 'http:' && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) {\n return `${url.origin}${OUTREACH_OAUTH_CALLBACK_PATH}`\n }\n } catch {\n // Not a URL: fall through to the canonical origin.\n }\n }\n const origin = canonicalConsoleOrigin()\n return origin ? `${origin}${OUTREACH_OAUTH_CALLBACK_PATH}` : null\n}\n"],"names":["OUTREACH_API_ROUTES","OUTREACH_OAUTH_CALLBACK_PATH","mailboxesOAuthCallback","stripTrailingSlash","value","replace","canonicalConsoleOrigin","process","raw","String","env","NEXT_PUBLIC_CONSOLE_URL","trim","url","URL","protocol","origin","outreachOAuthRedirectUri","requestUrl","hostname"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,mBAAmB,QAAQ,6BAAyB;AAE7D;;;;;;;;;;;;;;;CAeC,GAED,wCAAwC,GACxC,OAAO,MAAMC,+BAA+B,CAAC,KAAK,EAAED,oBAAoBE,sBAAsB,EAAE,CAAA;AAEhG,MAAMC,qBAAqB,CAACC,QAAkBA,MAAMC,OAAO,CAAC,QAAQ;AAEpE,6EAA6E,GAC7E,OAAO,SAASC;QACwBC;IAAtC,MAAMC,MAAML,mBAAmBM,QAAOF,uCAAAA,QAAQG,GAAG,CAACC,uBAAuB,YAAnCJ,uCAAuC,IAAIK,IAAI;IACrF,IAAI,CAACJ,KAAK,OAAO;IACjB,IAAI;QACF,MAAMK,MAAM,IAAIC,IAAIN;QACpB,OAAOK,IAAIE,QAAQ,KAAK,YAAYF,IAAIE,QAAQ,KAAK,UAAUF,IAAIG,MAAM,GAAG;IAC9E,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,SAASC,yBAAyBC,UAAkB;IACzD,IAAIX,QAAQG,GAAG,CAAC,WAAW,KAAK,cAAc;QAC5C,IAAI;YACF,MAAMG,MAAM,IAAIC,IAAII;YACpB,IAAIL,IAAIE,QAAQ,KAAK,WAAYF,CAAAA,IAAIM,QAAQ,KAAK,eAAeN,IAAIM,QAAQ,KAAK,WAAU,GAAI;gBAC9F,OAAO,GAAGN,IAAIG,MAAM,GAAGf,8BAA8B;YACvD;QACF,EAAE,eAAM;QACN,mDAAmD;QACrD;IACF;IACA,MAAMe,SAASV;IACf,OAAOU,SAAS,GAAGA,SAASf,8BAA8B,GAAG;AAC/D"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* THE OAUTH `state` FOR A MAILBOX CONNECT (AGL-2978): signed, short-lived,
|
|
19
|
+
* single-use, and bound to one member of one organization.
|
|
20
|
+
*
|
|
21
|
+
* ## Signed
|
|
22
|
+
*
|
|
23
|
+
* `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,
|
|
24
|
+
* e: expiry }` and the signature an HMAC under the shared, fail-closed
|
|
25
|
+
* `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the
|
|
26
|
+
* construction every other signed token here uses, and domain-separated from
|
|
27
|
+
* all of them. Google hands the state back verbatim, so a state that verifies
|
|
28
|
+
* names the org and the member it was minted for. The console dispatcher
|
|
29
|
+
* reads exactly that to ask its release gate about the right organization on
|
|
30
|
+
* a redirect that carries no bearer token.
|
|
31
|
+
*
|
|
32
|
+
* ## Single-use
|
|
33
|
+
*
|
|
34
|
+
* A signature proves who minted a state, not that it is fresh. Each connect
|
|
35
|
+
* also writes ONE pending record per member per organization, at
|
|
36
|
+
* `orgs/{orgId}/outreachOAuthStates/{sha256(orgId, uid)}`, holding the
|
|
37
|
+
* SHA-256 of the state's nonce, its expiry and the redirect address the code
|
|
38
|
+
* was issued for. Finishing a connect consumes that record in a transaction
|
|
39
|
+
* (`consumeOnce`), so a second use of the same state finds nothing, and
|
|
40
|
+
* starting another connect replaces the record, retiring every earlier state.
|
|
41
|
+
*
|
|
42
|
+
* ## Under the organization
|
|
43
|
+
*
|
|
44
|
+
* The record is the organization's, so it lives in the organization's tree:
|
|
45
|
+
* the workspace erasure's recursive delete and the workspace export's walk
|
|
46
|
+
* reach it with no sweep of their own, and nothing outside this plugin names
|
|
47
|
+
* the collection. No Firestore rule names it either, and the org block has no
|
|
48
|
+
* catch-all, so every client is refused it — a client that could write one
|
|
49
|
+
* could re-arm a consumed state, and one that could read one would learn
|
|
50
|
+
* which members have a connect open.
|
|
51
|
+
*
|
|
52
|
+
* ## Derived, not stored
|
|
53
|
+
*
|
|
54
|
+
* The PKCE verifier and the OpenID `nonce` are HMACs of the state's nonce
|
|
55
|
+
* under the same secret, each with its own context. Nothing secret is stored
|
|
56
|
+
* for them, and only this server can recompute either — a state copied off a
|
|
57
|
+
* redirect is not enough to redeem its code.
|
|
58
|
+
*/
|
|
59
|
+
export declare const OUTREACH_OAUTH_STATES_COLLECTION = "outreachOAuthStates";
|
|
60
|
+
/** How long a rep has to finish Google's consent screen. */
|
|
61
|
+
export declare const OUTREACH_OAUTH_STATE_TTL_MS: number;
|
|
62
|
+
export interface OutreachOAuthStateClaims {
|
|
63
|
+
orgId: string;
|
|
64
|
+
uid: string;
|
|
65
|
+
nonce: string;
|
|
66
|
+
/** Expiry, epoch ms. */
|
|
67
|
+
exp: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is
|
|
71
|
+
* not configured; the route answers that as a deployment that cannot connect.
|
|
72
|
+
*/
|
|
73
|
+
export declare function mintOutreachOAuthState(input: {
|
|
74
|
+
orgId: string;
|
|
75
|
+
uid: string;
|
|
76
|
+
nowMs: number;
|
|
77
|
+
/** Test seam; a random 32 bytes otherwise. */
|
|
78
|
+
nonce?: string;
|
|
79
|
+
}): {
|
|
80
|
+
state: string;
|
|
81
|
+
claims: OutreachOAuthStateClaims;
|
|
82
|
+
};
|
|
83
|
+
export type OutreachOAuthStateRead = {
|
|
84
|
+
ok: true;
|
|
85
|
+
claims: OutreachOAuthStateClaims;
|
|
86
|
+
}
|
|
87
|
+
/** Authentic and past its expiry — the claims can still say whose it was. */
|
|
88
|
+
| {
|
|
89
|
+
ok: false;
|
|
90
|
+
refusal: 'state-expired';
|
|
91
|
+
claims: OutreachOAuthStateClaims;
|
|
92
|
+
}
|
|
93
|
+
/** Tampered, truncated, another version, or no secret to check it with. */
|
|
94
|
+
| {
|
|
95
|
+
ok: false;
|
|
96
|
+
refusal: 'state-invalid';
|
|
97
|
+
};
|
|
98
|
+
/** Verifies a state's signature and expiry. Never throws. */
|
|
99
|
+
export declare function readOutreachOAuthState(state: unknown, nowMs: number): OutreachOAuthStateRead;
|
|
100
|
+
/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */
|
|
101
|
+
export declare function outreachPkceVerifier(nonce: string): string;
|
|
102
|
+
/** The OpenID `nonce` Google must echo in the ID token for this connect. */
|
|
103
|
+
export declare function outreachOidcNonce(nonce: string): string;
|
|
104
|
+
/** The pending record's id: one per member per organization. */
|
|
105
|
+
export declare function outreachOAuthStateDocId(orgId: string, uid: string): string;
|
|
106
|
+
/** `orgs/{orgId}/outreachOAuthStates/{id}` for a member's pending connect. */
|
|
107
|
+
export declare function outreachOAuthStateRef(firestore: FirebaseFirestore.Firestore, orgId: string, uid: string): FirebaseFirestore.DocumentReference;
|
|
108
|
+
/** `orgs/{orgId}/outreachOAuthStates/{id}` — see the module comment. */
|
|
109
|
+
export interface OutreachOAuthStateRecord {
|
|
110
|
+
orgId: string;
|
|
111
|
+
uid: string;
|
|
112
|
+
/** SHA-256 of the state's nonce; the nonce itself is never stored. */
|
|
113
|
+
nonceDigest: string;
|
|
114
|
+
/** The redirect address the authorization code was issued for. */
|
|
115
|
+
redirectUri: string;
|
|
116
|
+
expiresAtMs: number;
|
|
117
|
+
createdAtMs: number;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Writes the pending record for a freshly minted state, replacing any earlier
|
|
121
|
+
* one for the same member and organization.
|
|
122
|
+
*/
|
|
123
|
+
export declare function recordOutreachOAuthState(firestore: FirebaseFirestore.Firestore, input: {
|
|
124
|
+
claims: OutreachOAuthStateClaims;
|
|
125
|
+
redirectUri: string;
|
|
126
|
+
nowMs: number;
|
|
127
|
+
}): Promise<void>;
|
|
128
|
+
export type OutreachOAuthStateConsumed = {
|
|
129
|
+
ok: true;
|
|
130
|
+
redirectUri: string;
|
|
131
|
+
}
|
|
132
|
+
/** No pending record: this state was already used, or never recorded. */
|
|
133
|
+
| {
|
|
134
|
+
ok: false;
|
|
135
|
+
refusal: 'state-replayed';
|
|
136
|
+
}
|
|
137
|
+
/** A later connect by the same member replaced this state. */
|
|
138
|
+
| {
|
|
139
|
+
ok: false;
|
|
140
|
+
refusal: 'state-superseded';
|
|
141
|
+
} | {
|
|
142
|
+
ok: false;
|
|
143
|
+
refusal: 'state-expired';
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Consumes the pending record a verified state names — once. A superseded
|
|
147
|
+
* state leaves the newer record standing for the connect that owns it.
|
|
148
|
+
*/
|
|
149
|
+
export declare function consumeOutreachOAuthState(firestore: FirebaseFirestore.Firestore, input: {
|
|
150
|
+
claims: OutreachOAuthStateClaims;
|
|
151
|
+
nowMs: number;
|
|
152
|
+
}): Promise<OutreachOAuthStateConsumed>;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { consumeOnce } from "@aglyn/tenant-data-admin/server/consume-once";
|
|
17
|
+
import { tokenSigningSecret } from "@aglyn/tenant-data-admin/server/media-signing";
|
|
18
|
+
import { safeEqual } from "@aglyn/tenant-data-admin/server/safe-equal";
|
|
19
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
20
|
+
/**
|
|
21
|
+
* THE OAUTH `state` FOR A MAILBOX CONNECT (AGL-2978): signed, short-lived,
|
|
22
|
+
* single-use, and bound to one member of one organization.
|
|
23
|
+
*
|
|
24
|
+
* ## Signed
|
|
25
|
+
*
|
|
26
|
+
* `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,
|
|
27
|
+
* e: expiry }` and the signature an HMAC under the shared, fail-closed
|
|
28
|
+
* `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the
|
|
29
|
+
* construction every other signed token here uses, and domain-separated from
|
|
30
|
+
* all of them. Google hands the state back verbatim, so a state that verifies
|
|
31
|
+
* names the org and the member it was minted for. The console dispatcher
|
|
32
|
+
* reads exactly that to ask its release gate about the right organization on
|
|
33
|
+
* a redirect that carries no bearer token.
|
|
34
|
+
*
|
|
35
|
+
* ## Single-use
|
|
36
|
+
*
|
|
37
|
+
* A signature proves who minted a state, not that it is fresh. Each connect
|
|
38
|
+
* also writes ONE pending record per member per organization, at
|
|
39
|
+
* `orgs/{orgId}/outreachOAuthStates/{sha256(orgId, uid)}`, holding the
|
|
40
|
+
* SHA-256 of the state's nonce, its expiry and the redirect address the code
|
|
41
|
+
* was issued for. Finishing a connect consumes that record in a transaction
|
|
42
|
+
* (`consumeOnce`), so a second use of the same state finds nothing, and
|
|
43
|
+
* starting another connect replaces the record, retiring every earlier state.
|
|
44
|
+
*
|
|
45
|
+
* ## Under the organization
|
|
46
|
+
*
|
|
47
|
+
* The record is the organization's, so it lives in the organization's tree:
|
|
48
|
+
* the workspace erasure's recursive delete and the workspace export's walk
|
|
49
|
+
* reach it with no sweep of their own, and nothing outside this plugin names
|
|
50
|
+
* the collection. No Firestore rule names it either, and the org block has no
|
|
51
|
+
* catch-all, so every client is refused it — a client that could write one
|
|
52
|
+
* could re-arm a consumed state, and one that could read one would learn
|
|
53
|
+
* which members have a connect open.
|
|
54
|
+
*
|
|
55
|
+
* ## Derived, not stored
|
|
56
|
+
*
|
|
57
|
+
* The PKCE verifier and the OpenID `nonce` are HMACs of the state's nonce
|
|
58
|
+
* under the same secret, each with its own context. Nothing secret is stored
|
|
59
|
+
* for them, and only this server can recompute either — a state copied off a
|
|
60
|
+
* redirect is not enough to redeem its code.
|
|
61
|
+
*/ export const OUTREACH_OAUTH_STATES_COLLECTION = 'outreachOAuthStates';
|
|
62
|
+
/** How long a rep has to finish Google's consent screen. */ export const OUTREACH_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
|
63
|
+
const STATE_VERSION = 'os1';
|
|
64
|
+
const STATE_CONTEXT = 'outreach-oauth-state:v1';
|
|
65
|
+
const PKCE_CONTEXT = 'outreach-oauth-pkce:v1';
|
|
66
|
+
const OIDC_NONCE_CONTEXT = 'outreach-oauth-oidc-nonce:v1';
|
|
67
|
+
const STATE_MAX_CHARS = 1024;
|
|
68
|
+
const hmac = (context, value)=>createHmac('sha256', tokenSigningSecret()).update(`${context}:${value}`).digest('base64url');
|
|
69
|
+
/**
|
|
70
|
+
* Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is
|
|
71
|
+
* not configured; the route answers that as a deployment that cannot connect.
|
|
72
|
+
*/ export function mintOutreachOAuthState(input) {
|
|
73
|
+
var _input_nonce;
|
|
74
|
+
const claims = {
|
|
75
|
+
orgId: input.orgId,
|
|
76
|
+
uid: input.uid,
|
|
77
|
+
nonce: (_input_nonce = input.nonce) != null ? _input_nonce : randomBytes(32).toString('base64url'),
|
|
78
|
+
exp: input.nowMs + OUTREACH_OAUTH_STATE_TTL_MS
|
|
79
|
+
};
|
|
80
|
+
const wire = {
|
|
81
|
+
o: claims.orgId,
|
|
82
|
+
u: claims.uid,
|
|
83
|
+
n: claims.nonce,
|
|
84
|
+
e: claims.exp
|
|
85
|
+
};
|
|
86
|
+
const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url');
|
|
87
|
+
return {
|
|
88
|
+
state: `${STATE_VERSION}.${payload}.${hmac(STATE_CONTEXT, payload)}`,
|
|
89
|
+
claims
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** Verifies a state's signature and expiry. Never throws. */ export function readOutreachOAuthState(state, nowMs) {
|
|
93
|
+
const invalid = {
|
|
94
|
+
ok: false,
|
|
95
|
+
refusal: 'state-invalid'
|
|
96
|
+
};
|
|
97
|
+
if (typeof state !== 'string' || !state || state.length > STATE_MAX_CHARS) return invalid;
|
|
98
|
+
const parts = state.split('.');
|
|
99
|
+
if (parts.length !== 3 || parts[0] !== STATE_VERSION) return invalid;
|
|
100
|
+
const [, payload, signature] = parts;
|
|
101
|
+
let expected;
|
|
102
|
+
try {
|
|
103
|
+
expected = hmac(STATE_CONTEXT, payload);
|
|
104
|
+
} catch (unused) {
|
|
105
|
+
return invalid;
|
|
106
|
+
}
|
|
107
|
+
if (!safeEqual(signature, expected)) return invalid;
|
|
108
|
+
let wire;
|
|
109
|
+
try {
|
|
110
|
+
wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
111
|
+
} catch (unused) {
|
|
112
|
+
return invalid;
|
|
113
|
+
}
|
|
114
|
+
const claims = {
|
|
115
|
+
orgId: typeof (wire == null ? void 0 : wire.o) === 'string' ? wire.o : '',
|
|
116
|
+
uid: typeof (wire == null ? void 0 : wire.u) === 'string' ? wire.u : '',
|
|
117
|
+
nonce: typeof (wire == null ? void 0 : wire.n) === 'string' ? wire.n : '',
|
|
118
|
+
exp: Number(wire == null ? void 0 : wire.e)
|
|
119
|
+
};
|
|
120
|
+
if (!claims.orgId || !claims.uid || !claims.nonce || !Number.isFinite(claims.exp)) return invalid;
|
|
121
|
+
if (claims.exp <= nowMs) return {
|
|
122
|
+
ok: false,
|
|
123
|
+
refusal: 'state-expired',
|
|
124
|
+
claims
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
ok: true,
|
|
128
|
+
claims
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */ export function outreachPkceVerifier(nonce) {
|
|
132
|
+
return hmac(PKCE_CONTEXT, nonce);
|
|
133
|
+
}
|
|
134
|
+
/** The OpenID `nonce` Google must echo in the ID token for this connect. */ export function outreachOidcNonce(nonce) {
|
|
135
|
+
return hmac(OIDC_NONCE_CONTEXT, nonce);
|
|
136
|
+
}
|
|
137
|
+
/** The pending record's id: one per member per organization. */ export function outreachOAuthStateDocId(orgId, uid) {
|
|
138
|
+
return createHash('sha256').update(`${orgId}\n${uid}`).digest('hex');
|
|
139
|
+
}
|
|
140
|
+
/** `orgs/{orgId}/outreachOAuthStates/{id}` for a member's pending connect. */ export function outreachOAuthStateRef(firestore, orgId, uid) {
|
|
141
|
+
return firestore.collection('orgs').doc(orgId).collection(OUTREACH_OAUTH_STATES_COLLECTION).doc(outreachOAuthStateDocId(orgId, uid));
|
|
142
|
+
}
|
|
143
|
+
const digest = (nonce)=>createHash('sha256').update(nonce).digest('base64url');
|
|
144
|
+
/**
|
|
145
|
+
* Writes the pending record for a freshly minted state, replacing any earlier
|
|
146
|
+
* one for the same member and organization.
|
|
147
|
+
*/ export async function recordOutreachOAuthState(firestore, input) {
|
|
148
|
+
const { claims } = input;
|
|
149
|
+
const record = {
|
|
150
|
+
orgId: claims.orgId,
|
|
151
|
+
uid: claims.uid,
|
|
152
|
+
nonceDigest: digest(claims.nonce),
|
|
153
|
+
redirectUri: input.redirectUri,
|
|
154
|
+
expiresAtMs: claims.exp,
|
|
155
|
+
createdAtMs: input.nowMs
|
|
156
|
+
};
|
|
157
|
+
await outreachOAuthStateRef(firestore, claims.orgId, claims.uid).set(record);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Consumes the pending record a verified state names — once. A superseded
|
|
161
|
+
* state leaves the newer record standing for the connect that owns it.
|
|
162
|
+
*/ export async function consumeOutreachOAuthState(firestore, input) {
|
|
163
|
+
const { claims } = input;
|
|
164
|
+
const ref = outreachOAuthStateRef(firestore, claims.orgId, claims.uid);
|
|
165
|
+
const result = await consumeOnce(firestore, ref, (data)=>{
|
|
166
|
+
var _data_nonceDigest, _data_redirectUri;
|
|
167
|
+
if (data['orgId'] !== claims.orgId || data['uid'] !== claims.uid) {
|
|
168
|
+
return {
|
|
169
|
+
accept: false,
|
|
170
|
+
reason: 'state-superseded'
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (!safeEqual(String((_data_nonceDigest = data['nonceDigest']) != null ? _data_nonceDigest : ''), digest(claims.nonce))) {
|
|
174
|
+
return {
|
|
175
|
+
accept: false,
|
|
176
|
+
reason: 'state-superseded'
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (Number(data['expiresAtMs']) <= input.nowMs) {
|
|
180
|
+
// Nobody can accept an expired record, so removing it destroys nothing
|
|
181
|
+
// a concurrent consume could have used.
|
|
182
|
+
return {
|
|
183
|
+
accept: false,
|
|
184
|
+
reason: 'state-expired',
|
|
185
|
+
remove: true
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
accept: true,
|
|
190
|
+
value: String((_data_redirectUri = data['redirectUri']) != null ? _data_redirectUri : ''),
|
|
191
|
+
remove: true
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
if (result.ok && result.value) return {
|
|
195
|
+
ok: true,
|
|
196
|
+
redirectUri: result.value
|
|
197
|
+
};
|
|
198
|
+
if (result.reason === 'state-superseded') return {
|
|
199
|
+
ok: false,
|
|
200
|
+
refusal: 'state-superseded'
|
|
201
|
+
};
|
|
202
|
+
if (result.reason === 'state-expired') return {
|
|
203
|
+
ok: false,
|
|
204
|
+
refusal: 'state-expired'
|
|
205
|
+
};
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
refusal: 'state-replayed'
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
//# sourceMappingURL=oauth-state.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/mailboxes/oauth-state.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { consumeOnce } from '@aglyn/tenant-data-admin/server/consume-once'\nimport { tokenSigningSecret } from '@aglyn/tenant-data-admin/server/media-signing'\nimport { safeEqual } from '@aglyn/tenant-data-admin/server/safe-equal'\nimport { createHash, createHmac, randomBytes } from 'node:crypto'\n\n/**\n * THE OAUTH `state` FOR A MAILBOX CONNECT (AGL-2978): signed, short-lived,\n * single-use, and bound to one member of one organization.\n *\n * ## Signed\n *\n * `os1.<payload>.<signature>`, the payload `{ o: orgId, u: uid, n: nonce,\n * e: expiry }` and the signature an HMAC under the shared, fail-closed\n * `TOKEN_SIGNING_SECRET` with its own `outreach-oauth-state:` context — the\n * construction every other signed token here uses, and domain-separated from\n * all of them. Google hands the state back verbatim, so a state that verifies\n * names the org and the member it was minted for. The console dispatcher\n * reads exactly that to ask its release gate about the right organization on\n * a redirect that carries no bearer token.\n *\n * ## Single-use\n *\n * A signature proves who minted a state, not that it is fresh. Each connect\n * also writes ONE pending record per member per organization, at\n * `orgs/{orgId}/outreachOAuthStates/{sha256(orgId, uid)}`, holding the\n * SHA-256 of the state's nonce, its expiry and the redirect address the code\n * was issued for. Finishing a connect consumes that record in a transaction\n * (`consumeOnce`), so a second use of the same state finds nothing, and\n * starting another connect replaces the record, retiring every earlier state.\n *\n * ## Under the organization\n *\n * The record is the organization's, so it lives in the organization's tree:\n * the workspace erasure's recursive delete and the workspace export's walk\n * reach it with no sweep of their own, and nothing outside this plugin names\n * the collection. No Firestore rule names it either, and the org block has no\n * catch-all, so every client is refused it — a client that could write one\n * could re-arm a consumed state, and one that could read one would learn\n * which members have a connect open.\n *\n * ## Derived, not stored\n *\n * The PKCE verifier and the OpenID `nonce` are HMACs of the state's nonce\n * under the same secret, each with its own context. Nothing secret is stored\n * for them, and only this server can recompute either — a state copied off a\n * redirect is not enough to redeem its code.\n */\n\nexport const OUTREACH_OAUTH_STATES_COLLECTION = 'outreachOAuthStates'\n\n/** How long a rep has to finish Google's consent screen. */\nexport const OUTREACH_OAUTH_STATE_TTL_MS = 10 * 60 * 1000\n\nconst STATE_VERSION = 'os1'\nconst STATE_CONTEXT = 'outreach-oauth-state:v1'\nconst PKCE_CONTEXT = 'outreach-oauth-pkce:v1'\nconst OIDC_NONCE_CONTEXT = 'outreach-oauth-oidc-nonce:v1'\nconst STATE_MAX_CHARS = 1024\n\nexport interface OutreachOAuthStateClaims {\n orgId: string\n uid: string\n nonce: string\n /** Expiry, epoch ms. */\n exp: number\n}\n\ninterface WireClaims {\n o: string\n u: string\n n: string\n e: number\n}\n\nconst hmac = (context: string, value: string): string =>\n createHmac('sha256', tokenSigningSecret()).update(`${context}:${value}`).digest('base64url')\n\n/**\n * Mints a state for a member's connect. Throws when `TOKEN_SIGNING_SECRET` is\n * not configured; the route answers that as a deployment that cannot connect.\n */\nexport function mintOutreachOAuthState(input: {\n orgId: string\n uid: string\n nowMs: number\n /** Test seam; a random 32 bytes otherwise. */\n nonce?: string\n}): { state: string; claims: OutreachOAuthStateClaims } {\n const claims: OutreachOAuthStateClaims = {\n orgId: input.orgId,\n uid: input.uid,\n nonce: input.nonce ?? randomBytes(32).toString('base64url'),\n exp: input.nowMs + OUTREACH_OAUTH_STATE_TTL_MS,\n }\n const wire: WireClaims = { o: claims.orgId, u: claims.uid, n: claims.nonce, e: claims.exp }\n const payload = Buffer.from(JSON.stringify(wire), 'utf8').toString('base64url')\n return { state: `${STATE_VERSION}.${payload}.${hmac(STATE_CONTEXT, payload)}`, claims }\n}\n\nexport type OutreachOAuthStateRead =\n | { ok: true; claims: OutreachOAuthStateClaims }\n /** Authentic and past its expiry — the claims can still say whose it was. */\n | { ok: false; refusal: 'state-expired'; claims: OutreachOAuthStateClaims }\n /** Tampered, truncated, another version, or no secret to check it with. */\n | { ok: false; refusal: 'state-invalid' }\n\n/** Verifies a state's signature and expiry. Never throws. */\nexport function readOutreachOAuthState(state: unknown, nowMs: number): OutreachOAuthStateRead {\n const invalid = { ok: false, refusal: 'state-invalid' } as const\n if (typeof state !== 'string' || !state || state.length > STATE_MAX_CHARS) return invalid\n const parts = state.split('.')\n if (parts.length !== 3 || parts[0] !== STATE_VERSION) return invalid\n const [, payload, signature] = parts\n let expected: string\n try {\n expected = hmac(STATE_CONTEXT, payload)\n } catch {\n return invalid\n }\n if (!safeEqual(signature, expected)) return invalid\n let wire: Partial<WireClaims>\n try {\n wire = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<WireClaims>\n } catch {\n return invalid\n }\n const claims: OutreachOAuthStateClaims = {\n orgId: typeof wire?.o === 'string' ? wire.o : '',\n uid: typeof wire?.u === 'string' ? wire.u : '',\n nonce: typeof wire?.n === 'string' ? wire.n : '',\n exp: Number(wire?.e),\n }\n if (!claims.orgId || !claims.uid || !claims.nonce || !Number.isFinite(claims.exp)) return invalid\n if (claims.exp <= nowMs) return { ok: false, refusal: 'state-expired', claims }\n return { ok: true, claims }\n}\n\n/** The PKCE `code_verifier` for a state's nonce: 43 base64url characters. */\nexport function outreachPkceVerifier(nonce: string): string {\n return hmac(PKCE_CONTEXT, nonce)\n}\n\n/** The OpenID `nonce` Google must echo in the ID token for this connect. */\nexport function outreachOidcNonce(nonce: string): string {\n return hmac(OIDC_NONCE_CONTEXT, nonce)\n}\n\n/** The pending record's id: one per member per organization. */\nexport function outreachOAuthStateDocId(orgId: string, uid: string): string {\n return createHash('sha256').update(`${orgId}\\n${uid}`).digest('hex')\n}\n\n/** `orgs/{orgId}/outreachOAuthStates/{id}` for a member's pending connect. */\nexport function outreachOAuthStateRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n uid: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(OUTREACH_OAUTH_STATES_COLLECTION)\n .doc(outreachOAuthStateDocId(orgId, uid))\n}\n\nconst digest = (nonce: string) => createHash('sha256').update(nonce).digest('base64url')\n\n/** `orgs/{orgId}/outreachOAuthStates/{id}` — see the module comment. */\nexport interface OutreachOAuthStateRecord {\n orgId: string\n uid: string\n /** SHA-256 of the state's nonce; the nonce itself is never stored. */\n nonceDigest: string\n /** The redirect address the authorization code was issued for. */\n redirectUri: string\n expiresAtMs: number\n createdAtMs: number\n}\n\n/**\n * Writes the pending record for a freshly minted state, replacing any earlier\n * one for the same member and organization.\n */\nexport async function recordOutreachOAuthState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: OutreachOAuthStateClaims; redirectUri: string; nowMs: number },\n): Promise<void> {\n const { claims } = input\n const record: OutreachOAuthStateRecord = {\n orgId: claims.orgId,\n uid: claims.uid,\n nonceDigest: digest(claims.nonce),\n redirectUri: input.redirectUri,\n expiresAtMs: claims.exp,\n createdAtMs: input.nowMs,\n }\n await outreachOAuthStateRef(firestore, claims.orgId, claims.uid).set(record)\n}\n\nexport type OutreachOAuthStateConsumed =\n | { ok: true; redirectUri: string }\n /** No pending record: this state was already used, or never recorded. */\n | { ok: false; refusal: 'state-replayed' }\n /** A later connect by the same member replaced this state. */\n | { ok: false; refusal: 'state-superseded' }\n | { ok: false; refusal: 'state-expired' }\n\n/**\n * Consumes the pending record a verified state names — once. A superseded\n * state leaves the newer record standing for the connect that owns it.\n */\nexport async function consumeOutreachOAuthState(\n firestore: FirebaseFirestore.Firestore,\n input: { claims: OutreachOAuthStateClaims; nowMs: number },\n): Promise<OutreachOAuthStateConsumed> {\n const { claims } = input\n const ref = outreachOAuthStateRef(firestore, claims.orgId, claims.uid)\n const result = await consumeOnce<string>(firestore, ref, (data) => {\n if (data['orgId'] !== claims.orgId || data['uid'] !== claims.uid) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (!safeEqual(String(data['nonceDigest'] ?? ''), digest(claims.nonce))) {\n return { accept: false, reason: 'state-superseded' }\n }\n if (Number(data['expiresAtMs']) <= input.nowMs) {\n // Nobody can accept an expired record, so removing it destroys nothing\n // a concurrent consume could have used.\n return { accept: false, reason: 'state-expired', remove: true }\n }\n return { accept: true, value: String(data['redirectUri'] ?? ''), remove: true }\n })\n if (result.ok && result.value) return { ok: true, redirectUri: result.value }\n if (result.reason === 'state-superseded') return { ok: false, refusal: 'state-superseded' }\n if (result.reason === 'state-expired') return { ok: false, refusal: 'state-expired' }\n return { ok: false, refusal: 'state-replayed' }\n}\n"],"names":["consumeOnce","tokenSigningSecret","safeEqual","createHash","createHmac","randomBytes","OUTREACH_OAUTH_STATES_COLLECTION","OUTREACH_OAUTH_STATE_TTL_MS","STATE_VERSION","STATE_CONTEXT","PKCE_CONTEXT","OIDC_NONCE_CONTEXT","STATE_MAX_CHARS","hmac","context","value","update","digest","mintOutreachOAuthState","input","claims","orgId","uid","nonce","toString","exp","nowMs","wire","o","u","n","e","payload","Buffer","from","JSON","stringify","state","readOutreachOAuthState","invalid","ok","refusal","length","parts","split","signature","expected","parse","Number","isFinite","outreachPkceVerifier","outreachOidcNonce","outreachOAuthStateDocId","outreachOAuthStateRef","firestore","collection","doc","recordOutreachOAuthState","record","nonceDigest","redirectUri","expiresAtMs","createdAtMs","set","consumeOutreachOAuthState","ref","result","data","accept","reason","String","remove"],"mappings":"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"}
|