From 6c7ee71d36e4c9d74815af98166698a7771d8b43 Mon Sep 17 00:00:00 2001 From: Jakob Karlstrand Date: Fri, 11 Sep 2026 17:26:46 +0200 Subject: [PATCH 1/2] feat: add OAuth device authorization flow with QR code Adds a second oauth method 'Berget Code Seat - QR or device code' for headless environments (SSH/CI) where the PKCE magic link cannot open a browser. Renders a scannable half-block QR code in the instructions dialog, links verification_uri_complete (user_code embedded) via the url field, and polls the token endpoint with slow_down/expiry handling. --- package-lock.json | 361 +++++++++++++++++++++++++++++++++++++- package.json | 4 +- src/plugin.ts | 8 +- src/plugin/device-flow.ts | 287 ++++++++++++++++++++++++++++++ 4 files changed, 656 insertions(+), 4 deletions(-) create mode 100644 src/plugin/device-flow.ts diff --git a/package-lock.json b/package-lock.json index e3db05b..1a3f421 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "version": "1.0.28", "license": "MIT", "dependencies": { - "@opencode-ai/plugin": "1.3.13" + "@opencode-ai/plugin": "1.3.13", + "qrcode": "^1.5.4" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/bun": "latest", "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^5.9.0", @@ -675,6 +677,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -1216,6 +1228,15 @@ "node": ">= 0.8" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001792", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", @@ -1326,6 +1347,114 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1380,6 +1509,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1397,6 +1535,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.353", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", @@ -1942,6 +2086,15 @@ "dev": true, "license": "MIT" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -2738,11 +2891,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2795,6 +2956,15 @@ "node": ">=4" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -2873,6 +3043,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/refa": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", @@ -2923,6 +3110,21 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -3009,6 +3211,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3512,6 +3720,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3557,6 +3771,12 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yaml": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", @@ -3574,6 +3794,143 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 449da75..2338d53 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@eslint/js": "^10.0.1", "@types/bun": "latest", "@types/node": "^22.0.0", + "@types/qrcode": "^1.5.6", "eslint": "^10.3.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-perfectionist": "^5.9.0", @@ -57,7 +58,8 @@ "typescript": "^5.0.0" }, "dependencies": { - "@opencode-ai/plugin": "1.3.13" + "@opencode-ai/plugin": "1.3.13", + "qrcode": "^1.5.4" }, "keywords": [ "opencode", diff --git a/src/plugin.ts b/src/plugin.ts index 5a0475f..c7a7f31 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -18,6 +18,7 @@ import type { Hooks, OAuthAuthDetails, PluginInput } from './plugin/types'; import { BERGET_PROVIDER_ID, getInferenceUrl } from './constants'; import { accessTokenExpired, isOAuthAuth } from './plugin/auth'; import { logDebug, logError } from './plugin/debug'; +import { createDeviceAuthorizeMethod } from './plugin/device-flow'; import { fetchBergetModels } from './plugin/models'; import { createPkceAuthorizeMethod } from './plugin/pkce-flow'; import { refreshAccessTokenDirect } from './plugin/token'; @@ -131,7 +132,12 @@ export const BergetAuthPlugin = async ({ client }: PluginInput): Promise methods: [ { authorize: createPkceAuthorizeMethod(), - label: 'Use Berget Code plan', + label: 'Berget Code Seat - Magic link', + type: 'oauth' as const, + }, + { + authorize: createDeviceAuthorizeMethod(), + label: 'Berget Code Seat - QR or device code', type: 'oauth' as const, }, { diff --git a/src/plugin/device-flow.ts b/src/plugin/device-flow.ts new file mode 100644 index 0000000..d4fd9cd --- /dev/null +++ b/src/plugin/device-flow.ts @@ -0,0 +1,287 @@ +/** + * OAuth 2.0 Device Authorization Grant for Berget + * Based on RFC 8628 + * + * Used in headless environments (SSH, CI) where a local callback + * server is not feasible. The user authenticates on another device. + */ + +import QRCode from 'qrcode'; + +import type { AuthOAuthResult, AuthorizeResult } from './types'; + +import { getKeycloakRealm, getKeycloakUrl, KEYCLOAK_CLIENT_ID } from '../constants'; +import { logDebug } from './debug'; + +const DEVICE_AUTHORIZATION_ENDPOINT_PATH = '/protocol/openid-connect/auth/device'; +const TOKEN_ENDPOINT_PATH = '/protocol/openid-connect/token'; + +const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const MAX_POLL_INTERVAL_SECONDS = 30; + +interface DeviceAuthorizationResponse { + device_code: string; + expires_in: number; + interval?: number; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; +} + +interface TokenErrorResponse { + error?: string; + error_description?: string; +} + +export function createDeviceAuthorizeMethod(): ( + inputs?: Record, +) => Promise { + return executeDeviceAuthorization; +} + +async function buildInstructions( + deviceInfo: DeviceAuthorizationResponse, + verificationUri: string, +): Promise { + const qrCode = await generateTerminalQrCode(verificationUri); + const validMinutes = Math.round(deviceInfo.expires_in / 60); + + const dividerLabel = ' or '; + const dividerDashCount = Math.floor((DIALOG_CONTENT_WIDTH - dividerLabel.length) / 2); + const divider = `${'─'.repeat(dividerDashCount)}${dividerLabel}${'─'.repeat(dividerDashCount)}`; + + const lines = [ + 'Scan with your phone:', + '', + ...qrCode.split('\n').map((line) => centerLine(line)), + '', + divider, + '', + 'Or open the link below — the code is included.', + '', + `Valid for ${validMinutes} minutes.`, + ]; + return lines.join('\n'); +} + +/** + * Approximate content width of the OpenCode instructions dialog. + * Used to center the QR code and user code box. + */ +const DIALOG_CONTENT_WIDTH = 56; + +function centerLine(line: string): string { + const pad = Math.max(0, Math.floor((DIALOG_CONTENT_WIDTH - line.length) / 2)); + return ' '.repeat(pad) + line; +} + +async function executeDeviceAuthorization( + _inputs?: Record, +): Promise { + const baseUrl = `${getKeycloakUrl()}/realms/${getKeycloakRealm()}`; + + let deviceInfo: DeviceAuthorizationResponse; + try { + const response = await fetch(`${baseUrl}${DEVICE_AUTHORIZATION_ENDPOINT_PATH}`, { + body: new URLSearchParams({ + client_id: KEYCLOAK_CLIENT_ID, + scope: 'openid email profile offline_access device-email-otp', + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }); + + if (!response.ok) { + const errorText = await response.text(); + logDebug(`Device authorization failed: ${errorText}`); + throw new Error(`Keycloak rejected device authorization (${response.status})`); + } + + deviceInfo = (await response.json()) as DeviceAuthorizationResponse; + } catch (error) { + return { + callback: async (): Promise => ({ + error: `Failed to start device flow: ${error instanceof Error ? error.message : String(error)}`, + type: 'failed', + }), + instructions: 'Device flow could not be started.', + method: 'auto' as const, + url: '', + }; + } + + logDebug( + `Device flow started, user_code=${deviceInfo.user_code}, expires_in=${deviceInfo.expires_in}`, + ); + + const verificationUri = deviceInfo.verification_uri_complete ?? deviceInfo.verification_uri; + + const instructions = await buildInstructions(deviceInfo, verificationUri); + + return { + callback: async (): Promise => { + return pollForTokens(baseUrl, deviceInfo); + }, + instructions, + method: 'auto' as const, + url: verificationUri, + }; +} + +function extractTokenResult(data: Record): AuthOAuthResult | undefined { + if (!(typeof data.access_token === 'string' && typeof data.expires_in === 'number')) { + return undefined; + } + if (typeof data.refresh_token !== 'string') { + logDebug('Token poll returned malformed body'); + return { error: 'Invalid token response from authorization server', type: 'failed' }; + } + + logDebug('Successfully obtained tokens via device flow'); + + return { + access: data.access_token, + expires: Date.now() + data.expires_in * 1000, + refresh: data.refresh_token, + type: 'success', + }; +} + +function formatPollError(errorData: TokenErrorResponse): string { + if (!errorData.error_description) { + return `Device flow failed: ${errorData.error}`; + } + const description = errorData.error_description; + return `Device flow failed: ${errorData.error} — ${description}`; +} + +const QR_QUIET_ZONE_MODULES = 2; + +/** + * Renders the QR matrix manually as half-block pairs: one character + * covers two vertical modules using ▀/▄/█/space. Full-width spaces and + * double characters get collapsed by the OpenCode TUI, so this + * half-block encoding is the only reliable rendering there. + * Light blocks on the terminal's dark background — scannable on dark themes. + */ +async function generateTerminalQrCode(data: string): Promise { + const code = QRCode.create(data, { errorCorrectionLevel: 'M' }); + const size = code.modules.size; + + const rows: string[] = []; + const rowWidth = size + QR_QUIET_ZONE_MODULES * 2; + const quietRow = ' '.repeat(rowWidth); + + rows.length = QR_QUIET_ZONE_MODULES; + rows.fill(quietRow); + + for (let r = 0; r < size; r += 2) { + let row = ' '.repeat(QR_QUIET_ZONE_MODULES); + for (let c = 0; c < size; c += 1) { + const top = code.modules.get(r, c) === 1; + const bottom = r + 1 < size && code.modules.get(r + 1, c) === 1; + if (top && bottom) { + row += '█'; + } else if (top) { + row += '▀'; + } else if (bottom) { + row += '▄'; + } else { + row += ' '; + } + } + rows.push(row); + } + + for (let index = 0; index < QR_QUIET_ZONE_MODULES; index += 1) { + rows.push(quietRow); + } + + return rows.join('\n'); +} + +function handleTokenPollError( + errorData: TokenErrorResponse, + intervalSeconds: number, +): { interval?: number; result?: AuthOAuthResult } { + switch (errorData.error) { + case 'access_denied': { + return { result: { error: 'Sign-in was denied in the browser.', type: 'failed' } }; + } + case 'authorization_pending': { + return {}; + } + case 'expired_token': { + return { + result: { error: 'Device code expired. Please try signing in again.', type: 'failed' }, + }; + } + case 'slow_down': { + const newInterval = Math.min( + intervalSeconds + DEFAULT_POLL_INTERVAL_SECONDS, + MAX_POLL_INTERVAL_SECONDS, + ); + logDebug(`Received slow_down, new interval: ${newInterval}s`); + return { interval: newInterval }; + } + default: { + return { result: { error: formatPollError(errorData), type: 'failed' } }; + } + } +} + +async function pollForTokens( + baseUrl: string, + deviceInfo: DeviceAuthorizationResponse, +): Promise { + const deadline = Date.now() + deviceInfo.expires_in * 1000; + let intervalSeconds = deviceInfo.interval ?? DEFAULT_POLL_INTERVAL_SECONDS; + + while (Date.now() < deadline) { + await sleep(intervalSeconds * 1000); + + let response: Response; + try { + response = await fetch(`${baseUrl}${TOKEN_ENDPOINT_PATH}`, { + body: new URLSearchParams({ + client_id: KEYCLOAK_CLIENT_ID, + device_code: deviceInfo.device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }); + } catch (error) { + logDebug( + `Token poll request failed, retrying: ${error instanceof Error ? error.message : String(error)}`, + ); + continue; + } + + const data = (await response.json()) as Record; + + const success = extractTokenResult(data); + if (success) { + return success; + } + const errorData = data as unknown as TokenErrorResponse; + + const action = handleTokenPollError(errorData, intervalSeconds); + if (action.interval !== undefined) { + intervalSeconds = action.interval; + } + if (action.result) { + return action.result; + } + } + + return { error: 'Authentication timed out. Please try signing in again.', type: 'failed' }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} From 79ed7ec32bcec08addb83f7c1c320987a8a8d1e8 Mon Sep 17 00:00:00 2001 From: Jakob Karlstrand Date: Fri, 11 Sep 2026 17:29:57 +0200 Subject: [PATCH 2/2] fix: handle non-JSON token poll responses, add device-flow tests Review feedback on #39: - response.json() in the poll loop could throw on non-JSON bodies (e.g. a 502 HTML page from the gateway); extracted fetchTokenPollBody which treats transport/parse errors as retryable until the deadline - add unit tests for extractTokenResult and handleTokenPollError --- src/plugin/device-flow.test.ts | 77 ++++++++++++++++ src/plugin/device-flow.ts | 160 +++++++++++++++++++-------------- 2 files changed, 169 insertions(+), 68 deletions(-) create mode 100644 src/plugin/device-flow.test.ts diff --git a/src/plugin/device-flow.test.ts b/src/plugin/device-flow.test.ts new file mode 100644 index 0000000..0f833fd --- /dev/null +++ b/src/plugin/device-flow.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { extractTokenResult, handleTokenPollError } from './device-flow'; + +vi.mock('../constants', () => ({ + getKeycloakRealm: () => 'berget', + getKeycloakUrl: () => 'https://auth.berget.ai', + KEYCLOAK_CLIENT_ID: 'berget-code', +})); + +describe('extractTokenResult', () => { + it('returns undefined when the body is not a token response', () => { + expect(extractTokenResult({ error: 'authorization_pending' })).toBeUndefined(); + expect(extractTokenResult({ access_token: 'a' })).toBeUndefined(); + expect(extractTokenResult({ access_token: 'a', expires_in: '300' })).toBeUndefined(); + }); + + it('fails when refresh_token is missing from a token response', () => { + const result = extractTokenResult({ access_token: 'a', expires_in: 300 }); + expect(result).toEqual({ + error: 'Invalid token response from authorization server', + type: 'failed', + }); + }); + + it('maps a valid token response to a success result', () => { + const before = Date.now(); + const result = extractTokenResult({ + access_token: 'access', + expires_in: 300, + refresh_token: 'refresh', + }); + + expect(result).toMatchObject({ + access: 'access', + refresh: 'refresh', + type: 'success', + }); + if (result?.type !== 'success' || !('expires' in result)) { + throw new Error('expected success result'); + } + expect(result.expires).toBeGreaterThanOrEqual(before + 300_000); + expect(result.expires).toBeLessThanOrEqual(Date.now() + 300_000); + }); +}); + +describe('handleTokenPollError', () => { + it('keeps polling on authorization_pending', () => { + expect(handleTokenPollError({ error: 'authorization_pending' }, 5)).toEqual({}); + }); + + it('increases the interval on slow_down, capped at 30s', () => { + expect(handleTokenPollError({ error: 'slow_down' }, 5)).toEqual({ interval: 10 }); + expect(handleTokenPollError({ error: 'slow_down' }, 28)).toEqual({ interval: 30 }); + }); + + it('fails with a retry hint on expired_token', () => { + expect(handleTokenPollError({ error: 'expired_token' }, 5)).toEqual({ + result: { error: 'Device code expired. Please try signing in again.', type: 'failed' }, + }); + }); + + it('fails on access_denied', () => { + expect(handleTokenPollError({ error: 'access_denied' }, 5)).toEqual({ + result: { error: 'Sign-in was denied in the browser.', type: 'failed' }, + }); + }); + + it('includes error_description for unknown errors when present', () => { + expect(handleTokenPollError({ error: 'server_error' }, 5)).toEqual({ + result: { error: 'Device flow failed: server_error', type: 'failed' }, + }); + expect(handleTokenPollError({ error: 'server_error', error_description: 'boom' }, 5)).toEqual({ + result: { error: 'Device flow failed: server_error — boom', type: 'failed' }, + }); + }); +}); diff --git a/src/plugin/device-flow.ts b/src/plugin/device-flow.ts index d4fd9cd..22219a6 100644 --- a/src/plugin/device-flow.ts +++ b/src/plugin/device-flow.ts @@ -39,6 +39,55 @@ export function createDeviceAuthorizeMethod(): ( return executeDeviceAuthorization; } +export function extractTokenResult(data: Record): AuthOAuthResult | undefined { + if (!(typeof data.access_token === 'string' && typeof data.expires_in === 'number')) { + return undefined; + } + if (typeof data.refresh_token !== 'string') { + logDebug('Token poll returned malformed body'); + return { error: 'Invalid token response from authorization server', type: 'failed' }; + } + + logDebug('Successfully obtained tokens via device flow'); + + return { + access: data.access_token, + expires: Date.now() + data.expires_in * 1000, + refresh: data.refresh_token, + type: 'success', + }; +} + +export function handleTokenPollError( + errorData: TokenErrorResponse, + intervalSeconds: number, +): { interval?: number; result?: AuthOAuthResult } { + switch (errorData.error) { + case 'access_denied': { + return { result: { error: 'Sign-in was denied in the browser.', type: 'failed' } }; + } + case 'authorization_pending': { + return {}; + } + case 'expired_token': { + return { + result: { error: 'Device code expired. Please try signing in again.', type: 'failed' }, + }; + } + case 'slow_down': { + const newInterval = Math.min( + intervalSeconds + DEFAULT_POLL_INTERVAL_SECONDS, + MAX_POLL_INTERVAL_SECONDS, + ); + logDebug(`Received slow_down, new interval: ${newInterval}s`); + return { interval: newInterval }; + } + default: { + return { result: { error: formatPollError(errorData), type: 'failed' } }; + } + } +} + async function buildInstructions( deviceInfo: DeviceAuthorizationResponse, verificationUri: string, @@ -130,25 +179,6 @@ async function executeDeviceAuthorization( }; } -function extractTokenResult(data: Record): AuthOAuthResult | undefined { - if (!(typeof data.access_token === 'string' && typeof data.expires_in === 'number')) { - return undefined; - } - if (typeof data.refresh_token !== 'string') { - logDebug('Token poll returned malformed body'); - return { error: 'Invalid token response from authorization server', type: 'failed' }; - } - - logDebug('Successfully obtained tokens via device flow'); - - return { - access: data.access_token, - expires: Date.now() + data.expires_in * 1000, - refresh: data.refresh_token, - type: 'success', - }; -} - function formatPollError(errorData: TokenErrorResponse): string { if (!errorData.error_description) { return `Device flow failed: ${errorData.error}`; @@ -159,6 +189,47 @@ function formatPollError(errorData: TokenErrorResponse): string { const QR_QUIET_ZONE_MODULES = 2; +/** + * Single token poll request. Returns undefined on transport errors or + * non-JSON bodies (e.g. a 502 HTML page from the gateway in front of + * Keycloak) so the caller keeps retrying until the deadline. + */ +async function fetchTokenPollBody( + baseUrl: string, + deviceInfo: DeviceAuthorizationResponse, +): Promise | undefined> { + let response: Response; + try { + response = await fetch(`${baseUrl}${TOKEN_ENDPOINT_PATH}`, { + body: new URLSearchParams({ + client_id: KEYCLOAK_CLIENT_ID, + device_code: deviceInfo.device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + }); + } catch (error) { + logDebug( + `Token poll request failed, retrying: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } + + try { + return (await response.json()) as Record; + } catch (error) { + logDebug( + `Token poll returned non-JSON body (status ${response.status}), retrying: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return undefined; + } +} + /** * Renders the QR matrix manually as half-block pairs: one character * covers two vertical modules using ▀/▄/█/space. Full-width spaces and @@ -202,36 +273,6 @@ async function generateTerminalQrCode(data: string): Promise { return rows.join('\n'); } -function handleTokenPollError( - errorData: TokenErrorResponse, - intervalSeconds: number, -): { interval?: number; result?: AuthOAuthResult } { - switch (errorData.error) { - case 'access_denied': { - return { result: { error: 'Sign-in was denied in the browser.', type: 'failed' } }; - } - case 'authorization_pending': { - return {}; - } - case 'expired_token': { - return { - result: { error: 'Device code expired. Please try signing in again.', type: 'failed' }, - }; - } - case 'slow_down': { - const newInterval = Math.min( - intervalSeconds + DEFAULT_POLL_INTERVAL_SECONDS, - MAX_POLL_INTERVAL_SECONDS, - ); - logDebug(`Received slow_down, new interval: ${newInterval}s`); - return { interval: newInterval }; - } - default: { - return { result: { error: formatPollError(errorData), type: 'failed' } }; - } - } -} - async function pollForTokens( baseUrl: string, deviceInfo: DeviceAuthorizationResponse, @@ -242,28 +283,11 @@ async function pollForTokens( while (Date.now() < deadline) { await sleep(intervalSeconds * 1000); - let response: Response; - try { - response = await fetch(`${baseUrl}${TOKEN_ENDPOINT_PATH}`, { - body: new URLSearchParams({ - client_id: KEYCLOAK_CLIENT_ID, - device_code: deviceInfo.device_code, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - }).toString(), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - method: 'POST', - }); - } catch (error) { - logDebug( - `Token poll request failed, retrying: ${error instanceof Error ? error.message : String(error)}`, - ); + const data = await fetchTokenPollBody(baseUrl, deviceInfo); + if (!data) { continue; } - const data = (await response.json()) as Record; - const success = extractTokenResult(data); if (success) { return success;