Skip to content

Commit 238f0c0

Browse files
committed
fix(auth): propagate scope-mismatch reason instead of collapsing to device_token_mismatch
When verifyDeviceToken returns { ok: false, reason: 'scope-mismatch' }, resolveConnectAuthDecision was discarding that reason and always emitting 'device_token_mismatch'. This caused the gateway to tell the user their token value was wrong (rotate/reissue) when the real problem was that the connected client requested scopes beyond what the stored device token allows (e.g. operator.admin / operator.pairing which bootstrap tokens do not include). Changes: - auth-context.ts: propagate scope-mismatch reason as scope_mismatch (also widens VerifyDeviceTokenResult to include optional reason) - connect-error-details.ts: add AUTH_DEVICE_TOKEN_SCOPE_MISMATCH code and map scope_mismatch in resolveAuthConnectErrorDetailCode - auth-messages.ts: human-readable message directing users to re-pair or approve a scope upgrade - auth-context.test.ts: regression test for scope-mismatch propagation Fixes #79292
1 parent 042c117 commit 238f0c0

4 files changed

Lines changed: 31 additions & 3 deletions

File tree

src/gateway/protocol/connect-error-details.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const ConnectErrorDetailCodes = {
1111
AUTH_PASSWORD_NOT_CONFIGURED: "AUTH_PASSWORD_NOT_CONFIGURED", // pragma: allowlist secret
1212
AUTH_BOOTSTRAP_TOKEN_INVALID: "AUTH_BOOTSTRAP_TOKEN_INVALID",
1313
AUTH_DEVICE_TOKEN_MISMATCH: "AUTH_DEVICE_TOKEN_MISMATCH",
14+
AUTH_DEVICE_TOKEN_SCOPE_MISMATCH: "AUTH_DEVICE_TOKEN_SCOPE_MISMATCH",
1415
AUTH_RATE_LIMITED: "AUTH_RATE_LIMITED",
1516
AUTH_TAILSCALE_IDENTITY_MISSING: "AUTH_TAILSCALE_IDENTITY_MISSING",
1617
AUTH_TAILSCALE_PROXY_MISSING: "AUTH_TAILSCALE_PROXY_MISSING",
@@ -158,6 +159,8 @@ export function resolveAuthConnectErrorDetailCode(
158159
return ConnectErrorDetailCodes.AUTH_RATE_LIMITED;
159160
case "device_token_mismatch":
160161
return ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_MISMATCH;
162+
case "scope_mismatch":
163+
return ConnectErrorDetailCodes.AUTH_DEVICE_TOKEN_SCOPE_MISMATCH;
161164
case undefined:
162165
return ConnectErrorDetailCodes.AUTH_REQUIRED;
163166
default:

src/gateway/server/ws-connection/auth-context.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,27 @@ describe("resolveConnectAuthDecision", () => {
126126
expect(decision.authResult.reason).toBe("device_token_mismatch");
127127
});
128128

129+
it("reports scope-mismatch from verifyDeviceToken as scope_mismatch (not device_token_mismatch)", async () => {
130+
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({
131+
ok: false,
132+
reason: "scope-mismatch",
133+
}));
134+
const decision = await resolveConnectAuthDecision({
135+
state: createBaseState({
136+
deviceTokenCandidateSource: "explicit-device-token",
137+
}),
138+
hasDeviceIdentity: true,
139+
deviceId: "dev-1",
140+
publicKey: "pub-1",
141+
role: "operator",
142+
scopes: ["operator.read", "operator.admin"],
143+
verifyBootstrapToken: async () => ({ ok: false, reason: "bootstrap_token_invalid" }),
144+
verifyDeviceToken,
145+
});
146+
expect(decision.authOk).toBe(false);
147+
expect(decision.authResult.reason).toBe("scope_mismatch");
148+
});
149+
129150
it("accepts valid device tokens and marks auth method as device-token", async () => {
130151
const rateLimiter = createRateLimiter();
131152
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));

src/gateway/server/ws-connection/auth-context.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export type ConnectAuthState = {
3232
deviceTokenCandidateSource?: DeviceTokenCandidateSource;
3333
};
3434

35-
type VerifyDeviceTokenResult = { ok: boolean };
35+
type VerifyDeviceTokenResult = { ok: boolean; reason?: string };
3636
type VerifyBootstrapTokenResult = { ok: boolean; reason?: string };
3737

3838
export type ConnectAuthDecision = {
@@ -214,10 +214,12 @@ export async function resolveConnectAuthDecision(params: {
214214
params.rateLimiter?.reset(params.clientIp, AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);
215215
}
216216
} else {
217+
const isScopeMismatch = tokenCheck.reason === "scope-mismatch";
217218
authResult = {
218219
ok: false,
219-
reason:
220-
params.state.deviceTokenCandidateSource === "explicit-device-token"
220+
reason: isScopeMismatch
221+
? "scope_mismatch"
222+
: params.state.deviceTokenCandidateSource === "explicit-device-token"
221223
? "device_token_mismatch"
222224
: (authResult.reason ?? "device_token_mismatch"),
223225
};

src/gateway/server/ws-connection/auth-messages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export function formatGatewayAuthFailureMessage(params: {
5555
return "unauthorized: too many failed authentication attempts (retry later)";
5656
case "device_token_mismatch":
5757
return "unauthorized: device token mismatch (rotate/reissue device token)";
58+
case "scope_mismatch":
59+
return "unauthorized: device token scope mismatch (re-pair or approve scope upgrade in Control UI)";
5860
default:
5961
break;
6062
}

0 commit comments

Comments
 (0)