From 1d03e63f4fce329b7c354546be8a986713d11da1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 16 Jun 2026 12:46:10 -0700 Subject: [PATCH] feat: implement package and cli tool for repairing oidc links (#26418) --- cli/server.go | 3 +- cli/server_fix_oidc_links.go | 156 +++++++++ cli/server_fix_oidc_links_test.go | 203 ++++++++++++ cli/testdata/coder_server_--help.golden | 3 + .../coder_server_fix-oidc-links_--help.golden | 29 ++ coderd/authlink/authlink.go | 135 ++++++++ coderd/authlink/authlink_test.go | 305 ++++++++++++++++++ coderd/authlink/doc.go | 10 + coderd/database/dbauthz/dbauthz.go | 15 + coderd/database/dbauthz/dbauthz_test.go | 9 + coderd/database/dbmetrics/querymetrics.go | 16 + coderd/database/dbmock/dbmock.go | 30 ++ coderd/database/querier.go | 9 + coderd/database/queries.sql.go | 71 ++++ coderd/database/queries/user_links.sql | 33 ++ docs/reference/cli/server.md | 1 + docs/reference/cli/server_fix-oidc-links.md | 57 ++++ .../cli/testdata/coder_server_--help.golden | 3 + .../coder_server_fix-oidc-links_--help.golden | 29 ++ 19 files changed, 1116 insertions(+), 1 deletion(-) create mode 100644 cli/server_fix_oidc_links.go create mode 100644 cli/server_fix_oidc_links_test.go create mode 100644 cli/testdata/coder_server_fix-oidc-links_--help.golden create mode 100644 coderd/authlink/authlink.go create mode 100644 coderd/authlink/authlink_test.go create mode 100644 coderd/authlink/doc.go create mode 100644 docs/reference/cli/server_fix-oidc-links.md create mode 100644 enterprise/cli/testdata/coder_server_fix-oidc-links_--help.golden 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.