feat: device flow-login (QR / annan enhet) i CLI:n - #90
Conversation
- src/auth/oauth/device-flow.ts: device authorization grant against
Keycloak — terminal QR (half-block, EC 'L'), verification_uri_complete
link, RFC 8628 polling (slow_down backoff, non-JSON gateway responses
retryable, deadline from expires_in)
- loginInteractive({method}) + berget auth login --device flag
- berget code init prompts browser vs device before login
- 8 new tests; all 265 green
There was a problem hiding this comment.
Review
Summary
This PR adds RFC 8628 device authorization grant login (berget auth login --device, plus a browser/device prompt in the code init wizard) with a terminal QR renderer and poll loop, and adapts existing init/auth-sync tests to the new prompt.
Risk
MEDIUM — the standalone auth login --device path works, but I verified in the vendored @clack/prompts@1.5.1 dist that the spinner repaints every 80 ms with cursor.up() + erase.down() on stdout and does not buffer other output; consequently the QR/link/user-code that startDeviceFlow prints after s.start() inside ensureCliAuth is erased within ~160 ms, making device login unusable in berget code init, and the new module ships with zero tests.
Issues
- critical
src/commands/code/auth-sync.ts:121— The QR/link/user-code are printed to stdout while the clack spinner is running; the spinner's 80 ms repaint (erase.down()) wipes everything below its frame, so device mode is unusable through the wizard. - warning
src/services/auth-service.ts:77—loginInteractivestill documents "Does NOT print to stdout", but the new device branch prints the whole QR block, breaking that documented contract (which is the direct cause of the spinner conflict). - warning
src/auth/oauth/device-flow.ts:8— Realm is hardcoded (/realms/berget/…) even thoughAuthConfig.realmexists andissuer.tscomposes the realm dynamically; this also bypasses the OIDC discovery (getConfiguration) already used for PKCE, so the two flows can drift to different endpoints. - warning
src/auth/oauth/device-flow.ts:141—https://${new URL(keycloakUrl).host}forces https and drops any path prefix, breaking theBERGET_KEYCLOAK_URL=http://localhost:8080local-dev override thatgetAuthConfig()explicitly supports (PKCE handles those via discovery). - warning
src/auth/oauth/device-flow.ts:60— No unit tests for the new logic (extractTokenResult,handlePollError,renderTerminalQrCode, polling loop) despite "Exported for tests" comments and the siblingpkce-flow.test.tsprecedent. - nit
src/auth/oauth/device-flow.ts:10— Device scope addsdevice-email-otp, absent from the PKCE scope; if that custom scope isn't assigned to theberget-codeclient, the device authorization request fails outright withinvalid_scope— confirm it exists on both stage and prod realms. - nit
src/auth/oauth/device-flow.ts:217— The authorization response is only checked via anascast; a missingexpires_inproduces aNaNdeadline so the poll loop never executes and the user instantly gets "timed out". - nit
src/commands/auth.ts:19— The new--deviceflag (headlined for SSH/headless) is undocumented in the README's Authentication section.
Suggestions
src/commands/code/auth-sync.ts— Stop the spinner before the device flow prints its instructions (or route the QR through the prompter), e.g.:if (method === 'device') s.stop('Scan the code to continue:'); const loginResult = await authService.loginInteractive({ debug: ..., method });
src/auth/oauth/device-flow.ts— Build endpoints from config likeissuer.tsdoes (${config.keycloakUrl}/realms/${config.realm}/protocol/openid-connect/auth/device) or read the discovereddevice_authorization_endpoint, which also fixes the https-forcing URL at line 141.src/auth/oauth/device-flow.ts— Add unit tests forhandlePollError(pending/slow_down/access_denied/expired),extractTokenResult, and the QR renderer.
Architecture
The device flow hand-builds Keycloak endpoint URLs instead of reusing the openid-client discovery stack (getConfiguration), creating a second source of truth for realm/endpoint config, and couples a documented-stdout-free library API (loginInteractive) directly to process.stdout.
Code quality score: 7/10 — clean decomposition into documented, testable pure helpers, but the module is untested, duplicates endpoint/realm construction, and breaks its caller contract by printing to stdout.
Inline findings
Berget AI (berget/zai-org/GLM-5.3-Flash) | PR #90
|
|
||
| const s = prompter.spinner(); | ||
| s.start('Waiting for browser login...'); | ||
| s.start( |
There was a problem hiding this comment.
🔴 blocker — Device flow prints the QR to stdout while this clack spinner is active; the spinner's 80ms erase.down() repaint wipes the QR/link/user-code, making device login unusable in berget code init.
| @@ -72,7 +77,12 @@ export class AuthService { | |||
| * Does NOT print to stdout — returns tokens so callers can display | |||
There was a problem hiding this comment.
🟠 warning — loginInteractive's documented 'Does NOT print to stdout' contract is now violated by the device branch, which prints the QR block and causes the spinner conflict.
|
|
||
| import { logger, LogLevel } from '../../utils/logger.js'; | ||
|
|
||
| const DEVICE_AUTHORIZATION_PATH = '/realms/berget/protocol/openid-connect/auth/device'; |
There was a problem hiding this comment.
🟠 warning — Realm hardcoded ('/realms/berget/…') although AuthConfig.realm exists and issuer.ts composes it dynamically; also bypasses the OIDC discovery already used for PKCE.
| */ | ||
| export async function startDeviceFlow(options: DeviceFlowOptions): Promise<BrowserAuthResult> { | ||
| const debug = options.debug || logger.getLogLevel() >= LogLevel.DEBUG; | ||
| const baseUrl = `https://${new URL(options.config.keycloakUrl).host}`; |
There was a problem hiding this comment.
🟠 warning — Forcing https://${URL.host} drops URL path prefixes and breaks BERGET_KEYCLOAK_URL=http://localhost:8080 local-dev overrides that getAuthConfig() explicitly supports.
| * @returns `{ intervalSeconds }` to adjust polling (slow_down), or throws a | ||
| * user-readable Error for terminal states. | ||
| */ | ||
| export function handlePollError( |
There was a problem hiding this comment.
🟠 warning — No tests added for any device-flow logic (extractTokenResult, handlePollError, QR renderer, poll loop) despite 'Exported for tests' comments and pkce-flow.test.ts precedent.
|
|
||
| const DEVICE_AUTHORIZATION_PATH = '/realms/berget/protocol/openid-connect/auth/device'; | ||
| const TOKEN_PATH = '/realms/berget/protocol/openid-connect/token'; | ||
| const DEVICE_FLOW_SCOPE = 'openid email profile offline_access device-email-otp'; |
There was a problem hiding this comment.
🟡 nit — Adds custom scope device-email-otp absent from the PKCE scope; if it's not assigned to the berget-code Keycloak client the device request fails with invalid_scope — confirm it exists.
| deviceInfo: DeviceAuthorizationResponse; | ||
| }): Promise<BrowserAuthResult> { | ||
| const { baseUrl, clientId, deviceInfo } = input; | ||
| const deadline = Date.now() + deviceInfo.expires_in * 1000; |
There was a problem hiding this comment.
🟡 nit — Authorization response trusted via as-cast; a missing expires_in yields a NaN deadline so the poll loop never runs and login instantly 'times out'.
| .command(AuthService.COMMANDS.LOGIN) | ||
| .description('Log in to Berget') | ||
| .action(async () => { | ||
| .option('--device', 'Sign in with a device code (QR / another machine) instead of the browser') |
There was a problem hiding this comment.
🟡 nit — New --device flag is undocumented in the README's Authentication section, despite SSH/headless being its stated use case.
- berget auth login prompts method on TTY (this device / other device with QR); --device and non-TTY skip the prompt - device flow output: OSC 8 clickable link (cyan, underlined), bold user code, breathing room around the QR block - drop static 'Waiting for approval' line — the spinner owns status
There was a problem hiding this comment.
Follow-up review
New changes: 1 commit (80b71d3) — adds an interactive browser/device method prompt to berget auth login and polishes device-flow output (chalk styling, OSC 8 hyperlink, removes the redundant "Waiting for approval…" print, QR indent).
Previous findings:
- ⏳ critical
auth-sync.ts(QR/link wiped by clack spinner incode init) — still present:s.start()still runs beforeloginInteractive, andstartDeviceFlowstillconsole.logs the QR under the live spinner (confirmed the wizard spinner is clack's,clack-prompter.ts:55); only the device-flow's own "Waiting…" line was removed. - ⏳ warning
auth-service.ts(loginInteractivedocumented "Does NOT print to stdout" but prints) — still present. - ⏳ warning
device-flow.ts(hardcoded/realms/berget) — still present. - ⏳ warning
device-flow.ts:142(https://...hostbreaks local-dev http override) — still present. - ⏳ warning (no unit tests for device flow) — still present; helpers say "Exported for tests" but no test file references
device-flow. - ⏳ warning (
device-email-otpscope /invalid_scoperisk) — still present. - ⏳ nit (NaN deadline when
expires_inmissing) — still present. - ⏳ nit (README lacks
--device/ QR docs) — still present.
New findings:
- nit
src/services/auth-service.ts:51— the login-methodp.selectblock is a verbatim copy ofensureCliAuth's prompt (identical labels/values/hint); extract a shared const/helper — this commit already shows the two must be edited in lockstep. - nit
src/auth/oauth/device-flow.ts:171— OSC 8 hyperlink is emitted with no terminal-capability check; chalk's color-level detection doesn't strip/wrap OSC 8, so unsupported terminals (conhost, tmux <3.3) and piped output render rawESC]8;;garbage where the previous plain URL worked.
Done right: the new --device-skip logic (!method && process.stdin.isTTY) keeps non-TTY/CI behavior on the old silent browser default, and both prompt copies were updated together in this commit.
Inline findings
Berget AI (berget/zai-org/GLM-5.3-Flash) | PR #90
| // --device skip the prompt). | ||
| let method = options?.method; | ||
| if (!method && process.stdin.isTTY) { | ||
| const choice = await p.select<'browser' | 'device'>({ |
There was a problem hiding this comment.
🟡 nit — Method-choice prompt is a verbatim copy of ensureCliAuth's select in auth-sync.ts — extract a shared helper to avoid drift.
|
|
||
| const verificationUri = deviceInfo.verification_uri_complete ?? deviceInfo.verification_uri; | ||
|
|
||
| const linkedUrl = `\u001B]8;;${verificationUri}\u0007${verificationUri}\u001B]8;;\u0007`; |
There was a problem hiding this comment.
🟡 nit — OSC 8 hyperlink emitted without terminal-capability detection; chalk color-level checks don't cover OSC 8, so unsupported terminals/pipes show raw \u001B]8;; escape garbage.
- CRITICAL: device output was erased by the clack spinner (80ms repaint); browser path keeps the spinner, device path logs via prompter instead - endpoints built from config realm (keycloakUrl + realm) — supports BERGET_KEYCLOAK_URL=http://localhost:8080 and custom realms - validate device authorization response (missing expires_in gave a NaN deadline and instant 'timed out') - poll errors (denied/expired/server) become error results, not throws - loginInteractive doc updated: device method prints to stdout - 7 unit tests (poll errors, token extraction, QR renderer, happy path, endpoint construction); vitest excludes .pi/** (local tool artifacts) - README documents --device
There was a problem hiding this comment.
Follow-up review
New changes: 1 commit (a401527) — "fix: review feedback on device flow": spinner removed from the device path, endpoint built from config.realm/keycloakUrl, response validation added, handlePollError throws contained, doc comment corrected, 7 new unit tests, --device documented in README.
Previous findings:
- ✅ critical spinner wiping QR in
code init— fixed: device path no longer starts a spinner (auth-sync.ts:124logs a step then runs the flow bare; browser spinner kept, else-branch returns early). - ✅ warning
loginInteractivedoc mismatch — fixed (src/services/auth-service.ts:99-103now states the device method prints to stdout). - ✅ warning hardcoded
/realms/berget— fixed (device-flow.ts:141builds fromconfig.realm, with a regression test). - ✅ warning
https://forcing breaking local dev — fixed;keycloakUrlused as-is and the tests run againsthttp://localhost:8080. - ✅ warning no unit tests — fixed:
device-flow.test.ts(7 tests, verified passing locally;tsc --noEmitalso clean). - ⏳ warning
device-email-otpinvalid_scoperisk — still present: scope unchanged; the new comment only asserts the scope is assigned on stage/prod, and a rejection still surfaces as a genericKeycloak rejected device authorization (400). - ✅ nit NaN deadline when
expires_inmissing — fixed via response validation. - ✅ nit README lacks
--device— fixed; flag verified wired (src/commands/auth.ts:19). - ⏳ nit duplicated login-method prompt — still present (verbatim in
auth-service.ts:51andauth-sync.ts:110); this commit's if/else restructure even duplicated the post-login handling insideensureCliAuth, strengthening the case for a shared helper. - ⏳ nit OSC 8 hyperlink without terminal-capability check — still present (
device-flow.ts:179unchanged).
New findings:
- nit
src/auth/oauth/device-flow.ts:166— the new validation checksdevice_code/user_code/expires_inbut notinterval, which is consumed unvalidated at line 229: a non-numericintervalmakessleep(NaN)fire immediately, hot-polling the token endpoint until the deadline; validate/clamp it (e.g.const interval = Number.isFinite(deviceInfo.interval) && deviceInfo.interval >= 1 ? deviceInfo.interval : DEFAULT_POLL_INTERVAL_SECONDS), and consider also requiringverification_uriso a malformed response can't render an "undefined" QR.
Done right: the critical spinner fix is correctly structured with a regression-safe early return, and README's --device matches the actual CLI flag.
Inline findings
Berget AI (berget/zai-org/GLM-5.3-Flash) | PR #90
| if ( | ||
| typeof deviceInfo.device_code !== 'string' || | ||
| typeof deviceInfo.user_code !== 'string' || | ||
| typeof deviceInfo.expires_in !== 'number' |
There was a problem hiding this comment.
🟡 nit — Validation skips interval — a non-numeric value reaches sleep(NaN) and hot-polls the token endpoint until expiry; validate/clamp it (and require verification_uri).
Vad
Samma device flow som i opencode-pluginen (#39/#40) och pi-provider (#55), nu i CLI:n:
berget code init: prompt Browser vs Device code innan login (browser = oförändrat PKCE-flöde)berget auth login --device: flagga för skript/CIsrc/auth/oauth/device-flow.ts: RFC 8628 — QR i terminalen (halvblock, kvadratiska pixlar, EC 'L'),verification_uri_completeså koden är inbäddad i länken, poll medslow_down-backoff, icke-JSON-svar som retrybara, deadline frånexpires_inTester
8 nya; hela sviten 265 gröna.
auth-sync/init-testskript uppdaterade för den nya select-prompten.Manuellt verifierat
BERGET_AUTH_URL=https://auth.stage.berget.ai ./bin/berget.js auth login --device(QR scannas, inloggning går igenom)berget code init→ välja Device code