@@ -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 ---\n POST %s\n assertion=%s...\n " , creds . TokenURI , signedJWT [:40 ])
103+ fmt .Fprintf (os .Stderr , "\n --- Token request ---\n POST %s\n assertion=%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