diff --git a/cli/server.go b/cli/server.go
index a79163963a..f61e561cff 100644
--- a/cli/server.go
+++ b/cli/server.go
@@ -1427,6 +1427,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
createAdminUserCmd := r.newCreateAdminUserCommand()
regenerateVapidKeypairCmd := r.newRegenerateVapidKeypairCommand()
+ fixOIDCLinksCmd := r.newFixOIDCLinksCommand()
rawURLOpt := serpent.Option{
Flag: "raw-url",
@@ -1440,7 +1441,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
serverCmd.Children = append(
serverCmd.Children,
- createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd,
+ createAdminUserCmd, postgresBuiltinURLCmd, postgresBuiltinServeCmd, regenerateVapidKeypairCmd, fixOIDCLinksCmd,
)
return serverCmd
diff --git a/cli/server_fix_oidc_links.go b/cli/server_fix_oidc_links.go
new file mode 100644
index 0000000000..0baeca881f
--- /dev/null
+++ b/cli/server_fix_oidc_links.go
@@ -0,0 +1,156 @@
+//go:build !slim
+
+package cli
+
+import (
+ "fmt"
+ "net/http"
+ "strings"
+
+ "golang.org/x/xerrors"
+
+ "cdr.dev/slog/v3"
+ "cdr.dev/slog/v3/sloggers/sloghuman"
+ "github.com/coder/coder/v2/cli/cliui"
+ "github.com/coder/coder/v2/coderd/authlink"
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/awsiamrds"
+ "github.com/coder/coder/v2/codersdk"
+ "github.com/coder/serpent"
+)
+
+func (r *RootCmd) newFixOIDCLinksCommand() *serpent.Command {
+ var (
+ pgURL string
+ pgAuth string
+ issuerURL string
+ dryRun bool
+ )
+ fixOIDCLinksCmd := &serpent.Command{
+ Use: "fix-oidc-links",
+ Short: "Reset OIDC linked IDs that do not match the expected issuer, allowing users to re-authenticate.",
+ Handler: func(inv *serpent.Invocation) error {
+ var (
+ ctx, cancel = inv.SignalNotifyContext(inv.Context(), StopSignals...)
+ logger = inv.Logger.AppendSinks(sloghuman.Sink(inv.Stderr))
+ )
+ if r.verbose {
+ logger = logger.Leveled(slog.LevelDebug)
+ }
+ defer cancel()
+
+ issuerURL = strings.TrimSpace(issuerURL)
+ if issuerURL == "" {
+ return xerrors.Errorf("the --%s flag is required, set it to the OIDC issuer URL (e.g. https://accounts.google.com)", "issuer-url")
+ }
+ // Resolve the canonical issuer from OIDC discovery.
+ cliui.Infof(inv.Stdout, "Resolving OIDC issuer from %q...", issuerURL)
+ // TODO: The default client might not be configured with the right certs to make this request.
+ issuer, err := authlink.ResolveIssuer(ctx, http.DefaultClient, issuerURL)
+ if err != nil {
+ return xerrors.Errorf("resolve issuer: %w", err)
+ }
+ _, _ = fmt.Fprintf(inv.Stdout, "Resolved OIDC issuer: %q\n\n", issuer)
+
+ // Connect to the database.
+ if pgURL == "" {
+ return xerrors.New("the --postgres-url flag is required")
+ }
+
+ sqlDriver := "postgres"
+ if codersdk.PostgresAuth(pgAuth) == codersdk.PostgresAuthAWSIAMRDS {
+ sqlDriver, err = awsiamrds.Register(inv.Context(), sqlDriver)
+ if err != nil {
+ return xerrors.Errorf("register aws rds iam auth: %w", err)
+ }
+ }
+
+ sqlDB, err := ConnectToPostgres(ctx, logger, sqlDriver, pgURL, nil)
+ if err != nil {
+ return xerrors.Errorf("connect to postgres: %w", err)
+ }
+ defer func() {
+ _ = sqlDB.Close()
+ }()
+ db := database.New(sqlDB)
+
+ // Run analysis.
+ analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, issuer)
+ if err != nil {
+ return xerrors.Errorf("analyze OIDC links: %w", err)
+ }
+ authlink.PrintAnalysis(inv.Stdout, analysis, issuer)
+ _, _ = fmt.Fprintln(inv.Stdout)
+
+ if dryRun {
+ return nil
+ }
+
+ mismatchedTotal := analysis.MismatchedTotal()
+ if mismatchedTotal == 0 {
+ _, _ = fmt.Fprintln(inv.Stdout, "Nothing to do. All OIDC links match the expected issuer.")
+ return nil
+ }
+
+ // Molly guard.
+ _, _ = fmt.Fprintf(inv.Stdout, "This will reset %d linked IDs to allow affected users to re-authenticate.\n", mismatchedTotal)
+ if _, err := cliui.Prompt(inv, cliui.PromptOptions{
+ Text: "Are you sure you want to continue?",
+ IsConfirm: true,
+ Default: cliui.ConfirmNo,
+ }); err != nil {
+ return err
+ }
+ _, _ = fmt.Fprintln(inv.Stdout)
+
+ // Execute the reset.
+ count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, issuer)
+ if err != nil {
+ return xerrors.Errorf("reset mismatched OIDC links: %w", err)
+ }
+ cliui.Infof(inv.Stdout, "Reset %d linked IDs.", count)
+ _, _ = fmt.Fprintln(inv.Stdout)
+
+ // Print updated analysis.
+ analysis, err = authlink.AnalyzeOIDCLinks(ctx, db, issuer)
+ if err != nil {
+ return xerrors.Errorf("re-analyze OIDC links: %w", err)
+ }
+ authlink.PrintAnalysis(inv.Stdout, analysis, issuer)
+ return nil
+ },
+ }
+
+ fixOIDCLinksCmd.Options.Add(
+ cliui.SkipPromptOption(),
+ serpent.Option{
+ Env: "CODER_PG_CONNECTION_URL",
+ Flag: "postgres-url",
+ Description: "URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).",
+ Value: serpent.StringOf(&pgURL),
+ },
+ serpent.Option{
+ Name: "Postgres Connection Auth",
+ Description: "Type of auth to use when connecting to postgres.",
+ Flag: "postgres-connection-auth",
+ Env: "CODER_PG_CONNECTION_AUTH",
+ Default: "password",
+ Value: serpent.EnumOf(&pgAuth, codersdk.PostgresAuthDrivers...),
+ },
+ serpent.Option{
+ Env: "CODER_OIDC_ISSUER_URL",
+ Flag: "issuer-url",
+ Description: "The OIDC issuer URL. The canonical issuer is resolved via OIDC discovery.",
+ Value: serpent.StringOf(&issuerURL),
+ },
+ serpent.Option{
+ Flag: "dry-run",
+ FlagShorthand: "n",
+ Env: "CODER_FIX_OIDC_LINKS_DRY_RUN",
+ Description: "Print analysis only, do not modify the database.",
+ Value: serpent.BoolOf(&dryRun),
+ },
+ )
+
+ return fixOIDCLinksCmd
+}
diff --git a/cli/server_fix_oidc_links_test.go b/cli/server_fix_oidc_links_test.go
new file mode 100644
index 0000000000..407ebc4c4f
--- /dev/null
+++ b/cli/server_fix_oidc_links_test.go
@@ -0,0 +1,203 @@
+package cli_test
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/cli/clitest"
+ "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/testutil"
+ "github.com/coder/coder/v2/testutil/expecter"
+)
+
+// fakeOIDCDiscovery returns a test server serving an OIDC discovery document.
+func fakeOIDCDiscovery(t *testing.T, issuer string) *httptest.Server {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/.well-known/openid-configuration" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "issuer": issuer,
+ })
+ }))
+ t.Cleanup(srv.Close)
+ return srv
+}
+
+func TestFixOIDCLinks(t *testing.T) {
+ t.Parallel()
+
+ t.Run("DryRun", func(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
+ t.Cleanup(cancel)
+
+ const expectedIssuer = "https://accounts.google.com"
+ oidcSrv := fakeOIDCDiscovery(t, expectedIssuer)
+
+ connectionURL, err := dbtestutil.Open(t)
+ require.NoError(t, err)
+
+ sqlDB, err := sql.Open("postgres", connectionURL)
+ require.NoError(t, err)
+ defer sqlDB.Close()
+
+ db := database.New(sqlDB)
+
+ // Seed a correctly linked user.
+ correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: correctUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-correct",
+ })
+
+ // Seed a mismatched user.
+ mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: mismatchedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://old-issuer.example.com||sub-mismatched",
+ })
+
+ inv, _ := clitest.New(t,
+ "server", "fix-oidc-links",
+ "--postgres-url", connectionURL,
+ "--issuer-url", oidcSrv.URL,
+ "--dry-run",
+ )
+
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ w := clitest.StartWithWaiter(t, inv)
+
+ stdout.ExpectMatch(ctx, "Resolved OIDC issuer: \""+expectedIssuer+"\"")
+ stdout.ExpectMatch(ctx, "Total OIDC users:")
+ stdout.ExpectMatch(ctx, "Correctly linked:")
+ stdout.ExpectMatch(ctx, "Linked to other issuers:")
+ w.RequireSuccess()
+
+ // Verify no changes were made.
+ link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: mismatchedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "https://old-issuer.example.com||sub-mismatched", link.LinkedID, "dry-run must not modify the database")
+ })
+
+ t.Run("Confirm", func(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
+ t.Cleanup(cancel)
+
+ const expectedIssuer = "https://accounts.google.com"
+ oidcSrv := fakeOIDCDiscovery(t, expectedIssuer)
+
+ connectionURL, err := dbtestutil.Open(t)
+ require.NoError(t, err)
+
+ sqlDB, err := sql.Open("postgres", connectionURL)
+ require.NoError(t, err)
+ defer sqlDB.Close()
+
+ db := database.New(sqlDB)
+
+ // Seed a correctly linked user.
+ correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: correctUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-correct",
+ })
+
+ // Seed mismatched users.
+ mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: mismatchedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://old-issuer.example.com||sub-mismatched",
+ })
+
+ inv, _ := clitest.New(t,
+ "server", "fix-oidc-links",
+ "--postgres-url", connectionURL,
+ "--issuer-url", oidcSrv.URL,
+ "--yes",
+ )
+
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ w := clitest.StartWithWaiter(t, inv)
+
+ stdout.ExpectMatch(ctx, "Reset 1 linked IDs.")
+ w.RequireSuccess()
+
+ // Verify the mismatched link was reset.
+ link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: mismatchedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "", link.LinkedID)
+
+ // Verify the correct link is unchanged.
+ link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: correctUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+ require.NoError(t, err)
+ require.Equal(t, expectedIssuer+"||sub-correct", link.LinkedID)
+ })
+
+ t.Run("NothingToDo", func(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
+ t.Cleanup(cancel)
+
+ const expectedIssuer = "https://accounts.google.com"
+ oidcSrv := fakeOIDCDiscovery(t, expectedIssuer)
+
+ connectionURL, err := dbtestutil.Open(t)
+ require.NoError(t, err)
+
+ sqlDB, err := sql.Open("postgres", connectionURL)
+ require.NoError(t, err)
+ defer sqlDB.Close()
+
+ db := database.New(sqlDB)
+
+ // All users correctly linked.
+ user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-correct",
+ })
+
+ inv, _ := clitest.New(t,
+ "server", "fix-oidc-links",
+ "--postgres-url", connectionURL,
+ "--issuer-url", oidcSrv.URL,
+ "--yes",
+ )
+
+ stdout := expecter.NewAttachedToInvocation(t, inv)
+ w := clitest.StartWithWaiter(t, inv)
+
+ stdout.ExpectMatch(ctx, "Nothing to do")
+ w.RequireSuccess()
+ })
+}
diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden
index 159bc26abd..ea137e1024 100644
--- a/cli/testdata/coder_server_--help.golden
+++ b/cli/testdata/coder_server_--help.golden
@@ -9,6 +9,9 @@ SUBCOMMANDS:
create-admin-user Create a new admin user with the given username,
email and password and adds it to every
organization.
+ fix-oidc-links Reset OIDC linked IDs that do not match the
+ expected issuer, allowing users to
+ re-authenticate.
postgres-builtin-serve Run the built-in PostgreSQL deployment.
postgres-builtin-url Output the connection URL for the built-in
PostgreSQL deployment.
diff --git a/cli/testdata/coder_server_fix-oidc-links_--help.golden b/cli/testdata/coder_server_fix-oidc-links_--help.golden
new file mode 100644
index 0000000000..201a71435e
--- /dev/null
+++ b/cli/testdata/coder_server_fix-oidc-links_--help.golden
@@ -0,0 +1,29 @@
+coder v0.0.0-devel
+
+USAGE:
+ coder server fix-oidc-links [flags]
+
+ Reset OIDC linked IDs that do not match the expected issuer, allowing users to
+ re-authenticate.
+
+OPTIONS:
+ --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password)
+ Type of auth to use when connecting to postgres.
+
+ -n, --dry-run bool, $CODER_FIX_OIDC_LINKS_DRY_RUN
+ Print analysis only, do not modify the database.
+
+ --issuer-url string, $CODER_OIDC_ISSUER_URL
+ The OIDC issuer URL. The canonical issuer is resolved via OIDC
+ discovery.
+
+ --postgres-url string, $CODER_PG_CONNECTION_URL
+ URL of a PostgreSQL database. If empty, the built-in PostgreSQL
+ deployment will be used (Coder must not be already running in this
+ case).
+
+ -y, --yes bool
+ Bypass confirmation prompts.
+
+———
+Run `coder --help` for a list of global options.
diff --git a/coderd/authlink/authlink.go b/coderd/authlink/authlink.go
new file mode 100644
index 0000000000..8bcd655ebe
--- /dev/null
+++ b/coderd/authlink/authlink.go
@@ -0,0 +1,135 @@
+package authlink
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "sort"
+
+ "golang.org/x/xerrors"
+
+ "github.com/coder/coder/v2/coderd/database"
+)
+
+// OIDCLinkAnalysis contains the results of analyzing OIDC user links
+// grouped by their issuer prefix.
+type OIDCLinkAnalysis struct {
+ Total int // Total OIDC user links
+ Unlinked int // linked_id == ""
+ CorrectIssuer int // linked_id starts with expectedIssuer||
+ MismatchedCounts map[string]int // issuer -> count for non-matching issuers
+}
+
+// MismatchedTotal returns the total number of links with a non-matching issuer.
+func (a OIDCLinkAnalysis) MismatchedTotal() int {
+ total := 0
+ for _, count := range a.MismatchedCounts {
+ total += count
+ }
+ return total
+}
+
+// AnalyzeOIDCLinks queries OIDC user links grouped by issuer prefix and
+// categorizes them relative to expectedIssuer.
+func AnalyzeOIDCLinks(ctx context.Context, db database.Store, expectedIssuer string) (OIDCLinkAnalysis, error) {
+ rows, err := db.CountOIDCLinkedIDsByIssuer(ctx)
+ if err != nil {
+ return OIDCLinkAnalysis{}, xerrors.Errorf("count OIDC linked IDs by issuer: %w", err)
+ }
+
+ analysis := OIDCLinkAnalysis{
+ MismatchedCounts: make(map[string]int),
+ }
+ for _, row := range rows {
+ count := int(row.Count)
+ analysis.Total += count
+ switch {
+ case row.IssuerPrefix == "":
+ analysis.Unlinked += count
+ case row.IssuerPrefix == expectedIssuer:
+ analysis.CorrectIssuer += count
+ default:
+ analysis.MismatchedCounts[row.IssuerPrefix] += count
+ }
+ }
+ return analysis, nil
+}
+
+// ResetMismatchedOIDCLinks resets linked_id to empty for all OIDC links whose
+// issuer prefix does not match expectedIssuer. Returns the number of rows
+// affected.
+func ResetMismatchedOIDCLinks(ctx context.Context, db database.Store, expectedIssuer string) (int64, error) {
+ prefix := expectedIssuer + "||"
+ count, err := db.UnlinkOIDCUsersByIssuerMismatch(ctx, prefix)
+ if err != nil {
+ return 0, xerrors.Errorf("unlink OIDC users by issuer mismatch: %w", err)
+ }
+ return count, nil
+}
+
+// ResolveIssuer uses OIDC discovery to fetch the canonical issuer string
+// from the provider's .well-known/openid-configuration endpoint.
+// This does not require OIDC client credentials.
+//
+// This works the same as `oidc.NewProvider`. The `oidc` package does not
+// expose a method to extract the Issuer. So we have to manually make the
+// http request.
+func ResolveIssuer(ctx context.Context, cli *http.Client, issuerURL string) (string, error) {
+ wellKnownURL, err := url.JoinPath(issuerURL, "/.well-known/openid-configuration")
+ if err != nil {
+ return "", xerrors.Errorf("resolve issuer URL: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, wellKnownURL, nil)
+ if err != nil {
+ return "", xerrors.Errorf("create discovery request: %w", err)
+ }
+
+ resp, err := cli.Do(req)
+ if err != nil {
+ return "", xerrors.Errorf("fetch OIDC discovery document: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", xerrors.Errorf("OIDC discovery returned HTTP %d", resp.StatusCode)
+ }
+
+ var discovery struct {
+ Issuer string `json:"issuer"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
+ return "", xerrors.Errorf("decode OIDC discovery document: %w", err)
+ }
+ if discovery.Issuer == "" {
+ return "", xerrors.New("OIDC discovery document has empty issuer field")
+ }
+ return discovery.Issuer, nil
+}
+
+// PrintAnalysis writes a human-readable summary of the OIDC link analysis.
+// Used for the cli command and debugging.
+func PrintAnalysis(w io.Writer, analysis OIDCLinkAnalysis, issuer string) {
+ _, _ = fmt.Fprintf(w, "OIDC Link Analysis (issuer: %s)\n", issuer)
+ _, _ = fmt.Fprintf(w, " Total OIDC users: %d\n", analysis.Total)
+ _, _ = fmt.Fprintf(w, " Correctly linked: %d\n", analysis.CorrectIssuer)
+ _, _ = fmt.Fprintf(w, " Unlinked (empty linked_id): %d\n", analysis.Unlinked)
+
+ mismatchedTotal := analysis.MismatchedTotal()
+ _, _ = fmt.Fprintf(w, " Linked to other issuers: %d\n", mismatchedTotal)
+
+ if mismatchedTotal > 0 {
+ // Sort issuer keys for deterministic output.
+ issuers := make([]string, 0, len(analysis.MismatchedCounts))
+ for issuer := range analysis.MismatchedCounts {
+ issuers = append(issuers, issuer)
+ }
+ sort.Strings(issuers)
+ for _, iss := range issuers {
+ _, _ = fmt.Fprintf(w, " %s: %d\n", iss, analysis.MismatchedCounts[iss])
+ }
+ }
+}
diff --git a/coderd/authlink/authlink_test.go b/coderd/authlink/authlink_test.go
new file mode 100644
index 0000000000..cf7a0725cc
--- /dev/null
+++ b/coderd/authlink/authlink_test.go
@@ -0,0 +1,305 @@
+package authlink_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/coderd/authlink"
+ "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/testutil"
+)
+
+func TestAnalyzeOIDCLinks(t *testing.T) {
+ t.Parallel()
+
+ t.Run("MixedIssuers", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+ const expectedIssuer = "https://accounts.google.com"
+
+ // 3 users linked to the expected issuer.
+ for i := 0; i < 3; i++ {
+ user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-" + user.ID.String(),
+ })
+ }
+
+ // 2 users linked to an old issuer.
+ for i := 0; i < 2; i++ {
+ user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://old-issuer.example.com||sub-" + user.ID.String(),
+ })
+ }
+
+ // 1 user linked to another old issuer.
+ user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://staging.example.com||sub-" + user.ID.String(),
+ })
+
+ // 1 unlinked user (empty linked_id).
+ unlinkedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: unlinkedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "",
+ })
+
+ analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
+ require.NoError(t, err)
+
+ require.Equal(t, 7, analysis.Total)
+ require.Equal(t, 3, analysis.CorrectIssuer)
+ require.Equal(t, 1, analysis.Unlinked)
+ require.Equal(t, 3, analysis.MismatchedTotal())
+ require.Equal(t, 2, analysis.MismatchedCounts["https://old-issuer.example.com"])
+ require.Equal(t, 1, analysis.MismatchedCounts["https://staging.example.com"])
+ })
+
+ t.Run("NoOIDCUsers", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+
+ analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, "https://issuer.example.com")
+ require.NoError(t, err)
+ require.Equal(t, 0, analysis.Total)
+ require.Equal(t, 0, analysis.CorrectIssuer)
+ require.Equal(t, 0, analysis.Unlinked)
+ require.Equal(t, 0, analysis.MismatchedTotal())
+ })
+
+ t.Run("AllCorrect", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+ const expectedIssuer = "https://accounts.google.com"
+
+ for i := 0; i < 3; i++ {
+ user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-" + user.ID.String(),
+ })
+ }
+
+ analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
+ require.NoError(t, err)
+ require.Equal(t, 3, analysis.Total)
+ require.Equal(t, 3, analysis.CorrectIssuer)
+ require.Equal(t, 0, analysis.Unlinked)
+ require.Equal(t, 0, analysis.MismatchedTotal())
+ })
+
+ t.Run("DeletedUsersExcluded", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+ const expectedIssuer = "https://accounts.google.com"
+
+ // Active user with mismatched issuer.
+ activeUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: activeUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://old-issuer.example.com||sub-active",
+ })
+
+ // Create user and link first, then soft-delete the user.
+ // The DB trigger prevents inserting links for already-deleted users.
+ deletedUser := dbgen.User(t, db, database.User{
+ LoginType: database.LoginTypeOIDC,
+ })
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: deletedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://old-issuer.example.com||sub-deleted",
+ })
+ require.NoError(t, db.UpdateUserDeletedByID(ctx, deletedUser.ID))
+
+ analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
+ require.NoError(t, err)
+ require.Equal(t, 1, analysis.Total)
+ require.Equal(t, 1, analysis.MismatchedTotal())
+ })
+
+ t.Run("NonOIDCLinksExcluded", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+ const expectedIssuer = "https://accounts.google.com"
+
+ // GitHub user link should not be counted.
+ ghUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeGithub})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: ghUser.ID,
+ LoginType: database.LoginTypeGithub,
+ LinkedID: "github||12345",
+ })
+
+ analysis, err := authlink.AnalyzeOIDCLinks(ctx, db, expectedIssuer)
+ require.NoError(t, err)
+ require.Equal(t, 0, analysis.Total)
+ })
+}
+
+func TestResetMismatchedOIDCLinks(t *testing.T) {
+ t.Parallel()
+
+ t.Run("ResetsOnlyMismatched", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+ const expectedIssuer = "https://accounts.google.com"
+
+ // Correctly linked user.
+ correctUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ correctLink := dbgen.UserLink(t, db, database.UserLink{
+ UserID: correctUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-correct",
+ })
+
+ // Mismatched user.
+ mismatchedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: mismatchedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "https://old-issuer.example.com||sub-mismatched",
+ })
+
+ // Unlinked user (empty linked_id).
+ unlinkedUser := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: unlinkedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: "",
+ })
+
+ count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer)
+ require.NoError(t, err)
+ require.EqualValues(t, 1, count)
+
+ // Verify the correct link is unchanged.
+ link, err := db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: correctUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+ require.NoError(t, err)
+ require.Equal(t, correctLink.LinkedID, link.LinkedID)
+
+ // Verify the mismatched link was reset.
+ link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: mismatchedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "", link.LinkedID)
+
+ // Verify the unlinked user is still unlinked.
+ link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
+ UserID: unlinkedUser.ID,
+ LoginType: database.LoginTypeOIDC,
+ })
+ require.NoError(t, err)
+ require.Equal(t, "", link.LinkedID)
+ })
+
+ t.Run("NothingToReset", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ db, _ := dbtestutil.NewDB(t)
+ const expectedIssuer = "https://accounts.google.com"
+
+ user := dbgen.User(t, db, database.User{LoginType: database.LoginTypeOIDC})
+ dbgen.UserLink(t, db, database.UserLink{
+ UserID: user.ID,
+ LoginType: database.LoginTypeOIDC,
+ LinkedID: expectedIssuer + "||sub-correct",
+ })
+
+ count, err := authlink.ResetMismatchedOIDCLinks(ctx, db, expectedIssuer)
+ require.NoError(t, err)
+ require.EqualValues(t, 0, count)
+ })
+}
+
+func TestResolveIssuer(t *testing.T) {
+ t.Parallel()
+
+ t.Run("Success", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ const expectedIssuer = "https://accounts.google.com"
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/.well-known/openid-configuration" {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "issuer": expectedIssuer,
+ })
+ }))
+ defer srv.Close()
+
+ issuer, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL)
+ require.NoError(t, err)
+ require.Equal(t, expectedIssuer, issuer)
+ })
+
+ t.Run("EmptyIssuer", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "issuer": "",
+ })
+ }))
+ defer srv.Close()
+
+ _, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "empty issuer")
+ })
+
+ t.Run("HTTPError", func(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ _, err := authlink.ResolveIssuer(ctx, srv.Client(), srv.URL)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "HTTP 500")
+ })
+}
diff --git a/coderd/authlink/doc.go b/coderd/authlink/doc.go
new file mode 100644
index 0000000000..50396907c9
--- /dev/null
+++ b/coderd/authlink/doc.go
@@ -0,0 +1,10 @@
+// Package authlink provides analysis and repair utilities for OIDC user link
+// records stored in the user_links table.
+//
+// When an OIDC provider is changed, the issuer (and possibly subject) in the
+// linked_id column changes. Because linked_id is composed as "issuer||subject",
+// existing users get locked out with "Account already linked" errors. The
+// functions in this package let an administrator inspect which links are
+// affected and reset the mismatched ones so users can re-authenticate under the
+// new provider.
+package authlink
diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go
index b0765b5c2c..c0f1cc7bab 100644
--- a/coderd/database/dbauthz/dbauthz.go
+++ b/coderd/database/dbauthz/dbauthz.go
@@ -1928,6 +1928,14 @@ func (q *querier) CountInProgressPrebuilds(ctx context.Context) ([]database.Coun
return q.db.CountInProgressPrebuilds(ctx)
}
+func (q *querier) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) {
+ // Requires the ability to read all user's personal data.
+ if err := q.authorizeContext(ctx, policy.ActionReadPersonal, rbac.ResourceUser); err != nil {
+ return nil, err
+ }
+ return q.db.CountOIDCLinkedIDsByIssuer(ctx)
+}
+
func (q *querier) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceWorkspace.All()); err != nil {
return nil, err
@@ -6927,6 +6935,13 @@ func (q *querier) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error {
return update(q.log, q.auth, fetch, q.db.UnfavoriteWorkspace)(ctx, id)
}
+func (q *querier) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
+ if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, rbac.ResourceUser); err != nil {
+ return 0, err
+ }
+ return q.db.UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix)
+}
+
func (q *querier) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
chat, err := q.db.GetChatByID(ctx, id)
if err != nil {
diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go
index e1083ed850..f8200119ba 100644
--- a/coderd/database/dbauthz/dbauthz_test.go
+++ b/coderd/database/dbauthz/dbauthz_test.go
@@ -4761,6 +4761,15 @@ func (s *MethodTestSuite) TestSystemFunctions() {
dbm.EXPECT().GetUserLinkByLinkedID(gomock.Any(), l.LinkedID).Return(l, nil).AnyTimes()
check.Args(l.LinkedID).Asserts(rbac.ResourceSystem, policy.ActionRead).Returns(l)
}))
+ s.Run("CountOIDCLinkedIDsByIssuer", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
+ dbm.EXPECT().CountOIDCLinkedIDsByIssuer(gomock.Any()).Return([]database.CountOIDCLinkedIDsByIssuerRow{}, nil).AnyTimes()
+ check.Args().Asserts(rbac.ResourceUser, policy.ActionReadPersonal).Returns([]database.CountOIDCLinkedIDsByIssuerRow{})
+ }))
+ s.Run("UnlinkOIDCUsersByIssuerMismatch", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
+ dbm.EXPECT().UnlinkOIDCUsersByIssuerMismatch(gomock.Any(), "issuer||").Return(int64(0), nil).AnyTimes()
+ check.Args("issuer||").Asserts(rbac.ResourceUser, policy.ActionUpdatePersonal).Returns(int64(0))
+ }))
+
s.Run("GetUserLinkByUserIDLoginType", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
l := testutil.Fake(s.T(), faker, database.UserLink{})
arg := database.GetUserLinkByUserIDLoginTypeParams{UserID: l.UserID, LoginType: l.LoginType}
diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go
index fbc34519c1..cafb9fdc9e 100644
--- a/coderd/database/dbmetrics/querymetrics.go
+++ b/coderd/database/dbmetrics/querymetrics.go
@@ -370,6 +370,14 @@ func (m queryMetricsStore) CountInProgressPrebuilds(ctx context.Context) ([]data
return r0, r1
}
+func (m queryMetricsStore) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) {
+ start := time.Now()
+ r0, r1 := m.s.CountOIDCLinkedIDsByIssuer(ctx)
+ m.queryLatencies.WithLabelValues("CountOIDCLinkedIDsByIssuer").Observe(time.Since(start).Seconds())
+ m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountOIDCLinkedIDsByIssuer").Inc()
+ return r0, r1
+}
+
func (m queryMetricsStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
start := time.Now()
r0, r1 := m.s.CountPendingNonActivePrebuilds(ctx)
@@ -4994,6 +5002,14 @@ func (m queryMetricsStore) UnfavoriteWorkspace(ctx context.Context, id uuid.UUID
return r0
}
+func (m queryMetricsStore) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
+ start := time.Now()
+ r0, r1 := m.s.UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix)
+ m.queryLatencies.WithLabelValues("UnlinkOIDCUsersByIssuerMismatch").Observe(time.Since(start).Seconds())
+ m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UnlinkOIDCUsersByIssuerMismatch").Inc()
+ return r0, r1
+}
+
func (m queryMetricsStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
start := time.Now()
r0 := m.s.UnpinChatByID(ctx, id)
diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go
index 5d62e26239..565c3a6051 100644
--- a/coderd/database/dbmock/dbmock.go
+++ b/coderd/database/dbmock/dbmock.go
@@ -573,6 +573,21 @@ func (mr *MockStoreMockRecorder) CountInProgressPrebuilds(ctx any) *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountInProgressPrebuilds", reflect.TypeOf((*MockStore)(nil).CountInProgressPrebuilds), ctx)
}
+// CountOIDCLinkedIDsByIssuer mocks base method.
+func (m *MockStore) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]database.CountOIDCLinkedIDsByIssuerRow, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CountOIDCLinkedIDsByIssuer", ctx)
+ ret0, _ := ret[0].([]database.CountOIDCLinkedIDsByIssuerRow)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// CountOIDCLinkedIDsByIssuer indicates an expected call of CountOIDCLinkedIDsByIssuer.
+func (mr *MockStoreMockRecorder) CountOIDCLinkedIDsByIssuer(ctx any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountOIDCLinkedIDsByIssuer", reflect.TypeOf((*MockStore)(nil).CountOIDCLinkedIDsByIssuer), ctx)
+}
+
// CountPendingNonActivePrebuilds mocks base method.
func (m *MockStore) CountPendingNonActivePrebuilds(ctx context.Context) ([]database.CountPendingNonActivePrebuildsRow, error) {
m.ctrl.T.Helper()
@@ -9417,6 +9432,21 @@ func (mr *MockStoreMockRecorder) UnfavoriteWorkspace(ctx, id any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnfavoriteWorkspace", reflect.TypeOf((*MockStore)(nil).UnfavoriteWorkspace), ctx, id)
}
+// UnlinkOIDCUsersByIssuerMismatch mocks base method.
+func (m *MockStore) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UnlinkOIDCUsersByIssuerMismatch", ctx, expectedPrefix)
+ ret0, _ := ret[0].(int64)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// UnlinkOIDCUsersByIssuerMismatch indicates an expected call of UnlinkOIDCUsersByIssuerMismatch.
+func (mr *MockStoreMockRecorder) UnlinkOIDCUsersByIssuerMismatch(ctx, expectedPrefix any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UnlinkOIDCUsersByIssuerMismatch", reflect.TypeOf((*MockStore)(nil).UnlinkOIDCUsersByIssuerMismatch), ctx, expectedPrefix)
+}
+
// UnpinChatByID mocks base method.
func (m *MockStore) UnpinChatByID(ctx context.Context, id uuid.UUID) error {
m.ctrl.T.Helper()
diff --git a/coderd/database/querier.go b/coderd/database/querier.go
index 5dd22c0246..c2fcd6d5e7 100644
--- a/coderd/database/querier.go
+++ b/coderd/database/querier.go
@@ -107,6 +107,11 @@ type sqlcQuerier interface {
// CountInProgressPrebuilds returns the number of in-progress prebuilds, grouped by preset ID and transition.
// Prebuild considered in-progress if it's in the "pending", "starting", "stopping", or "deleting" state.
CountInProgressPrebuilds(ctx context.Context) ([]CountInProgressPrebuildsRow, error)
+ // Groups OIDC user links by their issuer prefix (the part before "||" in
+ // linked_id) and returns a count for each. Empty linked_ids are reported
+ // with an empty issuer_prefix. Used for analysis before resetting
+ // mismatched links.
+ CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]CountOIDCLinkedIDsByIssuerRow, error)
// CountPendingNonActivePrebuilds returns the number of pending prebuilds for non-active template versions
CountPendingNonActivePrebuilds(ctx context.Context) ([]CountPendingNonActivePrebuildsRow, error)
CountUnreadInboxNotificationsByUserID(ctx context.Context, userID uuid.UUID) (int64, error)
@@ -1299,6 +1304,10 @@ type sqlcQuerier interface {
// This will always work regardless of the current state of the template version.
UnarchiveTemplateVersion(ctx context.Context, arg UnarchiveTemplateVersionParams) error
UnfavoriteWorkspace(ctx context.Context, id uuid.UUID) error
+ // Resets linked_id to '' for OIDC links where the linked_id is non-empty
+ // and does not begin with the expected issuer prefix. This allows users to
+ // re-authenticate under a new OIDC provider.
+ UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error)
UnpinChatByID(ctx context.Context, id uuid.UUID) error
UnsetDefaultChatModelConfigs(ctx context.Context) error
UpdateAIBridgeInterceptionEnded(ctx context.Context, arg UpdateAIBridgeInterceptionEndedParams) (AIBridgeInterception, error)
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index d28ed289a3..81d86e8c76 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -28866,6 +28866,55 @@ func (q *sqlQuerier) UpsertUserAIProviderKey(ctx context.Context, arg UpsertUser
return i, err
}
+const countOIDCLinkedIDsByIssuer = `-- name: CountOIDCLinkedIDsByIssuer :many
+SELECT
+ (CASE
+ WHEN user_links.linked_id = '' THEN ''
+ ELSE split_part(user_links.linked_id, '||', 1)
+ END)::text AS issuer_prefix,
+ COUNT(*)::int AS count
+FROM
+ user_links
+INNER JOIN
+ users ON user_links.user_id = users.id
+WHERE
+ user_links.login_type = 'oidc'
+ AND users.deleted = false
+GROUP BY issuer_prefix
+`
+
+type CountOIDCLinkedIDsByIssuerRow struct {
+ IssuerPrefix string `db:"issuer_prefix" json:"issuer_prefix"`
+ Count int32 `db:"count" json:"count"`
+}
+
+// Groups OIDC user links by their issuer prefix (the part before "||" in
+// linked_id) and returns a count for each. Empty linked_ids are reported
+// with an empty issuer_prefix. Used for analysis before resetting
+// mismatched links.
+func (q *sqlQuerier) CountOIDCLinkedIDsByIssuer(ctx context.Context) ([]CountOIDCLinkedIDsByIssuerRow, error) {
+ rows, err := q.db.QueryContext(ctx, countOIDCLinkedIDsByIssuer)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var items []CountOIDCLinkedIDsByIssuerRow
+ for rows.Next() {
+ var i CountOIDCLinkedIDsByIssuerRow
+ if err := rows.Scan(&i.IssuerPrefix, &i.Count); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Close(); err != nil {
+ return nil, err
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const getUserLinkByLinkedID = `-- name: GetUserLinkByLinkedID :one
SELECT
user_links.user_id, user_links.login_type, user_links.linked_id, user_links.oauth_access_token, user_links.oauth_refresh_token, user_links.oauth_expiry, user_links.oauth_access_token_key_id, user_links.oauth_refresh_token_key_id, user_links.claims
@@ -29124,6 +29173,28 @@ func (q *sqlQuerier) OIDCClaimFields(ctx context.Context, organizationID uuid.UU
return items, nil
}
+const unlinkOIDCUsersByIssuerMismatch = `-- name: UnlinkOIDCUsersByIssuerMismatch :execrows
+UPDATE user_links
+SET linked_id = ''
+FROM users
+WHERE user_links.user_id = users.id
+ AND user_links.login_type = 'oidc'
+ AND user_links.linked_id != ''
+ AND NOT starts_with(user_links.linked_id, $1)
+ AND users.deleted = false
+`
+
+// Resets linked_id to ” for OIDC links where the linked_id is non-empty
+// and does not begin with the expected issuer prefix. This allows users to
+// re-authenticate under a new OIDC provider.
+func (q *sqlQuerier) UnlinkOIDCUsersByIssuerMismatch(ctx context.Context, expectedPrefix string) (int64, error) {
+ result, err := q.db.ExecContext(ctx, unlinkOIDCUsersByIssuerMismatch, expectedPrefix)
+ if err != nil {
+ return 0, err
+ }
+ return result.RowsAffected()
+}
+
const updateUserLink = `-- name: UpdateUserLink :one
UPDATE
user_links
diff --git a/coderd/database/queries/user_links.sql b/coderd/database/queries/user_links.sql
index f566d42967..fb7567e4ff 100644
--- a/coderd/database/queries/user_links.sql
+++ b/coderd/database/queries/user_links.sql
@@ -113,3 +113,36 @@ WHERE
ELSE true
END
;
+
+-- name: CountOIDCLinkedIDsByIssuer :many
+-- Groups OIDC user links by their issuer prefix (the part before "||" in
+-- linked_id) and returns a count for each. Empty linked_ids are reported
+-- with an empty issuer_prefix. Used for analysis before resetting
+-- mismatched links.
+SELECT
+ (CASE
+ WHEN user_links.linked_id = '' THEN ''
+ ELSE split_part(user_links.linked_id, '||', 1)
+ END)::text AS issuer_prefix,
+ COUNT(*)::int AS count
+FROM
+ user_links
+INNER JOIN
+ users ON user_links.user_id = users.id
+WHERE
+ user_links.login_type = 'oidc'
+ AND users.deleted = false
+GROUP BY issuer_prefix;
+
+-- name: UnlinkOIDCUsersByIssuerMismatch :execrows
+-- Resets linked_id to '' for OIDC links where the linked_id is non-empty
+-- and does not begin with the expected issuer prefix. This allows users to
+-- re-authenticate under a new OIDC provider.
+UPDATE user_links
+SET linked_id = ''
+FROM users
+WHERE user_links.user_id = users.id
+ AND user_links.login_type = 'oidc'
+ AND user_links.linked_id != ''
+ AND NOT starts_with(user_links.linked_id, @expected_prefix)
+ AND users.deleted = false;
diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md
index ea3858a54a..987fc849a8 100644
--- a/docs/reference/cli/server.md
+++ b/docs/reference/cli/server.md
@@ -16,6 +16,7 @@ coder server [flags]
| [create-admin-user](./server_create-admin-user.md) | Create a new admin user with the given username, email and password and adds it to every organization. |
| [postgres-builtin-url](./server_postgres-builtin-url.md) | Output the connection URL for the built-in PostgreSQL deployment. |
| [postgres-builtin-serve](./server_postgres-builtin-serve.md) | Run the built-in PostgreSQL deployment. |
+| [fix-oidc-links](./server_fix-oidc-links.md) | Reset OIDC linked IDs that do not match the expected issuer, allowing users to re-authenticate. |
| [dbcrypt](./server_dbcrypt.md) | Manage database encryption. |
## Options
diff --git a/docs/reference/cli/server_fix-oidc-links.md b/docs/reference/cli/server_fix-oidc-links.md
new file mode 100644
index 0000000000..ef5250948e
--- /dev/null
+++ b/docs/reference/cli/server_fix-oidc-links.md
@@ -0,0 +1,57 @@
+
+# server fix-oidc-links
+
+Reset OIDC linked IDs that do not match the expected issuer, allowing users to re-authenticate.
+
+## Usage
+
+```console
+coder server fix-oidc-links [flags]
+```
+
+## Options
+
+### -y, --yes
+
+| | |
+|------|-------------------|
+| Type | bool |
+
+Bypass confirmation prompts.
+
+### --postgres-url
+
+| | |
+|-------------|---------------------------------------|
+| Type | string |
+| Environment | $CODER_PG_CONNECTION_URL |
+
+URL of a PostgreSQL database. If empty, the built-in PostgreSQL deployment will be used (Coder must not be already running in this case).
+
+### --postgres-connection-auth
+
+| | |
+|-------------|----------------------------------------|
+| Type | password\|awsiamrds |
+| Environment | $CODER_PG_CONNECTION_AUTH |
+| Default | password |
+
+Type of auth to use when connecting to postgres.
+
+### --issuer-url
+
+| | |
+|-------------|-------------------------------------|
+| Type | string |
+| Environment | $CODER_OIDC_ISSUER_URL |
+
+The OIDC issuer URL. The canonical issuer is resolved via OIDC discovery.
+
+### -n, --dry-run
+
+| | |
+|-------------|--------------------------------------------|
+| Type | bool |
+| Environment | $CODER_FIX_OIDC_LINKS_DRY_RUN |
+
+Print analysis only, do not modify the database.
diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden
index 1af797609d..18c6da79cb 100644
--- a/enterprise/cli/testdata/coder_server_--help.golden
+++ b/enterprise/cli/testdata/coder_server_--help.golden
@@ -10,6 +10,9 @@ SUBCOMMANDS:
email and password and adds it to every
organization.
dbcrypt Manage database encryption.
+ fix-oidc-links Reset OIDC linked IDs that do not match the
+ expected issuer, allowing users to
+ re-authenticate.
postgres-builtin-serve Run the built-in PostgreSQL deployment.
postgres-builtin-url Output the connection URL for the built-in
PostgreSQL deployment.
diff --git a/enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden b/enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden
new file mode 100644
index 0000000000..201a71435e
--- /dev/null
+++ b/enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden
@@ -0,0 +1,29 @@
+coder v0.0.0-devel
+
+USAGE:
+ coder server fix-oidc-links [flags]
+
+ Reset OIDC linked IDs that do not match the expected issuer, allowing users to
+ re-authenticate.
+
+OPTIONS:
+ --postgres-connection-auth password|awsiamrds, $CODER_PG_CONNECTION_AUTH (default: password)
+ Type of auth to use when connecting to postgres.
+
+ -n, --dry-run bool, $CODER_FIX_OIDC_LINKS_DRY_RUN
+ Print analysis only, do not modify the database.
+
+ --issuer-url string, $CODER_OIDC_ISSUER_URL
+ The OIDC issuer URL. The canonical issuer is resolved via OIDC
+ discovery.
+
+ --postgres-url string, $CODER_PG_CONNECTION_URL
+ URL of a PostgreSQL database. If empty, the built-in PostgreSQL
+ deployment will be used (Coder must not be already running in this
+ case).
+
+ -y, --yes bool
+ Bypass confirmation prompts.
+
+———
+Run `coder --help` for a list of global options.