Skip to content

Commit 71f9ba8

Browse files
committed
feat(auth): add the device grant protocol and token binding checks
1 parent 5c8ddfe commit 71f9ba8

9 files changed

Lines changed: 640 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { nextPollStep, slowDownIncrementMs } from "./pollState.js";
4+
import type { DeviceTokens, PollState } from "./types.js";
5+
6+
const state: PollState = { intervalMs: 5_000, deadlineMs: 300_000 };
7+
8+
const tokens: DeviceTokens = {
9+
accessToken: "access",
10+
refreshToken: "refresh",
11+
expiresAt: undefined,
12+
organizationId: undefined,
13+
};
14+
15+
describe("nextPollStep", () => {
16+
it("finishes when the response carries tokens", () => {
17+
const step = nextPollStep(state, { kind: "tokens", tokens }, 0);
18+
19+
expect(step).toEqual({ action: "done", tokens });
20+
});
21+
22+
it("finishes on tokens even after the deadline has passed", () => {
23+
const step = nextPollStep(state, { kind: "tokens", tokens }, 300_001);
24+
25+
expect(step).toEqual({ action: "done", tokens });
26+
});
27+
28+
it("polls again at the current interval while authorization is pending", () => {
29+
const step = nextPollStep(state, { kind: "pending" }, 0);
30+
31+
expect(step).toEqual({ action: "poll", delayMs: 5_000, state });
32+
});
33+
34+
it("raises the interval by five seconds on slow-down", () => {
35+
const step = nextPollStep(state, { kind: "slow-down" }, 0);
36+
37+
expect(step).toEqual({
38+
action: "poll",
39+
delayMs: 5_000 + slowDownIncrementMs,
40+
state: { intervalMs: 5_000 + slowDownIncrementMs, deadlineMs: 300_000 },
41+
});
42+
});
43+
44+
it("keeps the raised interval for later pending responses", () => {
45+
const slowed = nextPollStep(state, { kind: "slow-down" }, 0);
46+
if (slowed.action !== "poll") throw Error("expected to keep polling");
47+
48+
const step = nextPollStep(slowed.state, { kind: "pending" }, 0);
49+
50+
expect(step).toEqual({
51+
action: "poll",
52+
delayMs: 10_000,
53+
state: slowed.state,
54+
});
55+
});
56+
57+
it("fails when the person rejects the request", () => {
58+
const step = nextPollStep(state, { kind: "denied" }, 0);
59+
60+
expect(step).toEqual({
61+
action: "fail",
62+
reason: "access-denied",
63+
detail: undefined,
64+
});
65+
});
66+
67+
it("fails when the device code expires", () => {
68+
const step = nextPollStep(state, { kind: "expired" }, 0);
69+
70+
expect(step).toEqual({
71+
action: "fail",
72+
reason: "expired",
73+
detail: undefined,
74+
});
75+
});
76+
77+
it("times out once the deadline passes, however long the server stalls", () => {
78+
const step = nextPollStep(state, { kind: "pending" }, 300_001);
79+
80+
expect(step).toEqual({
81+
action: "fail",
82+
reason: "timeout",
83+
detail: undefined,
84+
});
85+
});
86+
87+
it("keeps polling at the deadline itself", () => {
88+
const step = nextPollStep(state, { kind: "pending" }, 300_000);
89+
90+
expect(step).toEqual({ action: "poll", delayMs: 5_000, state });
91+
});
92+
93+
it("retries a server it could not reach, at double the interval", () => {
94+
const step = nextPollStep(state, { kind: "unreachable", detail: "x" }, 0);
95+
96+
expect(step).toEqual({
97+
action: "poll",
98+
delayMs: 10_000,
99+
state: { intervalMs: 10_000, deadlineMs: 300_000 },
100+
});
101+
});
102+
103+
it("doubles again while it still cannot reach the server", () => {
104+
const first = nextPollStep(state, { kind: "unreachable", detail: "x" }, 0);
105+
if (first.action !== "poll") throw Error("expected to keep polling");
106+
107+
const second = nextPollStep(
108+
first.state,
109+
{ kind: "unreachable", detail: "x" },
110+
0,
111+
);
112+
113+
expect(second).toEqual({
114+
action: "poll",
115+
delayMs: 20_000,
116+
state: { intervalMs: 20_000, deadlineMs: 300_000 },
117+
});
118+
});
119+
120+
it("keeps the backed-off interval once the server answers again", () => {
121+
const backedOff = nextPollStep(
122+
state,
123+
{ kind: "unreachable", detail: "x" },
124+
0,
125+
);
126+
if (backedOff.action !== "poll") throw Error("expected to keep polling");
127+
128+
const step = nextPollStep(backedOff.state, { kind: "pending" }, 0);
129+
130+
expect(step).toEqual({
131+
action: "poll",
132+
delayMs: 10_000,
133+
state: backedOff.state,
134+
});
135+
});
136+
137+
it("stops retrying an unreachable server once the deadline passes", () => {
138+
const step = nextPollStep(
139+
state,
140+
{ kind: "unreachable", detail: "x" },
141+
300_001,
142+
);
143+
144+
expect(step).toEqual({
145+
action: "fail",
146+
reason: "timeout",
147+
detail: undefined,
148+
});
149+
});
150+
151+
it("fails with the detail from an unexpected error", () => {
152+
const step = nextPollStep(state, { kind: "error", detail: "boom" }, 0);
153+
154+
expect(step).toEqual({
155+
action: "fail",
156+
reason: "network",
157+
detail: "boom",
158+
});
159+
});
160+
});

src/core/deviceAuth/pollState.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { PollResponse, PollState, PollStep } from "./types.js";
2+
3+
/**
4+
* The increase lasts for the rest of the flow. Treating it as a one-off skip
5+
* would return to the very rate the server just objected to.
6+
*/
7+
export const slowDownIncrementMs = 5_000;
8+
9+
/**
10+
* RFC 8628 asks a client meeting a connection error to slow down before
11+
* retrying, and recommends doubling. Persists for the rest of the flow: a
12+
* network that dropped one request is likelier to drop the next.
13+
*/
14+
const unreachableBackoffFactor = 2;
15+
16+
/** Pure, so the protocol is testable without a clock or a socket. */
17+
export function nextPollStep(
18+
state: PollState,
19+
response: PollResponse,
20+
nowMs: number,
21+
): PollStep {
22+
// Tokens win over the deadline. An approval that lands as the code expires is
23+
// still an approval, and rejecting it would strand someone who just finished.
24+
if (response.kind === "tokens") {
25+
return { action: "done", tokens: response.tokens };
26+
}
27+
28+
if (nowMs > state.deadlineMs) {
29+
return { action: "fail", reason: "timeout", detail: undefined };
30+
}
31+
32+
switch (response.kind) {
33+
case "pending":
34+
return { action: "poll", delayMs: state.intervalMs, state };
35+
36+
case "slow-down": {
37+
const slowed: PollState = {
38+
intervalMs: state.intervalMs + slowDownIncrementMs,
39+
deadlineMs: state.deadlineMs,
40+
};
41+
return { action: "poll", delayMs: slowed.intervalMs, state: slowed };
42+
}
43+
44+
case "denied":
45+
return { action: "fail", reason: "access-denied", detail: undefined };
46+
47+
case "expired":
48+
return { action: "fail", reason: "expired", detail: undefined };
49+
50+
// Retryable, unlike `error`. The person has often already approved in the
51+
// browser by now, so abandoning the flow over one dropped request would
52+
// throw away work they have done and cannot see failing.
53+
case "unreachable": {
54+
const backedOff: PollState = {
55+
intervalMs: state.intervalMs * unreachableBackoffFactor,
56+
deadlineMs: state.deadlineMs,
57+
};
58+
return {
59+
action: "poll",
60+
delayMs: backedOff.intervalMs,
61+
state: backedOff,
62+
};
63+
}
64+
65+
case "error":
66+
return { action: "fail", reason: "network", detail: response.detail };
67+
}
68+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { apiResource, sameIssuer } from "./resource.js";
4+
5+
describe("apiResource", () => {
6+
it("is the deployment origin followed by /api", () => {
7+
expect(apiResource("https://app.qawolf.com")).toBe(
8+
"https://app.qawolf.com/api",
9+
);
10+
});
11+
12+
it("keeps the port, which is part of the deployment's identity", () => {
13+
expect(apiResource("http://localhost:3000")).toBe(
14+
"http://localhost:3000/api",
15+
);
16+
});
17+
18+
it("does not double a slash the host url carries", () => {
19+
expect(apiResource("https://app.qawolf.com/")).toBe(
20+
"https://app.qawolf.com/api",
21+
);
22+
});
23+
24+
it("ignores a path on the host url; the resource is the origin's", () => {
25+
expect(apiResource("https://app.qawolf.com/some/page")).toBe(
26+
"https://app.qawolf.com/api",
27+
);
28+
});
29+
});
30+
31+
describe("sameIssuer", () => {
32+
it("matches an issuer regardless of a trailing slash", () => {
33+
expect(
34+
sameIssuer("https://signin.example/", "https://signin.example"),
35+
).toBe(true);
36+
});
37+
38+
it("does not match a different host", () => {
39+
expect(sameIssuer("https://signin.example", "https://other.example")).toBe(
40+
false,
41+
);
42+
});
43+
44+
it("does not match a different scheme", () => {
45+
expect(sameIssuer("http://signin.example", "https://signin.example")).toBe(
46+
false,
47+
);
48+
});
49+
});

src/core/deviceAuth/resource.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* The API resource a deployment's tokens must be bound to: its origin followed
3+
* by `/api`. The API derives the same string from its own configured origin, so
4+
* scheme, host and port all have to agree, and `/api/` is a different string.
5+
*/
6+
export function apiResource(hostUrl: string): string {
7+
return new URL("/api", hostUrl).href;
8+
}
9+
10+
function withoutTrailingSlash(url: string): string {
11+
return url.replace(/\/+$/, "");
12+
}
13+
14+
/**
15+
* Issuers compare as strings once a trailing slash is discounted. Anything
16+
* looser — case folding, resolving a path — would let one server's metadata
17+
* or token pass for another's.
18+
*/
19+
export function sameIssuer(a: string, b: string): boolean {
20+
return withoutTrailingSlash(a) === withoutTrailingSlash(b);
21+
}

0 commit comments

Comments
 (0)