Skip to content

Commit 656e7da

Browse files
zeroshadeamoeba
andauthored
feat(auth): automatically fetch Columnar license on login (#223)
When using `dbc auth login` to login to the Columnar private registry, we'll check the token and see if it contains the `trial_start` claim, downloading the license if it exists. There's some more work that needs to happen here so I'm leaving this marked as WIP, such as: * Picking a domain for the cloudflare workers * making the worker domain configurable via env var * unit tests * verifying this workflow makes sense --------- Co-authored-by: Bryce Mecum <petridish@gmail.com>
1 parent 9db8013 commit 656e7da

File tree

6 files changed

+194
-65
lines changed

6 files changed

+194
-65
lines changed

auth/credentials.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"encoding/json"
1919
"errors"
2020
"fmt"
21+
"io/fs"
2122
"net/http"
2223
"net/url"
2324
"os"
@@ -26,6 +27,7 @@ import (
2627
"slices"
2728
"sync"
2829

30+
"github.com/golang-jwt/jwt/v5"
2931
"github.com/pelletier/go-toml/v2"
3032
)
3133

@@ -265,3 +267,80 @@ func UpdateCreds() error {
265267
Credentials: loadedCredentials,
266268
})
267269
}
270+
271+
func IsColumnarPrivateRegistry(u *url.URL) bool {
272+
return u.Host == DefaultOauthURI
273+
}
274+
275+
const licenseURI = "https://heimdall.columnar.tech/trial_license"
276+
277+
var (
278+
ErrNoTrialLicense = errors.New("no trial license found")
279+
ErrTrialExpired = errors.New("trial license has expired")
280+
)
281+
282+
func FetchColumnarLicense(cred *Credential) error {
283+
licensePath := filepath.Join(filepath.Dir(credPath), "columnar.lic")
284+
_, err := os.Stat(licensePath)
285+
if err == nil { // license exists already
286+
return nil
287+
}
288+
289+
if !errors.Is(err, fs.ErrNotExist) {
290+
return err
291+
}
292+
293+
var authToken string
294+
switch cred.Type {
295+
case TypeApiKey:
296+
authToken = cred.ApiKey
297+
case TypeToken:
298+
p := jwt.NewParser()
299+
tk, err := p.Parse(cred.GetAuthToken(), nil)
300+
if err != nil && !errors.Is(err, jwt.ErrTokenUnverifiable) {
301+
return fmt.Errorf("failed to parse oauth token: %w", err)
302+
}
303+
304+
_, ok := tk.Claims.(jwt.MapClaims)["urn:columnar:trial_start"]
305+
if !ok {
306+
return ErrNoTrialLicense
307+
}
308+
authToken = cred.GetAuthToken()
309+
default:
310+
return fmt.Errorf("unsupported credential type: %s", cred.Type)
311+
}
312+
313+
req, err := http.NewRequest(http.MethodGet, licenseURI, nil)
314+
if err != nil {
315+
return err
316+
}
317+
318+
req.Header.Add("authorization", "Bearer "+authToken)
319+
resp, err := http.DefaultClient.Do(req)
320+
if err != nil {
321+
return err
322+
}
323+
defer resp.Body.Close()
324+
325+
if resp.StatusCode != http.StatusOK {
326+
switch resp.StatusCode {
327+
case http.StatusBadRequest:
328+
return ErrNoTrialLicense
329+
case http.StatusForbidden:
330+
return ErrTrialExpired
331+
default:
332+
return fmt.Errorf("failed to fetch license: %s", resp.Status)
333+
}
334+
}
335+
336+
licenseFile, err := os.OpenFile(licensePath, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0o600)
337+
if err != nil {
338+
return err
339+
}
340+
defer licenseFile.Close()
341+
if _, err = licenseFile.ReadFrom(resp.Body); err != nil {
342+
licenseFile.Close()
343+
os.Remove(licensePath)
344+
}
345+
return err
346+
}

cmd/dbc/auth.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package main
1717
import (
1818
"bufio"
1919
"context"
20+
"errors"
2021
"fmt"
2122
"io"
2223
"net/url"
@@ -83,6 +84,10 @@ func (l LoginCmd) GetModel() tea.Model {
8384
)
8485
}
8586

87+
type authSuccessMsg struct {
88+
cred auth.Credential
89+
}
90+
8691
type loginModel struct {
8792
baseModel
8893

@@ -190,13 +195,32 @@ func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
190195
}
191196
})
192197
case auth.Credential:
193-
return m, tea.Sequence(func() tea.Msg {
198+
return m, func() tea.Msg {
194199
if err := auth.AddCredential(msg, true); err != nil {
195200
return err
196201
}
197-
return nil
198-
}, tea.Println("Authentication successful!"),
199-
tea.Quit)
202+
return authSuccessMsg{cred: msg}
203+
}
204+
case authSuccessMsg:
205+
return m, tea.Sequence(tea.Println("Authentication successful!"),
206+
func() tea.Msg {
207+
if auth.IsColumnarPrivateRegistry((*url.URL)(&msg.cred.RegistryURL)) {
208+
if err := auth.FetchColumnarLicense(&msg.cred); err != nil {
209+
return err
210+
}
211+
}
212+
return tea.Quit()
213+
})
214+
case error:
215+
switch {
216+
case errors.Is(msg, auth.ErrTrialExpired) ||
217+
errors.Is(msg, auth.ErrNoTrialLicense):
218+
// ignore these errors during auth login
219+
// the user can still login but won't be able to download trial licenses
220+
return m, tea.Quit
221+
default:
222+
// for other errors, let the baseModel update handle it.
223+
}
200224
}
201225

202226
base, cmd := m.baseModel.Update(msg)

cmd/dbc/main.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package main
1616

1717
import (
18+
"errors"
1819
"fmt"
1920
"os"
2021
"slices"
@@ -23,6 +24,7 @@ import (
2324
tea "github.com/charmbracelet/bubbletea"
2425
"github.com/charmbracelet/lipgloss"
2526
"github.com/columnar-tech/dbc"
27+
"github.com/columnar-tech/dbc/auth"
2628
"github.com/columnar-tech/dbc/cmd/dbc/completions"
2729
"github.com/columnar-tech/dbc/config"
2830
"github.com/mattn/go-isatty"
@@ -119,7 +121,16 @@ func (m baseModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
119121
}
120122
case error:
121123
m.status = 1
122-
return m, tea.Sequence(tea.Println("Error: ", msg.Error()), tea.Quit)
124+
var cmd tea.Cmd
125+
switch {
126+
case errors.Is(msg, auth.ErrTrialExpired):
127+
cmd = tea.Println(errStyle.Render("Could not download license, trial has expired"))
128+
case errors.Is(msg, auth.ErrNoTrialLicense):
129+
cmd = tea.Println(errStyle.Render("Could not download license, trial not started"))
130+
default:
131+
cmd = tea.Println("Error: ", msg.Error())
132+
}
133+
return m, tea.Sequence(cmd, tea.Quit)
123134
}
124135
return m, nil
125136
}

drivers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,13 @@ func makereq(u string) (resp *http.Response, err error) {
164164
}
165165

166166
if cred != nil {
167+
if auth.IsColumnarPrivateRegistry(uri) {
168+
// if we're accessing the private registry then attempt to
169+
// fetch the trial license. This will be a no-op if they have
170+
// a license saved already, and if they haven't started their
171+
// trial or it is expired, then this will silently fail.
172+
_ = auth.FetchColumnarLicense(cred)
173+
}
167174
req.Header.Set("Authorization", "Bearer "+cred.GetAuthToken())
168175
}
169176

go.mod

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,51 +20,54 @@ toolchain go1.24.6
2020

2121
require (
2222
github.com/Masterminds/semver/v3 v3.4.0
23-
github.com/ProtonMail/gopenpgp/v3 v3.2.1
23+
github.com/ProtonMail/gopenpgp/v3 v3.3.0
2424
github.com/alexflint/go-arg v1.6.1
2525
github.com/charmbracelet/bubbles v0.21.0
26-
github.com/charmbracelet/bubbletea v1.3.7
26+
github.com/charmbracelet/bubbletea v1.3.10
2727
github.com/charmbracelet/lipgloss v1.1.0
2828
github.com/cli/browser v1.3.0
29-
github.com/cli/oauth v1.2.0
29+
github.com/cli/oauth v1.2.1
3030
github.com/go-faster/yaml v0.4.6
31+
github.com/golang-jwt/jwt/v5 v5.3.0
3132
github.com/google/uuid v1.6.0
3233
github.com/mattn/go-isatty v0.0.20
3334
github.com/pelletier/go-toml/v2 v2.2.4
34-
github.com/stretchr/testify v1.10.0
35-
github.com/zeroshade/machine-id v0.0.0-20250917170903-4283e98485ba
36-
golang.org/x/sys v0.38.0
35+
github.com/stretchr/testify v1.11.1
36+
github.com/zeroshade/machine-id v0.0.0-20251223181436-930511047eef
37+
golang.org/x/sys v0.39.0
3738
)
3839

3940
require (
40-
github.com/ProtonMail/go-crypto v1.2.0 // indirect
41+
github.com/ProtonMail/go-crypto v1.3.0 // indirect
4142
github.com/alexflint/go-scalar v1.2.0 // indirect
4243
github.com/atotto/clipboard v0.1.4 // indirect
4344
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
44-
github.com/charmbracelet/colorprofile v0.3.1 // indirect
45+
github.com/charmbracelet/colorprofile v0.4.1 // indirect
4546
github.com/charmbracelet/harmonica v0.2.0 // indirect
46-
github.com/charmbracelet/x/ansi v0.10.1 // indirect
47-
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
48-
github.com/charmbracelet/x/term v0.2.1 // indirect
49-
github.com/cloudflare/circl v1.6.1 // indirect
50-
github.com/columnar-tech/machine-id v0.0.0-20250917165521-f900c2b8afc9 // indirect
47+
github.com/charmbracelet/x/ansi v0.11.3 // indirect
48+
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
49+
github.com/charmbracelet/x/term v0.2.2 // indirect
50+
github.com/clipperhouse/displaywidth v0.6.2 // indirect
51+
github.com/clipperhouse/stringish v0.1.1 // indirect
52+
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
53+
github.com/cloudflare/circl v1.6.2 // indirect
5154
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
5255
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
5356
github.com/go-faster/errors v0.7.1 // indirect
54-
github.com/go-faster/jx v1.1.0 // indirect
55-
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
57+
github.com/go-faster/jx v1.2.0 // indirect
58+
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
5659
github.com/mattn/go-localereader v0.0.1 // indirect
57-
github.com/mattn/go-runewidth v0.0.16 // indirect
60+
github.com/mattn/go-runewidth v0.0.19 // indirect
5861
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5962
github.com/muesli/cancelreader v0.2.2 // indirect
6063
github.com/muesli/termenv v0.16.0 // indirect
6164
github.com/pmezard/go-difflib v1.0.0 // indirect
6265
github.com/rivo/uniseg v0.4.7 // indirect
6366
github.com/sahilm/fuzzy v0.1.1 // indirect
64-
github.com/segmentio/asm v1.2.0 // indirect
67+
github.com/segmentio/asm v1.2.1 // indirect
6568
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
6669
go.uber.org/multierr v1.11.0 // indirect
67-
golang.org/x/crypto v0.45.0 // indirect
68-
golang.org/x/text v0.31.0 // indirect
70+
golang.org/x/crypto v0.46.0 // indirect
71+
golang.org/x/text v0.32.0 // indirect
6972
gopkg.in/yaml.v3 v3.0.1 // indirect
7073
)

0 commit comments

Comments
 (0)