diff --git a/api/auth/middleware.go b/api/auth/middleware.go index 1c2c9a3629102b1b14b0fb5d6cce8ec8c5d822d0..d72354fe71c8c13bff1369cf44e987d01d70f61b 100644 --- a/api/auth/middleware.go +++ b/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) - }) - } -} diff --git a/api/auth/provider/github.go b/api/auth/provider/github.go deleted file mode 100644 index 9ae68a8f5363d2b90ed13ea9ae989d708ff7bb6a..0000000000000000000000000000000000000000 --- a/api/auth/provider/github.go +++ /dev/null @@ -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 -} diff --git a/api/auth/provider/provider.go b/api/auth/provider/provider.go deleted file mode 100644 index 1fb45bdf0539d62edea131ace20e68d120a61299..0000000000000000000000000000000000000000 --- a/api/auth/provider/provider.go +++ /dev/null @@ -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 -} diff --git a/api/auth/session.go b/api/auth/session.go deleted file mode 100644 index a0506e4238a69c72094f5d80e851501eaae7eb29..0000000000000000000000000000000000000000 --- a/api/auth/session.go +++ /dev/null @@ -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() -} diff --git a/api/graphql/graph/root.generated.go b/api/graphql/graph/root.generated.go index e822102d3b6582c9b4946c269f62f7ec51d1558a..f98f85e51aac8785a0ff75ace48f40a9395fb04b 100644 --- a/api/graphql/graph/root.generated.go +++ b/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 ***************************** diff --git a/api/graphql/graph/root_.generated.go b/api/graphql/graph/root_.generated.go index bfd145912ded4be3f8d6f2f8ba29a6551332c8eb..dfccd87bf2f78a2ce5af36870588330de8159713 100644 --- a/api/graphql/graph/root_.generated.go +++ b/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 diff --git a/api/graphql/graphql_test.go b/api/graphql/graphql_test.go index 7a1a6e5ba8f8df04316b74518dd444bae58c3bbc..716febc6b10db20e924e6c57f0affa3ae1cdaa36 100644 --- a/api/graphql/graphql_test.go +++ b/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 } } }`) diff --git a/api/graphql/handler.go b/api/graphql/handler.go index 733f8369f2ae0a2395813eee1a8ec8c5ee72cdf0..2410453c1a376b68972bce11af30c6266a71a0e9 100644 --- a/api/graphql/handler.go +++ b/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)) diff --git a/api/graphql/models/gen_models.go b/api/graphql/models/gen_models.go index d06745661d53e0038a290303a9462996a31a41d8..51e6452862411cc4ef8658e683f5768783f56595 100644 --- a/api/graphql/models/gen_models.go +++ b/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 { } diff --git a/api/graphql/resolvers/query.go b/api/graphql/resolvers/query.go index 9ce50e246233cd8135d6aaee55a2899b5e9f7168..c65edf3ccbafbd569d94752d69f07b0353583c86 100644 --- a/api/graphql/resolvers/query.go +++ b/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, diff --git a/api/graphql/resolvers/root.go b/api/graphql/resolvers/root.go index bcc8a6f836facdffe43fe67d41ada41d9adea8eb..fb9948d129bccb4331fb968597ac93ba880d4b8b 100644 --- a/api/graphql/resolvers/root.go +++ b/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, } } diff --git a/api/graphql/schema/root.graphql b/api/graphql/schema/root.graphql index 4d797c6fe405075bc6bac0ef86438eeeb7fe957e..ed54581ee293c0059c7007ff668412447e4a0146 100644 --- a/api/graphql/schema/root.graphql +++ b/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 diff --git a/api/http/auth_handler.go b/api/http/auth_handler.go deleted file mode 100644 index 3b29379e1540f98d9435f65e0a5cd20864cce7ba..0000000000000000000000000000000000000000 --- a/api/http/auth_handler.go +++ /dev/null @@ -1,404 +0,0 @@ -// auth_handler.go implements the HTTP endpoints for the OAuth2 login flow: -// -// GET /auth/login?provider= — 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= -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": ""} 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: "/"}) -} diff --git a/commands/webui.go b/commands/webui.go index 5d22907e8981d68e8eb49ed1f4ebae48646181b4..ab12537d042bca24e274a8ed6fcf1f5d79ae4dc5 100644 --- a/commands/webui.go +++ b/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