mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
feat: Longer lived api keys for cli (#1935)
* feat: Longer lived api keys for cli * feat: Refresh tokens based on their lifetime set in the db * test: Add unit test for refreshing
This commit is contained in:
@ -1128,9 +1128,14 @@ func (q *fakeQuerier) InsertAPIKey(_ context.Context, arg database.InsertAPIKeyP
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
if arg.LifetimeSeconds == 0 {
|
||||
arg.LifetimeSeconds = 86400
|
||||
}
|
||||
|
||||
//nolint:gosimple
|
||||
key := database.APIKey{
|
||||
ID: arg.ID,
|
||||
LifetimeSeconds: arg.LifetimeSeconds,
|
||||
HashedSecret: arg.HashedSecret,
|
||||
UserID: arg.UserID,
|
||||
ExpiresAt: arg.ExpiresAt,
|
||||
|
3
coderd/database/dump.sql
generated
3
coderd/database/dump.sql
generated
@ -94,7 +94,8 @@ CREATE TABLE api_keys (
|
||||
oauth_access_token text DEFAULT ''::text NOT NULL,
|
||||
oauth_refresh_token text DEFAULT ''::text NOT NULL,
|
||||
oauth_id_token text DEFAULT ''::text NOT NULL,
|
||||
oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL
|
||||
oauth_expiry timestamp with time zone DEFAULT '0001-01-01 00:00:00+00'::timestamp with time zone NOT NULL,
|
||||
lifetime_seconds bigint DEFAULT 86400 NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE audit_logs (
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE api_keys DROP COLUMN lifetime_seconds;
|
@ -0,0 +1,2 @@
|
||||
-- Default lifetime is 24hours.
|
||||
ALTER TABLE api_keys ADD COLUMN lifetime_seconds bigint default 86400 NOT NULL;
|
@ -305,6 +305,7 @@ type APIKey struct {
|
||||
OAuthRefreshToken string `db:"oauth_refresh_token" json:"oauth_refresh_token"`
|
||||
OAuthIDToken string `db:"oauth_id_token" json:"oauth_id_token"`
|
||||
OAuthExpiry time.Time `db:"oauth_expiry" json:"oauth_expiry"`
|
||||
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
|
@ -30,7 +30,7 @@ func (q *sqlQuerier) DeleteAPIKeyByID(ctx context.Context, id string) error {
|
||||
|
||||
const getAPIKeyByID = `-- name: GetAPIKeyByID :one
|
||||
SELECT
|
||||
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry
|
||||
id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds
|
||||
FROM
|
||||
api_keys
|
||||
WHERE
|
||||
@ -55,6 +55,7 @@ func (q *sqlQuerier) GetAPIKeyByID(ctx context.Context, id string) (APIKey, erro
|
||||
&i.OAuthRefreshToken,
|
||||
&i.OAuthIDToken,
|
||||
&i.OAuthExpiry,
|
||||
&i.LifetimeSeconds,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@ -63,6 +64,7 @@ const insertAPIKey = `-- name: InsertAPIKey :one
|
||||
INSERT INTO
|
||||
api_keys (
|
||||
id,
|
||||
lifetime_seconds,
|
||||
hashed_secret,
|
||||
user_id,
|
||||
last_used,
|
||||
@ -76,11 +78,18 @@ INSERT INTO
|
||||
oauth_expiry
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry
|
||||
($1,
|
||||
-- If the lifetime is set to 0, default to 24hrs
|
||||
CASE $2::bigint
|
||||
WHEN 0 THEN 86400
|
||||
ELSE $2::bigint
|
||||
END
|
||||
, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING id, hashed_secret, user_id, last_used, expires_at, created_at, updated_at, login_type, oauth_access_token, oauth_refresh_token, oauth_id_token, oauth_expiry, lifetime_seconds
|
||||
`
|
||||
|
||||
type InsertAPIKeyParams struct {
|
||||
ID string `db:"id" json:"id"`
|
||||
LifetimeSeconds int64 `db:"lifetime_seconds" json:"lifetime_seconds"`
|
||||
HashedSecret []byte `db:"hashed_secret" json:"hashed_secret"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
LastUsed time.Time `db:"last_used" json:"last_used"`
|
||||
@ -97,6 +106,7 @@ type InsertAPIKeyParams struct {
|
||||
func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertAPIKey,
|
||||
arg.ID,
|
||||
arg.LifetimeSeconds,
|
||||
arg.HashedSecret,
|
||||
arg.UserID,
|
||||
arg.LastUsed,
|
||||
@ -123,6 +133,7 @@ func (q *sqlQuerier) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (
|
||||
&i.OAuthRefreshToken,
|
||||
&i.OAuthIDToken,
|
||||
&i.OAuthExpiry,
|
||||
&i.LifetimeSeconds,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
@ -12,6 +12,7 @@ LIMIT
|
||||
INSERT INTO
|
||||
api_keys (
|
||||
id,
|
||||
lifetime_seconds,
|
||||
hashed_secret,
|
||||
user_id,
|
||||
last_used,
|
||||
@ -25,7 +26,13 @@ INSERT INTO
|
||||
oauth_expiry
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *;
|
||||
(@id,
|
||||
-- If the lifetime is set to 0, default to 24hrs
|
||||
CASE @lifetime_seconds::bigint
|
||||
WHEN 0 THEN 86400
|
||||
ELSE @lifetime_seconds::bigint
|
||||
END
|
||||
, @hashed_secret, @user_id, @last_used, @expires_at, @created_at, @updated_at, @login_type, @oauth_access_token, @oauth_refresh_token, @oauth_id_token, @oauth_expiry) RETURNING *;
|
||||
|
||||
-- name: UpdateAPIKeyByID :exec
|
||||
UPDATE
|
||||
|
@ -166,7 +166,7 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h
|
||||
}
|
||||
// Only update the ExpiresAt once an hour to prevent database spam.
|
||||
// We extend the ExpiresAt to reduce re-authentication.
|
||||
apiKeyLifetime := 24 * time.Hour
|
||||
apiKeyLifetime := time.Duration(key.LifetimeSeconds) * time.Second
|
||||
if key.ExpiresAt.Sub(now) <= apiKeyLifetime-time.Hour {
|
||||
key.ExpiresAt = now.Add(apiKeyLifetime)
|
||||
changed = true
|
||||
|
@ -660,9 +660,14 @@ func (api *API) postAPIKey(rw http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
lifeTime := time.Hour * 24 * 7
|
||||
sessionToken, created := api.createAPIKey(rw, r, database.InsertAPIKeyParams{
|
||||
UserID: user.ID,
|
||||
LoginType: database.LoginTypePassword,
|
||||
// All api generated keys will last 1 week. Browser login tokens have
|
||||
// a shorter life.
|
||||
ExpiresAt: database.Now().Add(lifeTime),
|
||||
LifetimeSeconds: int64(lifeTime.Seconds()),
|
||||
})
|
||||
if !created {
|
||||
return
|
||||
@ -723,10 +728,21 @@ func (api *API) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat
|
||||
}
|
||||
hashed := sha256.Sum256([]byte(keySecret))
|
||||
|
||||
// Default expires at to now+lifetime, or just 24hrs if not set
|
||||
if params.ExpiresAt.IsZero() {
|
||||
if params.LifetimeSeconds != 0 {
|
||||
params.ExpiresAt = database.Now().Add(time.Duration(params.LifetimeSeconds) * time.Second)
|
||||
} else {
|
||||
params.ExpiresAt = database.Now().Add(24 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = api.Database.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
|
||||
ID: keyID,
|
||||
UserID: params.UserID,
|
||||
ExpiresAt: database.Now().Add(24 * time.Hour),
|
||||
ID: keyID,
|
||||
UserID: params.UserID,
|
||||
LifetimeSeconds: params.LifetimeSeconds,
|
||||
// Make sure in UTC time for common time zone
|
||||
ExpiresAt: params.ExpiresAt.UTC(),
|
||||
CreatedAt: database.Now(),
|
||||
UpdatedAt: database.Now(),
|
||||
HashedSecret: hashed[:],
|
||||
|
@ -8,11 +8,13 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/databasefake"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
@ -130,6 +132,99 @@ func TestPostLogin(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Lifetime&Expire", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = context.Background()
|
||||
)
|
||||
client, api := coderdtest.NewWithAPI(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
split := strings.Split(client.SessionToken, "-")
|
||||
loginKey, err := api.Database.GetAPIKeyByID(ctx, split[0])
|
||||
require.NoError(t, err, "fetch login key")
|
||||
require.Equal(t, int64(86400), loginKey.LifetimeSeconds, "default should be 86400")
|
||||
|
||||
// Generated tokens have a longer life
|
||||
token, err := client.CreateAPIKey(ctx, admin.UserID.String())
|
||||
require.NoError(t, err, "make new api key")
|
||||
split = strings.Split(token.Key, "-")
|
||||
apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0])
|
||||
require.NoError(t, err, "fetch api key")
|
||||
|
||||
require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*6)), "api key lasts more than 6 days")
|
||||
require.True(t, apiKey.ExpiresAt.After(loginKey.ExpiresAt.Add(time.Hour)), "api key should be longer expires")
|
||||
require.Greater(t, apiKey.LifetimeSeconds, loginKey.LifetimeSeconds, "api key should have longer lifetime")
|
||||
})
|
||||
|
||||
t.Run("APIKeyExtend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = context.Background()
|
||||
)
|
||||
client, api := coderdtest.NewWithAPI(t, nil)
|
||||
admin := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
token, err := client.CreateAPIKey(ctx, admin.UserID.String())
|
||||
require.NoError(t, err, "make new api key")
|
||||
client.SessionToken = token.Key
|
||||
split := strings.Split(token.Key, "-")
|
||||
|
||||
apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0])
|
||||
require.NoError(t, err, "fetch api key")
|
||||
|
||||
err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
|
||||
ID: apiKey.ID,
|
||||
LastUsed: apiKey.LastUsed,
|
||||
// This should cause a refresh
|
||||
ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2),
|
||||
OAuthAccessToken: apiKey.OAuthAccessToken,
|
||||
OAuthRefreshToken: apiKey.OAuthRefreshToken,
|
||||
OAuthExpiry: apiKey.OAuthExpiry,
|
||||
})
|
||||
require.NoError(t, err, "update api key")
|
||||
|
||||
_, err = client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err, "fetch user")
|
||||
|
||||
apiKey, err = api.Database.GetAPIKeyByID(ctx, split[0])
|
||||
require.NoError(t, err, "fetch refreshed api key")
|
||||
// 1 minute tolerance
|
||||
require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24*7).Add(time.Minute*-1)), "api key lasts 7 days")
|
||||
})
|
||||
|
||||
t.Run("LoginKeyExtend", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var (
|
||||
ctx = context.Background()
|
||||
)
|
||||
client, api := coderdtest.NewWithAPI(t, nil)
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
split := strings.Split(client.SessionToken, "-")
|
||||
|
||||
apiKey, err := api.Database.GetAPIKeyByID(ctx, split[0])
|
||||
require.NoError(t, err, "fetch login key")
|
||||
|
||||
err = api.Database.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
|
||||
ID: apiKey.ID,
|
||||
LastUsed: apiKey.LastUsed,
|
||||
// This should cause a refresh
|
||||
ExpiresAt: apiKey.ExpiresAt.Add(time.Hour * -2),
|
||||
OAuthAccessToken: apiKey.OAuthAccessToken,
|
||||
OAuthRefreshToken: apiKey.OAuthRefreshToken,
|
||||
OAuthExpiry: apiKey.OAuthExpiry,
|
||||
})
|
||||
require.NoError(t, err, "update login key")
|
||||
|
||||
_, err = client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err, "fetch user")
|
||||
|
||||
apiKey, err = api.Database.GetAPIKeyByID(ctx, split[0])
|
||||
require.NoError(t, err, "fetch refreshed login key")
|
||||
// 1 minute tolerance
|
||||
require.True(t, apiKey.ExpiresAt.After(time.Now().Add(time.Hour*24).Add(time.Minute*-1)), "login key lasts 24 hrs")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPostLogout(t *testing.T) {
|
||||
|
Reference in New Issue
Block a user