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
331 lines
10 KiB
Go
331 lines
10 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-jose/go-jose/v4/jwt"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/coderd/cryptokeys"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbgen"
|
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
|
"github.com/coder/coder/v2/coderd/jwtutils"
|
|
"github.com/coder/coder/v2/coderd/workspaceapps"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/testutil"
|
|
"github.com/coder/quartz"
|
|
)
|
|
|
|
func TestGetAppHost(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
accessURL string
|
|
appHostname string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "OK",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
expected: "*.test.coder.com",
|
|
},
|
|
{
|
|
name: "None",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "",
|
|
expected: "",
|
|
},
|
|
{
|
|
name: "OKWithPort",
|
|
accessURL: "https://test.coder.com:8443",
|
|
appHostname: "*.test.coder.com",
|
|
expected: "*.test.coder.com:8443",
|
|
},
|
|
{
|
|
name: "OKWithSuffix",
|
|
accessURL: "https://test.coder.com:8443",
|
|
appHostname: "*--suffix.test.coder.com",
|
|
expected: "*--suffix.test.coder.com:8443",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
accessURL, err := url.Parse(c.accessURL)
|
|
require.NoError(t, err)
|
|
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
AccessURL: accessURL,
|
|
AppHostname: c.appHostname,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
// Should not leak to unauthenticated users.
|
|
host, err := client.AppHost(ctx)
|
|
require.Error(t, err)
|
|
require.Equal(t, "", host.Host)
|
|
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
host, err = client.AppHost(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, c.expected, host.Host)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceApplicationAuth(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cases := []struct {
|
|
name string
|
|
accessURL string
|
|
appHostname string
|
|
proxyURL string
|
|
proxyAppHostname string
|
|
|
|
redirectURI string
|
|
expectRedirect string
|
|
}{
|
|
{
|
|
name: "OK",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "https://something.test.coder.com",
|
|
expectRedirect: "https://something.test.coder.com",
|
|
},
|
|
{
|
|
name: "ProxyPathOK",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "https://proxy.test.coder.com/path",
|
|
expectRedirect: "https://proxy.test.coder.com/path",
|
|
},
|
|
{
|
|
name: "RejectProxyAccessURLPrefix",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "https://proxy.test.coder/path",
|
|
expectRedirect: "",
|
|
},
|
|
{
|
|
name: "ProxySubdomainOK",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "https://something.proxy.test.coder.com/path?yeah=true",
|
|
expectRedirect: "https://something.proxy.test.coder.com/path?yeah=true",
|
|
},
|
|
{
|
|
name: "ProxySubdomainSuffixOK",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*--suffix.proxy.test.coder.com",
|
|
redirectURI: "https://something--suffix.proxy.test.coder.com/",
|
|
expectRedirect: "https://something--suffix.proxy.test.coder.com/",
|
|
},
|
|
{
|
|
name: "NormalizeSchemePrimaryAppHostname",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "http://x.test.coder.com",
|
|
expectRedirect: "https://x.test.coder.com",
|
|
},
|
|
{
|
|
name: "NormalizeSchemeProxyAppHostname",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "http://x.proxy.test.coder.com",
|
|
expectRedirect: "https://x.proxy.test.coder.com",
|
|
},
|
|
{
|
|
name: "NoneError",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "",
|
|
expectRedirect: "",
|
|
},
|
|
{
|
|
name: "PrimaryAccessURLError",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "https://test.coder.com/",
|
|
expectRedirect: "",
|
|
},
|
|
{
|
|
name: "OtherError",
|
|
accessURL: "https://test.coder.com",
|
|
appHostname: "*.test.coder.com",
|
|
proxyURL: "https://proxy.test.coder.com",
|
|
proxyAppHostname: "*.proxy.test.coder.com",
|
|
redirectURI: "https://example.com/",
|
|
expectRedirect: "",
|
|
},
|
|
}
|
|
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
logger := testutil.Logger(t)
|
|
accessURL, err := url.Parse(c.accessURL)
|
|
require.NoError(t, err)
|
|
|
|
db, ps := dbtestutil.NewDB(t)
|
|
fetcher := &cryptokeys.DBFetcher{
|
|
DB: db,
|
|
}
|
|
|
|
kc, err := cryptokeys.NewEncryptionCache(ctx, logger, fetcher, codersdk.CryptoKeyFeatureWorkspaceAppsAPIKey)
|
|
require.NoError(t, err)
|
|
|
|
clock := quartz.NewMock(t)
|
|
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
AccessURL: accessURL,
|
|
AppHostname: c.appHostname,
|
|
Database: db,
|
|
Pubsub: ps,
|
|
APIKeyEncryptionCache: kc,
|
|
Clock: clock,
|
|
})
|
|
_ = coderdtest.CreateFirstUser(t, client)
|
|
|
|
// Disable redirects.
|
|
client.HTTPClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
}
|
|
|
|
_, _ = dbgen.WorkspaceProxy(t, db, database.WorkspaceProxy{
|
|
Url: c.proxyURL,
|
|
WildcardHostname: c.proxyAppHostname,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/applications/auth-redirect", nil, func(req *http.Request) {
|
|
q := req.URL.Query()
|
|
q.Set("redirect_uri", c.redirectURI)
|
|
req.URL.RawQuery = q.Encode()
|
|
})
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusSeeOther {
|
|
err = codersdk.ReadBodyAsError(resp)
|
|
if c.expectRedirect == "" {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
require.NoError(t, err)
|
|
return
|
|
}
|
|
if c.expectRedirect == "" {
|
|
t.Fatal("expected a failure but got a success")
|
|
}
|
|
|
|
loc, err := resp.Location()
|
|
require.NoError(t, err)
|
|
q := loc.Query()
|
|
|
|
// Verify the API key is set.
|
|
encryptedAPIKey := loc.Query().Get(workspaceapps.SubdomainProxyAPIKeyParam)
|
|
require.NotEmpty(t, encryptedAPIKey, "no API key was set in the query parameters")
|
|
|
|
// Strip the API key from the actual redirect URI and compare.
|
|
q.Del(workspaceapps.SubdomainProxyAPIKeyParam)
|
|
loc.RawQuery = q.Encode()
|
|
require.Equal(t, c.expectRedirect, loc.String())
|
|
|
|
var token workspaceapps.EncryptedAPIKeyPayload
|
|
err = jwtutils.Decrypt(ctx, kc, encryptedAPIKey, &token, jwtutils.WithDecryptExpected(jwt.Expected{
|
|
Time: clock.Now(),
|
|
AnyAudience: jwt.Audience{"wsproxy"},
|
|
Issuer: "coderd",
|
|
}))
|
|
require.NoError(t, err)
|
|
require.Equal(t, jwt.NewNumericDate(clock.Now().Add(time.Minute)), token.Expiry)
|
|
require.Equal(t, jwt.NewNumericDate(clock.Now().Add(-time.Minute)), token.NotBefore)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceApplicationAuthScoped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := testutil.Context(t, testutil.WaitLong)
|
|
accessURL, err := url.Parse("https://test.coder.com")
|
|
require.NoError(t, err)
|
|
client, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{
|
|
AccessURL: accessURL,
|
|
AppHostname: "*.test.coder.com",
|
|
})
|
|
user := coderdtest.CreateFirstUser(t, client)
|
|
|
|
allow := []codersdk.APIAllowListTarget{
|
|
codersdk.AllowResourceTarget(codersdk.ResourceWorkspace, uuid.New()),
|
|
codersdk.AllowTypeTarget(codersdk.ResourceApiKey),
|
|
}
|
|
authRedirect := func(scopes ...codersdk.APIKeyScope) int {
|
|
token, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{Scopes: scopes, AllowList: allow})
|
|
require.NoError(t, err)
|
|
scoped := codersdk.New(client.URL, codersdk.WithSessionToken(token.Key))
|
|
scoped.HTTPClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
|
|
resp, err := scoped.Request(ctx, http.MethodGet, "/api/v2/applications/auth-redirect", nil, func(req *http.Request) {
|
|
req.URL.RawQuery = url.Values{"redirect_uri": {"https://app.test.coder.com"}}.Encode()
|
|
})
|
|
require.NoError(t, err)
|
|
_ = resp.Body.Close()
|
|
return resp.StatusCode
|
|
}
|
|
|
|
require.Equal(t, http.StatusNotFound, authRedirect(codersdk.APIKeyScopeApiKeyCreate))
|
|
require.Equal(t, http.StatusSeeOther, authRedirect(codersdk.APIKeyScopeCoderWorkspacesAccess, codersdk.APIKeyScopeApiKeyCreate))
|
|
// Unnamed token keys would collide on the per-user name index.
|
|
require.Equal(t, http.StatusSeeOther, authRedirect(codersdk.APIKeyScopeCoderWorkspacesAccess, codersdk.APIKeyScopeApiKeyCreate))
|
|
|
|
//nolint:gocritic // Reading the minted key back.
|
|
keys, err := api.Database.GetAPIKeysByUserID(dbauthz.AsSystemRestricted(ctx), database.GetAPIKeysByUserIDParams{
|
|
LoginType: database.LoginTypeToken,
|
|
UserID: user.UserID,
|
|
})
|
|
require.NoError(t, err)
|
|
idx := slices.IndexFunc(keys, func(k database.APIKey) bool {
|
|
return k.Scopes.Has(database.ApiKeyScopeCoderApplicationConnect)
|
|
})
|
|
require.NotEqual(t, -1, idx, "no application_connect key minted")
|
|
key, err := client.APIKeyByID(ctx, codersdk.Me, keys[idx].ID)
|
|
require.NoError(t, err)
|
|
require.ElementsMatch(t, allow, key.AllowList)
|
|
}
|