mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
A scoped key holding api_key:create could mint a broader key, e.g. a token scoped to [api_key:create user:read] could mint coder:all.
- New keys must be covered by the caller's scopes and allow list, and inherit them when none are requested
- Requests beyond the caller return 403
- New key expiry is capped at the caller's
- Covers POST /users/{user}/keys/tokens, POST /users/{user}/keys, app auth-redirect, and /oauth2/authorize (invalid_scope on excess grants or a restricted session allow list)
- coder:all sessions, including browser logins, behave as before
- Not addressed: workspace start still mints a coder:all key for the owner
939 lines
33 KiB
Go
939 lines
33 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func TestTokenCRUD(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
auditor := audit.NewMock()
|
|
numLogs := len(auditor.AuditLogs())
|
|
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
numLogs++ // add an audit log for user creation
|
|
|
|
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
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)
|
|
numLogs++ // add an audit log for token creation
|
|
|
|
keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
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, dbtime.Now().Add(time.Hour*24*6))
|
|
require.Less(t, keys[0].ExpiresAt, dbtime.Now().Add(time.Hour*24*8))
|
|
require.Equal(t, codersdk.APIKeyScopeAll, keys[0].Scope)
|
|
require.Len(t, keys[0].AllowList, 1)
|
|
require.Equal(t, "*:*", keys[0].AllowList[0].String())
|
|
|
|
// no update
|
|
|
|
err = client.DeleteAPIKey(ctx, codersdk.Me, keys[0].ID)
|
|
require.NoError(t, err)
|
|
numLogs++ // add an audit log for token deletion
|
|
keys, err = client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, keys)
|
|
|
|
// ensure audit log count is correct
|
|
require.Len(t, auditor.AuditLogs(), numLogs)
|
|
require.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[numLogs-2].Action)
|
|
require.Equal(t, database.AuditActionDelete, auditor.AuditLogs()[numLogs-1].Action)
|
|
}
|
|
|
|
func TestTokensFilterExpired(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
adminClient := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, adminClient)
|
|
|
|
// Create a token.
|
|
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 7,
|
|
})
|
|
require.NoError(t, err)
|
|
keyID := strings.Split(res.Key, "-")[0]
|
|
|
|
// List tokens without including expired - should see the token.
|
|
keys, err := adminClient.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
|
|
// Expire the token.
|
|
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// List tokens without including expired - should NOT see expired token.
|
|
keys, err = adminClient.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Empty(t, keys)
|
|
|
|
// List tokens WITH including expired - should see expired token.
|
|
keys, err = adminClient.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{
|
|
IncludeExpired: true,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
require.Equal(t, keyID, keys[0].ID)
|
|
}
|
|
|
|
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.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
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)
|
|
require.Len(t, keys[0].AllowList, 1)
|
|
require.Equal(t, "*:*", keys[0].AllowList[0].String())
|
|
}
|
|
|
|
// Ensure backward-compat: when a token is created using the legacy singular
|
|
// scope names ("all" or "application_connect"), the API returns the same
|
|
// legacy value in the deprecated singular Scope field while also supporting
|
|
// the new multi-scope field.
|
|
func TestTokenLegacySingularScopeCompat(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
scope codersdk.APIKeyScope
|
|
scopes []codersdk.APIKeyScope
|
|
}{
|
|
{
|
|
name: "all",
|
|
scope: codersdk.APIKeyScopeAll,
|
|
scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
|
|
},
|
|
{
|
|
name: "application_connect",
|
|
scope: codersdk.APIKeyScopeApplicationConnect,
|
|
scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong)
|
|
defer cancel()
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Create with legacy singular scope.
|
|
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Scope: tc.scope,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Read back and ensure the deprecated singular field matches exactly.
|
|
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
require.Equal(t, tc.scope, keys[0].Scope)
|
|
require.ElementsMatch(t, keys[0].Scopes, tc.scopes)
|
|
require.Len(t, keys[0].AllowList, 1)
|
|
require.Equal(t, "*:*", keys[0].AllowList[0].String())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateTokenScopes(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
sendScopes []codersdk.APIKeyScope
|
|
sendLegacyScope codersdk.APIKeyScope
|
|
wantScopes []codersdk.APIKeyScope
|
|
wantLegacyScope codersdk.APIKeyScope
|
|
wantErrDetail string
|
|
}{
|
|
{
|
|
name: "alias all is stored as coder:all",
|
|
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll},
|
|
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
|
|
wantLegacyScope: codersdk.APIKeyScopeAll,
|
|
},
|
|
{
|
|
name: "alias application_connect is stored as coder:application_connect",
|
|
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApplicationConnect},
|
|
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderApplicationConnect},
|
|
wantLegacyScope: codersdk.APIKeyScopeApplicationConnect,
|
|
},
|
|
{
|
|
name: "alias is stored canonically alongside another scope",
|
|
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeWorkspaceRead},
|
|
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll, codersdk.APIKeyScopeWorkspaceRead},
|
|
wantLegacyScope: codersdk.APIKeyScopeAll,
|
|
},
|
|
{
|
|
name: "alias and canonical spelling collapse to one scope",
|
|
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeAll, codersdk.APIKeyScopeCoderAll},
|
|
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll},
|
|
wantLegacyScope: codersdk.APIKeyScopeAll,
|
|
},
|
|
{
|
|
// The read-only request must not widen to the coder:all sent in the
|
|
// deprecated field. The legacy field reads back empty because
|
|
// convertAPIKey derives it only for coder:all and
|
|
// coder:application_connect.
|
|
name: "plural Scopes wins over singular Scope",
|
|
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead},
|
|
sendLegacyScope: codersdk.APIKeyScopeAll,
|
|
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceRead},
|
|
wantLegacyScope: "",
|
|
},
|
|
{
|
|
name: "name that is no scope at all is rejected",
|
|
sendScopes: []codersdk.APIKeyScope{"not_a_real_scope"},
|
|
wantErrDetail: "unknown API key scope",
|
|
},
|
|
{
|
|
// A real api_key_scope member that IsExternalScope refuses, so no
|
|
// re-spelling makes it requestable and the message must not read
|
|
// like a typo.
|
|
name: "internal scope is rejected",
|
|
sendScopes: []codersdk.APIKeyScope{codersdk.APIKeyScope(database.ApiKeyScopeDebugInfoRead)},
|
|
wantErrDetail: "is internal and cannot be requested",
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Scope: tc.sendLegacyScope,
|
|
Scopes: tc.sendScopes,
|
|
})
|
|
if tc.wantErrDetail != "" {
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Detail, string(tc.sendScopes[0]))
|
|
require.Contains(t, sdkErr.Detail, tc.wantErrDetail)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Len(t, keys, 1)
|
|
require.ElementsMatch(t, tc.wantScopes, keys[0].Scopes)
|
|
require.Equal(t, tc.wantLegacyScope, keys[0].Scope)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTokenScopeCeiling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
keyCreate, userRead := codersdk.APIKeyScopeApiKeyCreate, codersdk.APIKeyScopeUserRead
|
|
narrow := []codersdk.APIKeyScope{keyCreate, userRead}
|
|
narrowAllow := []codersdk.APIAllowListTarget{
|
|
codersdk.AllowResourceTarget(codersdk.ResourceWorkspace, uuid.New()),
|
|
codersdk.AllowTypeTarget(codersdk.ResourceApiKey),
|
|
codersdk.AllowTypeTarget(codersdk.ResourceUser),
|
|
}
|
|
all := []codersdk.APIAllowListTarget{codersdk.AllowAllTarget()}
|
|
|
|
cases := []struct {
|
|
name string
|
|
callerScopes []codersdk.APIKeyScope // nil uses the unscoped session
|
|
callerAllow []codersdk.APIAllowListTarget
|
|
sessionKey bool // POST /users/me/keys instead of /keys/tokens
|
|
req codersdk.CreateTokenRequest
|
|
wantScopes []codersdk.APIKeyScope
|
|
wantAllow []codersdk.APIAllowListTarget
|
|
wantErr string
|
|
}{
|
|
{name: "unscoped caller keeps coder:all", wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}, wantAllow: all},
|
|
{name: "omitted values inherit", callerScopes: narrow, callerAllow: narrowAllow, wantScopes: narrow, wantAllow: narrowAllow},
|
|
{name: "session key inherits", callerScopes: narrow, callerAllow: narrowAllow, sessionKey: true, wantScopes: narrow, wantAllow: narrowAllow},
|
|
{name: "narrower scope", callerScopes: narrow, req: codersdk.CreateTokenRequest{Scopes: []codersdk.APIKeyScope{userRead}}, wantScopes: []codersdk.APIKeyScope{userRead}, wantAllow: all},
|
|
{name: "coder:all refused", callerScopes: narrow, req: codersdk.CreateTokenRequest{Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderAll}}, wantErr: "coder:all"},
|
|
{name: "unheld scope refused", callerScopes: narrow, req: codersdk.CreateTokenRequest{Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeUserSecretAll}}, wantErr: "user_secret:*"},
|
|
{
|
|
name: "composite covers low-level",
|
|
callerScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeCoderWorkspacesAccess, keyCreate, userRead},
|
|
req: codersdk.CreateTokenRequest{Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceSsh}},
|
|
wantScopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeWorkspaceSsh},
|
|
wantAllow: all,
|
|
},
|
|
{
|
|
name: "narrower allow list",
|
|
callerScopes: narrow,
|
|
callerAllow: narrowAllow,
|
|
req: codersdk.CreateTokenRequest{AllowList: narrowAllow[:1]},
|
|
wantScopes: narrow,
|
|
wantAllow: narrowAllow[:1],
|
|
},
|
|
{name: "wider allow list refused", callerScopes: narrow, callerAllow: narrowAllow, req: codersdk.CreateTokenRequest{AllowList: all}, wantErr: "*:*"},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
caller := client
|
|
if tc.callerScopes != nil {
|
|
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Scopes: tc.callerScopes,
|
|
AllowList: tc.callerAllow,
|
|
})
|
|
require.NoError(t, err)
|
|
caller = codersdk.New(client.URL, codersdk.WithSessionToken(token.Key))
|
|
}
|
|
|
|
var (
|
|
resp codersdk.GenerateAPIKeyResponse
|
|
err error
|
|
)
|
|
if tc.sessionKey {
|
|
resp, err = caller.CreateAPIKey(ctx, codersdk.Me)
|
|
} else {
|
|
resp, err = caller.CreateToken(ctx, codersdk.Me, tc.req)
|
|
}
|
|
if tc.wantErr != "" {
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Detail, tc.wantErr)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
|
|
key, err := client.APIKeyByID(ctx, codersdk.Me, strings.Split(resp.Key, "-")[0])
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, tc.wantScopes, key.Scopes)
|
|
require.ElementsMatch(t, tc.wantAllow, key.AllowList)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A scoped caller's key must not outlive it; a coder:all caller's may.
|
|
func TestTokenLifetimeCeiling(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
parent, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Scopes: []codersdk.APIKeyScope{codersdk.APIKeyScopeApiKeyCreate, codersdk.APIKeyScopeUserRead},
|
|
Lifetime: time.Hour,
|
|
})
|
|
require.NoError(t, err)
|
|
parentKey, err := client.APIKeyByID(ctx, codersdk.Me, strings.Split(parent.Key, "-")[0])
|
|
require.NoError(t, err)
|
|
scoped := codersdk.New(client.URL, codersdk.WithSessionToken(parent.Key))
|
|
|
|
token, err := scoped.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{Lifetime: 7 * 24 * time.Hour})
|
|
require.NoError(t, err)
|
|
session, err := scoped.CreateAPIKey(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
// Unnamed token keys would collide on the per-user name index.
|
|
second, err := scoped.CreateAPIKey(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
for _, child := range []string{token.Key, session.Key, second.Key} {
|
|
key, err := client.APIKeyByID(ctx, codersdk.Me, strings.Split(child, "-")[0])
|
|
require.NoError(t, err)
|
|
require.False(t, key.ExpiresAt.After(parentKey.ExpiresAt), "child outlives its creator")
|
|
require.Equal(t, codersdk.LoginTypeToken, key.LoginType, "a password key would slide past its creator")
|
|
}
|
|
|
|
unscoped, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{Lifetime: 7 * 24 * time.Hour})
|
|
require.NoError(t, err)
|
|
key, err := client.APIKeyByID(ctx, codersdk.Me, strings.Split(unscoped.Key, "-")[0])
|
|
require.NoError(t, err)
|
|
require.Greater(t, key.ExpiresAt, dbtime.Now().Add(6*24*time.Hour))
|
|
}
|
|
|
|
// Lives in this package because database imports rbac, so rbac cannot check its
|
|
// own names against the api_key_scope enum.
|
|
func TestExternalScopesAreStorable(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
for _, name := range rbac.ExternalScopeNames() {
|
|
// CanonicalScopeName is a no-op today, since ExternalScopeNames omits
|
|
// the bare aliases. It stays so an alias added to that list later is
|
|
// still checked against the enum.
|
|
canonical := rbac.CanonicalScopeName(rbac.ScopeName(name))
|
|
require.Truef(t, database.APIKeyScope(canonical).Valid(),
|
|
"external scope %q canonicalizes to %q, which is not an api_key_scope member",
|
|
name, canonical)
|
|
}
|
|
}
|
|
|
|
func TestUserSetTokenDuration(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.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Greater(t, keys[0].ExpiresAt, dbtime.Now().Add(time.Hour*6*24))
|
|
require.Less(t, keys[0].ExpiresAt, dbtime.Now().Add(time.Hour*8*24))
|
|
}
|
|
|
|
func TestDefaultTokenDuration(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{})
|
|
require.NoError(t, err)
|
|
keys, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Greater(t, keys[0].ExpiresAt, dbtime.Now().Add(time.Hour*24*6))
|
|
require.Less(t, keys[0].ExpiresAt, dbtime.Now().Add(time.Hour*24*8))
|
|
}
|
|
|
|
func TestTokenUserSetMaxLifetime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
dc := coderdtest.DeploymentValues(t)
|
|
dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 7)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: dc,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
// success
|
|
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 6,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// fail
|
|
_, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 8,
|
|
})
|
|
require.ErrorContains(t, err, "lifetime must be less")
|
|
}
|
|
|
|
func TestTokenAdminSetMaxLifetime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
dc := coderdtest.DeploymentValues(t)
|
|
dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 7)
|
|
dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 14)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: dc,
|
|
})
|
|
adminUser := coderdtest.CreateFirstUser(t, client)
|
|
nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
|
|
|
// Admin should be able to create a token with a lifetime longer than the non-admin max.
|
|
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 10,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Admin should NOT be able to create a token with a lifetime longer than the admin max.
|
|
_, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 15,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "lifetime must be less")
|
|
|
|
// Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max.
|
|
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 8,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "lifetime must be less")
|
|
|
|
// Non-admin should be able to create a token with a lifetime shorter than the non-admin max.
|
|
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 6,
|
|
})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestTokenAdminSetMaxLifetimeShorter(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
dc := coderdtest.DeploymentValues(t)
|
|
dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 14)
|
|
dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 7)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: dc,
|
|
})
|
|
adminUser := coderdtest.CreateFirstUser(t, client)
|
|
nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID)
|
|
|
|
// Admin should NOT be able to create a token with a lifetime longer than the admin max.
|
|
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 8,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "lifetime must be less")
|
|
|
|
// Admin should be able to create a token with a lifetime shorter than the admin max.
|
|
_, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 6,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Non-admin should be able to create a token with a lifetime longer than the admin max.
|
|
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 10,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max.
|
|
_, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 15,
|
|
})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "lifetime must be less")
|
|
}
|
|
|
|
func TestTokenCustomDefaultLifetime(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
dc := coderdtest.DeploymentValues(t)
|
|
dc.Sessions.DefaultTokenDuration = serpent.Duration(time.Hour * 12)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: dc,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
_, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{})
|
|
require.NoError(t, err)
|
|
|
|
tokens, err := client.Tokens(ctx, codersdk.Me, codersdk.TokensFilter{})
|
|
require.NoError(t, err)
|
|
require.Len(t, tokens, 1)
|
|
require.EqualValues(t, dc.Sessions.DefaultTokenDuration.Value().Seconds(), tokens[0].LifetimeSeconds)
|
|
}
|
|
|
|
func TestSessionExpiry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
dc := coderdtest.DeploymentValues(t)
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
adminClient := coderdtest.New(t, &coderdtest.Options{
|
|
DeploymentValues: dc,
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
})
|
|
adminUser := coderdtest.CreateFirstUser(t, adminClient)
|
|
|
|
// This is a hack, but we need the admin account to have a long expiry
|
|
// otherwise the test will flake, so we only update the expiry config after
|
|
// the admin account has been created.
|
|
//
|
|
// We don't support updating the deployment config after startup, but for
|
|
// this test it works because we don't copy the value (and we use pointers).
|
|
dc.Sessions.DefaultDuration = serpent.Duration(time.Second)
|
|
|
|
userClient, _ := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
|
|
|
|
// Find the session cookie, and ensure it has the correct expiry.
|
|
token := userClient.SessionToken()
|
|
apiKey, err := db.GetAPIKeyByID(ctx, strings.Split(token, "-")[0])
|
|
require.NoError(t, err)
|
|
|
|
require.EqualValues(t, dc.Sessions.DefaultDuration.Value().Seconds(), apiKey.LifetimeSeconds)
|
|
require.WithinDuration(t, apiKey.CreatedAt.Add(dc.Sessions.DefaultDuration.Value()), apiKey.ExpiresAt, 2*time.Second)
|
|
|
|
// Update the session token to be expired so we can test that it is
|
|
// rejected for extra points.
|
|
err = db.UpdateAPIKeyByID(ctx, database.UpdateAPIKeyByIDParams{
|
|
ID: apiKey.ID,
|
|
LastUsed: apiKey.LastUsed,
|
|
ExpiresAt: dbtime.Now().Add(-time.Hour),
|
|
IPAddress: apiKey.IPAddress,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
_, err = userClient.User(ctx, codersdk.Me)
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
if assert.ErrorAs(t, err, &sdkErr) {
|
|
require.Equal(t, http.StatusUnauthorized, sdkErr.StatusCode())
|
|
require.Contains(t, sdkErr.Message, "session has expired")
|
|
}
|
|
}
|
|
|
|
// TestSessionCookieMaxAge verifies that the session cookie is a persistent
|
|
// cookie (has MaxAge set) rather than a session cookie. Standalone PWAs
|
|
// run in their own browser process and mobile OSes purge in-memory
|
|
// (session) cookies when that process is killed, so the cookie must be
|
|
// persisted to disk.
|
|
func TestSessionCookieMaxAge(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
|
|
// Create the first user (password-based login).
|
|
req := codersdk.CreateFirstUserRequest{
|
|
Email: "testuser@coder.com",
|
|
Username: "testuser",
|
|
Password: "SomeSecurePassword!",
|
|
}
|
|
_, err := client.CreateFirstUser(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
// Login via the raw HTTP endpoint so we can inspect the Set-Cookie header.
|
|
loginURL, err := client.URL.Parse("/api/v2/users/login")
|
|
require.NoError(t, err)
|
|
|
|
res, err := client.Request(ctx, http.MethodPost, loginURL.String(), codersdk.LoginWithPasswordRequest{
|
|
Email: req.Email,
|
|
Password: req.Password,
|
|
})
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusCreated, res.StatusCode)
|
|
|
|
oneYear := int((365 * 24 * time.Hour).Seconds())
|
|
var found bool
|
|
for _, cookie := range res.Cookies() {
|
|
if cookie.Name == codersdk.SessionTokenCookie {
|
|
// MaxAge should be set to a long value so the browser
|
|
// persists the cookie to disk. The server handles real
|
|
// expiry via the API key's ExpiresAt field.
|
|
require.Equal(t, oneYear, cookie.MaxAge,
|
|
"Session cookie MaxAge should be set to 1 year for disk persistence")
|
|
found = true
|
|
}
|
|
}
|
|
require.True(t, found, "session cookie should be present in login response")
|
|
}
|
|
|
|
func TestAPIKey_OK(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Given: a deployment with auditing enabled
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
auditor := audit.NewMock()
|
|
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
auditor.ResetLogs()
|
|
|
|
// When: an API key is created
|
|
res, err := client.CreateAPIKey(ctx, codersdk.Me)
|
|
require.NoError(t, err)
|
|
require.Greater(t, len(res.Key), 2)
|
|
|
|
// Then: an audit log is generated
|
|
als := auditor.AuditLogs()
|
|
require.Len(t, als, 1)
|
|
al := als[0]
|
|
assert.Equal(t, owner.UserID, al.UserID)
|
|
assert.Equal(t, database.AuditActionCreate, al.Action)
|
|
assert.Equal(t, database.ResourceTypeApiKey, al.ResourceType)
|
|
|
|
// Then: the diff MUST NOT contain the generated key.
|
|
raw, err := json.Marshal(al)
|
|
require.NoError(t, err)
|
|
require.NotContains(t, res.Key, string(raw))
|
|
}
|
|
|
|
func TestAPIKey_Deleted(t *testing.T) {
|
|
t.Parallel()
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
|
user := coderdtest.CreateFirstUser(t, client)
|
|
_, anotherUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
|
|
require.NoError(t, client.DeleteUser(context.Background(), anotherUser.ID))
|
|
|
|
// Attempt to create an API key for the deleted user. This should fail.
|
|
_, err := client.CreateAPIKey(ctx, anotherUser.Username)
|
|
require.Error(t, err)
|
|
var apiErr *codersdk.Error
|
|
require.ErrorAs(t, err, &apiErr)
|
|
require.Equal(t, http.StatusNotFound, apiErr.StatusCode())
|
|
}
|
|
|
|
func TestAPIKey_SetDefault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
dc := coderdtest.DeploymentValues(t)
|
|
dc.Sessions.DefaultTokenDuration = serpent.Duration(time.Hour * 12)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
DeploymentValues: dc,
|
|
})
|
|
owner := coderdtest.CreateFirstUser(t, client)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
token, err := client.CreateAPIKey(ctx, owner.UserID.String())
|
|
require.NoError(t, err)
|
|
split := strings.Split(token.Key, "-")
|
|
apiKey1, err := db.GetAPIKeyByID(ctx, split[0])
|
|
require.NoError(t, err)
|
|
require.EqualValues(t, dc.Sessions.DefaultTokenDuration.Value().Seconds(), apiKey1.LifetimeSeconds)
|
|
}
|
|
|
|
func TestAPIKey_PrebuildsNotAllowed(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, pubsub := dbtestutil.NewDB(t)
|
|
dc := coderdtest.DeploymentValues(t)
|
|
dc.Sessions.DefaultTokenDuration = serpent.Duration(time.Hour * 12)
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
Database: db,
|
|
Pubsub: pubsub,
|
|
DeploymentValues: dc,
|
|
})
|
|
|
|
setupCtx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Given: an existing api token for the prebuilds user
|
|
_, prebuildsToken := dbgen.APIKey(t, db, database.APIKey{
|
|
UserID: database.PrebuildsSystemUserID,
|
|
})
|
|
client.SetSessionToken(prebuildsToken)
|
|
|
|
// When: the prebuilds user tries to create an API key
|
|
_, err := client.CreateAPIKey(setupCtx, database.PrebuildsSystemUserID.String())
|
|
// Then: denied.
|
|
require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message)
|
|
|
|
// When: the prebuilds user tries to create a token
|
|
_, err = client.CreateToken(setupCtx, database.PrebuildsSystemUserID.String(), codersdk.CreateTokenRequest{})
|
|
// Then: also denied.
|
|
require.ErrorContains(t, err, httpapi.ResourceForbiddenResponse.Message)
|
|
}
|
|
|
|
//nolint:tparallel,paralleltest // Subtests share the same coderdtest instance and auditor.
|
|
func TestExpireAPIKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
auditor := audit.NewMock()
|
|
adminClient := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
|
admin := coderdtest.CreateFirstUser(t, adminClient)
|
|
memberClient, member := coderdtest.CreateAnotherUser(t, adminClient, admin.OrganizationID)
|
|
|
|
t.Run("OwnerCanExpireOwnToken", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create a token.
|
|
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 7,
|
|
})
|
|
require.NoError(t, err)
|
|
keyID := strings.Split(res.Key, "-")[0]
|
|
|
|
// Verify the token is not expired.
|
|
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
require.True(t, key.ExpiresAt.After(dbtime.Now()))
|
|
|
|
auditor.ResetLogs()
|
|
|
|
// Expire the token.
|
|
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the token is expired.
|
|
key, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
require.LessOrEqual(t, key.ExpiresAt, dbtime.Now(), "key should be expired")
|
|
|
|
// Verify audit log.
|
|
als := auditor.AuditLogs()
|
|
require.Len(t, als, 1)
|
|
require.Equal(t, database.AuditActionWrite, als[0].Action)
|
|
require.Equal(t, database.ResourceTypeApiKey, als[0].ResourceType)
|
|
require.Equal(t, admin.UserID.String(), als[0].UserID.String())
|
|
})
|
|
|
|
t.Run("AdminCanExpireOtherUsersToken", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create a token for the member.
|
|
res, err := memberClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 7,
|
|
})
|
|
require.NoError(t, err)
|
|
keyID := strings.Split(res.Key, "-")[0]
|
|
|
|
// Admin expires the member's token.
|
|
err = adminClient.ExpireAPIKey(ctx, member.ID.String(), keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the token is expired.
|
|
key, err := memberClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
require.LessOrEqual(t, key.ExpiresAt, dbtime.Now(), "key should be expired")
|
|
})
|
|
|
|
t.Run("MemberCannotExpireOtherUsersToken", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create a token for the admin.
|
|
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 7,
|
|
})
|
|
require.NoError(t, err)
|
|
keyID := strings.Split(res.Key, "-")[0]
|
|
|
|
// Member attempts to expire admin's token.
|
|
err = memberClient.ExpireAPIKey(ctx, admin.UserID.String(), keyID)
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
// Members cannot read other users, so they get a 404 Not Found
|
|
// from the authorization layer.
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
|
|
t.Run("NotFound", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Try to expire a non-existent token.
|
|
err := adminClient.ExpireAPIKey(ctx, codersdk.Me, "nonexistent")
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
|
|
t.Run("ExpiringAlreadyExpiredTokenSucceeds", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create and expire a token.
|
|
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 7,
|
|
})
|
|
require.NoError(t, err)
|
|
keyID := strings.Split(res.Key, "-")[0]
|
|
|
|
// Expire it once.
|
|
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Invariant: make sure it's actually expired
|
|
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
require.LessOrEqual(t, key.ExpiresAt, dbtime.Now(), "key should be expired")
|
|
|
|
// Expire it again - should succeed (idempotent).
|
|
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Token should still be just as expired as before. No more, no less.
|
|
keyAgain, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
require.Equal(t, key.ExpiresAt, keyAgain.ExpiresAt, "expiration should be idempotent")
|
|
})
|
|
|
|
t.Run("DeletingExpiredTokenSucceeds", func(t *testing.T) {
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
|
|
// Create a token.
|
|
res, err := adminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{
|
|
Lifetime: time.Hour * 24 * 7,
|
|
})
|
|
require.NoError(t, err)
|
|
keyID := strings.Split(res.Key, "-")[0]
|
|
|
|
// Expire it first.
|
|
err = adminClient.ExpireAPIKey(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify it's expired.
|
|
key, err := adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
require.LessOrEqual(t, key.ExpiresAt, dbtime.Now(), "key should be expired")
|
|
|
|
// Delete the expired token - should succeed.
|
|
err = adminClient.DeleteAPIKey(ctx, codersdk.Me, keyID)
|
|
require.NoError(t, err)
|
|
|
|
// Verify it's gone.
|
|
_, err = adminClient.APIKeyByID(ctx, codersdk.Me, keyID)
|
|
require.Error(t, err)
|
|
var sdkErr *codersdk.Error
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, http.StatusNotFound, sdkErr.StatusCode())
|
|
})
|
|
}
|