Detailed changes
@@ -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)
- })
- }
-}
@@ -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
-}
@@ -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
-}
@@ -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()
-}
@@ -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 *****************************
@@ -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
@@ -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 } } }`)
@@ -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))
@@ -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 {
}
@@ -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,
@@ -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,
}
}
@@ -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
@@ -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: "/"})
-}
@@ -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