Skip to content

Commit 74b6992

Browse files
maksim-zuev-dbIsaac
andcommitted
Split device code login into authorization and token exchange steps
libs/auth/u2m now provides the two steps of device code login instead of the whole login. PersistentAuth.DeviceCodeLogin checks that the host supports it and fetches the OAuth endpoints, and its RequestDeviceAuthorization and RequestDeviceAccessToken methods make the two requests. A rejected code matches ErrInvalidConfirmationCode or ErrInvalidDeviceCode. The confirmation code prompt and its retries move from DeviceCodeChallenge to auth login. The login's output does not change. Co-authored-by: Isaac <no-reply@databricks.com>
1 parent 8afc86f commit 74b6992

5 files changed

Lines changed: 217 additions & 177 deletions

File tree

‎cmd/auth/login.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,7 @@ a new profile is created.
343343
}
344344
// Check the host once it is resolved, before setAccountID prompts for
345345
// an account ID for account and unified hosts. A workspace without
346-
// device code login enabled is rejected later by DeviceCodeChallenge.
346+
// device code login enabled is rejected later by deviceCodeLogin.
347347
if useDeviceCode {
348348
// The host prompt accepts an empty answer. Without this check,
349349
// login would resolve the host from DATABRICKS_HOST instead.
@@ -401,7 +401,7 @@ a new profile is created.
401401

402402
var token *oauth2.Token
403403
if useDeviceCode {
404-
token, err = persistentAuth.DeviceCodeChallenge(ctx, promptForConfirmationCode)
404+
token, err = deviceCodeLogin(ctx, persistentAuth)
405405
} else {
406406
token, err = persistentAuth.Challenge()
407407
}

‎cmd/auth/login_device_code.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ import (
1111
"github.com/databricks/cli/libs/databrickscfg/profile"
1212
"github.com/databricks/cli/libs/env"
1313
"github.com/spf13/cobra"
14+
"golang.org/x/oauth2"
1415
)
1516

17+
// maxConfirmationAttempts is how many confirmation codes the user can enter
18+
// for a single device code before the login fails.
19+
const maxConfirmationAttempts = 3
20+
1621
// deviceCodeIncompatibleFlags lists flags that device code login cannot honor.
1722
var deviceCodeIncompatibleFlags = []string{
1823
// Resource indicators are sent on the browser authorization request,
@@ -57,6 +62,41 @@ func validateDeviceCodeLogin(ctx context.Context, cmd *cobra.Command) error {
5762
return nil
5863
}
5964

65+
// deviceCodeLogin logs in using the Databricks variant of the RFC 8628
66+
// device authorization grant. It shows the user where to approve the login
67+
// and exchanges the device code together with the confirmation code that the
68+
// user enters for a token. A rejected confirmation code leaves the device
69+
// code valid, so the user can re-enter it up to maxConfirmationAttempts
70+
// times.
71+
func deviceCodeLogin(ctx context.Context, persistentAuth *u2m.PersistentAuth) (*oauth2.Token, error) {
72+
login, err := persistentAuth.DeviceCodeLogin()
73+
if err != nil {
74+
return nil, err
75+
}
76+
da, err := login.RequestDeviceAuthorization(ctx)
77+
if err != nil {
78+
return nil, err
79+
}
80+
81+
var lastErr error
82+
for attempt := range maxConfirmationAttempts {
83+
code, err := promptForConfirmationCode(ctx, *da, lastErr, maxConfirmationAttempts-attempt)
84+
if err != nil {
85+
return nil, err
86+
}
87+
88+
token, err := login.RequestDeviceAccessToken(ctx, da.DeviceCode, code)
89+
if err == nil {
90+
return token, nil
91+
}
92+
lastErr = err
93+
if !errors.Is(err, u2m.ErrInvalidConfirmationCode) {
94+
return nil, fmt.Errorf("token exchange: %w", err)
95+
}
96+
}
97+
return nil, fmt.Errorf("%w. Start login from scratch", lastErr)
98+
}
99+
60100
// promptForConfirmationCode shows the user where to approve the login and
61101
// reads the confirmation code from stdin. If the server rejected the previous
62102
// code, it shows the server's error and the number of attempts left instead

‎cmd/auth/login_device_code_test.go‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"io"
8+
"net/http"
89
"runtime"
910
"strings"
1011
"testing"
@@ -17,6 +18,7 @@ import (
1718
"github.com/databricks/cli/libs/databrickscfg/profile"
1819
"github.com/databricks/cli/libs/env"
1920
"github.com/databricks/cli/libs/flags"
21+
"github.com/databricks/cli/libs/testserver"
2022
"github.com/spf13/cobra"
2123
"github.com/stretchr/testify/assert"
2224
"github.com/stretchr/testify/require"
@@ -304,3 +306,23 @@ resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github
304306
_, err := tm.Result()
305307
assert.EqualError(t, err, "^C")
306308
}
309+
310+
// Only a wrong confirmation code can be fixed by entering the code again, so
311+
// any other token exchange error ends the login.
312+
func TestLoginDeviceCodeFailsOnTokenExchangeError(t *testing.T) {
313+
server := testserver.New(t)
314+
// The router keeps the first handler registered for a route, so this one
315+
// replaces the default token endpoint.
316+
server.Handle("POST", "/oidc/v1/token", func(_ testserver.Request) any {
317+
return testserver.Response{
318+
StatusCode: http.StatusTooManyRequests,
319+
Body: map[string]string{"error": "invalid_request", "error_description": "Device grant rate limit exceeded"},
320+
}
321+
})
322+
testserver.AddDefaultHandlers(server)
323+
tm := startDeviceCodeLogin(t, "[test]\nhost = "+server.URL+"\n", "--profile", "test")
324+
tm.WaitFor("Confirmation code")
325+
tm.Type("FTFA-QQKY\n")
326+
_, err := tm.Result()
327+
assert.EqualError(t, err, "token exchange: Device grant rate limit exceeded (error code: invalid_request)")
328+
}

‎libs/auth/u2m/device_code.go‎

Lines changed: 79 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -32,27 +32,28 @@ const invalidDeviceCodeDescription = "Invalid device code"
3232
// a device authorization endpoint in its OIDC discovery document.
3333
var ErrDeviceCodeNotSupported = errors.New("device code login is not enabled for this workspace")
3434

35+
// ErrInvalidConfirmationCode matches the token exchange error for a
36+
// confirmation code the server rejects. The device code stays valid, so the
37+
// exchange can be retried with the right code.
38+
var ErrInvalidConfirmationCode = errors.New("invalid confirmation code")
39+
40+
// ErrInvalidDeviceCode matches the token exchange error for a device code the
41+
// server does not know, e.g. one it discarded after the login expired. The
42+
// login has to be started again.
43+
var ErrInvalidDeviceCode = errors.New("invalid device code")
44+
3545
// DeviceAuthorization holds what the user needs to approve a device code
36-
// login from another device.
46+
// login from another device, and the device code to exchange afterwards.
3747
type DeviceAuthorization struct {
3848
// UserCode is the code the user enters at VerificationURI.
3949
UserCode string
4050

4151
// VerificationURI is the page where the user approves the login.
4252
VerificationURI string
43-
}
4453

45-
// maxConfirmationAttempts is how many confirmation codes the user can enter
46-
// for a single device code before the login fails.
47-
const maxConfirmationAttempts = 3
48-
49-
// ConfirmFunc shows the device authorization to the user and returns the
50-
// confirmation code that the user received after approving the login. When
51-
// the server rejects a confirmation code, it is called again with the
52-
// server's error in lastErr so that the user can re-enter the code. lastErr
53-
// is nil on the first call. attemptsLeft is the number of confirmation codes
54-
// the user can still enter, including this one.
55-
type ConfirmFunc func(ctx context.Context, da DeviceAuthorization, lastErr error, attemptsLeft int) (string, error)
54+
// DeviceCode identifies the login in the token exchange.
55+
DeviceCode string
56+
}
5657

5758
// deviceCodeConfig returns the OAuth config for device code login, or an
5859
// error if the host does not support it.
@@ -75,53 +76,83 @@ func (a *PersistentAuth) deviceCodeConfig() (*oauth2.Config, error) {
7576
return cfg, nil
7677
}
7778

78-
// DeviceCodeChallenge logs in using the Databricks variant of the RFC 8628
79-
// device authorization grant. It requests a device authorization, calls
80-
// confirm with the user code and verification URI, and exchanges the device
81-
// code together with the returned confirmation code for a token. A rejected
82-
// confirmation code leaves the device code valid, so the user can re-enter
83-
// it up to maxConfirmationAttempts times. Only workspace hosts are supported.
84-
// The caller is responsible for storing the returned token.
85-
func (a *PersistentAuth) DeviceCodeChallenge(ctx context.Context, confirm ConfirmFunc) (*oauth2.Token, error) {
79+
// DeviceCodeLogin makes the requests of a login using the Databricks variant
80+
// of the RFC 8628 device authorization grant. Get one with
81+
// PersistentAuth.DeviceCodeLogin.
82+
type DeviceCodeLogin struct {
83+
auth *PersistentAuth
84+
cfg *oauth2.Config
85+
}
86+
87+
// DeviceCodeLogin fetches the OAuth endpoints for a device code login, or
88+
// returns an error if the host does not support it. Only workspace hosts are
89+
// supported.
90+
func (a *PersistentAuth) DeviceCodeLogin() (*DeviceCodeLogin, error) {
8691
cfg, err := a.deviceCodeConfig()
8792
if err != nil {
8893
return nil, err
8994
}
95+
return &DeviceCodeLogin{auth: a, cfg: cfg}, nil
96+
}
9097

91-
da, err := a.requestDeviceAuthorization(ctx, cfg)
98+
// RequestDeviceAuthorization starts the login. The caller shows the user code
99+
// and the verification URI to the user and later passes the device code to
100+
// RequestDeviceAccessToken.
101+
func (l *DeviceCodeLogin) RequestDeviceAuthorization(ctx context.Context) (*DeviceAuthorization, error) {
102+
da, err := l.auth.requestDeviceAuthorization(ctx, l.cfg)
92103
if err != nil {
93104
return nil, fmt.Errorf("device authorization: %w", oauthError(err))
94105
}
95-
96-
authorization := DeviceAuthorization{
106+
return &DeviceAuthorization{
97107
UserCode: da.UserCode,
98108
VerificationURI: da.VerificationURI,
99-
}
100-
var lastErr error
101-
for attempt := range maxConfirmationAttempts {
102-
code, err := confirm(ctx, authorization, lastErr, maxConfirmationAttempts-attempt)
103-
if err != nil {
104-
return nil, err
105-
}
109+
DeviceCode: da.DeviceCode,
110+
}, nil
111+
}
106112

107-
t, err := a.exchangeDeviceCode(ctx, cfg, da.DeviceCode, normalizeCode(code))
108-
if err == nil {
109-
return t, nil
110-
}
111-
lastErr = oauthError(err)
112-
// The server answers a wrong confirmation code with HTTP 400 and
113-
// invalid_request. Any other error cannot be fixed by entering the
114-
// code again, including invalid_request with HTTP 429 when the server
115-
// rate limits the token requests for this device code. An unknown
116-
// device code also gets HTTP 400 and invalid_request, and only the
117-
// description tells it apart. If the description changes, the user is
118-
// asked for the code again until the attempts run out.
119-
status, errCode, description := oauthErrorResponse(err)
120-
if status != http.StatusBadRequest || errCode != "invalid_request" || description == invalidDeviceCodeDescription {
121-
return nil, fmt.Errorf("token exchange: %w", lastErr)
122-
}
113+
// RequestDeviceAccessToken completes the login with a single token request.
114+
// The confirmation code is accepted the way the browser displays it. An error
115+
// matches ErrInvalidConfirmationCode or ErrInvalidDeviceCode when the server
116+
// rejects one of the codes. A rejected confirmation code leaves the device
117+
// code valid, so the exchange can be retried with the right code. The caller
118+
// is responsible for storing the returned token.
119+
func (l *DeviceCodeLogin) RequestDeviceAccessToken(ctx context.Context, deviceCode, confirmationCode string) (*oauth2.Token, error) {
120+
t, err := l.auth.exchangeDeviceCode(ctx, l.cfg, deviceCode, normalizeCode(confirmationCode))
121+
if err == nil {
122+
return t, nil
123+
}
124+
// The server answers a wrong confirmation code with HTTP 400 and
125+
// invalid_request. Any other error cannot be fixed by entering the
126+
// code again, including invalid_request with HTTP 429 when the server
127+
// rate limits the token requests for this device code. An unknown
128+
// device code also gets HTTP 400 and invalid_request, and only the
129+
// description tells it apart. If the description changes, the error is
130+
// reported as a wrong confirmation code.
131+
status, errCode, description := oauthErrorResponse(err)
132+
err = oauthError(err)
133+
switch {
134+
case status != http.StatusBadRequest || errCode != "invalid_request":
135+
return nil, err
136+
case description == invalidDeviceCodeDescription:
137+
return nil, &codeRejectedError{err: err, sentinel: ErrInvalidDeviceCode}
138+
default:
139+
return nil, &codeRejectedError{err: err, sentinel: ErrInvalidConfirmationCode}
123140
}
124-
return nil, fmt.Errorf("%w. Start login from scratch", lastErr)
141+
}
142+
143+
// codeRejectedError shows the server's error for a rejected code and matches
144+
// sentinel with errors.Is.
145+
type codeRejectedError struct {
146+
err error
147+
sentinel error
148+
}
149+
150+
func (e *codeRejectedError) Error() string {
151+
return e.err.Error()
152+
}
153+
154+
func (e *codeRejectedError) Unwrap() []error {
155+
return []error{e.err, e.sentinel}
125156
}
126157

127158
// requestDeviceAuthorization makes the RFC 8628 device authorization request.

0 commit comments

Comments
 (0)