Skip to content

Commit 02a596c

Browse files
authored
Merge pull request #137 from datum-cloud/feat/machine-account-login
feat: add machine account login via credentials file
2 parents 1c0874e + d75c366 commit 02a596c

File tree

7 files changed

+409
-20
lines changed

7 files changed

+409
-20
lines changed

flake.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
# Hash of Go module dependencies.
4242
# Update this after changing go.mod/go.sum:
4343
# task nix-update-hash
44-
vendorHash = "sha256-e8StaOrjkhc3VGyoCndfenrM4onTk2/QvmylKvR2+bs=";
44+
vendorHash = "sha256-JMIKdKmT9g4isKgg4/CFmTil7T4FUlpC0p5Wqiunc9Q=";
4545

4646
ldflags = [
4747
"-s"

go.mod

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ toolchain go1.26.2
66

77
require (
88
github.com/coreos/go-oidc/v3 v3.18.0
9+
github.com/go-jose/go-jose/v4 v4.1.4
10+
github.com/google/uuid v1.6.0
911
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
1012
github.com/rodaine/table v1.3.1
1113
github.com/spf13/cobra v1.10.2
@@ -25,7 +27,6 @@ require (
2527
)
2628

2729
require (
28-
al.essio.dev/pkg/shellescape v1.5.1 // indirect
2930
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
3031
github.com/MakeNowJust/heredoc v1.0.0 // indirect
3132
github.com/beorn7/perks v1.0.1 // indirect
@@ -42,7 +43,6 @@ require (
4243
github.com/fatih/camelcase v1.0.0 // indirect
4344
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
4445
github.com/go-errors/errors v1.5.1 // indirect
45-
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
4646
github.com/go-logr/logr v1.4.3 // indirect
4747
github.com/go-openapi/jsonpointer v0.22.4 // indirect
4848
github.com/go-openapi/jsonreference v0.21.4 // indirect
@@ -62,7 +62,6 @@ require (
6262
github.com/google/btree v1.1.3 // indirect
6363
github.com/google/gnostic-models v0.7.1 // indirect
6464
github.com/google/go-cmp v0.7.0 // indirect
65-
github.com/google/uuid v1.6.0 // indirect
6665
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
6766
github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect
6867
github.com/inconshreveable/mousetrap v1.1.0 // indirect

go.sum

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho=
2-
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
31
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
42
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
53
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@@ -18,16 +16,12 @@ github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnT
1816
github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
1917
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
2018
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
21-
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
22-
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
2319
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
2420
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
2521
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
2622
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
2723
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
2824
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
29-
github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
30-
github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
3125
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
3226
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
3327
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -90,8 +84,6 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6
9084
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
9185
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
9286
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
93-
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
94-
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
9587
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
9688
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
9789
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
@@ -103,8 +95,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
10395
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
10496
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
10597
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
106-
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
107-
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
10898
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
10999
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
110100
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
@@ -195,8 +185,6 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
195185
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
196186
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
197187
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
198-
github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=
199-
github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI=
200188
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
201189
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
202190
go.miloapis.com/activity v0.3.1 h1:Yq8pdfphiAqr3DqZNQ0a50SadHrbdZyqng/HEwHe4WI=

internal/authutil/credentials.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,17 @@ var ErrNoActiveUser = customerrors.NewUserErrorWithHint(
3030
"Please login first using: `datumctl auth login`",
3131
)
3232

33+
// MachineAccountState holds fields needed to re-mint a JWT when the access token expires.
34+
// Only populated when CredentialType == "machine_account".
35+
type MachineAccountState struct {
36+
ClientEmail string `json:"client_email"`
37+
ClientID string `json:"client_id"`
38+
PrivateKeyID string `json:"private_key_id"`
39+
PrivateKey string `json:"private_key"`
40+
TokenURI string `json:"token_uri"`
41+
Scope string `json:"scope,omitempty"`
42+
}
43+
3344
// StoredCredentials holds all necessary information for a single authenticated session.
3445
type StoredCredentials struct {
3546
Hostname string `json:"hostname"` // The auth server hostname used (e.g., auth.datum.net)
@@ -42,6 +53,11 @@ type StoredCredentials struct {
4253
UserName string `json:"user_name"` // User's Name (e.g., from 'name' claim)
4354
UserEmail string `json:"user_email"` // User's Email (e.g., from 'email' claim)
4455
Subject string `json:"subject"` // User's Subject ID (sub claim from JWT)
56+
// CredentialType distinguishes how stored credentials should be refreshed.
57+
// "" or "interactive" → standard oauth2 refresh token path.
58+
// "machine_account" → re-mint JWT and re-exchange on expiry.
59+
CredentialType string `json:"credential_type,omitempty"`
60+
MachineAccount *MachineAccountState `json:"machine_account,omitempty"`
4561
}
4662

4763
// GetActiveCredentials retrieves the StoredCredentials for the currently active user.
@@ -152,6 +168,17 @@ func GetTokenSource(ctx context.Context) (oauth2.TokenSource, error) {
152168
return nil, err
153169
}
154170

171+
if creds.CredentialType == "machine_account" {
172+
if creds.MachineAccount == nil {
173+
return nil, fmt.Errorf("machine account credentials are missing from stored session")
174+
}
175+
return &machineAccountTokenSource{
176+
ctx: ctx,
177+
creds: creds,
178+
userKey: userKey,
179+
}, nil
180+
}
181+
155182
// Rebuild the oauth2.Config needed for refreshing
156183
conf := &oauth2.Config{
157184
ClientID: creds.ClientID,
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Package authutil provides shared constants and functions for handling authentication
2+
// credentials, including storage in the system keyring and OAuth2 token management.
3+
package authutil
4+
5+
import (
6+
"context"
7+
"crypto/rsa"
8+
"crypto/x509"
9+
"encoding/json"
10+
"encoding/pem"
11+
"fmt"
12+
"io"
13+
"net/http"
14+
"net/url"
15+
"strings"
16+
"sync"
17+
"time"
18+
19+
jose "github.com/go-jose/go-jose/v4"
20+
josejwt "github.com/go-jose/go-jose/v4/jwt"
21+
"github.com/google/uuid"
22+
customerrors "go.datum.net/datumctl/internal/errors"
23+
"go.datum.net/datumctl/internal/keyring"
24+
"golang.org/x/oauth2"
25+
)
26+
27+
// MachineAccountCredentials is the on-disk JSON format downloaded from the Datum Cloud portal.
28+
type MachineAccountCredentials struct {
29+
Type string `json:"type"` // "datum_machine_account"
30+
APIEndpoint string `json:"api_endpoint"` // "https://api.datum.net"
31+
TokenURI string `json:"token_uri"` // "https://auth.datum.net/oauth/v2/token"
32+
Scope string `json:"scope"` // OAuth2 scope string, e.g. "openid profile email urn:zitadel:..."
33+
ProjectID string `json:"project_id"`
34+
ClientEmail string `json:"client_email"` // identity e-mail, used as display name
35+
ClientID string `json:"client_id"` // numeric Zitadel user ID (iss / sub)
36+
PrivateKeyID string `json:"private_key_id"` // kid header
37+
PrivateKey string `json:"private_key"` // PEM-encoded RSA private key
38+
}
39+
40+
// tokenResponse is a minimal struct for parsing token endpoint responses in the
41+
// JWT bearer exchange. It mirrors the fields we care about from deviceTokenResponse
42+
// without creating a circular import with the auth command package.
43+
type tokenResponse struct {
44+
AccessToken string `json:"access_token"`
45+
TokenType string `json:"token_type"`
46+
ExpiresIn int64 `json:"expires_in"`
47+
Error string `json:"error"`
48+
ErrorDesc string `json:"error_description"`
49+
}
50+
51+
// MintJWT mints a signed RS256 JWT suitable for the jwt-bearer grant.
52+
// Claims: iss=clientID, sub=clientID, aud=issuer (scheme+host of tokenURI),
53+
// kid=privateKeyID, jti=random UUID, iat=now, exp=now+60s.
54+
func MintJWT(clientID, privateKeyID, privateKeyPEM, tokenURI string) (string, error) {
55+
block, _ := pem.Decode([]byte(privateKeyPEM))
56+
if block == nil {
57+
return "", fmt.Errorf("failed to decode PEM block from private key")
58+
}
59+
60+
var rsaKey *rsa.PrivateKey
61+
// Try PKCS#1 first, fall back to PKCS#8.
62+
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
63+
rsaKey = key
64+
} else {
65+
key8, err := x509.ParsePKCS8PrivateKey(block.Bytes)
66+
if err != nil {
67+
return "", fmt.Errorf("failed to parse private key (tried PKCS#1 and PKCS#8): %w", err)
68+
}
69+
var ok bool
70+
rsaKey, ok = key8.(*rsa.PrivateKey)
71+
if !ok {
72+
return "", fmt.Errorf("private key is not an RSA key")
73+
}
74+
}
75+
76+
// aud must be the issuer (scheme+host), not the full token endpoint URL.
77+
u, err := url.Parse(tokenURI)
78+
if err != nil {
79+
return "", fmt.Errorf("failed to parse token URI: %w", err)
80+
}
81+
issuer := u.Scheme + "://" + u.Host
82+
83+
jwk := jose.JSONWebKey{Key: rsaKey, KeyID: privateKeyID}
84+
85+
sig, err := jose.NewSigner(
86+
jose.SigningKey{Algorithm: jose.RS256, Key: jwk},
87+
(&jose.SignerOptions{}).WithType("JWT"),
88+
)
89+
if err != nil {
90+
return "", fmt.Errorf("failed to create JWT signer: %w", err)
91+
}
92+
93+
now := time.Now()
94+
signed, err := josejwt.Signed(sig).
95+
Claims(josejwt.Claims{
96+
Issuer: clientID,
97+
Subject: clientID,
98+
Audience: josejwt.Audience{issuer},
99+
IssuedAt: josejwt.NewNumericDate(now),
100+
Expiry: josejwt.NewNumericDate(now.Add(60 * time.Second)),
101+
ID: uuid.NewString(),
102+
}).
103+
Serialize()
104+
if err != nil {
105+
return "", fmt.Errorf("failed to serialize JWT: %w", err)
106+
}
107+
108+
return signed, nil
109+
}
110+
111+
// tokenHTTPClient is used for all JWT bearer token exchanges.
112+
// A dedicated client with a timeout prevents indefinite hangs on slow endpoints.
113+
var tokenHTTPClient = &http.Client{Timeout: 30 * time.Second}
114+
115+
// ExchangeJWT POSTs a signed JWT to tokenURI using the jwt-bearer grant and
116+
// returns the resulting oauth2.Token. The token will have no RefreshToken.
117+
// If scope is empty, "openid profile email" is used as the default.
118+
func ExchangeJWT(ctx context.Context, tokenURI, signedJWT, scope string) (*oauth2.Token, error) {
119+
u, err := url.Parse(tokenURI)
120+
if err != nil {
121+
return nil, fmt.Errorf("failed to parse token URI: %w", err)
122+
}
123+
if u.Scheme != "https" {
124+
return nil, fmt.Errorf("token_uri must use HTTPS, got %q", u.Scheme)
125+
}
126+
127+
if scope == "" {
128+
scope = "openid profile email"
129+
}
130+
form := url.Values{}
131+
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
132+
form.Set("assertion", signedJWT)
133+
form.Set("scope", scope)
134+
135+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURI, strings.NewReader(form.Encode()))
136+
if err != nil {
137+
return nil, fmt.Errorf("failed to create JWT bearer request: %w", err)
138+
}
139+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
140+
141+
resp, err := tokenHTTPClient.Do(req)
142+
if err != nil {
143+
return nil, fmt.Errorf("JWT bearer token request failed: %w", err)
144+
}
145+
defer resp.Body.Close()
146+
147+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB cap
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to read JWT bearer response: %w", err)
150+
}
151+
152+
var tr tokenResponse
153+
if err := json.Unmarshal(body, &tr); err != nil {
154+
return nil, fmt.Errorf("failed to parse JWT bearer response: %w", err)
155+
}
156+
157+
if resp.StatusCode != http.StatusOK {
158+
if tr.Error != "" {
159+
return nil, fmt.Errorf("JWT bearer exchange failed: %s (%s)", tr.Error, tr.ErrorDesc)
160+
}
161+
return nil, fmt.Errorf("JWT bearer exchange failed with status %s", resp.Status)
162+
}
163+
164+
token := &oauth2.Token{
165+
AccessToken: tr.AccessToken,
166+
TokenType: tr.TokenType,
167+
}
168+
if tr.ExpiresIn > 0 {
169+
token.Expiry = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
170+
}
171+
172+
return token, nil
173+
}
174+
175+
// machineAccountTokenSource implements oauth2.TokenSource for machine account sessions.
176+
// It re-mints a JWT and re-exchanges it whenever the stored access token has expired,
177+
// since machine account sessions have no refresh token.
178+
type machineAccountTokenSource struct {
179+
ctx context.Context
180+
creds *StoredCredentials
181+
userKey string
182+
mu sync.Mutex
183+
}
184+
185+
// Token implements oauth2.TokenSource. If the cached token is still valid it is
186+
// returned immediately. Otherwise a new JWT is minted, exchanged for an access
187+
// token, and the updated credentials are persisted to the keyring.
188+
func (m *machineAccountTokenSource) Token() (*oauth2.Token, error) {
189+
m.mu.Lock()
190+
defer m.mu.Unlock()
191+
192+
if m.creds.Token != nil && m.creds.Token.Valid() {
193+
return m.creds.Token, nil
194+
}
195+
196+
ma := m.creds.MachineAccount
197+
signedJWT, err := MintJWT(ma.ClientID, ma.PrivateKeyID, ma.PrivateKey, ma.TokenURI)
198+
if err != nil {
199+
return nil, customerrors.WrapUserErrorWithHint(
200+
"Failed to mint JWT for machine account authentication.",
201+
"Please re-authenticate using: `datumctl auth login --credentials <file>`",
202+
err,
203+
)
204+
}
205+
206+
token, err := ExchangeJWT(m.ctx, ma.TokenURI, signedJWT, ma.Scope)
207+
if err != nil {
208+
return nil, customerrors.WrapUserErrorWithHint(
209+
"Failed to exchange JWT for access token.",
210+
"Please re-authenticate using: `datumctl auth login --credentials <file>`",
211+
err,
212+
)
213+
}
214+
215+
m.creds.Token = token
216+
217+
credsJSON, err := json.Marshal(m.creds)
218+
if err != nil {
219+
// Return token even if persistence fails — the caller can still proceed.
220+
return token, fmt.Errorf("failed to marshal updated machine account credentials: %w", err)
221+
}
222+
223+
if err := keyring.Set(ServiceName, m.userKey, string(credsJSON)); err != nil {
224+
return token, fmt.Errorf("failed to persist refreshed machine account token to keyring: %w", err)
225+
}
226+
227+
return token, nil
228+
}

0 commit comments

Comments
 (0)