feat: implement package and cli tool for repairing oidc links (#26418)

This commit is contained in:
Steven Masley
2026-06-16 12:46:10 -07:00
committed by GitHub
parent b71bc31eec
commit 1d03e63f4f
19 changed files with 1116 additions and 1 deletions
+135
View File
@@ -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])
}
}
}
+305
View File
@@ -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")
})
}
+10
View File
@@ -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