feat: add RFC 8628 device authorization flow for out-of-VPN authentication
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 52s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 43s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 2m11s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 1m6s
Build and Push Docker Container / build-and-push (push) Successful in 20m0s

This commit is contained in:
2026-04-13 23:32:18 +02:00
parent 2c48c75387
commit 77e2610fe8
4 changed files with 222 additions and 68 deletions
+96 -51
View File
@@ -22,67 +22,112 @@ var loginCmd = &cobra.Command{
}
base := fmt.Sprintf("http://%s:%d", rc.Host, rc.Port)
resp, err := http.Post(base+"/auth/start", "application/json", nil)
sessionID := tryDeviceFlow(base)
if sessionID == "" {
sessionID = tryCallbackFlow(base)
}
pollForToken(base, sessionID)
},
}
// tryDeviceFlow attempts the device authorization flow. Returns a session ID
// on success, or "" if the server does not support it.
func tryDeviceFlow(base string) string {
resp, err := http.Post(base+"/auth/device/start", "application/json", nil)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var start struct {
SessionID string `json:"session_id"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
}
json.NewDecoder(resp.Body).Decode(&start)
if start.SessionID == "" {
return ""
}
uri := start.VerificationURI
if start.VerificationURIComplete != "" {
uri = start.VerificationURIComplete
}
fmt.Printf("To sign in, open this URL in any browser:\n\n %s\n\nThen enter this code: %s\n\nWaiting for authentication...\n", uri, start.UserCode)
return start.SessionID
}
// tryCallbackFlow initiates the traditional callback-based OIDC flow.
// Exits the process on failure.
func tryCallbackFlow(base string) string {
resp, err := http.Post(base+"/auth/start", "application/json", nil)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to contact server: %v\n", err)
os.Exit(1)
}
var start struct {
URL string `json:"url"`
SessionID string `json:"session_id"`
}
json.NewDecoder(resp.Body).Decode(&start)
resp.Body.Close()
if start.URL == "" {
fmt.Fprintln(os.Stderr, "server did not return an auth URL; is OIDC configured on the server?")
os.Exit(1)
}
fmt.Printf("Open this URL in your browser:\n\n %s\n\nWaiting for login...\n", start.URL)
return start.SessionID
}
// pollForToken polls the server until the login completes or times out.
func pollForToken(base, sessionID string) {
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
resp, err := http.Get(fmt.Sprintf("%s/auth/poll?session_id=%s", base, sessionID))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to contact server: %v\n", err)
continue
}
if resp.StatusCode == http.StatusAccepted {
resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
fmt.Fprintln(os.Stderr, "login failed")
os.Exit(1)
}
var start struct {
URL string `json:"url"`
SessionID string `json:"session_id"`
var result struct {
Token string `json:"token"`
Username string `json:"username"`
}
json.NewDecoder(resp.Body).Decode(&start)
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
if start.URL == "" {
fmt.Fprintln(os.Stderr, "server did not return an auth URL; is OIDC configured on the server?")
session, err := store.LoadSession()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
fmt.Printf("Open this URL in your browser:\n\n %s\n\nWaiting for login...\n", start.URL)
deadline := time.Now().Add(5 * time.Minute)
for time.Now().Before(deadline) {
time.Sleep(2 * time.Second)
resp, err := http.Get(fmt.Sprintf("%s/auth/poll?session_id=%s", base, start.SessionID))
if err != nil {
continue
}
if resp.StatusCode == http.StatusAccepted {
resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
fmt.Fprintln(os.Stderr, "login failed")
os.Exit(1)
}
var result struct {
Token string `json:"token"`
Username string `json:"username"`
}
json.NewDecoder(resp.Body).Decode(&result)
resp.Body.Close()
session, err := store.LoadSession()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
session.Token = result.Token
if err := session.Save(); err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
fmt.Printf("Logged in as %s\n", result.Username)
return
session.Token = result.Token
if err := session.Save(); err != nil {
fmt.Fprintf(os.Stderr, "failed to save session: %v\n", err)
os.Exit(1)
}
fmt.Printf("Logged in as %s\n", result.Username)
return
}
fmt.Fprintln(os.Stderr, "login timed out")
os.Exit(1)
},
fmt.Fprintln(os.Stderr, "login timed out")
os.Exit(1)
}
func init() {