From a16f266609fc3164b7f03c99958997b12be4018e Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Wed, 8 Apr 2026 21:13:16 +0200 Subject: [PATCH] refactor: rip out auth system from webui backend 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) --- 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(+), 1067 deletions(-) delete mode 100644 api/auth/provider/github.go delete mode 100644 api/auth/provider/provider.go delete mode 100644 api/auth/session.go delete mode 100644 api/http/auth_handler.go 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