@tula-tech/core 0.2.0 → 0.3.1
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/index.d.ts +1132 -92
- package/dist/index.js +684 -399
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -325,10 +325,293 @@ function isStepUpPrepared(value) {
|
|
|
325
325
|
return isPhoneCodeSent(value) && (method === "email_code" || method === "sms_code");
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
-
// src/
|
|
328
|
+
// src/oauth.ts
|
|
329
|
+
import { ERROR_DEFINITIONS as ERROR_DEFINITIONS2 } from "@tula-tech/contract/error-codes";
|
|
330
|
+
import {
|
|
331
|
+
EMAIL_LINK_ATTEMPT_PARAM as EMAIL_LINK_ATTEMPT_PARAM2,
|
|
332
|
+
OAUTH_ERROR_PARAM,
|
|
333
|
+
OAUTH_TICKET_PARAM
|
|
334
|
+
} from "@tula-tech/contract/headers";
|
|
335
|
+
var OAUTH_BINDING_TTL_MS = 15 * 60000;
|
|
336
|
+
var OAUTH_TICKET_HOLD_MS = 60000;
|
|
337
|
+
var RETRYABLE = new Set([
|
|
338
|
+
"network.failed",
|
|
339
|
+
"network.timeout",
|
|
340
|
+
"rate_limited"
|
|
341
|
+
]);
|
|
342
|
+
function isRetryableOAuthError(error) {
|
|
343
|
+
return isTulaError(error) && RETRYABLE.has(error.code);
|
|
344
|
+
}
|
|
345
|
+
var OAUTH_STORAGE_PREFIX = "tula.oauth.";
|
|
329
346
|
function isRecord3(value) {
|
|
330
347
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
331
348
|
}
|
|
349
|
+
function createOAuthStore(environment, scope) {
|
|
350
|
+
const storage = environment.tabStorage;
|
|
351
|
+
const keyOf = (attemptId) => `${OAUTH_STORAGE_PREFIX}${attemptId}`;
|
|
352
|
+
function guarded(work, fallback) {
|
|
353
|
+
try {
|
|
354
|
+
return work();
|
|
355
|
+
} catch {
|
|
356
|
+
return fallback;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function prune() {
|
|
360
|
+
if (!storage) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const stale = [];
|
|
364
|
+
for (let index = 0;index < storage.length; index++) {
|
|
365
|
+
const key = storage.key(index);
|
|
366
|
+
if (key?.startsWith(OAUTH_STORAGE_PREFIX)) {
|
|
367
|
+
const expiry = guarded(() => JSON.parse(storage.getItem(key) ?? "null")?.e, undefined);
|
|
368
|
+
if (typeof expiry !== "number" || expiry <= environment.now()) {
|
|
369
|
+
stale.push(key);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
for (const key of stale) {
|
|
374
|
+
storage.removeItem(key);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
available: () => guarded(() => {
|
|
379
|
+
if (!storage) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
382
|
+
const probe = `${OAUTH_STORAGE_PREFIX}probe`;
|
|
383
|
+
storage.setItem(probe, "1");
|
|
384
|
+
storage.removeItem(probe);
|
|
385
|
+
return true;
|
|
386
|
+
}, false),
|
|
387
|
+
save: (attemptId, binding, intent) => guarded(() => {
|
|
388
|
+
if (!storage) {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
prune();
|
|
392
|
+
storage.setItem(keyOf(attemptId), JSON.stringify({
|
|
393
|
+
b: binding,
|
|
394
|
+
e: environment.now() + OAUTH_BINDING_TTL_MS,
|
|
395
|
+
s: scope,
|
|
396
|
+
k: intent
|
|
397
|
+
}));
|
|
398
|
+
return true;
|
|
399
|
+
}, false),
|
|
400
|
+
read: (attemptId) => guarded(() => {
|
|
401
|
+
prune();
|
|
402
|
+
const entry = JSON.parse(storage?.getItem(keyOf(attemptId)) ?? "null");
|
|
403
|
+
return isRecord3(entry) && typeof entry.b === "string" && entry.s === scope && (entry.k === "sign_in" || entry.k === "link") ? { binding: entry.b, intent: entry.k } : null;
|
|
404
|
+
}, null),
|
|
405
|
+
remove(attemptId) {
|
|
406
|
+
guarded(() => storage?.removeItem(keyOf(attemptId)), undefined);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function readOAuthFragment(url) {
|
|
411
|
+
const at = url.indexOf("#");
|
|
412
|
+
if (at === -1) {
|
|
413
|
+
return null;
|
|
414
|
+
}
|
|
415
|
+
const fragment = new URLSearchParams(url.slice(at + 1));
|
|
416
|
+
const ticket = fragment.get(OAUTH_TICKET_PARAM);
|
|
417
|
+
const error = fragment.get(OAUTH_ERROR_PARAM);
|
|
418
|
+
if (!ticket && !error) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
const attemptId = fragment.get(EMAIL_LINK_ATTEMPT_PARAM2) ?? "";
|
|
422
|
+
for (const name of [OAUTH_TICKET_PARAM, OAUTH_ERROR_PARAM, EMAIL_LINK_ATTEMPT_PARAM2]) {
|
|
423
|
+
fragment.delete(name);
|
|
424
|
+
}
|
|
425
|
+
const rest = fragment.toString();
|
|
426
|
+
return {
|
|
427
|
+
attemptId,
|
|
428
|
+
ticket: ticket || null,
|
|
429
|
+
error: ticket ? null : error,
|
|
430
|
+
cleanUrl: `${url.slice(0, at)}${rest ? `#${rest}` : ""}`
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
function createOAuthTicketHolder() {
|
|
434
|
+
let held = null;
|
|
435
|
+
return {
|
|
436
|
+
get: () => held,
|
|
437
|
+
set(next) {
|
|
438
|
+
held = next;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
function discardOAuthCallback(context) {
|
|
443
|
+
const held = context.held.get();
|
|
444
|
+
if (held) {
|
|
445
|
+
context.held.set(null);
|
|
446
|
+
context.oauth.remove(held.attemptId);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function isLinkStart(value) {
|
|
450
|
+
return isRecord3(value) && typeof value.attemptId === "string" && value.attemptId !== "" && typeof value.authorizationUrl === "string" && typeof value.binding === "string";
|
|
451
|
+
}
|
|
452
|
+
function isIdentity(value) {
|
|
453
|
+
return isRecord3(value) && typeof value.id === "string" && typeof value.provider === "string";
|
|
454
|
+
}
|
|
455
|
+
function isIdentityList(value) {
|
|
456
|
+
return isRecord3(value) && Array.isArray(value.data) && value.data.every(isIdentity);
|
|
457
|
+
}
|
|
458
|
+
async function startOAuth(context, input, intent) {
|
|
459
|
+
const { environment, oauth, transport, session } = context;
|
|
460
|
+
discardOAuthCallback(context);
|
|
461
|
+
if (!oauth.available()) {
|
|
462
|
+
throw clientError("storage.failed", context.messages());
|
|
463
|
+
}
|
|
464
|
+
const page = environment.page;
|
|
465
|
+
if (page && originOf(input.redirectUrl) !== originOf(page.url())) {
|
|
466
|
+
throw clientError("link.cross_origin", context.messages());
|
|
467
|
+
}
|
|
468
|
+
const body = { provider: input.provider, redirectUrl: input.redirectUrl };
|
|
469
|
+
let attemptId;
|
|
470
|
+
let started;
|
|
471
|
+
if (intent === "link") {
|
|
472
|
+
const answer = await session.authorized("startIdentityLink", { body });
|
|
473
|
+
if (!isLinkStart(answer)) {
|
|
474
|
+
throw clientError("response.invalid", context.messages());
|
|
475
|
+
}
|
|
476
|
+
attemptId = answer.attemptId;
|
|
477
|
+
started = answer;
|
|
478
|
+
} else {
|
|
479
|
+
const answer = await transport.call("startOAuthSignIn", { body });
|
|
480
|
+
if (!isRecord3(answer) || !isRecord3(answer.attempt) || typeof answer.attempt.id !== "string" || typeof answer.authorizationUrl !== "string" || typeof answer.binding !== "string") {
|
|
481
|
+
throw clientError("response.invalid", context.messages());
|
|
482
|
+
}
|
|
483
|
+
attemptId = answer.attempt.id;
|
|
484
|
+
started = { authorizationUrl: answer.authorizationUrl, binding: answer.binding };
|
|
485
|
+
}
|
|
486
|
+
return keepAndNavigate(context, attemptId, started, input.navigate, intent);
|
|
487
|
+
}
|
|
488
|
+
function keepAndNavigate(context, attemptId, started, navigate, intent) {
|
|
489
|
+
const { oauth, environment } = context;
|
|
490
|
+
if (!/^https?:\/\//i.test(started.authorizationUrl)) {
|
|
491
|
+
throw clientError("response.invalid", context.messages());
|
|
492
|
+
}
|
|
493
|
+
if (!oauth.save(attemptId, started.binding, intent)) {
|
|
494
|
+
throw clientError("storage.failed", context.messages());
|
|
495
|
+
}
|
|
496
|
+
if (navigate !== false) {
|
|
497
|
+
environment.page?.assign?.(started.authorizationUrl);
|
|
498
|
+
}
|
|
499
|
+
return { url: started.authorizationUrl };
|
|
500
|
+
}
|
|
501
|
+
async function startSso(context, bound, input) {
|
|
502
|
+
discardOAuthCallback(context);
|
|
503
|
+
if (!context.oauth.available()) {
|
|
504
|
+
throw clientError("storage.failed", context.messages());
|
|
505
|
+
}
|
|
506
|
+
const page = context.environment.page;
|
|
507
|
+
if (page && originOf(input.redirectUrl) !== originOf(page.url())) {
|
|
508
|
+
throw clientError("link.cross_origin", context.messages());
|
|
509
|
+
}
|
|
510
|
+
const answer = await context.transport.call("startSsoSignIn", {
|
|
511
|
+
...bound,
|
|
512
|
+
body: { redirectUrl: input.redirectUrl }
|
|
513
|
+
});
|
|
514
|
+
if (!isRecord3(answer) || typeof answer.authorizationUrl !== "string" || typeof answer.binding !== "string") {
|
|
515
|
+
throw clientError("response.invalid", context.messages());
|
|
516
|
+
}
|
|
517
|
+
return keepAndNavigate(context, bound.params.attemptId, { authorizationUrl: answer.authorizationUrl, binding: answer.binding }, input.navigate, "sign_in");
|
|
518
|
+
}
|
|
519
|
+
function originOf(url) {
|
|
520
|
+
try {
|
|
521
|
+
return new URL(url).origin;
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
function failed(code, messages) {
|
|
527
|
+
const known = Object.hasOwn(ERROR_DEFINITIONS2, code) ? code : "oauth.provider_error";
|
|
528
|
+
return {
|
|
529
|
+
status: "error",
|
|
530
|
+
code: known,
|
|
531
|
+
message: formatMessage(known, { messages })
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
async function handleOAuthCallback(context) {
|
|
535
|
+
const { environment, oauth, held, transport, session } = context;
|
|
536
|
+
const page = environment.page;
|
|
537
|
+
if (!page) {
|
|
538
|
+
return { status: "none" };
|
|
539
|
+
}
|
|
540
|
+
const arrived = readOAuthFragment(page.url());
|
|
541
|
+
let answer;
|
|
542
|
+
if (arrived) {
|
|
543
|
+
try {
|
|
544
|
+
page.replaceUrl(arrived.cleanUrl);
|
|
545
|
+
} catch {}
|
|
546
|
+
discardOAuthCallback(context);
|
|
547
|
+
if (arrived.error !== null || arrived.ticket === null) {
|
|
548
|
+
oauth.remove(arrived.attemptId);
|
|
549
|
+
return arrived.error !== null ? failed(arrived.error, context.messages()) : { status: "different_browser" };
|
|
550
|
+
}
|
|
551
|
+
answer = {
|
|
552
|
+
attemptId: arrived.attemptId,
|
|
553
|
+
ticket: arrived.ticket,
|
|
554
|
+
until: environment.now() + OAUTH_TICKET_HOLD_MS
|
|
555
|
+
};
|
|
556
|
+
} else {
|
|
557
|
+
const waiting = held.get();
|
|
558
|
+
if (!waiting) {
|
|
559
|
+
return { status: "none" };
|
|
560
|
+
}
|
|
561
|
+
if (waiting.until <= environment.now()) {
|
|
562
|
+
discardOAuthCallback(context);
|
|
563
|
+
return failed("oauth.ticket_invalid", context.messages());
|
|
564
|
+
}
|
|
565
|
+
answer = waiting;
|
|
566
|
+
}
|
|
567
|
+
const kept = oauth.read(answer.attemptId);
|
|
568
|
+
const settle = () => {
|
|
569
|
+
held.set(null);
|
|
570
|
+
oauth.remove(answer.attemptId);
|
|
571
|
+
};
|
|
572
|
+
if (!kept) {
|
|
573
|
+
settle();
|
|
574
|
+
return { status: "different_browser" };
|
|
575
|
+
}
|
|
576
|
+
held.set(answer);
|
|
577
|
+
const body = { ticket: answer.ticket, attemptId: answer.attemptId, binding: kept.binding };
|
|
578
|
+
try {
|
|
579
|
+
let outcome;
|
|
580
|
+
if (kept.intent === "link") {
|
|
581
|
+
const identity = await session.authorized("exchangeIdentityLinkTicket", { body });
|
|
582
|
+
if (!isIdentity(identity)) {
|
|
583
|
+
throw clientError("response.invalid", context.messages());
|
|
584
|
+
}
|
|
585
|
+
outcome = { status: "linked", identity };
|
|
586
|
+
} else {
|
|
587
|
+
const attempt = await transport.call("exchangeOAuthTicket", { body });
|
|
588
|
+
const complete = isRecord3(attempt) && isRecord3(attempt.step) && attempt.step.status === "complete";
|
|
589
|
+
outcome = {
|
|
590
|
+
status: complete ? "complete" : "needs_step",
|
|
591
|
+
flow: await signInFlow(context, attempt)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
settle();
|
|
595
|
+
return outcome;
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (isRetryableOAuthError(error)) {
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
settle();
|
|
601
|
+
if (!isTulaError(error) || error.status === 0) {
|
|
602
|
+
throw error;
|
|
603
|
+
}
|
|
604
|
+
if (error.code === "oauth.different_browser") {
|
|
605
|
+
return { status: "different_browser" };
|
|
606
|
+
}
|
|
607
|
+
return { status: "error", code: error.code, message: error.message };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/passkey.ts
|
|
612
|
+
function isRecord4(value) {
|
|
613
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
614
|
+
}
|
|
332
615
|
function base64UrlToBytes(text) {
|
|
333
616
|
const binary = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
|
334
617
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -346,7 +629,7 @@ function bytesToBase64Url(buffer) {
|
|
|
346
629
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
347
630
|
}
|
|
348
631
|
function descriptors(list) {
|
|
349
|
-
return Array.isArray(list) ? list.map((entry) =>
|
|
632
|
+
return Array.isArray(list) ? list.map((entry) => isRecord4(entry) && typeof entry.id === "string" ? { ...entry, id: base64UrlToBytes(entry.id) } : entry) : undefined;
|
|
350
633
|
}
|
|
351
634
|
function creationOptionsFromJson(options) {
|
|
352
635
|
return {
|
|
@@ -368,7 +651,7 @@ function isBinary(value) {
|
|
|
368
651
|
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
369
652
|
}
|
|
370
653
|
function credentialToJson(credential) {
|
|
371
|
-
const response =
|
|
654
|
+
const response = isRecord4(credential.response) ? credential.response : {};
|
|
372
655
|
const encoded = {};
|
|
373
656
|
for (const name of [
|
|
374
657
|
"clientDataJSON",
|
|
@@ -401,7 +684,7 @@ function call(target, method) {
|
|
|
401
684
|
return typeof candidate === "function" ? candidate.call(target) : undefined;
|
|
402
685
|
}
|
|
403
686
|
function toJson(credential) {
|
|
404
|
-
if (!
|
|
687
|
+
if (!isRecord4(credential)) {
|
|
405
688
|
return null;
|
|
406
689
|
}
|
|
407
690
|
if (typeof credential.toJSON === "function") {
|
|
@@ -415,13 +698,13 @@ function isBase64Url(value) {
|
|
|
415
698
|
return typeof value === "string" && /^[A-Za-z0-9_-]+$/.test(value);
|
|
416
699
|
}
|
|
417
700
|
function isRegistration(value) {
|
|
418
|
-
return
|
|
701
|
+
return isRecord4(value) && isBase64Url(value.id) && value.type === "public-key" && isRecord4(value.response) && isBase64Url(value.response.clientDataJSON) && isBase64Url(value.response.attestationObject);
|
|
419
702
|
}
|
|
420
703
|
function isAssertion(value) {
|
|
421
|
-
return
|
|
704
|
+
return isRecord4(value) && isBase64Url(value.id) && value.type === "public-key" && isRecord4(value.response) && isBase64Url(value.response.clientDataJSON) && isBase64Url(value.response.authenticatorData) && isBase64Url(value.response.signature);
|
|
422
705
|
}
|
|
423
706
|
function ceremonyCode(error) {
|
|
424
|
-
const name =
|
|
707
|
+
const name = isRecord4(error) || error instanceof Error ? error.name : "";
|
|
425
708
|
if (name === "InvalidStateError") {
|
|
426
709
|
return "passkey.already_on_device";
|
|
427
710
|
}
|
|
@@ -473,7 +756,7 @@ function browserProvider(globals) {
|
|
|
473
756
|
};
|
|
474
757
|
}
|
|
475
758
|
function isPasskey(value) {
|
|
476
|
-
return
|
|
759
|
+
return isRecord4(value) && typeof value.id === "string" && value.id !== "" && typeof value.name === "string" && typeof value.synced === "boolean" && typeof value.createdAt === "string" && (value.lastUsedAt === null || typeof value.lastUsedAt === "string");
|
|
477
760
|
}
|
|
478
761
|
function toPasskey(value) {
|
|
479
762
|
const { id, name, synced, createdAt, lastUsedAt } = value;
|
|
@@ -483,22 +766,23 @@ function isPasskeyAnswer(value) {
|
|
|
483
766
|
return isPasskey(value);
|
|
484
767
|
}
|
|
485
768
|
function isPasskeyList(value) {
|
|
486
|
-
return
|
|
769
|
+
return isRecord4(value) && Array.isArray(value.passkeys) && value.passkeys.every(isPasskey);
|
|
487
770
|
}
|
|
488
771
|
function isCreationOptions(value) {
|
|
489
|
-
return
|
|
772
|
+
return isRecord4(value) && isBase64Url(value.challenge) && isRecord4(value.rp) && typeof value.rp.id === "string" && isRecord4(value.user) && isBase64Url(value.user.id) && Array.isArray(value.pubKeyCredParams);
|
|
490
773
|
}
|
|
491
774
|
function isRequestOptions(value) {
|
|
492
|
-
return
|
|
775
|
+
return isRecord4(value) && isBase64Url(value.challenge) && typeof value.rpId === "string";
|
|
493
776
|
}
|
|
494
777
|
function isPasskeySignInStart(value) {
|
|
495
|
-
return
|
|
778
|
+
return isRecord4(value) && isRecord4(value.attempt) && isRequestOptions(value.options);
|
|
496
779
|
}
|
|
497
780
|
|
|
498
781
|
// src/session.ts
|
|
499
782
|
var ACCESS_TOKEN_EXPIRY_SKEW_MS = 1e4;
|
|
500
783
|
var REFRESH_TIMEOUT_MS = 8000;
|
|
501
784
|
var REFRESH_RETRY_WINDOW_MS = 1e4;
|
|
785
|
+
var MAX_UNDELIVERED = 8;
|
|
502
786
|
var MIN_REFRESH_RETRY_TIMEOUT_MS = 1000;
|
|
503
787
|
var MAX_REFRESH_BACKOFF_MS = 5 * 60000;
|
|
504
788
|
function refreshBudgetMs(refreshTimeoutMs) {
|
|
@@ -507,27 +791,40 @@ function refreshBudgetMs(refreshTimeoutMs) {
|
|
|
507
791
|
var LOCK_WAIT_MARGIN_MS = 2000;
|
|
508
792
|
var MAX_ENDED_SESSIONS = 32;
|
|
509
793
|
var CHANNEL_VERSION2 = 1;
|
|
510
|
-
function
|
|
794
|
+
function isRecord5(value) {
|
|
511
795
|
return typeof value === "object" && value !== null;
|
|
512
796
|
}
|
|
513
797
|
function jwtLifetimeMs(token) {
|
|
514
798
|
try {
|
|
515
799
|
const payload = token.split(".")[1] ?? "";
|
|
516
800
|
const claims = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
517
|
-
if (
|
|
801
|
+
if (isRecord5(claims) && typeof claims.iat === "number" && typeof claims.exp === "number" && claims.exp > claims.iat) {
|
|
518
802
|
return (claims.exp - claims.iat) * 1000;
|
|
519
803
|
}
|
|
520
804
|
} catch {}
|
|
521
805
|
return;
|
|
522
806
|
}
|
|
807
|
+
function organizationIdOf(token) {
|
|
808
|
+
try {
|
|
809
|
+
const payload = token.split(".")[1] ?? "";
|
|
810
|
+
const claims = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
811
|
+
if (isRecord5(claims) && typeof claims.org_id === "string" && claims.org_id !== "") {
|
|
812
|
+
return claims.org_id;
|
|
813
|
+
}
|
|
814
|
+
} catch {}
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
function isActiveOrganization(value) {
|
|
818
|
+
return isRecord5(value) && typeof value.sessionId === "string" && value.sessionId !== "" && (value.organizationId === null || typeof value.organizationId === "string" && value.organizationId !== "") && (value.accessToken === undefined || typeof value.accessToken === "string" && value.accessToken !== "" && (jwtLifetimeMs(value.accessToken) !== undefined || typeof value.accessTokenExpiresAt === "string" && !Number.isNaN(Date.parse(value.accessTokenExpiresAt))));
|
|
819
|
+
}
|
|
523
820
|
function accessTokenLifetimeMs(issued, now) {
|
|
524
821
|
return jwtLifetimeMs(issued.accessToken) ?? Math.max(0, Date.parse(issued.accessTokenExpiresAt ?? "") - now);
|
|
525
822
|
}
|
|
526
823
|
function isSessionTokens(value) {
|
|
527
|
-
return
|
|
824
|
+
return isRecord5(value) && typeof value.accessToken === "string" && value.accessToken !== "" && typeof value.sessionId === "string" && value.sessionId !== "" && (value.refreshToken === undefined || typeof value.refreshToken === "string") && (jwtLifetimeMs(value.accessToken) !== undefined || typeof value.accessTokenExpiresAt === "string" && !Number.isNaN(Date.parse(value.accessTokenExpiresAt)));
|
|
528
825
|
}
|
|
529
826
|
function isStatefulSession(value) {
|
|
530
|
-
return
|
|
827
|
+
return isRecord5(value) && typeof value.sessionId === "string" && value.sessionId !== "" && value.accessToken === undefined && value.accessTokenExpiresAt === undefined && value.refreshToken === undefined;
|
|
531
828
|
}
|
|
532
829
|
function sessionIsGone(error) {
|
|
533
830
|
return error.code.startsWith("session.") || error.code === "auth.unauthenticated" || error.code === "auth.user_banned";
|
|
@@ -553,13 +850,13 @@ function createSessionManager(options) {
|
|
|
553
850
|
let refreshing = null;
|
|
554
851
|
let backoff = null;
|
|
555
852
|
let orphan = null;
|
|
556
|
-
let undelivered =
|
|
853
|
+
let undelivered = [];
|
|
557
854
|
let channel;
|
|
558
855
|
let channelOpened = false;
|
|
559
856
|
const endedSessions = new Set;
|
|
560
857
|
let signingOut = 0;
|
|
561
858
|
function setState(next) {
|
|
562
|
-
const same = next.status === state.status && (next.status !== "signed-in" || state.status === "signed-in" && next.sessionId === state.sessionId && next.user === state.user);
|
|
859
|
+
const same = next.status === state.status && (next.status !== "signed-in" || state.status === "signed-in" && next.sessionId === state.sessionId && next.user === state.user && next.organizationId === state.organizationId);
|
|
563
860
|
if (same) {
|
|
564
861
|
return;
|
|
565
862
|
}
|
|
@@ -616,7 +913,12 @@ function createSessionManager(options) {
|
|
|
616
913
|
if (generation !== mine) {
|
|
617
914
|
return;
|
|
618
915
|
}
|
|
619
|
-
setState({
|
|
916
|
+
setState({
|
|
917
|
+
status: "signed-in",
|
|
918
|
+
sessionId: current.sessionId,
|
|
919
|
+
user,
|
|
920
|
+
organizationId: current.organizationId
|
|
921
|
+
});
|
|
620
922
|
}
|
|
621
923
|
function commit(issued) {
|
|
622
924
|
generation += 1;
|
|
@@ -627,6 +929,7 @@ function createSessionManager(options) {
|
|
|
627
929
|
tokens = {
|
|
628
930
|
accessToken: null,
|
|
629
931
|
sessionId: issued.sessionId,
|
|
932
|
+
organizationId: tokens?.sessionId === issued.sessionId ? tokens.organizationId : null,
|
|
630
933
|
expiresAt: Number.MAX_SAFE_INTEGER,
|
|
631
934
|
refreshAt: Number.MAX_SAFE_INTEGER
|
|
632
935
|
};
|
|
@@ -635,6 +938,7 @@ function createSessionManager(options) {
|
|
|
635
938
|
tokens = {
|
|
636
939
|
accessToken,
|
|
637
940
|
sessionId: issued.sessionId,
|
|
941
|
+
organizationId: organizationIdOf(accessToken),
|
|
638
942
|
expiresAt: now + lifetime,
|
|
639
943
|
refreshAt: now + lifetime - Math.min(ACCESS_TOKEN_EXPIRY_SKEW_MS, lifetime / 2)
|
|
640
944
|
};
|
|
@@ -674,7 +978,7 @@ function createSessionManager(options) {
|
|
|
674
978
|
return refreshToken;
|
|
675
979
|
}
|
|
676
980
|
function receive(data) {
|
|
677
|
-
if (!
|
|
981
|
+
if (!isRecord5(data) || data.v !== CHANNEL_VERSION2) {
|
|
678
982
|
return;
|
|
679
983
|
}
|
|
680
984
|
if (data.type === "signed-out") {
|
|
@@ -694,6 +998,7 @@ function createSessionManager(options) {
|
|
|
694
998
|
tokens = {
|
|
695
999
|
accessToken: data.accessToken,
|
|
696
1000
|
sessionId: data.sessionId,
|
|
1001
|
+
organizationId: typeof data.organizationId === "string" && data.organizationId !== "" ? data.organizationId : null,
|
|
697
1002
|
expiresAt: data.expiresAt,
|
|
698
1003
|
refreshAt: data.refreshAt
|
|
699
1004
|
};
|
|
@@ -803,6 +1108,9 @@ function createSessionManager(options) {
|
|
|
803
1108
|
return { token: issued.accessToken ?? null, installed, failure: await persist(issued) };
|
|
804
1109
|
}
|
|
805
1110
|
async function runRefresh(explicit) {
|
|
1111
|
+
if (signingOut > 0) {
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
806
1114
|
if (!explicit && backoff && environment.now() < backoff.until) {
|
|
807
1115
|
throw backoff.error;
|
|
808
1116
|
}
|
|
@@ -857,7 +1165,6 @@ function createSessionManager(options) {
|
|
|
857
1165
|
throw clientError("response.invalid", options.messages());
|
|
858
1166
|
}
|
|
859
1167
|
openChannel();
|
|
860
|
-
undelivered = null;
|
|
861
1168
|
const installed = commit(issued);
|
|
862
1169
|
const failure = await persist(issued);
|
|
863
1170
|
await settle(installed);
|
|
@@ -895,19 +1202,29 @@ function createSessionManager(options) {
|
|
|
895
1202
|
failure = clientError("storage.failed", options.messages(), cause);
|
|
896
1203
|
}
|
|
897
1204
|
}
|
|
898
|
-
const newest = orphan ?? presented
|
|
1205
|
+
const newest = orphan ?? presented;
|
|
899
1206
|
orphan = null;
|
|
900
|
-
|
|
1207
|
+
const owed = [...new Set([newest, ...undelivered].filter((t) => !!t))];
|
|
1208
|
+
if (web) {
|
|
901
1209
|
try {
|
|
902
|
-
await withLock(() => transport.call("signOut", { body:
|
|
903
|
-
undelivered = null;
|
|
1210
|
+
await withLock(() => transport.call("signOut", { body: {} }));
|
|
904
1211
|
} catch (error) {
|
|
905
1212
|
failure = error;
|
|
906
|
-
|
|
1213
|
+
}
|
|
1214
|
+
} else {
|
|
1215
|
+
const kept = [];
|
|
1216
|
+
for (const token of owed) {
|
|
1217
|
+
try {
|
|
1218
|
+
await withLock(() => transport.call("signOut", { body: { refreshToken: token } }));
|
|
1219
|
+
} catch (error) {
|
|
1220
|
+
failure = failure ?? error;
|
|
907
1221
|
const over = isTulaError(error) && error.code.startsWith("session.");
|
|
908
|
-
|
|
1222
|
+
if (!over) {
|
|
1223
|
+
kept.push(token);
|
|
1224
|
+
}
|
|
909
1225
|
}
|
|
910
1226
|
}
|
|
1227
|
+
undelivered = kept.slice(0, MAX_UNDELIVERED);
|
|
911
1228
|
}
|
|
912
1229
|
if (failure) {
|
|
913
1230
|
throw failure;
|
|
@@ -996,6 +1313,42 @@ function createSessionManager(options) {
|
|
|
996
1313
|
throw unauthenticated();
|
|
997
1314
|
}
|
|
998
1315
|
}
|
|
1316
|
+
let organizationEpoch = 0;
|
|
1317
|
+
async function setActiveOrganization(organizationId) {
|
|
1318
|
+
openChannel();
|
|
1319
|
+
organizationEpoch += 1;
|
|
1320
|
+
await refreshing?.catch(ignore);
|
|
1321
|
+
const { answer, sent } = await send("setActiveOrganization", { body: { organizationId } });
|
|
1322
|
+
if (!isActiveOrganization(answer)) {
|
|
1323
|
+
throw clientError("response.invalid", options.messages());
|
|
1324
|
+
}
|
|
1325
|
+
if (state.status === "signed-out" || tokens?.sessionId !== answer.sessionId) {
|
|
1326
|
+
throw unauthenticated();
|
|
1327
|
+
}
|
|
1328
|
+
if (answer.accessToken === undefined) {
|
|
1329
|
+
if (generation === sent && !refreshing && tokens) {
|
|
1330
|
+
tokens = { ...tokens, organizationId: answer.organizationId };
|
|
1331
|
+
post({ type: "session", ...tokens });
|
|
1332
|
+
if (state.status === "signed-in") {
|
|
1333
|
+
setState({ ...state, organizationId: answer.organizationId });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return answer;
|
|
1337
|
+
}
|
|
1338
|
+
if (generation === sent && !refreshing) {
|
|
1339
|
+
await settle(commit({
|
|
1340
|
+
sessionId: answer.sessionId,
|
|
1341
|
+
accessToken: answer.accessToken,
|
|
1342
|
+
accessTokenExpiresAt: answer.accessTokenExpiresAt
|
|
1343
|
+
}));
|
|
1344
|
+
return answer;
|
|
1345
|
+
}
|
|
1346
|
+
await refreshing?.catch(ignore);
|
|
1347
|
+
if (tokens?.sessionId !== answer.sessionId || !await refresh(true)) {
|
|
1348
|
+
throw unauthenticated();
|
|
1349
|
+
}
|
|
1350
|
+
return answer;
|
|
1351
|
+
}
|
|
999
1352
|
return {
|
|
1000
1353
|
state: () => state,
|
|
1001
1354
|
subscribe(listener) {
|
|
@@ -1020,6 +1373,16 @@ function createSessionManager(options) {
|
|
|
1020
1373
|
},
|
|
1021
1374
|
authorized,
|
|
1022
1375
|
stepUp,
|
|
1376
|
+
setActiveOrganization,
|
|
1377
|
+
organizationEpoch: () => organizationEpoch,
|
|
1378
|
+
noteActiveOrganization(sessionId, organizationId, epoch) {
|
|
1379
|
+
if (epoch === organizationEpoch && tokens && tokens.accessToken === null && tokens.sessionId === sessionId) {
|
|
1380
|
+
tokens = { ...tokens, organizationId };
|
|
1381
|
+
if (state.status === "signed-in" && state.sessionId === sessionId) {
|
|
1382
|
+
setState({ ...state, organizationId });
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
},
|
|
1023
1386
|
expire() {
|
|
1024
1387
|
if (tokens) {
|
|
1025
1388
|
tokens = { ...tokens, refreshAt: 0 };
|
|
@@ -1029,11 +1392,11 @@ function createSessionManager(options) {
|
|
|
1029
1392
|
}
|
|
1030
1393
|
|
|
1031
1394
|
// src/flows.ts
|
|
1032
|
-
function
|
|
1395
|
+
function isRecord6(value) {
|
|
1033
1396
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1034
1397
|
}
|
|
1035
1398
|
function isUsableAttempt(value) {
|
|
1036
|
-
if (!
|
|
1399
|
+
if (!isRecord6(value) || typeof value.id !== "string" || value.id === "" || !isRecord6(value.step) || typeof value.step.status !== "string") {
|
|
1037
1400
|
return false;
|
|
1038
1401
|
}
|
|
1039
1402
|
return value.step.status === "complete" ? isSessionTokens(value.session) || isStatefulSession(value.session) : value.session === undefined;
|
|
@@ -1127,7 +1490,7 @@ function enrolmentActions(context, attempt, kind) {
|
|
|
1127
1490
|
}),
|
|
1128
1491
|
confirmTotpEnrolment: ({ code }) => attempt.exclusive(async (bound) => {
|
|
1129
1492
|
const next = await context.transport.call(confirm, { ...bound, body: { code } });
|
|
1130
|
-
const backupCodes =
|
|
1493
|
+
const backupCodes = isRecord6(next) ? next.backupCodes : undefined;
|
|
1131
1494
|
if (!isCodes(backupCodes)) {
|
|
1132
1495
|
throw clientError("response.invalid", context.messages());
|
|
1133
1496
|
}
|
|
@@ -1146,7 +1509,7 @@ function secondFactorAction(context, attempt, operation) {
|
|
|
1146
1509
|
const submit = async (bound, proof) => {
|
|
1147
1510
|
const body = proof.method === "passkey" ? { method: "passkey", credential: proof.credential } : { method: proof.method, code: proof.code };
|
|
1148
1511
|
const next = await context.transport.call(operation, { ...bound, body });
|
|
1149
|
-
const remaining =
|
|
1512
|
+
const remaining = isRecord6(next) ? next.backupCodesRemaining : undefined;
|
|
1150
1513
|
if (remaining !== undefined && !(Number.isInteger(remaining) && Number(remaining) >= 0)) {
|
|
1151
1514
|
throw clientError("response.invalid", context.messages());
|
|
1152
1515
|
}
|
|
@@ -1211,6 +1574,7 @@ async function signInFlow(context, started, first) {
|
|
|
1211
1574
|
},
|
|
1212
1575
|
attemptFirstFactor: (input) => attempt.step((bound) => transport.call("attemptSignInFirstFactor", { ...bound, body: input })),
|
|
1213
1576
|
waitForEmailLink: (options) => waiting.wait(options?.signal),
|
|
1577
|
+
startSso: (input) => attempt.exclusive((bound) => startSso(context, bound, input)),
|
|
1214
1578
|
discard() {
|
|
1215
1579
|
attempt.discard();
|
|
1216
1580
|
waiting.stop();
|
|
@@ -1315,377 +1679,116 @@ function emailLinkWait(context, attempt) {
|
|
|
1315
1679
|
stopListening();
|
|
1316
1680
|
if (channel) {
|
|
1317
1681
|
channel.onmessage = null;
|
|
1318
|
-
channel.close?.();
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
return {
|
|
1323
|
-
wait(signal) {
|
|
1324
|
-
if (attempt.finished()) {
|
|
1325
|
-
return Promise.reject(attempt.refused("flow.invalid_step"));
|
|
1326
|
-
}
|
|
1327
|
-
if (signal?.aborted) {
|
|
1328
|
-
return Promise.resolve(attempt.snapshot().step);
|
|
1329
|
-
}
|
|
1330
|
-
if (stopping) {
|
|
1331
|
-
stopping = false;
|
|
1332
|
-
resumed = true;
|
|
1333
|
-
}
|
|
1334
|
-
loop ??= run();
|
|
1335
|
-
const running = loop;
|
|
1336
|
-
return new Promise((resolve, reject) => {
|
|
1337
|
-
const leave = () => {
|
|
1338
|
-
callers.delete(release);
|
|
1339
|
-
signal?.removeEventListener("abort", release);
|
|
1340
|
-
};
|
|
1341
|
-
const release = () => {
|
|
1342
|
-
leave();
|
|
1343
|
-
resolve(attempt.snapshot().step);
|
|
1344
|
-
if (callers.size === 0) {
|
|
1345
|
-
stopping = true;
|
|
1346
|
-
wake?.();
|
|
1347
|
-
}
|
|
1348
|
-
};
|
|
1349
|
-
callers.add(release);
|
|
1350
|
-
signal?.addEventListener("abort", release);
|
|
1351
|
-
running.then((step) => {
|
|
1352
|
-
leave();
|
|
1353
|
-
resolve(step);
|
|
1354
|
-
}, (error) => {
|
|
1355
|
-
leave();
|
|
1356
|
-
reject(error);
|
|
1357
|
-
});
|
|
1358
|
-
});
|
|
1359
|
-
},
|
|
1360
|
-
stop
|
|
1361
|
-
};
|
|
1362
|
-
}
|
|
1363
|
-
async function passwordResetFlow(context, started) {
|
|
1364
|
-
const { transport } = context;
|
|
1365
|
-
const attempt = createAttempt(context, started);
|
|
1366
|
-
await attempt.accept(started);
|
|
1367
|
-
return flowObject(attempt, {
|
|
1368
|
-
submit: ({ code, password }) => attempt.step((bound) => transport.call("submitPasswordReset", { ...bound, body: { code, password } })),
|
|
1369
|
-
resendCode: () => attempt.step((bound) => transport.call("resendPasswordResetCode", bound)),
|
|
1370
|
-
...secondFactorAction(context, attempt, "submitPasswordResetSecondFactor"),
|
|
1371
|
-
...enrolmentActions(context, attempt, "password_reset"),
|
|
1372
|
-
discard: () => attempt.discard()
|
|
1373
|
-
});
|
|
1374
|
-
}
|
|
1375
|
-
var PASSKEY_AUTOFILL_ROUND_MS = 240000;
|
|
1376
|
-
async function askForPasskey(context, authenticator, options, request) {
|
|
1377
|
-
if (!request.autofill) {
|
|
1378
|
-
return authenticator.get(options, { signal: request.signal });
|
|
1379
|
-
}
|
|
1380
|
-
const round = new AbortController;
|
|
1381
|
-
let lapsed = false;
|
|
1382
|
-
const stop = () => round.abort();
|
|
1383
|
-
request.signal?.addEventListener("abort", stop);
|
|
1384
|
-
const cancel = context.environment.setTimer(() => {
|
|
1385
|
-
lapsed = true;
|
|
1386
|
-
round.abort();
|
|
1387
|
-
}, PASSKEY_AUTOFILL_ROUND_MS);
|
|
1388
|
-
try {
|
|
1389
|
-
if (request.signal?.aborted) {
|
|
1390
|
-
round.abort();
|
|
1391
|
-
}
|
|
1392
|
-
return await authenticator.get(options, { signal: round.signal, autofill: true });
|
|
1393
|
-
} catch (error) {
|
|
1394
|
-
if (lapsed && !request.signal?.aborted) {
|
|
1395
|
-
return null;
|
|
1396
|
-
}
|
|
1397
|
-
throw error;
|
|
1398
|
-
} finally {
|
|
1399
|
-
cancel();
|
|
1400
|
-
request.signal?.removeEventListener("abort", stop);
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
async function passkeySignIn(context, request = {}) {
|
|
1404
|
-
const authenticator = context.passkeys();
|
|
1405
|
-
for (;; ) {
|
|
1406
|
-
const started = await context.transport.call("startPasskeySignIn", {});
|
|
1407
|
-
if (!isPasskeySignInStart(started)) {
|
|
1408
|
-
throw clientError("response.invalid", context.messages());
|
|
1409
|
-
}
|
|
1410
|
-
const credential = await askForPasskey(context, authenticator, started.options, request);
|
|
1411
|
-
if (credential !== null) {
|
|
1412
|
-
return signInFlow(context, started.attempt, (bound) => context.transport.call("submitSignInPasskey", { ...bound, body: { credential } }));
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
async function idTokenSignIn(context, provider) {
|
|
1417
|
-
const started = await context.transport.call("startIdTokenSignIn", {
|
|
1418
|
-
body: { provider }
|
|
1419
|
-
});
|
|
1420
|
-
if (!isRecord5(started) || typeof started.nonce !== "string" || !isUsableAttempt(started.attempt)) {
|
|
1421
|
-
throw clientError("response.invalid", context.messages());
|
|
1422
|
-
}
|
|
1423
|
-
const { attempt } = started;
|
|
1424
|
-
return {
|
|
1425
|
-
nonce: started.nonce,
|
|
1426
|
-
exchange: (idToken, name) => signInFlow(context, attempt, (bound) => context.transport.call("submitSignInIdToken", { ...bound, body: { ...name, idToken } }))
|
|
1427
|
-
};
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
// src/oauth.ts
|
|
1431
|
-
import { ERROR_DEFINITIONS as ERROR_DEFINITIONS2 } from "@tula-tech/contract/error-codes";
|
|
1432
|
-
import {
|
|
1433
|
-
EMAIL_LINK_ATTEMPT_PARAM as EMAIL_LINK_ATTEMPT_PARAM2,
|
|
1434
|
-
OAUTH_ERROR_PARAM,
|
|
1435
|
-
OAUTH_TICKET_PARAM
|
|
1436
|
-
} from "@tula-tech/contract/headers";
|
|
1437
|
-
var OAUTH_BINDING_TTL_MS = 15 * 60000;
|
|
1438
|
-
var OAUTH_TICKET_HOLD_MS = 60000;
|
|
1439
|
-
var RETRYABLE = new Set([
|
|
1440
|
-
"network.failed",
|
|
1441
|
-
"network.timeout",
|
|
1442
|
-
"rate_limited"
|
|
1443
|
-
]);
|
|
1444
|
-
function isRetryableOAuthError(error) {
|
|
1445
|
-
return isTulaError(error) && RETRYABLE.has(error.code);
|
|
1446
|
-
}
|
|
1447
|
-
var OAUTH_STORAGE_PREFIX = "tula.oauth.";
|
|
1448
|
-
function isRecord6(value) {
|
|
1449
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1450
|
-
}
|
|
1451
|
-
function createOAuthStore(environment, scope) {
|
|
1452
|
-
const storage = environment.tabStorage;
|
|
1453
|
-
const keyOf = (attemptId) => `${OAUTH_STORAGE_PREFIX}${attemptId}`;
|
|
1454
|
-
function guarded(work, fallback) {
|
|
1455
|
-
try {
|
|
1456
|
-
return work();
|
|
1457
|
-
} catch {
|
|
1458
|
-
return fallback;
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
function prune() {
|
|
1462
|
-
if (!storage) {
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
const stale = [];
|
|
1466
|
-
for (let index = 0;index < storage.length; index++) {
|
|
1467
|
-
const key = storage.key(index);
|
|
1468
|
-
if (key?.startsWith(OAUTH_STORAGE_PREFIX)) {
|
|
1469
|
-
const expiry = guarded(() => JSON.parse(storage.getItem(key) ?? "null")?.e, undefined);
|
|
1470
|
-
if (typeof expiry !== "number" || expiry <= environment.now()) {
|
|
1471
|
-
stale.push(key);
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
for (const key of stale) {
|
|
1476
|
-
storage.removeItem(key);
|
|
1477
|
-
}
|
|
1478
|
-
}
|
|
1479
|
-
return {
|
|
1480
|
-
available: () => guarded(() => {
|
|
1481
|
-
if (!storage) {
|
|
1482
|
-
return false;
|
|
1483
|
-
}
|
|
1484
|
-
const probe = `${OAUTH_STORAGE_PREFIX}probe`;
|
|
1485
|
-
storage.setItem(probe, "1");
|
|
1486
|
-
storage.removeItem(probe);
|
|
1487
|
-
return true;
|
|
1488
|
-
}, false),
|
|
1489
|
-
save: (attemptId, binding, intent) => guarded(() => {
|
|
1490
|
-
if (!storage) {
|
|
1491
|
-
return false;
|
|
1492
|
-
}
|
|
1493
|
-
prune();
|
|
1494
|
-
storage.setItem(keyOf(attemptId), JSON.stringify({
|
|
1495
|
-
b: binding,
|
|
1496
|
-
e: environment.now() + OAUTH_BINDING_TTL_MS,
|
|
1497
|
-
s: scope,
|
|
1498
|
-
k: intent
|
|
1499
|
-
}));
|
|
1500
|
-
return true;
|
|
1501
|
-
}, false),
|
|
1502
|
-
read: (attemptId) => guarded(() => {
|
|
1503
|
-
prune();
|
|
1504
|
-
const entry = JSON.parse(storage?.getItem(keyOf(attemptId)) ?? "null");
|
|
1505
|
-
return isRecord6(entry) && typeof entry.b === "string" && entry.s === scope && (entry.k === "sign_in" || entry.k === "link") ? { binding: entry.b, intent: entry.k } : null;
|
|
1506
|
-
}, null),
|
|
1507
|
-
remove(attemptId) {
|
|
1508
|
-
guarded(() => storage?.removeItem(keyOf(attemptId)), undefined);
|
|
1509
|
-
}
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1512
|
-
function readOAuthFragment(url) {
|
|
1513
|
-
const at = url.indexOf("#");
|
|
1514
|
-
if (at === -1) {
|
|
1515
|
-
return null;
|
|
1516
|
-
}
|
|
1517
|
-
const fragment = new URLSearchParams(url.slice(at + 1));
|
|
1518
|
-
const ticket = fragment.get(OAUTH_TICKET_PARAM);
|
|
1519
|
-
const error = fragment.get(OAUTH_ERROR_PARAM);
|
|
1520
|
-
if (!ticket && !error) {
|
|
1521
|
-
return null;
|
|
1522
|
-
}
|
|
1523
|
-
const attemptId = fragment.get(EMAIL_LINK_ATTEMPT_PARAM2) ?? "";
|
|
1524
|
-
for (const name of [OAUTH_TICKET_PARAM, OAUTH_ERROR_PARAM, EMAIL_LINK_ATTEMPT_PARAM2]) {
|
|
1525
|
-
fragment.delete(name);
|
|
1526
|
-
}
|
|
1527
|
-
const rest = fragment.toString();
|
|
1528
|
-
return {
|
|
1529
|
-
attemptId,
|
|
1530
|
-
ticket: ticket || null,
|
|
1531
|
-
error: ticket ? null : error,
|
|
1532
|
-
cleanUrl: `${url.slice(0, at)}${rest ? `#${rest}` : ""}`
|
|
1533
|
-
};
|
|
1534
|
-
}
|
|
1535
|
-
function createOAuthTicketHolder() {
|
|
1536
|
-
let held = null;
|
|
1537
|
-
return {
|
|
1538
|
-
get: () => held,
|
|
1539
|
-
set(next) {
|
|
1540
|
-
held = next;
|
|
1682
|
+
channel.close?.();
|
|
1683
|
+
}
|
|
1541
1684
|
}
|
|
1542
|
-
};
|
|
1543
|
-
}
|
|
1544
|
-
function discardOAuthCallback(context) {
|
|
1545
|
-
const held = context.held.get();
|
|
1546
|
-
if (held) {
|
|
1547
|
-
context.held.set(null);
|
|
1548
|
-
context.oauth.remove(held.attemptId);
|
|
1549
1685
|
}
|
|
1686
|
+
return {
|
|
1687
|
+
wait(signal) {
|
|
1688
|
+
if (attempt.finished()) {
|
|
1689
|
+
return Promise.reject(attempt.refused("flow.invalid_step"));
|
|
1690
|
+
}
|
|
1691
|
+
if (signal?.aborted) {
|
|
1692
|
+
return Promise.resolve(attempt.snapshot().step);
|
|
1693
|
+
}
|
|
1694
|
+
if (stopping) {
|
|
1695
|
+
stopping = false;
|
|
1696
|
+
resumed = true;
|
|
1697
|
+
}
|
|
1698
|
+
loop ??= run();
|
|
1699
|
+
const running = loop;
|
|
1700
|
+
return new Promise((resolve, reject) => {
|
|
1701
|
+
const leave = () => {
|
|
1702
|
+
callers.delete(release);
|
|
1703
|
+
signal?.removeEventListener("abort", release);
|
|
1704
|
+
};
|
|
1705
|
+
const release = () => {
|
|
1706
|
+
leave();
|
|
1707
|
+
resolve(attempt.snapshot().step);
|
|
1708
|
+
if (callers.size === 0) {
|
|
1709
|
+
stopping = true;
|
|
1710
|
+
wake?.();
|
|
1711
|
+
}
|
|
1712
|
+
};
|
|
1713
|
+
callers.add(release);
|
|
1714
|
+
signal?.addEventListener("abort", release);
|
|
1715
|
+
running.then((step) => {
|
|
1716
|
+
leave();
|
|
1717
|
+
resolve(step);
|
|
1718
|
+
}, (error) => {
|
|
1719
|
+
leave();
|
|
1720
|
+
reject(error);
|
|
1721
|
+
});
|
|
1722
|
+
});
|
|
1723
|
+
},
|
|
1724
|
+
stop
|
|
1725
|
+
};
|
|
1550
1726
|
}
|
|
1551
|
-
function
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
return
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1727
|
+
async function passwordResetFlow(context, started) {
|
|
1728
|
+
const { transport } = context;
|
|
1729
|
+
const attempt = createAttempt(context, started);
|
|
1730
|
+
await attempt.accept(started);
|
|
1731
|
+
return flowObject(attempt, {
|
|
1732
|
+
submit: ({ code, password }) => attempt.step((bound) => transport.call("submitPasswordReset", { ...bound, body: { code, password } })),
|
|
1733
|
+
resendCode: () => attempt.step((bound) => transport.call("resendPasswordResetCode", bound)),
|
|
1734
|
+
...secondFactorAction(context, attempt, "submitPasswordResetSecondFactor"),
|
|
1735
|
+
...enrolmentActions(context, attempt, "password_reset"),
|
|
1736
|
+
discard: () => attempt.discard()
|
|
1737
|
+
});
|
|
1559
1738
|
}
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
throw clientError("storage.failed", context.messages());
|
|
1739
|
+
var PASSKEY_AUTOFILL_ROUND_MS = 240000;
|
|
1740
|
+
async function askForPasskey(context, authenticator, options, request) {
|
|
1741
|
+
if (!request.autofill) {
|
|
1742
|
+
return authenticator.get(options, { signal: request.signal });
|
|
1565
1743
|
}
|
|
1566
|
-
const
|
|
1567
|
-
|
|
1568
|
-
|
|
1744
|
+
const round = new AbortController;
|
|
1745
|
+
let lapsed = false;
|
|
1746
|
+
const stop = () => round.abort();
|
|
1747
|
+
request.signal?.addEventListener("abort", stop);
|
|
1748
|
+
const cancel = context.environment.setTimer(() => {
|
|
1749
|
+
lapsed = true;
|
|
1750
|
+
round.abort();
|
|
1751
|
+
}, PASSKEY_AUTOFILL_ROUND_MS);
|
|
1752
|
+
try {
|
|
1753
|
+
if (request.signal?.aborted) {
|
|
1754
|
+
round.abort();
|
|
1755
|
+
}
|
|
1756
|
+
return await authenticator.get(options, { signal: round.signal, autofill: true });
|
|
1757
|
+
} catch (error) {
|
|
1758
|
+
if (lapsed && !request.signal?.aborted) {
|
|
1759
|
+
return null;
|
|
1760
|
+
}
|
|
1761
|
+
throw error;
|
|
1762
|
+
} finally {
|
|
1763
|
+
cancel();
|
|
1764
|
+
request.signal?.removeEventListener("abort", stop);
|
|
1569
1765
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
const
|
|
1575
|
-
if (!
|
|
1766
|
+
}
|
|
1767
|
+
async function passkeySignIn(context, request = {}) {
|
|
1768
|
+
const authenticator = context.passkeys();
|
|
1769
|
+
for (;; ) {
|
|
1770
|
+
const started = await context.transport.call("startPasskeySignIn", {});
|
|
1771
|
+
if (!isPasskeySignInStart(started)) {
|
|
1576
1772
|
throw clientError("response.invalid", context.messages());
|
|
1577
1773
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
const answer = await transport.call("startOAuthSignIn", { body });
|
|
1582
|
-
if (!isRecord6(answer) || !isRecord6(answer.attempt) || typeof answer.attempt.id !== "string" || typeof answer.authorizationUrl !== "string" || typeof answer.binding !== "string") {
|
|
1583
|
-
throw clientError("response.invalid", context.messages());
|
|
1774
|
+
const credential = await askForPasskey(context, authenticator, started.options, request);
|
|
1775
|
+
if (credential !== null) {
|
|
1776
|
+
return signInFlow(context, started.attempt, (bound) => context.transport.call("submitSignInPasskey", { ...bound, body: { credential } }));
|
|
1584
1777
|
}
|
|
1585
|
-
attemptId = answer.attempt.id;
|
|
1586
|
-
started = { authorizationUrl: answer.authorizationUrl, binding: answer.binding };
|
|
1587
1778
|
}
|
|
1588
|
-
if (!/^https?:\/\//i.test(started.authorizationUrl)) {
|
|
1589
|
-
throw clientError("response.invalid", context.messages());
|
|
1590
|
-
}
|
|
1591
|
-
if (!oauth.save(attemptId, started.binding, intent)) {
|
|
1592
|
-
throw clientError("storage.failed", context.messages());
|
|
1593
|
-
}
|
|
1594
|
-
if (input.navigate !== false) {
|
|
1595
|
-
page?.assign?.(started.authorizationUrl);
|
|
1596
|
-
}
|
|
1597
|
-
return { url: started.authorizationUrl };
|
|
1598
1779
|
}
|
|
1599
|
-
function
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}
|
|
1603
|
-
|
|
1780
|
+
async function idTokenSignIn(context, provider) {
|
|
1781
|
+
const started = await context.transport.call("startIdTokenSignIn", {
|
|
1782
|
+
body: { provider }
|
|
1783
|
+
});
|
|
1784
|
+
if (!isRecord6(started) || typeof started.nonce !== "string" || !isUsableAttempt(started.attempt)) {
|
|
1785
|
+
throw clientError("response.invalid", context.messages());
|
|
1604
1786
|
}
|
|
1605
|
-
}
|
|
1606
|
-
function failed(code, messages) {
|
|
1607
|
-
const known = Object.hasOwn(ERROR_DEFINITIONS2, code) ? code : "oauth.provider_error";
|
|
1787
|
+
const { attempt } = started;
|
|
1608
1788
|
return {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
message: formatMessage(known, { messages })
|
|
1612
|
-
};
|
|
1613
|
-
}
|
|
1614
|
-
async function handleOAuthCallback(context) {
|
|
1615
|
-
const { environment, oauth, held, transport, session } = context;
|
|
1616
|
-
const page = environment.page;
|
|
1617
|
-
if (!page) {
|
|
1618
|
-
return { status: "none" };
|
|
1619
|
-
}
|
|
1620
|
-
const arrived = readOAuthFragment(page.url());
|
|
1621
|
-
let answer;
|
|
1622
|
-
if (arrived) {
|
|
1623
|
-
try {
|
|
1624
|
-
page.replaceUrl(arrived.cleanUrl);
|
|
1625
|
-
} catch {}
|
|
1626
|
-
discardOAuthCallback(context);
|
|
1627
|
-
if (arrived.error !== null || arrived.ticket === null) {
|
|
1628
|
-
oauth.remove(arrived.attemptId);
|
|
1629
|
-
return arrived.error !== null ? failed(arrived.error, context.messages()) : { status: "different_browser" };
|
|
1630
|
-
}
|
|
1631
|
-
answer = {
|
|
1632
|
-
attemptId: arrived.attemptId,
|
|
1633
|
-
ticket: arrived.ticket,
|
|
1634
|
-
until: environment.now() + OAUTH_TICKET_HOLD_MS
|
|
1635
|
-
};
|
|
1636
|
-
} else {
|
|
1637
|
-
const waiting = held.get();
|
|
1638
|
-
if (!waiting) {
|
|
1639
|
-
return { status: "none" };
|
|
1640
|
-
}
|
|
1641
|
-
if (waiting.until <= environment.now()) {
|
|
1642
|
-
discardOAuthCallback(context);
|
|
1643
|
-
return failed("oauth.ticket_invalid", context.messages());
|
|
1644
|
-
}
|
|
1645
|
-
answer = waiting;
|
|
1646
|
-
}
|
|
1647
|
-
const kept = oauth.read(answer.attemptId);
|
|
1648
|
-
const settle = () => {
|
|
1649
|
-
held.set(null);
|
|
1650
|
-
oauth.remove(answer.attemptId);
|
|
1789
|
+
nonce: started.nonce,
|
|
1790
|
+
exchange: (idToken, name) => signInFlow(context, attempt, (bound) => context.transport.call("submitSignInIdToken", { ...bound, body: { ...name, idToken } }))
|
|
1651
1791
|
};
|
|
1652
|
-
if (!kept) {
|
|
1653
|
-
settle();
|
|
1654
|
-
return { status: "different_browser" };
|
|
1655
|
-
}
|
|
1656
|
-
held.set(answer);
|
|
1657
|
-
const body = { ticket: answer.ticket, attemptId: answer.attemptId, binding: kept.binding };
|
|
1658
|
-
try {
|
|
1659
|
-
let outcome;
|
|
1660
|
-
if (kept.intent === "link") {
|
|
1661
|
-
const identity = await session.authorized("exchangeIdentityLinkTicket", { body });
|
|
1662
|
-
if (!isIdentity(identity)) {
|
|
1663
|
-
throw clientError("response.invalid", context.messages());
|
|
1664
|
-
}
|
|
1665
|
-
outcome = { status: "linked", identity };
|
|
1666
|
-
} else {
|
|
1667
|
-
const attempt = await transport.call("exchangeOAuthTicket", { body });
|
|
1668
|
-
const complete = isRecord6(attempt) && isRecord6(attempt.step) && attempt.step.status === "complete";
|
|
1669
|
-
outcome = {
|
|
1670
|
-
status: complete ? "complete" : "needs_step",
|
|
1671
|
-
flow: await signInFlow(context, attempt)
|
|
1672
|
-
};
|
|
1673
|
-
}
|
|
1674
|
-
settle();
|
|
1675
|
-
return outcome;
|
|
1676
|
-
} catch (error) {
|
|
1677
|
-
if (isRetryableOAuthError(error)) {
|
|
1678
|
-
throw error;
|
|
1679
|
-
}
|
|
1680
|
-
settle();
|
|
1681
|
-
if (!isTulaError(error) || error.status === 0) {
|
|
1682
|
-
throw error;
|
|
1683
|
-
}
|
|
1684
|
-
if (error.code === "oauth.different_browser") {
|
|
1685
|
-
return { status: "different_browser" };
|
|
1686
|
-
}
|
|
1687
|
-
return { status: "error", code: error.code, message: error.message };
|
|
1688
|
-
}
|
|
1689
1792
|
}
|
|
1690
1793
|
|
|
1691
1794
|
// src/storage.ts
|
|
@@ -1719,6 +1822,8 @@ import {
|
|
|
1719
1822
|
|
|
1720
1823
|
// src/generated/api.gen.ts
|
|
1721
1824
|
var OPERATIONS = {
|
|
1825
|
+
acceptMyOrganizationInvitation: { method: "POST", path: "/v1/client/me/organization-invitations/{invitationId}/accept", session: true },
|
|
1826
|
+
acceptOrganizationInvitation: { method: "POST", path: "/v1/client/me/organization-invitations/accept", session: true },
|
|
1722
1827
|
attemptSignInFirstFactor: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/first-factor/attempt", session: false },
|
|
1723
1828
|
changeMyPassword: { method: "POST", path: "/v1/client/me/password", session: true },
|
|
1724
1829
|
confirmPasswordResetTotpEnrolment: { method: "POST", path: "/v1/client/password-resets/{attemptId}/factor-enrolment/totp/confirm", session: false },
|
|
@@ -1726,7 +1831,14 @@ var OPERATIONS = {
|
|
|
1726
1831
|
confirmSignUpTotpEnrolment: { method: "POST", path: "/v1/client/sign-ups/{attemptId}/factor-enrolment/totp/confirm", session: false },
|
|
1727
1832
|
confirmSmsFactorEnrolment: { method: "POST", path: "/v1/client/me/factors/sms/confirm", session: true },
|
|
1728
1833
|
confirmTotpEnrolment: { method: "POST", path: "/v1/client/me/factors/totp/confirm", session: true },
|
|
1834
|
+
createMyOrganization: { method: "POST", path: "/v1/client/me/organizations", session: true },
|
|
1835
|
+
createMyOrganizationSetupLink: { method: "POST", path: "/v1/client/me/organizations/{id}/setup-links", session: true },
|
|
1836
|
+
createMyOrganizationSsoConnection: { method: "POST", path: "/v1/client/me/organizations/{organizationId}/sso-connections", session: true },
|
|
1837
|
+
declineMyOrganizationInvitation: { method: "POST", path: "/v1/client/me/organization-invitations/{invitationId}/decline", session: true },
|
|
1838
|
+
declineOrganizationInvitation: { method: "POST", path: "/v1/client/me/organization-invitations/decline", session: true },
|
|
1729
1839
|
deleteMyIdentity: { method: "DELETE", path: "/v1/client/me/identities/{identityId}", session: true },
|
|
1840
|
+
deleteMyOrganization: { method: "DELETE", path: "/v1/client/me/organizations/{organizationId}", session: true },
|
|
1841
|
+
deleteMyOrganizationSsoConnection: { method: "DELETE", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}", session: true },
|
|
1730
1842
|
disableSmsFactor: { method: "DELETE", path: "/v1/client/me/factors/sms", session: true },
|
|
1731
1843
|
disableTotp: { method: "DELETE", path: "/v1/client/me/factors/totp", session: true },
|
|
1732
1844
|
exchangeIdentityLinkTicket: { method: "POST", path: "/v1/client/me/identities/oauth/exchange", session: true },
|
|
@@ -1735,33 +1847,57 @@ var OPERATIONS = {
|
|
|
1735
1847
|
getClientConfig: { method: "GET", path: "/v1/client/config", session: false },
|
|
1736
1848
|
getMe: { method: "GET", path: "/v1/client/me", session: true },
|
|
1737
1849
|
getMyFactors: { method: "GET", path: "/v1/client/me/factors", session: true },
|
|
1850
|
+
getMyOrganizationSsoConnection: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}", session: true },
|
|
1851
|
+
getMyOrganizationSsoConnectionTest: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}/tests/{testId}", session: true },
|
|
1852
|
+
getMyOrganizationSsoGroupRoles: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}/group-roles", session: true },
|
|
1738
1853
|
getPasswordPolicy: { method: "GET", path: "/v1/client/password-policy", session: false },
|
|
1739
1854
|
getPasswordResetSecondFactorPasskeyOptions: { method: "POST", path: "/v1/client/password-resets/{attemptId}/second-factor/passkey/options", session: false },
|
|
1740
1855
|
getSignInSecondFactorPasskeyOptions: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/second-factor/passkey/options", session: false },
|
|
1741
1856
|
getStepUpPasskeyOptions: { method: "POST", path: "/v1/client/sessions/step-up/passkey", session: true },
|
|
1857
|
+
inviteToMyOrganization: { method: "POST", path: "/v1/client/me/organizations/{organizationId}/invitations", session: true },
|
|
1858
|
+
joinOrganizationSuggestion: { method: "POST", path: "/v1/client/me/organizations/suggestions/{organizationId}/join", session: true },
|
|
1742
1859
|
listMyIdentities: { method: "GET", path: "/v1/client/me/identities", session: true },
|
|
1860
|
+
listMyOrganizationDomains: { method: "GET", path: "/v1/client/me/organizations/{id}/domains", session: true },
|
|
1861
|
+
listMyOrganizationInvitations: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/invitations", session: true },
|
|
1862
|
+
listMyOrganizationMembers: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/members", session: true },
|
|
1863
|
+
listMyOrganizationRoles: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/roles", session: true },
|
|
1864
|
+
listMyOrganizations: { method: "GET", path: "/v1/client/me/organizations", session: true },
|
|
1865
|
+
listMyOrganizationSetupLinks: { method: "GET", path: "/v1/client/me/organizations/{id}/setup-links", session: true },
|
|
1866
|
+
listMyOrganizationSsoConnections: { method: "GET", path: "/v1/client/me/organizations/{organizationId}/sso-connections", session: true },
|
|
1867
|
+
listMyOrganizationSuggestions: { method: "GET", path: "/v1/client/me/organizations/suggestions", session: true },
|
|
1743
1868
|
listMyPasskeys: { method: "GET", path: "/v1/client/me/passkeys", session: true },
|
|
1869
|
+
listMyPendingOrganizationInvitations: { method: "GET", path: "/v1/client/me/organization-invitations", session: true },
|
|
1744
1870
|
listSessions: { method: "GET", path: "/v1/client/sessions", session: true },
|
|
1745
1871
|
preparePasswordResetSecondFactor: { method: "POST", path: "/v1/client/password-resets/{attemptId}/second-factor/prepare", session: false },
|
|
1746
1872
|
prepareSignInFirstFactor: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/first-factor/prepare", session: false },
|
|
1747
1873
|
prepareSignInSecondFactor: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/second-factor/prepare", session: false },
|
|
1874
|
+
previewOrganizationInvitation: { method: "POST", path: "/v1/client/organization-invitations/preview", session: false },
|
|
1875
|
+
refreshMyOrganizationSsoMetadata: { method: "POST", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}/refresh-metadata", session: true },
|
|
1748
1876
|
refreshSession: { method: "POST", path: "/v1/client/sessions/refresh", session: false },
|
|
1749
1877
|
regenerateBackupCodes: { method: "POST", path: "/v1/client/me/factors/backup-codes", session: true },
|
|
1878
|
+
removeMyOrganizationDomain: { method: "DELETE", path: "/v1/client/me/organizations/{id}/domains/{domainId}", session: true },
|
|
1879
|
+
removeMyOrganizationMember: { method: "DELETE", path: "/v1/client/me/organizations/{organizationId}/members/{userId}", session: true },
|
|
1750
1880
|
removePasskey: { method: "DELETE", path: "/v1/client/me/passkeys/{passkeyId}", session: true },
|
|
1751
1881
|
removePhoneNumber: { method: "DELETE", path: "/v1/client/me/phone", session: true },
|
|
1752
1882
|
renamePasskey: { method: "PATCH", path: "/v1/client/me/passkeys/{passkeyId}", session: true },
|
|
1883
|
+
replaceMyOrganizationSsoGroupRoles: { method: "PUT", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}/group-roles", session: true },
|
|
1753
1884
|
requestEmailAddressCode: { method: "POST", path: "/v1/client/me/email", session: true },
|
|
1754
1885
|
requestPhoneCode: { method: "POST", path: "/v1/client/me/phone", session: true },
|
|
1755
1886
|
resendPasswordResetCode: { method: "POST", path: "/v1/client/password-resets/{attemptId}/resend-code", session: false },
|
|
1756
1887
|
resendSignInCode: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/resend-code", session: false },
|
|
1757
1888
|
resendSignUpCode: { method: "POST", path: "/v1/client/sign-ups/{attemptId}/resend-code", session: false },
|
|
1889
|
+
revokeMyOrganizationInvitation: { method: "DELETE", path: "/v1/client/me/organizations/{organizationId}/invitations/{invitationId}", session: true },
|
|
1890
|
+
revokeMyOrganizationSetupLink: { method: "DELETE", path: "/v1/client/me/organizations/{id}/setup-links/{linkId}", session: true },
|
|
1758
1891
|
revokeOtherSessions: { method: "POST", path: "/v1/client/sessions/revoke-others", session: true },
|
|
1759
1892
|
revokeSession: { method: "DELETE", path: "/v1/client/sessions/{sessionId}", session: true },
|
|
1760
1893
|
sendStepUpEmailCode: { method: "POST", path: "/v1/client/sessions/step-up/email-code", session: true },
|
|
1761
1894
|
sendStepUpSmsCode: { method: "POST", path: "/v1/client/sessions/step-up/sms-code", session: true },
|
|
1895
|
+
setActiveOrganization: { method: "PUT", path: "/v1/client/me/organizations/active", session: true },
|
|
1762
1896
|
signOut: { method: "POST", path: "/v1/client/sessions/sign-out", session: false },
|
|
1763
1897
|
startIdentityLink: { method: "POST", path: "/v1/client/me/identities/oauth", session: true },
|
|
1764
1898
|
startIdTokenSignIn: { method: "POST", path: "/v1/client/sign-ins/id-token", session: false },
|
|
1899
|
+
startMyOrganizationDomain: { method: "POST", path: "/v1/client/me/organizations/{id}/domains", session: true },
|
|
1900
|
+
startMyOrganizationSsoConnectionTest: { method: "POST", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}/tests", session: true },
|
|
1765
1901
|
startOAuthSignIn: { method: "POST", path: "/v1/client/sign-ins/oauth", session: false },
|
|
1766
1902
|
startPasskeyRegistration: { method: "POST", path: "/v1/client/me/passkeys/options", session: true },
|
|
1767
1903
|
startPasskeySignIn: { method: "POST", path: "/v1/client/sign-ins/passkey", session: false },
|
|
@@ -1772,6 +1908,7 @@ var OPERATIONS = {
|
|
|
1772
1908
|
startSignUp: { method: "POST", path: "/v1/client/sign-ups", session: false },
|
|
1773
1909
|
startSignUpTotpEnrolment: { method: "POST", path: "/v1/client/sign-ups/{attemptId}/factor-enrolment/totp", session: false },
|
|
1774
1910
|
startSmsFactorEnrolment: { method: "POST", path: "/v1/client/me/factors/sms", session: true },
|
|
1911
|
+
startSsoSignIn: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/sso", session: false },
|
|
1775
1912
|
startTotpEnrolment: { method: "POST", path: "/v1/client/me/factors/totp", session: true },
|
|
1776
1913
|
stepUpSession: { method: "POST", path: "/v1/client/sessions/step-up", session: true },
|
|
1777
1914
|
submitPasswordReset: { method: "POST", path: "/v1/client/password-resets/{attemptId}/password", session: false },
|
|
@@ -1781,7 +1918,12 @@ var OPERATIONS = {
|
|
|
1781
1918
|
submitSignInPasskey: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/passkey", session: false },
|
|
1782
1919
|
submitSignInPassword: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/password", session: false },
|
|
1783
1920
|
submitSignInSecondFactor: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/second-factor", session: false },
|
|
1921
|
+
updateMyOrganization: { method: "PATCH", path: "/v1/client/me/organizations/{organizationId}", session: true },
|
|
1922
|
+
updateMyOrganizationDomain: { method: "PATCH", path: "/v1/client/me/organizations/{id}/domains/{domainId}", session: true },
|
|
1923
|
+
updateMyOrganizationMember: { method: "PATCH", path: "/v1/client/me/organizations/{organizationId}/members/{userId}", session: true },
|
|
1924
|
+
updateMyOrganizationSsoConnection: { method: "PATCH", path: "/v1/client/me/organizations/{organizationId}/sso-connections/{id}", session: true },
|
|
1784
1925
|
verifyEmailAddress: { method: "POST", path: "/v1/client/me/email/verify", session: true },
|
|
1926
|
+
verifyMyOrganizationDomain: { method: "POST", path: "/v1/client/me/organizations/{id}/domains/{domainId}/verify", session: true },
|
|
1785
1927
|
verifyPhoneNumber: { method: "POST", path: "/v1/client/me/phone/verify", session: true },
|
|
1786
1928
|
verifySignInEmail: { method: "POST", path: "/v1/client/sign-ins/{attemptId}/verify-email", session: false },
|
|
1787
1929
|
verifySignInLink: { method: "POST", path: "/v1/client/sign-ins/link", session: false },
|
|
@@ -1863,8 +2005,15 @@ function errorFromResponse(response, payload, messages) {
|
|
|
1863
2005
|
retryAfterMs: readRetryAfterMs(response, params)
|
|
1864
2006
|
});
|
|
1865
2007
|
}
|
|
1866
|
-
function buildUrl(baseUrl, path, params) {
|
|
1867
|
-
|
|
2008
|
+
function buildUrl(baseUrl, path, params, query = {}) {
|
|
2009
|
+
const search = new URLSearchParams;
|
|
2010
|
+
for (const [name, value] of Object.entries(query)) {
|
|
2011
|
+
if (value !== undefined) {
|
|
2012
|
+
search.set(name, String(value));
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
const text = search.toString();
|
|
2016
|
+
return baseUrl + path.replace(/\{(\w+)\}/g, (_placeholder, name) => encodeURIComponent(params[name] ?? "")) + (text === "" ? "" : `?${text}`);
|
|
1868
2017
|
}
|
|
1869
2018
|
function createTransport(options) {
|
|
1870
2019
|
let nonce;
|
|
@@ -1900,7 +2049,7 @@ function createTransport(options) {
|
|
|
1900
2049
|
if (input.attemptSecret) {
|
|
1901
2050
|
headers.set(FLOW_ATTEMPT_HEADER, input.attemptSecret);
|
|
1902
2051
|
}
|
|
1903
|
-
const url = buildUrl(options.baseUrl, route.path, input.params ?? {});
|
|
2052
|
+
const url = buildUrl(options.baseUrl, route.path, input.params ?? {}, input.query);
|
|
1904
2053
|
const source = PROVEN.has(id) ? options.deviceKey : undefined;
|
|
1905
2054
|
const controller = new AbortController;
|
|
1906
2055
|
const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? options.timeoutMs);
|
|
@@ -1917,6 +2066,7 @@ function createTransport(options) {
|
|
|
1917
2066
|
headers,
|
|
1918
2067
|
body: input.body === undefined ? undefined : JSON.stringify(input.body),
|
|
1919
2068
|
signal: controller.signal,
|
|
2069
|
+
redirect: "manual",
|
|
1920
2070
|
...options.client === "web" && { credentials: "include" }
|
|
1921
2071
|
}));
|
|
1922
2072
|
if (options.deviceKey) {
|
|
@@ -1952,11 +2102,11 @@ function createTransport(options) {
|
|
|
1952
2102
|
if (!response.ok) {
|
|
1953
2103
|
throw errorFromResponse(response, payload, options.messages());
|
|
1954
2104
|
}
|
|
1955
|
-
if (
|
|
2105
|
+
if (response.status !== 204 && !isRecord7(payload)) {
|
|
1956
2106
|
throw new TulaError({
|
|
1957
2107
|
code: "response.invalid",
|
|
1958
2108
|
message: formatMessage("response.invalid", { messages: options.messages() }),
|
|
1959
|
-
status: response.status
|
|
2109
|
+
status: payload === null ? response.status : 0
|
|
1960
2110
|
});
|
|
1961
2111
|
}
|
|
1962
2112
|
return payload;
|
|
@@ -1969,7 +2119,7 @@ var DEFAULT_TIMEOUT_MS = 15000;
|
|
|
1969
2119
|
var PUBLISHABLE_KEY_PREFIX = "tula_pk_";
|
|
1970
2120
|
var SECRET_KEY_PREFIX = "tula_sk_";
|
|
1971
2121
|
var SESSION_PROFILE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
1972
|
-
function normalizeBaseUrl(baseUrl) {
|
|
2122
|
+
function normalizeBaseUrl(baseUrl, allowInsecureHttp) {
|
|
1973
2123
|
let url;
|
|
1974
2124
|
try {
|
|
1975
2125
|
url = new URL(baseUrl);
|
|
@@ -1979,6 +2129,10 @@ function normalizeBaseUrl(baseUrl) {
|
|
|
1979
2129
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
1980
2130
|
throw new TypeError("createTulaClient: `baseUrl` must be an http(s) URL");
|
|
1981
2131
|
}
|
|
2132
|
+
const host = url.hostname;
|
|
2133
|
+
if (url.protocol === "http:" && !allowInsecureHttp && host !== "localhost" && !host.endsWith(".localhost") && host !== "127.0.0.1" && host !== "[::1]") {
|
|
2134
|
+
throw new TypeError("createTulaClient: `baseUrl` is plain http to another machine; use https (or set `allowInsecureHttp` on a private network you trust)");
|
|
2135
|
+
}
|
|
1982
2136
|
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
1983
2137
|
}
|
|
1984
2138
|
function defaultClientKind() {
|
|
@@ -1989,7 +2143,7 @@ function createClient(options, environment) {
|
|
|
1989
2143
|
if (typeof publishableKey !== "string" || !publishableKey.startsWith(PUBLISHABLE_KEY_PREFIX)) {
|
|
1990
2144
|
throw new TypeError(typeof publishableKey === "string" && publishableKey.startsWith(SECRET_KEY_PREFIX) ? "createTulaClient: this is a secret key. Never put one in a client; use the publishable key" : "createTulaClient: `publishableKey` must be a publishable key (tula_pk_…)");
|
|
1991
2145
|
}
|
|
1992
|
-
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
2146
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl, options.allowInsecureHttp);
|
|
1993
2147
|
const client = options.client ?? defaultClientKind();
|
|
1994
2148
|
if (client === "web" && options.storage) {
|
|
1995
2149
|
throw new TypeError("createTulaClient: a `web` client keeps its refresh token in an httpOnly cookie and takes no `storage`");
|
|
@@ -2051,13 +2205,11 @@ function createClient(options, environment) {
|
|
|
2051
2205
|
environment,
|
|
2052
2206
|
links,
|
|
2053
2207
|
scope,
|
|
2054
|
-
passkeys
|
|
2055
|
-
};
|
|
2056
|
-
const oauth = {
|
|
2057
|
-
...flows,
|
|
2208
|
+
passkeys,
|
|
2058
2209
|
oauth: createOAuthStore(environment, scope),
|
|
2059
2210
|
held: createOAuthTicketHolder()
|
|
2060
2211
|
};
|
|
2212
|
+
const oauth = flows;
|
|
2061
2213
|
let config = null;
|
|
2062
2214
|
function checked(answer, guard) {
|
|
2063
2215
|
if (!guard(answer)) {
|
|
@@ -2166,6 +2318,122 @@ function createClient(options, environment) {
|
|
|
2166
2318
|
},
|
|
2167
2319
|
prepareStepUp: ({ method }) => receipt(method === "sms_code" ? "sendStepUpSmsCode" : "sendStepUpEmailCode", method)
|
|
2168
2320
|
},
|
|
2321
|
+
organizations: {
|
|
2322
|
+
async list(query) {
|
|
2323
|
+
const epoch = session.organizationEpoch();
|
|
2324
|
+
const answer = await session.authorized("listMyOrganizations", { query });
|
|
2325
|
+
const now = session.state();
|
|
2326
|
+
if (now.status === "signed-in") {
|
|
2327
|
+
session.noteActiveOrganization(now.sessionId, answer.activeOrganizationId, epoch);
|
|
2328
|
+
}
|
|
2329
|
+
return answer;
|
|
2330
|
+
},
|
|
2331
|
+
create: (input) => session.authorized("createMyOrganization", { body: input }),
|
|
2332
|
+
setActive: (organizationId) => session.setActiveOrganization(organizationId),
|
|
2333
|
+
suggestions: () => session.authorized("listMyOrganizationSuggestions", {}),
|
|
2334
|
+
join: (organizationId) => session.authorized("joinOrganizationSuggestion", { params: { organizationId } }),
|
|
2335
|
+
update: (organizationId, input) => session.authorized("updateMyOrganization", { params: { organizationId }, body: input }),
|
|
2336
|
+
async remove(organizationId) {
|
|
2337
|
+
await session.authorized("deleteMyOrganization", { params: { organizationId } });
|
|
2338
|
+
},
|
|
2339
|
+
async leave(organizationId) {
|
|
2340
|
+
const now = session.state();
|
|
2341
|
+
const user = now.status === "signed-in" ? now.user : null;
|
|
2342
|
+
const userId = user?.id ?? (await asUser(() => session.authorized("getMe", {}))).id;
|
|
2343
|
+
await session.authorized("removeMyOrganizationMember", {
|
|
2344
|
+
params: { organizationId, userId }
|
|
2345
|
+
});
|
|
2346
|
+
},
|
|
2347
|
+
roles: (organizationId) => session.authorized("listMyOrganizationRoles", { params: { organizationId } }),
|
|
2348
|
+
members: {
|
|
2349
|
+
list: (organizationId) => session.authorized("listMyOrganizationMembers", { params: { organizationId } }),
|
|
2350
|
+
changeRole: (organizationId, userId, role) => session.authorized("updateMyOrganizationMember", {
|
|
2351
|
+
params: { organizationId, userId },
|
|
2352
|
+
body: { role }
|
|
2353
|
+
}),
|
|
2354
|
+
async remove(organizationId, userId) {
|
|
2355
|
+
await session.authorized("removeMyOrganizationMember", {
|
|
2356
|
+
params: { organizationId, userId }
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
},
|
|
2360
|
+
domains: {
|
|
2361
|
+
list: (id) => session.authorized("listMyOrganizationDomains", { params: { id } }),
|
|
2362
|
+
start: (id, input) => session.authorized("startMyOrganizationDomain", { params: { id }, body: input }),
|
|
2363
|
+
verify: (id, domainId, code) => session.authorized("verifyMyOrganizationDomain", {
|
|
2364
|
+
params: { id, domainId },
|
|
2365
|
+
body: { code }
|
|
2366
|
+
}),
|
|
2367
|
+
update: (id, domainId, mode) => session.authorized("updateMyOrganizationDomain", {
|
|
2368
|
+
params: { id, domainId },
|
|
2369
|
+
body: { mode }
|
|
2370
|
+
}),
|
|
2371
|
+
async remove(id, domainId) {
|
|
2372
|
+
await session.authorized("removeMyOrganizationDomain", { params: { id, domainId } });
|
|
2373
|
+
}
|
|
2374
|
+
},
|
|
2375
|
+
sso: {
|
|
2376
|
+
list: (organizationId) => session.authorized("listMyOrganizationSsoConnections", { params: { organizationId } }),
|
|
2377
|
+
get: (organizationId, id) => session.authorized("getMyOrganizationSsoConnection", { params: { organizationId, id } }),
|
|
2378
|
+
create: (organizationId, input) => session.authorized("createMyOrganizationSsoConnection", {
|
|
2379
|
+
params: { organizationId },
|
|
2380
|
+
body: input
|
|
2381
|
+
}),
|
|
2382
|
+
update: (organizationId, id, input) => session.authorized("updateMyOrganizationSsoConnection", {
|
|
2383
|
+
params: { organizationId, id },
|
|
2384
|
+
body: input
|
|
2385
|
+
}),
|
|
2386
|
+
refreshMetadata: (organizationId, id) => session.authorized("refreshMyOrganizationSsoMetadata", {
|
|
2387
|
+
params: { organizationId, id }
|
|
2388
|
+
}),
|
|
2389
|
+
async remove(organizationId, id) {
|
|
2390
|
+
await session.authorized("deleteMyOrganizationSsoConnection", {
|
|
2391
|
+
params: { organizationId, id }
|
|
2392
|
+
});
|
|
2393
|
+
},
|
|
2394
|
+
groupRoles: (organizationId, id) => session.authorized("getMyOrganizationSsoGroupRoles", { params: { organizationId, id } }),
|
|
2395
|
+
setGroupRoles: (organizationId, id, mapping) => session.authorized("replaceMyOrganizationSsoGroupRoles", {
|
|
2396
|
+
params: { organizationId, id },
|
|
2397
|
+
body: mapping
|
|
2398
|
+
}),
|
|
2399
|
+
startTest: (organizationId, id, redirectUrl) => session.authorized("startMyOrganizationSsoConnectionTest", {
|
|
2400
|
+
params: { organizationId, id },
|
|
2401
|
+
body: { redirectUrl }
|
|
2402
|
+
}),
|
|
2403
|
+
testResult: (organizationId, id, testId) => session.authorized("getMyOrganizationSsoConnectionTest", {
|
|
2404
|
+
params: { organizationId, id, testId }
|
|
2405
|
+
}),
|
|
2406
|
+
setupLinks: (organizationId) => session.authorized("listMyOrganizationSetupLinks", { params: { id: organizationId } }),
|
|
2407
|
+
createSetupLink: (organizationId, input = {}) => session.authorized("createMyOrganizationSetupLink", {
|
|
2408
|
+
params: { id: organizationId },
|
|
2409
|
+
body: input
|
|
2410
|
+
}),
|
|
2411
|
+
async revokeSetupLink(organizationId, linkId) {
|
|
2412
|
+
await session.authorized("revokeMyOrganizationSetupLink", {
|
|
2413
|
+
params: { id: organizationId, linkId }
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
},
|
|
2417
|
+
invitations: {
|
|
2418
|
+
list: (organizationId) => session.authorized("listMyOrganizationInvitations", { params: { organizationId } }),
|
|
2419
|
+
invite: (organizationId, input) => session.authorized("inviteToMyOrganization", { params: { organizationId }, body: input }),
|
|
2420
|
+
async revoke(organizationId, invitationId) {
|
|
2421
|
+
await session.authorized("revokeMyOrganizationInvitation", {
|
|
2422
|
+
params: { organizationId, invitationId }
|
|
2423
|
+
});
|
|
2424
|
+
},
|
|
2425
|
+
preview: (token) => transport.call("previewOrganizationInvitation", { body: { token } }),
|
|
2426
|
+
accept: (token) => session.authorized("acceptOrganizationInvitation", { body: { token } }),
|
|
2427
|
+
async decline(token) {
|
|
2428
|
+
await session.authorized("declineOrganizationInvitation", { body: { token } });
|
|
2429
|
+
},
|
|
2430
|
+
mine: () => session.authorized("listMyPendingOrganizationInvitations", {}),
|
|
2431
|
+
acceptMine: (invitationId) => session.authorized("acceptMyOrganizationInvitation", { params: { invitationId } }),
|
|
2432
|
+
async declineMine(invitationId) {
|
|
2433
|
+
await session.authorized("declineMyOrganizationInvitation", { params: { invitationId } });
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
},
|
|
2169
2437
|
user: {
|
|
2170
2438
|
get: () => asUser(() => session.authorized("getMe", {})),
|
|
2171
2439
|
email: {
|
|
@@ -2266,6 +2534,22 @@ function createClient(options, environment) {
|
|
|
2266
2534
|
function createTulaClient(options) {
|
|
2267
2535
|
return createClient(options, runtimeEnvironment());
|
|
2268
2536
|
}
|
|
2537
|
+
// src/invitation.ts
|
|
2538
|
+
import { ORGANIZATION_INVITATION_TOKEN_PARAM } from "@tula-tech/contract/headers";
|
|
2539
|
+
function readInvitationLink(url) {
|
|
2540
|
+
const at = url.indexOf("#");
|
|
2541
|
+
if (at === -1) {
|
|
2542
|
+
return null;
|
|
2543
|
+
}
|
|
2544
|
+
const fragment = new URLSearchParams(url.slice(at + 1));
|
|
2545
|
+
const token = fragment.get(ORGANIZATION_INVITATION_TOKEN_PARAM);
|
|
2546
|
+
if (!token) {
|
|
2547
|
+
return null;
|
|
2548
|
+
}
|
|
2549
|
+
fragment.delete(ORGANIZATION_INVITATION_TOKEN_PARAM);
|
|
2550
|
+
const rest = fragment.toString();
|
|
2551
|
+
return { token, cleanUrl: `${url.slice(0, at)}${rest ? `#${rest}` : ""}` };
|
|
2552
|
+
}
|
|
2269
2553
|
export {
|
|
2270
2554
|
ACCESS_TOKEN_EXPIRY_SKEW_MS,
|
|
2271
2555
|
DEFAULT_TIMEOUT_MS,
|
|
@@ -2285,6 +2569,7 @@ export {
|
|
|
2285
2569
|
isStepUpRequired,
|
|
2286
2570
|
isTulaError,
|
|
2287
2571
|
memoryStorage,
|
|
2572
|
+
readInvitationLink,
|
|
2288
2573
|
runtimeEnvironment,
|
|
2289
2574
|
stepUpMethods
|
|
2290
2575
|
};
|