@hraness/suite-accounts 0.9.20
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 +21 -0
- package/README.md +496 -0
- package/compiler-foundation.css +1 -0
- package/dist/auth-client.js +16 -0
- package/dist/auth-proxy.js +757 -0
- package/dist/bearer-verifier.js +1119 -0
- package/dist/browser-session.js +208 -0
- package/dist/client-configuration.js +470 -0
- package/dist/convex-browser-auth-browser.js +923 -0
- package/dist/convex-browser-auth-server.js +846 -0
- package/dist/convex-browser-auth.js +597 -0
- package/dist/entitlements.js +502 -0
- package/dist/identity/functions.js +44 -0
- package/dist/identity/index.js +1716 -0
- package/dist/identity/return-targets.js +33 -0
- package/dist/index.js +1133 -0
- package/dist/oidc-device-code.js +253 -0
- package/dist/oidc-rp.js +2259 -0
- package/dist/oidc-session-policy.js +5 -0
- package/dist/oidc-surface-server.js +2332 -0
- package/dist/profile-form.js +1934 -0
- package/dist/profile.js +17 -0
- package/dist/public-config.js +462 -0
- package/dist/react.js +137 -0
- package/dist/receipt-verifier.js +619 -0
- package/dist/registry.js +327 -0
- package/dist/stylex-manifest.json +1 -0
- package/dist/stylex.css +435 -0
- package/dist/urls.js +391 -0
- package/package.json +263 -0
- package/portfolio-inventory.json +37 -0
- package/src/auth-client.ts +37 -0
- package/src/auth-proxy.ts +473 -0
- package/src/bearer-verifier.ts +478 -0
- package/src/browser-session.ts +260 -0
- package/src/client-configuration.ts +172 -0
- package/src/convex-browser-auth-browser.ts +238 -0
- package/src/convex-browser-auth-server.ts +492 -0
- package/src/convex-browser-auth.ts +208 -0
- package/src/convex-url.ts +74 -0
- package/src/entitlements.ts +317 -0
- package/src/identity/catalog.ts +227 -0
- package/src/identity/consumers.ts +53 -0
- package/src/identity/functions.ts +70 -0
- package/src/identity/identifiers.ts +82 -0
- package/src/identity/index.ts +10 -0
- package/src/identity/links.ts +280 -0
- package/src/identity/membership.ts +156 -0
- package/src/identity/principals.ts +301 -0
- package/src/identity/profiles-v2.ts +330 -0
- package/src/identity/profiles.ts +524 -0
- package/src/identity/return-targets.ts +20 -0
- package/src/identity/usernames.ts +101 -0
- package/src/identity/views.ts +289 -0
- package/src/immutable.ts +25 -0
- package/src/index.ts +7 -0
- package/src/oidc-continuation.ts +59 -0
- package/src/oidc-device-code.ts +401 -0
- package/src/oidc-rp.ts +2217 -0
- package/src/oidc-session-policy.ts +6 -0
- package/src/oidc-surface-server.ts +186 -0
- package/src/profile-form.css +1 -0
- package/src/profile-form.stylex.ts +226 -0
- package/src/profile-form.tsx +314 -0
- package/src/profile.ts +33 -0
- package/src/public-config.ts +294 -0
- package/src/public-profile-form-result.ts +82 -0
- package/src/public-profile-form.tsx +319 -0
- package/src/public-profile-links.ts +100 -0
- package/src/react.tsx +196 -0
- package/src/receipt-verifier.ts +273 -0
- package/src/registry.ts +600 -0
- package/src/urls.ts +234 -0
package/src/oidc-rp.ts
ADDED
|
@@ -0,0 +1,2217 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parseSuiteCatalogRevision,
|
|
3
|
+
parseSuiteFeatureId,
|
|
4
|
+
type SuiteFeatureId,
|
|
5
|
+
} from "./identity/catalog.js";
|
|
6
|
+
import {
|
|
7
|
+
parseSuiteAccountId,
|
|
8
|
+
type SuiteAccountId,
|
|
9
|
+
} from "./identity/identifiers.js";
|
|
10
|
+
import {
|
|
11
|
+
type ProductLinkProof,
|
|
12
|
+
type SuiteEntitlementReceipt,
|
|
13
|
+
type SuiteLinkReceipt,
|
|
14
|
+
validateProductLinkProof,
|
|
15
|
+
validateSuiteEntitlementReceipt,
|
|
16
|
+
validateSuiteLinkReceipt,
|
|
17
|
+
} from "./identity/links.js";
|
|
18
|
+
import { parseSuiteJwtClaims } from "./identity/principals.js";
|
|
19
|
+
import {
|
|
20
|
+
parseSuiteUsername,
|
|
21
|
+
type SuiteUsername,
|
|
22
|
+
} from "./identity/usernames.js";
|
|
23
|
+
import {
|
|
24
|
+
createLocalJWKSet,
|
|
25
|
+
decodeProtectedHeader,
|
|
26
|
+
jwtVerify,
|
|
27
|
+
type JSONWebKeySet,
|
|
28
|
+
type JWTPayload,
|
|
29
|
+
} from "jose";
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
SUITE_ENTITLEMENT_MAX_PROVIDER_AGE_MS,
|
|
33
|
+
verifySuiteEntitlementToken,
|
|
34
|
+
type SuiteEntitlementsClaim,
|
|
35
|
+
type VerifiedSuiteEntitlements,
|
|
36
|
+
} from "./entitlements.js";
|
|
37
|
+
import {
|
|
38
|
+
getSuiteAccountsCurrentConsumerEnvironment,
|
|
39
|
+
isSuiteAccountsCurrentLinkedOidcConsumerId,
|
|
40
|
+
suiteAccountsCurrentConsumerRequiresEmailOtp,
|
|
41
|
+
type SuiteAccountsCurrentLinkedOidcConsumerId,
|
|
42
|
+
type SuiteAccountsCurrentOidcConsumerId,
|
|
43
|
+
type SuiteAccountsRemoteEnvironment,
|
|
44
|
+
} from "./registry.js";
|
|
45
|
+
import {
|
|
46
|
+
suiteAccountsCurrentOidcClientRegistration,
|
|
47
|
+
suiteAccountsOidcProviderConfiguration,
|
|
48
|
+
type SuiteAccountsOidcProviderConfiguration,
|
|
49
|
+
} from "./urls.js";
|
|
50
|
+
import { deepFreeze } from "./immutable.js";
|
|
51
|
+
import { createOidcContinuationResponse } from "./oidc-continuation.js";
|
|
52
|
+
import { SUITE_OIDC_EARLY_REFRESH_WINDOW_MS } from "./oidc-session-policy.js";
|
|
53
|
+
|
|
54
|
+
const TRANSACTION_TTL_MS = 10 * 60_000;
|
|
55
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
56
|
+
const FETCH_TIMEOUT_MS = 8_000;
|
|
57
|
+
const MAX_PROVIDER_BODY_BYTES = 64 * 1_024;
|
|
58
|
+
const MAX_COOKIE_BYTES = 4_096;
|
|
59
|
+
const MAX_RETURN_PATH_BYTES = 1_024;
|
|
60
|
+
const MAX_CODE_BYTES = 2_048;
|
|
61
|
+
const ALLOWED_TOKEN_ALGORITHMS = ["ES256"] as const;
|
|
62
|
+
const REMOTE_TRANSACTION_COOKIE = "__Host-hraness-suite-oidc-transaction";
|
|
63
|
+
const REMOTE_SESSION_COOKIE = "__Host-hraness-suite-oidc-session";
|
|
64
|
+
const LOCAL_TRANSACTION_COOKIE = "hraness-suite-oidc-local-transaction";
|
|
65
|
+
const LOCAL_SESSION_COOKIE = "hraness-suite-oidc-local-session";
|
|
66
|
+
|
|
67
|
+
export type SuiteOidcConsumer = SuiteAccountsCurrentOidcConsumerId;
|
|
68
|
+
type SuiteOidcReceiptConsumer = SuiteAccountsCurrentLinkedOidcConsumerId;
|
|
69
|
+
type FetchImplementation = (
|
|
70
|
+
input: RequestInfo | URL,
|
|
71
|
+
init?: RequestInit,
|
|
72
|
+
) => Promise<Response>;
|
|
73
|
+
|
|
74
|
+
function isReceiptConsumer(
|
|
75
|
+
consumer: SuiteOidcConsumer,
|
|
76
|
+
): consumer is SuiteOidcReceiptConsumer {
|
|
77
|
+
return isSuiteAccountsCurrentLinkedOidcConsumerId(consumer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type TransactionBase = Readonly<{
|
|
81
|
+
consumer: SuiteOidcConsumer;
|
|
82
|
+
environment: SuiteAccountsRemoteEnvironment;
|
|
83
|
+
expiresAtMs: number;
|
|
84
|
+
issuedAtMs: number;
|
|
85
|
+
nonce: string;
|
|
86
|
+
returnTo: string;
|
|
87
|
+
state: string;
|
|
88
|
+
verifier: string;
|
|
89
|
+
}>;
|
|
90
|
+
|
|
91
|
+
type Transaction = TransactionBase & (
|
|
92
|
+
| Readonly<{ version: 1 }>
|
|
93
|
+
| Readonly<{ version: 2; context: string }>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
/** Opaque server-owned action context, never copied from browser parameters. */
|
|
97
|
+
export type SuiteOidcFreshAuthenticationInput = Readonly<{
|
|
98
|
+
context: string;
|
|
99
|
+
expiresAtMs: number;
|
|
100
|
+
/**
|
|
101
|
+
* Optional reuse window floor. When present, the authorization request sets
|
|
102
|
+
* a positive OIDC `max_age` so the provider may satisfy `prompt=login` with
|
|
103
|
+
* a live session whose authentication is at or after this instant instead
|
|
104
|
+
* of forcing another interactive sign-in. Omitted means always re-auth.
|
|
105
|
+
*/
|
|
106
|
+
authenticationNotBeforeMs?: number;
|
|
107
|
+
}>;
|
|
108
|
+
|
|
109
|
+
/** Verified evidence only. The product must durably consume it before authorizing. */
|
|
110
|
+
export type SuiteOidcFreshAuthentication = Readonly<{
|
|
111
|
+
authenticatedAtMs: number;
|
|
112
|
+
context: string;
|
|
113
|
+
expiresAtMs: number;
|
|
114
|
+
startedAtMs: number;
|
|
115
|
+
suiteAccountId: SuiteAccountId;
|
|
116
|
+
}>;
|
|
117
|
+
|
|
118
|
+
export type SuiteOidcFreshAuthenticationResult =
|
|
119
|
+
| Readonly<{
|
|
120
|
+
kind: "authenticated";
|
|
121
|
+
authentication: SuiteOidcFreshAuthentication;
|
|
122
|
+
response: Response;
|
|
123
|
+
}>
|
|
124
|
+
| Readonly<{ kind: "rejected"; response: Response }>;
|
|
125
|
+
|
|
126
|
+
type AuthorizationResult = SuiteOidcFreshAuthenticationResult
|
|
127
|
+
| Readonly<{ kind: "ordinary"; response: Response }>;
|
|
128
|
+
|
|
129
|
+
type SuiteOidcProfile =
|
|
130
|
+
| Readonly<{
|
|
131
|
+
profileComplete: false;
|
|
132
|
+
profileRevision: "username-v1" | null;
|
|
133
|
+
username: null;
|
|
134
|
+
}>
|
|
135
|
+
| Readonly<{
|
|
136
|
+
profileComplete: true;
|
|
137
|
+
profileRevision: "username-v1";
|
|
138
|
+
username: SuiteUsername;
|
|
139
|
+
}>;
|
|
140
|
+
|
|
141
|
+
type StoredSession = Readonly<{
|
|
142
|
+
accessToken: string;
|
|
143
|
+
audience: string;
|
|
144
|
+
entitlements: VerifiedSuiteEntitlements;
|
|
145
|
+
environment: SuiteAccountsRemoteEnvironment;
|
|
146
|
+
expiresAtMs: number;
|
|
147
|
+
accessTokenExpiresAtMs: number;
|
|
148
|
+
issuer: string;
|
|
149
|
+
nonce: string;
|
|
150
|
+
pendingEntitlementReceipt: SuiteEntitlementReceipt | null;
|
|
151
|
+
refreshToken: string;
|
|
152
|
+
subject: string;
|
|
153
|
+
suiteAccountId: SuiteAccountId;
|
|
154
|
+
version: 2;
|
|
155
|
+
}> & SuiteOidcProfile;
|
|
156
|
+
|
|
157
|
+
type SuiteOidcSessionViewBase = Readonly<{
|
|
158
|
+
entitlementReceipt: SuiteEntitlementReceipt | null;
|
|
159
|
+
entitlements: Readonly<{
|
|
160
|
+
features: readonly SuiteFeatureId[];
|
|
161
|
+
kind: "fresh" | "legacy" | "stale";
|
|
162
|
+
}>;
|
|
163
|
+
suiteAccountId: SuiteAccountId;
|
|
164
|
+
}>;
|
|
165
|
+
|
|
166
|
+
export type SuiteOidcSessionView =
|
|
167
|
+
SuiteOidcSessionViewBase & SuiteOidcProfile;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Server-only identity projected from a verified, unexpired RP session.
|
|
171
|
+
*
|
|
172
|
+
* The access token remains inside this accessor and the encrypted HttpOnly
|
|
173
|
+
* cookie. It is never part of the public `/session` response.
|
|
174
|
+
*/
|
|
175
|
+
export type SuiteOidcServerAccountSession = Readonly<{
|
|
176
|
+
accessToken: string;
|
|
177
|
+
accessTokenExpiresAtMs: number;
|
|
178
|
+
suiteAccountId: SuiteAccountId;
|
|
179
|
+
}>;
|
|
180
|
+
|
|
181
|
+
export type SuiteOidcServerSession = SuiteOidcServerAccountSession & Readonly<{
|
|
182
|
+
username: SuiteUsername;
|
|
183
|
+
}>;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Canonical account email projected only to trusted product server code.
|
|
187
|
+
*
|
|
188
|
+
* This value is fetched from the live Suite userinfo response. It is never
|
|
189
|
+
* stored in the relying-party cookie or included in the browser session view,
|
|
190
|
+
* and it does not require optional username onboarding to be complete.
|
|
191
|
+
*/
|
|
192
|
+
export type SuiteOidcServerVerifiedAccountEmail = Readonly<{
|
|
193
|
+
accessTokenExpiresAtMs: number;
|
|
194
|
+
email: string;
|
|
195
|
+
suiteAccountId: SuiteAccountId;
|
|
196
|
+
}>;
|
|
197
|
+
|
|
198
|
+
/** Completed-profile email projection with the permanent Suite username. */
|
|
199
|
+
export type SuiteOidcServerVerifiedEmail =
|
|
200
|
+
SuiteOidcServerVerifiedAccountEmail & Readonly<{
|
|
201
|
+
username: SuiteUsername;
|
|
202
|
+
}>;
|
|
203
|
+
|
|
204
|
+
export type SuiteOidcRelyingPartyOptions = Readonly<{
|
|
205
|
+
consumer: SuiteOidcConsumer;
|
|
206
|
+
cookieSecret: string;
|
|
207
|
+
environment: SuiteAccountsRemoteEnvironment;
|
|
208
|
+
fetch?: FetchImplementation;
|
|
209
|
+
fetchTimeoutMs?: number;
|
|
210
|
+
now?: () => number;
|
|
211
|
+
randomBytes?: (length: number) => Uint8Array;
|
|
212
|
+
receiptKeyVersion: string;
|
|
213
|
+
}>;
|
|
214
|
+
|
|
215
|
+
export type SuiteOidcRelyingParty = Readonly<{
|
|
216
|
+
callback(request: Request): Promise<Response>;
|
|
217
|
+
completeFreshAuthentication(
|
|
218
|
+
request: Request,
|
|
219
|
+
): Promise<SuiteOidcFreshAuthenticationResult>;
|
|
220
|
+
acknowledgeEntitlementReceipt(request: Request): Promise<Response>;
|
|
221
|
+
configuration: Readonly<{
|
|
222
|
+
callbackUrl: string;
|
|
223
|
+
clientId: string;
|
|
224
|
+
provider: SuiteAccountsOidcProviderConfiguration;
|
|
225
|
+
siteUrl: string;
|
|
226
|
+
}>;
|
|
227
|
+
currentSession(request: Request): Promise<Response>;
|
|
228
|
+
handle(request: Request): Promise<Response>;
|
|
229
|
+
linkReceipt(request: Request): Promise<Response>;
|
|
230
|
+
refreshSession(request: Request): Promise<Response>;
|
|
231
|
+
serverAccountSession(
|
|
232
|
+
request: Request,
|
|
233
|
+
): Promise<SuiteOidcServerAccountSession | null>;
|
|
234
|
+
serverVerifiedAccountEmail(
|
|
235
|
+
request: Request,
|
|
236
|
+
): Promise<SuiteOidcServerVerifiedAccountEmail | null>;
|
|
237
|
+
serverSession(request: Request): Promise<SuiteOidcServerSession | null>;
|
|
238
|
+
serverVerifiedEmail(
|
|
239
|
+
request: Request,
|
|
240
|
+
): Promise<SuiteOidcServerVerifiedEmail | null>;
|
|
241
|
+
signOut(request: Request): Promise<Response>;
|
|
242
|
+
start(request: Request): Promise<Response>;
|
|
243
|
+
startFreshAuthentication(request: Request, input: unknown): Promise<Response>;
|
|
244
|
+
}>;
|
|
245
|
+
|
|
246
|
+
type TokenResponse = Readonly<{
|
|
247
|
+
accessToken: string;
|
|
248
|
+
idToken: string | null;
|
|
249
|
+
refreshToken: string;
|
|
250
|
+
tokenType: "Bearer";
|
|
251
|
+
}>;
|
|
252
|
+
|
|
253
|
+
type IdentityLinkReceiptRequest = ProductLinkProof & Readonly<{
|
|
254
|
+
proofSignature: string;
|
|
255
|
+
}>;
|
|
256
|
+
|
|
257
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
258
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function safeInteger(value: unknown): value is number {
|
|
262
|
+
return typeof value === "number"
|
|
263
|
+
&& Number.isSafeInteger(value)
|
|
264
|
+
&& value >= 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function boundedString(
|
|
268
|
+
value: unknown,
|
|
269
|
+
minimum: number,
|
|
270
|
+
maximum: number,
|
|
271
|
+
): value is string {
|
|
272
|
+
return typeof value === "string"
|
|
273
|
+
&& value.length >= minimum
|
|
274
|
+
&& value.length <= maximum
|
|
275
|
+
&& !value.includes("\u0000");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function encodeBase64Url(bytes: Uint8Array): string {
|
|
279
|
+
let binary = "";
|
|
280
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
281
|
+
return btoa(binary)
|
|
282
|
+
.replaceAll("+", "-")
|
|
283
|
+
.replaceAll("/", "_")
|
|
284
|
+
.replace(/=+$/u, "");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function decodeBase64Url(value: string): Uint8Array | null {
|
|
288
|
+
if (!/^[A-Za-z0-9_-]+$/u.test(value)) return null;
|
|
289
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/")
|
|
290
|
+
+ "=".repeat((4 - (value.length % 4)) % 4);
|
|
291
|
+
try {
|
|
292
|
+
const binary = atob(padded);
|
|
293
|
+
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function ownedBytes(value: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
300
|
+
const result = new Uint8Array(new ArrayBuffer(value.byteLength));
|
|
301
|
+
result.set(value);
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function randomValue(
|
|
306
|
+
length: number,
|
|
307
|
+
source: (length: number) => Uint8Array,
|
|
308
|
+
): string {
|
|
309
|
+
const bytes = source(length);
|
|
310
|
+
if (bytes.byteLength !== length) {
|
|
311
|
+
throw new Error("The OIDC random source returned the wrong byte length.");
|
|
312
|
+
}
|
|
313
|
+
return encodeBase64Url(bytes);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function sha256Base64Url(value: string): Promise<string> {
|
|
317
|
+
const digest = await crypto.subtle.digest(
|
|
318
|
+
"SHA-256",
|
|
319
|
+
new TextEncoder().encode(value),
|
|
320
|
+
);
|
|
321
|
+
return encodeBase64Url(new Uint8Array(digest));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function cookieNames(siteUrl: string) {
|
|
325
|
+
const secure = new URL(siteUrl).protocol === "https:";
|
|
326
|
+
return {
|
|
327
|
+
secure,
|
|
328
|
+
session: secure ? REMOTE_SESSION_COOKIE : LOCAL_SESSION_COOKIE,
|
|
329
|
+
transaction: secure
|
|
330
|
+
? REMOTE_TRANSACTION_COOKIE
|
|
331
|
+
: LOCAL_TRANSACTION_COOKIE,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cookieAttributes(secure: boolean, maxAgeSeconds: number): string {
|
|
336
|
+
return [
|
|
337
|
+
`Max-Age=${maxAgeSeconds}`,
|
|
338
|
+
"Path=/",
|
|
339
|
+
"HttpOnly",
|
|
340
|
+
...(secure ? ["Secure"] : []),
|
|
341
|
+
"SameSite=Lax",
|
|
342
|
+
].join("; ");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function setCookie(
|
|
346
|
+
name: string,
|
|
347
|
+
value: string,
|
|
348
|
+
secure: boolean,
|
|
349
|
+
maxAgeSeconds: number,
|
|
350
|
+
): string {
|
|
351
|
+
return `${name}=${value}; ${cookieAttributes(secure, maxAgeSeconds)}`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function clearCookie(name: string, secure: boolean): string {
|
|
355
|
+
return [
|
|
356
|
+
`${name}=`,
|
|
357
|
+
"Max-Age=0",
|
|
358
|
+
"Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
|
359
|
+
"Path=/",
|
|
360
|
+
"HttpOnly",
|
|
361
|
+
...(secure ? ["Secure"] : []),
|
|
362
|
+
"SameSite=Lax",
|
|
363
|
+
].join("; ");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function requestCookie(request: Request, name: string): string | null {
|
|
367
|
+
const header = request.headers.get("cookie");
|
|
368
|
+
if (header === null || header.length > 16_384) return null;
|
|
369
|
+
const values: string[] = [];
|
|
370
|
+
for (const segment of header.split(";")) {
|
|
371
|
+
const trimmed = segment.trim();
|
|
372
|
+
const separator = trimmed.indexOf("=");
|
|
373
|
+
if (separator <= 0 || trimmed.slice(0, separator) !== name) continue;
|
|
374
|
+
values.push(trimmed.slice(separator + 1));
|
|
375
|
+
}
|
|
376
|
+
return values.length === 1 ? values[0]! : null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function deriveCookieKey(
|
|
380
|
+
secret: string,
|
|
381
|
+
consumer: SuiteOidcConsumer,
|
|
382
|
+
environment: SuiteAccountsRemoteEnvironment,
|
|
383
|
+
): Promise<CryptoKey> {
|
|
384
|
+
const material = new TextEncoder().encode(
|
|
385
|
+
`hraness-suite-oidc-cookie-v1\u0000${consumer}\u0000${environment}\u0000${secret}`,
|
|
386
|
+
);
|
|
387
|
+
const digest = await crypto.subtle.digest("SHA-256", material);
|
|
388
|
+
return await crypto.subtle.importKey(
|
|
389
|
+
"raw",
|
|
390
|
+
digest,
|
|
391
|
+
{ name: "AES-GCM" },
|
|
392
|
+
false,
|
|
393
|
+
["decrypt", "encrypt"],
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function sealCookie(
|
|
398
|
+
value: unknown,
|
|
399
|
+
key: CryptoKey,
|
|
400
|
+
purpose: "session" | "transaction",
|
|
401
|
+
randomBytes: (length: number) => Uint8Array,
|
|
402
|
+
): Promise<string> {
|
|
403
|
+
const iv = ownedBytes(randomBytes(12));
|
|
404
|
+
if (iv.byteLength !== 12) throw new Error("Invalid OIDC cookie IV.");
|
|
405
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(value));
|
|
406
|
+
if (plaintext.byteLength > MAX_COOKIE_BYTES - 256) {
|
|
407
|
+
throw new Error("The OIDC cookie payload was too large.");
|
|
408
|
+
}
|
|
409
|
+
const aad = new TextEncoder().encode(`hraness-suite-oidc-${purpose}-v1`);
|
|
410
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
411
|
+
{ additionalData: aad, iv, name: "AES-GCM", tagLength: 128 },
|
|
412
|
+
key,
|
|
413
|
+
plaintext,
|
|
414
|
+
);
|
|
415
|
+
const sealed = `v1.${encodeBase64Url(iv)}.${encodeBase64Url(
|
|
416
|
+
new Uint8Array(encrypted),
|
|
417
|
+
)}`;
|
|
418
|
+
if (sealed.length > MAX_COOKIE_BYTES) {
|
|
419
|
+
throw new Error("The sealed OIDC cookie was too large.");
|
|
420
|
+
}
|
|
421
|
+
return sealed;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function unsealCookie(
|
|
425
|
+
value: string,
|
|
426
|
+
key: CryptoKey,
|
|
427
|
+
purpose: "session" | "transaction",
|
|
428
|
+
): Promise<unknown> {
|
|
429
|
+
if (value.length < 1 || value.length > MAX_COOKIE_BYTES) return null;
|
|
430
|
+
const parts = value.split(".");
|
|
431
|
+
if (parts.length !== 3 || parts[0] !== "v1") return null;
|
|
432
|
+
const iv = decodeBase64Url(parts[1]!);
|
|
433
|
+
const ciphertext = decodeBase64Url(parts[2]!);
|
|
434
|
+
if (iv?.byteLength !== 12 || ciphertext === null) return null;
|
|
435
|
+
try {
|
|
436
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
437
|
+
{
|
|
438
|
+
additionalData: new TextEncoder().encode(
|
|
439
|
+
`hraness-suite-oidc-${purpose}-v1`,
|
|
440
|
+
),
|
|
441
|
+
iv: ownedBytes(iv),
|
|
442
|
+
name: "AES-GCM",
|
|
443
|
+
tagLength: 128,
|
|
444
|
+
},
|
|
445
|
+
key,
|
|
446
|
+
ownedBytes(ciphertext),
|
|
447
|
+
);
|
|
448
|
+
if (plaintext.byteLength > MAX_COOKIE_BYTES) return null;
|
|
449
|
+
return JSON.parse(new TextDecoder().decode(plaintext)) as unknown;
|
|
450
|
+
} catch {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function parseTransaction(
|
|
456
|
+
value: unknown,
|
|
457
|
+
consumer: SuiteOidcConsumer,
|
|
458
|
+
environment: SuiteAccountsRemoteEnvironment,
|
|
459
|
+
nowMs: number,
|
|
460
|
+
): Transaction | null {
|
|
461
|
+
if (
|
|
462
|
+
!isRecord(value)
|
|
463
|
+
|| (value["version"] !== 1 && value["version"] !== 2)
|
|
464
|
+
|| value["consumer"] !== consumer
|
|
465
|
+
|| value["environment"] !== environment
|
|
466
|
+
|| !safeInteger(value["issuedAtMs"])
|
|
467
|
+
|| !safeInteger(value["expiresAtMs"])
|
|
468
|
+
|| value["expiresAtMs"] <= nowMs
|
|
469
|
+
|| value["issuedAtMs"] > nowMs + 30_000
|
|
470
|
+
|| value["expiresAtMs"] - value["issuedAtMs"] > TRANSACTION_TTL_MS
|
|
471
|
+
|| !boundedString(value["state"], 43, 128)
|
|
472
|
+
|| !boundedString(value["nonce"], 43, 128)
|
|
473
|
+
|| !boundedString(value["verifier"], 43, 128)
|
|
474
|
+
|| parseReturnPath(value["returnTo"], "") === null
|
|
475
|
+
) {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
if (value["version"] === 2 && (
|
|
479
|
+
!safeInteger(nowMs)
|
|
480
|
+
|| value["issuedAtMs"] > nowMs
|
|
481
|
+
|| value["expiresAtMs"] <= value["issuedAtMs"]
|
|
482
|
+
|| !validFreshContext(value["context"])
|
|
483
|
+
|| Object.keys(value).length !== 10
|
|
484
|
+
)) return null;
|
|
485
|
+
return value as Transaction;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function validFreshContext(value: unknown): value is string {
|
|
489
|
+
return typeof value === "string" && /^[A-Za-z0-9_-]{32,256}$/u.test(value);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function parseFreshAuthenticationInput(
|
|
493
|
+
value: unknown,
|
|
494
|
+
nowMs: number,
|
|
495
|
+
): SuiteOidcFreshAuthenticationInput | null {
|
|
496
|
+
if (!isRecord(value) || !safeInteger(nowMs)
|
|
497
|
+
|| !safeInteger(nowMs + TRANSACTION_TTL_MS)) return null;
|
|
498
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
499
|
+
const ownKeys = Reflect.ownKeys(descriptors);
|
|
500
|
+
if ((ownKeys.length !== 2 && ownKeys.length !== 3)
|
|
501
|
+
|| !("value" in (descriptors["context"] ?? {}))
|
|
502
|
+
|| !("value" in (descriptors["expiresAtMs"] ?? {}))) return null;
|
|
503
|
+
if (ownKeys.length === 3 && !("value" in (descriptors["authenticationNotBeforeMs"] ?? {}))) return null;
|
|
504
|
+
const context: unknown = descriptors["context"]!.value;
|
|
505
|
+
const expiresAtMs: unknown = descriptors["expiresAtMs"]!.value;
|
|
506
|
+
const notBeforeMs: unknown = descriptors["authenticationNotBeforeMs"]?.value;
|
|
507
|
+
if (!validFreshContext(context) || !safeInteger(expiresAtMs)
|
|
508
|
+
|| expiresAtMs <= nowMs || expiresAtMs > nowMs + TRANSACTION_TTL_MS
|
|
509
|
+
|| (notBeforeMs !== undefined && (!safeInteger(notBeforeMs) || notBeforeMs < 0))) return null;
|
|
510
|
+
return notBeforeMs === undefined
|
|
511
|
+
? { context, expiresAtMs }
|
|
512
|
+
: { context, expiresAtMs, authenticationNotBeforeMs: notBeforeMs };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function parseStoredEntitlements(value: unknown): VerifiedSuiteEntitlements | null {
|
|
516
|
+
if (!isRecord(value) || !Array.isArray(value["features"])) return null;
|
|
517
|
+
const features: SuiteFeatureId[] = [];
|
|
518
|
+
for (const rawFeature of value["features"]) {
|
|
519
|
+
const feature = parseSuiteFeatureId(rawFeature);
|
|
520
|
+
if (!feature.ok || features.includes(feature.value)) return null;
|
|
521
|
+
features.push(feature.value);
|
|
522
|
+
}
|
|
523
|
+
if (value["kind"] === "legacy") {
|
|
524
|
+
return value["claim"] === null && features.length === 0
|
|
525
|
+
? { claim: null, features: [], kind: "legacy" }
|
|
526
|
+
: null;
|
|
527
|
+
}
|
|
528
|
+
if (value["kind"] !== "fresh" && value["kind"] !== "stale") return null;
|
|
529
|
+
const claim = value["claim"];
|
|
530
|
+
const catalogRevision = isRecord(claim)
|
|
531
|
+
? parseSuiteCatalogRevision(claim["catalogRevision"])
|
|
532
|
+
: null;
|
|
533
|
+
if (
|
|
534
|
+
!isRecord(claim)
|
|
535
|
+
|| claim["version"] !== "suite-entitlements-v1"
|
|
536
|
+
|| catalogRevision === null
|
|
537
|
+
|| !catalogRevision.ok
|
|
538
|
+
|| !safeInteger(claim["observedAtMs"])
|
|
539
|
+
|| !safeInteger(claim["expiresAtMs"])
|
|
540
|
+
|| !safeInteger(claim["projectionRevision"])
|
|
541
|
+
|| !Array.isArray(claim["features"])
|
|
542
|
+
) {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
const claimFeatures: SuiteFeatureId[] = [];
|
|
546
|
+
for (const rawFeature of claim["features"]) {
|
|
547
|
+
const feature = parseSuiteFeatureId(rawFeature);
|
|
548
|
+
if (!feature.ok || claimFeatures.includes(feature.value)) return null;
|
|
549
|
+
claimFeatures.push(feature.value);
|
|
550
|
+
}
|
|
551
|
+
if (
|
|
552
|
+
claim["expiresAtMs"] <= claim["observedAtMs"]
|
|
553
|
+
|| (
|
|
554
|
+
value["kind"] === "fresh"
|
|
555
|
+
&& (
|
|
556
|
+
features.length !== claimFeatures.length
|
|
557
|
+
|| features.some(feature => !claimFeatures.includes(feature))
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const parsedClaim: SuiteEntitlementsClaim = {
|
|
564
|
+
catalogRevision: catalogRevision.value,
|
|
565
|
+
expiresAtMs: claim["expiresAtMs"],
|
|
566
|
+
features: claimFeatures,
|
|
567
|
+
observedAtMs: claim["observedAtMs"],
|
|
568
|
+
projectionRevision: claim["projectionRevision"],
|
|
569
|
+
version: "suite-entitlements-v1",
|
|
570
|
+
};
|
|
571
|
+
return value["kind"] === "fresh"
|
|
572
|
+
? { claim: parsedClaim, features, kind: "fresh" }
|
|
573
|
+
: { claim: parsedClaim, features: [], kind: "stale" };
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function parseEntitlementReceipt(
|
|
577
|
+
value: unknown,
|
|
578
|
+
consumer: SuiteOidcConsumer,
|
|
579
|
+
environment: SuiteAccountsRemoteEnvironment,
|
|
580
|
+
keyVersion: string,
|
|
581
|
+
suiteAccountId: string,
|
|
582
|
+
nowMs: number,
|
|
583
|
+
): SuiteEntitlementReceipt | null {
|
|
584
|
+
if (!isReceiptConsumer(consumer)) return null;
|
|
585
|
+
if (
|
|
586
|
+
!isRecord(value)
|
|
587
|
+
|| !isRecord(value["entitlements"])
|
|
588
|
+
) {
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
try {
|
|
592
|
+
const receipt = value as unknown as SuiteEntitlementReceipt;
|
|
593
|
+
return validateSuiteEntitlementReceipt(receipt, nowMs) === null
|
|
594
|
+
&& receipt.product === consumer
|
|
595
|
+
&& receipt.environment === environment
|
|
596
|
+
&& receipt.keyVersion === keyVersion
|
|
597
|
+
&& receipt.suiteAccountId === suiteAccountId
|
|
598
|
+
? receipt
|
|
599
|
+
: null;
|
|
600
|
+
} catch {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function parseIdentityLinkReceiptRequest(
|
|
606
|
+
value: unknown,
|
|
607
|
+
consumer: SuiteOidcConsumer,
|
|
608
|
+
environment: SuiteAccountsRemoteEnvironment,
|
|
609
|
+
nowMs: number,
|
|
610
|
+
): IdentityLinkReceiptRequest | null {
|
|
611
|
+
if (!isReceiptConsumer(consumer)) return null;
|
|
612
|
+
if (
|
|
613
|
+
!isRecord(value)
|
|
614
|
+
|| Object.keys(value).length !== 8
|
|
615
|
+
|| value["product"] !== consumer
|
|
616
|
+
|| value["environment"] !== environment
|
|
617
|
+
|| !boundedString(value["challengeId"], 22, 128)
|
|
618
|
+
|| !boundedString(value["localSubject"], 1, 255)
|
|
619
|
+
|| !boundedString(value["keyVersion"], 1, 32)
|
|
620
|
+
|| !boundedString(value["proofSignature"], 43, 43)
|
|
621
|
+
|| !safeInteger(value["issuedAtMs"])
|
|
622
|
+
|| !safeInteger(value["expiresAtMs"])
|
|
623
|
+
) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
const proof: ProductLinkProof = {
|
|
627
|
+
challengeId: value["challengeId"],
|
|
628
|
+
environment,
|
|
629
|
+
expiresAtMs: value["expiresAtMs"],
|
|
630
|
+
issuedAtMs: value["issuedAtMs"],
|
|
631
|
+
keyVersion: value["keyVersion"],
|
|
632
|
+
localSubject: value["localSubject"],
|
|
633
|
+
product: consumer,
|
|
634
|
+
};
|
|
635
|
+
return validateProductLinkProof(proof, nowMs) === null
|
|
636
|
+
&& /^[A-Za-z0-9_-]{43}$/u.test(value["proofSignature"])
|
|
637
|
+
? { ...proof, proofSignature: value["proofSignature"] }
|
|
638
|
+
: null;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function parseIdentityLinkReceipt(
|
|
642
|
+
value: unknown,
|
|
643
|
+
proof: IdentityLinkReceiptRequest,
|
|
644
|
+
suiteAccountId: string,
|
|
645
|
+
nowMs: number,
|
|
646
|
+
): SuiteLinkReceipt | null {
|
|
647
|
+
if (!isRecord(value)) return null;
|
|
648
|
+
try {
|
|
649
|
+
const receipt = value as unknown as SuiteLinkReceipt;
|
|
650
|
+
return validateSuiteLinkReceipt(receipt, nowMs) === null
|
|
651
|
+
&& receipt.challengeId === proof.challengeId
|
|
652
|
+
&& receipt.environment === proof.environment
|
|
653
|
+
&& receipt.expiresAtMs === proof.expiresAtMs
|
|
654
|
+
&& receipt.issuedAtMs === proof.issuedAtMs
|
|
655
|
+
&& receipt.keyVersion === proof.keyVersion
|
|
656
|
+
&& receipt.localSubject === proof.localSubject
|
|
657
|
+
&& receipt.product === proof.product
|
|
658
|
+
&& receipt.suiteAccountId === suiteAccountId
|
|
659
|
+
? receipt
|
|
660
|
+
: null;
|
|
661
|
+
} catch {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function parseStoredProfile(value: Record<string, unknown>): SuiteOidcProfile | null {
|
|
667
|
+
const profileRevision = value["profileRevision"];
|
|
668
|
+
const profileComplete = value["profileComplete"];
|
|
669
|
+
const rawUsername = value["username"];
|
|
670
|
+
if (
|
|
671
|
+
profileComplete === false
|
|
672
|
+
&& (profileRevision === null || profileRevision === "username-v1")
|
|
673
|
+
&& rawUsername === null
|
|
674
|
+
) {
|
|
675
|
+
return {
|
|
676
|
+
profileComplete: false,
|
|
677
|
+
profileRevision,
|
|
678
|
+
username: null,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (profileComplete !== true || profileRevision !== "username-v1") {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
const username = parseSuiteUsername(rawUsername);
|
|
685
|
+
return username.ok
|
|
686
|
+
? {
|
|
687
|
+
profileComplete: true,
|
|
688
|
+
profileRevision: "username-v1",
|
|
689
|
+
username: username.value,
|
|
690
|
+
}
|
|
691
|
+
: null;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function profileFromClaims(
|
|
695
|
+
claims: Readonly<{
|
|
696
|
+
profileComplete: boolean;
|
|
697
|
+
profileRevision: "username-v1" | null;
|
|
698
|
+
username: SuiteUsername | null;
|
|
699
|
+
}>,
|
|
700
|
+
): SuiteOidcProfile | null {
|
|
701
|
+
if (
|
|
702
|
+
claims.profileComplete
|
|
703
|
+
&& claims.profileRevision === "username-v1"
|
|
704
|
+
&& claims.username !== null
|
|
705
|
+
) {
|
|
706
|
+
return {
|
|
707
|
+
profileComplete: true,
|
|
708
|
+
profileRevision: "username-v1",
|
|
709
|
+
username: claims.username,
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
return !claims.profileComplete && claims.username === null
|
|
713
|
+
? {
|
|
714
|
+
profileComplete: false,
|
|
715
|
+
profileRevision: claims.profileRevision,
|
|
716
|
+
username: null,
|
|
717
|
+
}
|
|
718
|
+
: null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function parseSession(
|
|
722
|
+
value: unknown,
|
|
723
|
+
configuration: SuiteOidcRelyingParty["configuration"],
|
|
724
|
+
consumer: SuiteOidcConsumer,
|
|
725
|
+
environment: SuiteAccountsRemoteEnvironment,
|
|
726
|
+
receiptKeyVersion: string,
|
|
727
|
+
nowMs: number,
|
|
728
|
+
): StoredSession | null {
|
|
729
|
+
if (
|
|
730
|
+
!isRecord(value)
|
|
731
|
+
|| value["version"] !== 2
|
|
732
|
+
|| value["environment"] !== environment
|
|
733
|
+
|| value["issuer"] !== configuration.provider.issuer
|
|
734
|
+
|| !boundedString(value["accessToken"], 16, 16_384)
|
|
735
|
+
|| value["audience"] !== configuration.provider.resource
|
|
736
|
+
|| !boundedString(value["subject"], 1, 255)
|
|
737
|
+
|| !/^acct_[0-9a-f]{32}$/u.test(String(value["suiteAccountId"]))
|
|
738
|
+
|| !boundedString(value["nonce"], 43, 128)
|
|
739
|
+
|| !boundedString(value["refreshToken"], 16, 2_048)
|
|
740
|
+
|| !safeInteger(value["accessTokenExpiresAtMs"])
|
|
741
|
+
|| !safeInteger(value["expiresAtMs"])
|
|
742
|
+
|| value["expiresAtMs"] <= nowMs
|
|
743
|
+
|| value["expiresAtMs"] > nowMs + SESSION_TTL_MS + 30_000
|
|
744
|
+
) {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
const suiteAccountId = parseSuiteAccountId(value["suiteAccountId"]);
|
|
748
|
+
const entitlements = parseStoredEntitlements(value["entitlements"]);
|
|
749
|
+
const profile = parseStoredProfile(value);
|
|
750
|
+
const pendingEntitlementReceipt = value["pendingEntitlementReceipt"] === null
|
|
751
|
+
? null
|
|
752
|
+
: parseEntitlementReceipt(
|
|
753
|
+
value["pendingEntitlementReceipt"],
|
|
754
|
+
consumer,
|
|
755
|
+
environment,
|
|
756
|
+
receiptKeyVersion,
|
|
757
|
+
String(value["suiteAccountId"]),
|
|
758
|
+
nowMs,
|
|
759
|
+
);
|
|
760
|
+
return entitlements === null || profile === null || !suiteAccountId.ok
|
|
761
|
+
? null
|
|
762
|
+
: {
|
|
763
|
+
...(value as Omit<
|
|
764
|
+
StoredSession,
|
|
765
|
+
| "entitlements"
|
|
766
|
+
| "pendingEntitlementReceipt"
|
|
767
|
+
| "profileComplete"
|
|
768
|
+
| "profileRevision"
|
|
769
|
+
| "suiteAccountId"
|
|
770
|
+
| "username"
|
|
771
|
+
>),
|
|
772
|
+
entitlements,
|
|
773
|
+
pendingEntitlementReceipt,
|
|
774
|
+
...profile,
|
|
775
|
+
suiteAccountId: suiteAccountId.value,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function parseReturnPath(value: unknown, fallback = "/"): string | null {
|
|
780
|
+
if (value === undefined || value === null || value === "") return fallback;
|
|
781
|
+
if (
|
|
782
|
+
typeof value !== "string"
|
|
783
|
+
|| value.length > MAX_RETURN_PATH_BYTES
|
|
784
|
+
|| !value.startsWith("/")
|
|
785
|
+
|| value.startsWith("//")
|
|
786
|
+
|| value.includes("\\")
|
|
787
|
+
|| value.includes("#")
|
|
788
|
+
) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
let parsed: URL;
|
|
792
|
+
try {
|
|
793
|
+
parsed = new URL(value, "https://return.invalid");
|
|
794
|
+
} catch {
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
if (
|
|
798
|
+
parsed.origin !== "https://return.invalid"
|
|
799
|
+
|| parsed.username !== ""
|
|
800
|
+
|| parsed.password !== ""
|
|
801
|
+
|| parsed.pathname.startsWith("/api/suite-auth/")
|
|
802
|
+
) {
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function exactRequest(
|
|
809
|
+
request: Request,
|
|
810
|
+
siteUrl: string,
|
|
811
|
+
path: string,
|
|
812
|
+
method: "GET" | "POST",
|
|
813
|
+
): boolean {
|
|
814
|
+
let url: URL;
|
|
815
|
+
try {
|
|
816
|
+
url = new URL(request.url);
|
|
817
|
+
} catch {
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
if (request.method !== method || url.origin !== siteUrl || url.pathname !== path) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
824
|
+
if (fetchSite !== null && fetchSite.toLowerCase() !== "same-origin") {
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
if (method === "POST" && request.headers.get("origin") !== siteUrl) {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function exactCallbackRequest(
|
|
834
|
+
request: Request,
|
|
835
|
+
siteUrl: string,
|
|
836
|
+
): boolean {
|
|
837
|
+
let url: URL;
|
|
838
|
+
try {
|
|
839
|
+
url = new URL(request.url);
|
|
840
|
+
} catch {
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
if (
|
|
844
|
+
request.method !== "GET"
|
|
845
|
+
|| url.origin !== siteUrl
|
|
846
|
+
|| url.pathname !== "/api/suite-auth/callback"
|
|
847
|
+
) {
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
const mode = request.headers.get("sec-fetch-mode");
|
|
851
|
+
const destination = request.headers.get("sec-fetch-dest");
|
|
852
|
+
const site = request.headers.get("sec-fetch-site");
|
|
853
|
+
return (mode === null || mode.toLowerCase() === "navigate")
|
|
854
|
+
&& (destination === null || destination.toLowerCase() === "document")
|
|
855
|
+
&& (
|
|
856
|
+
site === null
|
|
857
|
+
|| ["cross-site", "same-origin", "same-site", "none"].includes(
|
|
858
|
+
site.toLowerCase(),
|
|
859
|
+
)
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function jsonResponse(
|
|
864
|
+
body: unknown,
|
|
865
|
+
status: number,
|
|
866
|
+
cookies: readonly string[] = [],
|
|
867
|
+
): Response {
|
|
868
|
+
const headers = new Headers({
|
|
869
|
+
"cache-control": "no-store",
|
|
870
|
+
"content-type": "application/json; charset=utf-8",
|
|
871
|
+
pragma: "no-cache",
|
|
872
|
+
});
|
|
873
|
+
for (const cookie of cookies) headers.append("set-cookie", cookie);
|
|
874
|
+
return new Response(JSON.stringify(body), { headers, status });
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function failure(
|
|
878
|
+
code: string,
|
|
879
|
+
status: number,
|
|
880
|
+
cookies: readonly string[] = [],
|
|
881
|
+
): Response {
|
|
882
|
+
return jsonResponse({
|
|
883
|
+
error: { code, retryable: status >= 500 },
|
|
884
|
+
schemaVersion: 1,
|
|
885
|
+
}, status, cookies);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function readBoundedJson(
|
|
889
|
+
response: Response,
|
|
890
|
+
maximumBytes = MAX_PROVIDER_BODY_BYTES,
|
|
891
|
+
): Promise<unknown> {
|
|
892
|
+
const declared = response.headers.get("content-length");
|
|
893
|
+
if (
|
|
894
|
+
declared !== null
|
|
895
|
+
&& (!/^(?:0|[1-9]\d*)$/u.test(declared)
|
|
896
|
+
|| Number(declared) > maximumBytes)
|
|
897
|
+
) {
|
|
898
|
+
throw new Error("Provider response was too large.");
|
|
899
|
+
}
|
|
900
|
+
if (response.body === null) throw new Error("Provider response was empty.");
|
|
901
|
+
const reader = response.body.getReader();
|
|
902
|
+
const chunks: Uint8Array[] = [];
|
|
903
|
+
let length = 0;
|
|
904
|
+
while (true) {
|
|
905
|
+
const result = await reader.read();
|
|
906
|
+
if (result.done) break;
|
|
907
|
+
length += result.value.byteLength;
|
|
908
|
+
if (length > maximumBytes) {
|
|
909
|
+
await reader.cancel();
|
|
910
|
+
throw new Error("Provider response was too large.");
|
|
911
|
+
}
|
|
912
|
+
chunks.push(result.value);
|
|
913
|
+
}
|
|
914
|
+
const bytes = new Uint8Array(length);
|
|
915
|
+
let offset = 0;
|
|
916
|
+
for (const chunk of chunks) {
|
|
917
|
+
bytes.set(chunk, offset);
|
|
918
|
+
offset += chunk.byteLength;
|
|
919
|
+
}
|
|
920
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function consumeBoundedBody(
|
|
924
|
+
response: Response,
|
|
925
|
+
maximumBytes: number,
|
|
926
|
+
): Promise<void> {
|
|
927
|
+
const declared = response.headers.get("content-length");
|
|
928
|
+
if (
|
|
929
|
+
declared !== null
|
|
930
|
+
&& (!/^(?:0|[1-9]\d*)$/u.test(declared)
|
|
931
|
+
|| Number(declared) > maximumBytes)
|
|
932
|
+
) {
|
|
933
|
+
throw new Error("Provider response was too large.");
|
|
934
|
+
}
|
|
935
|
+
if (response.body === null) return;
|
|
936
|
+
const reader = response.body.getReader();
|
|
937
|
+
let length = 0;
|
|
938
|
+
while (true) {
|
|
939
|
+
const result = await reader.read();
|
|
940
|
+
if (result.done) return;
|
|
941
|
+
length += result.value.byteLength;
|
|
942
|
+
if (length > maximumBytes) {
|
|
943
|
+
await reader.cancel();
|
|
944
|
+
throw new Error("Provider response was too large.");
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function readBoundedRequestJson(
|
|
950
|
+
request: Request,
|
|
951
|
+
maximumBytes: number,
|
|
952
|
+
): Promise<unknown> {
|
|
953
|
+
const declared = request.headers.get("content-length");
|
|
954
|
+
if (
|
|
955
|
+
declared !== null
|
|
956
|
+
&& (!/^(?:0|[1-9]\d*)$/u.test(declared)
|
|
957
|
+
|| Number(declared) > maximumBytes)
|
|
958
|
+
) {
|
|
959
|
+
throw new Error("Request body was too large.");
|
|
960
|
+
}
|
|
961
|
+
if (request.body === null) throw new Error("Request body was empty.");
|
|
962
|
+
const reader = request.body.getReader();
|
|
963
|
+
const chunks: Uint8Array[] = [];
|
|
964
|
+
let length = 0;
|
|
965
|
+
while (true) {
|
|
966
|
+
const result = await reader.read();
|
|
967
|
+
if (result.done) break;
|
|
968
|
+
if (result.value.byteLength > maximumBytes - length) {
|
|
969
|
+
await reader.cancel();
|
|
970
|
+
throw new Error("Request body was too large.");
|
|
971
|
+
}
|
|
972
|
+
length += result.value.byteLength;
|
|
973
|
+
chunks.push(result.value);
|
|
974
|
+
}
|
|
975
|
+
const bytes = new Uint8Array(length);
|
|
976
|
+
let offset = 0;
|
|
977
|
+
for (const chunk of chunks) {
|
|
978
|
+
bytes.set(chunk, offset);
|
|
979
|
+
offset += chunk.byteLength;
|
|
980
|
+
}
|
|
981
|
+
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function providerJson(
|
|
985
|
+
url: string,
|
|
986
|
+
init: RequestInit,
|
|
987
|
+
fetchImplementation: FetchImplementation,
|
|
988
|
+
timeoutMs: number,
|
|
989
|
+
): Promise<unknown> {
|
|
990
|
+
const controller = new AbortController();
|
|
991
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
992
|
+
try {
|
|
993
|
+
const response = await fetchImplementation(url, {
|
|
994
|
+
...init,
|
|
995
|
+
redirect: "manual",
|
|
996
|
+
signal: controller.signal,
|
|
997
|
+
});
|
|
998
|
+
if (
|
|
999
|
+
response.status !== 200
|
|
1000
|
+
|| response.headers.has("location")
|
|
1001
|
+
) {
|
|
1002
|
+
throw new Error("Provider request failed.");
|
|
1003
|
+
}
|
|
1004
|
+
return await readBoundedJson(response);
|
|
1005
|
+
} finally {
|
|
1006
|
+
clearTimeout(timeout);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function validateSuiteOidcDiscovery(
|
|
1011
|
+
value: unknown,
|
|
1012
|
+
expected: SuiteAccountsOidcProviderConfiguration,
|
|
1013
|
+
): boolean {
|
|
1014
|
+
if (!isRecord(value)) return false;
|
|
1015
|
+
const exact = (
|
|
1016
|
+
value["issuer"] === expected.issuer
|
|
1017
|
+
&& value["authorization_endpoint"] === expected.authorizationEndpoint
|
|
1018
|
+
&& value["token_endpoint"] === expected.tokenEndpoint
|
|
1019
|
+
&& value["jwks_uri"] === expected.jwksEndpoint
|
|
1020
|
+
&& value["revocation_endpoint"] === expected.revocationEndpoint
|
|
1021
|
+
);
|
|
1022
|
+
const responseTypes = value["response_types_supported"];
|
|
1023
|
+
const grants = value["grant_types_supported"];
|
|
1024
|
+
const challenges = value["code_challenge_methods_supported"];
|
|
1025
|
+
const methods = value["token_endpoint_auth_methods_supported"];
|
|
1026
|
+
const algorithms = value["id_token_signing_alg_values_supported"];
|
|
1027
|
+
return exact
|
|
1028
|
+
&& Array.isArray(responseTypes)
|
|
1029
|
+
&& responseTypes.includes("code")
|
|
1030
|
+
&& Array.isArray(grants)
|
|
1031
|
+
&& grants.includes("authorization_code")
|
|
1032
|
+
&& grants.includes("refresh_token")
|
|
1033
|
+
&& Array.isArray(challenges)
|
|
1034
|
+
&& challenges.includes("S256")
|
|
1035
|
+
&& Array.isArray(methods)
|
|
1036
|
+
&& methods.includes("none")
|
|
1037
|
+
&& Array.isArray(algorithms)
|
|
1038
|
+
&& algorithms.length === 1
|
|
1039
|
+
&& algorithms[0] === "ES256"
|
|
1040
|
+
&& !algorithms.includes("HS256");
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function parseTokenResponse(
|
|
1044
|
+
value: unknown,
|
|
1045
|
+
requireIdToken: boolean,
|
|
1046
|
+
): TokenResponse | null {
|
|
1047
|
+
if (
|
|
1048
|
+
!isRecord(value)
|
|
1049
|
+
|| value["token_type"] !== "Bearer"
|
|
1050
|
+
|| !boundedString(value["access_token"], 16, 16_384)
|
|
1051
|
+
|| !boundedString(value["refresh_token"], 16, 4_096)
|
|
1052
|
+
|| (
|
|
1053
|
+
value["id_token"] !== undefined
|
|
1054
|
+
&& !boundedString(value["id_token"], 64, 16_384)
|
|
1055
|
+
)
|
|
1056
|
+
|| (requireIdToken && !boundedString(value["id_token"], 64, 16_384))
|
|
1057
|
+
) {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
accessToken: value["access_token"],
|
|
1062
|
+
idToken: typeof value["id_token"] === "string"
|
|
1063
|
+
? value["id_token"]
|
|
1064
|
+
: null,
|
|
1065
|
+
refreshToken: value["refresh_token"],
|
|
1066
|
+
tokenType: "Bearer",
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function parseJwks(value: unknown): JSONWebKeySet | null {
|
|
1071
|
+
if (
|
|
1072
|
+
!isRecord(value)
|
|
1073
|
+
|| !Array.isArray(value["keys"])
|
|
1074
|
+
|| value["keys"].length < 1
|
|
1075
|
+
|| value["keys"].length > 8
|
|
1076
|
+
) {
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
for (const key of value["keys"]) {
|
|
1080
|
+
if (
|
|
1081
|
+
!isRecord(key)
|
|
1082
|
+
|| !boundedString(key["kid"], 1, 128)
|
|
1083
|
+
|| (
|
|
1084
|
+
key["alg"] !== undefined
|
|
1085
|
+
&& key["alg"] !== "ES256"
|
|
1086
|
+
)
|
|
1087
|
+
|| key["kty"] !== "EC"
|
|
1088
|
+
|| key["crv"] !== "P-256"
|
|
1089
|
+
|| !boundedString(key["x"], 1, 256)
|
|
1090
|
+
|| !boundedString(key["y"], 1, 256)
|
|
1091
|
+
|| key["d"] !== undefined
|
|
1092
|
+
) {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
return value as unknown as JSONWebKeySet;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
type VerifiedIdToken = Readonly<{
|
|
1100
|
+
freshTimes: Readonly<{ authenticatedAtMs: number; expiresAtMs: number }> | null;
|
|
1101
|
+
profile: SuiteOidcProfile;
|
|
1102
|
+
subject: string;
|
|
1103
|
+
suiteAccountId: SuiteAccountId;
|
|
1104
|
+
}>;
|
|
1105
|
+
|
|
1106
|
+
async function verifiedIdToken(input: {
|
|
1107
|
+
configuration: SuiteOidcRelyingParty["configuration"];
|
|
1108
|
+
expectedNonce: string;
|
|
1109
|
+
idToken: string;
|
|
1110
|
+
jwks: JSONWebKeySet;
|
|
1111
|
+
nowMs: number;
|
|
1112
|
+
freshStartedAtMs?: number;
|
|
1113
|
+
}): Promise<VerifiedIdToken | null> {
|
|
1114
|
+
const header = decodeProtectedHeader(input.idToken);
|
|
1115
|
+
if (
|
|
1116
|
+
!boundedString(header.kid, 1, 128)
|
|
1117
|
+
|| header.alg !== "ES256"
|
|
1118
|
+
) {
|
|
1119
|
+
return null;
|
|
1120
|
+
}
|
|
1121
|
+
const verified = await jwtVerify(
|
|
1122
|
+
input.idToken,
|
|
1123
|
+
createLocalJWKSet(input.jwks),
|
|
1124
|
+
{
|
|
1125
|
+
algorithms: [...ALLOWED_TOKEN_ALGORITHMS],
|
|
1126
|
+
audience: input.configuration.clientId,
|
|
1127
|
+
clockTolerance: input.freshStartedAtMs === undefined ? 30 : 0,
|
|
1128
|
+
currentDate: new Date(input.nowMs),
|
|
1129
|
+
issuer: input.configuration.provider.issuer,
|
|
1130
|
+
maxTokenAge: "20m",
|
|
1131
|
+
},
|
|
1132
|
+
);
|
|
1133
|
+
if (
|
|
1134
|
+
verified.payload["nonce"] !== input.expectedNonce
|
|
1135
|
+
|| !boundedString(verified.payload.sub, 1, 255)
|
|
1136
|
+
) {
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
const claims = parseSuiteJwtClaims(verified.payload);
|
|
1140
|
+
if (!claims.ok) return null;
|
|
1141
|
+
const profile = profileFromClaims(claims.value);
|
|
1142
|
+
if (profile === null) return null;
|
|
1143
|
+
let freshTimes: VerifiedIdToken["freshTimes"] = null;
|
|
1144
|
+
if (input.freshStartedAtMs !== undefined) {
|
|
1145
|
+
const authTime = verified.payload["auth_time"];
|
|
1146
|
+
const issuedAt = verified.payload.iat;
|
|
1147
|
+
const expiresAt = verified.payload.exp;
|
|
1148
|
+
if (!safeInteger(input.nowMs) || input.nowMs < input.freshStartedAtMs
|
|
1149
|
+
|| !safeInteger(authTime) || !safeInteger(authTime * 1_000)
|
|
1150
|
+
|| !safeInteger(issuedAt) || !safeInteger(expiresAt)
|
|
1151
|
+
|| !safeInteger(expiresAt * 1_000)
|
|
1152
|
+
|| authTime < Math.floor(input.freshStartedAtMs / 1_000)
|
|
1153
|
+
|| authTime > Math.floor(input.nowMs / 1_000)
|
|
1154
|
+
|| authTime > issuedAt || issuedAt > Math.floor(input.nowMs / 1_000)
|
|
1155
|
+
|| expiresAt * 1_000 <= input.nowMs
|
|
1156
|
+
|| verified.payload.aud !== input.configuration.clientId
|
|
1157
|
+
|| (verified.payload["azp"] !== undefined
|
|
1158
|
+
&& verified.payload["azp"] !== input.configuration.clientId)) return null;
|
|
1159
|
+
freshTimes = { authenticatedAtMs: authTime * 1_000, expiresAtMs: expiresAt * 1_000 };
|
|
1160
|
+
}
|
|
1161
|
+
return {
|
|
1162
|
+
freshTimes,
|
|
1163
|
+
profile,
|
|
1164
|
+
subject: String(claims.value.principal.subject),
|
|
1165
|
+
suiteAccountId: claims.value.suiteAccountId,
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function exactAccessTokenAudience(
|
|
1170
|
+
value: unknown,
|
|
1171
|
+
provider: SuiteAccountsOidcProviderConfiguration,
|
|
1172
|
+
): boolean {
|
|
1173
|
+
if (!Array.isArray(value) || value.length !== 2) return false;
|
|
1174
|
+
const audiences = new Set(value);
|
|
1175
|
+
return audiences.size === 2
|
|
1176
|
+
&& audiences.has(provider.resource)
|
|
1177
|
+
&& audiences.has(provider.userInfoAudience);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function verifiedSessionFromAccessToken(input: {
|
|
1181
|
+
accessToken: string;
|
|
1182
|
+
configuration: SuiteOidcRelyingParty["configuration"];
|
|
1183
|
+
environment: SuiteAccountsRemoteEnvironment;
|
|
1184
|
+
expectedAccountId?: string;
|
|
1185
|
+
expectedProfile?: SuiteOidcProfile;
|
|
1186
|
+
expectedSubject?: string;
|
|
1187
|
+
profileTransition: "exact" | "forward";
|
|
1188
|
+
jwks: JSONWebKeySet;
|
|
1189
|
+
nonce: string;
|
|
1190
|
+
nowMs: number;
|
|
1191
|
+
refreshToken: string;
|
|
1192
|
+
requireFresh?: boolean;
|
|
1193
|
+
}): Promise<StoredSession | null> {
|
|
1194
|
+
const header = decodeProtectedHeader(input.accessToken);
|
|
1195
|
+
if (!boundedString(header.kid, 1, 128) || header.alg !== "ES256") {
|
|
1196
|
+
return null;
|
|
1197
|
+
}
|
|
1198
|
+
let verifiedPayload: JWTPayload;
|
|
1199
|
+
try {
|
|
1200
|
+
const verified = await jwtVerify(
|
|
1201
|
+
input.accessToken,
|
|
1202
|
+
createLocalJWKSet(input.jwks),
|
|
1203
|
+
{
|
|
1204
|
+
algorithms: [...ALLOWED_TOKEN_ALGORITHMS],
|
|
1205
|
+
audience: input.configuration.provider.resource,
|
|
1206
|
+
clockTolerance: input.requireFresh === true ? 0 : 30,
|
|
1207
|
+
currentDate: new Date(input.nowMs),
|
|
1208
|
+
issuer: input.configuration.provider.issuer,
|
|
1209
|
+
maxTokenAge: "20m",
|
|
1210
|
+
},
|
|
1211
|
+
);
|
|
1212
|
+
verifiedPayload = verified.payload;
|
|
1213
|
+
} catch {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
const result = await verifySuiteEntitlementToken(input.accessToken, {
|
|
1217
|
+
...(input.requireFresh === true ? { clockSkewMs: 0 } : {}),
|
|
1218
|
+
expectedAudience: input.configuration.provider.resource,
|
|
1219
|
+
expectedIssuer: input.configuration.provider.issuer,
|
|
1220
|
+
nowMs: input.nowMs,
|
|
1221
|
+
verify: () => Promise.resolve(verifiedPayload),
|
|
1222
|
+
});
|
|
1223
|
+
const profile = result.kind === "verified"
|
|
1224
|
+
? profileFromClaims(result.claims)
|
|
1225
|
+
: null;
|
|
1226
|
+
const expectedProfileMatches = input.expectedProfile === undefined
|
|
1227
|
+
|| (
|
|
1228
|
+
input.expectedProfile.profileComplete
|
|
1229
|
+
? profile?.profileComplete === true
|
|
1230
|
+
&& profile.username === input.expectedProfile.username
|
|
1231
|
+
: input.profileTransition === "forward"
|
|
1232
|
+
? (
|
|
1233
|
+
profile?.profileComplete === true
|
|
1234
|
+
|| (
|
|
1235
|
+
profile?.profileComplete === false
|
|
1236
|
+
&& (
|
|
1237
|
+
input.expectedProfile.profileRevision === null
|
|
1238
|
+
|| profile.profileRevision === input.expectedProfile.profileRevision
|
|
1239
|
+
)
|
|
1240
|
+
)
|
|
1241
|
+
)
|
|
1242
|
+
: profile?.profileComplete === false
|
|
1243
|
+
&& profile.profileRevision === input.expectedProfile.profileRevision
|
|
1244
|
+
);
|
|
1245
|
+
if (
|
|
1246
|
+
result.kind !== "verified"
|
|
1247
|
+
|| profile === null
|
|
1248
|
+
|| !expectedProfileMatches
|
|
1249
|
+
|| !exactAccessTokenAudience(
|
|
1250
|
+
verifiedPayload.aud,
|
|
1251
|
+
input.configuration.provider,
|
|
1252
|
+
)
|
|
1253
|
+
|| verifiedPayload["azp"] !== input.configuration.clientId
|
|
1254
|
+
|| verifiedPayload["suite_client_id"] !== input.configuration.clientId
|
|
1255
|
+
|| (
|
|
1256
|
+
input.expectedSubject !== undefined
|
|
1257
|
+
&& result.claims.principal.subject !== input.expectedSubject
|
|
1258
|
+
)
|
|
1259
|
+
|| (
|
|
1260
|
+
input.expectedAccountId !== undefined
|
|
1261
|
+
&& result.claims.suiteAccountId !== input.expectedAccountId
|
|
1262
|
+
)
|
|
1263
|
+
) {
|
|
1264
|
+
return null;
|
|
1265
|
+
}
|
|
1266
|
+
return {
|
|
1267
|
+
accessToken: input.accessToken,
|
|
1268
|
+
audience: input.configuration.provider.resource,
|
|
1269
|
+
entitlements: result.entitlements,
|
|
1270
|
+
environment: input.environment,
|
|
1271
|
+
expiresAtMs: input.nowMs + SESSION_TTL_MS,
|
|
1272
|
+
accessTokenExpiresAtMs: result.claims.expiresAtSeconds * 1_000,
|
|
1273
|
+
issuer: input.configuration.provider.issuer,
|
|
1274
|
+
nonce: input.nonce,
|
|
1275
|
+
pendingEntitlementReceipt: null,
|
|
1276
|
+
...profile,
|
|
1277
|
+
refreshToken: input.refreshToken,
|
|
1278
|
+
subject: String(result.claims.principal.subject),
|
|
1279
|
+
suiteAccountId: result.claims.suiteAccountId,
|
|
1280
|
+
version: 2,
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function currentView(
|
|
1285
|
+
session: StoredSession,
|
|
1286
|
+
nowMs: number,
|
|
1287
|
+
): SuiteOidcSessionView | null {
|
|
1288
|
+
if (session.accessTokenExpiresAtMs <= nowMs) return null;
|
|
1289
|
+
const entitlements = session.entitlements;
|
|
1290
|
+
const fresh = entitlements.kind === "fresh"
|
|
1291
|
+
&& entitlements.claim.expiresAtMs > nowMs
|
|
1292
|
+
&& nowMs - entitlements.claim.observedAtMs
|
|
1293
|
+
<= SUITE_ENTITLEMENT_MAX_PROVIDER_AGE_MS;
|
|
1294
|
+
const base: SuiteOidcSessionViewBase = {
|
|
1295
|
+
entitlementReceipt: session.pendingEntitlementReceipt,
|
|
1296
|
+
entitlements: {
|
|
1297
|
+
features: fresh ? entitlements.features : [],
|
|
1298
|
+
kind: entitlements.kind === "legacy"
|
|
1299
|
+
? "legacy"
|
|
1300
|
+
: fresh ? "fresh" : "stale",
|
|
1301
|
+
},
|
|
1302
|
+
suiteAccountId: session.suiteAccountId,
|
|
1303
|
+
};
|
|
1304
|
+
return session.profileComplete
|
|
1305
|
+
? {
|
|
1306
|
+
...base,
|
|
1307
|
+
profileComplete: true,
|
|
1308
|
+
profileRevision: "username-v1",
|
|
1309
|
+
username: session.username,
|
|
1310
|
+
}
|
|
1311
|
+
: {
|
|
1312
|
+
...base,
|
|
1313
|
+
profileComplete: false,
|
|
1314
|
+
profileRevision: session.profileRevision,
|
|
1315
|
+
username: null,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function liveUserInfoMatchesSession(
|
|
1320
|
+
value: unknown,
|
|
1321
|
+
configuration: SuiteOidcRelyingParty["configuration"],
|
|
1322
|
+
session: StoredSession,
|
|
1323
|
+
view: SuiteOidcSessionView,
|
|
1324
|
+
): boolean {
|
|
1325
|
+
if (!isRecord(value)) return false;
|
|
1326
|
+
const account = parseSuiteAccountId(value["suite_account_id"]);
|
|
1327
|
+
if (
|
|
1328
|
+
!account.ok
|
|
1329
|
+
|| account.value !== session.suiteAccountId
|
|
1330
|
+
|| value["suite_client_id"] !== configuration.clientId
|
|
1331
|
+
|| value["sub"] !== session.subject
|
|
1332
|
+
|| value["profile_complete"] !== view.profileComplete
|
|
1333
|
+
|| value["profile_revision"] !== view.profileRevision
|
|
1334
|
+
) {
|
|
1335
|
+
return false;
|
|
1336
|
+
}
|
|
1337
|
+
if (!view.profileComplete) return value["username"] === null;
|
|
1338
|
+
const username = parseSuiteUsername(value["username"]);
|
|
1339
|
+
return username.ok && username.value === view.username;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
function verifiedEmailFromUserInfo(value: unknown): string | null {
|
|
1343
|
+
if (!isRecord(value) || value["email_verified"] !== true) return null;
|
|
1344
|
+
const rawEmail = value["email"];
|
|
1345
|
+
if (
|
|
1346
|
+
typeof rawEmail !== "string"
|
|
1347
|
+
|| rawEmail.length === 0
|
|
1348
|
+
|| rawEmail.length > 320
|
|
1349
|
+
|| rawEmail.trim() !== rawEmail
|
|
1350
|
+
|| [...rawEmail].some((character) => {
|
|
1351
|
+
const codePoint = character.codePointAt(0);
|
|
1352
|
+
return codePoint !== undefined && (codePoint <= 0x20 || codePoint === 0x7f);
|
|
1353
|
+
})
|
|
1354
|
+
|| !/^[^@]+@[^@]+\.[^@]+$/u.test(rawEmail)
|
|
1355
|
+
) return null;
|
|
1356
|
+
return rawEmail.toLocaleLowerCase("en-US");
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
export function createSuiteOidcRelyingParty(
|
|
1360
|
+
options: SuiteOidcRelyingPartyOptions,
|
|
1361
|
+
): SuiteOidcRelyingParty {
|
|
1362
|
+
const consumer = options.consumer;
|
|
1363
|
+
const cookieSecret = options.cookieSecret;
|
|
1364
|
+
const environment = options.environment;
|
|
1365
|
+
const receiptKeyVersion = options.receiptKeyVersion;
|
|
1366
|
+
const cookieSecretBytes = new TextEncoder().encode(cookieSecret);
|
|
1367
|
+
if (
|
|
1368
|
+
cookieSecretBytes.byteLength < 32
|
|
1369
|
+
|| cookieSecretBytes.byteLength > 1_024
|
|
1370
|
+
) {
|
|
1371
|
+
throw new Error("The suite OIDC cookie secret must be 32–1024 bytes.");
|
|
1372
|
+
}
|
|
1373
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,31}$/u.test(receiptKeyVersion)) {
|
|
1374
|
+
throw new Error("The suite receipt key version was invalid.");
|
|
1375
|
+
}
|
|
1376
|
+
const registration = suiteAccountsCurrentOidcClientRegistration(
|
|
1377
|
+
consumer,
|
|
1378
|
+
environment,
|
|
1379
|
+
);
|
|
1380
|
+
if (registration === null) {
|
|
1381
|
+
throw new Error(
|
|
1382
|
+
"The suite consumer has no OIDC client in this environment.",
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const consumerEnvironment = getSuiteAccountsCurrentConsumerEnvironment(
|
|
1386
|
+
consumer,
|
|
1387
|
+
environment,
|
|
1388
|
+
);
|
|
1389
|
+
if (consumerEnvironment === null) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
"The suite consumer has no registered origin in this environment.",
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
const siteUrl = consumerEnvironment.siteUrl;
|
|
1395
|
+
const provider = suiteAccountsOidcProviderConfiguration(environment);
|
|
1396
|
+
const configuration = deepFreeze({
|
|
1397
|
+
callbackUrl: registration.callbackUrl,
|
|
1398
|
+
clientId: registration.clientId,
|
|
1399
|
+
provider,
|
|
1400
|
+
siteUrl,
|
|
1401
|
+
} as const);
|
|
1402
|
+
const names = cookieNames(siteUrl);
|
|
1403
|
+
const fetchImplementation = options.fetch ?? fetch;
|
|
1404
|
+
const timeoutMs = options.fetchTimeoutMs ?? FETCH_TIMEOUT_MS;
|
|
1405
|
+
if (!safeInteger(timeoutMs) || timeoutMs < 100 || timeoutMs > 30_000) {
|
|
1406
|
+
throw new Error("The suite OIDC fetch timeout must be 100–30000ms.");
|
|
1407
|
+
}
|
|
1408
|
+
const now = options.now ?? Date.now;
|
|
1409
|
+
const randomBytes = options.randomBytes ?? ((length: number) => {
|
|
1410
|
+
const value = new Uint8Array(length);
|
|
1411
|
+
crypto.getRandomValues(value);
|
|
1412
|
+
return value;
|
|
1413
|
+
});
|
|
1414
|
+
const key = deriveCookieKey(
|
|
1415
|
+
cookieSecret,
|
|
1416
|
+
consumer,
|
|
1417
|
+
environment,
|
|
1418
|
+
);
|
|
1419
|
+
|
|
1420
|
+
async function discovery(): Promise<void> {
|
|
1421
|
+
const value = await providerJson(
|
|
1422
|
+
provider.discoveryEndpoint,
|
|
1423
|
+
{ headers: { accept: "application/json" }, method: "GET" },
|
|
1424
|
+
fetchImplementation,
|
|
1425
|
+
timeoutMs,
|
|
1426
|
+
);
|
|
1427
|
+
if (!validateSuiteOidcDiscovery(value, provider)) {
|
|
1428
|
+
throw new Error("The OIDC provider metadata did not match the registry.");
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
async function jwks(): Promise<JSONWebKeySet> {
|
|
1433
|
+
const value = await providerJson(
|
|
1434
|
+
provider.jwksEndpoint,
|
|
1435
|
+
{ headers: { accept: "application/json" }, method: "GET" },
|
|
1436
|
+
fetchImplementation,
|
|
1437
|
+
timeoutMs,
|
|
1438
|
+
);
|
|
1439
|
+
const parsed = parseJwks(value);
|
|
1440
|
+
if (parsed === null) throw new Error("The OIDC JWKS was invalid.");
|
|
1441
|
+
return parsed;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function tokenRequest(
|
|
1445
|
+
body: URLSearchParams,
|
|
1446
|
+
requireIdToken: boolean,
|
|
1447
|
+
): Promise<TokenResponse> {
|
|
1448
|
+
const value = await providerJson(
|
|
1449
|
+
provider.tokenEndpoint,
|
|
1450
|
+
{
|
|
1451
|
+
body,
|
|
1452
|
+
headers: {
|
|
1453
|
+
accept: "application/json",
|
|
1454
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1455
|
+
origin: provider.issuer,
|
|
1456
|
+
},
|
|
1457
|
+
method: "POST",
|
|
1458
|
+
},
|
|
1459
|
+
fetchImplementation,
|
|
1460
|
+
timeoutMs,
|
|
1461
|
+
);
|
|
1462
|
+
const parsed = parseTokenResponse(value, requireIdToken);
|
|
1463
|
+
if (parsed === null) throw new Error("The OIDC token response was invalid.");
|
|
1464
|
+
return parsed;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
async function revokeRefreshToken(refreshToken: string): Promise<void> {
|
|
1468
|
+
const controller = new AbortController();
|
|
1469
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1470
|
+
try {
|
|
1471
|
+
const response = await fetchImplementation(
|
|
1472
|
+
provider.revocationEndpoint,
|
|
1473
|
+
{
|
|
1474
|
+
body: new URLSearchParams({
|
|
1475
|
+
client_id: configuration.clientId,
|
|
1476
|
+
token: refreshToken,
|
|
1477
|
+
token_type_hint: "refresh_token",
|
|
1478
|
+
}),
|
|
1479
|
+
headers: {
|
|
1480
|
+
accept: "application/json",
|
|
1481
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
1482
|
+
origin: provider.issuer,
|
|
1483
|
+
},
|
|
1484
|
+
method: "POST",
|
|
1485
|
+
redirect: "manual",
|
|
1486
|
+
signal: controller.signal,
|
|
1487
|
+
},
|
|
1488
|
+
);
|
|
1489
|
+
if (response.status !== 200 || response.headers.has("location")) {
|
|
1490
|
+
throw new Error("OIDC refresh-token revocation failed.");
|
|
1491
|
+
}
|
|
1492
|
+
await consumeBoundedBody(response, 1_024);
|
|
1493
|
+
} finally {
|
|
1494
|
+
clearTimeout(timeout);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
async function entitlementReceipt(
|
|
1499
|
+
accessToken: string,
|
|
1500
|
+
suiteAccountId: string,
|
|
1501
|
+
): Promise<SuiteEntitlementReceipt | null> {
|
|
1502
|
+
if (!isReceiptConsumer(consumer)) return null;
|
|
1503
|
+
const value = await providerJson(
|
|
1504
|
+
provider.entitlementReceiptEndpoint,
|
|
1505
|
+
{
|
|
1506
|
+
body: JSON.stringify({
|
|
1507
|
+
environment,
|
|
1508
|
+
keyVersion: receiptKeyVersion,
|
|
1509
|
+
product: consumer,
|
|
1510
|
+
}),
|
|
1511
|
+
headers: {
|
|
1512
|
+
accept: "application/json",
|
|
1513
|
+
authorization: `Bearer ${accessToken}`,
|
|
1514
|
+
"content-type": "application/json",
|
|
1515
|
+
},
|
|
1516
|
+
method: "POST",
|
|
1517
|
+
},
|
|
1518
|
+
fetchImplementation,
|
|
1519
|
+
timeoutMs,
|
|
1520
|
+
);
|
|
1521
|
+
const parsed = parseEntitlementReceipt(
|
|
1522
|
+
value,
|
|
1523
|
+
consumer,
|
|
1524
|
+
environment,
|
|
1525
|
+
receiptKeyVersion,
|
|
1526
|
+
suiteAccountId,
|
|
1527
|
+
now(),
|
|
1528
|
+
);
|
|
1529
|
+
if (parsed === null) {
|
|
1530
|
+
throw new Error("The suite entitlement receipt was invalid.");
|
|
1531
|
+
}
|
|
1532
|
+
return parsed;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
async function requestIdentityLinkReceipt(
|
|
1536
|
+
accessToken: string,
|
|
1537
|
+
proof: IdentityLinkReceiptRequest,
|
|
1538
|
+
suiteAccountId: string,
|
|
1539
|
+
): Promise<
|
|
1540
|
+
| Readonly<{ kind: "receipt"; receipt: SuiteLinkReceipt }>
|
|
1541
|
+
| Readonly<{ kind: "rejected"; status: 400 | 401 | 403 | 409 | 410 | 413 }>
|
|
1542
|
+
> {
|
|
1543
|
+
const controller = new AbortController();
|
|
1544
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1545
|
+
try {
|
|
1546
|
+
const response = await fetchImplementation(
|
|
1547
|
+
provider.identityLinkReceiptEndpoint,
|
|
1548
|
+
{
|
|
1549
|
+
body: JSON.stringify(proof),
|
|
1550
|
+
headers: {
|
|
1551
|
+
accept: "application/json",
|
|
1552
|
+
authorization: `Bearer ${accessToken}`,
|
|
1553
|
+
"content-type": "application/json",
|
|
1554
|
+
},
|
|
1555
|
+
method: "POST",
|
|
1556
|
+
redirect: "manual",
|
|
1557
|
+
signal: controller.signal,
|
|
1558
|
+
},
|
|
1559
|
+
);
|
|
1560
|
+
if (response.headers.has("location")) {
|
|
1561
|
+
throw new Error("Identity-link receipt endpoint redirected.");
|
|
1562
|
+
}
|
|
1563
|
+
if ([400, 401, 403, 409, 410, 413].includes(response.status)) {
|
|
1564
|
+
await consumeBoundedBody(response, 16_384);
|
|
1565
|
+
return {
|
|
1566
|
+
kind: "rejected",
|
|
1567
|
+
status: response.status as 400 | 401 | 403 | 409 | 410 | 413,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
if (response.status !== 200) {
|
|
1571
|
+
throw new Error("Identity-link receipt endpoint failed.");
|
|
1572
|
+
}
|
|
1573
|
+
const value = await readBoundedJson(response, 16_384);
|
|
1574
|
+
const receipt = parseIdentityLinkReceipt(
|
|
1575
|
+
value,
|
|
1576
|
+
proof,
|
|
1577
|
+
suiteAccountId,
|
|
1578
|
+
now(),
|
|
1579
|
+
);
|
|
1580
|
+
if (receipt === null) {
|
|
1581
|
+
throw new Error("Identity-link receipt was invalid.");
|
|
1582
|
+
}
|
|
1583
|
+
return { kind: "receipt", receipt };
|
|
1584
|
+
} finally {
|
|
1585
|
+
clearTimeout(timeout);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
async function loadSession(request: Request): Promise<StoredSession | null> {
|
|
1590
|
+
const sealed = requestCookie(request, names.session);
|
|
1591
|
+
if (sealed === null) return null;
|
|
1592
|
+
return parseSession(
|
|
1593
|
+
await unsealCookie(sealed, await key, "session"),
|
|
1594
|
+
configuration,
|
|
1595
|
+
consumer,
|
|
1596
|
+
environment,
|
|
1597
|
+
receiptKeyVersion,
|
|
1598
|
+
now(),
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
async function startAuthorization(
|
|
1603
|
+
request: Request,
|
|
1604
|
+
freshInput?: unknown,
|
|
1605
|
+
requireFresh = false,
|
|
1606
|
+
): Promise<Response> {
|
|
1607
|
+
if (!exactRequest(request, siteUrl, "/api/suite-auth/start", "GET")) {
|
|
1608
|
+
return failure("OIDC_START_REJECTED", 403);
|
|
1609
|
+
}
|
|
1610
|
+
const requestUrl = new URL(request.url);
|
|
1611
|
+
const returnTo = parseReturnPath(
|
|
1612
|
+
requestUrl.searchParams.get("return_to"),
|
|
1613
|
+
);
|
|
1614
|
+
if (returnTo === null) return failure("OIDC_RETURN_REJECTED", 400);
|
|
1615
|
+
const issuedAtMs = now();
|
|
1616
|
+
const fresh = requireFresh
|
|
1617
|
+
? parseFreshAuthenticationInput(freshInput, issuedAtMs)
|
|
1618
|
+
: null;
|
|
1619
|
+
if (requireFresh && fresh === null) return failure("OIDC_FRESH_INPUT_INVALID", 400);
|
|
1620
|
+
const verifier = randomValue(48, randomBytes);
|
|
1621
|
+
const transaction: Transaction = {
|
|
1622
|
+
consumer,
|
|
1623
|
+
environment,
|
|
1624
|
+
expiresAtMs: fresh?.expiresAtMs ?? issuedAtMs + TRANSACTION_TTL_MS,
|
|
1625
|
+
issuedAtMs,
|
|
1626
|
+
nonce: randomValue(32, randomBytes),
|
|
1627
|
+
returnTo,
|
|
1628
|
+
state: randomValue(32, randomBytes),
|
|
1629
|
+
verifier,
|
|
1630
|
+
...(fresh === null ? { version: 1 as const } : { version: 2 as const, context: fresh.context }),
|
|
1631
|
+
};
|
|
1632
|
+
const authorize = new URL(provider.authorizationEndpoint);
|
|
1633
|
+
authorize.searchParams.set("client_id", configuration.clientId);
|
|
1634
|
+
authorize.searchParams.set("code_challenge", await sha256Base64Url(verifier));
|
|
1635
|
+
authorize.searchParams.set("code_challenge_method", "S256");
|
|
1636
|
+
authorize.searchParams.set("nonce", transaction.nonce);
|
|
1637
|
+
// Ordinary authorization never forces a fresh sign-in: a live Accounts
|
|
1638
|
+
// session already satisfies the checked client. The provider-side hook
|
|
1639
|
+
// still forces `prompt=login` for a session that lacks the required
|
|
1640
|
+
// email-OTP method, so nothing here weakens the method boundary.
|
|
1641
|
+
if (requireFresh) {
|
|
1642
|
+
authorize.searchParams.set("prompt", "login");
|
|
1643
|
+
const notBefore = fresh?.authenticationNotBeforeMs;
|
|
1644
|
+
authorize.searchParams.set(
|
|
1645
|
+
"max_age",
|
|
1646
|
+
String(notBefore === undefined ? 0 : Math.max(0, Math.ceil((issuedAtMs - notBefore) / 1_000))),
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
authorize.searchParams.set("redirect_uri", configuration.callbackUrl);
|
|
1650
|
+
authorize.searchParams.set("resource", provider.resource);
|
|
1651
|
+
authorize.searchParams.set("response_type", "code");
|
|
1652
|
+
authorize.searchParams.set(
|
|
1653
|
+
"scope",
|
|
1654
|
+
suiteAccountsCurrentConsumerRequiresEmailOtp(consumer)
|
|
1655
|
+
? "openid profile email offline_access"
|
|
1656
|
+
: "openid offline_access",
|
|
1657
|
+
);
|
|
1658
|
+
authorize.searchParams.set("state", transaction.state);
|
|
1659
|
+
const sealed = await sealCookie(
|
|
1660
|
+
transaction,
|
|
1661
|
+
await key,
|
|
1662
|
+
"transaction",
|
|
1663
|
+
randomBytes,
|
|
1664
|
+
);
|
|
1665
|
+
return new Response(null, {
|
|
1666
|
+
headers: {
|
|
1667
|
+
"cache-control": "no-store",
|
|
1668
|
+
location: authorize.href,
|
|
1669
|
+
"set-cookie": setCookie(
|
|
1670
|
+
names.transaction,
|
|
1671
|
+
sealed,
|
|
1672
|
+
names.secure,
|
|
1673
|
+
Math.ceil((transaction.expiresAtMs - issuedAtMs) / 1_000),
|
|
1674
|
+
),
|
|
1675
|
+
},
|
|
1676
|
+
status: 302,
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
async function start(request: Request): Promise<Response> {
|
|
1681
|
+
return await startAuthorization(request);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async function startFreshAuthentication(request: Request, input: unknown): Promise<Response> {
|
|
1685
|
+
try {
|
|
1686
|
+
return await startAuthorization(request, input, true);
|
|
1687
|
+
} catch {
|
|
1688
|
+
return failure("OIDC_FRESH_START_FAILED", 400);
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
async function completeAuthorization(
|
|
1693
|
+
request: Request,
|
|
1694
|
+
requireFresh: boolean,
|
|
1695
|
+
): Promise<AuthorizationResult> {
|
|
1696
|
+
const clear = clearCookie(names.transaction, names.secure);
|
|
1697
|
+
const reject = (code: string, status: number): AuthorizationResult => ({
|
|
1698
|
+
kind: "rejected", response: failure(code, status, [clear]),
|
|
1699
|
+
});
|
|
1700
|
+
if (
|
|
1701
|
+
!exactCallbackRequest(request, siteUrl)
|
|
1702
|
+
) {
|
|
1703
|
+
return reject("OIDC_CALLBACK_REJECTED", 403);
|
|
1704
|
+
}
|
|
1705
|
+
const sealed = requestCookie(request, names.transaction);
|
|
1706
|
+
const transaction = sealed === null
|
|
1707
|
+
? null
|
|
1708
|
+
: parseTransaction(
|
|
1709
|
+
await unsealCookie(sealed, await key, "transaction"),
|
|
1710
|
+
consumer,
|
|
1711
|
+
environment,
|
|
1712
|
+
now(),
|
|
1713
|
+
);
|
|
1714
|
+
if (transaction === null || (transaction.version === 2) !== requireFresh) {
|
|
1715
|
+
return reject("OIDC_TRANSACTION_INVALID", 400);
|
|
1716
|
+
}
|
|
1717
|
+
const url = new URL(request.url);
|
|
1718
|
+
const states = url.searchParams.getAll("state");
|
|
1719
|
+
const codes = url.searchParams.getAll("code");
|
|
1720
|
+
const errors = url.searchParams.getAll("error");
|
|
1721
|
+
if (
|
|
1722
|
+
states.length !== 1
|
|
1723
|
+
|| states[0] !== transaction.state
|
|
1724
|
+
|| errors.length > 0
|
|
1725
|
+
|| codes.length !== 1
|
|
1726
|
+
|| !boundedString(codes[0], 1, MAX_CODE_BYTES)
|
|
1727
|
+
) {
|
|
1728
|
+
return reject("OIDC_CALLBACK_INVALID", 400);
|
|
1729
|
+
}
|
|
1730
|
+
try {
|
|
1731
|
+
await discovery();
|
|
1732
|
+
const tokens = await tokenRequest(new URLSearchParams({
|
|
1733
|
+
client_id: configuration.clientId,
|
|
1734
|
+
code: codes[0],
|
|
1735
|
+
code_verifier: transaction.verifier,
|
|
1736
|
+
grant_type: "authorization_code",
|
|
1737
|
+
redirect_uri: configuration.callbackUrl,
|
|
1738
|
+
resource: provider.resource,
|
|
1739
|
+
}), true);
|
|
1740
|
+
if (tokens.idToken === null) {
|
|
1741
|
+
return reject("OIDC_TOKEN_INVALID", 502);
|
|
1742
|
+
}
|
|
1743
|
+
const providerKeys = await jwks();
|
|
1744
|
+
const identity = await verifiedIdToken({
|
|
1745
|
+
configuration,
|
|
1746
|
+
expectedNonce: transaction.nonce,
|
|
1747
|
+
idToken: tokens.idToken,
|
|
1748
|
+
jwks: providerKeys,
|
|
1749
|
+
nowMs: now(),
|
|
1750
|
+
...(transaction.version === 2 ? { freshStartedAtMs: transaction.issuedAtMs } : {}),
|
|
1751
|
+
});
|
|
1752
|
+
if (identity === null) {
|
|
1753
|
+
return reject("OIDC_TOKEN_INVALID", 502);
|
|
1754
|
+
}
|
|
1755
|
+
const verifiedSession = await verifiedSessionFromAccessToken({
|
|
1756
|
+
accessToken: tokens.accessToken,
|
|
1757
|
+
configuration,
|
|
1758
|
+
environment,
|
|
1759
|
+
expectedAccountId: identity.suiteAccountId,
|
|
1760
|
+
expectedProfile: identity.profile,
|
|
1761
|
+
expectedSubject: identity.subject,
|
|
1762
|
+
jwks: providerKeys,
|
|
1763
|
+
nonce: transaction.nonce,
|
|
1764
|
+
nowMs: now(),
|
|
1765
|
+
profileTransition: "exact",
|
|
1766
|
+
refreshToken: tokens.refreshToken,
|
|
1767
|
+
requireFresh,
|
|
1768
|
+
});
|
|
1769
|
+
if (verifiedSession === null) {
|
|
1770
|
+
return reject("OIDC_TOKEN_INVALID", 502);
|
|
1771
|
+
}
|
|
1772
|
+
const session: StoredSession = {
|
|
1773
|
+
...verifiedSession,
|
|
1774
|
+
pendingEntitlementReceipt: await entitlementReceipt(
|
|
1775
|
+
tokens.accessToken,
|
|
1776
|
+
verifiedSession.suiteAccountId,
|
|
1777
|
+
),
|
|
1778
|
+
};
|
|
1779
|
+
const sessionCookie = await sealCookie(
|
|
1780
|
+
session,
|
|
1781
|
+
await key,
|
|
1782
|
+
"session",
|
|
1783
|
+
randomBytes,
|
|
1784
|
+
);
|
|
1785
|
+
// Recheck after every provider call and cookie operation. Token clock skew
|
|
1786
|
+
// allowed for ordinary login never extends fresh-action validity.
|
|
1787
|
+
let authentication: SuiteOidcFreshAuthentication | null = null;
|
|
1788
|
+
if (transaction.version === 2) {
|
|
1789
|
+
const completedAtMs = now();
|
|
1790
|
+
const times = identity.freshTimes;
|
|
1791
|
+
const expiresAtMs = Math.min(
|
|
1792
|
+
transaction.expiresAtMs,
|
|
1793
|
+
times?.expiresAtMs ?? 0,
|
|
1794
|
+
session.accessTokenExpiresAtMs,
|
|
1795
|
+
);
|
|
1796
|
+
if (times === null || !safeInteger(completedAtMs)
|
|
1797
|
+
|| completedAtMs < transaction.issuedAtMs
|
|
1798
|
+
|| times.authenticatedAtMs > completedAtMs
|
|
1799
|
+
|| !safeInteger(expiresAtMs) || expiresAtMs <= completedAtMs) {
|
|
1800
|
+
return reject("OIDC_FRESH_AUTHENTICATION_INVALID", 502);
|
|
1801
|
+
}
|
|
1802
|
+
authentication = Object.freeze({
|
|
1803
|
+
authenticatedAtMs: times.authenticatedAtMs,
|
|
1804
|
+
context: transaction.context,
|
|
1805
|
+
expiresAtMs,
|
|
1806
|
+
startedAtMs: transaction.issuedAtMs,
|
|
1807
|
+
suiteAccountId: identity.suiteAccountId,
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
const response = createOidcContinuationResponse(
|
|
1811
|
+
transaction.returnTo,
|
|
1812
|
+
randomValue(24, randomBytes),
|
|
1813
|
+
[
|
|
1814
|
+
clear,
|
|
1815
|
+
setCookie(
|
|
1816
|
+
names.session,
|
|
1817
|
+
sessionCookie,
|
|
1818
|
+
names.secure,
|
|
1819
|
+
SESSION_TTL_MS / 1_000,
|
|
1820
|
+
),
|
|
1821
|
+
],
|
|
1822
|
+
);
|
|
1823
|
+
return authentication === null
|
|
1824
|
+
? { kind: "ordinary", response }
|
|
1825
|
+
: Object.freeze({ kind: "authenticated", authentication, response });
|
|
1826
|
+
} catch {
|
|
1827
|
+
return reject("OIDC_UPSTREAM_FAILED", 502);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
async function callback(request: Request): Promise<Response> {
|
|
1832
|
+
return (await completeAuthorization(request, false)).response;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
async function completeFreshAuthentication(request: Request): Promise<SuiteOidcFreshAuthenticationResult> {
|
|
1836
|
+
const result = await completeAuthorization(request, true);
|
|
1837
|
+
return result.kind === "ordinary"
|
|
1838
|
+
? { kind: "rejected", response: failure("OIDC_TRANSACTION_INVALID", 400) }
|
|
1839
|
+
: result;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
async function currentSession(request: Request): Promise<Response> {
|
|
1843
|
+
if (!exactRequest(
|
|
1844
|
+
request,
|
|
1845
|
+
siteUrl,
|
|
1846
|
+
"/api/suite-auth/session",
|
|
1847
|
+
"GET",
|
|
1848
|
+
)) {
|
|
1849
|
+
return failure("OIDC_SESSION_REJECTED", 403);
|
|
1850
|
+
}
|
|
1851
|
+
const session = await loadSession(request);
|
|
1852
|
+
if (session === null) {
|
|
1853
|
+
return jsonResponse({ kind: "signed_out" }, 200, [
|
|
1854
|
+
clearCookie(names.session, names.secure),
|
|
1855
|
+
]);
|
|
1856
|
+
}
|
|
1857
|
+
const nowMs = now();
|
|
1858
|
+
const view = currentView(session, nowMs);
|
|
1859
|
+
return view === null
|
|
1860
|
+
|| session.accessTokenExpiresAtMs - nowMs
|
|
1861
|
+
< SUITE_OIDC_EARLY_REFRESH_WINDOW_MS
|
|
1862
|
+
? jsonResponse({ kind: "refresh_required" }, 200)
|
|
1863
|
+
: jsonResponse({ kind: "signed_in", session: view }, 200);
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
async function serverSessionState(
|
|
1867
|
+
request: Request,
|
|
1868
|
+
): Promise<Readonly<{
|
|
1869
|
+
session: StoredSession;
|
|
1870
|
+
verifiedEmail: string | null;
|
|
1871
|
+
view: SuiteOidcSessionView;
|
|
1872
|
+
}> | null> {
|
|
1873
|
+
let requestOrigin: string;
|
|
1874
|
+
try {
|
|
1875
|
+
requestOrigin = new URL(request.url).origin;
|
|
1876
|
+
} catch {
|
|
1877
|
+
return null;
|
|
1878
|
+
}
|
|
1879
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
1880
|
+
if (
|
|
1881
|
+
requestOrigin !== siteUrl
|
|
1882
|
+
|| (
|
|
1883
|
+
fetchSite !== null
|
|
1884
|
+
&& fetchSite.toLowerCase() !== "same-origin"
|
|
1885
|
+
)
|
|
1886
|
+
) {
|
|
1887
|
+
return null;
|
|
1888
|
+
}
|
|
1889
|
+
const session = await loadSession(request);
|
|
1890
|
+
const view = session === null ? null : currentView(session, now());
|
|
1891
|
+
if (session === null || view === null) return null;
|
|
1892
|
+
let verifiedEmail: string | null = null;
|
|
1893
|
+
if (
|
|
1894
|
+
suiteAccountsCurrentConsumerRequiresEmailOtp(consumer)
|
|
1895
|
+
) {
|
|
1896
|
+
let userInfo: unknown;
|
|
1897
|
+
try {
|
|
1898
|
+
userInfo = await providerJson(
|
|
1899
|
+
provider.userInfoAudience,
|
|
1900
|
+
{
|
|
1901
|
+
headers: {
|
|
1902
|
+
accept: "application/json",
|
|
1903
|
+
authorization: `Bearer ${session.accessToken}`,
|
|
1904
|
+
},
|
|
1905
|
+
method: "GET",
|
|
1906
|
+
},
|
|
1907
|
+
fetchImplementation,
|
|
1908
|
+
timeoutMs,
|
|
1909
|
+
);
|
|
1910
|
+
} catch {
|
|
1911
|
+
return null;
|
|
1912
|
+
}
|
|
1913
|
+
if (!liveUserInfoMatchesSession(
|
|
1914
|
+
userInfo,
|
|
1915
|
+
configuration,
|
|
1916
|
+
session,
|
|
1917
|
+
view,
|
|
1918
|
+
)) return null;
|
|
1919
|
+
verifiedEmail = verifiedEmailFromUserInfo(userInfo);
|
|
1920
|
+
}
|
|
1921
|
+
return { session, verifiedEmail, view };
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
async function serverAccountSession(
|
|
1925
|
+
request: Request,
|
|
1926
|
+
): Promise<SuiteOidcServerAccountSession | null> {
|
|
1927
|
+
const state = await serverSessionState(request);
|
|
1928
|
+
return state === null
|
|
1929
|
+
? null
|
|
1930
|
+
: {
|
|
1931
|
+
accessToken: state.session.accessToken,
|
|
1932
|
+
accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
|
|
1933
|
+
suiteAccountId: state.view.suiteAccountId,
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function serverVerifiedAccountEmail(
|
|
1938
|
+
request: Request,
|
|
1939
|
+
): Promise<SuiteOidcServerVerifiedAccountEmail | null> {
|
|
1940
|
+
const state = await serverSessionState(request);
|
|
1941
|
+
return state === null || state.verifiedEmail === null
|
|
1942
|
+
? null
|
|
1943
|
+
: {
|
|
1944
|
+
accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
|
|
1945
|
+
email: state.verifiedEmail,
|
|
1946
|
+
suiteAccountId: state.view.suiteAccountId,
|
|
1947
|
+
};
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
async function serverSession(
|
|
1951
|
+
request: Request,
|
|
1952
|
+
): Promise<SuiteOidcServerSession | null> {
|
|
1953
|
+
const state = await serverSessionState(request);
|
|
1954
|
+
return state?.view.profileComplete === true
|
|
1955
|
+
? {
|
|
1956
|
+
accessToken: state.session.accessToken,
|
|
1957
|
+
accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
|
|
1958
|
+
suiteAccountId: state.view.suiteAccountId,
|
|
1959
|
+
username: state.view.username,
|
|
1960
|
+
}
|
|
1961
|
+
: null;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
async function serverVerifiedEmail(
|
|
1965
|
+
request: Request,
|
|
1966
|
+
): Promise<SuiteOidcServerVerifiedEmail | null> {
|
|
1967
|
+
const state = await serverSessionState(request);
|
|
1968
|
+
return state === null
|
|
1969
|
+
|| state.verifiedEmail === null
|
|
1970
|
+
|| state.view.profileComplete !== true
|
|
1971
|
+
? null
|
|
1972
|
+
: {
|
|
1973
|
+
accessTokenExpiresAtMs: state.session.accessTokenExpiresAtMs,
|
|
1974
|
+
email: state.verifiedEmail,
|
|
1975
|
+
suiteAccountId: state.view.suiteAccountId,
|
|
1976
|
+
username: state.view.username,
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
async function acknowledgeEntitlementReceipt(
|
|
1981
|
+
request: Request,
|
|
1982
|
+
): Promise<Response> {
|
|
1983
|
+
const clear = clearCookie(names.session, names.secure);
|
|
1984
|
+
if (!exactRequest(
|
|
1985
|
+
request,
|
|
1986
|
+
siteUrl,
|
|
1987
|
+
"/api/suite-auth/entitlements/ack",
|
|
1988
|
+
"POST",
|
|
1989
|
+
)) {
|
|
1990
|
+
return failure("OIDC_ENTITLEMENT_ACK_REJECTED", 403);
|
|
1991
|
+
}
|
|
1992
|
+
const session = await loadSession(request);
|
|
1993
|
+
if (session === null) {
|
|
1994
|
+
return failure("OIDC_SESSION_INVALID", 401, [clear]);
|
|
1995
|
+
}
|
|
1996
|
+
let body: unknown;
|
|
1997
|
+
try {
|
|
1998
|
+
body = await readBoundedRequestJson(request, 512);
|
|
1999
|
+
} catch {
|
|
2000
|
+
return failure("OIDC_ENTITLEMENT_ACK_INVALID", 400);
|
|
2001
|
+
}
|
|
2002
|
+
if (
|
|
2003
|
+
!isRecord(body)
|
|
2004
|
+
|| Object.keys(body).length !== 1
|
|
2005
|
+
|| !boundedString(body["signature"], 43, 43)
|
|
2006
|
+
|| session.pendingEntitlementReceipt?.signature !== body["signature"]
|
|
2007
|
+
) {
|
|
2008
|
+
return failure("OIDC_ENTITLEMENT_ACK_INVALID", 400);
|
|
2009
|
+
}
|
|
2010
|
+
const updated: StoredSession = {
|
|
2011
|
+
...session,
|
|
2012
|
+
pendingEntitlementReceipt: null,
|
|
2013
|
+
};
|
|
2014
|
+
const sealed = await sealCookie(
|
|
2015
|
+
updated,
|
|
2016
|
+
await key,
|
|
2017
|
+
"session",
|
|
2018
|
+
randomBytes,
|
|
2019
|
+
);
|
|
2020
|
+
return jsonResponse({ acknowledged: true }, 200, [
|
|
2021
|
+
setCookie(names.session, sealed, names.secure, SESSION_TTL_MS / 1_000),
|
|
2022
|
+
]);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
async function linkReceipt(request: Request): Promise<Response> {
|
|
2026
|
+
if (!exactRequest(
|
|
2027
|
+
request,
|
|
2028
|
+
siteUrl,
|
|
2029
|
+
"/api/suite-auth/link-receipt",
|
|
2030
|
+
"POST",
|
|
2031
|
+
)) {
|
|
2032
|
+
return failure("OIDC_LINK_RECEIPT_REJECTED", 403);
|
|
2033
|
+
}
|
|
2034
|
+
const session = await loadSession(request);
|
|
2035
|
+
if (session === null || session.accessTokenExpiresAtMs <= now()) {
|
|
2036
|
+
return failure("OIDC_REFRESH_REQUIRED", 401);
|
|
2037
|
+
}
|
|
2038
|
+
let value: unknown;
|
|
2039
|
+
try {
|
|
2040
|
+
value = await readBoundedRequestJson(request, 16_384);
|
|
2041
|
+
} catch {
|
|
2042
|
+
return failure("OIDC_LINK_PROOF_INVALID", 400);
|
|
2043
|
+
}
|
|
2044
|
+
const proof = parseIdentityLinkReceiptRequest(
|
|
2045
|
+
value,
|
|
2046
|
+
consumer,
|
|
2047
|
+
environment,
|
|
2048
|
+
now(),
|
|
2049
|
+
);
|
|
2050
|
+
if (proof === null) {
|
|
2051
|
+
return failure("OIDC_LINK_PROOF_INVALID", 400);
|
|
2052
|
+
}
|
|
2053
|
+
try {
|
|
2054
|
+
const result = await requestIdentityLinkReceipt(
|
|
2055
|
+
session.accessToken,
|
|
2056
|
+
proof,
|
|
2057
|
+
session.suiteAccountId,
|
|
2058
|
+
);
|
|
2059
|
+
return result.kind === "receipt"
|
|
2060
|
+
? jsonResponse({ receipt: result.receipt }, 200)
|
|
2061
|
+
: failure("OIDC_LINK_RECEIPT_REJECTED", result.status);
|
|
2062
|
+
} catch {
|
|
2063
|
+
return failure("OIDC_LINK_RECEIPT_FAILED", 502);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
async function refreshSession(request: Request): Promise<Response> {
|
|
2068
|
+
const clear = clearCookie(names.session, names.secure);
|
|
2069
|
+
if (!exactRequest(
|
|
2070
|
+
request,
|
|
2071
|
+
siteUrl,
|
|
2072
|
+
"/api/suite-auth/refresh",
|
|
2073
|
+
"POST",
|
|
2074
|
+
)) {
|
|
2075
|
+
return failure("OIDC_REFRESH_REJECTED", 403);
|
|
2076
|
+
}
|
|
2077
|
+
const session = await loadSession(request);
|
|
2078
|
+
if (session === null) {
|
|
2079
|
+
return failure("OIDC_SESSION_INVALID", 401, [clear]);
|
|
2080
|
+
}
|
|
2081
|
+
try {
|
|
2082
|
+
await discovery();
|
|
2083
|
+
const tokens = await tokenRequest(new URLSearchParams({
|
|
2084
|
+
client_id: configuration.clientId,
|
|
2085
|
+
grant_type: "refresh_token",
|
|
2086
|
+
refresh_token: session.refreshToken,
|
|
2087
|
+
resource: provider.resource,
|
|
2088
|
+
}), false);
|
|
2089
|
+
if (tokens.refreshToken === session.refreshToken) {
|
|
2090
|
+
return failure("OIDC_REFRESH_ROTATION_INVALID", 502, [clear]);
|
|
2091
|
+
}
|
|
2092
|
+
const verifiedRefresh = await verifiedSessionFromAccessToken({
|
|
2093
|
+
accessToken: tokens.accessToken,
|
|
2094
|
+
configuration,
|
|
2095
|
+
environment,
|
|
2096
|
+
expectedAccountId: session.suiteAccountId,
|
|
2097
|
+
expectedProfile: session.profileComplete
|
|
2098
|
+
? {
|
|
2099
|
+
profileComplete: true,
|
|
2100
|
+
profileRevision: "username-v1",
|
|
2101
|
+
username: session.username,
|
|
2102
|
+
}
|
|
2103
|
+
: {
|
|
2104
|
+
profileComplete: false,
|
|
2105
|
+
profileRevision: session.profileRevision,
|
|
2106
|
+
username: null,
|
|
2107
|
+
},
|
|
2108
|
+
expectedSubject: session.subject,
|
|
2109
|
+
jwks: await jwks(),
|
|
2110
|
+
nonce: session.nonce,
|
|
2111
|
+
nowMs: now(),
|
|
2112
|
+
profileTransition: "forward",
|
|
2113
|
+
refreshToken: tokens.refreshToken,
|
|
2114
|
+
});
|
|
2115
|
+
if (verifiedRefresh === null) {
|
|
2116
|
+
return failure("OIDC_REFRESH_TOKEN_INVALID", 502, [clear]);
|
|
2117
|
+
}
|
|
2118
|
+
const refreshed: StoredSession = {
|
|
2119
|
+
...verifiedRefresh,
|
|
2120
|
+
pendingEntitlementReceipt: await entitlementReceipt(
|
|
2121
|
+
tokens.accessToken,
|
|
2122
|
+
verifiedRefresh.suiteAccountId,
|
|
2123
|
+
),
|
|
2124
|
+
};
|
|
2125
|
+
const view = currentView(refreshed, now());
|
|
2126
|
+
if (view === null) {
|
|
2127
|
+
return failure("OIDC_REFRESH_TOKEN_EXPIRED", 502, [clear]);
|
|
2128
|
+
}
|
|
2129
|
+
const sealed = await sealCookie(
|
|
2130
|
+
refreshed,
|
|
2131
|
+
await key,
|
|
2132
|
+
"session",
|
|
2133
|
+
randomBytes,
|
|
2134
|
+
);
|
|
2135
|
+
return jsonResponse({ kind: "signed_in", session: view }, 200, [
|
|
2136
|
+
setCookie(
|
|
2137
|
+
names.session,
|
|
2138
|
+
sealed,
|
|
2139
|
+
names.secure,
|
|
2140
|
+
SESSION_TTL_MS / 1_000,
|
|
2141
|
+
),
|
|
2142
|
+
]);
|
|
2143
|
+
} catch {
|
|
2144
|
+
return failure("OIDC_REFRESH_FAILED", 502, [clear]);
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
async function signOut(request: Request): Promise<Response> {
|
|
2149
|
+
if (!exactRequest(
|
|
2150
|
+
request,
|
|
2151
|
+
siteUrl,
|
|
2152
|
+
"/api/suite-auth/sign-out",
|
|
2153
|
+
"POST",
|
|
2154
|
+
)) {
|
|
2155
|
+
return failure("OIDC_SIGN_OUT_REJECTED", 403);
|
|
2156
|
+
}
|
|
2157
|
+
const session = await loadSession(request);
|
|
2158
|
+
if (session !== null) {
|
|
2159
|
+
try {
|
|
2160
|
+
await revokeRefreshToken(session.refreshToken);
|
|
2161
|
+
} catch {
|
|
2162
|
+
// Local token custody still ends when the provider is unavailable.
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
return jsonResponse({ kind: "signed_out" }, 200, [
|
|
2166
|
+
clearCookie(names.session, names.secure),
|
|
2167
|
+
clearCookie(names.transaction, names.secure),
|
|
2168
|
+
]);
|
|
2169
|
+
}
|
|
2170
|
+
|
|
2171
|
+
async function handle(request: Request): Promise<Response> {
|
|
2172
|
+
let path: string;
|
|
2173
|
+
try {
|
|
2174
|
+
path = new URL(request.url).pathname;
|
|
2175
|
+
} catch {
|
|
2176
|
+
return failure("OIDC_ROUTE_NOT_FOUND", 404);
|
|
2177
|
+
}
|
|
2178
|
+
if (request.method === "GET") {
|
|
2179
|
+
if (path === "/api/suite-auth/start") return await start(request);
|
|
2180
|
+
if (path === "/api/suite-auth/callback") return await callback(request);
|
|
2181
|
+
if (path === "/api/suite-auth/session") {
|
|
2182
|
+
return await currentSession(request);
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
if (request.method === "POST") {
|
|
2186
|
+
if (path === "/api/suite-auth/refresh") {
|
|
2187
|
+
return await refreshSession(request);
|
|
2188
|
+
}
|
|
2189
|
+
if (path === "/api/suite-auth/sign-out") return await signOut(request);
|
|
2190
|
+
if (path === "/api/suite-auth/link-receipt") {
|
|
2191
|
+
return await linkReceipt(request);
|
|
2192
|
+
}
|
|
2193
|
+
if (path === "/api/suite-auth/entitlements/ack") {
|
|
2194
|
+
return await acknowledgeEntitlementReceipt(request);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
return failure("OIDC_ROUTE_NOT_FOUND", 404);
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
return Object.freeze({
|
|
2201
|
+
acknowledgeEntitlementReceipt,
|
|
2202
|
+
callback,
|
|
2203
|
+
completeFreshAuthentication,
|
|
2204
|
+
configuration,
|
|
2205
|
+
currentSession,
|
|
2206
|
+
handle,
|
|
2207
|
+
linkReceipt,
|
|
2208
|
+
refreshSession,
|
|
2209
|
+
serverAccountSession,
|
|
2210
|
+
serverVerifiedAccountEmail,
|
|
2211
|
+
serverSession,
|
|
2212
|
+
serverVerifiedEmail,
|
|
2213
|
+
signOut,
|
|
2214
|
+
start,
|
|
2215
|
+
startFreshAuthentication,
|
|
2216
|
+
});
|
|
2217
|
+
}
|