Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ require (
github.com/stretchr/testify v1.10.0
github.com/symfony-cli/terminal v1.0.7
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.37.0
golang.org/x/sync v0.13.0
golang.org/x/crypto v0.39.0
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -62,7 +63,7 @@ require (
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.26.0 // indirect
)
22 changes: 12 additions & 10 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -140,18 +140,20 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
Expand All @@ -161,18 +163,18 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Expand Down
38 changes: 38 additions & 0 deletions internal/auth/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package auth

import (
"context"
"fmt"
"net/http"

"golang.org/x/oauth2"

"github.com/platformsh/cli/internal/legacy"
)

// NewLegacyCLIClient creates an HTTP client authenticated through the legacy CLI.
// The wrapper argument must be a dedicated wrapper, not used by other callers.
func NewLegacyCLIClient(ctx context.Context, wrapper *legacy.CLIWrapper) (*http.Client, error) {
ts, err := NewLegacyCLITokenSource(ctx, wrapper)
if err != nil {
return nil, fmt.Errorf("oauth2: create token source: %w", err)
}

refresher, ok := ts.(refresher)
if !ok {
return nil, fmt.Errorf("token source does not implement refresher")
}
baseRT := http.DefaultTransport
if rt, ok := TransportFromContext(ctx); ok && rt != nil {
baseRT = rt
}
return &http.Client{
Transport: &Transport{
refresher: refresher,
base: &oauth2.Transport{
Source: ts,
Base: baseRT,
},
},
}, nil
}
42 changes: 42 additions & 0 deletions internal/auth/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package auth

import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)

// unsafeGetJWTExpiry parses a JWT without verifying its signature and returns its expiry time.
// WARNING: This is intentionally unsafe and must not be used for trust decisions.
func unsafeGetJWTExpiry(token string) (time.Time, error) {
if token == "" {
return time.Time{}, errors.New("jwt: empty token")
}
parts := strings.Split(token, ".")
if len(parts) < 2 {
return time.Time{}, fmt.Errorf("jwt: malformed token, expected 3 parts, got %d", len(parts))
}
payloadSeg := parts[1]

// Base64 URL decode without padding as per RFC 7515.
payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadSeg)
if err != nil {
return time.Time{}, fmt.Errorf("jwt: decode payload: %w", err)
}

var claims struct {
ExpiresAt *int64 `json:"exp,omitempty"`
}
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
return time.Time{}, fmt.Errorf("jwt: unmarshal claims: %w", err)
}

if claims.ExpiresAt == nil {
return time.Time{}, errors.New("jwt: no expiry time found")
}

return time.Unix(*claims.ExpiresAt, 0), nil
}
110 changes: 110 additions & 0 deletions internal/auth/legacy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package auth

import (
"bytes"
"context"
"fmt"
"io"
"sync"

"golang.org/x/oauth2"

"github.com/platformsh/cli/internal/legacy"
)

type legacyCLITokenSource struct {
ctx context.Context
cached *oauth2.Token
wrapper *legacy.CLIWrapper
mu sync.Mutex
}

func (ts *legacyCLITokenSource) unsafeGetLegacyCLIToken() (*oauth2.Token, error) {
bt := bytes.NewBuffer(nil)
ts.wrapper.Stdout = bt
if err := ts.wrapper.Exec(ts.ctx, "auth:token", "-W"); err != nil {
return nil, fmt.Errorf("cannot retrieve token: %w", err)
}

expiry, err := unsafeGetJWTExpiry(bt.String())

if err != nil {
return nil, fmt.Errorf("cannot parse token: %w", err)
}

return &oauth2.Token{
AccessToken: bt.String(),
TokenType: "Bearer",
Expiry: expiry,
}, nil
}

func (ts *legacyCLITokenSource) refreshToken() error {
ts.mu.Lock()
defer ts.mu.Unlock()

return ts.unsafeRefreshToken()
}

func (ts *legacyCLITokenSource) unsafeRefreshToken() error {
ts.cached = nil
ts.wrapper.Stdout = io.Discard
if err := ts.wrapper.Exec(ts.ctx, "auth:info", "--refresh"); err != nil {
return fmt.Errorf("cannot refresh token: %w", err)
}

return nil
}

func (ts *legacyCLITokenSource) invalidateToken() error {
ts.mu.Lock()
defer ts.mu.Unlock()

return ts.unsafeInvalidateToken()
}

func (ts *legacyCLITokenSource) unsafeInvalidateToken() error {
if ts.cached != nil {
ts.cached.AccessToken = ""
}

return nil
}

func (ts *legacyCLITokenSource) Token() (*oauth2.Token, error) {
ts.mu.Lock()
defer ts.mu.Unlock()

if ts.cached == nil {
tok, err := ts.unsafeGetLegacyCLIToken()
if err != nil {
return nil, err
}
ts.cached = tok
}

if ts.cached != nil && ts.cached.Valid() {
return ts.cached, nil
}

if err := ts.unsafeRefreshToken(); err != nil {
return nil, err
}

tok, err := ts.unsafeGetLegacyCLIToken()
if err != nil {
return nil, err
}

ts.cached = tok
return ts.cached, nil
}

func NewLegacyCLITokenSource(ctx context.Context, wrapper *legacy.CLIWrapper) (oauth2.TokenSource, error) {
wrapper.ForceColor = true
wrapper.DisableInteraction = true
return &legacyCLITokenSource{
ctx: ctx,
wrapper: wrapper,
}, nil
}
95 changes: 95 additions & 0 deletions internal/auth/transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package auth

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
)

type refresher interface {
refreshToken() error
invalidateToken() error
}

// Transport is an HTTP RoundTripper similar to golang.org/x/oauth2.Transport.
// It injects Authorization headers using a savingSource and, on a 401 response,
// clears the cached token and retries the request once.
type Transport struct {
// base is the underlying oauth2.Transport that adds the Authorization header.
base http.RoundTripper

// refresher is the savingSource used as the TokenSource for base; kept private
// so we can clear its cached token on 401.
refresher refresher

LogFunc func(msg string, args ...any)
}

// RoundTrip adds Authorization via the underlying oauth2.Transport. If the
// response is 401 Unauthorized, it clears the cached token and retries once.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Body = wrapReader(req.Body)

resp, err := t.base.RoundTrip(req)

// Retry on 401
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
_ = t.log("The access token needs to be refreshed. Retrying request.")
if err := t.refresher.invalidateToken(); err != nil {
return nil, fmt.Errorf("failed to invalidate token: %w", err)
}
flushReader(resp.Body)
resp, err = t.base.RoundTrip(req)
}

return resp, err
}

func (t *Transport) log(msg string, args ...any) error {
if t.LogFunc == nil {
return nil
}
t.LogFunc(msg, args...)
return nil
}

// context key for storing a custom RoundTripper.
type transportCtxKey struct{}

// WithTransport returns a new context that carries the provided RoundTripper.
func WithTransport(ctx context.Context, rt http.RoundTripper) context.Context {
return context.WithValue(ctx, transportCtxKey{}, rt)
}

// TransportFromContext retrieves a RoundTripper previously stored with
// WithTransport. It returns (nil, false) if none is set.
func TransportFromContext(ctx context.Context) (http.RoundTripper, bool) {
v := ctx.Value(transportCtxKey{})
if v == nil {
return nil, false
}
rt, ok := v.(http.RoundTripper)
if !ok || rt == nil {
return nil, false
}
return rt, true
}

func wrapReader(r io.ReadCloser) io.ReadCloser {
if r == nil {
return nil
}
bodyBytes, _ := io.ReadAll(r)
_ = r.Close()
return io.NopCloser(bytes.NewBuffer(bodyBytes))
}

func flushReader(r io.ReadCloser) {
if r == nil {
return
}
_, _ = io.Copy(io.Discard, r)
_ = r.Close()
}
Loading
Loading