Files
coder/coderd/apikey_test.go
T
Bobby Ho c9fc5b7ea1 fix: canonicalize API key scope aliases at ingress (#28322)
`rbac.IsExternalScope` accepts `all` and `application_connect`, neither
of which is an `api_key_scope` enum member. The plural `Scopes` field
stored the name as given, so `{"scopes":["all"]}` passed validation and
then 500'd inside `apikey.Generate` with `invalid API key scope: "all"`.
Reachable from `coder tokens create --scope all`, from the spellings
`docs/admin/users/sessions-tokens.md` taught, and from the `codersdk`
constants exported for exactly this purpose.

- Canonicalize inside `apikey.Generate`, in the loop that already
validates each name. One statement covers every caller and all three
input paths: plural `Scopes`, deprecated singular `Scope`, and the
default. Replaces two open-coded switches and the handler's own
per-element copy, so one place decides the stored spelling and the
handler only decides what may be requested.
- Deduplicate in the same pass. An alias and its canonical spelling are
two names going in and one name in the column.
- `ExternalScopeNames()` lists every name `IsExternalScope` accepts,
instead of dropping catalog entries that fail to parse. A curated entry
that cannot be stored used to pass every test and 500 at runtime; it now
fails two.
- Split the 400. A misspelled name and an internal `api_key_scope`
member need different words, since no re-spelling makes the second
requestable. Both rejection sites share one helper that names the case
and links the docs.
- Add reject-path tests for `not_a_real_scope` and `debug_info:read`.
The second is a valid enum member the rbac catalog treats as internal,
so before this both handler guards could be deleted with the suite still
green. A further case pins that plural `Scopes` wins when a caller sets
both fields.
- `TestExternalScopesAreStorable` pins the class rather than the two
known instances: every public rbac scope name must be storable.
`coderd/rbac` cannot assert this itself, since `database` imports `rbac`
and not the reverse.
- Docs use the canonical spellings, link the `codersdk.APIKeyScope`
schema, and say which scopes a token cannot request.

Egress is unchanged: `convertAPIKey` still derives a legacy singular
name on the way out. `ExternalScopeNames()` returns the same 57 names as
before, and `codersdk/apikey_scopes_gen.go` regenerates identically.

`rbac.CanonicalScopeName` merged with #28167, so this applies to `main`
and reviews on its own. The OAuth2 provider ignores scopes entirely
today (`authorize.go:237`, `tokens.go:377`, `tokens.go:520`), so it is
unaffected until those TODOs resolve.

Deferred from review: PLAT-528, PLAT-529, PLAT-530, and PLAT-532.
PLAT-532 covers `{"scopes":[]}` and `{"allow_list":[]}` defaulting open,
which predates this PR.

---------

Co-authored-by: McKayla はな <mckayla@hey.com>
2026-08-25 17:12:51 -07:00

810 lines
28 KiB
Go

package coderd_test
import (
"context"
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"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)
})
}
}
// 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())
})
}