Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/services/deepLinkUrl.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect } from "vitest";
import { intentKey, isAttenuatedIntent, isConfirmIntent, isNavigateIntent, isSilentIntent, isUnpromptedIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID } from "./deepLinkUrl";
import { intentKey, isAttenuatedIntent, isConfirmIntent, isNavigateIntent, isUnpromptedIntent, parseDeepLink, buildDeepLink, DEFAULT_PLUGIN_SOURCE_ID, type SilentIntent } from "./deepLinkUrl";

const SESSION = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
const TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef";
Expand Down Expand Up @@ -80,11 +80,17 @@ test("join is a confirm route and verified is an attenuated one", () => {
const verified = parseDeepLink(`voltius://verified?u=${USER}`)!;
expect(isConfirmIntent(parseDeepLink(`voltius://join?s=${SESSION}&t=${TOKEN}`)!)).toBe(true);
expect(isAttenuatedIntent(verified)).toBe(true);
expect(isSilentIntent(verified)).toBe(false);
// Attenuated still acts without a prompt; that is the whole point of the class.
expect(isUnpromptedIntent(verified)).toBe(true);
});

test("the silent class is empty", () => {
// No runtime assertion can pin this: `isSilentIntent` returns false for every
// intent while the class has no members, so the typecheck is the only gate.
const silentIsEmpty: [SilentIntent] extends [never] ? true : false = true;
expect(silentIsEmpty).toBe(true);
});

test("builds a join link in both forms", () => {
const intent = { route: "join", sessionId: SESSION, token: TOKEN } as const;
expect(buildDeepLink(intent, "scheme")).toBe(`voltius://join?s=${SESSION}&t=${TOKEN}`);
Expand Down Expand Up @@ -184,7 +190,7 @@ test("parses a billing link, which takes no parameters", () => {
expect(parseDeepLink("voltius://billing?section=account")).toEqual({ route: "billing" });
});

test("the navigate routes are neither confirm nor silent", () => {
test("the navigate routes are not confirm routes", () => {
for (const url of [
"voltius://notification",
"voltius://settings?section=account",
Expand All @@ -193,7 +199,6 @@ test("the navigate routes are neither confirm nor silent", () => {
const intent = parseDeepLink(url)!;
expect(isNavigateIntent(intent)).toBe(true);
expect(isConfirmIntent(intent)).toBe(false);
expect(isSilentIntent(intent)).toBe(false);
}
});

Expand Down
23 changes: 13 additions & 10 deletions src/services/deepLinkUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ type Route = DeepLinkIntent["route"];
* person at the wrong moment, still costs the tapping user nothing.
*
* `verified` is the only member, and is classified on the strongest form it
* will carry rather than the weakest form it carries today. Today the portal
* could carry rather than the weakest form it carries today. Today the portal
* spends the verification token server-side and the app receives an inert
* user id, which would also satisfy `silent`; once the mail link opens the app
* directly the app receives the raw token instead. That capability is a
* user id, which would also satisfy `silent`; a mail link that opened the app
* directly would hand it the raw token instead. That capability is a
* single-use, short-lived token, bound server-side to one account, proving an
* address is reachable — no session, no key material, no grant. So a hostile
* `verified` link carries the *attacker's* own token: tapping it verifies the
Expand All @@ -50,13 +50,16 @@ type Route = DeepLinkIntent["route"];
* a forged one could spend *against* the tapper — a session, a wrapped key, an
* account-scoped grant. That route is `confirm`, not `attenuated`.
*
* Open, and it gates putting these links in mail rather than this
* classification: enterprise rewriters (Outlook SafeLinks, Defender ATP)
* re-encode the link onto their own logging host. The tap then resolves
* against *that* host, so no App Link fires and the mail client's own browser
* follows the redirect — the app never sees the link at all, and whatever the
* rewriter kept of it sits in the gateway's logs. Whether a fragment survives
* the round trip is undocumented and untested here.
* The stronger form stays hypothetical because mailing the raw token was
* considered and rejected. Enterprise rewriters (Outlook SafeLinks, Defender
* ATP) re-encode a mailed link onto their own logging host, so the tap
* resolves against *that* host: no App Link fires, the mail client's own
* browser follows the redirect, and the app never sees the link at all.
* Whether a fragment even survives that round trip is undocumented, while the
* one rewriter behaviour that is documented mangles query structure. A carrier
* that can silently drop the token is not worth what it would buy, which is
* one browser tab: the portal already hands the app a `verified` link the
* moment it succeeds.
* - `silent` routes carry no capability at all and run a side effect unprompted.
* Currently unpopulated: it is the narrower claim `verified` used to make,
* kept for a route that genuinely carries nothing.
Expand Down