Skip to content

Commit a75fab7

Browse files
authored
auth: rewrite terraform provider auth (#103)
Before this PR, the terraform provider looks at your existing Docker credential store, grabs your pull credentials, and uses them to make API calls. This works well in CI environments when you have an OAT or username/password auth. It works very poorly in Desktop environments. Desktop generates a PAT that doesn't work well for interacting with APIs. To handle this case, we first try to check if there's a Desktop JWT with a valid session. If there is, we use the Desktop JWT for making API calls. This works much better, particularly for orgs that enforce SSO on Desktop. Fixes #84 Signed-off-by: Nick Santos <[email protected]>
1 parent e919807 commit a75fab7

6 files changed

Lines changed: 382 additions & 182 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
*.tfstate.backup
44

55
# Store acceptance testing secrets in .env
6-
.env
6+
.env

internal/auth/config.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
Copyright 2024 Docker Terraform Provider authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package auth
18+
19+
import (
20+
"fmt"
21+
"os"
22+
"strings"
23+
"time"
24+
25+
"github.com/docker/cli/cli/config"
26+
"github.com/docker/cli/cli/config/configfile"
27+
"github.com/go-jose/go-jose/v3/jwt"
28+
)
29+
30+
// ConfigStore wraps the config file and provides credential access methods
31+
type ConfigStore struct {
32+
configFile *configfile.ConfigFile
33+
}
34+
35+
// NewConfigStore creates a new ConfigStore with the default config file
36+
func NewConfigStore() *ConfigStore {
37+
return &ConfigStore{
38+
configFile: config.LoadDefaultConfigFile(os.Stderr),
39+
}
40+
}
41+
42+
// GetCredentialStorePullTokens retrieves the user credentials the the user is
43+
// using to pull. This may be a username/password, a PAT, or an OAT.
44+
func (c *ConfigStore) GetCredentialStorePullTokens(registryEntry string) (string, string, error) {
45+
return c.readUserCreds(registryEntry)
46+
}
47+
48+
// GetCredentialStoreAccessTokens retrieves the JWT that Desktop uses for API
49+
// sessions.
50+
func (c *ConfigStore) GetCredentialStoreAccessTokens(registryEntry string) (string, string, error) {
51+
accessTokenKey := c.getAccessTokenConfigKey(registryEntry)
52+
username, accessToken, err := c.readUserCreds(accessTokenKey)
53+
if err == nil && accessToken != "" {
54+
// Check if the accessToken is a valid JWT and not expired
55+
if isJWTAcceptable(accessToken) {
56+
return username, accessToken, nil
57+
}
58+
}
59+
60+
return "", "", fmt.Errorf("no valid JWT found for %s", registryEntry)
61+
}
62+
63+
// readUserCreds reads user credentials from the config file for a given
64+
// registry entry
65+
func (c *ConfigStore) readUserCreds(registryEntry string) (string, string, error) {
66+
authConfig, err := c.configFile.GetAuthConfig(registryEntry)
67+
if err != nil {
68+
return "", "", fmt.Errorf("get auth config: %w", err)
69+
}
70+
username := authConfig.Username
71+
secret := authConfig.Password
72+
if authConfig.IdentityToken != "" {
73+
secret = authConfig.IdentityToken
74+
}
75+
76+
return username, secret, nil
77+
}
78+
79+
// getAccessTokenConfigKey generates the config key for access tokens
80+
// (used by Desktop)
81+
func (c *ConfigStore) getAccessTokenConfigKey(configKey string) string {
82+
result := configKey
83+
if !strings.HasSuffix(result, "/") {
84+
result = result + "/"
85+
}
86+
return result + "access-token"
87+
}
88+
89+
// Check if we think the JWT is still usable
90+
// and worth sending. Not used for security, just used
91+
// as a simple heuristic of which token is better.
92+
func isJWTAcceptable(token string) bool {
93+
claims, err := getClaims(token)
94+
if err != nil {
95+
return false
96+
}
97+
if claims.Expiry == nil {
98+
return false
99+
}
100+
expiry := claims.Expiry.Time()
101+
return time.Now().Before(expiry)
102+
}
103+
104+
// getClaims returns claims from an access token without verification.
105+
func getClaims(accessToken string) (*jwt.Claims, error) {
106+
token, err := jwt.ParseSigned(accessToken)
107+
if err != nil {
108+
return nil, err
109+
}
110+
111+
var claims jwt.Claims
112+
err = token.UnsafeClaimsWithoutVerification(&claims)
113+
if err != nil {
114+
return nil, err
115+
}
116+
117+
return &claims, nil
118+
}

internal/auth/token.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/*
2+
Copyright 2024 Docker Terraform Provider authors
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package auth
18+
19+
import (
20+
"bytes"
21+
"context"
22+
"encoding/json"
23+
"fmt"
24+
"net/http"
25+
"sync"
26+
"time"
27+
)
28+
29+
// LoginTokenProvider uses static username/password to obtain tokens via login API
30+
// The name of this struct was specifically chosen
31+
// to troll iam-team :)
32+
type LoginTokenProvider struct {
33+
username string
34+
password string
35+
baseURL string
36+
httpClient *http.Client
37+
token string
38+
tokenExpiry time.Time
39+
mu sync.Mutex
40+
}
41+
42+
// NewLoginTokenProvider creates a token provider that uses username/password
43+
func NewLoginTokenProvider(username, password, baseURL string) *LoginTokenProvider {
44+
return &LoginTokenProvider{
45+
username: username,
46+
password: password,
47+
baseURL: baseURL,
48+
httpClient: http.DefaultClient,
49+
}
50+
}
51+
52+
// EnsureToken returns a cached token if valid, otherwise authenticates with
53+
// username/password to get a new token from the Docker Hub API.
54+
func (p *LoginTokenProvider) EnsureToken(ctx context.Context) (string, error) {
55+
p.mu.Lock()
56+
defer p.mu.Unlock()
57+
58+
// Return cached token if still valid
59+
if p.token != "" && time.Now().Before(p.tokenExpiry) {
60+
return p.token, nil
61+
}
62+
63+
// Request new token
64+
auth := struct {
65+
Identifier string `json:"identifier"`
66+
Secret string `json:"secret"`
67+
}{
68+
Identifier: p.username,
69+
Secret: p.password,
70+
}
71+
72+
authJSON, err := json.Marshal(auth)
73+
if err != nil {
74+
return "", fmt.Errorf("marshal auth: %v", err)
75+
}
76+
77+
req, err := http.NewRequestWithContext(ctx, "POST", fmt.Sprintf("%s/auth/token", p.baseURL), bytes.NewBuffer(authJSON))
78+
if err != nil {
79+
return "", err
80+
}
81+
req.Header.Set("Content-Type", "application/json")
82+
83+
res, err := p.httpClient.Do(req)
84+
if err != nil {
85+
return "", fmt.Errorf("login request: %v", err)
86+
}
87+
defer res.Body.Close()
88+
89+
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
90+
return "", fmt.Errorf("login failed: %s", res.Status)
91+
}
92+
93+
var tokenResponse struct {
94+
AccessToken string `json:"access_token"`
95+
}
96+
if err := json.NewDecoder(res.Body).Decode(&tokenResponse); err != nil {
97+
return "", fmt.Errorf("decode token response: %v", err)
98+
}
99+
100+
// Parse token expiry
101+
claims, err := getClaims(tokenResponse.AccessToken)
102+
if err != nil {
103+
return "", fmt.Errorf("parse token claims: %v", err)
104+
}
105+
if claims.Expiry == nil {
106+
return "", fmt.Errorf("token does not contain expiry")
107+
}
108+
109+
// Cache the token
110+
p.token = tokenResponse.AccessToken
111+
p.tokenExpiry = claims.Expiry.Time()
112+
113+
return p.token, nil
114+
}
115+
116+
func (p *LoginTokenProvider) Username() string {
117+
return p.username
118+
}
119+
120+
// AccessTokenProvider uses access tokens directly from the credential store
121+
type AccessTokenProvider struct {
122+
configKey string
123+
cachedUsername string
124+
configStore *ConfigStore
125+
mu sync.Mutex
126+
}
127+
128+
// NewAccessTokenProvider creates a token provider that uses access tokens from the credential store
129+
func NewAccessTokenProvider(configStore *ConfigStore, configKey string) *AccessTokenProvider {
130+
return &AccessTokenProvider{
131+
configKey: configKey,
132+
configStore: configStore,
133+
}
134+
}
135+
136+
func (p *AccessTokenProvider) EnsureToken(ctx context.Context) (string, error) {
137+
p.mu.Lock()
138+
defer p.mu.Unlock()
139+
140+
// Always get fresh access token from store (no caching for access tokens)
141+
username, accessToken, err := p.configStore.GetCredentialStoreAccessTokens(p.configKey)
142+
if err != nil {
143+
return "", fmt.Errorf("get access token from store: %v", err)
144+
}
145+
146+
// Cache username for display purposes
147+
p.cachedUsername = username
148+
149+
return accessToken, nil
150+
}
151+
152+
func (p *AccessTokenProvider) Username() string {
153+
p.mu.Lock()
154+
defer p.mu.Unlock()
155+
return p.cachedUsername
156+
}
157+
158+
// Helper methods for creating token providers
159+
160+
// NewAccessTokenProviderFromStore creates an AccessTokenProvider from a ConfigStore
161+
// Returns error if no valid access token is available
162+
func NewAccessTokenProviderFromStore(configStore *ConfigStore, configKey string) (*AccessTokenProvider, error) {
163+
// Test if we can get a valid access token
164+
_, _, err := configStore.GetCredentialStoreAccessTokens(configKey)
165+
if err != nil {
166+
return nil, fmt.Errorf("no valid access token available: %v", err)
167+
}
168+
169+
return NewAccessTokenProvider(configStore, configKey), nil
170+
}
171+
172+
// NewLoginTokenProviderFromStore creates a LoginTokenProvider from pull credentials in the ConfigStore
173+
func NewLoginTokenProviderFromStore(configStore *ConfigStore, configKey, baseURL string) (*LoginTokenProvider, error) {
174+
username, password, err := configStore.GetCredentialStorePullTokens(configKey)
175+
if err != nil {
176+
return nil, fmt.Errorf("no pull credentials available: %v", err)
177+
}
178+
179+
if username == "" {
180+
return nil, fmt.Errorf("empty username found in store")
181+
}
182+
183+
if password == "" {
184+
return nil, fmt.Errorf("empty password found in store")
185+
}
186+
187+
return NewLoginTokenProvider(username, password, baseURL), nil
188+
}

0 commit comments

Comments
 (0)