feat(auth): add QR code support for device auth flow (#942)

* feat(auth): add QR code support for device auth flow

* docs: update login QR code display hints for AI agent

* feat(auth): add ASCII QR code support for auth flow

* docs: add comments for login and auth helper functions

* chore: remove unused qrCodeToBase64 helper function

* fix(auth/login): clarify verification_url handling in login hint
This commit is contained in:
JackZhao10086
2026-05-18 20:17:15 +08:00
committed by GitHub
parent df4b657737
commit 7af616b9e5
2 changed files with 89 additions and 29 deletions

View File

@@ -5,12 +5,14 @@ package auth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
qrcode "github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
@@ -265,11 +267,15 @@ func authLoginRun(opts *LoginOptions) error {
if err := saveLoginRequestedScope(authResp.DeviceCode, finalScope); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] auth login: failed to cache requested scopes: %v\n", err)
}
qrCodeASCII, qrCodeBase64 := generateQRCode(authResp.VerificationUriComplete)
data := map[string]interface{}{
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the URL the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"qr_code_ascii": qrCodeASCII,
"qr_code_base64": qrCodeBase64,
"qr_code_display_hint": msg.QRCodeDisplayHint,
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show qr_code_base64 as an image and verification_url exactly as returned by the CLI to the user. If your agent cannot display images, show qr_code_ascii (ASCII QR code) or verification_url instead. Treat verification_url as an opaque string: Do not URL-encode or decode it, do not normalize, rewrite, do not add %%20, spaces, or punctuation, do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. For agent harnesses that only deliver final turn messages, make the image (if displayable) and verification_url the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -285,8 +291,12 @@ func authLoginRun(opts *LoginOptions) error {
// stdout into a JSON parser sees it without stream-mixing surprises),
// text mode prints to stderr (alongside the URL prompt).
if opts.JSON {
qrCodeASCII, qrCodeBase64 := generateQRCode(authResp.VerificationUriComplete)
data := map[string]interface{}{
"event": "device_authorization",
"qr_code_ascii": qrCodeASCII,
"qr_code_base64": qrCodeBase64,
"qr_code_display_hint": msg.QRCodeDisplayHint,
"verification_uri": authResp.VerificationUri,
"verification_uri_complete": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
@@ -299,7 +309,21 @@ func authLoginRun(opts *LoginOptions) error {
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
}
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
// Branch on TTY: human-friendly copy in interactive terminals,
// For non-TTY (AI agent callers), output text with both ASCII and base64 QR code.
fmt.Fprintf(f.IOStreams.ErrOut, msg.ScanQRCode)
qrCodeASCII, qrCodeBase64 := generateQRCode(authResp.VerificationUriComplete)
fmt.Fprint(f.IOStreams.ErrOut, qrCodeASCII)
if !f.IOStreams.IsTerminal {
if qrCodeBase64 != "" {
fmt.Fprintf(f.IOStreams.ErrOut, "[BASE64 QR CODE START]\n")
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", qrCodeBase64)
fmt.Fprintf(f.IOStreams.ErrOut, "[BASE64 QR CODE END]\n")
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.QRCodeDisplayHint)
}
}
fmt.Fprintln(f.IOStreams.ErrOut)
fmt.Fprintf(f.IOStreams.ErrOut, msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
fmt.Fprintln(f.IOStreams.ErrOut, msg.AgentTimeoutHint)
}
@@ -452,6 +476,8 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile updates the profile's user list to contain only the newly
// authenticated user, removing any previously stored tokens for other users.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
@@ -477,6 +503,7 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName locates an AppConfig by profile name from the multi-app configuration.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
@@ -668,3 +695,15 @@ func applyExcludeScopes(requested string, excludes []string) (string, []string)
}
return joinSortedScopeSet(kept), nil
}
// generateQRCode creates both ASCII art and base64-encoded PNG versions of a QR code
// for the given verification URL. Returns empty strings if generation fails.
func generateQRCode(verificationURL string) (ascii string, base64Str string) {
if qr, err := qrcode.New(verificationURL, qrcode.Medium); err == nil {
ascii = qr.ToSmallString(true)
if pngBytes, err := qr.PNG(256); err == nil {
base64Str = base64.StdEncoding.EncodeToString(pngBytes)
}
}
return
}

View File

@@ -23,6 +23,7 @@ type loginMsg struct {
OpenURL string
WaitingAuth string
AgentTimeoutHint string
AgentNoJSONHint string
AuthSuccess string
LoginSuccess string
AuthorizedUser string
@@ -32,6 +33,12 @@ type loginMsg struct {
NewlyGrantedScopes string
NoScopes string
StatusHint string
// QR code messages
ScanQRCode string // header shown above QR code (TTY)
ScanOrOpenLink string // post-QR alt link prompt ("or open...") (TTY)
OpenLinkNonTTY string // primary link prompt (non-TTY/AI)
WaitingForScanNonTTY string // passive waiting indicator (non-TTY/AI)
QRCodeDisplayHint string // hint for displaying qr_code_base64
// Non-interactive hint (no flags)
HintHeader string
@@ -57,18 +64,24 @@ var loginMsgZh = &loginMsg{
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息原样发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 只会把最终回复发给用户,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code、qr_code_base64 和 verification_url二维码图片(解码 qr_code_base64 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 `lark-cli auth login --device-code <code>` 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试;每次重启会作废上一轮的 device code导致用户授权链接失效。若你的 agent 无法显示图片,只展示 verification_url 即可。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码或解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
AgentNoJSONHint: "[AI agent] 如需获取二维码图片,请添加 --json 或使用 `lark-cli auth login --no-wait --json` 获取 qr_code_base64 字段。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
ScopeMismatch: "授权结果异常: 以下请求 scopes 未被授予: %s",
ScopeHint: "以上结果是本次授权请求用户最终确认后的结果请勿持续重试Scopes 未授予的原因是多样的,如 scope 被禁用;具体原因已通过授权页提示用户。可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
RequestedScopes: " 本次请求 scopes: %s\n",
NewlyGrantedScopes: " 本次新授予 scopes: %s\n",
NoScopes: "(空)",
StatusHint: "可执行 `lark-cli auth status` 查看账号当前已授予的全部 scopes",
ScanQRCode: "\n使用飞书 / Lark 扫码授权:\n\n",
ScanOrOpenLink: "\n或打开以下链接完成授权\n",
OpenLinkNonTTY: "\n打开以下链接授权:\n\n",
WaitingForScanNonTTY: "等待授权...",
QRCodeDisplayHint: "[AI Agent] 请尽可能展示 qr_code_base64 图片qr_code_base64 包含 base64 编码的 PNG 图片解码后以图片形式显示256x256 像素)便于用户扫码授权。如果 agent 无法显示图片,可以展示 qr_code_asciiASCII 二维码),并且展示 verification_url 让用户可以手动打开。",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
@@ -93,18 +106,24 @@ var loginMsgEn = &loginMsg{
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness only delivers final turn messages, use `lark-cli auth login --no-wait --json` to get device_code, qr_code_base64 and verification_url, present the QR code image (decode qr_code_base64) and verification_url to the user as the final message of this turn, then end the turn; after the user replies that they authorized, run `lark-cli auth login --device-code <code>` in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless. If your agent cannot display images, show verification_url only. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AgentNoJSONHint: "[AI agent] To get QR code image, add --json or use `lark-cli auth login --no-wait --json` to get the qr_code_base64 field.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
ScopeMismatch: "authorization result is abnormal: these requested scopes were not granted: %s",
ScopeHint: "The result above is the user's final confirmation for this authorization request. Do not retry continuously. Scopes may be not granted for various reasons, such as a scope being disabled. The specific reason has already been shown to the user on the authorization page. Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
RequestedScopes: " Requested scopes: %s\n",
NewlyGrantedScopes: " Newly granted scopes: %s\n",
NoScopes: "(none)",
StatusHint: "Run `lark-cli auth status` to inspect all scopes currently granted to the account.",
ScanQRCode: "\nScan the QR code with Feishu/Lark to authorize:\n\n",
ScanOrOpenLink: "\nOr open the link below to complete authorization:\n",
OpenLinkNonTTY: "\nOpen the link below to authorize:\n\n",
WaitingForScanNonTTY: "Waiting for authorization...",
QRCodeDisplayHint: "[AI Agent] Please display qr_code_base64 image if at all possible! qr_code_base64 contains a base64-encoded PNG image. Decode it and display as an image (256x256 pixels) for easy QR code scanning. If your agent cannot display images, you can show qr_code_ascii (ASCII QR code) and display verification_url for users to open manually.",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",
@@ -114,6 +133,8 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the specified language.
// Supports "zh" for Chinese and "en" for English. Defaults to Chinese.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn