Skip to content

Commit 923dfb8

Browse files
committed
feat(auth): add the device grant protocol state machine
1 parent 5c8ddfe commit 923dfb8

5 files changed

Lines changed: 352 additions & 0 deletions

File tree

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

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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import { readAccessTokenExpiry } from "./tokenExpiry.js";
4+
5+
function base64Url(value: string): string {
6+
return Buffer.from(value, "utf8").toString("base64url");
7+
}
8+
9+
function makeJwt(payload: unknown): string {
10+
return [
11+
base64Url(JSON.stringify({ alg: "RS256", typ: "JWT" })),
12+
base64Url(JSON.stringify(payload)),
13+
"signature-is-not-checked-here",
14+
].join(".");
15+
}
16+
17+
describe("readAccessTokenExpiry", () => {
18+
it("converts the exp claim from seconds to epoch milliseconds", () => {
19+
const token = makeJwt({ exp: 1_700_000_000, sub: "user_1" });
20+
21+
expect(readAccessTokenExpiry(token)).toBe(1_700_000_000_000);
22+
});
23+
24+
it("returns undefined when the payload carries no exp claim", () => {
25+
expect(readAccessTokenExpiry(makeJwt({ sub: "user_1" }))).toBeUndefined();
26+
});
27+
28+
it("returns undefined when exp is not a number", () => {
29+
expect(readAccessTokenExpiry(makeJwt({ exp: "soon" }))).toBeUndefined();
30+
});
31+
32+
it("returns undefined for a token that is not three segments", () => {
33+
expect(readAccessTokenExpiry("not.ajwt")).toBeUndefined();
34+
});
35+
36+
it("returns undefined when the payload segment is not JSON", () => {
37+
const token = ["header", base64Url("not json at all"), "sig"].join(".");
38+
39+
expect(readAccessTokenExpiry(token)).toBeUndefined();
40+
});
41+
42+
it("returns undefined for an empty token", () => {
43+
expect(readAccessTokenExpiry("")).toBeUndefined();
44+
});
45+
46+
it("decodes payloads containing base64url-only characters", () => {
47+
// A payload whose base64 encoding needs - and _ rather than + and /.
48+
const token = makeJwt({ exp: 1_700_000_001, note: "??~~??>>>" });
49+
50+
expect(readAccessTokenExpiry(token)).toBe(1_700_000_001_000);
51+
});
52+
});

src/core/deviceAuth/tokenExpiry.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/**
2+
* Epoch milliseconds. The device token response carries no `expires_in`, so the
3+
* only expiry available is the `exp` claim inside the token. Decoded without
4+
* verifying the signature: the value decides when to refresh, and the API
5+
* judges whether a token is genuine.
6+
*
7+
* Undefined for anything malformed — an unreadable expiry means "refresh it",
8+
* not "crash the command".
9+
*/
10+
export function readAccessTokenExpiry(accessToken: string): number | undefined {
11+
const segments = accessToken.split(".");
12+
if (segments.length !== 3) return undefined;
13+
14+
const [, payload] = segments;
15+
if (!payload) return undefined;
16+
17+
let claims: unknown;
18+
try {
19+
claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
20+
} catch {
21+
return undefined;
22+
}
23+
24+
if (typeof claims !== "object" || claims === null) return undefined;
25+
26+
const exp = (claims as { exp?: unknown }).exp;
27+
if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
28+
29+
return exp * 1_000;
30+
}

src/core/deviceAuth/types.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
export type DeviceTokens = {
2+
accessToken: string;
3+
refreshToken: string;
4+
/** Epoch ms; absent when the token carried no readable expiry. */
5+
expiresAt: number | undefined;
6+
email: string;
7+
/**
8+
* WorkOS organization the session is scoped to. WorkOS picks one when the
9+
* person belongs to several and the device flow never asks, so it is kept
10+
* both to show which was chosen and to pin refreshes to it.
11+
*/
12+
organizationId: string | undefined;
13+
};
14+
15+
/** One token-endpoint answer, already narrowed from its OAuth error code. */
16+
export type PollResponse =
17+
| { kind: "tokens"; tokens: DeviceTokens }
18+
| { kind: "pending" }
19+
| { kind: "slow-down" }
20+
| { kind: "denied" }
21+
| { kind: "expired" }
22+
/** The server answered something unrecognised, or refused outright. */
23+
| { kind: "error"; detail: string }
24+
/**
25+
* The server never gave a usable answer — unreachable, or failing with a 5xx
26+
* or 429. Transient either way, so worth retrying.
27+
*/
28+
| { kind: "unreachable"; detail: string };
29+
30+
export type PollState = {
31+
intervalMs: number;
32+
/** Epoch ms the device code stops being redeemable. */
33+
deadlineMs: number;
34+
};
35+
36+
type PollFailure = "access-denied" | "expired" | "timeout" | "network";
37+
38+
export type PollStep =
39+
| { action: "poll"; delayMs: number; state: PollState }
40+
| { action: "done"; tokens: DeviceTokens }
41+
| { action: "fail"; reason: PollFailure; detail: string | undefined };

0 commit comments

Comments
 (0)