diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go
index 293dd4db169ac..b802ede9c93e7 100644
--- a/agent/agentssh/agentssh.go
+++ b/agent/agentssh/agentssh.go
@@ -124,6 +124,7 @@ type Server struct {
listeners map[net.Listener]struct{}
conns map[net.Conn]struct{}
sessions map[ssh.Session]struct{}
+ processes map[*os.Process]struct{}
closing chan struct{}
// Wait for goroutines to exit, waited without
// a lock on mu but protected by closing.
@@ -182,6 +183,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
fs: fs,
conns: make(map[net.Conn]struct{}),
sessions: make(map[ssh.Session]struct{}),
+ processes: make(map[*os.Process]struct{}),
logger: logger,
config: config,
@@ -586,7 +588,10 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
// otherwise context cancellation will not propagate properly
// and SSH server close may be delayed.
cmd.SysProcAttr = cmdSysProcAttr()
- cmd.Cancel = cmdCancel(session.Context(), logger, cmd)
+
+ // to match OpenSSH, we don't actually tear a non-TTY command down, even if the session ends.
+ // c.f. https://github.com/coder/coder/issues/18519#issuecomment-3019118271
+ cmd.Cancel = nil
cmd.Stdout = session
cmd.Stderr = session.Stderr()
@@ -609,6 +614,16 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag
s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1)
return xerrors.Errorf("start: %w", err)
}
+
+ // Since we don't cancel the process when the session stops, we still need to tear it down if we are closing. So
+ // track it here.
+ if !s.trackProcess(cmd.Process, true) {
+ // must be closing
+ err = cmdCancel(logger, cmd.Process)
+ return xerrors.Errorf("failed to track process: %w", err)
+ }
+ defer s.trackProcess(cmd.Process, false)
+
sigs := make(chan ssh.Signal, 1)
session.Signals(sigs)
defer func() {
@@ -1052,6 +1067,27 @@ func (s *Server) trackSession(ss ssh.Session, add bool) (ok bool) {
return true
}
+// trackCommand registers the process with the server. If the server is
+// closing, the process is not registered and should be closed.
+//
+//nolint:revive
+func (s *Server) trackProcess(p *os.Process, add bool) (ok bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if add {
+ if s.closing != nil {
+ // Server closed.
+ return false
+ }
+ s.wg.Add(1)
+ s.processes[p] = struct{}{}
+ return true
+ }
+ s.wg.Done()
+ delete(s.processes, p)
+ return true
+}
+
// Close the server and all active connections. Server can be re-used
// after Close is done.
func (s *Server) Close() error {
@@ -1091,6 +1127,10 @@ func (s *Server) Close() error {
_ = c.Close()
}
+ for p := range s.processes {
+ _ = cmdCancel(s.logger, p)
+ }
+
s.logger.Debug(ctx, "closing SSH server")
err := s.srv.Close()
diff --git a/agent/agentssh/exec_other.go b/agent/agentssh/exec_other.go
index 54dfd50899412..aef496a1ef775 100644
--- a/agent/agentssh/exec_other.go
+++ b/agent/agentssh/exec_other.go
@@ -4,7 +4,7 @@ package agentssh
import (
"context"
- "os/exec"
+ "os"
"syscall"
"cdr.dev/slog"
@@ -16,9 +16,7 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
}
}
-func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
- return func() error {
- logger.Debug(ctx, "cmdCancel: sending SIGHUP to process and children", slog.F("pid", cmd.Process.Pid))
- return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP)
- }
+func cmdCancel(logger slog.Logger, p *os.Process) error {
+ logger.Debug(context.Background(), "cmdCancel: sending SIGHUP to process and children", slog.F("pid", p.Pid))
+ return syscall.Kill(-p.Pid, syscall.SIGHUP)
}
diff --git a/agent/agentssh/exec_windows.go b/agent/agentssh/exec_windows.go
index 39f0f97198479..0dafa67958a67 100644
--- a/agent/agentssh/exec_windows.go
+++ b/agent/agentssh/exec_windows.go
@@ -2,7 +2,7 @@ package agentssh
import (
"context"
- "os/exec"
+ "os"
"syscall"
"cdr.dev/slog"
@@ -12,14 +12,12 @@ func cmdSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{}
}
-func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error {
- return func() error {
- logger.Debug(ctx, "cmdCancel: killing process", slog.F("pid", cmd.Process.Pid))
- // Windows doesn't support sending signals to process groups, so we
- // have to kill the process directly. In the future, we may want to
- // implement a more sophisticated solution for process groups on
- // Windows, but for now, this is a simple way to ensure that the
- // process is terminated when the context is cancelled.
- return cmd.Process.Kill()
- }
+func cmdCancel(logger slog.Logger, p *os.Process) error {
+ logger.Debug(context.Background(), "cmdCancel: killing process", slog.F("pid", p.Pid))
+ // Windows doesn't support sending signals to process groups, so we
+ // have to kill the process directly. In the future, we may want to
+ // implement a more sophisticated solution for process groups on
+ // Windows, but for now, this is a simple way to ensure that the
+ // process is terminated when the context is cancelled.
+ return p.Kill()
}
diff --git a/cli/ssh.go b/cli/ssh.go
index 7c5bda073f973..dd0568dc5e14c 100644
--- a/cli/ssh.go
+++ b/cli/ssh.go
@@ -1569,12 +1569,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error
// Converts workspace name input to owner/workspace.agent format
// Possible valid input formats:
// workspace
+// workspace.agent
// owner/workspace
// owner--workspace
// owner/workspace--agent
// owner/workspace.agent
// owner--workspace--agent
// owner--workspace.agent
+// agent.workspace.owner - for parity with Coder Connect
func normalizeWorkspaceInput(input string) string {
// Split on "/", "--", and "."
parts := workspaceNameRe.Split(input, -1)
@@ -1583,8 +1585,15 @@ func normalizeWorkspaceInput(input string) string {
case 1:
return input // "workspace"
case 2:
+ if strings.Contains(input, ".") {
+ return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent"
+ }
return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace"
case 3:
+ // If the only separator is a dot, it's the Coder Connect format
+ if !strings.Contains(input, "/") && !strings.Contains(input, "--") {
+ return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent"
+ }
return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent"
default:
return input // Fallback
diff --git a/cli/ssh_test.go b/cli/ssh_test.go
index 5fcb6205d5e45..9f85652029f50 100644
--- a/cli/ssh_test.go
+++ b/cli/ssh_test.go
@@ -107,12 +107,14 @@ func TestSSH(t *testing.T) {
cases := []string{
"myworkspace",
+ "myworkspace.dev",
"myuser/myworkspace",
"myuser--myworkspace",
"myuser/myworkspace--dev",
"myuser/myworkspace.dev",
"myuser--myworkspace--dev",
"myuser--myworkspace.dev",
+ "dev.myworkspace.myuser",
}
for _, tc := range cases {
diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go
index b82f8a00dedb4..c7f7d35937198 100644
--- a/coderd/coderdtest/oidctest/idp.go
+++ b/coderd/coderdtest/oidctest/idp.go
@@ -307,7 +307,7 @@ func WithCustomClientAuth(hook func(t testing.TB, req *http.Request) (url.Values
// WithLogging is optional, but will log some HTTP calls made to the IDP.
func WithLogging(t testing.TB, options *slogtest.Options) func(*FakeIDP) {
return func(f *FakeIDP) {
- f.logger = slogtest.Make(t, options)
+ f.logger = slogtest.Make(t, options).Named("fakeidp")
}
}
@@ -794,6 +794,7 @@ func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string
func (f *FakeIDP) newRefreshTokens(email string) string {
refreshToken := uuid.NewString()
f.refreshTokens.Store(refreshToken, email)
+ f.logger.Info(context.Background(), "new refresh token", slog.F("email", email), slog.F("token", refreshToken))
return refreshToken
}
@@ -1003,6 +1004,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
return
}
+ f.logger.Info(r.Context(), "http idp call refresh_token", slog.F("token", refreshToken))
_, ok := f.refreshTokens.Load(refreshToken)
if !assert.True(t, ok, "invalid refresh_token") {
http.Error(rw, "invalid refresh_token", http.StatusBadRequest)
@@ -1026,6 +1028,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
f.refreshTokensUsed.Store(refreshToken, true)
// Always invalidate the refresh token after it is used.
f.refreshTokens.Delete(refreshToken)
+ f.logger.Info(r.Context(), "refresh token invalidated", slog.F("token", refreshToken))
case "urn:ietf:params:oauth:grant-type:device_code":
// Device flow
var resp externalauth.ExchangeDeviceCodeResponse
diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go
index d614b37a3d897..4b92848b773e2 100644
--- a/coderd/httpmw/apikey.go
+++ b/coderd/httpmw/apikey.go
@@ -232,16 +232,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
return optionalWrite(http.StatusUnauthorized, resp)
}
- var (
- link database.UserLink
- now = dbtime.Now()
- // Tracks if the API key has properties updated
- changed = false
- )
+ now := dbtime.Now()
+ if key.ExpiresAt.Before(now) {
+ return optionalWrite(http.StatusUnauthorized, codersdk.Response{
+ Message: SignedOutErrorMessage,
+ Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
+ })
+ }
+
+ // We only check OIDC stuff if we have a valid APIKey. An expired key means we don't trust the requestor
+ // really is the user whose key they have, and so we shouldn't be doing anything on their behalf including possibly
+ // refreshing the OIDC token.
if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC {
var err error
//nolint:gocritic // System needs to fetch UserLink to check if it's valid.
- link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
+ link, err := cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{
UserID: key.UserID,
LoginType: key.LoginType,
})
@@ -258,7 +263,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
// Check if the OAuth token is expired
- if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" {
+ if !link.OAuthExpiry.IsZero() && link.OAuthExpiry.Before(now) {
if cfg.OAuth2Configs.IsZero() {
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
@@ -267,12 +272,15 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
+ var friendlyName string
var oauthConfig promoauth.OAuth2Config
switch key.LoginType {
case database.LoginTypeGithub:
oauthConfig = cfg.OAuth2Configs.Github
+ friendlyName = "GitHub"
case database.LoginTypeOIDC:
oauthConfig = cfg.OAuth2Configs.OIDC
+ friendlyName = "OpenID Connect"
default:
return write(http.StatusInternalServerError, codersdk.Response{
Message: internalErrorMessage,
@@ -292,7 +300,13 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
})
}
- // If it is, let's refresh it from the provided config
+ if link.OAuthRefreshToken == "" {
+ return optionalWrite(http.StatusUnauthorized, codersdk.Response{
+ Message: SignedOutErrorMessage,
+ Detail: fmt.Sprintf("%s session expired at %q. Try signing in again.", friendlyName, link.OAuthExpiry.String()),
+ })
+ }
+ // We have a refresh token, so let's try it
token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{
AccessToken: link.OAuthAccessToken,
RefreshToken: link.OAuthRefreshToken,
@@ -300,28 +314,39 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
}).Token()
if err != nil {
return write(http.StatusUnauthorized, codersdk.Response{
- Message: "Could not refresh expired Oauth token. Try re-authenticating to resolve this issue.",
- Detail: err.Error(),
+ Message: fmt.Sprintf(
+ "Could not refresh expired %s token. Try re-authenticating to resolve this issue.",
+ friendlyName),
+ Detail: err.Error(),
})
}
link.OAuthAccessToken = token.AccessToken
link.OAuthRefreshToken = token.RefreshToken
link.OAuthExpiry = token.Expiry
- key.ExpiresAt = token.Expiry
- changed = true
+ //nolint:gocritic // system needs to update user link
+ link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
+ UserID: link.UserID,
+ LoginType: link.LoginType,
+ OAuthAccessToken: link.OAuthAccessToken,
+ OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
+ OAuthRefreshToken: link.OAuthRefreshToken,
+ OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
+ OAuthExpiry: link.OAuthExpiry,
+ // Refresh should keep the same debug context because we use
+ // the original claims for the group/role sync.
+ Claims: link.Claims,
+ })
+ if err != nil {
+ return write(http.StatusInternalServerError, codersdk.Response{
+ Message: internalErrorMessage,
+ Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
+ })
+ }
}
}
- // Checking if the key is expired.
- // NOTE: The `RequireAuth` React component depends on this `Detail` to detect when
- // the users token has expired. If you change the text here, make sure to update it
- // in site/src/components/RequireAuth/RequireAuth.tsx as well.
- if key.ExpiresAt.Before(now) {
- return optionalWrite(http.StatusUnauthorized, codersdk.Response{
- Message: SignedOutErrorMessage,
- Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()),
- })
- }
+ // Tracks if the API key has properties updated
+ changed := false
// Only update LastUsed once an hour to prevent database spam.
if now.Sub(key.LastUsed) > time.Hour {
@@ -363,29 +388,6 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()),
})
}
- // If the API Key is associated with a user_link (e.g. Github/OIDC)
- // then we want to update the relevant oauth fields.
- if link.UserID != uuid.Nil {
- //nolint:gocritic // system needs to update user link
- link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{
- UserID: link.UserID,
- LoginType: link.LoginType,
- OAuthAccessToken: link.OAuthAccessToken,
- OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required
- OAuthRefreshToken: link.OAuthRefreshToken,
- OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required
- OAuthExpiry: link.OAuthExpiry,
- // Refresh should keep the same debug context because we use
- // the original claims for the group/role sync.
- Claims: link.Claims,
- })
- if err != nil {
- return write(http.StatusInternalServerError, codersdk.Response{
- Message: internalErrorMessage,
- Detail: fmt.Sprintf("update user_link: %s.", err.Error()),
- })
- }
- }
// We only want to update this occasionally to reduce DB write
// load. We update alongside the UserLink and APIKey since it's
diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go
index bd979e88235ad..6e2e75ace9825 100644
--- a/coderd/httpmw/apikey_test.go
+++ b/coderd/httpmw/apikey_test.go
@@ -508,6 +508,102 @@ func TestAPIKey(t *testing.T) {
require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})
+ t.Run("APIKeyExpiredOAuthExpired", func(t *testing.T) {
+ t.Parallel()
+ var (
+ db = dbmem.New()
+ user = dbgen.User(t, db, database.User{})
+ sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
+ UserID: user.ID,
+ LastUsed: dbtime.Now().AddDate(0, 0, -1),
+ ExpiresAt: dbtime.Now().AddDate(0, 0, -1),
+ LoginType: database.LoginTypeOIDC,
+ })
+ _ = dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ OAuthExpiry: dbtime.Now().AddDate(0, 0, -1),
+ })
+
+ r = httptest.NewRequest("GET", "/", nil)
+ rw = httptest.NewRecorder()
+ )
+ r.Header.Set(codersdk.SessionTokenHeader, token)
+
+ // Include a valid oauth token for refreshing. If this token is invalid,
+ // it is difficult to tell an auth failure from an expired api key, or
+ // an expired oauth key.
+ oauthToken := &oauth2.Token{
+ AccessToken: "wow",
+ RefreshToken: "moo",
+ Expiry: dbtime.Now().AddDate(0, 0, 1),
+ }
+ httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
+ DB: db,
+ OAuth2Configs: &httpmw.OAuth2Configs{
+ OIDC: &testutil.OAuth2Config{
+ Token: oauthToken,
+ },
+ },
+ RedirectToLogin: false,
+ })(successHandler).ServeHTTP(rw, r)
+ res := rw.Result()
+ defer res.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, res.StatusCode)
+
+ gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
+ require.NoError(t, err)
+
+ require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
+ })
+
+ t.Run("APIKeyExpiredOAuthNotExpired", func(t *testing.T) {
+ t.Parallel()
+ var (
+ db = dbmem.New()
+ user = dbgen.User(t, db, database.User{})
+ sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
+ UserID: user.ID,
+ LastUsed: dbtime.Now().AddDate(0, 0, -1),
+ ExpiresAt: dbtime.Now().AddDate(0, 0, -1),
+ LoginType: database.LoginTypeOIDC,
+ })
+ _ = dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+
+ r = httptest.NewRequest("GET", "/", nil)
+ rw = httptest.NewRecorder()
+ )
+ r.Header.Set(codersdk.SessionTokenHeader, token)
+
+ oauthToken := &oauth2.Token{
+ AccessToken: "wow",
+ RefreshToken: "moo",
+ Expiry: dbtime.Now().AddDate(0, 0, 1),
+ }
+ httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
+ DB: db,
+ OAuth2Configs: &httpmw.OAuth2Configs{
+ OIDC: &testutil.OAuth2Config{
+ Token: oauthToken,
+ },
+ },
+ RedirectToLogin: false,
+ })(successHandler).ServeHTTP(rw, r)
+ res := rw.Result()
+ defer res.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, res.StatusCode)
+
+ gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
+ require.NoError(t, err)
+
+ require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
+ })
+
t.Run("OAuthRefresh", func(t *testing.T) {
t.Parallel()
var (
@@ -553,7 +649,67 @@ func TestAPIKey(t *testing.T) {
require.NoError(t, err)
require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
- require.Equal(t, oauthToken.Expiry, gotAPIKey.ExpiresAt)
+ // Note that OAuth expiry is independent of APIKey expiry, so an OIDC refresh DOES NOT affect the expiry of the
+ // APIKey
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
+
+ gotLink, err := db.GetUserLinkByUserIDLoginType(r.Context(), database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: user.ID,
+ LoginType: database.LoginTypeGithub,
+ })
+ require.NoError(t, err)
+ require.Equal(t, gotLink.OAuthRefreshToken, "moo")
+ })
+
+ t.Run("OAuthExpiredNoRefresh", func(t *testing.T) {
+ t.Parallel()
+ var (
+ ctx = testutil.Context(t, testutil.WaitShort)
+ db = dbmem.New()
+ user = dbgen.User(t, db, database.User{})
+ sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{
+ UserID: user.ID,
+ LastUsed: dbtime.Now(),
+ ExpiresAt: dbtime.Now().AddDate(0, 0, 1),
+ LoginType: database.LoginTypeGithub,
+ })
+
+ r = httptest.NewRequest("GET", "/", nil)
+ rw = httptest.NewRecorder()
+ )
+ _, err := db.InsertUserLink(ctx, database.InsertUserLinkParams{
+ UserID: user.ID,
+ LoginType: database.LoginTypeGithub,
+ OAuthExpiry: dbtime.Now().AddDate(0, 0, -1),
+ OAuthAccessToken: "letmein",
+ })
+ require.NoError(t, err)
+
+ r.Header.Set(codersdk.SessionTokenHeader, token)
+
+ oauthToken := &oauth2.Token{
+ AccessToken: "wow",
+ RefreshToken: "moo",
+ Expiry: dbtime.Now().AddDate(0, 0, 1),
+ }
+ httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
+ DB: db,
+ OAuth2Configs: &httpmw.OAuth2Configs{
+ Github: &testutil.OAuth2Config{
+ Token: oauthToken,
+ },
+ },
+ RedirectToLogin: false,
+ })(successHandler).ServeHTTP(rw, r)
+ res := rw.Result()
+ defer res.Body.Close()
+ require.Equal(t, http.StatusUnauthorized, res.StatusCode)
+
+ gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID)
+ require.NoError(t, err)
+
+ require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed)
+ require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})
t.Run("RemoteIPUpdates", func(t *testing.T) {
diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/okidcpki_test.go
index 509da563a9145..7f7dda17bcba8 100644
--- a/coderd/oauthpki/okidcpki_test.go
+++ b/coderd/oauthpki/okidcpki_test.go
@@ -144,6 +144,7 @@ func TestAzureAKPKIWithCoderd(t *testing.T) {
return values, nil
}),
oidctest.WithServing(),
+ oidctest.WithLogging(t, nil),
)
cfg := fake.OIDCConfig(t, scopes, func(cfg *coderd.OIDCConfig) {
cfg.AllowSignups = true
diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json
index a9307bfc78446..da86c1a26c8b1 100644
--- a/site/src/theme/icons.json
+++ b/site/src/theme/icons.json
@@ -58,6 +58,7 @@
"javascript.svg",
"jax.svg",
"jetbrains-toolbox.svg",
+ "jetbrains.svg",
"jfrog.svg",
"jupyter.svg",
"k8s.png",
@@ -101,6 +102,7 @@
"vault.svg",
"webstorm.svg",
"widgets.svg",
+ "windows.svg",
"windsurf.svg",
"zed.svg"
]
diff --git a/site/static/icon/jetbrains.svg b/site/static/icon/jetbrains.svg
new file mode 100644
index 0000000000000..b281f962fca81
--- /dev/null
+++ b/site/static/icon/jetbrains.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/site/static/icon/windows.svg b/site/static/icon/windows.svg
new file mode 100644
index 0000000000000..8b774a501cdc1
--- /dev/null
+++ b/site/static/icon/windows.svg
@@ -0,0 +1,29 @@
+