iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

許多 CLI 工具(如 Claude Code)在執行前需先驗證使用者身分。在設計登入機制時,關鍵在於明文密碼是否有流經 CLI Process。

最直接的做法是在終端機輸入帳號密碼,由 CLI 轉送給 Auth Server 驗證:

https://ithelp.ithome.com.tw/upload/images/20261003/20111896iWA8cgRZ3i.png

這條路徑上,密碼會先進入 CLI Process 的記憶體,再由 CLI 送出。CLI 本身、它引用的套件,以及記錄 log 的程式碼,都有機會接觸到密碼。

OAuth 2.0 把輸入密碼的步驟移到瀏覽器登入頁,驗證成功後,Auth Server 只把 Access Token 交給 CLI:

https://ithelp.ithome.com.tw/upload/images/20261003/20111896Vg4zdlqe1b.png

使用者在瀏覽器完成密碼、MFA 或 SSO 驗證,並指定要授予 CLI 的權限範圍(scope)。密碼完全不離開瀏覽器與 Auth Server,CLI Process 從頭到尾只會拿到 Access Token。

這種做法讓服務端能控制 Access Token 的有效期限與權限範圍,並允許使用者隨時單獨撤銷某一個 CLI 的授權。即使 token 外流,也能獨立撤銷,不必更換帳號密碼或中斷其他已登入的應用。

以 GitHub CLI(gh)為例,執行 gh auth login 時,GitHub CLI 不會在終端機詢問密碼,而是顯示一次性代碼,並引導使用者開啟 GitHub 網頁授權:

$ gh auth login

! First copy your one-time code: 449E-A75B
Press Enter to open https://github.com/login/device in your browser...

接著在開啟的網頁上,輸入這個one-time code,接著完成登入流程。
https://ithelp.ithome.com.tw/upload/images/20261003/20111896ocjWk159LD.png

瀏覽器完成授權後,CLI 取得 access token,後續的 gh repo view 或 gh pr create 便能帶著 token 呼叫 GitHub API。

https://ithelp.ithome.com.tw/upload/images/20261003/20111896NqayyoLBfO.png

Device Flow 與 Authorization Code + PKCE

了解 GitHub CLI 的登入體驗後,要在自己的 CLI 實作 OAuth 2.0 授權,主要依據執行環境選擇以下兩種標準流程之一:

  • Device Authorization Grant(Device Flow):CLI 顯示驗證網址與使用者驗證碼後,由 CLI 主動定期輪詢(Polling)token endpoint 查詢授權結果。由於不需在 CLI 機器監聽 local port,適合 SSH 遠端連線、Server 或無 GUI 裝置。
  • Authorization Code + PKCE:CLI 暫時啟動本地 callback Server(監聽 127.0.0.1),瀏覽器完成授權後直接重導向(Redirect)將結果傳回 CLI。適用於具備本機瀏覽器的桌機與筆電,省去輪詢等待。

兩者均在瀏覽器進行身分驗證,主要差異在於 Auth Server 如何將授權結果傳回 CLI。

對應的可執行 Go 範例放在 ../cli-sample/ 目錄:goAuthSample/ 負責 Device Flow,goAuthPKCE/ 則負責 Authorization Code + PKCE。

兩個專案各自包含本機 mock Auth Server,會將 authorization code、token 與使用者資料存在記憶體中。

Device Flow

Device Flow 適合 CLI 所在的機器無法開啟瀏覽器,或 CLI 不適合監聽 callback port 的情境。CLI 顯示網址與驗證碼後,使用者可以拿手機或另一台電腦開啟該網址:

請在瀏覽器開啟:
  https://example.com/activate
並輸入代碼:ABCD-EFGH

等待授權中...

CLI 不需要知道瀏覽器在哪裡,只要按照 Auth Server 指定的 interval 輪詢 token endpoint。使用者完成授權後,下一次輪詢就會收到 token。

首先在goAuthSample執行go run server/main.go 啟動 Device Flow 的 Auth Server:

go run server/main.go

Server 監聽 127.0.0.1:8080,提供四個 endpoint:

  • POST /device/authorization:產生 device_code、user_code、有效期限與輪詢間隔。
  • GET/POST /activate:讓使用者輸入驗證碼,進行登入與同意授權。
  • POST /token:供 CLI 查詢授權狀態並交換 Token。
  • GET /me:驗證 Bearer Token 並回傳使用者資料。

在第二個終端機上執行 CLI:

go run client/main.go

CLI 呼叫 POST /device/authorization 後會顯示:

正在向 auth server 申請授權...

請在瀏覽器開啟:
  http://127.0.0.1:8080/activate
並輸入代碼:ABCD-EFGH

等待授權中

由於 Mock Auth Server 僅監聽本機位址(127.0.0.1 / Loopback),因此測試時需在同一台電腦開啟 http://127.0.0.1:8080/activate 。真實環境下,Device Flow 的 verification URI 會指向遠端 Auth Server,使用者便能直接改用手機或其他裝置開啟並完成授權。

在網頁輸入 CLI 顯示的代碼:

https://ithelp.ithome.com.tw/upload/images/20261003/20111896dO6jgab1VH.png

再使用任測試帳號登入:

bob / bob456

https://ithelp.ithome.com.tw/upload/images/20261003/20111896suAwRaeoyE.png

瀏覽器完成授權後,CLI 的下一次輪詢會取得 access token,並用 Bearer token 呼叫 GET /me:

✓ 授權成功!
  access_token : tok_...
  token_type   : Bearer
  scope        : read write

受保護 API 回應 (GET /me):
{"client_id":"my-cli-app","scope":"read write","username":"bob"}

執行期間的資料流如下:

https://ithelp.ithome.com.tw/upload/images/20261003/20111896x4mSE3UZiF.png

在程式實作上,client/main.go 與 server/main.go 依據這套資料流分工:

  • 申請代碼:Client 呼叫 requestDeviceAuth() 發送請求,Server 由 handleDeviceAuthorization() 產生驗證碼。
  • 瀏覽器授權:使用者開啟網頁時,由 Server 的 handleActivate() 處理登入與授權(Client 此階段無須處置)。
  • 等待 Token:Client 透過 pollToken() 定期輪詢,Server 由 handleToken() 檢查狀態並發放 Token。
  • 呼叫 API:Client 透過 callProtectedAPI() 攜帶 Bearer Token 請求,Server 由 handleMe() 驗證身分並回傳使用者資料。

在發送與驗證請求時,Auth Server 會產生兩種用途不同的代碼:

  • device_code:由 CLI 內部保留,用於向 Auth Server 輪詢交換 Token。此為輪詢憑證,切勿寫入 Log 或顯示給使用者。
  • user_code:顯示於終端機供使用者複製,並在瀏覽器驗證頁面輸入(格式通常簡短易讀,如 ABCD-EFGH)。

Auth Server 在後端將這兩組代碼對應至同一筆授權狀態。當使用者於網頁輸入 user_code 並完成授權後,CLI 透過 device_code 輪詢即可順利取得 Access Token。

在使用者開啟瀏覽器登入的同時,CLI 無法預知使用者何時完成操作,因此必須定期向 Auth Server 發送 POST /token 詢問「使用者授權完了嗎?」。

Auth Server 在一開始傳回的 interval(例如 5 秒)規定了查詢頻率。CLI 在輪詢過程中,會依據 Server 回傳的狀態調整行為:

  • 等待中(authorization_pending):使用者還在網頁輸入密碼或同意授權。這是正常的等待過程,CLI 依據 interval 間隔繼續下一輪查詢。
  • 查詢過於頻繁(slow_down):Server 提示 CLI 發送請求過快,CLI 必須自動拉長查詢間隔(如每次增加 5 秒),避免對 Server 造成負擔。
  • 使用者拒絕(access_denied):使用者在網頁點選取消授權,CLI 應立即結束輪詢並顯示授權失敗。
  • 驗證碼過期(expired_token):使用者超過有效時間未完成授權,CLI 應結束輪詢並提示使用者重新發起登入。

Authorization Code + PKCE 透過 callback 接收授權結果

Authorization Code + PKCE 適合在具備本機瀏覽器的個人電腦(如桌機或筆電)上執行。CLI 可以在 127.0.0.1 監聽隨機埠,開啟瀏覽器後等待重導向(Redirect)。使用者完成登入時,Auth Server 把短效的 authorization code 重導向傳回 callback,CLI 隨即拿 code 交換 Token,不需要定期輪詢。

先啟動 PKCE 的 Auth Server,在goAuthPKCE執行:

go run server/main.go

Server 監聽 127.0.0.1:8081,提供 /authorize、/token 與 /me。在第二個終端機執行 CLI:

go run client/main.go

CLI 先產生 verifier、challenge 與 state,再讓作業系統選擇可用的 callback port:

正在啟動本地授權回調伺服器...
回調位址:http://127.0.0.1:60108/callback

正在開啟瀏覽器授權頁面...
  http://127.0.0.1:8081/authorize?client_id=my-cli-app&code_challenge=...

若瀏覽器未自動開啟,請手動複製上方連結。

等待瀏覽器授權中

使用前面提到的帳號登入。Auth Server 驗證帳密後產生 authorization code,並 redirect 到 CLI 的 callback。CLI 驗證 state,再把 code 與 verifier 送到 POST /token:

正在換取 access token...

✓ 授權成功!
  access_token : tok_...
  token_type   : Bearer
  scope        : read write

受保護 API 回應 (GET /me):
{"client_id":"my-cli-app","scope":"read write","username":"admin"}

PKCE 流程的資料流如下:
https://ithelp.ithome.com.tw/upload/images/20261003/20111896NRbi1Vj3G3.png

程式碼依照這條資料流分工:

在程式實作上,client/main.go 與 server/main.go 依據這套資料流分工:

  • 產生安全參數:Client 呼叫 generateCodeVerifier()、generateCodeChallenge() 與 generateState()。
  • 啟動 Callback:Client 透過 startCallbackServer() 在 127.0.0.1:0 監聽隨機埠。
  • 顯示登入頁:Client 組合 authURL 並以 openBrowser() 開啟;Server 由 handleAuthorize() GET 處理並渲染頁面。
  • 登入與重導向:使用者登入後,Server 由 handleAuthorize() POST 產生 code 並重導向;Client 在 callback Channel 等待接收。
  • 交換 Token:Client 呼叫 exchangeCode() 送出 code 與 verifier;Server 由 handleToken() 驗證後發放 Token。
  • 呼叫 API:Client 透過 callProtectedAPI() 攜帶 Bearer Token 發送請求;Server 由 handleMe() 驗證身分並回傳資料。

startCallbackServer() 使用 127.0.0.1:0 監聽,port 0 代表由作業系統分配可用 port。callback 收到 request 後先驗證 state,再把 authorization code 送回主流程:

ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
    log.Fatal(err)
}
ch := make(chan callbackResult, 1)

mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
    if r.URL.Query().Get("state") != expectedState {
        http.Error(w, "state mismatch", http.StatusBadRequest)
        return
    }
    ch <- callbackResult{Code: r.URL.Query().Get("code")}
})

state 綁定「發出授權請求的 CLI Process」與「收到 redirect 的 callback」。若兩者不一致,CLI 拒絕這次 callback,避免其他網頁把自己的授權結果塞進目前的登入流程。

Server 收到 token request 時,還要確認 client_id、redirect_uri 與 SHA-256(code_verifier) 都和授權時保存的資料一致。handleToken() 在同一個 mutex critical section 內完成驗證、刪除 authorization code 與建立 token,避免兩個並行 request 重複兌換同一組 code。

本機 Token 儲存與憑證管理

兩個 client 範例取得 token 後會立刻呼叫 /me。正式的 mycli login 還要保存 token,後續命令才能直接使用:

mycli login
  └─ 完成 OAuth flow,保存 token

mycli repo list
  └─ 讀取 token,設定 Authorization: Bearer <token>

mycli logout
  └─ 撤銷遠端 token,刪除本機 credential

系統 keychain 能把 credential 交給 macOS Keychain、Windows Credential Manager 或 Linux Secret Service 管理。若執行環境沒有 keychain,可以把 token 寫進使用者設定目錄,並把目錄權限設為 0700、檔案權限設為 0600:

func saveToken(data []byte) error {
    configRoot, err := os.UserConfigDir()
    if err != nil {
        return err
    }

    dir := filepath.Join(configRoot, "mycli")
    if err := os.MkdirAll(dir, 0o700); err != nil {
        return err
    }
    if err := os.Chmod(dir, 0o700); err != nil {
        return err
    }

    path := filepath.Join(dir, "token.json")
    if err := os.WriteFile(path, data, 0o600); err != nil {
        return err
    }
    return os.Chmod(path, 0o600)
}

access token 過期時,CLI 應使用 refresh token 取得新 token,或重新啟動登入流程。refresh token 的存放標準要和 access token 相同;logout 除了刪除本機資料,也要呼叫 Auth Server 的 revocation endpoint,讓已複製出去的 token 一併失效。

小結

採用 OAuth 2.0 能避免使用者於終端機輸入明文密碼,將密碼驗證留在安全的 Auth Server 瀏覽器頁面,並讓 CLI 僅持有權限受限且可獨立撤銷的 Access Token。在實作上,開發者可依據 CLI 的執行環境與連線條件,選擇 Device Flow 或 Authorization Code + PKCE 兩種標準流程之一,兼顧安全性與使用者體驗。


Go 知識補充:make 內建函式、sync.Mutex 互斥鎖、陣列與切片轉型與帶容量 Channel

如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:

1. make 內建函式與動態切片記憶體分配

在 Go 語言中,make 是專門用來初始化內建型別(Slice、Map 與 Channel)的內建函式。與 new 回傳指標不同,make 會直接回傳已適當初始化並分配底層記憶體的實例:

// 使用 make 分配一個包含 32 個 byte 的切片
b := make([]byte, 32)
_, err := rand.Read(b) // 將亂數填充至以 make 分配的切片中
if err != nil {
	return "", err
}

2. sync.Mutex 互斥鎖與 Map 併發存取保護

Go 的 map 不是執行緒安全的(Not Thread-Safe)。若有多個 HTTP Handler Goroutine 同時讀寫 map,程式會觸發 concurrent map writes 致命錯誤並崩潰。使用 sync.Mutex 可以在存取前加鎖、存取後解鎖:

var mu sync.Mutex
var sessionMap = make(map[string]Session)

// 加鎖保護 map 的讀寫操作
mu.Lock()
sessionMap[state] = session
mu.Unlock()

3. 固定長度陣列(Array)與動態切片(Slice)型別轉型

sha256.Sum256 回傳值為 [32]byte(長度固定為 32 的陣列 Array)。在 Go 中,陣列 [32]byte 與切片 []byte 是不同的型別。要將固定陣列傳給接收 []byte 切片的函式,需使用 hash[:] 切片運算子轉型:

hash := sha256.Sum256([]byte(verifier)) // 回傳 [32]byte 陣列

// 使用 hash[:] 語法將 [32]byte 陣列切片化為 []byte 切片
challenge := base64.RawURLEncoding.EncodeToString(hash[:])

4. 帶容量 Channel(Buffered Channel)與 Goroutine 洩漏防禦

若使用無容量通道(Unbuffered Channel make(chan string)),當接收端因為逾時(Timeout)結束等待離開後,發送端的背景 Goroutine 會永遠阻塞在 Channel 寫入,導致 Goroutine 洩漏。宣告容量為 1 的通道能確保即使沒有接收者,發送端也能順利寫入後結束:

// 容量為 1 的通道:寫入一筆資料時不會阻塞背景 Handler Goroutine
codeCh := make(chan string, 1)

// HTTP Callback 處理常式寫入資料後可立即返回,不會因逾時棄收而永遠卡住
codeCh <- authCode

上一篇
CLI 實作 WebSocket 即時雙向通訊
下一篇
打造 AI Chat CLI 串流對話工具
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言