Skip to content

feat: add flag for token lifetime #5385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli/deployment/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,12 @@ func newConfig() *codersdk.DeploymentConfig {
Flag: "update-check",
Default: flag.Lookup("test.v") == nil && !buildinfo.IsDev(),
},
MaxTokenLifetime: &codersdk.DeploymentConfigField[time.Duration]{
Name: "Max Token Lifetime",
Usage: "The maximum lifetime duration for any user creating a token.",
Flag: "max-token-lifetime",
Default: 24 * 30 * time.Hour,
},
}
}

Expand Down
4 changes: 4 additions & 0 deletions cli/testdata/coder_server_--help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ Flags:
production.
Consumes $CODER_EXPERIMENTAL
-h, --help help for server
--max-token-lifetime duration The maximum lifetime duration for any
user creating a token.
Consumes $CODER_MAX_TOKEN_LIFETIME
(default 720h0m0s)
--oauth2-github-allow-everyone Allow all logins, setting this option
means allowed orgs and teams must be
empty.
Expand Down
10 changes: 9 additions & 1 deletion cli/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/spf13/cobra"
"golang.org/x/xerrors"

"github.com/coder/coder/cli/cliflag"
"github.com/coder/coder/cli/cliui"
"github.com/coder/coder/codersdk"
)
Expand Down Expand Up @@ -46,6 +47,9 @@ func tokens() *cobra.Command {
}

func createToken() *cobra.Command {
var (
tokenLifetime time.Duration
)
cmd := &cobra.Command{
Use: "create",
Short: "Create a tokens",
Expand All @@ -55,7 +59,9 @@ func createToken() *cobra.Command {
return xerrors.Errorf("create codersdk client: %w", err)
}

res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{})
res, err := client.CreateToken(cmd.Context(), codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: tokenLifetime,
})
if err != nil {
return xerrors.Errorf("create tokens: %w", err)
}
Expand All @@ -74,6 +80,8 @@ func createToken() *cobra.Command {
},
}

cliflag.DurationVarP(cmd.Flags(), &tokenLifetime, "lifetime", "", "CODER_TOKEN_LIFETIME", 30*24*time.Hour, "Specify a duration for the lifetime of the token.")

return cmd
}

Expand Down
30 changes: 27 additions & 3 deletions coderd/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,21 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
scope = database.APIKeyScope(createToken.Scope)
}

// tokens last 100 years
lifeTime := time.Hour * 876000
// default lifetime is 30 days
lifeTime := 30 * 24 * time.Hour
if createToken.Lifetime != 0 {
lifeTime = createToken.Lifetime
}

err := api.validateAPIKeyLifetime(lifeTime)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to validate create API key request.",
Detail: err.Error(),
})
return
}

cookie, err := api.createAPIKey(ctx, createAPIKeyParams{
UserID: user.ID,
LoginType: database.LoginTypeToken,
Expand All @@ -65,7 +78,6 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) {
}

// Creates a new session key, used for logging in via the CLI.
// DEPRECATED: use postToken instead.
func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
user := httpmw.UserParam(r)
Expand Down Expand Up @@ -214,6 +226,18 @@ type createAPIKeyParams struct {
Scope database.APIKeyScope
}

func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error {
if lifetime <= 0 {
return xerrors.New("lifetime must be positive number greater than 0")
}

if lifetime > api.DeploymentConfig.MaxTokenLifetime.Value {
return xerrors.Errorf("lifetime must be less than %s", api.DeploymentConfig.MaxTokenLifetime.Value)
}

return nil
}

func (api *API) createAPIKey(ctx context.Context, params createAPIKeyParams) (*http.Cookie, error) {
keyID, keySecret, err := generateAPIKeyIDSecret()
if err != nil {
Expand Down
142 changes: 90 additions & 52 deletions coderd/apikey_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,63 +12,101 @@ import (
"github.com/coder/coder/testutil"
)

func TestTokens(t *testing.T) {
func TestTokenCRUD(t *testing.T) {
t.Parallel()

t.Run("CRUD", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)

res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)

keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
// expires_at must be greater than 50 years
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)

// no update

err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
require.NoError(t, err)
keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)

res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)

keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
// expires_at should default to 30 days
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*29*24))
require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*31*24))
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)

// no update

err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
require.NoError(t, err)
keys, err = client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Empty(t, keys)
}

func TestTokenScoped(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scope: codersdk.APIKeyScopeApplicationConnect,
})
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)

keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
}

func TestTokenDuration(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 7,
})
require.NoError(t, err)
keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*6*24))
require.Less(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*8*24))
}

func TestTokenMaxLifetime(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
dc := coderdtest.DeploymentConfig(t)
dc.MaxTokenLifetime.Value = time.Hour * 24 * 7
client := coderdtest.New(t, &coderdtest.Options{
DeploymentConfig: dc,
})
_ = coderdtest.CreateFirstUser(t, client)

// success
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 6,
})
require.NoError(t, err)

t.Run("Scoped", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)

res, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Scope: codersdk.APIKeyScopeApplicationConnect,
})
require.NoError(t, err)
require.Greater(t, len(res.Key), 2)

keys, err := client.GetTokens(ctx, codersdk.Me)
require.NoError(t, err)
require.EqualValues(t, len(keys), 1)
require.Contains(t, res.Key, keys[0].ID)
// expires_at must be greater than 50 years
require.Greater(t, keys[0].ExpiresAt, time.Now().Add(time.Hour*438300))
require.Equal(t, keys[0].Scope, codersdk.APIKeyScopeApplicationConnect)
// fail
_, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
Lifetime: time.Hour * 24 * 8,
})
require.ErrorContains(t, err, "lifetime must be less")
}

func TestAPIKey(t *testing.T) {
Expand Down
3 changes: 2 additions & 1 deletion coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,8 @@ func TestPostLogin(t *testing.T) {
apiKey, err := client.GetAPIKey(ctx, admin.UserID.String(), split[0])
require.NoError(t, err, "fetch api key")

require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*438300)), "tokens lasts more than 50 years")
require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*29)), "default tokens lasts more than 29 days")
require.True(t, apiKey.ExpiresAt.Before(time.Now().Add(time.Hour*24*31)), "default tokens lasts less than 31 days")
require.Greater(t, apiKey.LifetimeSeconds, key.LifetimeSeconds, "token should have longer lifetime")
})
}
Expand Down
3 changes: 2 additions & 1 deletion codersdk/apikey.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ const (
)

type CreateTokenRequest struct {
Scope APIKeyScope `json:"scope"`
Lifetime time.Duration `json:"lifetime"`
Scope APIKeyScope `json:"scope"`
}

// GenerateAPIKeyResponse contains an API key for a user.
Expand Down
1 change: 1 addition & 0 deletions codersdk/deploymentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type DeploymentConfig struct {
APIRateLimit *DeploymentConfigField[int] `json:"api_rate_limit" typescript:",notnull"`
Experimental *DeploymentConfigField[bool] `json:"experimental" typescript:",notnull"`
UpdateCheck *DeploymentConfigField[bool] `json:"update_check" typescript:",notnull"`
MaxTokenLifetime *DeploymentConfigField[time.Duration] `json:"max_token_lifetime" typescript:",notnull"`
}

type DERP struct {
Expand Down
3 changes: 3 additions & 0 deletions site/src/api/typesGenerated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,8 @@ export interface CreateTestAuditLogRequest {

// From codersdk/apikey.go
export interface CreateTokenRequest {
// This is likely an enum in an external package ("time.Duration")
readonly lifetime: number
readonly scope: APIKeyScope
}

Expand Down Expand Up @@ -303,6 +305,7 @@ export interface DeploymentConfig {
readonly api_rate_limit: DeploymentConfigField<number>
readonly experimental: DeploymentConfigField<boolean>
readonly update_check: DeploymentConfigField<boolean>
readonly max_token_lifetime: DeploymentConfigField<number>
}

// From codersdk/deploymentconfig.go
Expand Down