Skip to content

Commit 5bd5801

Browse files
authored
fix: allow posting licenses that will be valid in future (coder#14491)
1 parent 0785b77 commit 5bd5801

File tree

4 files changed

+106
-24
lines changed

4 files changed

+106
-24
lines changed

enterprise/coderd/coderdenttest/coderdenttest.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,10 @@ type LicenseOptions struct {
174174
// ExpiresAt is the time at which the license will hard expire.
175175
// ExpiresAt should always be greater then GraceAt.
176176
ExpiresAt time.Time
177+
// NotBefore is the time at which the license becomes valid. If set to the
178+
// zero value, the `nbf` claim on the license is set to 1 minute in the
179+
// past.
180+
NotBefore time.Time
177181
Features license.Features
178182
}
179183

@@ -233,13 +237,16 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string {
233237
if options.GraceAt.IsZero() {
234238
options.GraceAt = time.Now().Add(time.Hour)
235239
}
240+
if options.NotBefore.IsZero() {
241+
options.NotBefore = time.Now().Add(-time.Minute)
242+
}
236243

237244
c := &license.Claims{
238245
RegisteredClaims: jwt.RegisteredClaims{
239246
ID: uuid.NewString(),
240247
Issuer: "test@testing.test",
241248
ExpiresAt: jwt.NewNumericDate(options.ExpiresAt),
242-
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
249+
NotBefore: jwt.NewNumericDate(options.NotBefore),
243250
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
244251
},
245252
LicenseExpires: jwt.NewNumericDate(options.GraceAt),

enterprise/coderd/license/license.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,8 @@ var (
287287
ErrInvalidVersion = xerrors.New("license must be version 3")
288288
ErrMissingKeyID = xerrors.Errorf("JOSE header must contain %s", HeaderKeyID)
289289
ErrMissingLicenseExpires = xerrors.New("license missing license_expires")
290+
ErrMissingExp = xerrors.New("exp claim missing or not parsable")
291+
ErrMultipleIssues = xerrors.New("license has multiple issues; contact support")
290292
)
291293

292294
type Features map[codersdk.FeatureName]int64
@@ -336,7 +338,7 @@ func ParseRaw(l string, keys map[string]ed25519.PublicKey) (jwt.MapClaims, error
336338
return nil, xerrors.New("unable to parse Claims")
337339
}
338340

339-
// ParseClaims validates a database.License record, and if valid, returns the claims. If
341+
// ParseClaims validates a raw JWT, and if valid, returns the claims. If
340342
// unparsable or invalid, it returns an error
341343
func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
342344
tok, err := jwt.ParseWithClaims(
@@ -348,18 +350,53 @@ func ParseClaims(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, err
348350
if err != nil {
349351
return nil, err
350352
}
351-
if claims, ok := tok.Claims.(*Claims); ok && tok.Valid {
353+
return validateClaims(tok)
354+
}
355+
356+
func validateClaims(tok *jwt.Token) (*Claims, error) {
357+
if claims, ok := tok.Claims.(*Claims); ok {
352358
if claims.Version != uint64(CurrentVersion) {
353359
return nil, ErrInvalidVersion
354360
}
355361
if claims.LicenseExpires == nil {
356362
return nil, ErrMissingLicenseExpires
357363
}
364+
if claims.ExpiresAt == nil {
365+
return nil, ErrMissingExp
366+
}
358367
return claims, nil
359368
}
360369
return nil, xerrors.New("unable to parse Claims")
361370
}
362371

372+
// ParseClaimsIgnoreNbf validates a raw JWT, but ignores `nbf` claim. If otherwise valid, it returns
373+
// the claims. If unparsable or invalid, it returns an error. Ignoring the `nbf` (not before) is
374+
// useful to determine if a JWT _will_ become valid at any point now or in the future.
375+
func ParseClaimsIgnoreNbf(rawJWT string, keys map[string]ed25519.PublicKey) (*Claims, error) {
376+
tok, err := jwt.ParseWithClaims(
377+
rawJWT,
378+
&Claims{},
379+
keyFunc(keys),
380+
jwt.WithValidMethods(ValidMethods),
381+
)
382+
var vErr *jwt.ValidationError
383+
if xerrors.As(err, &vErr) {
384+
// zero out the NotValidYet error to check if there were other problems
385+
vErr.Errors = vErr.Errors & (^jwt.ValidationErrorNotValidYet)
386+
if vErr.Errors != 0 {
387+
// There are other errors besides not being valid yet. We _could_ go
388+
// through all the jwt.ValidationError bits and try to work out the
389+
// correct error, but if we get here something very strange is
390+
// going on so let's just return a generic error that says to get in
391+
// touch with our support team.
392+
return nil, ErrMultipleIssues
393+
}
394+
} else if err != nil {
395+
return nil, err
396+
}
397+
return validateClaims(tok)
398+
}
399+
363400
func keyFunc(keys map[string]ed25519.PublicKey) func(*jwt.Token) (interface{}, error) {
364401
return func(j *jwt.Token) (interface{}, error) {
365402
keyID, ok := j.Header[HeaderKeyID].(string)

enterprise/coderd/licenses.go

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -86,25 +86,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
8686
return
8787
}
8888

89-
rawClaims, err := license.ParseRaw(addLicense.License, api.LicenseKeys)
90-
if err != nil {
91-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
92-
Message: "Invalid license",
93-
Detail: err.Error(),
94-
})
95-
return
96-
}
97-
exp, ok := rawClaims["exp"].(float64)
98-
if !ok {
99-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
100-
Message: "Invalid license",
101-
Detail: "exp claim missing or not parsable",
102-
})
103-
return
104-
}
105-
expTime := time.Unix(int64(exp), 0)
106-
107-
claims, err := license.ParseClaims(addLicense.License, api.LicenseKeys)
89+
claims, err := license.ParseClaimsIgnoreNbf(addLicense.License, api.LicenseKeys)
10890
if err != nil {
10991
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
11092
Message: "Invalid license",
@@ -134,7 +116,7 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
134116
dl, err := api.Database.InsertLicense(ctx, database.InsertLicenseParams{
135117
UploadedAt: dbtime.Now(),
136118
JWT: addLicense.License,
137-
Exp: expTime,
119+
Exp: claims.ExpiresAt.Time,
138120
UUID: id,
139121
})
140122
if err != nil {
@@ -160,7 +142,15 @@ func (api *API) postLicense(rw http.ResponseWriter, r *http.Request) {
160142
// don't fail the HTTP request, since we did write it successfully to the database
161143
}
162144

163-
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, rawClaims))
145+
c, err := decodeClaims(dl)
146+
if err != nil {
147+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
148+
Message: "Failed to decode database response",
149+
Detail: err.Error(),
150+
})
151+
return
152+
}
153+
httpapi.Write(ctx, rw, http.StatusCreated, convertLicense(dl, c))
164154
}
165155

166156
// postRefreshEntitlements forces an `updateEntitlements` call and publishes

enterprise/coderd/licenses_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"net/http"
66
"testing"
7+
"time"
78

89
"github.com/google/uuid"
910
"github.com/stretchr/testify/assert"
@@ -82,6 +83,53 @@ func TestPostLicense(t *testing.T) {
8283
t.Error("expected to get error status 400")
8384
}
8485
})
86+
87+
// Test a license that isn't yet valid, but will be in the future. We should allow this so that
88+
// operators can upload a license ahead of time.
89+
t.Run("NotYet", func(t *testing.T) {
90+
t.Parallel()
91+
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
92+
respLic := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
93+
AccountType: license.AccountTypeSalesforce,
94+
AccountID: "testing",
95+
Features: license.Features{
96+
codersdk.FeatureAuditLog: 1,
97+
},
98+
NotBefore: time.Now().Add(time.Hour),
99+
GraceAt: time.Now().Add(2 * time.Hour),
100+
ExpiresAt: time.Now().Add(3 * time.Hour),
101+
})
102+
assert.GreaterOrEqual(t, respLic.ID, int32(0))
103+
// just a couple spot checks for sanity
104+
assert.Equal(t, "testing", respLic.Claims["account_id"])
105+
features, err := respLic.FeaturesClaims()
106+
require.NoError(t, err)
107+
assert.EqualValues(t, 1, features[codersdk.FeatureAuditLog])
108+
})
109+
110+
// Test we still reject a license that isn't valid yet, but has other issues (e.g. expired
111+
// before it starts).
112+
t.Run("NotEver", func(t *testing.T) {
113+
t.Parallel()
114+
client, _ := coderdenttest.New(t, &coderdenttest.Options{DontAddLicense: true})
115+
lic := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
116+
AccountType: license.AccountTypeSalesforce,
117+
AccountID: "testing",
118+
Features: license.Features{
119+
codersdk.FeatureAuditLog: 1,
120+
},
121+
NotBefore: time.Now().Add(time.Hour),
122+
GraceAt: time.Now().Add(2 * time.Hour),
123+
ExpiresAt: time.Now().Add(-time.Hour),
124+
})
125+
_, err := client.AddLicense(context.Background(), codersdk.AddLicenseRequest{
126+
License: lic,
127+
})
128+
errResp := &codersdk.Error{}
129+
require.ErrorAs(t, err, &errResp)
130+
require.Equal(t, http.StatusBadRequest, errResp.StatusCode())
131+
require.Contains(t, errResp.Detail, license.ErrMultipleIssues.Error())
132+
})
85133
}
86134

87135
func TestGetLicense(t *testing.T) {

0 commit comments

Comments
 (0)