Skip to content

Commit 209d881

Browse files
committed
feat: implement device authentication (no browser)
We need a way to authentication the datumctl even when a browser is not available. For example inside a container or in a server. OAuth cover this scenario with device authentication. Zitadel supports device auth and it is documented here: https://zitadel.com/docs/guides/integrate/login/oidc/device-authorization This commit implements this flow when `--no-browser` is passed to `datumctl auth login`
1 parent 2b5586c commit 209d881

2 files changed

Lines changed: 227 additions & 5 deletions

File tree

docs/authentication.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,21 @@ keyring.
2525
To authenticate with Datum Cloud, use the `login` command:
2626

2727
```
28-
datumctl auth login [--hostname <auth-hostname>] [-v]
28+
datumctl auth login [--hostname <auth-hostname>] [--no-browser] [-v]
2929
```
3030

3131
* `--hostname <auth-hostname>`: (Optional) Specify the hostname of the Datum
3232
Cloud authentication server. Defaults to `auth.datum.net`.
33+
* `--no-browser`: (Optional) Do not attempt to open a browser; print the login
34+
URL and use the device authorization flow (enter a user code) so you can
35+
complete login without a local callback.
3336
* `-v, --verbose`: (Optional) Print the full ID token claims after successful
3437
login.
3538

3639
Running this command will:
3740

3841
1. Attempt to open your default web browser to the Datum Cloud authentication
39-
page.
42+
page (or use device authorization if `--no-browser` is used).
4043
2. If the browser cannot be opened automatically, it will print a URL for you
4144
to visit manually.
4245
3. Authenticate via the web page (this might involve entering your

internal/cmd/auth/login.go

Lines changed: 222 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import (
88
"encoding/json"
99
"errors"
1010
"fmt"
11+
"io"
1112
"net"
1213
"net/http"
14+
"net/url"
1315
"os"
1416
"strings"
17+
"time"
1518

1619
kubectlcmd "k8s.io/kubectl/pkg/cmd"
1720

@@ -25,7 +28,7 @@ import (
2528
)
2629

2730
const (
28-
stagingClientID = "325848904128073754" // Client ID for staging
31+
stagingClientID = "360304563007327549" // Client ID for staging
2932
prodClientID = "328728232771788043" // Client ID for prod
3033
redirectPath = "/datumctl/auth/callback"
3134
// Listen on a random port
@@ -36,6 +39,7 @@ var (
3639
hostname string // Variable to store hostname flag
3740
apiHostname string // Variable to store api-hostname flag
3841
clientIDFlag string // Variable to store client-id flag
42+
noBrowser bool // Variable to store no-browser flag
3943
)
4044

4145
var LoginCmd = &cobra.Command{
@@ -53,7 +57,7 @@ var LoginCmd = &cobra.Command{
5357
// Return an error if no client ID could be determined
5458
return fmt.Errorf("client ID not configured for hostname '%s'. Please specify one with the --client-id flag", hostname)
5559
}
56-
return runLoginFlow(cmd.Context(), hostname, apiHostname, actualClientID, (kubectlcmd.GetLogVerbosity(os.Args) != "0"))
60+
return runLoginFlow(cmd.Context(), hostname, apiHostname, actualClientID, noBrowser, (kubectlcmd.GetLogVerbosity(os.Args) != "0"))
5761
},
5862
}
5963

@@ -64,6 +68,8 @@ func init() {
6468
LoginCmd.Flags().StringVar(&apiHostname, "api-hostname", "", "Hostname of the Datum Cloud API server (if not specified, will be derived from auth hostname)")
6569
// Add the client-id flag
6670
LoginCmd.Flags().StringVar(&clientIDFlag, "client-id", "", "Override the OAuth2 Client ID")
71+
// Add the no-browser flag
72+
LoginCmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Do not open a browser; use the device authorization flow")
6773
}
6874

6975
// Generates a random PKCE code verifier
@@ -94,7 +100,7 @@ func generateRandomState(length int) (string, error) {
94100
}
95101

96102
// runLoginFlow now accepts context, hostname, apiHostname, clientID, and verbose flag
97-
func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, clientID string, verbose bool) error {
103+
func runLoginFlow(ctx context.Context, authHostname string, apiHostname string, clientID string, noBrowser bool, verbose bool) error {
98104
fmt.Printf("Starting login process for %s ...\n", authHostname)
99105

100106
// Determine the final API hostname to use
@@ -122,6 +128,14 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string,
122128
// Define scopes
123129
scopes := []string{oidc.ScopeOpenID, "profile", "email", oidc.ScopeOfflineAccess}
124130

131+
if noBrowser {
132+
token, err := runDeviceFlow(ctx, provider, providerURL, clientID, scopes)
133+
if err != nil {
134+
return err
135+
}
136+
return completeLogin(ctx, provider, clientID, authHostname, finalAPIHostname, scopes, token, verbose)
137+
}
138+
125139
listener, err := net.Listen("tcp", listenAddr)
126140
if err != nil {
127141
return fmt.Errorf("failed to listen on %s: %w", listenAddr, err)
@@ -262,6 +276,10 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string,
262276
// Wait for server shutdown *after* successful exchange (or failed exchange)
263277
<-serverClosed
264278

279+
return completeLogin(ctx, provider, clientID, authHostname, finalAPIHostname, scopes, token, verbose)
280+
}
281+
282+
func completeLogin(ctx context.Context, provider *oidc.Provider, clientID string, authHostname string, finalAPIHostname string, scopes []string, token *oauth2.Token, verbose bool) error {
265283
// Verify ID token and extract claims
266284
idTokenString, ok := token.Extra("id_token").(string)
267285
if !ok {
@@ -362,6 +380,207 @@ func runLoginFlow(ctx context.Context, authHostname string, apiHostname string,
362380
return nil
363381
}
364382

383+
type deviceAuthorizationResponse struct {
384+
DeviceCode string `json:"device_code"`
385+
UserCode string `json:"user_code"`
386+
VerificationURI string `json:"verification_uri"`
387+
VerificationURIComplete string `json:"verification_uri_complete"`
388+
ExpiresIn int64 `json:"expires_in"`
389+
Interval int64 `json:"interval"`
390+
}
391+
392+
type deviceTokenResponse struct {
393+
AccessToken string `json:"access_token"`
394+
TokenType string `json:"token_type"`
395+
RefreshToken string `json:"refresh_token"`
396+
IDToken string `json:"id_token"`
397+
ExpiresIn int64 `json:"expires_in"`
398+
Scope string `json:"scope"`
399+
}
400+
401+
type oauthErrorResponse struct {
402+
Error string `json:"error"`
403+
ErrorDescription string `json:"error_description"`
404+
}
405+
406+
func runDeviceFlow(ctx context.Context, provider *oidc.Provider, providerURL string, clientID string, scopes []string) (*oauth2.Token, error) {
407+
var discovery struct {
408+
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
409+
}
410+
if err := provider.Claims(&discovery); err != nil {
411+
return nil, fmt.Errorf("failed to read OIDC discovery document: %w", err)
412+
}
413+
414+
deviceEndpoint := discovery.DeviceAuthorizationEndpoint
415+
if deviceEndpoint == "" {
416+
deviceEndpoint = strings.TrimRight(providerURL, "/") + "/oauth/v2/device_authorization"
417+
}
418+
419+
deviceResp, err := requestDeviceAuthorization(ctx, deviceEndpoint, clientID, scopes)
420+
if err != nil {
421+
return nil, err
422+
}
423+
424+
fmt.Println("\nTo authenticate, visit:")
425+
if deviceResp.VerificationURIComplete != "" {
426+
fmt.Printf("\n%s\n\n", deviceResp.VerificationURIComplete)
427+
} else {
428+
fmt.Printf("\n%s\n\n", deviceResp.VerificationURI)
429+
}
430+
if deviceResp.UserCode != "" {
431+
fmt.Printf("And enter code: %s\n\n", deviceResp.UserCode)
432+
}
433+
434+
fmt.Println("Waiting for authorization...")
435+
436+
tokenURL := provider.Endpoint().TokenURL
437+
token, err := pollDeviceToken(ctx, tokenURL, clientID, deviceResp.DeviceCode, deviceResp.Interval, deviceResp.ExpiresIn)
438+
if err != nil {
439+
return nil, err
440+
}
441+
442+
return token, nil
443+
}
444+
445+
func requestDeviceAuthorization(ctx context.Context, endpoint string, clientID string, scopes []string) (*deviceAuthorizationResponse, error) {
446+
form := url.Values{}
447+
form.Set("client_id", clientID)
448+
form.Set("scope", strings.Join(scopes, " "))
449+
450+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
451+
if err != nil {
452+
return nil, fmt.Errorf("failed to create device authorization request: %w", err)
453+
}
454+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
455+
456+
resp, err := http.DefaultClient.Do(req)
457+
if err != nil {
458+
return nil, fmt.Errorf("device authorization request failed: %w", err)
459+
}
460+
defer resp.Body.Close()
461+
462+
body, err := io.ReadAll(resp.Body)
463+
if err != nil {
464+
return nil, fmt.Errorf("failed to read device authorization response: %w", err)
465+
}
466+
467+
if resp.StatusCode != http.StatusOK {
468+
var oauthErr oauthErrorResponse
469+
_ = json.Unmarshal(body, &oauthErr)
470+
if oauthErr.Error != "" {
471+
return nil, fmt.Errorf("device authorization failed: %s (%s)", oauthErr.Error, oauthErr.ErrorDescription)
472+
}
473+
return nil, fmt.Errorf("device authorization failed with status %s", resp.Status)
474+
}
475+
476+
var deviceResp deviceAuthorizationResponse
477+
if err := json.Unmarshal(body, &deviceResp); err != nil {
478+
return nil, fmt.Errorf("failed to parse device authorization response: %w", err)
479+
}
480+
if deviceResp.DeviceCode == "" {
481+
return nil, fmt.Errorf("device authorization response missing device_code")
482+
}
483+
484+
return &deviceResp, nil
485+
}
486+
487+
func pollDeviceToken(ctx context.Context, tokenURL string, clientID string, deviceCode string, intervalSeconds int64, expiresIn int64) (*oauth2.Token, error) {
488+
interval := time.Duration(intervalSeconds) * time.Second
489+
if interval <= 0 {
490+
interval = 5 * time.Second
491+
}
492+
493+
var deadline time.Time
494+
if expiresIn > 0 {
495+
deadline = time.Now().Add(time.Duration(expiresIn) * time.Second)
496+
}
497+
498+
for {
499+
if !deadline.IsZero() && time.Now().After(deadline) {
500+
return nil, fmt.Errorf("device authorization expired before completion")
501+
}
502+
503+
token, errType, err := requestDeviceToken(ctx, tokenURL, clientID, deviceCode)
504+
if err != nil {
505+
return nil, err
506+
}
507+
switch errType {
508+
case "":
509+
return token, nil
510+
case "authorization_pending":
511+
// Keep polling
512+
case "slow_down":
513+
interval += 5 * time.Second
514+
case "access_denied":
515+
return nil, fmt.Errorf("device authorization denied by user")
516+
case "expired_token":
517+
return nil, fmt.Errorf("device authorization expired")
518+
default:
519+
return nil, fmt.Errorf("device authorization failed: %s", errType)
520+
}
521+
522+
select {
523+
case <-ctx.Done():
524+
return nil, ctx.Err()
525+
case <-time.After(interval):
526+
}
527+
}
528+
}
529+
530+
func requestDeviceToken(ctx context.Context, tokenURL string, clientID string, deviceCode string) (*oauth2.Token, string, error) {
531+
form := url.Values{}
532+
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
533+
form.Set("device_code", deviceCode)
534+
form.Set("client_id", clientID)
535+
536+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
537+
if err != nil {
538+
return nil, "", fmt.Errorf("failed to create device token request: %w", err)
539+
}
540+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
541+
542+
resp, err := http.DefaultClient.Do(req)
543+
if err != nil {
544+
return nil, "", fmt.Errorf("device token request failed: %w", err)
545+
}
546+
defer resp.Body.Close()
547+
548+
body, err := io.ReadAll(resp.Body)
549+
if err != nil {
550+
return nil, "", fmt.Errorf("failed to read device token response: %w", err)
551+
}
552+
553+
if resp.StatusCode != http.StatusOK {
554+
var oauthErr oauthErrorResponse
555+
_ = json.Unmarshal(body, &oauthErr)
556+
if oauthErr.Error != "" {
557+
return nil, oauthErr.Error, nil
558+
}
559+
return nil, "", fmt.Errorf("device token request failed with status %s", resp.Status)
560+
}
561+
562+
var tokenResp deviceTokenResponse
563+
if err := json.Unmarshal(body, &tokenResp); err != nil {
564+
return nil, "", fmt.Errorf("failed to parse device token response: %w", err)
565+
}
566+
567+
token := &oauth2.Token{
568+
AccessToken: tokenResp.AccessToken,
569+
TokenType: tokenResp.TokenType,
570+
RefreshToken: tokenResp.RefreshToken,
571+
ExpiresIn: tokenResp.ExpiresIn,
572+
}
573+
if tokenResp.ExpiresIn > 0 {
574+
token.Expiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
575+
}
576+
token = token.WithExtra(map[string]any{
577+
"id_token": tokenResp.IDToken,
578+
"scope": tokenResp.Scope,
579+
})
580+
581+
return token, "", nil
582+
}
583+
365584
// addKnownUser adds a userKey (now email@hostname) to the known_users list in the keyring.
366585
func addKnownUser(newUserKey string) error {
367586
knownUsers := []string{}

0 commit comments

Comments
 (0)