-
Notifications
You must be signed in to change notification settings - Fork 13
Introduce authenticated client in Go using the Legacy CLI #260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
6fd4e4e
feat(auth): introduce authenticated client in Go using the Legacy CLI
akalipetis 7e77910
feat: improve logging in the transport
akalipetis 85d89f2
feat: fix locking
akalipetis 37649cc
feat: simplify parsing of the JWT token
akalipetis c87c650
fixup! feat: simplify parsing of the JWT token
akalipetis de1283d
fix: handle error for token invalidation
akalipetis a82e1c2
fix: skip importing `go-jose` just for parsing a date
akalipetis bb5b0f2
feat(tests): add tests for the retry logic in the transport
akalipetis cbb3c49
fix(tests): make them work nicely on macos
akalipetis 7cae987
chore: simplify logging mechanism in the transport
akalipetis 362dde7
Language tweak in log message
pjcdawkins 2f203f8
Fix validate on OAuth2ClientID
pjcdawkins 63b2a91
Update internal/auth/client.go
pjcdawkins File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
pjcdawkins marked this conversation as resolved.
Show resolved
Hide resolved
pjcdawkins marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ts.wrapper.Stdout = io.Discard | ||
pjcdawkins marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 { | ||
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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, | ||
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }, nil | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| 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() | ||
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return io.NopCloser(bytes.NewBuffer(bodyBytes)) | ||
| } | ||
|
|
||
| func flushReader(r io.ReadCloser) { | ||
| if r == nil { | ||
| return | ||
| } | ||
| _, _ = io.Copy(io.Discard, r) | ||
| _ = r.Close() | ||
akalipetis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.