@remit/mailbox-service 0.0.82 → 0.0.83
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/package.json +5 -1
- package/src/account-credentials.ts +109 -27
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/mailbox-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.83",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"types": "src/index.ts",
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
"./account-credentials": {
|
|
17
17
|
"types": "./src/account-credentials.ts",
|
|
18
18
|
"default": "./src/account-credentials.ts"
|
|
19
|
+
},
|
|
20
|
+
"./types": {
|
|
21
|
+
"types": "./src/types.ts",
|
|
22
|
+
"default": "./src/types.ts"
|
|
19
23
|
}
|
|
20
24
|
},
|
|
21
25
|
"scripts": {
|
|
@@ -19,6 +19,83 @@ import {
|
|
|
19
19
|
} from "@remit/secrets-service";
|
|
20
20
|
import type { MailCredentials } from "./types.js";
|
|
21
21
|
|
|
22
|
+
/** ConnectionState is a const object (not a TS enum); this is its value type. */
|
|
23
|
+
export type ConnectionStateValue =
|
|
24
|
+
(typeof ConnectionState)[keyof typeof ConnectionState];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The fields that say whether an account holds a credential at all. Narrower
|
|
28
|
+
* than AccountItem so a caller holding only an account summary can ask.
|
|
29
|
+
*/
|
|
30
|
+
export type StoredCredentialFields = Partial<
|
|
31
|
+
Pick<AccountItem, "authType" | "passwordHash" | "oauthRefreshTokenHash">
|
|
32
|
+
>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* No credential of the kind this account's authType needs is stored. An
|
|
36
|
+
* imported account holds none by design until its owner types one, so this is
|
|
37
|
+
* a normal state of the world rather than a fault: no retry can change it, and
|
|
38
|
+
* it is distinct from a credential the mail server rejected.
|
|
39
|
+
*
|
|
40
|
+
* `terminalState` is the connectionState that describes the account while it
|
|
41
|
+
* stays this way, decided here so nothing downstream branches on authType.
|
|
42
|
+
*/
|
|
43
|
+
export interface MissingCredential {
|
|
44
|
+
readonly status: "missing";
|
|
45
|
+
readonly terminalState: ConnectionStateValue;
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type CredentialResolution =
|
|
50
|
+
| { readonly status: "resolved"; readonly credentials: MailCredentials }
|
|
51
|
+
| MissingCredential;
|
|
52
|
+
|
|
53
|
+
type StoredCredential =
|
|
54
|
+
| { readonly status: "password"; readonly passwordHash: string }
|
|
55
|
+
| { readonly status: "oauthMicrosoft"; readonly refreshTokenHash: string }
|
|
56
|
+
| MissingCredential;
|
|
57
|
+
|
|
58
|
+
const readStoredCredential = (
|
|
59
|
+
account: StoredCredentialFields,
|
|
60
|
+
): StoredCredential => {
|
|
61
|
+
const authType = account.authType ?? AccountAuthType.Password;
|
|
62
|
+
|
|
63
|
+
if (authType === AccountAuthType.OauthMicrosoft) {
|
|
64
|
+
if (!account.oauthRefreshTokenHash) {
|
|
65
|
+
return {
|
|
66
|
+
status: "missing",
|
|
67
|
+
terminalState: ConnectionState.ReauthRequired,
|
|
68
|
+
reason: "authType=oauthMicrosoft but no oauthRefreshTokenHash",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
status: "oauthMicrosoft",
|
|
73
|
+
refreshTokenHash: account.oauthRefreshTokenHash,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!account.passwordHash) {
|
|
78
|
+
return {
|
|
79
|
+
status: "missing",
|
|
80
|
+
terminalState: ConnectionState.CredentialsMissing,
|
|
81
|
+
reason: "authType=password but no passwordHash",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { status: "password", passwordHash: account.passwordHash };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Whether the account holds the credential its authType requires.
|
|
89
|
+
*
|
|
90
|
+
* Keyed on the stored credential, never on `connectionState`: PATCH
|
|
91
|
+
* /accounts/{id} writes a password without clearing `credentials_missing` —
|
|
92
|
+
* only a successful connect clears it, via markAuthenticated — so a
|
|
93
|
+
* state-based check would go on skipping an account that can sync perfectly
|
|
94
|
+
* well.
|
|
95
|
+
*/
|
|
96
|
+
export const hasStoredCredential = (account: StoredCredentialFields): boolean =>
|
|
97
|
+
readStoredCredential(account).status !== "missing";
|
|
98
|
+
|
|
22
99
|
export interface AccountCredentialsDeps {
|
|
23
100
|
secrets: Pick<SecretsService, "decrypt" | "encrypt">;
|
|
24
101
|
tokenService: Pick<MailOAuthService, "getAccessToken">;
|
|
@@ -42,6 +119,9 @@ export interface AccountCredentialsDeps {
|
|
|
42
119
|
* tokenService. If the provider rotated the refresh token, persist the
|
|
43
120
|
* new token via `deps.persistRotatedToken` BEFORE returning credentials.
|
|
44
121
|
*
|
|
122
|
+
* Returns `{ status: "missing" }` when the account stores no credential —
|
|
123
|
+
* an expected outcome every caller settles terminally, never retries.
|
|
124
|
+
*
|
|
45
125
|
* Throws `RefreshTokenError` on OAuth failures; callers should handle:
|
|
46
126
|
* - `kind === "reauth-required"` → set connectionState to reauth_required, ACK
|
|
47
127
|
* - `kind === "transient"` → rethrow (SQS retry)
|
|
@@ -50,49 +130,55 @@ export interface AccountCredentialsDeps {
|
|
|
50
130
|
export const resolveConnectionCredentials = async (
|
|
51
131
|
account: AccountItem,
|
|
52
132
|
deps: AccountCredentialsDeps,
|
|
53
|
-
): Promise<
|
|
54
|
-
const
|
|
133
|
+
): Promise<CredentialResolution> => {
|
|
134
|
+
const stored = readStoredCredential(account);
|
|
55
135
|
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
136
|
+
if (stored.status === "missing") {
|
|
137
|
+
return stored;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (stored.status === "oauthMicrosoft") {
|
|
141
|
+
return {
|
|
142
|
+
status: "resolved",
|
|
143
|
+
credentials: await resolveOauthCredentials(
|
|
144
|
+
account.accountId,
|
|
145
|
+
stored.refreshTokenHash,
|
|
146
|
+
deps,
|
|
147
|
+
),
|
|
148
|
+
};
|
|
58
149
|
}
|
|
59
150
|
|
|
60
|
-
|
|
61
|
-
|
|
151
|
+
return {
|
|
152
|
+
status: "resolved",
|
|
153
|
+
credentials: await resolvePasswordCredentials(
|
|
154
|
+
stored.passwordHash,
|
|
155
|
+
deps.secrets,
|
|
156
|
+
),
|
|
157
|
+
};
|
|
62
158
|
};
|
|
63
159
|
|
|
64
160
|
const resolvePasswordCredentials = async (
|
|
65
|
-
|
|
161
|
+
passwordHash: string,
|
|
66
162
|
secrets: Pick<SecretsService, "decrypt">,
|
|
67
163
|
): Promise<MailCredentials> => {
|
|
68
|
-
if (!account.passwordHash) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`Account ${account.accountId} has authType=password but no passwordHash`,
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
164
|
const password = await secrets.decrypt(
|
|
74
|
-
deserializeEncryptedPayload(JSON.parse(
|
|
165
|
+
deserializeEncryptedPayload(JSON.parse(passwordHash)),
|
|
75
166
|
);
|
|
76
167
|
return { kind: "password", password };
|
|
77
168
|
};
|
|
78
169
|
|
|
79
170
|
const resolveOauthCredentials = async (
|
|
80
|
-
|
|
171
|
+
accountId: string,
|
|
172
|
+
refreshTokenHash: string,
|
|
81
173
|
deps: AccountCredentialsDeps,
|
|
82
174
|
): Promise<MailCredentials> => {
|
|
83
|
-
if (!account.oauthRefreshTokenHash) {
|
|
84
|
-
throw new Error(
|
|
85
|
-
`Account ${account.accountId} has authType=oauthMicrosoft but no oauthRefreshTokenHash`,
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
175
|
const refreshToken = await deps.secrets.decrypt(
|
|
90
|
-
deserializeEncryptedPayload(JSON.parse(
|
|
176
|
+
deserializeEncryptedPayload(JSON.parse(refreshTokenHash)),
|
|
91
177
|
);
|
|
92
178
|
|
|
93
179
|
// getAccessToken throws RefreshTokenError on failure
|
|
94
180
|
const tokenSet = await deps.tokenService.getAccessToken(
|
|
95
|
-
|
|
181
|
+
accountId,
|
|
96
182
|
refreshToken,
|
|
97
183
|
);
|
|
98
184
|
|
|
@@ -103,11 +189,7 @@ const resolveOauthCredentials = async (
|
|
|
103
189
|
const encryptedHash = JSON.stringify(
|
|
104
190
|
serializeEncryptedPayload(encryptedPayload),
|
|
105
191
|
);
|
|
106
|
-
await deps.persistRotatedToken(
|
|
107
|
-
account.accountId,
|
|
108
|
-
encryptedHash,
|
|
109
|
-
Date.now(),
|
|
110
|
-
);
|
|
192
|
+
await deps.persistRotatedToken(accountId, encryptedHash, Date.now());
|
|
111
193
|
}
|
|
112
194
|
|
|
113
195
|
return { kind: "accessToken", accessToken: tokenSet.accessToken };
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
export {
|
|
8
8
|
type AccountCredentialsDeps,
|
|
9
|
+
type ConnectionStateValue,
|
|
10
|
+
type CredentialResolution,
|
|
9
11
|
encryptRefreshToken,
|
|
12
|
+
hasStoredCredential,
|
|
13
|
+
type MissingCredential,
|
|
10
14
|
resolveConnectionCredentials,
|
|
15
|
+
type StoredCredentialFields,
|
|
11
16
|
} from "./account-credentials.js";
|
|
12
17
|
export {
|
|
13
18
|
hasChildren,
|