@@ -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.
192237func 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