Skip to content

Commit d4ab844

Browse files
authored
Merge pull request #145 from datum-cloud/fix/144-machine-account-minimal-fields
fix: make machine account credentials files portable across environments
2 parents 02a596c + f18cf5a commit d4ab844

File tree

2 files changed

+73
-41
lines changed

2 files changed

+73
-41
lines changed

internal/cmd/auth/login.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,14 @@ cannot be derived from the auth hostname (e.g., in self-hosted environments).`,
7474
datumctl auth login --hostname auth.example.com --client-id 123456789
7575
7676
# Log in to a self-hosted environment with explicit API hostname
77-
datumctl auth login --hostname auth.example.com --api-hostname api.example.com --client-id 123456789`,
77+
datumctl auth login --hostname auth.example.com --api-hostname api.example.com --client-id 123456789
78+
79+
# Log in with a machine account credentials file (hostname is required
80+
# to tell datumctl which environment to authenticate against)
81+
datumctl auth login --credentials ./my-key.json --hostname auth.staging.env.datum.net`,
7882
RunE: func(cmd *cobra.Command, args []string) error {
7983
if credentialsFile != "" {
80-
return runMachineAccountLogin(cmd.Context(), credentialsFile, debugCredentials)
84+
return runMachineAccountLogin(cmd.Context(), credentialsFile, hostname, apiHostname, debugCredentials)
8185
}
8286

8387
var actualClientID string

internal/cmd/auth/machine_account_login.go

Lines changed: 67 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,29 @@ import (
55
"encoding/base64"
66
"encoding/json"
77
"fmt"
8-
"net/url"
98
"os"
109
"strings"
1110

11+
"github.com/coreos/go-oidc/v3/oidc"
12+
1213
"go.datum.net/datumctl/internal/authutil"
1314
"go.datum.net/datumctl/internal/keyring"
1415
)
1516

17+
// defaultMachineAccountScope is used when the credentials file does not
18+
// specify a scope. The file's scope field is still honored for backward
19+
// compatibility; new credentials files should omit it.
20+
const defaultMachineAccountScope = "openid profile email offline_access"
21+
1622
// runMachineAccountLogin handles the --credentials flag path for `datumctl auth login`.
17-
// It reads a machine account credentials file, mints a JWT, exchanges it for an
18-
// initial access token, and stores the resulting session in the keyring.
19-
func runMachineAccountLogin(ctx context.Context, credentialsPath string, debug bool) error {
23+
// It reads a machine account credentials file, discovers the token endpoint via OIDC
24+
// well-known config, mints a JWT, exchanges it for an initial access token, and stores
25+
// the resulting session in the keyring.
26+
//
27+
// hostname is the auth server hostname (e.g., "auth.datum.net"), taken from the --hostname
28+
// flag. apiHostname is the API server hostname; when empty, it is derived from hostname
29+
// using authutil.DeriveAPIHostname.
30+
func runMachineAccountLogin(ctx context.Context, credentialsPath, hostname, apiHostname string, debug bool) error {
2031
data, err := os.ReadFile(credentialsPath)
2132
if err != nil {
2233
return fmt.Errorf("failed to read credentials file %q: %w", credentialsPath, err)
@@ -32,14 +43,8 @@ func runMachineAccountLogin(ctx context.Context, credentialsPath string, debug b
3243
return fmt.Errorf("unsupported credentials type %q: expected \"datum_machine_account\"", creds.Type)
3344
}
3445

35-
// Validate all required fields are present.
46+
// Validate only the fields that cannot be discovered or derived.
3647
missing := []string{}
37-
if creds.TokenURI == "" {
38-
missing = append(missing, "token_uri")
39-
}
40-
if creds.ClientEmail == "" {
41-
missing = append(missing, "client_email")
42-
}
4348
if creds.ClientID == "" {
4449
missing = append(missing, "client_id")
4550
}
@@ -50,11 +55,38 @@ func runMachineAccountLogin(ctx context.Context, credentialsPath string, debug b
5055
missing = append(missing, "private_key")
5156
}
5257
if len(missing) > 0 {
53-
return fmt.Errorf("credentials file is missing required fields: %v", missing)
58+
return fmt.Errorf("credentials file is missing required fields: %s", strings.Join(missing, ", "))
5459
}
5560

56-
// Mint the initial JWT assertion.
57-
signedJWT, err := authutil.MintJWT(creds.ClientID, creds.PrivateKeyID, creds.PrivateKey, creds.TokenURI)
61+
// Discover the token endpoint from the OIDC provider's well-known config.
62+
// This mirrors the pattern used by the interactive login flow in login.go.
63+
providerURL := fmt.Sprintf("https://%s", hostname)
64+
provider, err := oidc.NewProvider(ctx, providerURL)
65+
if err != nil {
66+
return fmt.Errorf("failed to discover OIDC provider at %s: %w (pass --hostname to point datumctl at your Datum Cloud auth server)", providerURL, err)
67+
}
68+
tokenURI := provider.Endpoint().TokenURL
69+
70+
// Resolve the scope to use. Honor the file's scope for backward compatibility;
71+
// otherwise fall back to the default that mirrors the interactive login flow.
72+
scope := creds.Scope
73+
if scope == "" {
74+
scope = defaultMachineAccountScope
75+
}
76+
77+
// Resolve the API hostname. Use the flag value when provided; otherwise derive
78+
// it from the auth hostname using the same logic as the interactive login flow.
79+
finalAPIHostname := apiHostname
80+
if finalAPIHostname == "" {
81+
derived, err := authutil.DeriveAPIHostname(hostname)
82+
if err != nil {
83+
return fmt.Errorf("failed to derive API hostname from auth hostname %q: %w", hostname, err)
84+
}
85+
finalAPIHostname = derived
86+
}
87+
88+
// Mint the initial JWT assertion using the discovered token URI.
89+
signedJWT, err := authutil.MintJWT(creds.ClientID, creds.PrivateKeyID, creds.PrivateKey, tokenURI)
5890
if err != nil {
5991
return fmt.Errorf("failed to mint JWT: %w", err)
6092
}
@@ -68,39 +100,28 @@ func runMachineAccountLogin(ctx context.Context, credentialsPath string, debug b
68100
fmt.Fprintf(os.Stderr, "\n--- JWT header ---\n%s\n", hdr)
69101
fmt.Fprintf(os.Stderr, "--- JWT claims ---\n%s\n", claims)
70102
}
71-
fmt.Fprintf(os.Stderr, "\n--- Token request ---\nPOST %s\nassertion=%s...\n", creds.TokenURI, signedJWT[:40])
103+
fmt.Fprintf(os.Stderr, "\n--- Token request ---\nPOST %s\nassertion=%s...\n", tokenURI, signedJWT[:40])
72104
}
73105

74-
// Exchange for an access token.
75-
token, err := authutil.ExchangeJWT(ctx, creds.TokenURI, signedJWT, creds.Scope)
106+
// Exchange for an access token using the discovered token URI.
107+
token, err := authutil.ExchangeJWT(ctx, tokenURI, signedJWT, scope)
76108
if err != nil {
77109
return fmt.Errorf("failed to exchange JWT for access token: %w", err)
78110
}
79111

80-
// Derive auth hostname from token_uri (e.g. "auth.datum.net").
81-
tokenURIParsed, err := url.Parse(creds.TokenURI)
82-
if err != nil {
83-
return fmt.Errorf("failed to parse token_uri %q: %w", creds.TokenURI, err)
84-
}
85-
authHostname := tokenURIParsed.Host
86-
87-
// Derive api hostname from api_endpoint (e.g. "api.datum.net").
88-
var apiHostname string
89-
if creds.APIEndpoint != "" {
90-
apiEndpointParsed, err := url.Parse(creds.APIEndpoint)
91-
if err != nil {
92-
return fmt.Errorf("failed to parse api_endpoint %q: %w", creds.APIEndpoint, err)
93-
}
94-
apiHostname = apiEndpointParsed.Host
112+
// Determine the display name. Prefer client_email if present; fall back to client_id.
113+
displayName := creds.ClientEmail
114+
if displayName == "" {
115+
displayName = creds.ClientID
95116
}
96117

97118
stored := authutil.StoredCredentials{
98-
Hostname: authHostname,
99-
APIHostname: apiHostname,
119+
Hostname: hostname,
120+
APIHostname: finalAPIHostname,
100121
ClientID: creds.ClientID,
101-
EndpointTokenURL: creds.TokenURI,
122+
EndpointTokenURL: tokenURI,
102123
Token: token,
103-
UserName: creds.ClientEmail,
124+
UserName: displayName,
104125
UserEmail: creds.ClientEmail,
105126
Subject: creds.ClientID,
106127
CredentialType: "machine_account",
@@ -109,12 +130,19 @@ func runMachineAccountLogin(ctx context.Context, credentialsPath string, debug b
109130
ClientID: creds.ClientID,
110131
PrivateKeyID: creds.PrivateKeyID,
111132
PrivateKey: creds.PrivateKey,
112-
TokenURI: creds.TokenURI,
113-
Scope: creds.Scope,
133+
// Store the discovered token URI and resolved scope so that the
134+
// machineAccountTokenSource can refresh tokens without re-reading
135+
// the credentials file.
136+
TokenURI: tokenURI,
137+
Scope: scope,
114138
},
115139
}
116140

141+
// Use client_email as the keyring key when available; fall back to client_id.
117142
userKey := creds.ClientEmail
143+
if userKey == "" {
144+
userKey = creds.ClientID
145+
}
118146

119147
credsJSON, err := json.Marshal(stored)
120148
if err != nil {
@@ -133,6 +161,6 @@ func runMachineAccountLogin(ctx context.Context, credentialsPath string, debug b
133161
fmt.Printf("Warning: Failed to update list of known users: %v\n", err)
134162
}
135163

136-
fmt.Printf("Authenticated as machine account: %s\n", creds.ClientEmail)
164+
fmt.Printf("Authenticated as machine account: %s\n", displayName)
137165
return nil
138166
}

0 commit comments

Comments
 (0)