Skip to content

Commit 41dc49c

Browse files
committed
fix: allow posting licenses that will be valid in future
1 parent 4672849 commit 41dc49c

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy