From de3b2036f1f6212c46bb1b20f9acf2e1c3118000 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 18 Jun 2026 07:29:00 +0000 Subject: [PATCH 1/6] feat(auth)!: 6-digit OTP pairing code and crypto-secure tokens Separate the two roles the auth flow previously conflated. The persistent client bearer token and remote-dock tokens become high-entropy, CSPRNG credentials, while the human-typed pairing code becomes an easy-to-enter 6-digit one-time code guarded by a short TTL and an attempt cap. The pairing code is single-use, expires after five minutes, is compared in constant time, and rotates after five failed attempts, so its shorter length stays brute-force resistant. Bearer and dock tokens move off the vendored word-list generator (which relied on Math.random) to WebCrypto-backed randomness. The vendored human-id helper is removed in favour of a new `devframe/utils/crypto-token` util exposing `randomToken`, `randomDigits`, and `timingSafeEqual`. BREAKING CHANGE: the `devframe/utils/human-id` export is removed. Use `randomToken` from `devframe/utils/crypto-token` for random identifiers. --- alias.ts | 2 +- docs/helpers/utilities.md | 20 ++++--- packages/devframe/package.json | 2 +- packages/devframe/src/client/rpc.ts | 4 +- packages/devframe/src/node/auth/state.ts | 50 ++++++++++++++-- .../src/node/hub-internals/context.ts | 4 +- packages/devframe/src/utils/crypto-token.ts | 60 +++++++++++++++++++ packages/devframe/src/utils/human-id.ts | 21 ------- .../devframe/test/runtime-agnostic.test.ts | 2 +- packages/devframe/tsdown.config.ts | 4 +- skills/devframe/SKILL.md | 2 +- .../devframe/utils/crypto-token.snapshot.d.ts | 8 +++ .../devframe/utils/crypto-token.snapshot.js | 8 +++ .../devframe/utils/human-id.snapshot.d.ts | 6 -- .../devframe/utils/human-id.snapshot.js | 6 -- tsconfig.base.json | 6 +- 16 files changed, 145 insertions(+), 60 deletions(-) create mode 100644 packages/devframe/src/utils/crypto-token.ts delete mode 100644 packages/devframe/src/utils/human-id.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js delete mode 100644 tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts delete mode 100644 tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js diff --git a/alias.ts b/alias.ts index e1f0e42..d1f5344 100644 --- a/alias.ts +++ b/alias.ts @@ -18,9 +18,9 @@ export const alias = { 'devframe/node': r('devframe/src/node/index.ts'), 'devframe/constants': r('devframe/src/constants.ts'), 'devframe/utils/colors': r('devframe/src/utils/colors.ts'), + 'devframe/utils/crypto-token': r('devframe/src/utils/crypto-token.ts'), 'devframe/utils/events': r('devframe/src/utils/events.ts'), 'devframe/utils/hash': r('devframe/src/utils/hash.ts'), - 'devframe/utils/human-id': r('devframe/src/utils/human-id.ts'), 'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'), 'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'), 'devframe/utils/open': r('devframe/src/utils/open.ts'), diff --git a/docs/helpers/utilities.md b/docs/helpers/utilities.md index 0d27ab9..20071e5 100644 --- a/docs/helpers/utilities.md +++ b/docs/helpers/utilities.md @@ -74,25 +74,27 @@ const wire = structuredCloneStringify(new Map([['a', 1]])) const value = structuredCloneParse>(wire) ``` -### `devframe/utils/human-id` +### `devframe/utils/nanoid` -Generate a human-readable, lowercase, dash-separated random ID. +Tiny URL-safe random ID generator (vendored, no runtime dependency). ```ts -import { humanId } from 'devframe/utils/human-id' +import { nanoid } from 'devframe/utils/nanoid' -humanId() // 'bright-orange-tiger' +nanoid() // 21 chars +nanoid(10) // 10 chars ``` -### `devframe/utils/nanoid` +### `devframe/utils/crypto-token` -Tiny URL-safe random ID generator (vendored, no runtime dependency). +Cryptographically-secure token helpers built on the WebCrypto global, so they run in browsers and Node alike. Use these for bearer credentials and human-typed one-time codes. ```ts -import { nanoid } from 'devframe/utils/nanoid' +import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' -nanoid() // 21 chars -nanoid(10) // 10 chars +randomToken() // 32-char hex, 128 bits of entropy — use as a bearer token +randomDigits(6) // '047204' — uniform, leading zeros preserved +timingSafeEqual(input, secret) // constant-time string comparison ``` ### `devframe/utils/promise` diff --git a/packages/devframe/package.json b/packages/devframe/package.json index aaa2c90..946b0f4 100644 --- a/packages/devframe/package.json +++ b/packages/devframe/package.json @@ -40,9 +40,9 @@ "./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs", "./types": "./dist/types/index.mjs", "./utils/colors": "./dist/utils/colors.mjs", + "./utils/crypto-token": "./dist/utils/crypto-token.mjs", "./utils/events": "./dist/utils/events.mjs", "./utils/hash": "./dist/utils/hash.mjs", - "./utils/human-id": "./dist/utils/human-id.mjs", "./utils/launch-editor": "./dist/utils/launch-editor.mjs", "./utils/nanoid": "./dist/utils/nanoid.mjs", "./utils/open": "./dist/utils/open.mjs", diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index d5c3bf5..71fde67 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -7,8 +7,8 @@ import { DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' +import { randomToken } from 'devframe/utils/crypto-token' import { createEventEmitter } from 'devframe/utils/events' -import { humanId } from 'devframe/utils/human-id' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' @@ -144,7 +144,7 @@ function getConnectionAuthTokenFromWindows(userAuthToken?: string): string { } if (!value) - value = humanId() + value = randomToken() localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, value) ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = value diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 346a219..7157dce 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -1,7 +1,18 @@ import type { DevframeNodeRpcSession } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' -import { humanId } from 'devframe/utils/human-id' +import { randomDigits, timingSafeEqual } from 'devframe/utils/crypto-token' + +/** Number of decimal digits in a human-typed one-time pairing code. */ +const TEMP_AUTH_TOKEN_LENGTH = 6 +/** + * How long a pairing code stays valid after it is (re)generated. A 6-digit + * code only has ~20 bits of entropy, so a short lifetime plus the attempt cap + * below are what keep it brute-force resistant. + */ +const TEMP_AUTH_TOKEN_TTL = 5 * 60_000 +/** Failed attempts allowed against a single code before it is rotated. */ +const TEMP_AUTH_MAX_ATTEMPTS = 5 export interface PendingAuthRequest { clientAuthToken: string @@ -15,17 +26,26 @@ export interface PendingAuthRequest { let pendingAuth: PendingAuthRequest | null = null let tempAuthToken: string = generateTempId() +let tempAuthExpiresAt: number = Date.now() + TEMP_AUTH_TOKEN_TTL +let tempAuthFailedAttempts = 0 function generateTempId(): string { - return humanId() + return randomDigits(TEMP_AUTH_TOKEN_LENGTH) } export function getTempAuthToken(): string { return tempAuthToken } +/** + * Rotate the pairing code, resetting its expiry window and failed-attempt + * counter. Adapters call this when a new pairing flow begins so the displayed + * code is freshly valid. + */ export function refreshTempAuthToken(): string { tempAuthToken = generateTempId() + tempAuthExpiresAt = Date.now() + TEMP_AUTH_TOKEN_TTL + tempAuthFailedAttempts = 0 return tempAuthToken } @@ -49,14 +69,34 @@ export function abortPendingAuth(): void { } /** - * Consume the temp auth ID: verify it matches, trust the pending client, and clean up. - * Returns the client's authToken if successful, null otherwise. + * Consume the temp auth code: verify it matches an active, unexpired pairing + * code, trust the pending client, and clean up. Returns the client's authToken + * on success, `null` otherwise. + * + * Because the code is short and human-typed, verification is hardened against + * brute force: it requires a live pending request, enforces a time-to-live, + * compares in constant time, and rotates the code after + * {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an attacker cannot keep + * guessing against the same code. */ export function consumeTempAuthToken( id: string, storage: SharedState, ): string | null { - if (id !== tempAuthToken || !pendingAuth) { + if (!pendingAuth) + return null + + // Expired code: rotate so a stale code can never be redeemed. + if (Date.now() > tempAuthExpiresAt) { + refreshTempAuthToken() + return null + } + + if (!timingSafeEqual(id, tempAuthToken)) { + tempAuthFailedAttempts += 1 + // Too many wrong guesses — invalidate this code entirely. + if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS) + refreshTempAuthToken() return null } diff --git a/packages/devframe/src/node/hub-internals/context.ts b/packages/devframe/src/node/hub-internals/context.ts index 43506b8..080da08 100644 --- a/packages/devframe/src/node/hub-internals/context.ts +++ b/packages/devframe/src/node/hub-internals/context.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import { humanId } from 'devframe/utils/human-id' +import { randomToken } from 'devframe/utils/crypto-token' import { join } from 'pathe' import { revokeActiveConnectionsForToken, revokeAuthToken } from '../auth/revoke' import { createStorage } from '../storage' @@ -80,7 +80,7 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token), remoteTokens, allocateRemoteToken(dockId, origin, originLock) { - const token = humanId() + const token = randomToken() remoteTokens.set(token, { dockId, origin, originLock }) return token }, diff --git a/packages/devframe/src/utils/crypto-token.ts b/packages/devframe/src/utils/crypto-token.ts new file mode 100644 index 0000000..38799e8 --- /dev/null +++ b/packages/devframe/src/utils/crypto-token.ts @@ -0,0 +1,60 @@ +// Cryptographically-secure token helpers built on the WebCrypto global +// (`globalThis.crypto`), which is present in browsers and Node 19+. Kept free +// of node builtins so it stays runtime-agnostic (see `test/runtime-agnostic.test.ts`) +// and can be shared by browser-side client code and node-side auth code alike. +// +// `getRandomValues` is available in both secure and insecure contexts, unlike +// `crypto.randomUUID`, so it works even when a devtool is reached over plain +// HTTP on a LAN address. + +const HEX = '0123456789abcdef' + +/** + * Generate a high-entropy, URL-safe (hex) random token suitable for use as a + * bearer credential — e.g. the persistent client auth token or an ephemeral + * remote-dock token. Defaults to 16 bytes (128 bits) of entropy. + */ +export function randomToken(byteLength = 16): string { + const bytes = new Uint8Array(byteLength) + globalThis.crypto.getRandomValues(bytes) + let out = '' + for (let i = 0; i < bytes.length; i++) + out += HEX[bytes[i] >> 4] + HEX[bytes[i] & 0x0F] + return out +} + +/** + * Generate a uniformly-distributed string of decimal digits using rejection + * sampling to avoid modulo bias. Intended for short, human-typed one-time + * codes (e.g. a 6-digit pairing code). Leading zeros are preserved. + */ +export function randomDigits(length: number): string { + // Largest multiple of 10 that fits in a byte; reject values at/above it so + // every digit is equally likely. + const limit = 250 + const buf = new Uint8Array(1) + let out = '' + while (out.length < length) { + globalThis.crypto.getRandomValues(buf) + if (buf[0] < limit) + out += String(buf[0] % 10) + } + return out +} + +/** + * Constant-time string equality. Compares every character so the comparison + * time does not depend on the position of the first mismatch, mitigating + * timing side-channels when verifying secrets. + * + * Length is treated as public (it short-circuits on differing lengths), which + * is appropriate for fixed-length codes and tokens. + */ +export function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) + return false + let mismatch = 0 + for (let i = 0; i < a.length; i++) + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i) + return mismatch === 0 +} diff --git a/packages/devframe/src/utils/human-id.ts b/packages/devframe/src/utils/human-id.ts deleted file mode 100644 index 2e3b312..0000000 --- a/packages/devframe/src/utils/human-id.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Vendored from `human-id@4.1.3` (MIT — Copyright (c) 2018 RienNeVaPlus). -// The upstream is shipped as CommonJS, which tsdown wraps with a shim that -// imports `node:module`. Inlining the relevant subset keeps this entry -// runtime-agnostic (see `test/runtime-agnostic.test.ts`). -const adjectives = ['afraid', 'all', 'beige', 'better', 'big', 'blue', 'bold', 'brave', 'breezy', 'bright', 'brown', 'bumpy', 'busy', 'calm', 'chatty', 'chilly', 'chubby', 'clean', 'clear', 'clever', 'cold', 'common', 'cool', 'cozy', 'crisp', 'cuddly', 'curly', 'curvy', 'cute', 'cyan', 'dark', 'deep', 'dirty', 'dry', 'dull', 'eager', 'early', 'easy', 'eight', 'eighty', 'eleven', 'empty', 'every', 'fair', 'famous', 'fancy', 'fast', 'few', 'fiery', 'fifty', 'fine', 'five', 'flat', 'floppy', 'fluffy', 'forty', 'four', 'frank', 'free', 'fresh', 'fruity', 'full', 'funky', 'funny', 'fuzzy', 'gentle', 'giant', 'gold', 'good', 'goofy', 'great', 'green', 'grumpy', 'happy', 'heavy', 'hip', 'honest', 'hot', 'huge', 'humble', 'hungry', 'icy', 'itchy', 'jolly', 'khaki', 'kind', 'large', 'late', 'lazy', 'legal', 'lemon', 'light', 'little', 'long', 'loose', 'loud', 'lovely', 'lucky', 'major', 'many', 'metal', 'mighty', 'modern', 'moody', 'neat', 'new', 'nice', 'nine', 'ninety', 'odd', 'old', 'olive', 'open', 'orange', 'perky', 'petite', 'pink', 'plain', 'plenty', 'polite', 'pretty', 'proud', 'public', 'puny', 'purple', 'quick', 'quiet', 'rare', 'ready', 'real', 'red', 'rich', 'ripe', 'salty', 'seven', 'shaggy', 'shaky', 'sharp', 'shiny', 'short', 'shy', 'silent', 'silly', 'silver', 'six', 'sixty', 'slick', 'slimy', 'slow', 'small', 'smart', 'smooth', 'social', 'soft', 'solid', 'some', 'sour', 'sparkly', 'spicy', 'spotty', 'stale', 'strict', 'strong', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'tangy', 'tasty', 'ten', 'tender', 'thick', 'thin', 'thirty', 'three', 'tidy', 'tiny', 'tired', 'tough', 'tricky', 'true', 'twelve', 'twenty', 'two', 'upset', 'vast', 'violet', 'wacky', 'warm', 'wet', 'whole', 'wicked', 'wide', 'wild', 'wise', 'witty', 'yellow', 'young', 'yummy'] - -const nouns = ['actors', 'ads', 'adults', 'aliens', 'animals', 'ants', 'apes', 'apples', 'areas', 'baboons', 'badgers', 'bags', 'balloons', 'bananas', 'banks', 'bars', 'baths', 'bats', 'beans', 'bears', 'beds', 'beers', 'bees', 'berries', 'bikes', 'birds', 'boats', 'bobcats', 'books', 'bottles', 'boxes', 'breads', 'brooms', 'buckets', 'bugs', 'buses', 'bushes', 'buttons', 'camels', 'cameras', 'candies', 'candles', 'canyons', 'carpets', 'carrots', 'cars', 'cases', 'cats', 'chairs', 'chefs', 'chicken', 'cities', 'clocks', 'cloths', 'clouds', 'clowns', 'clubs', 'coats', 'cobras', 'coins', 'colts', 'comics', 'cooks', 'corners', 'cougars', 'cows', 'crabs', 'crews', 'cups', 'cycles', 'dancers', 'days', 'deer', 'deserts', 'dingos', 'dodos', 'dogs', 'dolls', 'donkeys', 'donuts', 'doodles', 'doors', 'dots', 'dragons', 'drinks', 'dryers', 'ducks', 'eagles', 'ears', 'eels', 'eggs', 'emus', 'ends', 'experts', 'eyes', 'facts', 'falcons', 'fans', 'feet', 'files', 'flies', 'flowers', 'forks', 'foxes', 'friends', 'frogs', 'games', 'garlics', 'geckos', 'geese', 'ghosts', 'gifts', 'glasses', 'goats', 'grapes', 'groups', 'guests', 'hairs', 'hands', 'hats', 'heads', 'hoops', 'hornets', 'horses', 'hotels', 'hounds', 'houses', 'humans', 'icons', 'ideas', 'impalas', 'insects', 'islands', 'items', 'jars', 'jeans', 'jobs', 'jokes', 'keys', 'kids', 'kings', 'kiwis', 'knives', 'lamps', 'lands', 'laws', 'lemons', 'lies', 'lights', 'lilies', 'lines', 'lions', 'lizards', 'llamas', 'loops', 'mails', 'mammals', 'mangos', 'maps', 'masks', 'meals', 'melons', 'memes', 'meteors', 'mice', 'mirrors', 'moles', 'moments', 'monkeys', 'months', 'moons', 'moose', 'mugs', 'nails', 'needles', 'news', 'nights', 'numbers', 'olives', 'onions', 'oranges', 'otters', 'owls', 'pandas', 'pans', 'pants', 'papayas', 'papers', 'parents', 'parks', 'parrots', 'parts', 'paths', 'paws', 'peaches', 'pears', 'peas', 'pens', 'pets', 'phones', 'pianos', 'pigs', 'pillows', 'places', 'planes', 'planets', 'plants', 'plums', 'poems', 'poets', 'points', 'pots', 'pugs', 'pumas', 'queens', 'rabbits', 'radios', 'rats', 'ravens', 'readers', 'regions', 'results', 'rice', 'rings', 'rivers', 'rockets', 'rocks', 'rooms', 'roses', 'rules', 'sails', 'schools', 'seals', 'seas', 'sheep', 'shirts', 'shoes', 'showers', 'shrimps', 'sides', 'signs', 'singers', 'sites', 'sloths', 'snails', 'snakes', 'socks', 'spiders', 'spies', 'spoons', 'squids', 'stamps', 'stars', 'states', 'steaks', 'streets', 'suits', 'suns', 'swans', 'symbols', 'tables', 'taxes', 'taxis', 'teams', 'teeth', 'terms', 'things', 'ties', 'tigers', 'times', 'tips', 'tires', 'toes', 'tools', 'towns', 'toys', 'trains', 'trams', 'trees', 'turkeys', 'turtles', 'vans', 'views', 'walls', 'wasps', 'waves', 'ways', 'webs', 'weeks', 'windows', 'wings', 'wolves', 'wombats', 'words', 'worlds', 'worms', 'yaks', 'years', 'zebras', 'zoos'] - -const verbs = ['accept', 'act', 'add', 'admire', 'agree', 'allow', 'appear', 'argue', 'arrive', 'ask', 'attack', 'attend', 'bake', 'bathe', 'battle', 'beam', 'beg', 'begin', 'behave', 'bet', 'boil', 'bow', 'brake', 'brush', 'build', 'burn', 'buy', 'call', 'camp', 'care', 'carry', 'change', 'cheat', 'check', 'cheer', 'chew', 'clap', 'clean', 'cough', 'count', 'cover', 'crash', 'create', 'cross', 'cry', 'cut', 'dance', 'decide', 'deny', 'design', 'dig', 'divide', 'do', 'double', 'doubt', 'draw', 'dream', 'dress', 'drive', 'drop', 'drum', 'eat', 'end', 'enjoy', 'enter', 'exist', 'fail', 'fall', 'feel', 'fetch', 'film', 'find', 'fix', 'flash', 'float', 'flow', 'fly', 'fold', 'follow', 'fry', 'give', 'glow', 'go', 'grab', 'greet', 'grin', 'grow', 'guess', 'hammer', 'hang', 'happen', 'heal', 'hear', 'help', 'hide', 'hope', 'hug', 'hunt', 'invent', 'invite', 'itch', 'jam', 'jog', 'join', 'joke', 'judge', 'juggle', 'jump', 'kick', 'kiss', 'kneel', 'knock', 'know', 'laugh', 'lay', 'lead', 'learn', 'leave', 'lick', 'lie', 'like', 'listen', 'live', 'look', 'lose', 'love', 'make', 'march', 'marry', 'mate', 'matter', 'melt', 'mix', 'move', 'nail', 'notice', 'obey', 'occur', 'open', 'own', 'pay', 'peel', 'pick', 'play', 'poke', 'post', 'press', 'prove', 'pull', 'pump', 'punch', 'push', 'raise', 'read', 'refuse', 'relate', 'relax', 'remain', 'repair', 'repeat', 'reply', 'report', 'rescue', 'rest', 'retire', 'return', 'rhyme', 'ring', 'roll', 'rule', 'run', 'rush', 'say', 'scream', 'search', 'see', 'sell', 'send', 'serve', 'shake', 'share', 'shave', 'shine', 'shop', 'shout', 'show', 'sin', 'sing', 'sink', 'sip', 'sit', 'sleep', 'slide', 'smash', 'smell', 'smile', 'smoke', 'sneeze', 'sniff', 'sort', 'speak', 'spend', 'stand', 'stare', 'start', 'stay', 'stick', 'stop', 'strive', 'study', 'swim', 'switch', 'take', 'talk', 'tan', 'tap', 'taste', 'teach', 'tease', 'tell', 'thank', 'think', 'throw', 'tickle', 'tie', 'trade', 'train', 'travel', 'try', 'turn', 'type', 'unite', 'vanish', 'visit', 'wait', 'walk', 'warn', 'wash', 'watch', 'wave', 'wear', 'win', 'wink', 'wish', 'wonder', 'work', 'worry', 'write', 'yawn', 'yell'] - -function pick(arr: readonly string[]): string { - return arr[(Math.random() * arr.length) | 0] -} - -/** - * Generate a human-readable, lowercase, dash-separated random ID - * (e.g. `bright-orange-tigers-jump`). - */ -export function humanId(): string { - return `${pick(adjectives)}-${pick(nouns)}-${pick(verbs)}` -} diff --git a/packages/devframe/test/runtime-agnostic.test.ts b/packages/devframe/test/runtime-agnostic.test.ts index cbd3375..cd63b30 100644 --- a/packages/devframe/test/runtime-agnostic.test.ts +++ b/packages/devframe/test/runtime-agnostic.test.ts @@ -10,9 +10,9 @@ import { describe, expect, it } from 'vitest' const AGNOSTIC_ENTRIES = [ 'client/index.mjs', 'utils/colors.mjs', + 'utils/crypto-token.mjs', 'utils/events.mjs', 'utils/hash.mjs', - 'utils/human-id.mjs', 'utils/nanoid.mjs', 'utils/promise.mjs', 'utils/shared-state.mjs', diff --git a/packages/devframe/tsdown.config.ts b/packages/devframe/tsdown.config.ts index 7a455d5..50fec16 100644 --- a/packages/devframe/tsdown.config.ts +++ b/packages/devframe/tsdown.config.ts @@ -59,9 +59,9 @@ const deps = { const clientEntries = { 'client/index': 'src/client/index.ts', 'utils/colors': 'src/utils/colors.ts', + 'utils/crypto-token': 'src/utils/crypto-token.ts', 'utils/events': 'src/utils/events.ts', 'utils/hash': 'src/utils/hash.ts', - 'utils/human-id': 'src/utils/human-id.ts', 'utils/nanoid': 'src/utils/nanoid.ts', 'utils/promise': 'src/utils/promise.ts', 'utils/shared-state': 'src/utils/shared-state.ts', @@ -128,9 +128,9 @@ export default defineConfig([ entries: [ resolve(distDir, 'client/index.mjs'), resolve(distDir, 'utils/colors.mjs'), + resolve(distDir, 'utils/crypto-token.mjs'), resolve(distDir, 'utils/events.mjs'), resolve(distDir, 'utils/hash.mjs'), - resolve(distDir, 'utils/human-id.mjs'), resolve(distDir, 'utils/nanoid.mjs'), resolve(distDir, 'utils/promise.mjs'), resolve(distDir, 'utils/shared-state.mjs'), diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 07b5c6b..dd2c71c 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -468,8 +468,8 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are | `launchEditor` from `devframe/utils/launch-editor` | `launch-editor` | Open `file:line:column` in the user's editor (optional `editor` arg) | | `hash` from `devframe/utils/hash` | `ohash` | Stable structural hash — cache keys, dedup | | `structuredClone{Serialize,Deserialize,Stringify,Parse}` from `devframe/utils/structured-clone` | `structured-clone-es` | JSON-safe round-trip of `Map` / `Set` / `Date` / `BigInt` / cycles | -| `humanId` from `devframe/utils/human-id` | `human-id` | Human-readable IDs (`bright-orange-tiger`) | | `nanoid` from `devframe/utils/nanoid` | (vendored) | URL-safe random IDs | +| `randomToken` / `randomDigits` / `timingSafeEqual` from `devframe/utils/crypto-token` | (native WebCrypto) | CSPRNG bearer tokens, one-time codes, constant-time compare | | `promiseWithResolver` from `devframe/utils/promise` | — | Externally-controlled `Promise` | | `createEventEmitter` from `devframe/utils/events` | — | Typed event bus | | `createSharedState` from `devframe/utils/shared-state` | (immer internal) | Immutable state container (see `ctx.rpc.sharedState`) | diff --git a/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts new file mode 100644 index 0000000..1a59254 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.d.ts @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/crypto-token` + */ +// #region Functions +export declare function randomDigits(_: number): string; +export declare function randomToken(_?: number): string; +export declare function timingSafeEqual(_: string, _: string): boolean; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js new file mode 100644 index 0000000..7c982b7 --- /dev/null +++ b/tests/__snapshots__/tsnapi/devframe/utils/crypto-token.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `devframe/utils/crypto-token` + */ +// #region Functions +export function randomDigits(_) {} +export function randomToken(_) {} +export function timingSafeEqual(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts deleted file mode 100644 index 3e2bb5e..0000000 --- a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `devframe/utils/human-id` - */ -// #region Functions -export declare function humanId(): string; -// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js b/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js deleted file mode 100644 index 83b3df5..0000000 --- a/tests/__snapshots__/tsnapi/devframe/utils/human-id.snapshot.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Generated by tsnapi — public API snapshot of `devframe/utils/human-id` - */ -// #region Functions -export function humanId() {} -// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index 1fac0a9..0176f62 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -45,15 +45,15 @@ "devframe/utils/colors": [ "./packages/devframe/src/utils/colors.ts" ], + "devframe/utils/crypto-token": [ + "./packages/devframe/src/utils/crypto-token.ts" + ], "devframe/utils/events": [ "./packages/devframe/src/utils/events.ts" ], "devframe/utils/hash": [ "./packages/devframe/src/utils/hash.ts" ], - "devframe/utils/human-id": [ - "./packages/devframe/src/utils/human-id.ts" - ], "devframe/utils/launch-editor": [ "./packages/devframe/src/utils/launch-editor.ts" ], From b47a934657b8de716756d32f3019efce047bf4a5 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 01:56:50 +0000 Subject: [PATCH 2/6] feat(auth)!: node-issued one-time-code token exchange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pivot the pairing flow from "client mints a token, node approves it" to a node-issued exchange: the dev server shows a 6-digit code, the browser submits it, and the node mints and returns the bearer token. The token now travels down only after the code is verified, and the node owns token generation. Node side (`devframe/node/auth`): drop the pending-promise machinery (`PendingAuthRequest`, `setPendingAuth`, `getPendingAuth`, `abortPendingAuth`, `consumeTempAuthToken`) in favour of two synchronous primitives — `exchangeTempAuthCode` (verify code → mint + store + trust + return token) and `verifyAuthToken` (re-auth a stored token on reconnect). The code keeps its TTL, constant-time compare, and attempt-cap guards. Client side: stop self-issuing a token. A fresh client connects unpaired and calls the new `requestTrustWithCode(code)` to exchange the code for a token, which is persisted and broadcast to sibling tabs. `requestTrustWithToken` remains for re-auth. The client still announces on connect so the standalone `auth: false` noop keeps auto-trusting. The auth wire methods (`devframe:anonymous:auth`, `devframe:auth:exchange`, `devframe:auth:revoked`) are now declared in the RPC contract types. BREAKING CHANGE: `devframe/node/auth` no longer exports `PendingAuthRequest`, `setPendingAuth`, `getPendingAuth`, `abortPendingAuth`, or `consumeTempAuthToken`. Host adapters register a `devframe:auth:exchange` handler built on `exchangeTempAuthCode`, and an `devframe:anonymous:auth` handler built on `verifyAuthToken`, instead of the pending-request dance. --- docs/guide/client.md | 22 ++- packages/devframe/src/client/rpc-static.ts | 2 + packages/devframe/src/client/rpc-ws.ts | 45 +++++-- packages/devframe/src/client/rpc.ts | 75 ++++++++--- packages/devframe/src/node/auth/state.ts | 126 ++++++++---------- packages/devframe/src/types/rpc-augments.ts | 25 ++++ .../tsnapi/devframe/client.snapshot.d.ts | 2 + .../tsnapi/devframe/node/auth.snapshot.d.ts | 21 +-- .../tsnapi/devframe/node/auth.snapshot.js | 6 +- 9 files changed, 197 insertions(+), 127 deletions(-) diff --git a/docs/guide/client.md b/docs/guide/client.md index 7d3a286..b097fea 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -66,7 +66,7 @@ The client picks a mode automatically from the backend field. Mode-specific code ## Trust & auth (WebSocket mode) -Dev-mode connections require trust before the server accepts calls. The client handles this automatically: on first connect it submits the locally-stored auth token, and `ensureTrusted()` resolves once the server accepts. +Dev-mode connections become trusted by pairing. A client that has paired before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it: ```ts const rpc = await connectDevframe() @@ -75,21 +75,31 @@ const rpc = await connectDevframe() const trusted = await rpc.ensureTrusted() if (!trusted) { - console.warn('Auth denied') + console.warn('Not paired yet') } ``` -### Replacing the token +### Pairing with a one-time code -For tokens supplied from a different source (e.g. copy-pasted from CLI output), swap one in without reloading: +A fresh client holds no token. The dev server prints a 6-digit one-time code; pass it to `requestTrustWithCode` to exchange it for a node-issued token. The token is persisted for future reconnections and shared with sibling tabs, which become trusted without re-entering the code: ```ts -const ok = await rpc.requestTrustWithToken('another-token') +const ok = await rpc.requestTrustWithCode('047204') +``` + +The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails. + +### Re-using an existing token + +Authenticate with a token obtained elsewhere (e.g. another surface) without reloading: + +```ts +const ok = await rpc.requestTrustWithToken('a1b2c3…') ``` ### Broadcast-channel sync -`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When an auth page in another tab announces a new token, every open client requests trust with it automatically — no reload required. +`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When another tab completes a pairing — or an auth page announces a token — every open client trusts it automatically, no reload required. ## Calling functions diff --git a/packages/devframe/src/client/rpc-static.ts b/packages/devframe/src/client/rpc-static.ts index ecdff34..a3ce402 100644 --- a/packages/devframe/src/client/rpc-static.ts +++ b/packages/devframe/src/client/rpc-static.ts @@ -16,6 +16,8 @@ export async function createStaticRpcClientMode( isTrusted: true, requestTrust: async () => true, requestTrustWithToken: async () => true, + // Static backends are always trusted, so there's nothing to exchange. + requestTrustWithCode: async () => null, ensureTrusted: async () => true, call: (...args: any): any => staticCaller.call( args[0] as string, diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index 54d8ccb..b0da8e6 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -6,7 +6,7 @@ import { promiseWithResolver } from 'devframe/utils/promise' import { parseUA } from 'ua-parser-modern' export interface CreateWsRpcClientModeOptions { - authToken: string + authToken?: string connectionMeta: ConnectionMeta events: EventEmitter clientRpc: DevframeClientRpcHost @@ -69,13 +69,11 @@ export function createWsRpcClientMode( }, }) - let currentAuthToken = authToken - - async function requestTrustWithToken(token: string) { - currentAuthToken = token + let currentAuthToken: string | undefined = authToken + function describeUA(): string { const info = parseUA(navigator.userAgent) - const ua = [ + return [ info.browser.name, info.browser.version, '|', @@ -83,23 +81,51 @@ export function createWsRpcClientMode( info.os.version, info.device.type, ].filter(i => i).join(' ') + } + + async function requestTrustWithToken(token: string) { + currentAuthToken = token const result = await serverRpc.$call('devframe:anonymous:auth', { authToken: token, - ua, + ua: describeUA(), origin: location.origin, }) isTrusted = result.isTrusted - trustedPromise.resolve(isTrusted) + // Only settle the trust gate on success; on failure the client can still + // pair via `requestTrustWithCode`, so leave `ensureTrusted` waiting. + if (isTrusted) + trustedPromise.resolve(true) events.emit('rpc:is-trusted:updated', isTrusted) return result.isTrusted } + async function requestTrustWithCode(code: string): Promise { + const result = await serverRpc.$call('devframe:auth:exchange', { + code, + ua: describeUA(), + origin: location.origin, + }) + + const token = result?.authToken ?? null + if (token) { + currentAuthToken = token + isTrusted = true + trustedPromise.resolve(true) + events.emit('rpc:is-trusted:updated', true) + } + return token + } + async function requestTrust() { if (isTrusted) return true - return requestTrustWithToken(currentAuthToken) + // Always announce on connect. The standalone (`auth: false`) noop handler + // auto-trusts regardless of token; the host adapter looks the token up and + // returns `false` for an unpaired client (empty/unknown token), which then + // pairs via `requestTrustWithCode`. The trust gate stays open until then. + return requestTrustWithToken(currentAuthToken ?? '') } async function ensureTrusted(timeout = 60_000): Promise { @@ -129,6 +155,7 @@ export function createWsRpcClientMode( }, requestTrust, requestTrustWithToken, + requestTrustWithCode, ensureTrusted, call: (...args: any): any => { return serverRpc.$call( diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 71fde67..b1ea794 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -7,7 +7,6 @@ import { DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' -import { randomToken } from 'devframe/utils/crypto-token' import { createEventEmitter } from 'devframe/utils/events' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' @@ -75,11 +74,18 @@ export interface DevframeRpcClient { requestTrust: () => Promise /** - * Request trust from the server using a specific auth token. + * Request trust from the server using a previously-issued auth token. * Updates the stored token and re-requests trust without reloading the page. */ requestTrustWithToken: (token: string) => Promise + /** + * Pair this client by exchanging a one-time code (shown by the dev server) + * for a node-issued auth token. On success the token is persisted for future + * reconnections and shared with sibling tabs. Resolves `true` when paired. + */ + requestTrustWithCode: (code: string) => Promise + /** * Call a RPC function on the server */ @@ -118,37 +124,45 @@ export interface DevframeRpcClientMode { ensureTrusted: DevframeRpcClient['ensureTrusted'] requestTrust: DevframeRpcClient['requestTrust'] requestTrustWithToken: DevframeRpcClient['requestTrustWithToken'] + /** + * Exchange a one-time code for a node-issued token. Resolves the minted + * token on success (for the caller to persist), or `null` on failure. + */ + requestTrustWithCode: (code: string) => Promise call: DevframeRpcClient['call'] callEvent: DevframeRpcClient['callEvent'] callOptional: DevframeRpcClient['callOptional'] } -function getConnectionAuthTokenFromWindows(userAuthToken?: string): string { +function getStoredAuthToken(userAuthToken?: string): string | undefined { const getters = [ () => userAuthToken, - () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY), + () => localStorage.getItem(CONNECTION_AUTH_TOKEN_KEY) ?? undefined, () => (window as any)?.[CONNECTION_AUTH_TOKEN_KEY], () => (globalThis as any)?.[CONNECTION_AUTH_TOKEN_KEY], () => (parent.window as any)?.[CONNECTION_AUTH_TOKEN_KEY], ] - let value: string | undefined - for (const getter of getters) { try { - value = getter() + const value = getter() if (value) - break + return value } catch {} } - if (!value) - value = randomToken() + // No token yet — the client is unpaired and must exchange a one-time code + // (see `requestTrustWithCode`) to obtain a node-issued token. + return undefined +} - localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, value) - ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = value - return value +function persistAuthToken(token: string): void { + try { + localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) + } + catch {} + ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token } function findConnectionMetaFromWindows(): ConnectionMeta | undefined { @@ -223,7 +237,7 @@ export async function getDevframeRpcClient( const context: DevframeRpcContext = { rpc: undefined!, } - const authToken = getConnectionAuthTokenFromWindows(options.authToken) + const authToken = getStoredAuthToken(options.authToken) const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) async function fetchJsonFromBases(path: string): Promise { @@ -283,6 +297,13 @@ export async function getDevframeRpcClient( wsOptions: options.wsOptions, }) + // Channel name kept for cross-tab interop with the Vite DevTools auth page. + let authChannel: BroadcastChannel | undefined + try { + authChannel = new BroadcastChannel('devframe-auth') + } + catch {} + const rpc: DevframeRpcClient = { events, get isTrusted() { @@ -293,10 +314,22 @@ export async function getDevframeRpcClient( requestTrust: mode.requestTrust, requestTrustWithToken: async (token: string) => { // Update stored token for future reconnections - localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, token) - ;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = token + persistAuthToken(token) return mode.requestTrustWithToken(token) }, + requestTrustWithCode: async (code: string) => { + const token = await mode.requestTrustWithCode(code) + if (!token) + return false + // Persist the node-issued token and share it with sibling tabs so they + // become trusted without re-entering the code. + persistAuthToken(token) + try { + authChannel?.postMessage({ type: 'auth-update', authToken: token }) + } + catch {} + return true + }, call: mode.call, callEvent: mode.callEvent, callOptional: mode.callOptional, @@ -313,17 +346,15 @@ export async function getDevframeRpcClient( context.rpc = rpc void mode.requestTrust() - // Listen for auth updates from other tabs (e.g., auth URL page). - // Channel name kept for cross-tab interop with the Vite DevTools auth page. - try { - const bc = new BroadcastChannel('devframe-auth') - bc.onmessage = (event) => { + // Listen for auth updates from other tabs (e.g., the auth page, or another + // tab that just completed a code exchange). + if (authChannel) { + authChannel.onmessage = (event) => { if (event.data?.type === 'auth-update' && event.data.authToken) { rpc.requestTrustWithToken(event.data.authToken) } } } - catch {} return rpc } diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 7157dce..6d4e62d 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -1,98 +1,93 @@ import type { DevframeNodeRpcSession } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' -import { randomDigits, timingSafeEqual } from 'devframe/utils/crypto-token' +import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' /** Number of decimal digits in a human-typed one-time pairing code. */ -const TEMP_AUTH_TOKEN_LENGTH = 6 +const TEMP_AUTH_CODE_LENGTH = 6 /** * How long a pairing code stays valid after it is (re)generated. A 6-digit * code only has ~20 bits of entropy, so a short lifetime plus the attempt cap * below are what keep it brute-force resistant. */ -const TEMP_AUTH_TOKEN_TTL = 5 * 60_000 +const TEMP_AUTH_CODE_TTL = 5 * 60_000 /** Failed attempts allowed against a single code before it is rotated. */ const TEMP_AUTH_MAX_ATTEMPTS = 5 -export interface PendingAuthRequest { - clientAuthToken: string - session: DevframeNodeRpcSession - ua: string - origin: string - resolve: (result: { isTrusted: boolean }) => void - abortController: AbortController - timeout: ReturnType -} - -let pendingAuth: PendingAuthRequest | null = null -let tempAuthToken: string = generateTempId() -let tempAuthExpiresAt: number = Date.now() + TEMP_AUTH_TOKEN_TTL +let tempAuthCode: string = generateTempCode() +let tempAuthCodeExpiresAt: number = Date.now() + TEMP_AUTH_CODE_TTL let tempAuthFailedAttempts = 0 -function generateTempId(): string { - return randomDigits(TEMP_AUTH_TOKEN_LENGTH) +function generateTempCode(): string { + return randomDigits(TEMP_AUTH_CODE_LENGTH) } +/** + * The current one-time pairing code. Display this to the user (e.g. in the + * dev-server terminal) so they can type it into the browser to pair. + */ export function getTempAuthToken(): string { - return tempAuthToken + return tempAuthCode } /** * Rotate the pairing code, resetting its expiry window and failed-attempt - * counter. Adapters call this when a new pairing flow begins so the displayed - * code is freshly valid. + * counter. Call this when a new pairing flow begins (e.g. when an untrusted + * client asks to pair) so the displayed code is freshly valid for its full TTL. */ export function refreshTempAuthToken(): string { - tempAuthToken = generateTempId() - tempAuthExpiresAt = Date.now() + TEMP_AUTH_TOKEN_TTL + tempAuthCode = generateTempCode() + tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL tempAuthFailedAttempts = 0 - return tempAuthToken -} - -export function getPendingAuth(): PendingAuthRequest | null { - return pendingAuth -} - -export function setPendingAuth(request: PendingAuthRequest | null): void { - pendingAuth = request + return tempAuthCode } /** - * Abort and clean up any existing pending auth request. + * Re-authenticate a connection that presents a previously-issued bearer token. + * Returns `true` and marks the session trusted when the token is known. + * + * Used by the `devframe:anonymous:auth` handler so a client that already + * completed pairing (token persisted in the browser) is trusted on reconnect + * without typing the code again. */ -export function abortPendingAuth(): void { - if (pendingAuth) { - pendingAuth.abortController.abort() - clearTimeout(pendingAuth.timeout) - pendingAuth = null - } +export function verifyAuthToken( + token: string, + session: DevframeNodeRpcSession, + storage: SharedState, +): boolean { + if (!token || !storage.value().trusted[token]) + return false + + session.meta.clientAuthToken = token + session.meta.isTrusted = true + return true } /** - * Consume the temp auth code: verify it matches an active, unexpired pairing - * code, trust the pending client, and clean up. Returns the client's authToken - * on success, `null` otherwise. + * Exchange a one-time pairing code for a fresh, node-issued bearer token. + * + * On success this mints a high-entropy token, records it in the trusted store, + * marks the calling session trusted, rotates the code, and returns the token + * for the client to persist. Returns `null` on any failure. * * Because the code is short and human-typed, verification is hardened against - * brute force: it requires a live pending request, enforces a time-to-live, - * compares in constant time, and rotates the code after - * {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an attacker cannot keep - * guessing against the same code. + * brute force: it enforces a time-to-live, compares in constant time, and + * rotates the code after {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an + * attacker cannot keep guessing against the same code. */ -export function consumeTempAuthToken( - id: string, +export function exchangeTempAuthCode( + code: string, + session: DevframeNodeRpcSession, + info: { ua: string, origin: string }, storage: SharedState, ): string | null { - if (!pendingAuth) - return null - // Expired code: rotate so a stale code can never be redeemed. - if (Date.now() > tempAuthExpiresAt) { + if (Date.now() > tempAuthCodeExpiresAt) { refreshTempAuthToken() return null } - if (!timingSafeEqual(id, tempAuthToken)) { + if (!timingSafeEqual(code, tempAuthCode)) { tempAuthFailedAttempts += 1 // Too many wrong guesses — invalidate this code entirely. if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS) @@ -100,28 +95,21 @@ export function consumeTempAuthToken( return null } - const { clientAuthToken, session, ua, origin, resolve } = pendingAuth - - // Trust the pending client + // Code is valid — mint a fresh, node-issued bearer token for this client. + const authToken = randomToken() storage.mutate((state) => { - state.trusted[clientAuthToken] = { - authToken: clientAuthToken, - ua, - origin, + state.trusted[authToken] = { + authToken, + ua: info.ua, + origin: info.origin, timestamp: Date.now(), } }) - session.meta.clientAuthToken = clientAuthToken + session.meta.clientAuthToken = authToken session.meta.isTrusted = true - // Resolve the pending auth RPC call - resolve({ isTrusted: true }) - - // Abort terminal prompt and clean up - abortPendingAuth() - - // Generate a new temp ID for next use + // Rotate the code so it can never be replayed. refreshTempAuthToken() - return clientAuthToken + return authToken } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 9403d08..18de054 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -2,6 +2,14 @@ * To be extended */ export interface DevframeRpcClientFunctions { + /** + * Server→client notification that this connection's auth token has been + * revoked. The client drops to untrusted on receipt. Broadcast by + * `revokeActiveConnectionsForToken`. + * + * @internal + */ + 'devframe:auth:revoked': () => Promise /** * Streaming chunk pushed from server to subscribed clients. Wired by * `RpcStreamingHost`; do not register manually. @@ -29,6 +37,23 @@ export interface DevframeRpcClientFunctions { * To be extended */ export interface DevframeRpcServerFunctions { + /** + * Authenticate a connection with a previously-issued bearer token; resolves + * whether the connection is now trusted. The interactive handler is provided + * by the host adapter (e.g. Vite DevTools); the standalone server registers + * an auto-trust noop when `auth: false`. + * + * @internal + */ + 'devframe:anonymous:auth': (params: { authToken: string, ua: string, origin: string }) => Promise<{ isTrusted: boolean }> + /** + * Exchange a one-time pairing code (shown by the dev server) for a fresh, + * node-issued bearer token, returning the token on success or `null`. The + * handler is provided by the host adapter on top of `exchangeTempAuthCode`. + * + * @internal + */ + 'devframe:auth:exchange': (params: { code: string, ua: string, origin: string }) => Promise<{ authToken: string | null }> /** * Subscribe a client to a shared-state key. Wired by * `RpcSharedStateHost`; do not register manually. diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 651268c..0d15daf 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -9,6 +9,7 @@ export interface DevframeRpcClient { ensureTrusted: (_?: number) => Promise; requestTrust: () => Promise; requestTrustWithToken: (_: string) => Promise; + requestTrustWithCode: (_: string) => Promise; call: DevframeRpcClientCall; callEvent: DevframeRpcClientCallEvent; callOptional: DevframeRpcClientCallOptional; @@ -22,6 +23,7 @@ export interface DevframeRpcClientMode { ensureTrusted: DevframeRpcClient['ensureTrusted']; requestTrust: DevframeRpcClient['requestTrust']; requestTrustWithToken: DevframeRpcClient['requestTrustWithToken']; + requestTrustWithCode: (_: string) => Promise; call: DevframeRpcClient['call']; callEvent: DevframeRpcClient['callEvent']; callOptional: DevframeRpcClient['callOptional']; diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts index 86bb867..ec25a8f 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -1,27 +1,14 @@ /** * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ -// #region Interfaces -export interface PendingAuthRequest { - clientAuthToken: string; - session: DevframeNodeRpcSession; +// #region Functions +export declare function exchangeTempAuthCode(_: string, _: DevframeNodeRpcSession, _: { ua: string; origin: string; - resolve: (_: { - isTrusted: boolean; - }) => void; - abortController: AbortController; - timeout: ReturnType; -} -// #endregion - -// #region Functions -export declare function abortPendingAuth(): void; -export declare function consumeTempAuthToken(_: string, _: SharedState): string | null; -export declare function getPendingAuth(): PendingAuthRequest | null; +}, _: SharedState): string | null; export declare function getTempAuthToken(): string; export declare function refreshTempAuthToken(): string; export declare function revokeActiveConnectionsForToken(_: DevframeNodeContext, _: string): Promise; export declare function revokeAuthToken(_: DevframeNodeContext, _: SharedState, _: string): Promise; -export declare function setPendingAuth(_: PendingAuthRequest | null): void; +export declare function verifyAuthToken(_: string, _: DevframeNodeRpcSession, _: SharedState): boolean; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js index fca4b00..1e08d3a 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js @@ -2,12 +2,10 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions -export function abortPendingAuth() {} -export function consumeTempAuthToken(_, _) {} -export function getPendingAuth() {} +export function exchangeTempAuthCode(_, _, _, _) {} export function getTempAuthToken() {} export function refreshTempAuthToken() {} -export function setPendingAuth(_) {} +export function verifyAuthToken(_, _, _) {} // #endregion // #region Other From 5ead924a4bf98bdf419f832285f3af96ab835452 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 03:34:30 +0000 Subject: [PATCH 3/6] docs(auth): security guide + preserve URL-query token auth Persist a token supplied to `connectDevframe({ authToken })` so a host that bootstraps trust from its own page-URL query survives reconnects, matching the previous always-persist behaviour. The transport still forwards the token via the `?devframe_auth_token=` WS query param; add a test locking that in. Add a "Security" guide page and a SKILL section covering the trust model and secure-by-default practices for tools built on devframe: keep `auth` on, restrict `auth: false` to single-user localhost, treat tokens as secrets, serve over wss beyond loopback, authorize handlers, and origin-lock remote docks. --- docs/.vitepress/config.ts | 1 + docs/guide/security.md | 41 +++++++++++++++++++ packages/devframe/src/client/rpc.ts | 6 +++ .../devframe/src/rpc/transports/ws.test.ts | 29 +++++++++++++ skills/devframe/SKILL.md | 14 +++++++ 5 files changed, 91 insertions(+) create mode 100644 docs/guide/security.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 034b98b..cc9f7df 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -27,6 +27,7 @@ function guideItems(prefix: string): DefaultTheme.NavItemWithLink[] { { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, { text: 'Client', link: `${prefix}/guide/client` }, + { text: 'Security', link: `${prefix}/guide/security` }, { text: 'Standalone CLI', link: `${prefix}/guide/standalone-cli` }, { text: 'Hub (multi-tool)', link: `${prefix}/guide/hub` }, { text: 'Agent-Native (experimental)', link: `${prefix}/guide/agent-native` }, diff --git a/docs/guide/security.md b/docs/guide/security.md new file mode 100644 index 0000000..f1c9504 --- /dev/null +++ b/docs/guide/security.md @@ -0,0 +1,41 @@ +--- +outline: deep +--- + +# Security + +Devframe tools are secure by default: connections bind to `localhost`, and dev-mode RPC requires a trust handshake before a browser is accepted. This page covers the trust model and the practices that keep a tool safe as it moves beyond a single developer's machine. + +## Trust model + +An RPC handler runs with the full privileges of the process hosting it — filesystem, child processes, network. A trusted connection can call any registered function, so the boundary that matters is *who is allowed to connect*. + +Two postures cover that boundary: + +- **Authenticated (default).** `auth` defaults to `true`. The browser pairs with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and pairing UI. +- **Unauthenticated opt-out.** Setting `auth: false` starts the server with an auto-trust handshake. It exists for single-user tools talking to their own `localhost`, where a round-trip would only add friction. + +> [!WARNING] +> `auth: false` trusts every connection that can reach the port. Only use it when the surface is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment. + +## Pairing and tokens + +Pairing exchanges a short code for a long token: + +1. The dev server shows a 6-digit one-time code in the developer's terminal. +2. The developer types it into the browser, which calls `requestTrustWithCode(code)`. +3. The server verifies the code, mints a high-entropy bearer token, records it as trusted, and returns it. +4. The browser persists the token and presents it on reconnect; sibling tabs receive it over the `devframe-auth` channel. + +The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts — which is what keeps a short code brute-force resistant. Show it only in a trusted channel (the terminal), never over the network. + +The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the surface is reachable beyond loopback. Revoke a token with `revokeAuthToken(context, storage, token)`; affected clients drop to untrusted via the `devframe:auth:revoked` event. + +## Practices for tools built on devframe + +- **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. +- **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. +- **Treat tokens as secrets.** Never log the bearer token or the pairing code, and never bake either into build output. +- **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. +- **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin. +- **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`. diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index b1ea794..652b0bd 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -238,6 +238,12 @@ export async function getDevframeRpcClient( rpc: undefined!, } const authToken = getStoredAuthToken(options.authToken) + // Persist a resolved token so one supplied out-of-band — e.g. a host that + // bootstraps trust by passing `authToken` (read from its own page URL query) + // — survives reconnects. The token is still sent to the server via the WS + // URL query param (`?devframe_auth_token=`) by the transport. + if (authToken) + persistAuthToken(authToken) const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase(context) async function fetchJsonFromBases(path: string): Promise { diff --git a/packages/devframe/src/rpc/transports/ws.test.ts b/packages/devframe/src/rpc/transports/ws.test.ts index d84c52e..037c3d5 100644 --- a/packages/devframe/src/rpc/transports/ws.test.ts +++ b/packages/devframe/src/rpc/transports/ws.test.ts @@ -8,6 +8,35 @@ import { attachWsRpcTransport } from './ws-server' vi.stubGlobal('WebSocket', WebSocket) +describe('ws auth token in URL', () => { + it('appends the auth token as a URL query param, url-encoded, and omits it when absent', () => { + const urls: string[] = [] + class CapturingWS { + constructor(public url: string) { + urls.push(url) + } + + addEventListener() {} + removeEventListener() {} + send() {} + readyState = 0 + } + + try { + vi.stubGlobal('WebSocket', CapturingWS) + createWsRpcChannel({ url: 'ws://127.0.0.1:1234' }) + createWsRpcChannel({ url: 'ws://127.0.0.1:1234', authToken: 'a b/c+d' }) + + expect(urls[0]).toBe('ws://127.0.0.1:1234') + expect(urls[1]).toBe('ws://127.0.0.1:1234?devframe_auth_token=a%20b%2Fc%2Bd') + } + finally { + // Restore the real ws implementation for the connection tests below. + vi.stubGlobal('WebSocket', WebSocket) + } + }) +}) + describe('devframe rpc', () => { it('should work w/ ws transport', async () => { const PORT = 3333 diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index dd2c71c..5dd92cd 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -478,6 +478,19 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers` RPC recipe — it wires the two utilities into named RPC functions ready to register. +## Security (secure by default) + +RPC handlers run with the full privileges of the host process, so the boundary that matters is who may connect. Keep that boundary tight: + +- **`auth` defaults to `true`** — dev-mode connections must pair before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and pairing UI. +- **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never pair it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. +- **Pairing** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. +- **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. +- **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. +- **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. + +See [Security](https://devfra.me/security) for the full reference. + ## Testing - Unit-test host classes with fake contexts. @@ -497,6 +510,7 @@ Devframe-level pages (one-tool, portable surface): - [Structured Diagnostics](https://devfra.me/diagnostics) — coded errors via `ctx.diagnostics`, register custom codes - [Utilities](https://devfra.me/utilities) — bundled `devframe/utils/*` helpers (colors, hash, launchEditor, structured-clone, …) - [Client](https://devfra.me/client) — auth handshake, modes, discovery +- [Security](https://devfra.me/security) — trust model, pairing, secure-by-default practices - [Agent-Native](https://devfra.me/agent-native) — agent field, tools/resources, MCP + Claude Desktop Host-specific extras (when mounting into Vite DevTools — other hosts have their own equivalents): From e8871f9dd7500a570c11505494eb6c9ca22f1454 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 04:20:26 +0000 Subject: [PATCH 4/6] feat(auth): magic-link pairing via one-time code in the URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Let a host print a pairing link (`/?devframe_auth=`) so opening it pairs the browser automatically. `connectDevframe` reads the code, exchanges it for a token, and strips the parameter from the URL before anything else; the resulting bearer token is persisted, never written back to the URL. Only the short-lived, single-use code rides the URL — never the bearer. The behaviour is configurable via the `autoPairParam` client option (defaults to `devframe_auth`, set `false` to disable). Add the shared `DEVFRAME_AUTH_URL_PARAM` constant and a node `buildAuthPairingUrl` helper for hosts to construct the link from the current code (devframe stays headless, so the host prints its own banner). Document the flow and its trusted-channel caveat in the security guide and skill. --- docs/guide/client.md | 2 + docs/guide/security.md | 10 ++++ .../src/client/__tests__/auth-url.test.ts | 47 +++++++++++++++++++ packages/devframe/src/client/auth-url.ts | 33 +++++++++++++ packages/devframe/src/client/rpc.ts | 22 +++++++++ packages/devframe/src/constants.ts | 9 ++++ packages/devframe/src/node/auth/state.ts | 13 +++++ skills/devframe/SKILL.md | 1 + .../tsnapi/devframe/client.snapshot.d.ts | 1 + .../tsnapi/devframe/constants.snapshot.d.ts | 1 + .../tsnapi/devframe/constants.snapshot.js | 1 + .../tsnapi/devframe/node/auth.snapshot.d.ts | 1 + .../tsnapi/devframe/node/auth.snapshot.js | 1 + 13 files changed, 142 insertions(+) create mode 100644 packages/devframe/src/client/__tests__/auth-url.test.ts create mode 100644 packages/devframe/src/client/auth-url.ts diff --git a/docs/guide/client.md b/docs/guide/client.md index b097fea..bc28192 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -89,6 +89,8 @@ const ok = await rpc.requestTrustWithCode('047204') The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails. +To pair without typing, a host can print a link embedding the code (`buildAuthPairingUrl(origin)`); `connectDevframe` reads the `devframe_auth` query parameter, exchanges it, and strips it from the URL. Disable or rename it with the `autoPairParam` option. + ### Re-using an existing token Authenticate with a token obtained elsewhere (e.g. another surface) without reloading: diff --git a/docs/guide/security.md b/docs/guide/security.md index f1c9504..ef94103 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -31,6 +31,16 @@ The 6-digit code is single-use, expires after five minutes, is compared in const The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the surface is reachable beyond loopback. Revoke a token with `revokeAuthToken(context, storage, token)`; affected clients drop to untrusted via the `devframe:auth:revoked` event. +### Magic-link pairing + +To skip typing, a host can print a link that embeds the code and open the browser straight into a paired session. Build it from the current code with `buildAuthPairingUrl(origin)` (devframe stays headless, so the host prints its own banner): + +``` +Devtools ready — pair this browser: http://localhost:3000/?devframe_auth=123456 +``` + +`connectDevframe` reads the `devframe_auth` parameter, exchanges it, and removes it from the URL before anything else (configurable via the `autoPairParam` client option). Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. + ## Practices for tools built on devframe - **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. diff --git a/packages/devframe/src/client/__tests__/auth-url.test.ts b/packages/devframe/src/client/__tests__/auth-url.test.ts new file mode 100644 index 0000000..7bf17e4 --- /dev/null +++ b/packages/devframe/src/client/__tests__/auth-url.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from '../auth-url' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('auth-url helpers', () => { + it('reads the pairing code from the page URL query string', () => { + vi.stubGlobal('location', { search: '?devframe_auth=123456&x=1', href: 'http://localhost:3000/?devframe_auth=123456&x=1' }) + expect(readAuthCodeFromUrl('devframe_auth')).toBe('123456') + }) + + it('returns undefined when the param is absent or empty', () => { + vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + expect(readAuthCodeFromUrl('devframe_auth')).toBeUndefined() + }) + + it('is safe when location is unavailable', () => { + vi.stubGlobal('location', undefined) + expect(readAuthCodeFromUrl('devframe_auth')).toBeUndefined() + expect(() => clearAuthCodeFromUrl('devframe_auth')).not.toThrow() + }) + + it('strips the pairing code from the URL via history.replaceState, keeping other params', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?devframe_auth=123456&x=1', href: 'http://localhost:3000/?devframe_auth=123456&x=1' }) + vi.stubGlobal('history', { state: { a: 1 }, replaceState }) + + clearAuthCodeFromUrl('devframe_auth') + + expect(replaceState).toHaveBeenCalledTimes(1) + const [state, , href] = replaceState.mock.calls[0] + expect(state).toEqual({ a: 1 }) + expect(href).toBe('http://localhost:3000/?x=1') + }) + + it('does nothing when the param is not present in the URL', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { href: 'http://localhost:3000/?x=1' }) + vi.stubGlobal('history', { state: null, replaceState }) + + clearAuthCodeFromUrl('devframe_auth') + + expect(replaceState).not.toHaveBeenCalled() + }) +}) diff --git a/packages/devframe/src/client/auth-url.ts b/packages/devframe/src/client/auth-url.ts new file mode 100644 index 0000000..351c7d8 --- /dev/null +++ b/packages/devframe/src/client/auth-url.ts @@ -0,0 +1,33 @@ +// Browser-only helpers for "magic link" pairing: a host can print a URL that +// carries a one-time pairing code, and the client reads the code, exchanges it +// for a token, and removes it from the address bar. Only the short-lived, +// single-use code ever rides the URL — never the resulting bearer token. + +/** + * Read a one-time pairing code from the current page URL's query string. + * Returns `undefined` when the parameter is absent or unavailable. + */ +export function readAuthCodeFromUrl(param: string): string | undefined { + try { + return new URLSearchParams(globalThis.location?.search).get(param) || undefined + } + catch { + return undefined + } +} + +/** + * Remove the pairing-code parameter from the address bar (and the current + * history entry) so the single-use code isn't left in the URL, browser + * history, or a `Referer` header. + */ +export function clearAuthCodeFromUrl(param: string): void { + try { + const url = new URL(globalThis.location!.href) + if (!url.searchParams.has(param)) + return + url.searchParams.delete(param) + globalThis.history?.replaceState(globalThis.history.state, '', url.href) + } + catch {} +} diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 652b0bd..4899cfb 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -4,10 +4,12 @@ import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types' import type { RpcStreamingClientHost } from './rpc-streaming' import { + DEVFRAME_AUTH_URL_PARAM, DEVFRAME_CONNECTION_META_FILENAME, } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' +import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from './auth-url' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' @@ -36,6 +38,13 @@ export interface DevframeRpcClientOptions { * The auth token to use for the client */ authToken?: string + /** + * Query-param name on the page URL carrying a one-time pairing code for + * "magic link" auth (e.g. a link the dev server prints). When present, the + * client exchanges the code for a token and removes the parameter from the + * URL. Set `false` to disable. Default: `'devframe_auth'`. + */ + autoPairParam?: string | false wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial @@ -352,6 +361,19 @@ export async function getDevframeRpcClient( context.rpc = rpc void mode.requestTrust() + // Magic-link pairing: if the page URL carries a one-time code, exchange it + // and strip it from the URL. The code is single-use and short-lived; the + // resulting bearer token is persisted (never written back to the URL). + const autoPairParam = options.autoPairParam ?? DEVFRAME_AUTH_URL_PARAM + if (autoPairParam) { + const code = readAuthCodeFromUrl(autoPairParam) + if (code) { + clearAuthCodeFromUrl(autoPairParam) + if (!rpc.isTrusted) + void rpc.requestTrustWithCode(code) + } + } + // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). if (authChannel) { diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 106fd8e..f46e56e 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -15,3 +15,12 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' * `@vitejs/devtools-kit`) injected into remote-UI iframe dock URLs. */ export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection' + +/** + * Page-URL query parameter carrying a one-time pairing code for "magic link" + * auth. A host can print a link like `/?devframe_auth=`; the + * client reads the code, exchanges it for a token, and strips the parameter + * from the URL. See `buildAuthPairingUrl` (node) and `connectDevframe`'s + * `autoPairParam` option (client). + */ +export const DEVFRAME_AUTH_URL_PARAM = 'devframe_auth' diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 6d4e62d..6773d7b 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -1,6 +1,7 @@ import type { DevframeNodeRpcSession } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' +import { DEVFRAME_AUTH_URL_PARAM } from 'devframe/constants' import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' /** Number of decimal digits in a human-typed one-time pairing code. */ @@ -42,6 +43,18 @@ export function refreshTempAuthToken(): string { return tempAuthCode } +/** + * Build a "magic link" pairing URL that embeds a one-time code as a query + * parameter. Opening it lets the client pair without typing — print it on + * startup (devframe stays headless, so the host prints its own banner). + * Defaults to the current code; the link is subject to the same TTL. + */ +export function buildAuthPairingUrl(baseUrl: string, code: string = tempAuthCode): string { + const url = new URL(baseUrl) + url.searchParams.set(DEVFRAME_AUTH_URL_PARAM, code) + return url.href +} + /** * Re-authenticate a connection that presents a previously-issued bearer token. * Returns `true` and marks the session trusted when the token is known. diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 5dd92cd..7329f13 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -485,6 +485,7 @@ RPC handlers run with the full privileges of the host process, so the boundary t - **`auth` defaults to `true`** — dev-mode connections must pair before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and pairing UI. - **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never pair it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. - **Pairing** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. +- **Magic-link (optional):** print `buildAuthPairingUrl(origin)` — `/?devframe_auth=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL (`autoPairParam` to disable/rename). Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. - **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 0d15daf..82a83f2 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -32,6 +32,7 @@ export interface DevframeRpcClientOptions { connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string; + autoPairParam?: string | false; wsOptions?: Partial; rpcOptions?: Partial>; cacheOptions?: boolean | Partial; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index de0b4fc..e14b028 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -2,6 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/constants` */ // #region Variables +export declare const DEVFRAME_AUTH_URL_PARAM: string; export declare const DEVFRAME_CONNECTION_META_FILENAME: string; export declare const DEVFRAME_DIRNAME: string; export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index 2146d20..a5972eb 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -2,6 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/constants` */ // #region Variables +export var DEVFRAME_AUTH_URL_PARAM /* const */ export var DEVFRAME_CONNECTION_META_FILENAME /* const */ export var DEVFRAME_DIRNAME /* const */ export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */ diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts index ec25a8f..d0b2ade 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -2,6 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions +export declare function buildAuthPairingUrl(_: string, _?: string): string; export declare function exchangeTempAuthCode(_: string, _: DevframeNodeRpcSession, _: { ua: string; origin: string; diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js index 1e08d3a..a64e330 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js @@ -2,6 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions +export function buildAuthPairingUrl(_, _) {} export function exchangeTempAuthCode(_, _, _, _) {} export function getTempAuthToken() {} export function refreshTempAuthToken() {} From cff5b16ea654958ea7557ece81902b46d3b59d4d Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 04:42:23 +0000 Subject: [PATCH 5/6] refactor(auth)!: rename OTP url param and expose client pairing utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the magic-link query parameter from `devframe_auth` to `devframe_otp` (and the constant to `DEVFRAME_OTP_URL_PARAM`) to distinguish it from the bearer-token param `devframe_auth_token`. Expose reusable client utilities from `devframe/client` so higher-level integrations (e.g. Vite DevTools) can consume the URL OTP themselves: `readOtpFromUrl`, `consumeOtpFromUrl` (read + strip), and `pairWithUrlOtp(rpc)` (consume + exchange). `connectDevframe`'s built-in handling now reuses `pairWithUrlOtp` and is opt-out via the renamed `otpParam: false` option. Rename the node helper to `buildOtpPairingUrl` for consistency. BREAKING CHANGE: `DEVFRAME_AUTH_URL_PARAM` → `DEVFRAME_OTP_URL_PARAM` (value `devframe_otp`), `buildAuthPairingUrl` → `buildOtpPairingUrl`, and the `connectDevframe` option `autoPairParam` → `otpParam`. All unreleased. --- docs/guide/client.md | 2 +- docs/guide/security.md | 8 +- .../src/client/__tests__/auth-url.test.ts | 47 ----------- .../devframe/src/client/__tests__/otp.test.ts | 83 +++++++++++++++++++ packages/devframe/src/client/auth-url.ts | 33 -------- packages/devframe/src/client/index.ts | 1 + packages/devframe/src/client/otp.ts | 66 +++++++++++++++ packages/devframe/src/client/rpc.ts | 25 +++--- packages/devframe/src/constants.ts | 10 +-- packages/devframe/src/node/auth/state.ts | 8 +- skills/devframe/SKILL.md | 2 +- .../tsnapi/devframe/client.snapshot.d.ts | 7 +- .../tsnapi/devframe/client.snapshot.js | 3 + .../tsnapi/devframe/constants.snapshot.d.ts | 2 +- .../tsnapi/devframe/constants.snapshot.js | 2 +- .../tsnapi/devframe/node/auth.snapshot.d.ts | 2 +- .../tsnapi/devframe/node/auth.snapshot.js | 2 +- 17 files changed, 190 insertions(+), 113 deletions(-) delete mode 100644 packages/devframe/src/client/__tests__/auth-url.test.ts create mode 100644 packages/devframe/src/client/__tests__/otp.test.ts delete mode 100644 packages/devframe/src/client/auth-url.ts create mode 100644 packages/devframe/src/client/otp.ts diff --git a/docs/guide/client.md b/docs/guide/client.md index bc28192..1668001 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -89,7 +89,7 @@ const ok = await rpc.requestTrustWithCode('047204') The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails. -To pair without typing, a host can print a link embedding the code (`buildAuthPairingUrl(origin)`); `connectDevframe` reads the `devframe_auth` query parameter, exchanges it, and strips it from the URL. Disable or rename it with the `autoPairParam` option. +To pair without typing, a host can print a link embedding the code (`buildOtpPairingUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive pairing yourself with the exposed `pairWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities. ### Re-using an existing token diff --git a/docs/guide/security.md b/docs/guide/security.md index ef94103..a8911d0 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -33,13 +33,15 @@ The bearer token is a secret. It travels to the server on the WebSocket URL (`?d ### Magic-link pairing -To skip typing, a host can print a link that embeds the code and open the browser straight into a paired session. Build it from the current code with `buildAuthPairingUrl(origin)` (devframe stays headless, so the host prints its own banner): +To skip typing, a host can print a link that embeds the code and open the browser straight into a paired session. Build it from the current code with `buildOtpPairingUrl(origin)` (devframe stays headless, so the host prints its own banner): ``` -Devtools ready — pair this browser: http://localhost:3000/?devframe_auth=123456 +Devtools ready — pair this browser: http://localhost:3000/?devframe_otp=123456 ``` -`connectDevframe` reads the `devframe_auth` parameter, exchanges it, and removes it from the URL before anything else (configurable via the `autoPairParam` client option). Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. +`connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. + +Higher-level integrations can drive their own pairing UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `pairWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`. ## Practices for tools built on devframe diff --git a/packages/devframe/src/client/__tests__/auth-url.test.ts b/packages/devframe/src/client/__tests__/auth-url.test.ts deleted file mode 100644 index 7bf17e4..0000000 --- a/packages/devframe/src/client/__tests__/auth-url.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from '../auth-url' - -afterEach(() => { - vi.unstubAllGlobals() -}) - -describe('auth-url helpers', () => { - it('reads the pairing code from the page URL query string', () => { - vi.stubGlobal('location', { search: '?devframe_auth=123456&x=1', href: 'http://localhost:3000/?devframe_auth=123456&x=1' }) - expect(readAuthCodeFromUrl('devframe_auth')).toBe('123456') - }) - - it('returns undefined when the param is absent or empty', () => { - vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) - expect(readAuthCodeFromUrl('devframe_auth')).toBeUndefined() - }) - - it('is safe when location is unavailable', () => { - vi.stubGlobal('location', undefined) - expect(readAuthCodeFromUrl('devframe_auth')).toBeUndefined() - expect(() => clearAuthCodeFromUrl('devframe_auth')).not.toThrow() - }) - - it('strips the pairing code from the URL via history.replaceState, keeping other params', () => { - const replaceState = vi.fn() - vi.stubGlobal('location', { search: '?devframe_auth=123456&x=1', href: 'http://localhost:3000/?devframe_auth=123456&x=1' }) - vi.stubGlobal('history', { state: { a: 1 }, replaceState }) - - clearAuthCodeFromUrl('devframe_auth') - - expect(replaceState).toHaveBeenCalledTimes(1) - const [state, , href] = replaceState.mock.calls[0] - expect(state).toEqual({ a: 1 }) - expect(href).toBe('http://localhost:3000/?x=1') - }) - - it('does nothing when the param is not present in the URL', () => { - const replaceState = vi.fn() - vi.stubGlobal('location', { href: 'http://localhost:3000/?x=1' }) - vi.stubGlobal('history', { state: null, replaceState }) - - clearAuthCodeFromUrl('devframe_auth') - - expect(replaceState).not.toHaveBeenCalled() - }) -}) diff --git a/packages/devframe/src/client/__tests__/otp.test.ts b/packages/devframe/src/client/__tests__/otp.test.ts new file mode 100644 index 0000000..4169012 --- /dev/null +++ b/packages/devframe/src/client/__tests__/otp.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { consumeOtpFromUrl, pairWithUrlOtp, readOtpFromUrl } from '../otp' + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('otp url helpers', () => { + it('reads the OTP from the page URL query string (default param)', () => { + vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' }) + expect(readOtpFromUrl()).toBe('123456') + }) + + it('supports a custom param name', () => { + vi.stubGlobal('location', { search: '?code=999', href: 'http://localhost:3000/?code=999' }) + expect(readOtpFromUrl('code')).toBe('999') + }) + + it('returns undefined when the param is absent and is safe without location', () => { + vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + expect(readOtpFromUrl()).toBeUndefined() + vi.stubGlobal('location', undefined) + expect(readOtpFromUrl()).toBeUndefined() + expect(() => consumeOtpFromUrl()).not.toThrow() + }) + + it('consume reads then strips the OTP via history.replaceState, keeping other params', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?devframe_otp=123456&x=1', href: 'http://localhost:3000/?devframe_otp=123456&x=1' }) + vi.stubGlobal('history', { state: { a: 1 }, replaceState }) + + expect(consumeOtpFromUrl()).toBe('123456') + expect(replaceState).toHaveBeenCalledTimes(1) + const [state, , href] = replaceState.mock.calls[0] + expect(state).toEqual({ a: 1 }) + expect(href).toBe('http://localhost:3000/?x=1') + }) + + it('does not touch the URL when no OTP is present', () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?x=1', href: 'http://localhost:3000/?x=1' }) + vi.stubGlobal('history', { state: null, replaceState }) + + expect(consumeOtpFromUrl()).toBeUndefined() + expect(replaceState).not.toHaveBeenCalled() + }) +}) + +describe('pairWithUrlOtp', () => { + it('exchanges the OTP via the client and resolves true on success', async () => { + vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + vi.stubGlobal('history', { state: null, replaceState: vi.fn() }) + const requestTrustWithCode = vi.fn().mockResolvedValue(true) + + const ok = await pairWithUrlOtp({ isTrusted: false, requestTrustWithCode }) + + expect(requestTrustWithCode).toHaveBeenCalledWith('123456') + expect(ok).toBe(true) + }) + + it('returns false (and does not exchange) when no OTP is present', async () => { + vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' }) + const requestTrustWithCode = vi.fn() + + const ok = await pairWithUrlOtp({ isTrusted: false, requestTrustWithCode }) + + expect(requestTrustWithCode).not.toHaveBeenCalled() + expect(ok).toBe(false) + }) + + it('skips the exchange but still consumes the OTP when already trusted', async () => { + const replaceState = vi.fn() + vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) + vi.stubGlobal('history', { state: null, replaceState }) + const requestTrustWithCode = vi.fn() + + const ok = await pairWithUrlOtp({ isTrusted: true, requestTrustWithCode }) + + expect(ok).toBe(true) + expect(requestTrustWithCode).not.toHaveBeenCalled() + expect(replaceState).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/devframe/src/client/auth-url.ts b/packages/devframe/src/client/auth-url.ts deleted file mode 100644 index 351c7d8..0000000 --- a/packages/devframe/src/client/auth-url.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Browser-only helpers for "magic link" pairing: a host can print a URL that -// carries a one-time pairing code, and the client reads the code, exchanges it -// for a token, and removes it from the address bar. Only the short-lived, -// single-use code ever rides the URL — never the resulting bearer token. - -/** - * Read a one-time pairing code from the current page URL's query string. - * Returns `undefined` when the parameter is absent or unavailable. - */ -export function readAuthCodeFromUrl(param: string): string | undefined { - try { - return new URLSearchParams(globalThis.location?.search).get(param) || undefined - } - catch { - return undefined - } -} - -/** - * Remove the pairing-code parameter from the address bar (and the current - * history entry) so the single-use code isn't left in the URL, browser - * history, or a `Referer` header. - */ -export function clearAuthCodeFromUrl(param: string): void { - try { - const url = new URL(globalThis.location!.href) - if (!url.searchParams.has(param)) - return - url.searchParams.delete(param) - globalThis.history?.replaceState(globalThis.history.state, '', url.href) - } - catch {} -} diff --git a/packages/devframe/src/client/index.ts b/packages/devframe/src/client/index.ts index d94b4f8..8072b1b 100644 --- a/packages/devframe/src/client/index.ts +++ b/packages/devframe/src/client/index.ts @@ -1,5 +1,6 @@ import { getDevframeRpcClient } from './rpc' +export * from './otp' export * from './rpc' export * from './rpc-streaming' diff --git a/packages/devframe/src/client/otp.ts b/packages/devframe/src/client/otp.ts new file mode 100644 index 0000000..5c79534 --- /dev/null +++ b/packages/devframe/src/client/otp.ts @@ -0,0 +1,66 @@ +import type { DevframeRpcClient } from './rpc' +import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' + +// Browser-only helpers for "magic link" pairing: a host prints a URL carrying a +// one-time pairing code (OTP), and the client reads it, exchanges it for a +// token, and removes it from the address bar. Only the short-lived, single-use +// OTP ever rides the URL — never the resulting bearer token. + +/** + * Read a one-time pairing code from the current page URL's query string, + * without side effects. Returns `undefined` when the parameter is absent. + */ +export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { + try { + return new URLSearchParams(globalThis.location?.search).get(param) || undefined + } + catch { + return undefined + } +} + +function stripParamFromUrl(param: string): void { + try { + const url = new URL(globalThis.location!.href) + if (!url.searchParams.has(param)) + return + url.searchParams.delete(param) + globalThis.history?.replaceState(globalThis.history.state, '', url.href) + } + catch {} +} + +/** + * Read the one-time code from the page URL and remove it from the address bar + * (and the current history entry), so the single-use code isn't left in the + * URL, browser history, or a `Referer`. Returns the code, or `undefined` when + * absent. + */ +export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { + const code = readOtpFromUrl(param) + if (code) + stripParamFromUrl(param) + return code +} + +/** + * Consume a one-time code from the page URL (see {@link consumeOtpFromUrl}) and + * exchange it for a token via the client. Resolves `true` when the client is + * paired (already trusted, or the exchange succeeded), and `false` when no code + * is present or the exchange failed. + * + * Higher-level integrations (e.g. Vite DevTools) that want to drive their own + * pairing UI can disable `connectDevframe`'s built-in handling with + * `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves. + */ +export async function pairWithUrlOtp( + rpc: Pick, + options: { param?: string } = {}, +): Promise { + const code = consumeOtpFromUrl(options.param ?? DEVFRAME_OTP_URL_PARAM) + if (!code) + return false + if (rpc.isTrusted) + return true + return rpc.requestTrustWithCode(code) +} diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 4899cfb..90d6da7 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -4,12 +4,12 @@ import type { WsRpcChannelOptions } from 'devframe/rpc/transports/ws-client' import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunctions, EventEmitter, RpcSharedStateHost } from 'devframe/types' import type { RpcStreamingClientHost } from './rpc-streaming' import { - DEVFRAME_AUTH_URL_PARAM, DEVFRAME_CONNECTION_META_FILENAME, + DEVFRAME_OTP_URL_PARAM, } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' -import { clearAuthCodeFromUrl, readAuthCodeFromUrl } from './auth-url' +import { pairWithUrlOtp } from './otp' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' @@ -39,12 +39,13 @@ export interface DevframeRpcClientOptions { */ authToken?: string /** - * Query-param name on the page URL carrying a one-time pairing code for + * Query-param name on the page URL carrying a one-time pairing code (OTP) for * "magic link" auth (e.g. a link the dev server prints). When present, the * client exchanges the code for a token and removes the parameter from the - * URL. Set `false` to disable. Default: `'devframe_auth'`. + * URL. Set `false` to disable — e.g. integrations that drive their own + * pairing via `pairWithUrlOtp`. Default: `'devframe_otp'`. */ - autoPairParam?: string | false + otpParam?: string | false wsOptions?: Partial rpcOptions?: Partial> cacheOptions?: boolean | Partial @@ -364,15 +365,11 @@ export async function getDevframeRpcClient( // Magic-link pairing: if the page URL carries a one-time code, exchange it // and strip it from the URL. The code is single-use and short-lived; the // resulting bearer token is persisted (never written back to the URL). - const autoPairParam = options.autoPairParam ?? DEVFRAME_AUTH_URL_PARAM - if (autoPairParam) { - const code = readAuthCodeFromUrl(autoPairParam) - if (code) { - clearAuthCodeFromUrl(autoPairParam) - if (!rpc.isTrusted) - void rpc.requestTrustWithCode(code) - } - } + // Integrations that drive their own pairing UI opt out with `otpParam: false` + // and call `pairWithUrlOtp` / `consumeOtpFromUrl` directly. + const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM + if (otpParam) + void pairWithUrlOtp(rpc, { param: otpParam }) // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index f46e56e..121604f 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -17,10 +17,10 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection' /** - * Page-URL query parameter carrying a one-time pairing code for "magic link" - * auth. A host can print a link like `/?devframe_auth=`; the + * Page-URL query parameter carrying a one-time pairing code (OTP) for "magic + * link" auth. A host can print a link like `/?devframe_otp=`; the * client reads the code, exchanges it for a token, and strips the parameter - * from the URL. See `buildAuthPairingUrl` (node) and `connectDevframe`'s - * `autoPairParam` option (client). + * from the URL. See `buildOtpPairingUrl` (node) and the `pairWithUrlOtp` / + * `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`). */ -export const DEVFRAME_AUTH_URL_PARAM = 'devframe_auth' +export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp' diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 6773d7b..cb03144 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -1,7 +1,7 @@ import type { DevframeNodeRpcSession } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' import type { InternalAnonymousAuthStorage } from '../hub-internals/context' -import { DEVFRAME_AUTH_URL_PARAM } from 'devframe/constants' +import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' /** Number of decimal digits in a human-typed one-time pairing code. */ @@ -44,14 +44,14 @@ export function refreshTempAuthToken(): string { } /** - * Build a "magic link" pairing URL that embeds a one-time code as a query + * Build a "magic link" pairing URL that embeds a one-time code (OTP) as a query * parameter. Opening it lets the client pair without typing — print it on * startup (devframe stays headless, so the host prints its own banner). * Defaults to the current code; the link is subject to the same TTL. */ -export function buildAuthPairingUrl(baseUrl: string, code: string = tempAuthCode): string { +export function buildOtpPairingUrl(baseUrl: string, code: string = tempAuthCode): string { const url = new URL(baseUrl) - url.searchParams.set(DEVFRAME_AUTH_URL_PARAM, code) + url.searchParams.set(DEVFRAME_OTP_URL_PARAM, code) return url.href } diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 7329f13..ba32ffe 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -485,7 +485,7 @@ RPC handlers run with the full privileges of the host process, so the boundary t - **`auth` defaults to `true`** — dev-mode connections must pair before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and pairing UI. - **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never pair it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. - **Pairing** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. -- **Magic-link (optional):** print `buildAuthPairingUrl(origin)` — `/?devframe_auth=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL (`autoPairParam` to disable/rename). Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. +- **Magic-link (optional):** print `buildOtpPairingUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `pairWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. - **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index 82a83f2..b83af07 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -32,7 +32,7 @@ export interface DevframeRpcClientOptions { connectionMeta?: ConnectionMeta; baseURL?: string | string[]; authToken?: string; - autoPairParam?: string | false; + otpParam?: string | false; wsOptions?: Partial; rpcOptions?: Partial>; cacheOptions?: boolean | Partial; @@ -60,8 +60,13 @@ export type DevframeRpcClientCallOptional = BirpcReturn; +export declare function pairWithUrlOtp(_: Pick, _?: { + param?: string; +}): Promise; +export declare function readOtpFromUrl(_?: string): string | undefined; // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index 948fd17..c7e5f14 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -2,8 +2,11 @@ * Generated by tsnapi — public API snapshot of `devframe/client` */ // #region Functions +export function consumeOtpFromUrl(_) {} export function createRpcStreamingClientHost(_) {} export async function getDevframeRpcClient(_) {} +export async function pairWithUrlOtp(_, _) {} +export function readOtpFromUrl(_) {} // #endregion // #region Variables diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts index e14b028..b6cf894 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.d.ts @@ -2,13 +2,13 @@ * Generated by tsnapi — public API snapshot of `devframe/constants` */ // #region Variables -export declare const DEVFRAME_AUTH_URL_PARAM: string; export declare const DEVFRAME_CONNECTION_META_FILENAME: string; export declare const DEVFRAME_DIRNAME: string; export declare const DEVFRAME_DOCK_IMPORTS_FILENAME: string; export declare const DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID: string; export declare const DEVFRAME_MOUNT_PATH: string; export declare const DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH: string; +export declare const DEVFRAME_OTP_URL_PARAM: string; export declare const DEVFRAME_RPC_DUMP_DIRNAME: string; export declare const DEVFRAME_RPC_DUMP_MANIFEST_FILENAME: string; export declare const REMOTE_CONNECTION_KEY: string; diff --git a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js index a5972eb..59c320f 100644 --- a/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/constants.snapshot.js @@ -2,13 +2,13 @@ * Generated by tsnapi — public API snapshot of `devframe/constants` */ // #region Variables -export var DEVFRAME_AUTH_URL_PARAM /* const */ export var DEVFRAME_CONNECTION_META_FILENAME /* const */ export var DEVFRAME_DIRNAME /* const */ export var DEVFRAME_DOCK_IMPORTS_FILENAME /* const */ export var DEVFRAME_DOCK_IMPORTS_VIRTUAL_ID /* const */ export var DEVFRAME_MOUNT_PATH /* const */ export var DEVFRAME_MOUNT_PATH_NO_TRAILING_SLASH /* const */ +export var DEVFRAME_OTP_URL_PARAM /* const */ export var DEVFRAME_RPC_DUMP_DIRNAME /* const */ export var DEVFRAME_RPC_DUMP_MANIFEST_FILENAME /* const */ export var REMOTE_CONNECTION_KEY /* const */ diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts index d0b2ade..450405d 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -2,7 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions -export declare function buildAuthPairingUrl(_: string, _?: string): string; +export declare function buildOtpPairingUrl(_: string, _?: string): string; export declare function exchangeTempAuthCode(_: string, _: DevframeNodeRpcSession, _: { ua: string; origin: string; diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js index a64e330..faaa774 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js @@ -2,7 +2,7 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions -export function buildAuthPairingUrl(_, _) {} +export function buildOtpPairingUrl(_, _) {} export function exchangeTempAuthCode(_, _, _, _) {} export function getTempAuthToken() {} export function refreshTempAuthToken() {} From 542c6d7deebb2f161d64e3d6f4227747506eeb8e Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 19 Jun 2026 06:22:28 +0000 Subject: [PATCH 6/6] refactor(auth)!: use "authenticate" wording and document the auth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the "pair/pairing" vocabulary with "authenticate/authentication" across the auth surface, and rename the API for consistency: - `getTempAuthToken` → `getTempAuthCode` and `refreshTempAuthToken` → `refreshTempAuthCode` (they handle the one-time code, not a token) - `buildOtpPairingUrl` → `buildOtpAuthUrl` - `pairWithUrlOtp` → `authenticateWithUrlOtp` Document the auth methods (RPC wire contract + `devframe/node/auth` and client primitives) and the end-to-end auth flow in the security guide. BREAKING CHANGE: `getTempAuthToken` → `getTempAuthCode`, `refreshTempAuthToken` → `refreshTempAuthCode`, `buildOtpPairingUrl` → `buildOtpAuthUrl`, `pairWithUrlOtp` → `authenticateWithUrlOtp`. All unreleased. --- docs/guide/client.md | 10 ++-- docs/guide/security.md | 47 ++++++++++++++----- .../devframe/src/client/__tests__/otp.test.ts | 10 ++-- packages/devframe/src/client/otp.ts | 20 ++++---- packages/devframe/src/client/rpc-ws.ts | 7 +-- packages/devframe/src/client/rpc.ts | 33 ++++++------- packages/devframe/src/constants.ts | 8 ++-- packages/devframe/src/node/auth/state.ts | 43 ++++++++--------- packages/devframe/src/types/rpc-augments.ts | 2 +- packages/devframe/src/utils/crypto-token.ts | 2 +- skills/devframe/SKILL.md | 10 ++-- .../tsnapi/devframe/client.snapshot.d.ts | 6 +-- .../tsnapi/devframe/client.snapshot.js | 2 +- .../tsnapi/devframe/node/auth.snapshot.d.ts | 6 +-- .../tsnapi/devframe/node/auth.snapshot.js | 6 +-- 15 files changed, 119 insertions(+), 93 deletions(-) diff --git a/docs/guide/client.md b/docs/guide/client.md index fcd9890..d3f2cc0 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -66,7 +66,7 @@ The client picks a mode automatically from the backend field. Mode-specific code ## Trust & auth (WebSocket mode) -Dev-mode connections become trusted by pairing. A client that has paired before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it: +Dev-mode connections become trusted by authenticating. A client that authenticated before presents its stored token automatically on reconnect, and `ensureTrusted()` resolves once the server accepts it: ```ts const rpc = await connectDevframe() @@ -75,11 +75,11 @@ const rpc = await connectDevframe() const trusted = await rpc.ensureTrusted() if (!trusted) { - console.warn('Not paired yet') + console.warn('Not authenticated yet') } ``` -### Pairing with a one-time code +### Authenticating with a one-time code A fresh client holds no token. The dev server prints a 6-digit one-time code; pass it to `requestTrustWithCode` to exchange it for a node-issued token. The token is persisted for future reconnections and shared with sibling tabs, which become trusted without re-entering the code: @@ -89,7 +89,7 @@ const ok = await rpc.requestTrustWithCode('047204') The code is single-use, expires after five minutes, and is rotated after repeated wrong attempts, so re-display the current code if an exchange fails. -To pair without typing, a host can print a link embedding the code (`buildOtpPairingUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive pairing yourself with the exposed `pairWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities. +To authenticate without typing, a host can print a link embedding the code (`buildOtpAuthUrl(origin)`); `connectDevframe` reads the `devframe_otp` query parameter, exchanges it, and strips it from the URL. Rename it with the `otpParam` option, or set `otpParam: false` and drive authentication yourself with the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` utilities. ### Re-using an existing token @@ -101,7 +101,7 @@ const ok = await rpc.requestTrustWithToken('a1b2c3…') ### Broadcast-channel sync -`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When another tab completes a pairing — or an auth page announces a token — every open client trusts it automatically, no reload required. +`connectDevframe` listens on a shared `BroadcastChannel` (named `devframe-auth` for cross-tab handshake interop with Vite DevTools' auth page) for `auth-update` messages. When another tab authenticates — or an auth page announces a token — every open client trusts it automatically, no reload required. ## Calling functions diff --git a/docs/guide/security.md b/docs/guide/security.md index a8911d0..1f4c3df 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -12,42 +12,65 @@ An RPC handler runs with the full privileges of the process hosting it — files Two postures cover that boundary: -- **Authenticated (default).** `auth` defaults to `true`. The browser pairs with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and pairing UI. +- **Authenticated (default).** `auth` defaults to `true`. The browser authenticates with the server before calls are accepted, and reconnects by presenting a node-issued bearer token. Devframe supplies the node-side primitives (`exchangeTempAuthCode`, `verifyAuthToken`); the host adapter — e.g. Vite DevTools — provides the interactive handler and authentication UI. - **Unauthenticated opt-out.** Setting `auth: false` starts the server with an auto-trust handshake. It exists for single-user tools talking to their own `localhost`, where a round-trip would only add friction. > [!WARNING] > `auth: false` trusts every connection that can reach the port. Only use it when the surface is reachable solely by the local developer. Never combine it with a non-loopback bind host, a tunnelled port, or a shared/CI environment. -## Pairing and tokens +## Authentication flow -Pairing exchanges a short code for a long token: +Authentication exchanges a short code for a long-lived token. A node mints and owns the token; the browser only ever sends the short code, and only over the open socket. -1. The dev server shows a 6-digit one-time code in the developer's terminal. -2. The developer types it into the browser, which calls `requestTrustWithCode(code)`. -3. The server verifies the code, mints a high-entropy bearer token, records it as trusted, and returns it. -4. The browser persists the token and presents it on reconnect; sibling tabs receive it over the `devframe-auth` channel. +1. A fresh client connects unauthenticated and calls `devframe:anonymous:auth` with its stored token (empty on first run). The server returns `{ isTrusted: false }`, so the trust gate stays open while the UI prompts for a code. +2. The dev server shows a 6-digit one-time code in the developer's terminal. +3. The developer enters it; the browser calls `requestTrustWithCode(code)` → `devframe:auth:exchange`. +4. The server verifies the code, mints a high-entropy bearer token, records it as trusted, marks the session trusted, and returns the token. +5. The browser persists the token and presents it on reconnect (`devframe:anonymous:auth` → `verifyAuthToken`); sibling tabs receive it over the `devframe-auth` channel and become trusted too. The 6-digit code is single-use, expires after five minutes, is compared in constant time, and rotates after repeated wrong attempts — which is what keeps a short code brute-force resistant. Show it only in a trusted channel (the terminal), never over the network. The bearer token is a secret. It travels to the server on the WebSocket URL (`?devframe_auth_token=…`), so serve over `wss://`/`https://` whenever the surface is reachable beyond loopback. Revoke a token with `revokeAuthToken(context, storage, token)`; affected clients drop to untrusted via the `devframe:auth:revoked` event. -### Magic-link pairing +### Auth methods -To skip typing, a host can print a link that embeds the code and open the browser straight into a paired session. Build it from the current code with `buildOtpPairingUrl(origin)` (devframe stays headless, so the host prints its own banner): +Devframe owns the wire contract; the host adapter registers the handlers on top of the `devframe/node/auth` primitives (the standalone server registers a noop auto-trust handler when `auth: false`). + +| RPC method | Direction | Shape | +|------------|-----------|-------| +| `devframe:anonymous:auth` | client → server | `{ authToken, ua, origin }` → `{ isTrusted }` — re-authenticate a stored token | +| `devframe:auth:exchange` | client → server | `{ code, ua, origin }` → `{ authToken \| null }` — exchange a one-time code for a token | +| `devframe:auth:revoked` | server → client | event — the connection's token was revoked | + +Node primitives (`devframe/node/auth`): + +| Function | Role | +|----------|------| +| `getTempAuthCode()` / `refreshTempAuthCode()` | read / rotate the current one-time code to display | +| `exchangeTempAuthCode(code, session, { ua, origin }, storage)` | verify a code, mint + store the token, trust the session, return the token (or `null`) | +| `verifyAuthToken(token, session, storage)` | trust a session presenting a known token (reconnect) | +| `buildOtpAuthUrl(origin, code?)` | build a magic-link URL embedding the code | +| `revokeAuthToken(context, storage, token)` | delete a token and disconnect any sessions using it | + +Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a code), `requestTrustWithToken(token)` (re-authenticate a token), `ensureTrusted(timeout?)` / `isTrusted` (the trust gate). + +### Magic-link authentication + +To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. Build it from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner): ``` -Devtools ready — pair this browser: http://localhost:3000/?devframe_otp=123456 +Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456 ``` `connectDevframe` reads the `devframe_otp` parameter, exchanges it, and removes it from the URL before anything else. Only the short-lived, single-use **code** ever rides the URL — the resulting bearer token is stored, never written back to it. Because the link grants trust to whoever opens it within the code's lifetime, print it only to a trusted channel (the terminal), exactly as you would the bare code. -Higher-level integrations can drive their own pairing UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `pairWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`. +Higher-level integrations can drive their own authentication UI instead: disable the built-in handling with the `otpParam: false` client option, then call the exposed `authenticateWithUrlOtp(rpc)` (consume the code from the URL and exchange it) or `consumeOtpFromUrl()` (read and strip the code) from `devframe/client`. ## Practices for tools built on devframe - **Stay on loopback.** The default bind host is `localhost`. Bind to a routable address only when you intend to, and require authentication when you do. - **Keep `auth: false` local.** Reach for it only for single-user localhost tools; leave the default in place anywhere a connection could originate elsewhere. -- **Treat tokens as secrets.** Never log the bearer token or the pairing code, and never bake either into build output. +- **Treat tokens as secrets.** Never log the bearer token or the one-time code, and never bake either into build output. - **Authorize every handler.** A registered function is callable by any trusted client. Validate inputs, and mark state-changing functions `type: 'destructive'` so MCP and agent clients prompt before invoking them. - **Origin-lock remote docks.** When a hub embeds a remote-UI dock, enable `originLock` so a dock token is only honored from its expected origin. - **Serve encrypted off-machine.** Use `https://`/`wss://` for any surface reachable beyond `localhost`. diff --git a/packages/devframe/src/client/__tests__/otp.test.ts b/packages/devframe/src/client/__tests__/otp.test.ts index 4169012..f2eba6a 100644 --- a/packages/devframe/src/client/__tests__/otp.test.ts +++ b/packages/devframe/src/client/__tests__/otp.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { consumeOtpFromUrl, pairWithUrlOtp, readOtpFromUrl } from '../otp' +import { authenticateWithUrlOtp, consumeOtpFromUrl, readOtpFromUrl } from '../otp' afterEach(() => { vi.unstubAllGlobals() @@ -46,13 +46,13 @@ describe('otp url helpers', () => { }) }) -describe('pairWithUrlOtp', () => { +describe('authenticateWithUrlOtp', () => { it('exchanges the OTP via the client and resolves true on success', async () => { vi.stubGlobal('location', { search: '?devframe_otp=123456', href: 'http://localhost:3000/?devframe_otp=123456' }) vi.stubGlobal('history', { state: null, replaceState: vi.fn() }) const requestTrustWithCode = vi.fn().mockResolvedValue(true) - const ok = await pairWithUrlOtp({ isTrusted: false, requestTrustWithCode }) + const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode }) expect(requestTrustWithCode).toHaveBeenCalledWith('123456') expect(ok).toBe(true) @@ -62,7 +62,7 @@ describe('pairWithUrlOtp', () => { vi.stubGlobal('location', { search: '', href: 'http://localhost:3000/' }) const requestTrustWithCode = vi.fn() - const ok = await pairWithUrlOtp({ isTrusted: false, requestTrustWithCode }) + const ok = await authenticateWithUrlOtp({ isTrusted: false, requestTrustWithCode }) expect(requestTrustWithCode).not.toHaveBeenCalled() expect(ok).toBe(false) @@ -74,7 +74,7 @@ describe('pairWithUrlOtp', () => { vi.stubGlobal('history', { state: null, replaceState }) const requestTrustWithCode = vi.fn() - const ok = await pairWithUrlOtp({ isTrusted: true, requestTrustWithCode }) + const ok = await authenticateWithUrlOtp({ isTrusted: true, requestTrustWithCode }) expect(ok).toBe(true) expect(requestTrustWithCode).not.toHaveBeenCalled() diff --git a/packages/devframe/src/client/otp.ts b/packages/devframe/src/client/otp.ts index 5c79534..ee7092f 100644 --- a/packages/devframe/src/client/otp.ts +++ b/packages/devframe/src/client/otp.ts @@ -1,14 +1,14 @@ import type { DevframeRpcClient } from './rpc' import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' -// Browser-only helpers for "magic link" pairing: a host prints a URL carrying a -// one-time pairing code (OTP), and the client reads it, exchanges it for a -// token, and removes it from the address bar. Only the short-lived, single-use -// OTP ever rides the URL — never the resulting bearer token. +// Browser-only helpers for "magic link" authentication: a host prints a URL +// carrying a one-time authentication code (OTP), and the client reads it, +// exchanges it for a token, and removes it from the address bar. Only the +// short-lived, single-use OTP ever rides the URL — never the resulting token. /** - * Read a one-time pairing code from the current page URL's query string, - * without side effects. Returns `undefined` when the parameter is absent. + * Read a one-time authentication code (OTP) from the current page URL's query + * string, without side effects. Returns `undefined` when the parameter is absent. */ export function readOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): string | undefined { try { @@ -46,14 +46,14 @@ export function consumeOtpFromUrl(param: string = DEVFRAME_OTP_URL_PARAM): strin /** * Consume a one-time code from the page URL (see {@link consumeOtpFromUrl}) and * exchange it for a token via the client. Resolves `true` when the client is - * paired (already trusted, or the exchange succeeded), and `false` when no code - * is present or the exchange failed. + * authenticated (already trusted, or the exchange succeeded), and `false` when + * no code is present or the exchange failed. * * Higher-level integrations (e.g. Vite DevTools) that want to drive their own - * pairing UI can disable `connectDevframe`'s built-in handling with + * authentication UI can disable `connectDevframe`'s built-in handling with * `otpParam: false` and call this — or {@link consumeOtpFromUrl} — themselves. */ -export async function pairWithUrlOtp( +export async function authenticateWithUrlOtp( rpc: Pick, options: { param?: string } = {}, ): Promise { diff --git a/packages/devframe/src/client/rpc-ws.ts b/packages/devframe/src/client/rpc-ws.ts index b0da8e6..877427b 100644 --- a/packages/devframe/src/client/rpc-ws.ts +++ b/packages/devframe/src/client/rpc-ws.ts @@ -94,7 +94,7 @@ export function createWsRpcClientMode( isTrusted = result.isTrusted // Only settle the trust gate on success; on failure the client can still - // pair via `requestTrustWithCode`, so leave `ensureTrusted` waiting. + // authenticate via `requestTrustWithCode`, so leave `ensureTrusted` waiting. if (isTrusted) trustedPromise.resolve(true) events.emit('rpc:is-trusted:updated', isTrusted) @@ -123,8 +123,9 @@ export function createWsRpcClientMode( return true // Always announce on connect. The standalone (`auth: false`) noop handler // auto-trusts regardless of token; the host adapter looks the token up and - // returns `false` for an unpaired client (empty/unknown token), which then - // pairs via `requestTrustWithCode`. The trust gate stays open until then. + // returns `false` for an unauthenticated client (empty/unknown token), which + // then authenticates via `requestTrustWithCode`. The trust gate stays open + // until then. return requestTrustWithToken(currentAuthToken ?? '') } diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index d18edb2..55aa1ab 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -10,7 +10,7 @@ import { } from 'devframe/constants' import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc' import { createEventEmitter } from 'devframe/utils/events' -import { pairWithUrlOtp } from './otp' +import { authenticateWithUrlOtp } from './otp' import { createRpcSharedStateClientHost } from './rpc-shared-state' import { createStaticRpcClientMode } from './rpc-static' import { createRpcStreamingClientHost } from './rpc-streaming' @@ -41,11 +41,11 @@ export interface DevframeRpcClientOptions { */ authToken?: string /** - * Query-param name on the page URL carrying a one-time pairing code (OTP) for - * "magic link" auth (e.g. a link the dev server prints). When present, the - * client exchanges the code for a token and removes the parameter from the - * URL. Set `false` to disable — e.g. integrations that drive their own - * pairing via `pairWithUrlOtp`. Default: `'devframe_otp'`. + * Query-param name on the page URL carrying a one-time authentication code + * (OTP) for "magic link" auth (e.g. a link the dev server prints). When + * present, the client exchanges the code for a token and removes the parameter + * from the URL. Set `false` to disable — e.g. integrations that drive their + * own authentication via `authenticateWithUrlOtp`. Default: `'devframe_otp'`. */ otpParam?: string | false wsOptions?: Partial @@ -92,9 +92,10 @@ export interface DevframeRpcClient { requestTrustWithToken: (token: string) => Promise /** - * Pair this client by exchanging a one-time code (shown by the dev server) - * for a node-issued auth token. On success the token is persisted for future - * reconnections and shared with sibling tabs. Resolves `true` when paired. + * Authenticate this client by exchanging a one-time code (shown by the dev + * server) for a node-issued auth token. On success the token is persisted for + * future reconnections and shared with sibling tabs. Resolves `true` when + * authenticated. */ requestTrustWithCode: (code: string) => Promise @@ -178,8 +179,8 @@ function getStoredAuthToken(userAuthToken?: string): string | undefined { catch {} } - // No token yet — the client is unpaired and must exchange a one-time code - // (see `requestTrustWithCode`) to obtain a node-issued token. + // No token yet — the client is unauthenticated and must exchange a one-time + // code (see `requestTrustWithCode`) to obtain a node-issued token. return undefined } @@ -393,14 +394,14 @@ export async function getDevframeRpcClient( context.rpc = rpc void mode.requestTrust() - // Magic-link pairing: if the page URL carries a one-time code, exchange it - // and strip it from the URL. The code is single-use and short-lived; the + // Magic-link authentication: if the page URL carries a one-time code, exchange + // it and strip it from the URL. The code is single-use and short-lived; the // resulting bearer token is persisted (never written back to the URL). - // Integrations that drive their own pairing UI opt out with `otpParam: false` - // and call `pairWithUrlOtp` / `consumeOtpFromUrl` directly. + // Integrations that drive their own auth UI opt out with `otpParam: false` + // and call `authenticateWithUrlOtp` / `consumeOtpFromUrl` directly. const otpParam = options.otpParam ?? DEVFRAME_OTP_URL_PARAM if (otpParam) - void pairWithUrlOtp(rpc, { param: otpParam }) + void authenticateWithUrlOtp(rpc, { param: otpParam }) // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). diff --git a/packages/devframe/src/constants.ts b/packages/devframe/src/constants.ts index 121604f..dacf603 100644 --- a/packages/devframe/src/constants.ts +++ b/packages/devframe/src/constants.ts @@ -17,10 +17,10 @@ export const DEVFRAME_RPC_DUMP_DIRNAME = '__rpc-dump' export const REMOTE_CONNECTION_KEY = 'devframe-remote-connection' /** - * Page-URL query parameter carrying a one-time pairing code (OTP) for "magic - * link" auth. A host can print a link like `/?devframe_otp=`; the - * client reads the code, exchanges it for a token, and strips the parameter - * from the URL. See `buildOtpPairingUrl` (node) and the `pairWithUrlOtp` / + * Page-URL query parameter carrying a one-time authentication code (OTP) for + * "magic link" auth. A host can print a link like `/?devframe_otp=`; + * the client reads the code, exchanges it for a token, and strips the parameter + * from the URL. See `buildOtpAuthUrl` (node) and the `authenticateWithUrlOtp` / * `consumeOtpFromUrl` client utilities (or `connectDevframe`'s `otpParam`). */ export const DEVFRAME_OTP_URL_PARAM = 'devframe_otp' diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index cb03144..490f64c 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -4,12 +4,12 @@ import type { InternalAnonymousAuthStorage } from '../hub-internals/context' import { DEVFRAME_OTP_URL_PARAM } from 'devframe/constants' import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token' -/** Number of decimal digits in a human-typed one-time pairing code. */ +/** Number of decimal digits in a human-typed one-time authentication code. */ const TEMP_AUTH_CODE_LENGTH = 6 /** - * How long a pairing code stays valid after it is (re)generated. A 6-digit - * code only has ~20 bits of entropy, so a short lifetime plus the attempt cap - * below are what keep it brute-force resistant. + * How long an authentication code stays valid after it is (re)generated. A + * 6-digit code only has ~20 bits of entropy, so a short lifetime plus the + * attempt cap below are what keep it brute-force resistant. */ const TEMP_AUTH_CODE_TTL = 5 * 60_000 /** Failed attempts allowed against a single code before it is rotated. */ @@ -24,19 +24,20 @@ function generateTempCode(): string { } /** - * The current one-time pairing code. Display this to the user (e.g. in the - * dev-server terminal) so they can type it into the browser to pair. + * The current one-time authentication code. Display this to the user (e.g. in + * the dev-server terminal) so they can type it into the browser to authenticate. */ -export function getTempAuthToken(): string { +export function getTempAuthCode(): string { return tempAuthCode } /** - * Rotate the pairing code, resetting its expiry window and failed-attempt - * counter. Call this when a new pairing flow begins (e.g. when an untrusted - * client asks to pair) so the displayed code is freshly valid for its full TTL. + * Rotate the authentication code, resetting its expiry window and failed-attempt + * counter. Call this when a new authentication flow begins (e.g. when an + * untrusted client starts authenticating) so the displayed code is freshly + * valid for its full TTL. */ -export function refreshTempAuthToken(): string { +export function refreshTempAuthCode(): string { tempAuthCode = generateTempCode() tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL tempAuthFailedAttempts = 0 @@ -44,12 +45,12 @@ export function refreshTempAuthToken(): string { } /** - * Build a "magic link" pairing URL that embeds a one-time code (OTP) as a query - * parameter. Opening it lets the client pair without typing — print it on - * startup (devframe stays headless, so the host prints its own banner). + * Build a "magic link" authentication URL that embeds a one-time code (OTP) as + * a query parameter. Opening it authenticates the client without typing — print + * it on startup (devframe stays headless, so the host prints its own banner). * Defaults to the current code; the link is subject to the same TTL. */ -export function buildOtpPairingUrl(baseUrl: string, code: string = tempAuthCode): string { +export function buildOtpAuthUrl(baseUrl: string, code: string = tempAuthCode): string { const url = new URL(baseUrl) url.searchParams.set(DEVFRAME_OTP_URL_PARAM, code) return url.href @@ -60,8 +61,8 @@ export function buildOtpPairingUrl(baseUrl: string, code: string = tempAuthCode) * Returns `true` and marks the session trusted when the token is known. * * Used by the `devframe:anonymous:auth` handler so a client that already - * completed pairing (token persisted in the browser) is trusted on reconnect - * without typing the code again. + * authenticated (token persisted in the browser) is trusted on reconnect + * without entering the code again. */ export function verifyAuthToken( token: string, @@ -77,7 +78,7 @@ export function verifyAuthToken( } /** - * Exchange a one-time pairing code for a fresh, node-issued bearer token. + * Exchange a one-time authentication code for a fresh, node-issued bearer token. * * On success this mints a high-entropy token, records it in the trusted store, * marks the calling session trusted, rotates the code, and returns the token @@ -96,7 +97,7 @@ export function exchangeTempAuthCode( ): string | null { // Expired code: rotate so a stale code can never be redeemed. if (Date.now() > tempAuthCodeExpiresAt) { - refreshTempAuthToken() + refreshTempAuthCode() return null } @@ -104,7 +105,7 @@ export function exchangeTempAuthCode( tempAuthFailedAttempts += 1 // Too many wrong guesses — invalidate this code entirely. if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS) - refreshTempAuthToken() + refreshTempAuthCode() return null } @@ -122,7 +123,7 @@ export function exchangeTempAuthCode( session.meta.isTrusted = true // Rotate the code so it can never be replayed. - refreshTempAuthToken() + refreshTempAuthCode() return authToken } diff --git a/packages/devframe/src/types/rpc-augments.ts b/packages/devframe/src/types/rpc-augments.ts index 268de50..02d1d34 100644 --- a/packages/devframe/src/types/rpc-augments.ts +++ b/packages/devframe/src/types/rpc-augments.ts @@ -61,7 +61,7 @@ export interface DevframeRpcServerFunctions { */ 'devframe:anonymous:auth': (params: { authToken: string, ua: string, origin: string }) => Promise<{ isTrusted: boolean }> /** - * Exchange a one-time pairing code (shown by the dev server) for a fresh, + * Exchange a one-time authentication code (shown by the dev server) for a fresh, * node-issued bearer token, returning the token on success or `null`. The * handler is provided by the host adapter on top of `exchangeTempAuthCode`. * diff --git a/packages/devframe/src/utils/crypto-token.ts b/packages/devframe/src/utils/crypto-token.ts index 38799e8..6998eae 100644 --- a/packages/devframe/src/utils/crypto-token.ts +++ b/packages/devframe/src/utils/crypto-token.ts @@ -26,7 +26,7 @@ export function randomToken(byteLength = 16): string { /** * Generate a uniformly-distributed string of decimal digits using rejection * sampling to avoid modulo bias. Intended for short, human-typed one-time - * codes (e.g. a 6-digit pairing code). Leading zeros are preserved. + * codes (e.g. a 6-digit authentication code). Leading zeros are preserved. */ export function randomDigits(length: number): string { // Largest multiple of 10 that fits in a byte; reject values at/above it so diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 2bbffcb..14a6738 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -566,10 +566,10 @@ For "open file in editor" + "reveal in finder", prefer the prebuilt `openHelpers RPC handlers run with the full privileges of the host process, so the boundary that matters is who may connect. Keep that boundary tight: -- **`auth` defaults to `true`** — dev-mode connections must pair before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and pairing UI. -- **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never pair it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. -- **Pairing** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. -- **Magic-link (optional):** print `buildOtpPairingUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `pairWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. +- **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and auth UI. +- **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never combine it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. +- **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. +- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. - **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. @@ -596,7 +596,7 @@ Devframe-level pages (one-tool, portable surface): - [Structured Diagnostics](https://devfra.me/diagnostics) — coded errors via `ctx.diagnostics`, register custom codes - [Utilities](https://devfra.me/utilities) — bundled `devframe/utils/*` helpers (colors, hash, launchEditor, structured-clone, …) - [Client](https://devfra.me/client) — auth handshake, modes, discovery -- [Security](https://devfra.me/security) — trust model, pairing, secure-by-default practices +- [Security](https://devfra.me/security) — trust model, authentication, secure-by-default practices - [Agent-Native](https://devfra.me/agent-native) — agent field, tools/resources, MCP + Claude Desktop Host-specific extras (when mounting into Vite DevTools — other hosts have their own equivalents): diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts index b7575c7..2a5f3a1 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.d.ts @@ -99,14 +99,14 @@ export type DevframeRpcClientCallOptional = BirpcReturn, _?: { + param?: string; +}): Promise; export declare function consumeOtpFromUrl(_?: string): string | undefined; export declare function createClientSettings = Record>(_: DevframeRpcClient, _: string): DevframeSettings; export declare function createRpcStreamingClientHost(_: DevframeRpcClient): RpcStreamingClientHost; export declare function createScopedClientContext(_: DevframeRpcClient, _: NS): DevframeScopedClientContext; export declare function getDevframeRpcClient(_?: DevframeRpcClientOptions): Promise; -export declare function pairWithUrlOtp(_: Pick, _?: { - param?: string; -}): Promise; export declare function readOtpFromUrl(_?: string): string | undefined; // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js index dfbdd4b..58e177d 100644 --- a/tests/__snapshots__/tsnapi/devframe/client.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/client.snapshot.js @@ -2,12 +2,12 @@ * Generated by tsnapi — public API snapshot of `devframe/client` */ // #region Functions +export async function authenticateWithUrlOtp(_, _) {} export function consumeOtpFromUrl(_) {} export function createClientSettings(_, _) {} export function createRpcStreamingClientHost(_) {} export function createScopedClientContext(_, _) {} export async function getDevframeRpcClient(_) {} -export async function pairWithUrlOtp(_, _) {} export function readOtpFromUrl(_) {} // #endregion diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts index 450405d..bfaabcb 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.d.ts @@ -2,13 +2,13 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions -export declare function buildOtpPairingUrl(_: string, _?: string): string; +export declare function buildOtpAuthUrl(_: string, _?: string): string; export declare function exchangeTempAuthCode(_: string, _: DevframeNodeRpcSession, _: { ua: string; origin: string; }, _: SharedState): string | null; -export declare function getTempAuthToken(): string; -export declare function refreshTempAuthToken(): string; +export declare function getTempAuthCode(): string; +export declare function refreshTempAuthCode(): string; export declare function revokeActiveConnectionsForToken(_: DevframeNodeContext, _: string): Promise; export declare function revokeAuthToken(_: DevframeNodeContext, _: SharedState, _: string): Promise; export declare function verifyAuthToken(_: string, _: DevframeNodeRpcSession, _: SharedState): boolean; diff --git a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js index faaa774..7a1bbb2 100644 --- a/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node/auth.snapshot.js @@ -2,10 +2,10 @@ * Generated by tsnapi — public API snapshot of `devframe/node/auth` */ // #region Functions -export function buildOtpPairingUrl(_, _) {} +export function buildOtpAuthUrl(_, _) {} export function exchangeTempAuthCode(_, _, _, _) {} -export function getTempAuthToken() {} -export function refreshTempAuthToken() {} +export function getTempAuthCode() {} +export function refreshTempAuthCode() {} export function verifyAuthToken(_, _, _) {} // #endregion