From 362b093fbd287693a0e5bad0406056d076e98142 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Mon, 19 Oct 2026 20:43:53 -0500 Subject: [PATCH] fix(auth): accept a typed pairing code regardless of letter case Pairing codes are issued as 12 uppercase characters, but the server matched them byte for byte, so a code typed in lowercase (the mobile Code field does not auto-capitalize) was rejected with auth_invalid. Fold a credential to uppercase before the pairing-link lookup when it has the issued code format. Seeded and rotating desktop bootstrap credentials are still matched exactly. Co-Authored-By: Claude Opus 5.5 --- .../server/src/auth/PairingGrantStore.test.ts | 29 +++++++++++++++++++ apps/server/src/auth/PairingGrantStore.ts | 15 ++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 1047f43489d9f..1052ff8b93e15 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -93,6 +93,18 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { }).pipe(Effect.provide(layerPairingGrantStore())), ); + it.effect("accepts a one-time token typed in lowercase", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const issued = yield* bootstrapCredentials.issueOneTimeToken(); + const first = yield* bootstrapCredentials.consume(issued.credential.toLowerCase()); + const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential)); + + expect(first.method).toBe("one-time-token"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); + }).pipe(Effect.provide(layerPairingGrantStore())), + ); + it.effect("atomically consumes a one-time token when multiple requests race", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -176,6 +188,23 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { ), ); + it.effect("matches a seeded desktop bootstrap credential exactly", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const exact = yield* bootstrapCredentials.consume("abcdefghjkmn"); + const otherCase = yield* Effect.flip(bootstrapCredentials.consume("ABCDEFGHJKMN")); + + expect(exact.method).toBe("desktop-bootstrap"); + expect(otherCase._tag).toBe("UnknownBootstrapCredentialError"); + }).pipe( + Effect.provide( + layerPairingGrantStore({ + desktopBootstrapToken: "abcdefghjkmn", + }), + ), + ), + ); + it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 36a2bc34b5698..2fb019b2c39b2 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -253,6 +253,10 @@ const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; +const PAIRING_TOKEN_PATTERN = new RegExp( + `^[${PAIRING_TOKEN_ALPHABET}]{${PAIRING_TOKEN_LENGTH}}$`, + "i", +); export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -538,12 +542,19 @@ export const make = Effect.gen(function* () { return yield* seededResult.error; } + // Pairing codes are issued in uppercase, but a phone keyboard types + // them in lowercase. Only codes in the issued format are folded, so + // other credentials still have to match exactly. + const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential) + ? credential.toUpperCase() + : credential; + // The scope check is part of the UPDATE's WHERE clause so a rejected // request cannot consume a one-time link. The re-check below only // explains why nothing matched. const consumed = yield* pairingLinks .consumeAvailable({ - credential, + credential: pairingCredential, proofKeyThumbprint: input?.proofKeyThumbprint ?? null, ...(input?.requestedScopes !== undefined ? { requestedScopes: input.requestedScopes } @@ -568,7 +579,7 @@ export const make = Effect.gen(function* () { } const matching = yield* pairingLinks - .getByCredential({ credential }) + .getByCredential({ credential: pairingCredential }) .pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause }))); if (Option.isNone(matching)) { return yield* new UnknownBootstrapCredentialError({});