refactor: rip out auth system from webui backend

Quentin Gliech and Claude Opus 4.6 (1M context) created

Remove the OAuth/session-based authentication system that was prototyped
on this branch. This includes the provider abstraction, session store,
auth HTTP handler, ServerConfig GraphQL type, and all related wiring in
the webui command. The simple fixed-identity middleware from trunk is
preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Change summary

api/auth/middleware.go               |  36 --
api/auth/provider/github.go          | 125 ---------
api/auth/provider/provider.go        |  45 ---
api/auth/session.go                  |  54 ----
api/graphql/graph/root.generated.go  | 220 ----------------
api/graphql/graph/root_.generated.go |  43 ---
api/graphql/graphql_test.go          |   6 
api/graphql/handler.go               |  13 
api/graphql/models/gen_models.go     |  10 
api/graphql/resolvers/query.go       |  19 -
api/graphql/resolvers/root.go        |  11 
api/graphql/schema/root.graphql      |  14 -
api/http/auth_handler.go             | 404 ------------------------------
commands/webui.go                    |  92 +-----
14 files changed, 25 insertions(+), 1,067 deletions(-)

Detailed changes

api/auth/middleware.go ๐Ÿ”—

@@ -6,9 +6,6 @@ import (
 	"github.com/git-bug/git-bug/entity"
 )
 
-// Middleware injects a fixed identity into every request context.
-// Used in local single-user mode where auth is implicit (identity comes from
-// git config at server startup rather than per-request login).
 func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -17,36 +14,3 @@ func Middleware(fixedUserId entity.Id) func(http.Handler) http.Handler {
 		})
 	}
 }
-
-// RequireAuth is middleware that rejects unauthenticated requests with 401.
-// Use this on subrouters that must never be accessible without a valid session
-// (e.g. the REST API in oauth mode when the server is publicly deployed).
-func RequireAuth(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if _, ok := r.Context().Value(identityCtxKey).(entity.Id); !ok {
-			http.Error(w, "authentication required", http.StatusUnauthorized)
-			return
-		}
-		next.ServeHTTP(w, r)
-	})
-}
-
-// SessionMiddleware reads the session cookie on every request and, when a
-// valid session exists, injects the corresponding identity ID into the context.
-//
-// Requests without a valid session are served as unauthenticated rather than
-// rejected: GraphQL's userIdentity field returns null and mutations fail with
-// ErrNotAuthenticated. This allows the frontend to gracefully degrade rather
-// than receiving hard HTTP errors for every unauthenticated page load.
-func SessionMiddleware(store *SessionStore) func(http.Handler) http.Handler {
-	return func(next http.Handler) http.Handler {
-		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			if cookie, err := r.Cookie(SessionCookie); err == nil {
-				if id, ok := store.Get(cookie.Value); ok {
-					r = r.WithContext(CtxWithUser(r.Context(), id))
-				}
-			}
-			next.ServeHTTP(w, r)
-		})
-	}
-}

api/auth/provider/github.go ๐Ÿ”—

@@ -1,125 +0,0 @@
-package provider
-
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"net/http"
-
-	"golang.org/x/oauth2"
-	"golang.org/x/oauth2/github"
-)
-
-var _ Provider = &GitHub{}
-
-// GitHub implements Provider for GitHub OAuth2.
-// It uses the standard authorization-code flow (not the device flow used by
-// the bridge) because the webui has a browser redirect available.
-//
-// GitHub does not support OpenID Connect, so this provider uses the GitHub
-// REST API to fetch profile and public key data after the token exchange.
-type GitHub struct {
-	clientID     string
-	clientSecret string
-}
-
-func NewGitHub(clientID, clientSecret string) *GitHub {
-	return &GitHub{clientID: clientID, clientSecret: clientSecret}
-}
-
-func (g *GitHub) Name() string      { return "github" }
-func (g *GitHub) HumanName() string { return "GitHub" }
-
-func (g *GitHub) config(callbackURL string) *oauth2.Config {
-	return &oauth2.Config{
-		ClientID:     g.clientID,
-		ClientSecret: g.clientSecret,
-		Endpoint:     github.Endpoint,
-		RedirectURL:  callbackURL,
-		// read:user for profile; user:email to get the primary email even when
-		// the user's email is set to private on their GitHub profile.
-		Scopes: []string{"read:user", "user:email"},
-	}
-}
-
-func (g *GitHub) AuthURL(state, callbackURL string) string {
-	return g.config(callbackURL).AuthCodeURL(state, oauth2.AccessTypeOnline)
-}
-
-func (g *GitHub) Exchange(ctx context.Context, code, callbackURL string) (*UserInfo, error) {
-	token, err := g.config(callbackURL).Exchange(ctx, code)
-	if err != nil {
-		return nil, fmt.Errorf("github: token exchange: %w", err)
-	}
-
-	client := g.config(callbackURL).Client(ctx, token)
-
-	user, err := g.fetchProfile(client)
-	if err != nil {
-		return nil, err
-	}
-
-	user.PublicKeys, err = g.fetchPublicKeys(client, user.Login)
-	if err != nil {
-		// Public keys are best-effort; a failure here should not block login.
-		user.PublicKeys = nil
-	}
-
-	return user, nil
-}
-
-func (g *GitHub) fetchProfile(client *http.Client) (*UserInfo, error) {
-	resp, err := client.Get("https://api.github.com/user")
-	if err != nil {
-		return nil, fmt.Errorf("github: fetch profile: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("github: unexpected status %d from /user", resp.StatusCode)
-	}
-
-	var u struct {
-		Login     string `json:"login"`
-		Email     string `json:"email"`
-		Name      string `json:"name"`
-		AvatarURL string `json:"avatar_url"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
-		return nil, fmt.Errorf("github: decode profile: %w", err)
-	}
-
-	return &UserInfo{
-		Login:     u.Login,
-		Email:     u.Email,
-		Name:      u.Name,
-		AvatarURL: u.AvatarURL,
-	}, nil
-}
-
-// fetchPublicKeys retrieves the user's public SSH keys from the GitHub API.
-// Returns the raw key strings (e.g. "ssh-ed25519 AAAA...").
-func (g *GitHub) fetchPublicKeys(client *http.Client, login string) ([]string, error) {
-	resp, err := client.Get("https://api.github.com/users/" + login + "/keys")
-	if err != nil {
-		return nil, fmt.Errorf("github: fetch keys: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("github: unexpected status %d from /keys", resp.StatusCode)
-	}
-
-	var keys []struct {
-		Key string `json:"key"`
-	}
-	if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil {
-		return nil, fmt.Errorf("github: decode keys: %w", err)
-	}
-
-	result := make([]string, len(keys))
-	for i, k := range keys {
-		result[i] = k.Key
-	}
-	return result, nil
-}

api/auth/provider/provider.go ๐Ÿ”—

@@ -1,45 +0,0 @@
-// Package provider defines the Provider interface and UserInfo type used for
-// external authentication in the webui.
-//
-// Each concrete provider (GitHub, GitLab, OIDC, โ€ฆ) implements Provider and is
-// registered by passing it to the auth handler at server startup.
-// The generic authorization-code flow (state, cookie) is handled by the auth
-// handler; providers only need to supply endpoints and profile fetching.
-//
-// The Provider interface is deliberately protocol-agnostic: it works for both
-// OAuth 2.0 providers (GitHub, legacy systems) and OpenID Connect providers
-// (GitLab, Gitea, Keycloak, Google). OIDC is simply OAuth 2.0 + a standard
-// identity layer; the same AuthURL/Exchange flow applies to both.
-package provider
-
-import "context"
-
-// Provider represents an external identity provider.
-type Provider interface {
-	// Name returns the machine-readable identifier, e.g. "github".
-	Name() string
-
-	// HumanName returns a user-facing display label, e.g. "GitHub".
-	HumanName() string
-
-	// AuthURL returns the URL the browser should be redirected to in order
-	// to begin the authorization-code flow.
-	AuthURL(state, callbackURL string) string
-
-	// Exchange converts an authorization code into a normalised UserInfo.
-	// The callbackURL must match the one used in AuthURL.
-	Exchange(ctx context.Context, code, callbackURL string) (*UserInfo, error)
-}
-
-// UserInfo holds the normalised user profile returned by a provider after a
-// successful authorization-code exchange. Fields may be empty when the
-// provider does not supply them.
-type UserInfo struct {
-	Login      string
-	Email      string
-	Name       string
-	AvatarURL  string
-	// PublicKeys holds SSH or GPG public keys associated with the account,
-	// if the provider exposes them. Used to pre-populate identity key data.
-	PublicKeys []string
-}

api/auth/session.go ๐Ÿ”—

@@ -1,54 +0,0 @@
-package auth
-
-import (
-	"crypto/rand"
-	"encoding/base64"
-	"sync"
-
-	"github.com/git-bug/git-bug/entity"
-)
-
-// SessionCookie is the name of the HTTP cookie that holds the session token.
-const SessionCookie = "git-bug-session"
-
-// SessionStore holds in-memory sessions mapping opaque tokens to identity IDs.
-// Sessions are intentionally not persisted: users simply re-authenticate after
-// a server restart. This keeps the implementation simple and dependency-free,
-// which is appropriate for a locally-run webui.
-type SessionStore struct {
-	mu sync.RWMutex
-	m  map[string]entity.Id
-}
-
-func NewSessionStore() *SessionStore {
-	return &SessionStore{m: make(map[string]entity.Id)}
-}
-
-// Create generates a new session token for the given identity, stores it, and
-// returns the token. The token is 32 bytes of crypto/rand encoded as base64url.
-func (s *SessionStore) Create(userId entity.Id) (string, error) {
-	b := make([]byte, 32)
-	if _, err := rand.Read(b); err != nil {
-		return "", err
-	}
-	token := base64.RawURLEncoding.EncodeToString(b)
-	s.mu.Lock()
-	s.m[token] = userId
-	s.mu.Unlock()
-	return token, nil
-}
-
-// Get retrieves the identity ID associated with a token.
-func (s *SessionStore) Get(token string) (entity.Id, bool) {
-	s.mu.RLock()
-	id, ok := s.m[token]
-	s.mu.RUnlock()
-	return id, ok
-}
-
-// Delete removes a session token (logout).
-func (s *SessionStore) Delete(token string) {
-	s.mu.Lock()
-	delete(s.m, token)
-	s.mu.Unlock()
-}

api/graphql/graph/root.generated.go ๐Ÿ”—

@@ -4,7 +4,6 @@ package graph
 
 import (
 	"context"
-	"errors"
 	"fmt"
 	"strconv"
 	"sync/atomic"
@@ -29,7 +28,6 @@ type MutationResolver interface {
 	BugSetTitle(ctx context.Context, input models.BugSetTitleInput) (*models.BugSetTitlePayload, error)
 }
 type QueryResolver interface {
-	ServerConfig(ctx context.Context) (*models.ServerConfig, error)
 	Repository(ctx context.Context, ref *string) (*models.Repository, error)
 	Repositories(ctx context.Context, after *string, before *string, first *int, last *int) (*models.RepositoryConnection, error)
 }
@@ -1024,56 +1022,6 @@ func (ec *executionContext) fieldContext_Mutation_bugSetTitle(ctx context.Contex
 	return fc, nil
 }
 
-func (ec *executionContext) _Query_serverConfig(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_Query_serverConfig(ctx, field)
-	if err != nil {
-		return graphql.Null
-	}
-	ctx = graphql.WithFieldContext(ctx, fc)
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
-		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Query().ServerConfig(rctx)
-	})
-	if err != nil {
-		ec.Error(ctx, err)
-		return graphql.Null
-	}
-	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
-		return graphql.Null
-	}
-	res := resTmp.(*models.ServerConfig)
-	fc.Result = res
-	return ec.marshalNServerConfig2แš–githubแš—comแš‹gitแš‘bugแš‹gitแš‘bugแš‹apiแš‹graphqlแš‹modelsแšServerConfig(ctx, field.Selections, res)
-}
-
-func (ec *executionContext) fieldContext_Query_serverConfig(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
-	fc = &graphql.FieldContext{
-		Object:     "Query",
-		Field:      field,
-		IsMethod:   true,
-		IsResolver: true,
-		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
-			switch field.Name {
-			case "authMode":
-				return ec.fieldContext_ServerConfig_authMode(ctx, field)
-			case "loginProviders":
-				return ec.fieldContext_ServerConfig_loginProviders(ctx, field)
-			}
-			return nil, fmt.Errorf("no field named %q was found under type ServerConfig", field.Name)
-		},
-	}
-	return fc, nil
-}
-
 func (ec *executionContext) _Query_repository(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
 	fc, err := ec.fieldContext_Query_repository(ctx, field)
 	if err != nil {
@@ -1352,94 +1300,6 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field
 	return fc, nil
 }
 
-func (ec *executionContext) _ServerConfig_authMode(ctx context.Context, field graphql.CollectedField, obj *models.ServerConfig) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_ServerConfig_authMode(ctx, field)
-	if err != nil {
-		return graphql.Null
-	}
-	ctx = graphql.WithFieldContext(ctx, fc)
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
-		ctx = rctx // use context from middleware stack in children
-		return obj.AuthMode, nil
-	})
-	if err != nil {
-		ec.Error(ctx, err)
-		return graphql.Null
-	}
-	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
-		return graphql.Null
-	}
-	res := resTmp.(string)
-	fc.Result = res
-	return ec.marshalNString2string(ctx, field.Selections, res)
-}
-
-func (ec *executionContext) fieldContext_ServerConfig_authMode(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
-	fc = &graphql.FieldContext{
-		Object:     "ServerConfig",
-		Field:      field,
-		IsMethod:   false,
-		IsResolver: false,
-		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
-			return nil, errors.New("field of type String does not have child fields")
-		},
-	}
-	return fc, nil
-}
-
-func (ec *executionContext) _ServerConfig_loginProviders(ctx context.Context, field graphql.CollectedField, obj *models.ServerConfig) (ret graphql.Marshaler) {
-	fc, err := ec.fieldContext_ServerConfig_loginProviders(ctx, field)
-	if err != nil {
-		return graphql.Null
-	}
-	ctx = graphql.WithFieldContext(ctx, fc)
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
-		ctx = rctx // use context from middleware stack in children
-		return obj.LoginProviders, nil
-	})
-	if err != nil {
-		ec.Error(ctx, err)
-		return graphql.Null
-	}
-	if resTmp == nil {
-		if !graphql.HasFieldError(ctx, fc) {
-			ec.Errorf(ctx, "must not be null")
-		}
-		return graphql.Null
-	}
-	res := resTmp.([]string)
-	fc.Result = res
-	return ec.marshalNString2แš•stringแš„(ctx, field.Selections, res)
-}
-
-func (ec *executionContext) fieldContext_ServerConfig_loginProviders(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
-	fc = &graphql.FieldContext{
-		Object:     "ServerConfig",
-		Field:      field,
-		IsMethod:   false,
-		IsResolver: false,
-		Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
-			return nil, errors.New("field of type String does not have child fields")
-		},
-	}
-	return fc, nil
-}
-
 // endregion **************************** field.gotpl *****************************
 
 // region    **************************** input.gotpl *****************************
@@ -1576,28 +1436,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
 		switch field.Name {
 		case "__typename":
 			out.Values[i] = graphql.MarshalString("Query")
-		case "serverConfig":
-			field := field
-
-			innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
-				defer func() {
-					if r := recover(); r != nil {
-						ec.Error(ctx, ec.Recover(ctx, r))
-					}
-				}()
-				res = ec._Query_serverConfig(ctx, field)
-				if res == graphql.Null {
-					atomic.AddUint32(&fs.Invalids, 1)
-				}
-				return res
-			}
-
-			rrm := func(ctx context.Context) graphql.Marshaler {
-				return ec.OperationContext.RootResolverMiddleware(ctx,
-					func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
-			}
-
-			out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
 		case "repository":
 			field := field
 
@@ -1670,66 +1508,8 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
 	return out
 }
 
-var serverConfigImplementors = []string{"ServerConfig"}
-
-func (ec *executionContext) _ServerConfig(ctx context.Context, sel ast.SelectionSet, obj *models.ServerConfig) graphql.Marshaler {
-	fields := graphql.CollectFields(ec.OperationContext, sel, serverConfigImplementors)
-
-	out := graphql.NewFieldSet(fields)
-	deferred := make(map[string]*graphql.FieldSet)
-	for i, field := range fields {
-		switch field.Name {
-		case "__typename":
-			out.Values[i] = graphql.MarshalString("ServerConfig")
-		case "authMode":
-			out.Values[i] = ec._ServerConfig_authMode(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				out.Invalids++
-			}
-		case "loginProviders":
-			out.Values[i] = ec._ServerConfig_loginProviders(ctx, field, obj)
-			if out.Values[i] == graphql.Null {
-				out.Invalids++
-			}
-		default:
-			panic("unknown field " + strconv.Quote(field.Name))
-		}
-	}
-	out.Dispatch(ctx)
-	if out.Invalids > 0 {
-		return graphql.Null
-	}
-
-	atomic.AddInt32(&ec.deferred, int32(len(deferred)))
-
-	for label, dfs := range deferred {
-		ec.processDeferredGroup(graphql.DeferredGroup{
-			Label:    label,
-			Path:     graphql.GetPath(ctx),
-			FieldSet: dfs,
-			Context:  ctx,
-		})
-	}
-
-	return out
-}
-
 // endregion **************************** object.gotpl ****************************
 
 // region    ***************************** type.gotpl *****************************
 
-func (ec *executionContext) marshalNServerConfig2githubแš—comแš‹gitแš‘bugแš‹gitแš‘bugแš‹apiแš‹graphqlแš‹modelsแšServerConfig(ctx context.Context, sel ast.SelectionSet, v models.ServerConfig) graphql.Marshaler {
-	return ec._ServerConfig(ctx, sel, &v)
-}
-
-func (ec *executionContext) marshalNServerConfig2แš–githubแš—comแš‹gitแš‘bugแš‹gitแš‘bugแš‹apiแš‹graphqlแš‹modelsแšServerConfig(ctx context.Context, sel ast.SelectionSet, v *models.ServerConfig) graphql.Marshaler {
-	if v == nil {
-		if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
-			ec.Errorf(ctx, "the requested element is null which the schema does not allow")
-		}
-		return graphql.Null
-	}
-	return ec._ServerConfig(ctx, sel, v)
-}
-
 // endregion ***************************** type.gotpl *****************************

api/graphql/graph/root_.generated.go ๐Ÿ”—

@@ -469,7 +469,6 @@ type ComplexityRoot struct {
 	Query struct {
 		Repositories func(childComplexity int, after *string, before *string, first *int, last *int) int
 		Repository   func(childComplexity int, ref *string) int
-		ServerConfig func(childComplexity int) int
 	}
 
 	Repository struct {
@@ -501,11 +500,6 @@ type ComplexityRoot struct {
 		Node   func(childComplexity int) int
 	}
 
-	ServerConfig struct {
-		AuthMode       func(childComplexity int) int
-		LoginProviders func(childComplexity int) int
-	}
-
 	Subscription struct {
 		AllEvents      func(childComplexity int, repoRef *string, typename *string) int
 		BugEvents      func(childComplexity int, repoRef *string) int
@@ -2281,13 +2275,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.Query.Repository(childComplexity, args["ref"].(*string)), true
 
-	case "Query.serverConfig":
-		if e.complexity.Query.ServerConfig == nil {
-			break
-		}
-
-		return e.complexity.Query.ServerConfig(childComplexity), true
-
 	case "Repository.allBugs":
 		if e.complexity.Repository.AllBugs == nil {
 			break
@@ -2483,20 +2470,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
 
 		return e.complexity.RepositoryEdge.Node(childComplexity), true
 
-	case "ServerConfig.authMode":
-		if e.complexity.ServerConfig.AuthMode == nil {
-			break
-		}
-
-		return e.complexity.ServerConfig.AuthMode(childComplexity), true
-
-	case "ServerConfig.loginProviders":
-		if e.complexity.ServerConfig.LoginProviders == nil {
-			break
-		}
-
-		return e.complexity.ServerConfig.LoginProviders(childComplexity), true
-
 	case "Subscription.allEvents":
 		if e.complexity.Subscription.AllEvents == nil {
 			break
@@ -3618,21 +3591,7 @@ type RepositoryEdge {
   node: Repository!
 }
 `, BuiltIn: false},
-	{Name: "../schema/root.graphql", Input: `"""Server-wide configuration, independent of any repository."""
-type ServerConfig {
-    """Authentication mode: 'local' (single user from git config),
-    'external' (multi-user via OAuth/OIDC providers), or 'readonly'."""
-    authMode: String!
-
-    """Names of the login providers enabled on this server, e.g. ['github'].
-    Empty when authMode is not 'external'."""
-    loginProviders: [String!]!
-}
-
-type Query {
-    """Server configuration and authentication mode."""
-    serverConfig: ServerConfig!
-
+	{Name: "../schema/root.graphql", Input: `type Query {
     """Access a repository by reference/name. If no ref is given, the default repository is returned if any.
     Returns null if the referenced repository does not exist."""
     repository(ref: String): Repository

api/graphql/graphql_test.go ๐Ÿ”—

@@ -24,7 +24,7 @@ func TestQueries(t *testing.T) {
 		require.NoError(t, event.Err)
 	}
 
-	handler := NewHandler(mrc, ServerConfig{AuthMode: "local"}, nil)
+	handler := NewHandler(mrc, nil)
 
 	c := client.New(handler)
 
@@ -301,7 +301,7 @@ func TestGitBrowseQueries(t *testing.T) {
 	for event := range events {
 		require.NoError(t, event.Err)
 	}
-	c := client.New(NewHandler(mrc, ServerConfig{AuthMode: "local"}, nil))
+	c := client.New(NewHandler(mrc, nil))
 
 	// โ”€โ”€ commit โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
 
@@ -476,7 +476,7 @@ func TestBugEventsSubscription(t *testing.T) {
 		require.NoError(t, event.Err)
 	}
 
-	h := NewHandler(mrc, ServerConfig{AuthMode: "local"}, nil)
+	h := NewHandler(mrc, nil)
 	c := client.New(h)
 
 	sub := c.Websocket(`subscription { bugEvents { type bug { id } } }`)

api/graphql/handler.go ๐Ÿ”—

@@ -20,17 +20,8 @@ import (
 	"github.com/git-bug/git-bug/cache"
 )
 
-// ServerConfig carries server-level configuration that is passed down to
-// GraphQL resolvers. It is constructed once at startup and does not change.
-type ServerConfig struct {
-	// AuthMode is one of "local", "external", or "readonly".
-	AuthMode string
-	// LoginProviders lists the names of enabled login providers, e.g. ["github"].
-	LoginProviders []string
-}
-
-func NewHandler(mrc *cache.MultiRepoCache, cfg ServerConfig, errorOut io.Writer) http.Handler {
-	rootResolver := resolvers.NewRootResolver(mrc, cfg.AuthMode, cfg.LoginProviders)
+func NewHandler(mrc *cache.MultiRepoCache, errorOut io.Writer) http.Handler {
+	rootResolver := resolvers.NewRootResolver(mrc)
 	config := graph.Config{Resolvers: rootResolver}
 
 	h := handler.New(graph.NewExecutableSchema(config))

api/graphql/models/gen_models.go ๐Ÿ”—

@@ -401,15 +401,5 @@ type RepositoryEdge struct {
 	Node   *Repository `json:"node"`
 }
 
-// Server-wide configuration, independent of any repository.
-type ServerConfig struct {
-	// Authentication mode: 'local' (single user from git config),
-	//     'external' (multi-user via OAuth/OIDC providers), or 'readonly'.
-	AuthMode string `json:"authMode"`
-	// Names of the login providers enabled on this server, e.g. ['github'].
-	//     Empty when authMode is not 'external'.
-	LoginProviders []string `json:"loginProviders"`
-}
-
 type Subscription struct {
 }

api/graphql/resolvers/query.go ๐Ÿ”—

@@ -12,23 +12,7 @@ import (
 var _ graph.QueryResolver = &rootQueryResolver{}
 
 type rootQueryResolver struct {
-	cache          *cache.MultiRepoCache
-	authMode       string
-	loginProviders []string
-}
-
-// ServerConfig returns static server configuration including the auth mode.
-// The frontend uses this to decide whether to show a login button, show "Read only",
-// or operate silently in single-user local mode.
-func (r rootQueryResolver) ServerConfig(_ context.Context) (*models.ServerConfig, error) {
-	providers := r.loginProviders
-	if providers == nil {
-		providers = []string{}
-	}
-	return &models.ServerConfig{
-		AuthMode:       r.authMode,
-		LoginProviders: providers,
-	}, nil
+	cache *cache.MultiRepoCache
 }
 
 func (r rootQueryResolver) Repository(_ context.Context, ref *string) (*models.Repository, error) {
@@ -51,7 +35,6 @@ func (r rootQueryResolver) Repository(_ context.Context, ref *string) (*models.R
 }
 
 // Repositories returns all registered repositories as a relay connection.
-// Used by the repo picker UI.
 func (r rootQueryResolver) Repositories(_ context.Context, after *string, before *string, first *int, last *int) (*models.RepositoryConnection, error) {
 	input := models.ConnectionInput{
 		After:  after,

api/graphql/resolvers/root.go ๐Ÿ”—

@@ -11,24 +11,17 @@ var _ graph.ResolverRoot = &RootResolver{}
 type RootResolver struct {
 	*cache.MultiRepoCache
 	bugRootSubResolver
-
-	authMode       string
-	loginProviders []string
 }
 
-func NewRootResolver(mrc *cache.MultiRepoCache, authMode string, loginProviders []string) *RootResolver {
+func NewRootResolver(mrc *cache.MultiRepoCache) *RootResolver {
 	return &RootResolver{
 		MultiRepoCache: mrc,
-		authMode:       authMode,
-		loginProviders: loginProviders,
 	}
 }
 
 func (r RootResolver) Query() graph.QueryResolver {
 	return &rootQueryResolver{
-		cache:          r.MultiRepoCache,
-		authMode:       r.authMode,
-		loginProviders: r.loginProviders,
+		cache: r.MultiRepoCache,
 	}
 }
 

api/graphql/schema/root.graphql ๐Ÿ”—

@@ -1,18 +1,4 @@
-"""Server-wide configuration, independent of any repository."""
-type ServerConfig {
-    """Authentication mode: 'local' (single user from git config),
-    'external' (multi-user via OAuth/OIDC providers), or 'readonly'."""
-    authMode: String!
-
-    """Names of the login providers enabled on this server, e.g. ['github'].
-    Empty when authMode is not 'external'."""
-    loginProviders: [String!]!
-}
-
 type Query {
-    """Server configuration and authentication mode."""
-    serverConfig: ServerConfig!
-
     """Access a repository by reference/name. If no ref is given, the default repository is returned if any.
     Returns null if the referenced repository does not exist."""
     repository(ref: String): Repository

api/http/auth_handler.go ๐Ÿ”—

@@ -1,404 +0,0 @@
-// auth_handler.go implements the HTTP endpoints for the OAuth2 login flow:
-//
-//	GET  /auth/login?provider=<name>  โ€” redirect browser to provider
-//	GET  /auth/callback               โ€” receive code, match identity, set session
-//	GET  /auth/user                   โ€” return current user as JSON
-//	POST /auth/logout                 โ€” clear session cookie
-//	GET  /auth/identities             โ€” list identities available for adoption
-//	POST /auth/adopt                  โ€” link/create identity and start session
-//
-// The flow for a returning user (identity already has provider metadata):
-//
-//	browser โ†’ /auth/login โ†’ provider โ†’ /auth/callback โ†’ set cookie โ†’ /
-//
-// The flow for a first-time user:
-//
-//	browser โ†’ /auth/login โ†’ provider โ†’ /auth/callback
-//	       โ†’ store pending โ†’ /auth/select-identity
-//	       โ†’ POST /auth/adopt โ†’ set cookie โ†’ /
-package http
-
-import (
-	"crypto/rand"
-	"encoding/base64"
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"sync"
-	"time"
-
-	apiauth "github.com/git-bug/git-bug/api/auth"
-	"github.com/git-bug/git-bug/api/auth/provider"
-	"github.com/git-bug/git-bug/cache"
-	"github.com/git-bug/git-bug/entity"
-)
-
-const (
-	authStateCookie    = "git-bug-auth-state"
-	oauthPendingCookie = "git-bug-pending"
-)
-
-// providerMetaKey returns the immutable-metadata key used to link a git-bug
-// identity to an external OAuth provider account. This follows the same
-// convention as the GitHub bridge (metaKeyGithubLogin = "github-login") so
-// that identities imported via the bridge are automatically recognised on
-// first webui login.
-func providerMetaKey(providerName string) string {
-	return providerName + "-login"
-}
-
-// authState is JSON-encoded as the OAuth2/OIDC state parameter.
-// It carries both a CSRF nonce and the provider name, so the callback can
-// verify the request and dispatch to the right provider without extra cookies.
-type authState struct {
-	Nonce    string `json:"nonce"`
-	Provider string `json:"provider"`
-}
-
-// pendingAuth holds the provider profile for a user who has authenticated
-// but has not yet been linked to a git-bug identity.
-// It expires after 10 minutes to limit the window for token reuse.
-type pendingAuth struct {
-	UserInfo  *provider.UserInfo
-	Provider  string
-	ExpiresAt time.Time
-}
-
-// AuthHandler handles the external login flow (OAuth 2.0 or OIDC).
-// It is protocol-agnostic: concrete providers implement provider.Provider and
-// are passed in at construction time.
-type AuthHandler struct {
-	mrc       *cache.MultiRepoCache
-	sessions  *apiauth.SessionStore
-	providers map[string]provider.Provider // provider name โ†’ implementation
-	baseURL   string                       // e.g. "http://localhost:3000"
-
-	// pending maps a short-lived random token (stored in a cookie) to an
-	// OAuth profile that needs identity selection before a real session is
-	// created.
-	pendingMu sync.Mutex
-	pending   map[string]*pendingAuth
-}
-
-func NewAuthHandler(mrc *cache.MultiRepoCache, sessions *apiauth.SessionStore, providers []provider.Provider, baseURL string) *AuthHandler {
-	pm := make(map[string]provider.Provider, len(providers))
-	for _, p := range providers {
-		pm[p.Name()] = p
-	}
-	return &AuthHandler{
-		mrc:       mrc,
-		sessions:  sessions,
-		providers: pm,
-		baseURL:   baseURL,
-		pending:   make(map[string]*pendingAuth),
-	}
-}
-
-// callbackURL builds the absolute URL the provider should redirect to.
-// It must match the URL registered in the provider's OAuth app settings.
-func (h *AuthHandler) callbackURL() string {
-	return h.baseURL + "/auth/callback"
-}
-
-// randToken generates a URL-safe random token of n bytes.
-func randToken(n int) (string, error) {
-	b := make([]byte, n)
-	if _, err := rand.Read(b); err != nil {
-		return "", err
-	}
-	return base64.RawURLEncoding.EncodeToString(b), nil
-}
-
-// HandleLogin initiates the OAuth2 authorization-code flow.
-// GET /auth/login?provider=<name>
-func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
-	providerName := r.URL.Query().Get("provider")
-	p, ok := h.providers[providerName]
-	if !ok {
-		http.Error(w, fmt.Sprintf("unknown provider %q", providerName), http.StatusBadRequest)
-		return
-	}
-
-	nonce, err := randToken(16)
-	if err != nil {
-		http.Error(w, "internal error", http.StatusInternalServerError)
-		return
-	}
-
-	stateData, _ := json.Marshal(authState{Nonce: nonce, Provider: providerName})
-	stateEncoded := base64.RawURLEncoding.EncodeToString(stateData)
-
-	// Store the state in a short-lived cookie for CSRF verification on callback.
-	http.SetCookie(w, &http.Cookie{
-		Name:     authStateCookie,
-		Value:    stateEncoded,
-		MaxAge:   300, // 5 minutes โ€” enough time to complete the login redirect
-		HttpOnly: true,
-		SameSite: http.SameSiteLaxMode,
-		Path:     "/",
-	})
-
-	http.Redirect(w, r, p.AuthURL(stateEncoded, h.callbackURL()), http.StatusFound)
-}
-
-// HandleCallback receives the authorization code from the provider.
-// GET /auth/callback?code=...&state=...
-func (h *AuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
-	// CSRF: verify that the state parameter matches the cookie we set.
-	stateCookie, err := r.Cookie(authStateCookie)
-	if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
-		http.Error(w, "invalid auth state", http.StatusBadRequest)
-		return
-	}
-	http.SetCookie(w, &http.Cookie{Name: authStateCookie, MaxAge: -1, Path: "/"})
-
-	stateBytes, err := base64.RawURLEncoding.DecodeString(stateCookie.Value)
-	if err != nil {
-		http.Error(w, "malformed state", http.StatusBadRequest)
-		return
-	}
-	var state authState
-	if err := json.Unmarshal(stateBytes, &state); err != nil {
-		http.Error(w, "malformed state", http.StatusBadRequest)
-		return
-	}
-
-	p, ok := h.providers[state.Provider]
-	if !ok {
-		http.Error(w, fmt.Sprintf("unknown provider %q", state.Provider), http.StatusBadRequest)
-		return
-	}
-
-	info, err := p.Exchange(r.Context(), r.URL.Query().Get("code"), h.callbackURL())
-	if err != nil {
-		http.Error(w, "OAuth exchange failed: "+err.Error(), http.StatusBadGateway)
-		return
-	}
-
-	// Try to match to an existing git-bug identity via provider metadata.
-	// This reuses the same metadata key as the GitHub bridge
-	// ("github-login"), so bridge-imported identities are recognised
-	// automatically on first login.
-	metaKey := providerMetaKey(state.Provider)
-	for _, repo := range h.mrc.AllRepos() {
-		id, err := repo.Identities().ResolveIdentityImmutableMetadata(metaKey, info.Login)
-		if err == nil {
-			h.startSession(w, id.Id())
-			http.Redirect(w, r, "/", http.StatusFound)
-			return
-		}
-	}
-
-	// No matching identity โ€” store the OAuth profile temporarily and send
-	// the user to the identity selection page.
-	pendingToken, err := randToken(16)
-	if err != nil {
-		http.Error(w, "internal error", http.StatusInternalServerError)
-		return
-	}
-	h.pendingMu.Lock()
-	h.pending[pendingToken] = &pendingAuth{
-		UserInfo:  info,
-		Provider:  state.Provider,
-		ExpiresAt: time.Now().Add(10 * time.Minute),
-	}
-	h.pendingMu.Unlock()
-
-	http.SetCookie(w, &http.Cookie{
-		Name:     oauthPendingCookie,
-		Value:    pendingToken,
-		MaxAge:   600,
-		HttpOnly: true,
-		SameSite: http.SameSiteLaxMode,
-		Path:     "/",
-	})
-	http.Redirect(w, r, "/auth/select-identity", http.StatusFound)
-}
-
-// HandleUser returns the current authenticated user as JSON.
-// GET /auth/user โ€” used by the frontend in oauth mode to poll auth state.
-func (h *AuthHandler) HandleUser(w http.ResponseWriter, r *http.Request) {
-	cookie, err := r.Cookie(apiauth.SessionCookie)
-	if err != nil {
-		w.WriteHeader(http.StatusUnauthorized)
-		return
-	}
-	userId, ok := h.sessions.Get(cookie.Value)
-	if !ok {
-		w.WriteHeader(http.StatusUnauthorized)
-		return
-	}
-
-	for _, repo := range h.mrc.AllRepos() {
-		id, err := repo.Identities().Resolve(userId)
-		if err != nil {
-			continue
-		}
-		w.Header().Set("Content-Type", "application/json")
-		json.NewEncoder(w).Encode(map[string]any{
-			"id":          id.Id().String(),
-			"humanId":     id.Id().Human(),
-			"name":        id.Name(),
-			"displayName": id.DisplayName(),
-			"login":       id.Login(),
-			"email":       id.Email(),
-			"avatarUrl":   id.AvatarUrl(),
-		})
-		return
-	}
-	w.WriteHeader(http.StatusUnauthorized)
-}
-
-// HandleLogout clears the session and redirects to the root.
-// POST /auth/logout
-func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
-	if cookie, err := r.Cookie(apiauth.SessionCookie); err == nil {
-		h.sessions.Delete(cookie.Value)
-	}
-	http.SetCookie(w, &http.Cookie{Name: apiauth.SessionCookie, MaxAge: -1, Path: "/"})
-	http.Redirect(w, r, "/", http.StatusFound)
-}
-
-// HandleIdentities returns all identities across all repos for the adoption UI.
-// GET /auth/identities โ€” only valid while a pending auth cookie is present.
-func (h *AuthHandler) HandleIdentities(w http.ResponseWriter, r *http.Request) {
-	if _, ok := h.getPending(r); !ok {
-		http.Error(w, "no pending authentication", http.StatusForbidden)
-		return
-	}
-
-	type identityJSON struct {
-		RepoSlug    string `json:"repoSlug"`
-		Id          string `json:"id"`
-		HumanId     string `json:"humanId"`
-		DisplayName string `json:"displayName"`
-		Login       string `json:"login,omitempty"`
-		AvatarUrl   string `json:"avatarUrl,omitempty"`
-	}
-
-	var identities []identityJSON
-	for _, repo := range h.mrc.AllRepos() {
-		for _, id := range repo.Identities().AllIds() {
-			i, err := repo.Identities().Resolve(id)
-			if err != nil {
-				continue
-			}
-			identities = append(identities, identityJSON{
-				RepoSlug:    repo.Name(),
-				Id:          i.Id().String(),
-				HumanId:     i.Id().Human(),
-				DisplayName: i.DisplayName(),
-				Login:       i.Login(),
-				AvatarUrl:   i.AvatarUrl(),
-			})
-		}
-	}
-
-	w.Header().Set("Content-Type", "application/json")
-	json.NewEncoder(w).Encode(identities)
-}
-
-// HandleAdopt links the pending OAuth profile to a git-bug identity (existing
-// or newly created) and starts a real session.
-// POST /auth/adopt  body: {"identityId": "<id>"}  or  {"create": true}
-func (h *AuthHandler) HandleAdopt(w http.ResponseWriter, r *http.Request) {
-	pa, ok := h.getPending(r)
-	if !ok {
-		http.Error(w, "no pending authentication", http.StatusForbidden)
-		return
-	}
-
-	var body struct {
-		IdentityId string `json:"identityId"` // empty string โ†’ create new
-	}
-	json.NewDecoder(r.Body).Decode(&body)
-
-	metaKey := providerMetaKey(pa.Provider)
-	var userId entity.Id
-
-	if body.IdentityId == "" {
-		// Create a new git-bug identity from the OAuth profile, tagging it
-		// with the provider metadata so future logins match automatically.
-		repos := h.mrc.AllRepos()
-		if len(repos) == 0 {
-			http.Error(w, "no repositories available", http.StatusInternalServerError)
-			return
-		}
-		created, err := repos[0].Identities().NewRaw(
-			pa.UserInfo.Name,
-			pa.UserInfo.Email,
-			pa.UserInfo.Login,
-			pa.UserInfo.AvatarURL,
-			nil,
-			map[string]string{metaKey: pa.UserInfo.Login},
-		)
-		if err != nil {
-			http.Error(w, "failed to create identity: "+err.Error(), http.StatusInternalServerError)
-			return
-		}
-		userId = created.Id()
-	} else {
-		// Adopt an existing identity by adding the provider metadata to it.
-		// This links the identity to the OAuth account for future logins.
-		id := entity.Id(body.IdentityId)
-		for _, repo := range h.mrc.AllRepos() {
-			cached, err := repo.Identities().Resolve(id)
-			if err != nil {
-				continue
-			}
-			cached.SetMetadata(metaKey, pa.UserInfo.Login)
-			if err := cached.Commit(); err != nil {
-				http.Error(w, "failed to update identity: "+err.Error(), http.StatusInternalServerError)
-				return
-			}
-			userId = cached.Id()
-			break
-		}
-		if userId == "" {
-			http.Error(w, "identity not found", http.StatusNotFound)
-			return
-		}
-	}
-
-	h.clearPending(r, w)
-	h.startSession(w, userId)
-	w.WriteHeader(http.StatusOK)
-}
-
-func (h *AuthHandler) startSession(w http.ResponseWriter, userId entity.Id) {
-	token, err := h.sessions.Create(userId)
-	if err != nil {
-		http.Error(w, "internal error", http.StatusInternalServerError)
-		return
-	}
-	http.SetCookie(w, &http.Cookie{
-		Name:     apiauth.SessionCookie,
-		Value:    token,
-		HttpOnly: true,
-		SameSite: http.SameSiteLaxMode,
-		Path:     "/",
-	})
-}
-
-func (h *AuthHandler) getPending(r *http.Request) (*pendingAuth, bool) {
-	cookie, err := r.Cookie(oauthPendingCookie)
-	if err != nil {
-		return nil, false
-	}
-	h.pendingMu.Lock()
-	pa, ok := h.pending[cookie.Value]
-	h.pendingMu.Unlock()
-	if !ok || time.Now().After(pa.ExpiresAt) {
-		return nil, false
-	}
-	return pa, true
-}
-
-func (h *AuthHandler) clearPending(r *http.Request, w http.ResponseWriter) {
-	if cookie, err := r.Cookie(oauthPendingCookie); err == nil {
-		h.pendingMu.Lock()
-		delete(h.pending, cookie.Value)
-		h.pendingMu.Unlock()
-	}
-	http.SetCookie(w, &http.Cookie{Name: oauthPendingCookie, MaxAge: -1, Path: "/"})
-}

commands/webui.go ๐Ÿ”—

@@ -18,7 +18,6 @@ import (
 	"github.com/spf13/cobra"
 
 	"github.com/git-bug/git-bug/api/auth"
-	"github.com/git-bug/git-bug/api/auth/provider"
 	"github.com/git-bug/git-bug/api/graphql"
 	httpapi "github.com/git-bug/git-bug/api/http"
 	"github.com/git-bug/git-bug/cache"
@@ -38,12 +37,6 @@ type webUIOptions struct {
 	readOnly  bool
 	logErrors bool
 	query     string
-
-	// OAuth provider credentials. A provider is enabled when both its
-	// client-id and client-secret are non-empty. Multiple providers can be
-	// active simultaneously.
-	githubClientId     string
-	githubClientSecret string
 }
 
 func newWebUICommand(env *execenv.Env) *cobra.Command {
@@ -74,40 +67,17 @@ Available git config:
 	flags.BoolVar(&options.logErrors, "log-errors", false, "Whether to log errors")
 	flags.StringVarP(&options.query, "query", "q", "", "The query to open in the web UI bug list")
 
-	// GitHub OAuth: both flags must be provided together to enable GitHub login.
-	flags.StringVar(&options.githubClientId, "github-client-id", "", "GitHub OAuth application client ID (enables GitHub login)")
-	flags.StringVar(&options.githubClientSecret, "github-client-secret", "", "GitHub OAuth application client secret")
-	cmd.MarkFlagsRequiredTogether("github-client-id", "github-client-secret")
-
 	return cmd
 }
 
 // setupRoutes builds the router and registers all API and UI routes.
-func setupRoutes(env *execenv.Env, opts webUIOptions, baseURL string) (*mux.Router, func() error, error) {
-	// Collect enabled login providers.
-	var providers []provider.Provider
-	if opts.githubClientId != "" {
-		providers = append(providers, provider.NewGitHub(opts.githubClientId, opts.githubClientSecret))
-	}
-
-	// Determine auth mode and configure middleware accordingly.
-	var authMode string
-	var sessions *auth.SessionStore
+func setupRoutes(env *execenv.Env, opts webUIOptions) (*mux.Router, func() error, error) {
 	router := mux.NewRouter()
 
-	switch {
-	case opts.readOnly:
-		authMode = "readonly"
-		// No middleware: every request is unauthenticated.
-
-	case len(providers) > 0:
-		authMode = "external"
-		sessions = auth.NewSessionStore()
-		router.Use(auth.SessionMiddleware(sessions))
-
-	default:
-		authMode = "local"
-		// Single-user mode: inject the identity from git config for every request.
+	// If the webUI is not read-only, use an authentication middleware with a
+	// fixed identity: the default user of the repo
+	// TODO: support dynamic authentication with OAuth
+	if !opts.readOnly {
 		author, err := identity.GetUserIdentity(env.Repo)
 		if err != nil {
 			return nil, nil, err
@@ -126,54 +96,19 @@ func setupRoutes(env *execenv.Env, opts webUIOptions, baseURL string) (*mux.Rout
 		errOut = env.Err
 	}
 
-	// Collect provider names for GraphQL serverConfig.
-	providerNames := make([]string, len(providers))
-	for i, p := range providers {
-		providerNames[i] = p.Name()
-	}
-
-	graphqlHandler := graphql.NewHandler(mrc, graphql.ServerConfig{
-		AuthMode:       authMode,
-		LoginProviders: providerNames,
-	}, errOut)
-
-	// Register OAuth routes before the catch-all static handler.
-	if authMode == "external" {
-		ah := httpapi.NewAuthHandler(mrc, sessions, providers, baseURL)
-		router.Path("/auth/login").Methods("GET").HandlerFunc(ah.HandleLogin)
-		router.Path("/auth/callback").Methods("GET").HandlerFunc(ah.HandleCallback)
-		router.Path("/auth/user").Methods("GET").HandlerFunc(ah.HandleUser)
-		router.Path("/auth/logout").Methods("POST").HandlerFunc(ah.HandleLogout)
-		router.Path("/auth/identities").Methods("GET").HandlerFunc(ah.HandleIdentities)
-		router.Path("/auth/adopt").Methods("POST").HandlerFunc(ah.HandleAdopt)
-	}
+	graphqlHandler := graphql.NewHandler(mrc, errOut)
 
 	router.Path("/playground").Handler(playground.Handler("git-bug", "/graphql"))
 	router.Path("/graphql").Handler(graphqlHandler)
-
-	// File and upload routes for bug attachments.
 	router.Path("/gitfile/{repo}/{hash}").Handler(httpapi.NewGitFileHandler(mrc))
-	router.PathPrefix("/gitraw/{repo}/{ref}/{path:.*}").Handler(httpapi.NewGitRawHandler(mrc))
 	router.Path("/upload/{repo}").Methods("POST").Handler(httpapi.NewGitUploadFileHandler(mrc))
-
 	router.PathPrefix("/").Handler(webui2.NewHandler())
 
 	return router, mrc.Close, nil
 }
 
 func runWebUI(env *execenv.Env, opts webUIOptions) error {
-	if opts.port == 0 {
-		var err error
-		opts.port, err = freeport.GetFreePort()
-		if err != nil {
-			return err
-		}
-	}
-
-	addr := net.JoinHostPort(opts.bind, strconv.Itoa(opts.port))
-	baseURL := "http://" + addr
-
-	router, closeRoutes, err := setupRoutes(env, opts, baseURL)
+	router, closeRoutes, err := setupRoutes(env, opts)
 	if err != nil {
 		return err
 	}
@@ -183,15 +118,20 @@ func runWebUI(env *execenv.Env, opts webUIOptions) error {
 		}
 	}()
 
+	if opts.port == 0 {
+		opts.port, err = freeport.GetFreePort()
+		if err != nil {
+			return err
+		}
+	}
+
+	addr := net.JoinHostPort(opts.bind, strconv.Itoa(opts.port))
 	server := &http.Server{Addr: addr, Handler: router}
+	baseURL := "http://" + addr
 
 	env.Out.Printf("Web UI: %s\n", baseURL)
 	env.Out.Printf("Graphql API: %s/graphql\n", baseURL)
 	env.Out.Printf("Graphql Playground: %s/playground\n", baseURL)
-	if opts.githubClientId != "" {
-		env.Out.Printf("Login callback URL: %s/auth/callback\n", baseURL)
-		env.Out.Println("  โ†ณ Register this URL in your OAuth/OIDC application settings")
-	}
 	env.Out.Printf("\n[ Press Ctrl+c to quit ]\n\n")
 
 	toOpen := baseURL