@djangocfg/api 2.1.448 → 2.1.450
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/dist/auth-server.cjs +1 -2272
- package/dist/auth-server.cjs.map +1 -1
- package/dist/auth-server.d.cts +1 -35
- package/dist/auth-server.d.ts +1 -35
- package/dist/auth-server.mjs +1 -2272
- package/dist/auth-server.mjs.map +1 -1
- package/dist/auth.cjs +285 -146
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +137 -2
- package/dist/auth.d.ts +137 -2
- package/dist/auth.mjs +294 -155
- package/dist/auth.mjs.map +1 -1
- package/dist/clients.cjs +35 -0
- package/dist/clients.cjs.map +1 -1
- package/dist/clients.d.cts +147 -2
- package/dist/clients.d.ts +147 -2
- package/dist/clients.mjs +35 -0
- package/dist/clients.mjs.map +1 -1
- package/dist/hooks.cjs +11 -0
- package/dist/hooks.cjs.map +1 -1
- package/dist/hooks.mjs +11 -0
- package/dist/hooks.mjs.map +1 -1
- package/dist/index.cjs +35 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +169 -6
- package/dist/index.d.ts +169 -6
- package/dist/index.mjs +35 -0
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -4
- package/src/_api/generated/_cfg_accounts/api.ts +8 -0
- package/src/_api/generated/_cfg_accounts/openapi.json +84 -5
- package/src/_api/generated/_cfg_accounts/schemas/OAuthTokenResponse.ts +2 -2
- package/src/_api/generated/_cfg_accounts/schemas/OTPRequestResponse.ts +2 -0
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLink.ts +15 -0
- package/src/_api/generated/_cfg_accounts/schemas/WebmailLinkProviderEnum.ts +9 -0
- package/src/_api/generated/_cfg_accounts/schemas/index.ts +2 -0
- package/src/_api/generated/_cfg_centrifugo/api.ts +8 -0
- package/src/_api/generated/_cfg_totp/api.ts +8 -0
- package/src/_api/generated/helpers/auth.ts +12 -0
- package/src/_api/generated/openapi.json +84 -5
- package/src/_api/generated/types.gen.ts +131 -2
- package/src/auth/__tests__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -0
- package/src/auth/__tests__/refreshRotation.test.ts +119 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +76 -0
- package/src/auth/context/AuthContext.tsx +131 -14
- package/src/auth/hooks/useAuthForm.ts +5 -2
- package/src/auth/hooks/useAuthFormState.ts +4 -1
- package/src/auth/hooks/useTokenRefresh.ts +28 -62
- package/src/auth/middlewares/index.ts +7 -7
- package/src/auth/refreshHandler.ts +79 -0
- package/src/auth/types/form.ts +10 -0
- package/src/auth/types/index.ts +1 -0
- package/src/auth/utils/jwt.ts +66 -0
- package/src/auth/middlewares/tokenRefresh.ts +0 -161
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.450",
|
|
4
4
|
"description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"django",
|
|
@@ -71,7 +71,9 @@
|
|
|
71
71
|
"build": "tsup",
|
|
72
72
|
"clean": "rm -rf dist",
|
|
73
73
|
"dev": "tsup --watch",
|
|
74
|
-
"check": "tsc --noEmit"
|
|
74
|
+
"check": "tsc --noEmit",
|
|
75
|
+
"test": "vitest run",
|
|
76
|
+
"test:watch": "vitest"
|
|
75
77
|
},
|
|
76
78
|
"peerDependencies": {
|
|
77
79
|
"consola": "^3.4.2",
|
|
@@ -84,10 +86,11 @@
|
|
|
84
86
|
"devDependencies": {
|
|
85
87
|
"@types/node": "^25.2.3",
|
|
86
88
|
"@types/react": "^19.2.15",
|
|
87
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
89
|
+
"@djangocfg/typescript-config": "^2.1.450",
|
|
88
90
|
"next": "^16.2.2",
|
|
89
91
|
"react": "^19.2.4",
|
|
90
92
|
"tsup": "^8.5.0",
|
|
91
|
-
"typescript": "^5.9.3"
|
|
93
|
+
"typescript": "^5.9.3",
|
|
94
|
+
"vitest": "^3.1.4"
|
|
92
95
|
}
|
|
93
96
|
}
|
|
@@ -81,6 +81,14 @@ export class API {
|
|
|
81
81
|
): void {
|
|
82
82
|
auth.setRefreshHandler(fn);
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Proactively refresh now via the registered handler, sharing the single
|
|
86
|
+
* 401-recovery flight (rotation + dedup + rotated-token persistence).
|
|
87
|
+
* See `auth.refreshNow`.
|
|
88
|
+
*/
|
|
89
|
+
refreshNow(): Promise<string | null> {
|
|
90
|
+
return auth.refreshNow();
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
export { CfgAccountsApiKey, CfgAccountsOauth, CfgAccounts, CfgAccountsProfile, CfgAccountsAuth };
|
|
@@ -1505,21 +1505,19 @@
|
|
|
1505
1505
|
},
|
|
1506
1506
|
"is_new_user": {
|
|
1507
1507
|
"type": "boolean",
|
|
1508
|
+
"default": false,
|
|
1508
1509
|
"description": "True if a new user was created during this OAuth flow"
|
|
1509
1510
|
},
|
|
1510
1511
|
"is_new_connection": {
|
|
1511
1512
|
"type": "boolean",
|
|
1513
|
+
"default": false,
|
|
1512
1514
|
"description": "True if a new OAuth connection was created"
|
|
1513
1515
|
},
|
|
1514
1516
|
"should_prompt_2fa": {
|
|
1515
1517
|
"type": "boolean",
|
|
1516
1518
|
"description": "True if user should be prompted to enable 2FA"
|
|
1517
1519
|
}
|
|
1518
|
-
}
|
|
1519
|
-
"required": [
|
|
1520
|
-
"is_new_connection",
|
|
1521
|
-
"is_new_user"
|
|
1522
|
-
]
|
|
1520
|
+
}
|
|
1523
1521
|
},
|
|
1524
1522
|
"OTPErrorResponse": {
|
|
1525
1523
|
"type": "object",
|
|
@@ -1574,6 +1572,17 @@
|
|
|
1574
1572
|
"message": {
|
|
1575
1573
|
"type": "string",
|
|
1576
1574
|
"description": "Success message"
|
|
1575
|
+
},
|
|
1576
|
+
"webmail": {
|
|
1577
|
+
"oneOf": [
|
|
1578
|
+
{
|
|
1579
|
+
"$ref": "#/components/schemas/WebmailLink"
|
|
1580
|
+
},
|
|
1581
|
+
{
|
|
1582
|
+
"type": "null"
|
|
1583
|
+
}
|
|
1584
|
+
],
|
|
1585
|
+
"description": "Webmail deep-link for the recipient's provider, or null if the domain is unknown."
|
|
1577
1586
|
}
|
|
1578
1587
|
},
|
|
1579
1588
|
"required": [
|
|
@@ -1865,6 +1874,76 @@
|
|
|
1865
1874
|
"last_login",
|
|
1866
1875
|
"unanswered_messages_count"
|
|
1867
1876
|
]
|
|
1877
|
+
},
|
|
1878
|
+
"WebmailLink": {
|
|
1879
|
+
"type": "object",
|
|
1880
|
+
"description": "Deep-link that opens the user's webmail on a search for our login email.\n\nPresent only when the recipient's email domain maps to a known webmail\nprovider (Gmail, Outlook, Mail.ru, Yandex, iCloud, …). For unknown/corporate\ndomains this whole object is null and the frontend shows no button.",
|
|
1881
|
+
"properties": {
|
|
1882
|
+
"provider": {
|
|
1883
|
+
"allOf": [
|
|
1884
|
+
{
|
|
1885
|
+
"$ref": "#/components/schemas/WebmailLinkProviderEnum"
|
|
1886
|
+
}
|
|
1887
|
+
],
|
|
1888
|
+
"description": "Provider slug (e.g. 'gmail', 'outlook', 'mail_ru'). Also selects the brand icon on the frontend.\n\n* `gmail` - gmail\n* `outlook` - outlook\n* `yahoo` - yahoo\n* `icloud` - icloud\n* `proton` - proton\n* `zoho` - zoho\n* `aol` - aol\n* `fastmail` - fastmail\n* `gmx` - gmx\n* `mailcom` - mailcom\n* `mail_ru` - mail_ru\n* `yandex` - yandex\n* `rambler` - rambler\n* `qq` - qq\n* `netease` - netease\n* `sina` - sina\n* `aliyun` - aliyun\n* `naver` - naver\n* `daum` - daum\n* `web_de` - web_de\n* `tonline` - tonline\n* `seznam` - seznam\n* `wp_pl` - wp_pl\n* `o2_pl` - o2_pl\n* `interia` - interia\n* `libero` - libero\n* `virgilio` - virgilio\n* `orange` - orange\n* `laposte` - laposte\n* `free_fr` - free_fr\n* `sfr` - sfr"
|
|
1889
|
+
},
|
|
1890
|
+
"provider_name": {
|
|
1891
|
+
"type": "string",
|
|
1892
|
+
"description": "Human-facing provider name, e.g. 'Gmail'."
|
|
1893
|
+
},
|
|
1894
|
+
"url": {
|
|
1895
|
+
"type": "string",
|
|
1896
|
+
"format": "uri",
|
|
1897
|
+
"description": "Absolute URL to open in a new tab (a sender-filtered search, or the inbox as fallback)."
|
|
1898
|
+
},
|
|
1899
|
+
"is_search": {
|
|
1900
|
+
"type": "boolean",
|
|
1901
|
+
"description": "True if the URL opens a search filtered to our sender; False if it only opens the inbox."
|
|
1902
|
+
}
|
|
1903
|
+
},
|
|
1904
|
+
"required": [
|
|
1905
|
+
"is_search",
|
|
1906
|
+
"provider",
|
|
1907
|
+
"provider_name",
|
|
1908
|
+
"url"
|
|
1909
|
+
]
|
|
1910
|
+
},
|
|
1911
|
+
"WebmailLinkProviderEnum": {
|
|
1912
|
+
"enum": [
|
|
1913
|
+
"gmail",
|
|
1914
|
+
"outlook",
|
|
1915
|
+
"yahoo",
|
|
1916
|
+
"icloud",
|
|
1917
|
+
"proton",
|
|
1918
|
+
"zoho",
|
|
1919
|
+
"aol",
|
|
1920
|
+
"fastmail",
|
|
1921
|
+
"gmx",
|
|
1922
|
+
"mailcom",
|
|
1923
|
+
"mail_ru",
|
|
1924
|
+
"yandex",
|
|
1925
|
+
"rambler",
|
|
1926
|
+
"qq",
|
|
1927
|
+
"netease",
|
|
1928
|
+
"sina",
|
|
1929
|
+
"aliyun",
|
|
1930
|
+
"naver",
|
|
1931
|
+
"daum",
|
|
1932
|
+
"web_de",
|
|
1933
|
+
"tonline",
|
|
1934
|
+
"seznam",
|
|
1935
|
+
"wp_pl",
|
|
1936
|
+
"o2_pl",
|
|
1937
|
+
"interia",
|
|
1938
|
+
"libero",
|
|
1939
|
+
"virgilio",
|
|
1940
|
+
"orange",
|
|
1941
|
+
"laposte",
|
|
1942
|
+
"free_fr",
|
|
1943
|
+
"sfr"
|
|
1944
|
+
],
|
|
1945
|
+
"type": "string",
|
|
1946
|
+
"description": "* `gmail` - gmail\n* `outlook` - outlook\n* `yahoo` - yahoo\n* `icloud` - icloud\n* `proton` - proton\n* `zoho` - zoho\n* `aol` - aol\n* `fastmail` - fastmail\n* `gmx` - gmx\n* `mailcom` - mailcom\n* `mail_ru` - mail_ru\n* `yandex` - yandex\n* `rambler` - rambler\n* `qq` - qq\n* `netease` - netease\n* `sina` - sina\n* `aliyun` - aliyun\n* `naver` - naver\n* `daum` - daum\n* `web_de` - web_de\n* `tonline` - tonline\n* `seznam` - seznam\n* `wp_pl` - wp_pl\n* `o2_pl` - o2_pl\n* `interia` - interia\n* `libero` - libero\n* `virgilio` - virgilio\n* `orange` - orange\n* `laposte` - laposte\n* `free_fr` - free_fr\n* `sfr` - sfr"
|
|
1868
1947
|
}
|
|
1869
1948
|
},
|
|
1870
1949
|
"securitySchemes": {
|
|
@@ -10,8 +10,8 @@ export const OAuthTokenResponseSchema = z.object({
|
|
|
10
10
|
access: z.string().nullable().optional(),
|
|
11
11
|
refresh: z.string().nullable().optional(),
|
|
12
12
|
user: z.object({}).passthrough().nullable().optional(),
|
|
13
|
-
is_new_user: z.boolean(),
|
|
14
|
-
is_new_connection: z.boolean(),
|
|
13
|
+
is_new_user: z.boolean().default(false).optional(),
|
|
14
|
+
is_new_connection: z.boolean().default(false).optional(),
|
|
15
15
|
should_prompt_2fa: z.boolean().optional(),
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// DO NOT EDIT — re-run `make gen-clients`.
|
|
4
4
|
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { WebmailLinkSchema } from "./WebmailLink";
|
|
6
7
|
|
|
7
8
|
export const OTPRequestResponseSchema = z.object({
|
|
8
9
|
message: z.string(),
|
|
10
|
+
webmail: WebmailLinkSchema.nullable().optional(),
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
export type OTPRequestResponse = z.infer<typeof OTPRequestResponseSchema>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// AUTO-GENERATED by cmdop_server / devtools.generator.ts_extras
|
|
2
|
+
// Source: OpenAPI 3.1 components.schemas
|
|
3
|
+
// DO NOT EDIT — re-run `make gen-clients`.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { WebmailLinkProviderEnumSchema } from "./WebmailLinkProviderEnum";
|
|
7
|
+
|
|
8
|
+
export const WebmailLinkSchema = z.object({
|
|
9
|
+
provider: WebmailLinkProviderEnumSchema,
|
|
10
|
+
provider_name: z.string(),
|
|
11
|
+
url: z.string(),
|
|
12
|
+
is_search: z.boolean(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type WebmailLink = z.infer<typeof WebmailLinkSchema>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// AUTO-GENERATED by cmdop_server / devtools.generator.ts_extras
|
|
2
|
+
// Source: OpenAPI 3.1 components.schemas
|
|
3
|
+
// DO NOT EDIT — re-run `make gen-clients`.
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
|
|
7
|
+
export const WebmailLinkProviderEnumSchema = z.enum(["gmail", "outlook", "yahoo", "icloud", "proton", "zoho", "aol", "fastmail", "gmx", "mailcom", "mail_ru", "yandex", "rambler", "qq", "netease", "sina", "aliyun", "naver", "daum", "web_de", "tonline", "seznam", "wp_pl", "o2_pl", "interia", "libero", "virgilio", "orange", "laposte", "free_fr", "sfr"]);
|
|
8
|
+
|
|
9
|
+
export type WebmailLinkProviderEnum = z.infer<typeof WebmailLinkProviderEnumSchema>;
|
|
@@ -28,5 +28,7 @@ export * from "./PatchedCfgUserUpdateRequest";
|
|
|
28
28
|
export * from "./TokenRefresh";
|
|
29
29
|
export * from "./TokenRefreshRequest";
|
|
30
30
|
export * from "./User";
|
|
31
|
+
export * from "./WebmailLink";
|
|
32
|
+
export * from "./WebmailLinkProviderEnum";
|
|
31
33
|
export * from "./cfg_accounts_oauth_connections_response_200_AutoRef";
|
|
32
34
|
export * from "./cfg_accounts_oauth_disconnect_response_200_AutoRef";
|
|
@@ -73,6 +73,14 @@ export class API {
|
|
|
73
73
|
): void {
|
|
74
74
|
auth.setRefreshHandler(fn);
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Proactively refresh now via the registered handler, sharing the single
|
|
78
|
+
* 401-recovery flight (rotation + dedup + rotated-token persistence).
|
|
79
|
+
* See `auth.refreshNow`.
|
|
80
|
+
*/
|
|
81
|
+
refreshNow(): Promise<string | null> {
|
|
82
|
+
return auth.refreshNow();
|
|
83
|
+
}
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export { CfgCentrifugo };
|
|
@@ -79,6 +79,14 @@ export class API {
|
|
|
79
79
|
): void {
|
|
80
80
|
auth.setRefreshHandler(fn);
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Proactively refresh now via the registered handler, sharing the single
|
|
84
|
+
* 401-recovery flight (rotation + dedup + rotated-token persistence).
|
|
85
|
+
* See `auth.refreshNow`.
|
|
86
|
+
*/
|
|
87
|
+
refreshNow(): Promise<string | null> {
|
|
88
|
+
return auth.refreshNow();
|
|
89
|
+
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
export { CfgTotpBackupCodes, CfgTotp, CfgTotpSetup, CfgTotpVerify };
|
|
@@ -263,6 +263,18 @@ export const auth = {
|
|
|
263
263
|
setRefreshHandler(fn: RefreshHandler | null): void {
|
|
264
264
|
_refreshHandler = fn;
|
|
265
265
|
},
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Proactively run the registered refresh handler right now, reusing the
|
|
269
|
+
* SAME single-flight promise as the 401-recovery interceptor. Callers
|
|
270
|
+
* (e.g. an expiry timer / focus / reconnect) get token rotation,
|
|
271
|
+
* de-duplication and rotated-token persistence for free — they must NOT
|
|
272
|
+
* re-implement any of it. Returns the fresh access token, or null if
|
|
273
|
+
* there is no handler / no refresh token / the refresh failed.
|
|
274
|
+
*/
|
|
275
|
+
refreshNow(): Promise<string | null> {
|
|
276
|
+
return tryRefresh();
|
|
277
|
+
},
|
|
266
278
|
};
|
|
267
279
|
|
|
268
280
|
/**
|
|
@@ -2200,21 +2200,19 @@
|
|
|
2200
2200
|
},
|
|
2201
2201
|
"is_new_user": {
|
|
2202
2202
|
"type": "boolean",
|
|
2203
|
+
"default": false,
|
|
2203
2204
|
"description": "True if a new user was created during this OAuth flow"
|
|
2204
2205
|
},
|
|
2205
2206
|
"is_new_connection": {
|
|
2206
2207
|
"type": "boolean",
|
|
2208
|
+
"default": false,
|
|
2207
2209
|
"description": "True if a new OAuth connection was created"
|
|
2208
2210
|
},
|
|
2209
2211
|
"should_prompt_2fa": {
|
|
2210
2212
|
"type": "boolean",
|
|
2211
2213
|
"description": "True if user should be prompted to enable 2FA"
|
|
2212
2214
|
}
|
|
2213
|
-
}
|
|
2214
|
-
"required": [
|
|
2215
|
-
"is_new_connection",
|
|
2216
|
-
"is_new_user"
|
|
2217
|
-
]
|
|
2215
|
+
}
|
|
2218
2216
|
},
|
|
2219
2217
|
"OTPErrorResponse": {
|
|
2220
2218
|
"type": "object",
|
|
@@ -2269,6 +2267,17 @@
|
|
|
2269
2267
|
"message": {
|
|
2270
2268
|
"type": "string",
|
|
2271
2269
|
"description": "Success message"
|
|
2270
|
+
},
|
|
2271
|
+
"webmail": {
|
|
2272
|
+
"oneOf": [
|
|
2273
|
+
{
|
|
2274
|
+
"$ref": "#/components/schemas/WebmailLink"
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
"type": "null"
|
|
2278
|
+
}
|
|
2279
|
+
],
|
|
2280
|
+
"description": "Webmail deep-link for the recipient's provider, or null if the domain is unknown."
|
|
2272
2281
|
}
|
|
2273
2282
|
},
|
|
2274
2283
|
"required": [
|
|
@@ -2827,6 +2836,76 @@
|
|
|
2827
2836
|
"user"
|
|
2828
2837
|
]
|
|
2829
2838
|
},
|
|
2839
|
+
"WebmailLink": {
|
|
2840
|
+
"type": "object",
|
|
2841
|
+
"description": "Deep-link that opens the user's webmail on a search for our login email.\n\nPresent only when the recipient's email domain maps to a known webmail\nprovider (Gmail, Outlook, Mail.ru, Yandex, iCloud, …). For unknown/corporate\ndomains this whole object is null and the frontend shows no button.",
|
|
2842
|
+
"properties": {
|
|
2843
|
+
"provider": {
|
|
2844
|
+
"allOf": [
|
|
2845
|
+
{
|
|
2846
|
+
"$ref": "#/components/schemas/WebmailLinkProviderEnum"
|
|
2847
|
+
}
|
|
2848
|
+
],
|
|
2849
|
+
"description": "Provider slug (e.g. 'gmail', 'outlook', 'mail_ru'). Also selects the brand icon on the frontend.\n\n* `gmail` - gmail\n* `outlook` - outlook\n* `yahoo` - yahoo\n* `icloud` - icloud\n* `proton` - proton\n* `zoho` - zoho\n* `aol` - aol\n* `fastmail` - fastmail\n* `gmx` - gmx\n* `mailcom` - mailcom\n* `mail_ru` - mail_ru\n* `yandex` - yandex\n* `rambler` - rambler\n* `qq` - qq\n* `netease` - netease\n* `sina` - sina\n* `aliyun` - aliyun\n* `naver` - naver\n* `daum` - daum\n* `web_de` - web_de\n* `tonline` - tonline\n* `seznam` - seznam\n* `wp_pl` - wp_pl\n* `o2_pl` - o2_pl\n* `interia` - interia\n* `libero` - libero\n* `virgilio` - virgilio\n* `orange` - orange\n* `laposte` - laposte\n* `free_fr` - free_fr\n* `sfr` - sfr"
|
|
2850
|
+
},
|
|
2851
|
+
"provider_name": {
|
|
2852
|
+
"type": "string",
|
|
2853
|
+
"description": "Human-facing provider name, e.g. 'Gmail'."
|
|
2854
|
+
},
|
|
2855
|
+
"url": {
|
|
2856
|
+
"type": "string",
|
|
2857
|
+
"format": "uri",
|
|
2858
|
+
"description": "Absolute URL to open in a new tab (a sender-filtered search, or the inbox as fallback)."
|
|
2859
|
+
},
|
|
2860
|
+
"is_search": {
|
|
2861
|
+
"type": "boolean",
|
|
2862
|
+
"description": "True if the URL opens a search filtered to our sender; False if it only opens the inbox."
|
|
2863
|
+
}
|
|
2864
|
+
},
|
|
2865
|
+
"required": [
|
|
2866
|
+
"is_search",
|
|
2867
|
+
"provider",
|
|
2868
|
+
"provider_name",
|
|
2869
|
+
"url"
|
|
2870
|
+
]
|
|
2871
|
+
},
|
|
2872
|
+
"WebmailLinkProviderEnum": {
|
|
2873
|
+
"enum": [
|
|
2874
|
+
"gmail",
|
|
2875
|
+
"outlook",
|
|
2876
|
+
"yahoo",
|
|
2877
|
+
"icloud",
|
|
2878
|
+
"proton",
|
|
2879
|
+
"zoho",
|
|
2880
|
+
"aol",
|
|
2881
|
+
"fastmail",
|
|
2882
|
+
"gmx",
|
|
2883
|
+
"mailcom",
|
|
2884
|
+
"mail_ru",
|
|
2885
|
+
"yandex",
|
|
2886
|
+
"rambler",
|
|
2887
|
+
"qq",
|
|
2888
|
+
"netease",
|
|
2889
|
+
"sina",
|
|
2890
|
+
"aliyun",
|
|
2891
|
+
"naver",
|
|
2892
|
+
"daum",
|
|
2893
|
+
"web_de",
|
|
2894
|
+
"tonline",
|
|
2895
|
+
"seznam",
|
|
2896
|
+
"wp_pl",
|
|
2897
|
+
"o2_pl",
|
|
2898
|
+
"interia",
|
|
2899
|
+
"libero",
|
|
2900
|
+
"virgilio",
|
|
2901
|
+
"orange",
|
|
2902
|
+
"laposte",
|
|
2903
|
+
"free_fr",
|
|
2904
|
+
"sfr"
|
|
2905
|
+
],
|
|
2906
|
+
"type": "string",
|
|
2907
|
+
"description": "* `gmail` - gmail\n* `outlook` - outlook\n* `yahoo` - yahoo\n* `icloud` - icloud\n* `proton` - proton\n* `zoho` - zoho\n* `aol` - aol\n* `fastmail` - fastmail\n* `gmx` - gmx\n* `mailcom` - mailcom\n* `mail_ru` - mail_ru\n* `yandex` - yandex\n* `rambler` - rambler\n* `qq` - qq\n* `netease` - netease\n* `sina` - sina\n* `aliyun` - aliyun\n* `naver` - naver\n* `daum` - daum\n* `web_de` - web_de\n* `tonline` - tonline\n* `seznam` - seznam\n* `wp_pl` - wp_pl\n* `o2_pl` - o2_pl\n* `interia` - interia\n* `libero` - libero\n* `virgilio` - virgilio\n* `orange` - orange\n* `laposte` - laposte\n* `free_fr` - free_fr\n* `sfr` - sfr"
|
|
2908
|
+
},
|
|
2830
2909
|
"cfg_accounts_oauth_connections_response_200_AutoRef": {
|
|
2831
2910
|
"type": "array",
|
|
2832
2911
|
"items": {
|
|
@@ -469,11 +469,11 @@ export type OAuthTokenResponse = {
|
|
|
469
469
|
/**
|
|
470
470
|
* True if a new user was created during this OAuth flow
|
|
471
471
|
*/
|
|
472
|
-
is_new_user
|
|
472
|
+
is_new_user?: boolean;
|
|
473
473
|
/**
|
|
474
474
|
* True if a new OAuth connection was created
|
|
475
475
|
*/
|
|
476
|
-
is_new_connection
|
|
476
|
+
is_new_connection?: boolean;
|
|
477
477
|
/**
|
|
478
478
|
* True if user should be prompted to enable 2FA
|
|
479
479
|
*/
|
|
@@ -530,6 +530,10 @@ export type OtpRequestResponse = {
|
|
|
530
530
|
* Success message
|
|
531
531
|
*/
|
|
532
532
|
message: string;
|
|
533
|
+
/**
|
|
534
|
+
* Webmail deep-link for the recipient's provider, or null if the domain is unknown.
|
|
535
|
+
*/
|
|
536
|
+
webmail?: WebmailLink | null;
|
|
533
537
|
};
|
|
534
538
|
|
|
535
539
|
/**
|
|
@@ -792,6 +796,131 @@ export type VerifyResponse = {
|
|
|
792
796
|
warning?: string;
|
|
793
797
|
};
|
|
794
798
|
|
|
799
|
+
/**
|
|
800
|
+
* Deep-link that opens the user's webmail on a search for our login email.
|
|
801
|
+
*
|
|
802
|
+
* Present only when the recipient's email domain maps to a known webmail
|
|
803
|
+
* provider (Gmail, Outlook, Mail.ru, Yandex, iCloud, …). For unknown/corporate
|
|
804
|
+
* domains this whole object is null and the frontend shows no button.
|
|
805
|
+
*/
|
|
806
|
+
export type WebmailLink = {
|
|
807
|
+
/**
|
|
808
|
+
* Provider slug (e.g. 'gmail', 'outlook', 'mail_ru'). Also selects the brand icon on the frontend.
|
|
809
|
+
*
|
|
810
|
+
* * `gmail` - gmail
|
|
811
|
+
* * `outlook` - outlook
|
|
812
|
+
* * `yahoo` - yahoo
|
|
813
|
+
* * `icloud` - icloud
|
|
814
|
+
* * `proton` - proton
|
|
815
|
+
* * `zoho` - zoho
|
|
816
|
+
* * `aol` - aol
|
|
817
|
+
* * `fastmail` - fastmail
|
|
818
|
+
* * `gmx` - gmx
|
|
819
|
+
* * `mailcom` - mailcom
|
|
820
|
+
* * `mail_ru` - mail_ru
|
|
821
|
+
* * `yandex` - yandex
|
|
822
|
+
* * `rambler` - rambler
|
|
823
|
+
* * `qq` - qq
|
|
824
|
+
* * `netease` - netease
|
|
825
|
+
* * `sina` - sina
|
|
826
|
+
* * `aliyun` - aliyun
|
|
827
|
+
* * `naver` - naver
|
|
828
|
+
* * `daum` - daum
|
|
829
|
+
* * `web_de` - web_de
|
|
830
|
+
* * `tonline` - tonline
|
|
831
|
+
* * `seznam` - seznam
|
|
832
|
+
* * `wp_pl` - wp_pl
|
|
833
|
+
* * `o2_pl` - o2_pl
|
|
834
|
+
* * `interia` - interia
|
|
835
|
+
* * `libero` - libero
|
|
836
|
+
* * `virgilio` - virgilio
|
|
837
|
+
* * `orange` - orange
|
|
838
|
+
* * `laposte` - laposte
|
|
839
|
+
* * `free_fr` - free_fr
|
|
840
|
+
* * `sfr` - sfr
|
|
841
|
+
*/
|
|
842
|
+
provider: WebmailLinkProviderEnum;
|
|
843
|
+
/**
|
|
844
|
+
* Human-facing provider name, e.g. 'Gmail'.
|
|
845
|
+
*/
|
|
846
|
+
provider_name: string;
|
|
847
|
+
/**
|
|
848
|
+
* Absolute URL to open in a new tab (a sender-filtered search, or the inbox as fallback).
|
|
849
|
+
*/
|
|
850
|
+
url: string;
|
|
851
|
+
/**
|
|
852
|
+
* True if the URL opens a search filtered to our sender; False if it only opens the inbox.
|
|
853
|
+
*/
|
|
854
|
+
is_search: boolean;
|
|
855
|
+
};
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* * `gmail` - gmail
|
|
859
|
+
* * `outlook` - outlook
|
|
860
|
+
* * `yahoo` - yahoo
|
|
861
|
+
* * `icloud` - icloud
|
|
862
|
+
* * `proton` - proton
|
|
863
|
+
* * `zoho` - zoho
|
|
864
|
+
* * `aol` - aol
|
|
865
|
+
* * `fastmail` - fastmail
|
|
866
|
+
* * `gmx` - gmx
|
|
867
|
+
* * `mailcom` - mailcom
|
|
868
|
+
* * `mail_ru` - mail_ru
|
|
869
|
+
* * `yandex` - yandex
|
|
870
|
+
* * `rambler` - rambler
|
|
871
|
+
* * `qq` - qq
|
|
872
|
+
* * `netease` - netease
|
|
873
|
+
* * `sina` - sina
|
|
874
|
+
* * `aliyun` - aliyun
|
|
875
|
+
* * `naver` - naver
|
|
876
|
+
* * `daum` - daum
|
|
877
|
+
* * `web_de` - web_de
|
|
878
|
+
* * `tonline` - tonline
|
|
879
|
+
* * `seznam` - seznam
|
|
880
|
+
* * `wp_pl` - wp_pl
|
|
881
|
+
* * `o2_pl` - o2_pl
|
|
882
|
+
* * `interia` - interia
|
|
883
|
+
* * `libero` - libero
|
|
884
|
+
* * `virgilio` - virgilio
|
|
885
|
+
* * `orange` - orange
|
|
886
|
+
* * `laposte` - laposte
|
|
887
|
+
* * `free_fr` - free_fr
|
|
888
|
+
* * `sfr` - sfr
|
|
889
|
+
*/
|
|
890
|
+
export enum WebmailLinkProviderEnum {
|
|
891
|
+
GMAIL = 'gmail',
|
|
892
|
+
OUTLOOK = 'outlook',
|
|
893
|
+
YAHOO = 'yahoo',
|
|
894
|
+
ICLOUD = 'icloud',
|
|
895
|
+
PROTON = 'proton',
|
|
896
|
+
ZOHO = 'zoho',
|
|
897
|
+
AOL = 'aol',
|
|
898
|
+
FASTMAIL = 'fastmail',
|
|
899
|
+
GMX = 'gmx',
|
|
900
|
+
MAILCOM = 'mailcom',
|
|
901
|
+
MAIL_RU = 'mail_ru',
|
|
902
|
+
YANDEX = 'yandex',
|
|
903
|
+
RAMBLER = 'rambler',
|
|
904
|
+
QQ = 'qq',
|
|
905
|
+
NETEASE = 'netease',
|
|
906
|
+
SINA = 'sina',
|
|
907
|
+
ALIYUN = 'aliyun',
|
|
908
|
+
NAVER = 'naver',
|
|
909
|
+
DAUM = 'daum',
|
|
910
|
+
WEB_DE = 'web_de',
|
|
911
|
+
TONLINE = 'tonline',
|
|
912
|
+
SEZNAM = 'seznam',
|
|
913
|
+
WP_PL = 'wp_pl',
|
|
914
|
+
O2_PL = 'o2_pl',
|
|
915
|
+
INTERIA = 'interia',
|
|
916
|
+
LIBERO = 'libero',
|
|
917
|
+
VIRGILIO = 'virgilio',
|
|
918
|
+
ORANGE = 'orange',
|
|
919
|
+
LAPOSTE = 'laposte',
|
|
920
|
+
FREE_FR = 'free_fr',
|
|
921
|
+
SFR = 'sfr'
|
|
922
|
+
}
|
|
923
|
+
|
|
795
924
|
export type CfgAccountsOauthConnectionsResponse200AutoRef = Array<OAuthConnection>;
|
|
796
925
|
|
|
797
926
|
export type CfgAccountsOauthDisconnectResponse200AutoRef = {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT expiry-helper tests (auth/utils/jwt.ts).
|
|
3
|
+
*
|
|
4
|
+
* These functions decide whether the client should trust a persisted token or
|
|
5
|
+
* treat the session as dead. They must FAIL CLOSED: anything undecodable counts
|
|
6
|
+
* as expired, so garbage in localStorage sends the user to the login form
|
|
7
|
+
* rather than looping on 401s. (This is the automated version of the user's
|
|
8
|
+
* manual "clear localStorage and the form appeared".)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { getTokenExpiry, isTokenExpired, isTokenExpiringSoon } from '../utils/jwt';
|
|
14
|
+
|
|
15
|
+
const NOW = 1_800_000_000_000; // fixed "now" in ms (injected, not Date.now())
|
|
16
|
+
|
|
17
|
+
/** Base64url without padding — mirrors real JWT segment encoding. */
|
|
18
|
+
function b64url(obj: unknown): string {
|
|
19
|
+
const json = JSON.stringify(obj);
|
|
20
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64');
|
|
21
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build a syntactically valid 3-segment JWT with the given payload. */
|
|
25
|
+
function jwt(payload: Record<string, unknown>): string {
|
|
26
|
+
const header = b64url({ alg: 'HS256', typ: 'JWT' });
|
|
27
|
+
const body = b64url(payload);
|
|
28
|
+
return `${header}.${body}.signature`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** exp claim is in SECONDS in a real JWT. */
|
|
32
|
+
const expSeconds = (ms: number) => Math.floor(ms / 1000);
|
|
33
|
+
|
|
34
|
+
describe('getTokenExpiry', () => {
|
|
35
|
+
it('extracts exp (converted to ms) from a well-formed token', () => {
|
|
36
|
+
const token = jwt({ exp: expSeconds(NOW) });
|
|
37
|
+
expect(getTokenExpiry(token)).toBe(expSeconds(NOW) * 1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns null for null / undefined / empty', () => {
|
|
41
|
+
expect(getTokenExpiry(null)).toBeNull();
|
|
42
|
+
expect(getTokenExpiry(undefined)).toBeNull();
|
|
43
|
+
expect(getTokenExpiry('')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null for a non-JWT string (wrong segment count)', () => {
|
|
47
|
+
expect(getTokenExpiry('not-a-jwt')).toBeNull();
|
|
48
|
+
expect(getTokenExpiry('only.two')).toBeNull();
|
|
49
|
+
expect(getTokenExpiry('a.b.c.d')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns null when the payload is not valid base64/JSON', () => {
|
|
53
|
+
expect(getTokenExpiry('aaa.!!!!.bbb')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns null when exp is missing or non-numeric', () => {
|
|
57
|
+
expect(getTokenExpiry(jwt({ sub: 'user' }))).toBeNull();
|
|
58
|
+
expect(getTokenExpiry(jwt({ exp: 'soon' }))).toBeNull();
|
|
59
|
+
expect(getTokenExpiry(jwt({ exp: null }))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles a non-object payload (e.g. a JSON number) as no-exp', () => {
|
|
63
|
+
const header = b64url({ alg: 'HS256' });
|
|
64
|
+
const body = b64url(42 as unknown as Record<string, unknown>);
|
|
65
|
+
expect(getTokenExpiry(`${header}.${body}.sig`)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('isTokenExpired (fail-closed)', () => {
|
|
70
|
+
it('false for a token that expires in the future', () => {
|
|
71
|
+
const token = jwt({ exp: expSeconds(NOW + 60_000) });
|
|
72
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('true for a token already past exp', () => {
|
|
76
|
+
const token = jwt({ exp: expSeconds(NOW - 1) });
|
|
77
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('true exactly at exp (boundary is inclusive → expired)', () => {
|
|
81
|
+
const token = jwt({ exp: expSeconds(NOW) });
|
|
82
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('skew treats a soon-to-expire token as already expired', () => {
|
|
86
|
+
const token = jwt({ exp: expSeconds(NOW + 30_000) }); // expires in 30s
|
|
87
|
+
expect(isTokenExpired(token, 60_000, NOW)).toBe(true); // 60s skew
|
|
88
|
+
expect(isTokenExpired(token, 10_000, NOW)).toBe(false); // 10s skew
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('true (fail-closed) for null / garbage / no-exp tokens', () => {
|
|
92
|
+
expect(isTokenExpired(null, 0, NOW)).toBe(true);
|
|
93
|
+
expect(isTokenExpired(undefined, 0, NOW)).toBe(true);
|
|
94
|
+
expect(isTokenExpired('garbage', 0, NOW)).toBe(true);
|
|
95
|
+
expect(isTokenExpired(jwt({ sub: 'x' }), 0, NOW)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('isTokenExpiringSoon', () => {
|
|
100
|
+
it('true when expiry is within the threshold', () => {
|
|
101
|
+
const token = jwt({ exp: expSeconds(NOW + 5 * 60_000) }); // 5 min
|
|
102
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('false when expiry is beyond the threshold', () => {
|
|
106
|
+
const token = jwt({ exp: expSeconds(NOW + 20 * 60_000) }); // 20 min
|
|
107
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('false (not "soon") for undecodable tokens — the expired path owns those', () => {
|
|
111
|
+
expect(isTokenExpiringSoon(null, 10 * 60_000, NOW)).toBe(false);
|
|
112
|
+
expect(isTokenExpiringSoon('garbage', 10 * 60_000, NOW)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('true for an already-expired but decodable token', () => {
|
|
116
|
+
const token = jwt({ exp: expSeconds(NOW - 1000) });
|
|
117
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|