Skip to content

Commit e27fd97

Browse files
committed
feat(auth): adopt authkit.DeviceCodeClient for the login flow
Replaces the bespoke device-code orchestration in runDeviceFlow with pkg/authkit.DeviceCodeClient. The CLI keeps: - Its --no-browser flag (mapped to OpenBrowser = no-op) - Its --org_id / --personal forwarding (via ExtraParams) - Its post-flow Cella token exchange + saveAndVerify pipeline - Its terminal RFC 8628 error messages (expired_token, access_denied) - The api.SaveAuthToken sidecar write (now performed in a captureStore that wraps authkit.FileTokenStore and persists to the same api.Token path the rest of the CLI reads via api.LoadAuthToken) Net deletions: the device authorization, prompt rendering, polling loop, and one-off token capture. The CLI's spec follow-up to migrate to authkit.FileTokenStore as the primary store can land independently once the api.Token shape is retired.
1 parent 42ac77c commit e27fd97

1 file changed

Lines changed: 68 additions & 48 deletions

File tree

internal/commands/auth.go

Lines changed: 68 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818

1919
"github.com/spf13/cobra"
2020
"golang.org/x/oauth2"
21+
"latere.ai/x/pkg/authkit"
2122
"latere.ai/x/pkg/oidc"
2223

2324
"github.com/latere-ai/latere-cli/internal/api"
@@ -184,11 +185,55 @@ type deviceFlowOpts struct {
184185
NoBrowser bool
185186
}
186187

188+
// captureStore is a TokenStore whose Save also retains the saved token
189+
// in-memory so the caller can do post-flow work (Cella exchange) without
190+
// re-reading from disk. Wraps authkit.FileTokenStore at the auth-token
191+
// path so the saved on-disk format stays compatible with the rest of the
192+
// CLI's token plumbing.
193+
type captureStore struct {
194+
disk *authkit.FileTokenStore
195+
last *oauth2.Token
196+
}
197+
198+
func newAuthTokenStore() (*captureStore, error) {
199+
p := api.AuthTokenPath()
200+
if p == "" {
201+
return nil, errors.New("cannot determine auth token path")
202+
}
203+
disk, err := authkit.NewFileTokenStore(p)
204+
if err != nil {
205+
return nil, err
206+
}
207+
return &captureStore{disk: disk}, nil
208+
}
209+
210+
func (s *captureStore) Save(t *oauth2.Token) error {
211+
if t == nil {
212+
return errors.New("nil token")
213+
}
214+
s.last = t
215+
// Persist in the api.Token shape so `latere lux` (which reads via
216+
// api.LoadAuthToken) finds the auth-issued root token where it
217+
// expects it. Best-effort: a write failure is reported via a
218+
// non-fatal warning above; lux access is additive.
219+
if err := api.SaveAuthToken(api.Token{
220+
AccessToken: t.AccessToken,
221+
RefreshToken: t.RefreshToken,
222+
TokenType: "Bearer",
223+
ExpiresAt: t.Expiry,
224+
IssuedAt: time.Now().UTC(),
225+
}); err != nil {
226+
fmt.Fprintf(os.Stderr, " warning: could not save auth token for lux (%v); `latere lux` may require re-login\n", err)
227+
}
228+
return nil
229+
}
230+
231+
func (s *captureStore) Load() (*oauth2.Token, error) { return s.disk.Load() }
232+
func (s *captureStore) Clear() error { return s.disk.Clear() }
233+
187234
// runDeviceFlow drives the RFC 8628 device-code flow against
188-
// auth.latere.ai. The HTTP plumbing (initiate, poll, RFC 8628 error
189-
// handling) is delegated to pkg/oidc; this function owns the CLI-side
190-
// concerns: rendering the user code, opening a browser, and exchanging
191-
// the resulting auth-issued token for a Cella-scoped one.
235+
// auth.latere.ai via pkg/authkit.DeviceCodeClient, then trades the
236+
// resulting auth-issued token for a Cella-scoped one.
192237
func runDeviceFlow(ctx context.Context, opts deviceFlowOpts) error {
193238
authBase := opts.AuthURL
194239
if authBase == "" {
@@ -205,6 +250,11 @@ func runDeviceFlow(ctx context.Context, opts deviceFlowOpts) error {
205250
return errors.New("oidc: missing AuthURL or ClientID")
206251
}
207252

253+
store, err := newAuthTokenStore()
254+
if err != nil {
255+
return err
256+
}
257+
208258
extra := url.Values{}
209259
if opts.OrgIDSet {
210260
// Forward the present-but-possibly-empty value. Auth reads
@@ -213,34 +263,18 @@ func runDeviceFlow(ctx context.Context, opts deviceFlowOpts) error {
213263
extra["org_id"] = []string{opts.OrgID}
214264
}
215265

216-
// 1. Initiate.
217-
da, err := client.DeviceAuth(ctx, extra)
218-
if err != nil {
219-
return fmt.Errorf("device/code: %w", err)
220-
}
221-
222-
// 2. Surface the user code.
223-
verify := da.VerificationURIComplete
224-
if verify == "" {
225-
verify = da.VerificationURI
226-
}
227-
fmt.Fprintln(os.Stderr)
228-
fmt.Fprintf(os.Stderr, " To sign in, open this URL:\n\n %s\n\n", verify)
229-
fmt.Fprintf(os.Stderr, " And confirm the code:\n\n %s\n\n", da.UserCode)
230-
if !opts.NoBrowser && verify != "" {
231-
if err := openBrowser(verify); err != nil {
232-
fmt.Fprintf(os.Stderr, " Could not open browser automatically: %v\n", err)
233-
} else {
234-
fmt.Fprintln(os.Stderr, " Opened browser for confirmation.")
235-
}
266+
dcc := authkit.NewDeviceCodeClient(client, store)
267+
dcc.Output = os.Stderr
268+
dcc.ExtraParams = extra
269+
if opts.NoBrowser {
270+
dcc.OpenBrowser = func(string) error { return nil }
271+
} else {
272+
dcc.OpenBrowser = openBrowser
236273
}
237-
fmt.Fprintln(os.Stderr, " Waiting for approval...")
238274

239-
// 3. Poll /token until terminal status. pkg/oidc honours RFC 8628
240-
// slow_down / authorization_pending; map the terminal errors back
241-
// to the CLI's user-facing strings.
242-
tok, err := client.DeviceAccessToken(ctx, da)
243-
if err != nil {
275+
if err := dcc.Login(ctx); err != nil {
276+
// Surface terminal RFC 8628 errors with the CLI's user-facing
277+
// strings; everything else passes through with authkit's wrap.
244278
var rerr *oauth2.RetrieveError
245279
if errors.As(err, &rerr) {
246280
switch rerr.ErrorCode {
@@ -251,26 +285,12 @@ func runDeviceFlow(ctx context.Context, opts deviceFlowOpts) error {
251285
}
252286
return fmt.Errorf("device-code login failed: %s (%s)", rerr.ErrorCode, rerr.ErrorDescription)
253287
}
254-
return fmt.Errorf("device-code login failed: %w", err)
255-
}
256-
if tok.AccessToken == "" {
257-
return errors.New("token endpoint returned no access_token")
288+
return err
258289
}
259290

260-
// Retain the auth.latere.ai root token (access + refresh) so the
261-
// `latere lux` commands can mint short-lived aud=lux.latere.ai actor
262-
// tokens and refresh when it expires. Persist BEFORE the cella
263-
// exchange so retention survives an exchange failure. Kept in a
264-
// separate file from the cella token.json; best-effort because lux
265-
// access is additive and must not block a successful login.
266-
if err := api.SaveAuthToken(api.Token{
267-
AccessToken: tok.AccessToken,
268-
RefreshToken: tok.RefreshToken,
269-
TokenType: "Bearer",
270-
ExpiresAt: tok.Expiry,
271-
IssuedAt: time.Now().UTC(),
272-
}); err != nil {
273-
fmt.Fprintf(os.Stderr, " warning: could not save auth token for lux (%v); `latere lux` may require re-login\n", err)
291+
tok := store.last
292+
if tok == nil || tok.AccessToken == "" {
293+
return errors.New("token endpoint returned no access_token")
274294
}
275295

276296
// Best-effort: trade the auth-issued token for a cella-issued

0 commit comments

Comments
 (0)