@@ -3,6 +3,7 @@ package commands
33import (
44 "bufio"
55 "context"
6+ "encoding/base64"
67 "encoding/json"
78 "errors"
89 "fmt"
@@ -290,10 +291,14 @@ func runDeviceFlow(ctx context.Context, opts deviceFlowOpts) error {
290291}
291292
292293// exchangeForCellaToken trades an auth-issued user JWT for a cella
293- // bearer token. The CLI mints a short-TTL actor token at auth, then
294- // exchanges it at cella's /v1/tokens/exchange. Either step may fail
295- // (older auth without /actor-tokens, older cella without the token
296- // catalog); the caller falls back to the auth token in that case.
294+ // bearer token. The preferred path mints a short-TTL actor token at
295+ // auth, then exchanges it at cella's /v1/tokens/exchange.
296+ //
297+ // Some deployed auth versions stamp device-code tokens with sandboxd's
298+ // audience, then reject those same tokens on /actor-tokens because the
299+ // auth middleware expects the auth issuer as audience. In that case the
300+ // device token is still accepted by sandboxd, so use it directly for the
301+ // cella exchange instead of persisting the short-lived auth token.
297302func exchangeForCellaToken (ctx context.Context , opts deviceFlowOpts , authToken string ) (string , error ) {
298303 authBase := opts .AuthURL
299304 if authBase == "" {
@@ -324,6 +329,9 @@ func exchangeForCellaToken(ctx context.Context, opts deviceFlowOpts, authToken s
324329 defer func () { _ = resp .Body .Close () }()
325330 if resp .StatusCode / 100 != 2 {
326331 b , _ := io .ReadAll (io .LimitReader (resp .Body , 1 << 14 ))
332+ if resp .StatusCode == http .StatusUnauthorized && strings .Contains (string (b ), "audience mismatch" ) {
333+ return exchangeAtCella (ctx , httpc , apiBase , authToken )
334+ }
327335 return "" , fmt .Errorf ("actor-tokens %d: %s" , resp .StatusCode , b )
328336 }
329337 var actor struct {
@@ -334,17 +342,21 @@ func exchangeForCellaToken(ctx context.Context, opts deviceFlowOpts, authToken s
334342 }
335343
336344 // 2. Exchange the actor token at cella.
345+ return exchangeAtCella (ctx , httpc , apiBase , actor .ActorToken )
346+ }
347+
348+ func exchangeAtCella (ctx context.Context , httpc * http.Client , apiBase , bearer string ) (string , error ) {
337349 hostname , _ := os .Hostname ()
338350 if hostname == "" {
339351 hostname = "CLI"
340352 }
341- body , _ = json .Marshal (map [string ]any {"label" : "CLI on " + hostname })
353+ body , _ : = json .Marshal (map [string ]any {"label" : "CLI on " + hostname })
342354 req2 , err := http .NewRequestWithContext (ctx , http .MethodPost , apiBase + "/v1/tokens/exchange" , strings .NewReader (string (body )))
343355 if err != nil {
344356 return "" , err
345357 }
346358 req2 .Header .Set ("Content-Type" , "application/json" )
347- req2 .Header .Set ("Authorization" , "Bearer " + actor . ActorToken )
359+ req2 .Header .Set ("Authorization" , "Bearer " + bearer )
348360 resp2 , err := httpc .Do (req2 )
349361 if err != nil {
350362 return "" , err
@@ -410,33 +422,137 @@ func newAuthWhoamiCmd() *cobra.Command {
410422 Scopes []string `json:"scopes"`
411423 ClientID string `json:"client_id,omitempty"`
412424 }
413- if err := req .GetJSON (cmd .Context (), "/tokeninfo" , & info ); err != nil {
414- return err
415- }
416- fmt .Printf ("sub: %s\n " , info .Sub )
417- if info .Email != nil && * info .Email != "" {
418- fmt .Printf ("email: %s\n " , * info .Email )
419- }
420- fmt .Printf ("principal: %s\n " , info .PrincipalType )
421- if info .OrgID != nil && * info .OrgID != "" {
422- fmt .Printf ("context: org\n " )
423- fmt .Printf ("org_id: %s\n " , * info .OrgID )
425+ if err := req .GetJSON (cmd .Context (), "/tokeninfo" , & info ); err == nil {
426+ printPrincipal (principalInfo {
427+ Sub : info .Sub ,
428+ Email : deref (info .Email ),
429+ PrincipalType : info .PrincipalType ,
430+ OrgID : deref (info .OrgID ),
431+ Scopes : info .Scopes ,
432+ ClientID : info .ClientID ,
433+ })
434+ return nil
424435 } else {
425- fmt .Printf ("context: personal\n " )
436+ var apiErr * api.APIError
437+ if ! errors .As (err , & apiErr ) || apiErr .Status != http .StatusUnauthorized {
438+ return err
439+ }
426440 }
427- if info .ClientID != "" {
428- fmt .Printf ("client_id: %s\n " , info .ClientID )
441+
442+ // Auth cannot introspect cella-issued tokens, and current
443+ // auth deployments also reject sandbox-audience device
444+ // tokens on /tokeninfo. Confirm sandboxd accepts the bearer,
445+ // then print the identity claims embedded in the JWT.
446+ var ignored any
447+ if err := c .GetJSON (cmd .Context (), "/v1/sandboxes" , & ignored ); err != nil {
448+ return err
429449 }
430- if len (info .Scopes ) > 0 {
431- fmt .Printf ("scopes: %s\n " , strings .Join (info .Scopes , " " ))
450+ local , err := principalFromJWT (c .Token )
451+ if err != nil {
452+ return err
432453 }
454+ printPrincipal (local )
433455 return nil
434456 },
435457 }
436458 cmd .Flags ().StringVar (& apiURL , "api-url" , "" , "override sandboxd base URL" )
437459 return cmd
438460}
439461
462+ type principalInfo struct {
463+ Sub string
464+ Email string
465+ PrincipalType string
466+ OrgID string
467+ Scopes []string
468+ ClientID string
469+ }
470+
471+ func printPrincipal (info principalInfo ) {
472+ fmt .Printf ("sub: %s\n " , info .Sub )
473+ if info .Email != "" {
474+ fmt .Printf ("email: %s\n " , info .Email )
475+ }
476+ fmt .Printf ("principal: %s\n " , info .PrincipalType )
477+ if info .OrgID != "" {
478+ fmt .Printf ("context: org\n " )
479+ fmt .Printf ("org_id: %s\n " , info .OrgID )
480+ } else {
481+ fmt .Printf ("context: personal\n " )
482+ }
483+ if info .ClientID != "" {
484+ fmt .Printf ("client_id: %s\n " , info .ClientID )
485+ }
486+ if len (info .Scopes ) > 0 {
487+ fmt .Printf ("scopes: %s\n " , strings .Join (info .Scopes , " " ))
488+ }
489+ }
490+
491+ func deref (s * string ) string {
492+ if s == nil {
493+ return ""
494+ }
495+ return * s
496+ }
497+
498+ func principalFromJWT (raw string ) (principalInfo , error ) {
499+ parts := strings .Split (raw , "." )
500+ if len (parts ) < 2 {
501+ return principalInfo {}, errors .New ("saved token is not a JWT" )
502+ }
503+ payload , err := base64 .RawURLEncoding .DecodeString (parts [1 ])
504+ if err != nil {
505+ return principalInfo {}, fmt .Errorf ("decode token payload: %w" , err )
506+ }
507+ var claims map [string ]any
508+ if err := json .Unmarshal (payload , & claims ); err != nil {
509+ return principalInfo {}, fmt .Errorf ("parse token payload: %w" , err )
510+ }
511+ info := principalInfo {
512+ Sub : stringClaim (claims , "sub" ),
513+ Email : stringClaim (claims , "email" ),
514+ PrincipalType : stringClaim (claims , "principal_type" ),
515+ OrgID : stringClaim (claims , "org_id" ),
516+ Scopes : scopesClaim (claims ),
517+ ClientID : stringClaim (claims , "client_id" ),
518+ }
519+ if info .Sub == "" {
520+ return principalInfo {}, errors .New ("saved token is missing sub" )
521+ }
522+ if info .PrincipalType == "" {
523+ info .PrincipalType = "user"
524+ }
525+ return info , nil
526+ }
527+
528+ func stringClaim (claims map [string ]any , key string ) string {
529+ v , _ := claims [key ].(string )
530+ return v
531+ }
532+
533+ func scopesClaim (claims map [string ]any ) []string {
534+ if scope , _ := claims ["scope" ].(string ); scope != "" {
535+ return strings .Fields (scope )
536+ }
537+ raw , ok := claims ["scp" ]
538+ if ! ok {
539+ return nil
540+ }
541+ switch v := raw .(type ) {
542+ case string :
543+ return strings .Fields (v )
544+ case []any :
545+ out := make ([]string , 0 , len (v ))
546+ for _ , item := range v {
547+ if s , ok := item .(string ); ok && s != "" {
548+ out = append (out , s )
549+ }
550+ }
551+ return out
552+ }
553+ return nil
554+ }
555+
440556func newAuthLogoutCmd () * cobra.Command {
441557 return & cobra.Command {
442558 Use : "logout" ,
0 commit comments