Просмотр исходного кода

Merge branch '0.6.0-github-org-access' of https://github.com/porter-dev/porter into 0.6.0-github-org-access

jusrhee 4 лет назад
Родитель
Сommit
d06d418c16

+ 1 - 0
cmd/app/main.go

@@ -72,6 +72,7 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
 	)
 
 	if err != nil {

+ 8 - 6
cmd/migrate/keyrotate/helpers_test.go

@@ -253,12 +253,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 3 - 0
cmd/migrate/main.go

@@ -57,6 +57,9 @@ func main() {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
+		&ints.GithubAppOAuthIntegration{},
+		&ints.GithubAppOAuthIntegration{},
 	)
 
 	if err != nil {

+ 10 - 0
dashboard/src/main/home/Home.tsx

@@ -26,6 +26,7 @@ import ProjectSettings from "./project-settings/ProjectSettings";
 import Sidebar from "./sidebar/Sidebar";
 import PageNotFound from "components/PageNotFound";
 import DeleteNamespaceModal from "./modals/DeleteNamespaceModal";
+import AccountSettingsModal from "./modals/AccountSettingsModal";
 
 type PropsType = RouteComponentProps & {
   logOut: () => void;
@@ -520,6 +521,15 @@ class Home extends Component<PropsType, StateType> {
             <DeleteNamespaceModal />
           </Modal>
         )}
+        {currentModal === "AccountSettingsModal" && (
+          <Modal
+            onRequestClose={() => setCurrentModal(null, null)}
+            width="700px"
+            height="280px"
+          >
+            <AccountSettingsModal />
+          </Modal>
+        )}
 
         {this.renderSidebar()}
 

+ 1 - 1
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -134,7 +134,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
             label={label}
             toggleCollapse={(e: MouseEvent) => this.toggleDisplay(e, i)}
             triggerDelete={(e: MouseEvent) => this.triggerDelete(e, i, item_id)}
-          ></IntegrationRow>
+          />
         );
       });
     } else if (integrations && integrations.length > 0) {

+ 1 - 5
dashboard/src/main/home/integrations/Integrations.tsx

@@ -72,11 +72,7 @@ class Integrations extends Component<PropsType, StateType> {
             if (!IntegrationCategoryStrings.includes(currentCategory)) {
               pushFiltered(this.props, "/integrations", ["project_id"]);
             }
-            return (
-              <IntegrationCategories
-                category={currentCategory}
-              ></IntegrationCategories>
-            );
+            return <IntegrationCategories category={currentCategory} />;
           }}
         />
         <Route>

+ 169 - 0
dashboard/src/main/home/modals/AccountSettingsModal.tsx

@@ -0,0 +1,169 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import close from "../../../assets/close.png";
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import Loading from "../../../components/Loading";
+
+interface GithubAppAccessData {
+  has_access: boolean;
+  username?: string;
+  accounts?: string[];
+}
+
+const AccountSettingsModal = () => {
+  const { setCurrentModal } = useContext(Context);
+  const [accessLoading, setAccessLoading] = useState(true);
+  const [accessError, setAccessError] = useState(false);
+  const [accessData, setAccessData] = useState<GithubAppAccessData>({
+    has_access: false,
+  });
+
+  useEffect(() => {
+    api
+      .getGithubAccess("<token>", {}, {})
+      .then(({ data }) => {
+        setAccessData(data);
+        setAccessLoading(false);
+      })
+      .catch(() => {
+        setAccessError(true);
+        setAccessLoading(false);
+      });
+  }, []);
+
+  return (
+    <>
+      <CloseButton
+        onClick={() => {
+          setCurrentModal(null, null);
+        }}
+      >
+        <CloseButtonImg src={close} />
+      </CloseButton>
+      <ModalTitle>Account Settings</ModalTitle>
+      <Subtitle>Github Integration</Subtitle>
+      <br />
+      {accessError ? (
+        <Placeholder>An error has occured.</Placeholder>
+      ) : accessLoading ? (
+        <LoadingWrapper>
+          {" "}
+          <Loading />
+        </LoadingWrapper>
+      ) : (
+        <>
+          {/* Will be styled (and show what account is connected) later */}
+          {accessData.has_access ? (
+            <Placeholder>
+              Authorized as <b>{accessData.username}</b> <br />
+              {!accessData.accounts || accessData.accounts.length == 0 ? (
+                <>
+                  It doesn't seem like the Porter application is installed in
+                  any repositories you have access to.
+                  <A href={"/api/integrations/github-app/install"}>
+                    Install the application in more repositories
+                  </A>
+                </>
+              ) : (
+                <>
+                  Additionally, porter has access to repos with the application
+                  installed in them in the following organizations and accounts:{" "}
+                  {accessData.accounts.map((name, i) => {
+                    return (
+                      <React.Fragment key={i}>
+                        <b>{name}</b>
+                        {i == accessData.accounts.length - 1 ? "" : ", "}
+                      </React.Fragment>
+                    );
+                  })}{" "}
+                  <br />
+                  Don't see the right repos?{" "}
+                  <A href={"/api/integrations/github-app/install"}>
+                    Install the application in more repositories
+                  </A>
+                </>
+              )}
+            </Placeholder>
+          ) : (
+            <>
+              No github integration detected. You can
+              <A href={"/api/integrations/github-app/authorize"}>
+                connect your GitHub account
+              </A>
+            </>
+          )}
+        </>
+      )}
+    </>
+  );
+};
+
+export default AccountSettingsModal;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: "Assistant";
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: "Work Sans", sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const A = styled.a`
+  color: #8590ff;
+  text-decoration: underline;
+  margin-left: 5px;
+  cursor: pointer;
+`;
+
+const LoadingWrapper = styled.div`
+  height: 50px;
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  line-height: 1.6em;
+  user-select: none;
+`;

+ 10 - 3
dashboard/src/main/home/navbar/Navbar.tsx

@@ -31,9 +31,16 @@ export default class Navbar extends Component<PropsType, StateType> {
             <DropdownLabel>
               {this.context.user && this.context.user.email}
             </DropdownLabel>
-            <LogOutButton onClick={this.props.logOut}>
+            <UserDropdownButton
+              onClick={() =>
+                this.context.setCurrentModal("AccountSettingsModal", {})
+              }
+            >
+              Account Settings
+            </UserDropdownButton>
+            <UserDropdownButton onClick={this.props.logOut}>
               <i className="material-icons">keyboard_return</i> Log Out
-            </LogOutButton>
+            </UserDropdownButton>
           </Dropdown>
         </>
       );
@@ -81,7 +88,7 @@ const CloseOverlay = styled.div`
   cursor: default;
 `;
 
-const LogOutButton = styled.button`
+const UserDropdownButton = styled.button`
   padding: 13px;
   height: 40px;
   font-size: 13px;

+ 5 - 0
dashboard/src/shared/api.tsx

@@ -732,6 +732,10 @@ const linkGithubProject = baseApi<
   return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
+const getGithubAccess = baseApi<{}, {}>("GET", () => {
+  return `/api/integrations/github-app/access`;
+});
+
 const logInUser = baseApi<{
   email: string;
   password: string;
@@ -996,6 +1000,7 @@ export default {
   getTemplates,
   getUser,
   linkGithubProject,
+  getGithubAccess,
   listConfigMaps,
   logInUser,
   logOutUser,

+ 1 - 0
dashboard/webpack.config.js

@@ -55,6 +55,7 @@ module.exports = () => {
     },
     devServer: {
       historyApiFallback: true,
+      disableHostCheck: true,
     },
     plugins: [
       new HtmlWebpackPlugin({

+ 15 - 0
docs/guides/linking-github-account.md

@@ -0,0 +1,15 @@
+# Configuring Github Access
+
+> 🚧
+>
+> **Note:** Porter currently uses an oauth app to authenticate and gain access to repositories. This mechanism will be phased out
+> over the next few weeks to transition to the authentication method below. After this, all old applications will still work as intended 
+> but new applications will need to be authenticated through the GitHub Application.
+
+
+Porter uses a GitHub application to authorize and gain access to your GitHub repositories. 
+In order to see your repositories on the web application, you first need to authorize the application through oauth. 
+You can do this by clicking "Account Settings" on the user dropdown on the top right and then authorizing the GitHub application through the link in the modal that appears.
+After you authorize the application, you can open the modal again to install your application in either your account or any organization you are part of. 
+Note that in organizations Porter will have access to every repository that the app is installed into regardless of who it was installed by. 
+So, if your organization does not grant you access to install applications, having an admin install the application into the appropriate organization repositories is sufficient.

+ 4 - 0
internal/config/config.go

@@ -41,6 +41,10 @@ type ServerConf struct {
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubLoginEnabled bool   `env:"GITHUB_LOGIN_ENABLED,default=true"`
 
+	GithubAppClientID     string `env:"GITHUB_APP_CLIENT_ID"`
+	GithubAppClientSecret string `env:"GITHUB_APP_CLIENT_SECRET"`
+	GithubAppName         string `env:"GITHUB_APP_NAME"`
+
 	GoogleClientID         string `env:"GOOGLE_CLIENT_ID"`
 	GoogleClientSecret     string `env:"GOOGLE_CLIENT_SECRET"`
 	GoogleRestrictedDomain string `env:"GOOGLE_RESTRICTED_DOMAIN"`

+ 8 - 6
internal/forms/helper_test.go

@@ -200,12 +200,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 33 - 0
internal/models/integrations/github_app.go

@@ -0,0 +1,33 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// GithubAppInstallation is an instance of the porter github app
+// we need to store account/installation id pairs in order to authenticate as the installation
+type GithubAppInstallation struct {
+	gorm.Model
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id" gorm:"unique"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+type GithubAppInstallationExternal struct {
+	ID uint `json:"id"`
+
+	// Can belong to either a user or an organization
+	AccountID int64 `json:"account_id"`
+
+	// Installation ID (used for authentication)
+	InstallationID int64 `json:"installation_id"`
+}
+
+func (r *GithubAppInstallation) Externalize() *GithubAppInstallationExternal {
+	return &GithubAppInstallationExternal{
+		ID:             r.ID,
+		AccountID:      r.AccountID,
+		InstallationID: r.InstallationID,
+	}
+}

+ 21 - 7
internal/models/integrations/oauth.go

@@ -14,10 +14,23 @@ const (
 	OAuthGoogle       OAuthIntegrationClient = "google"
 )
 
+// SharedOAuthModel stores general fields needed for OAuth Integration
+type SharedOAuthModel struct {
+	// The ID issued to the client
+	ClientID []byte `json:"client-id"`
+
+	// The end-users's access token
+	AccessToken []byte `json:"access-token"`
+
+	// The end-user's refresh token
+	RefreshToken []byte `json:"refresh-token"`
+}
+
 // OAuthIntegration is an auth mechanism that uses oauth
 // https://tools.ietf.org/html/rfc6749
 type OAuthIntegration struct {
 	gorm.Model
+	SharedOAuthModel
 
 	// The name of the auth mechanism
 	Client OAuthIntegrationClient `json:"client"`
@@ -31,15 +44,16 @@ type OAuthIntegration struct {
 	// ------------------------------------------------------------------
 	// All fields encrypted before storage.
 	// ------------------------------------------------------------------
+}
 
-	// The ID issued to the client
-	ClientID []byte `json:"client-id"`
-
-	// The end-users's access token
-	AccessToken []byte `json:"access-token"`
+// GithubAppOAuthIntegration is the model used for storing github app oauth data
+// Unlike the above, this model is tied to a specific user, not a project
+type GithubAppOAuthIntegration struct {
+	gorm.Model
+	SharedOAuthModel
 
-	// The end-user's refresh token
-	RefreshToken []byte `json:"refresh-token"`
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
 }
 
 // OAuthIntegrationExternal is an OAuthIntegration to be shared over REST

+ 3 - 0
internal/models/user.go

@@ -12,6 +12,9 @@ type User struct {
 	Password      string `json:"password"`
 	EmailVerified bool   `json:"email_verified"`
 
+	// ID of oauth integration for github connection (optional)
+	GithubAppIntegrationID uint
+
 	// The github user id used for login (optional)
 	GithubUserID int64
 	GoogleUserID string

+ 22 - 0
internal/oauth/config.go

@@ -18,6 +18,12 @@ type Config struct {
 	BaseURL      string
 }
 
+// GithubAppConf is standard oauth2 config but it need to keeps track of the app name
+type GithubAppConf struct {
+	AppName string
+	oauth2.Config
+}
+
 func NewGithubClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,
@@ -31,6 +37,22 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewGithubAppClient(cfg *Config, name string) *GithubAppConf {
+	return &GithubAppConf{
+		AppName: name,
+		Config: oauth2.Config{
+			ClientID:     cfg.ClientID,
+			ClientSecret: cfg.ClientSecret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:  "https://github.com/login/oauth/authorize",
+				TokenURL: "https://github.com/login/oauth/access_token",
+			},
+			RedirectURL: cfg.BaseURL + "/api/oauth/github-app/callback",
+			Scopes:      cfg.Scopes,
+		},
+	}
+}
+
 func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
 	return &oauth2.Config{
 		ClientID:     cfg.ClientID,

+ 81 - 0
internal/repository/gorm/auth.go

@@ -1087,3 +1087,84 @@ func (repo *AWSIntegrationRepository) DecryptAWSIntegrationData(
 
 	return nil
 }
+
+// GithubAppInstallationRepository implements repository.GithubAppInstallationRepository
+type GithubAppInstallationRepository struct {
+	db *gorm.DB
+}
+
+func NewGithubAppInstallationRepository(db *gorm.DB) repository.GithubAppInstallationRepository {
+	return &GithubAppInstallationRepository{db}
+}
+
+func (repo *GithubAppInstallationRepository) CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error) {
+	ret := &ints.GithubAppInstallation{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error) {
+
+	ret := &ints.GithubAppInstallation{}
+
+	if err := repo.db.Where("account_id = ?", accountID).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error) {
+	ret := make([]*ints.GithubAppInstallation, 0)
+
+	if err := repo.db.Where("account_id IN ?", accountIDs).Find(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}
+
+func (repo *GithubAppInstallationRepository) DeleteGithubAppInstallationByAccountID(accountID int64) error {
+	if err := repo.db.Unscoped().Where("account_id = ?", accountID).Delete(&ints.GithubAppInstallation{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// GithubAppOAuthIntegrationRepository implements repository.GithubAppOAuthIntegrationRepository
+type GithubAppOAuthIntegrationRepository struct {
+	db *gorm.DB
+}
+
+func NewGithubAppOAuthIntegrationRepository(db *gorm.DB) repository.GithubAppOAuthIntegrationRepository {
+	return &GithubAppOAuthIntegrationRepository{db}
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if err := repo.db.Create(am).Error; err != nil {
+		return nil, err
+	}
+	return am, nil
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error) {
+	ret := &ints.GithubAppOAuthIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&ret).Error; err != nil {
+		return nil, err
+	}
+
+	return ret, nil
+}

+ 16 - 12
internal/repository/gorm/auth_test.go

@@ -285,12 +285,14 @@ func TestCreateOAuthIntegration(t *testing.T) {
 	defer cleanup(tester, t)
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	expOAuth := *oauth
@@ -345,12 +347,14 @@ func TestListOAuthIntegrationsByProjectID(t *testing.T) {
 
 	// make sure data is correct
 	expOAuth := ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth := oauths[0]

+ 9 - 6
internal/repository/gorm/helpers_test.go

@@ -72,6 +72,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&ints.ClusterTokenCache{},
 		&ints.RegTokenCache{},
 		&ints.HelmRepoTokenCache{},
+		&ints.GithubAppInstallation{},
 	)
 
 	if err != nil {
@@ -242,12 +243,14 @@ func initOAuthIntegration(tester *tester, t *testing.T) {
 	}
 
 	oauth := &ints.OAuthIntegration{
-		Client:       ints.OAuthGithub,
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		ClientID:     []byte("exampleclientid"),
-		AccessToken:  []byte("idtoken"),
-		RefreshToken: []byte("refreshtoken"),
+		SharedOAuthModel: ints.SharedOAuthModel{
+			ClientID:     []byte("exampleclientid"),
+			AccessToken:  []byte("idtoken"),
+			RefreshToken: []byte("refreshtoken"),
+		},
+		Client:    ints.OAuthGithub,
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
 	}
 
 	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)

+ 22 - 20
internal/repository/gorm/repository.go

@@ -9,25 +9,27 @@ import (
 // gorm.DB for querying the database
 func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 	return &repository.Repository{
-		User:             NewUserRepository(db),
-		Session:          NewSessionRepository(db),
-		Project:          NewProjectRepository(db),
-		Release:          NewReleaseRepository(db),
-		GitRepo:          NewGitRepoRepository(db, key),
-		Cluster:          NewClusterRepository(db, key),
-		HelmRepo:         NewHelmRepoRepository(db, key),
-		Registry:         NewRegistryRepository(db, key),
-		Infra:            NewInfraRepository(db, key),
-		GitActionConfig:  NewGitActionConfigRepository(db),
-		Invite:           NewInviteRepository(db),
-		AuthCode:         NewAuthCodeRepository(db),
-		DNSRecord:        NewDNSRecordRepository(db),
-		PWResetToken:     NewPWResetTokenRepository(db),
-		KubeIntegration:  NewKubeIntegrationRepository(db, key),
-		BasicIntegration: NewBasicIntegrationRepository(db, key),
-		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
-		OAuthIntegration: NewOAuthIntegrationRepository(db, key),
-		GCPIntegration:   NewGCPIntegrationRepository(db, key),
-		AWSIntegration:   NewAWSIntegrationRepository(db, key),
+		User:                      NewUserRepository(db),
+		Session:                   NewSessionRepository(db),
+		Project:                   NewProjectRepository(db),
+		Release:                   NewReleaseRepository(db),
+		GitRepo:                   NewGitRepoRepository(db, key),
+		Cluster:                   NewClusterRepository(db, key),
+		HelmRepo:                  NewHelmRepoRepository(db, key),
+		Registry:                  NewRegistryRepository(db, key),
+		Infra:                     NewInfraRepository(db, key),
+		GitActionConfig:           NewGitActionConfigRepository(db),
+		Invite:                    NewInviteRepository(db),
+		AuthCode:                  NewAuthCodeRepository(db),
+		DNSRecord:                 NewDNSRecordRepository(db),
+		PWResetToken:              NewPWResetTokenRepository(db),
+		KubeIntegration:           NewKubeIntegrationRepository(db, key),
+		BasicIntegration:          NewBasicIntegrationRepository(db, key),
+		OIDCIntegration:           NewOIDCIntegrationRepository(db, key),
+		OAuthIntegration:          NewOAuthIntegrationRepository(db, key),
+		GCPIntegration:            NewGCPIntegrationRepository(db, key),
+		AWSIntegration:            NewAWSIntegrationRepository(db, key),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(db),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 	}
 }

+ 16 - 0
internal/repository/integrations.go

@@ -37,6 +37,13 @@ type OAuthIntegrationRepository interface {
 	UpdateOAuthIntegration(am *ints.OAuthIntegration) (*ints.OAuthIntegration, error)
 }
 
+// GithubAppOAuthIntegrationRepository represents the set of queries on the oauth
+// mechanism
+type GithubAppOAuthIntegrationRepository interface {
+	CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error)
+	ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error)
+}
+
 // AWSIntegrationRepository represents the set of queries on the AWS auth
 // mechanism
 type AWSIntegrationRepository interface {
@@ -53,3 +60,12 @@ type GCPIntegrationRepository interface {
 	ReadGCPIntegration(id uint) (*ints.GCPIntegration, error)
 	ListGCPIntegrationsByProjectID(projectID uint) ([]*ints.GCPIntegration, error)
 }
+
+// GithubAppInstallationRepository represents the set of queries for github app installations
+type GithubAppInstallationRepository interface {
+	CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error)
+	ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error)
+	DeleteGithubAppInstallationByAccountID(accountID int64) error
+}

+ 121 - 2
internal/repository/memory/auth.go

@@ -2,7 +2,6 @@ package test
 
 import (
 	"errors"
-
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 
@@ -220,7 +219,7 @@ func (repo *OAuthIntegrationRepository) CreateOAuthIntegration(
 	am *ints.OAuthIntegration,
 ) (*ints.OAuthIntegration, error) {
 	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
+		return nil, errors.New("cannot write database")
 	}
 
 	repo.oIntegrations = append(repo.oIntegrations, am)
@@ -427,3 +426,123 @@ func (repo *GCPIntegrationRepository) ListGCPIntegrationsByProjectID(
 
 	return res, nil
 }
+
+// GithubAppInstallationRepository implements repository.GithubAppInstallationRepository
+type GithubAppInstallationRepository struct {
+	canQuery               bool
+	githubAppInstallations []*ints.GithubAppInstallation
+}
+
+func NewGithubAppInstallationRepository(canQuery bool) repository.GithubAppInstallationRepository {
+	return &GithubAppInstallationRepository{
+		canQuery,
+		[]*ints.GithubAppInstallation{},
+	}
+}
+
+func (repo *GithubAppInstallationRepository) CreateGithubAppInstallation(am *ints.GithubAppInstallation) (*ints.GithubAppInstallation, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.githubAppInstallations = append(repo.githubAppInstallations, am)
+	am.ID = uint(len(repo.githubAppInstallations))
+
+	return am, nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallation(id uint) (*ints.GithubAppInstallation, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	if int(id-1) >= len(repo.githubAppInstallations) || repo.githubAppInstallations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return repo.githubAppInstallations[int(id-1)], nil
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountID(accountID int64) (*ints.GithubAppInstallation, error) {
+
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	for _, installation := range repo.githubAppInstallations {
+		if installation != nil && installation.AccountID == accountID {
+			return installation, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+func (repo *GithubAppInstallationRepository) ReadGithubAppInstallationByAccountIDs(accountIDs []int64) ([]*ints.GithubAppInstallation, error) {
+
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	ret := make([]*ints.GithubAppInstallation, 0)
+
+	for _, installation := range repo.githubAppInstallations {
+		// O(n^2) can be made into O(n) if this is too slow
+		for _, id := range accountIDs {
+			if installation.AccountID == id {
+				ret = append(ret, installation)
+			}
+		}
+	}
+
+	return ret, nil
+}
+
+func (repo *GithubAppInstallationRepository) DeleteGithubAppInstallationByAccountID(accountID int64) error {
+	if !repo.canQuery {
+		return errors.New("cannot write database")
+	}
+
+	for i, installation := range repo.githubAppInstallations {
+		if installation != nil && installation.AccountID == accountID {
+			repo.githubAppInstallations[i] = nil
+		}
+	}
+
+	return nil
+}
+
+type GithubAppOAuthIntegrationRepository struct {
+	canQuery                   bool
+	githubAppOauthIntegrations []*ints.GithubAppOAuthIntegration
+}
+
+func NewGithubAppOAuthIntegrationRepository(canQuery bool) repository.GithubAppOAuthIntegrationRepository {
+	return &GithubAppOAuthIntegrationRepository{
+		canQuery,
+		[]*ints.GithubAppOAuthIntegration{},
+	}
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) CreateGithubAppOAuthIntegration(am *ints.GithubAppOAuthIntegration) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.githubAppOauthIntegrations = append(repo.githubAppOauthIntegrations, am)
+	am.ID = uint(len(repo.githubAppOauthIntegrations))
+
+	return am, nil
+}
+
+func (repo *GithubAppOAuthIntegrationRepository) ReadGithubAppOauthIntegration(id uint) (*ints.GithubAppOAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	if int(id-1) >= len(repo.githubAppOauthIntegrations) || repo.githubAppOauthIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	return repo.githubAppOauthIntegrations[int(id-1)], nil
+}

+ 19 - 17
internal/repository/memory/repository.go

@@ -8,22 +8,24 @@ import (
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool) *repository.Repository {
 	return &repository.Repository{
-		User:             NewUserRepository(canQuery),
-		Session:          NewSessionRepository(canQuery),
-		Project:          NewProjectRepository(canQuery),
-		Cluster:          NewClusterRepository(canQuery),
-		HelmRepo:         NewHelmRepoRepository(canQuery),
-		Registry:         NewRegistryRepository(canQuery),
-		GitRepo:          NewGitRepoRepository(canQuery),
-		Invite:           NewInviteRepository(canQuery),
-		AuthCode:         NewAuthCodeRepository(canQuery),
-		DNSRecord:        NewDNSRecordRepository(canQuery),
-		PWResetToken:     NewPWResetTokenRepository(canQuery),
-		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
-		BasicIntegration: NewBasicIntegrationRepository(canQuery),
-		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),
-		OAuthIntegration: NewOAuthIntegrationRepository(canQuery),
-		GCPIntegration:   NewGCPIntegrationRepository(canQuery),
-		AWSIntegration:   NewAWSIntegrationRepository(canQuery),
+		User:                      NewUserRepository(canQuery),
+		Session:                   NewSessionRepository(canQuery),
+		Project:                   NewProjectRepository(canQuery),
+		Cluster:                   NewClusterRepository(canQuery),
+		HelmRepo:                  NewHelmRepoRepository(canQuery),
+		Registry:                  NewRegistryRepository(canQuery),
+		GitRepo:                   NewGitRepoRepository(canQuery),
+		Invite:                    NewInviteRepository(canQuery),
+		AuthCode:                  NewAuthCodeRepository(canQuery),
+		DNSRecord:                 NewDNSRecordRepository(canQuery),
+		PWResetToken:              NewPWResetTokenRepository(canQuery),
+		KubeIntegration:           NewKubeIntegrationRepository(canQuery),
+		BasicIntegration:          NewBasicIntegrationRepository(canQuery),
+		OIDCIntegration:           NewOIDCIntegrationRepository(canQuery),
+		OAuthIntegration:          NewOAuthIntegrationRepository(canQuery),
+		GCPIntegration:            NewGCPIntegrationRepository(canQuery),
+		AWSIntegration:            NewAWSIntegrationRepository(canQuery),
+		GithubAppInstallation:     NewGithubAppInstallationRepository(canQuery),
+		GithubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 	}
 }

+ 22 - 20
internal/repository/repository.go

@@ -2,24 +2,26 @@ package repository
 
 // Repository collects the repositories for each model
 type Repository struct {
-	User             UserRepository
-	Project          ProjectRepository
-	Release          ReleaseRepository
-	Session          SessionRepository
-	GitRepo          GitRepoRepository
-	Cluster          ClusterRepository
-	HelmRepo         HelmRepoRepository
-	Registry         RegistryRepository
-	Infra            InfraRepository
-	GitActionConfig  GitActionConfigRepository
-	Invite           InviteRepository
-	AuthCode         AuthCodeRepository
-	DNSRecord        DNSRecordRepository
-	PWResetToken     PWResetTokenRepository
-	KubeIntegration  KubeIntegrationRepository
-	BasicIntegration BasicIntegrationRepository
-	OIDCIntegration  OIDCIntegrationRepository
-	OAuthIntegration OAuthIntegrationRepository
-	GCPIntegration   GCPIntegrationRepository
-	AWSIntegration   AWSIntegrationRepository
+	User                      UserRepository
+	Project                   ProjectRepository
+	Release                   ReleaseRepository
+	Session                   SessionRepository
+	GitRepo                   GitRepoRepository
+	Cluster                   ClusterRepository
+	HelmRepo                  HelmRepoRepository
+	Registry                  RegistryRepository
+	Infra                     InfraRepository
+	GitActionConfig           GitActionConfigRepository
+	Invite                    InviteRepository
+	AuthCode                  AuthCodeRepository
+	DNSRecord                 DNSRecordRepository
+	PWResetToken              PWResetTokenRepository
+	KubeIntegration           KubeIntegrationRepository
+	BasicIntegration          BasicIntegrationRepository
+	OIDCIntegration           OIDCIntegrationRepository
+	OAuthIntegration          OAuthIntegrationRepository
+	GCPIntegration            GCPIntegrationRepository
+	AWSIntegration            AWSIntegrationRepository
+	GithubAppInstallation     GithubAppInstallationRepository
+	GithubAppOAuthIntegration GithubAppOAuthIntegrationRepository
 }

+ 10 - 0
server/api/api.go

@@ -83,6 +83,7 @@ type App struct {
 	// oauth-specific clients
 	GithubUserConf    *oauth2.Config
 	GithubProjectConf *oauth2.Config
+	GithubAppConf     *oauth.GithubAppConf
 	DOConf            *oauth2.Config
 	GoogleUserConf    *oauth2.Config
 
@@ -169,6 +170,15 @@ func New(conf *AppConfig) (*App, error) {
 		app.Capabilities.GithubLogin = sc.GithubLoginEnabled
 	}
 
+	if sc.GithubAppClientID != "" && sc.GithubAppClientSecret != "" && sc.GithubAppName != "" {
+		app.GithubAppConf = oauth.NewGithubAppClient(&oauth.Config{
+			ClientID:     sc.GithubAppClientID,
+			ClientSecret: sc.GithubAppClientSecret,
+			Scopes:       []string{"read:user"},
+			BaseURL:      sc.ServerURL,
+		}, sc.GithubAppName)
+	}
+
 	if sc.GoogleClientID != "" && sc.GoogleClientSecret != "" {
 		app.Capabilities.GoogleLogin = true
 

+ 178 - 0
server/api/integration_handler.go

@@ -1,9 +1,17 @@
 package api
 
 import (
+	"context"
 	"encoding/json"
+	"fmt"
+	"github.com/google/go-github/github"
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+	"io/ioutil"
 	"net/http"
 	"net/url"
+	"sort"
 	"strconv"
 
 	"github.com/go-chi/chi"
@@ -377,3 +385,173 @@ func (app *App) HandleListProjectOAuthIntegrations(w http.ResponseWriter, r *htt
 		return
 	}
 }
+
+func (app *App) HandleGithubAppEvent(w http.ResponseWriter, r *http.Request) {
+	payload, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	event, err := github.ParseWebHook(github.WebHookType(r), payload)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	switch e := event.(type) {
+	case *github.InstallationEvent:
+		if *e.Action == "created" {
+			_, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil && err == gorm.ErrRecordNotFound {
+				// insert account/installation pair into database
+				_, err := app.Repo.GithubAppInstallation.CreateGithubAppInstallation(&ints.GithubAppInstallation{
+					AccountID:      *e.Installation.Account.ID,
+					InstallationID: *e.Installation.ID,
+				})
+
+				if err != nil {
+					app.handleErrorInternal(err, w)
+				}
+
+				return
+			} else if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+		}
+		if *e.Action == "deleted" {
+			err := app.Repo.GithubAppInstallation.DeleteGithubAppInstallationByAccountID(*e.Installation.Account.ID)
+
+			if err != nil {
+				app.handleErrorInternal(err, w)
+				return
+			}
+		}
+	}
+
+}
+
+// HandleGithubAppAuthorize starts the oauth2 flow for a project repo request.
+func (app *App) HandleGithubAppAuthorize(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, false)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.GithubAppConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+func (app *App) HandleGithubAppInstall(w http.ResponseWriter, r *http.Request) {
+	http.Redirect(w, r, fmt.Sprintf("https://github.com/apps/%s/installations/new", app.GithubAppConf.AppName), 302)
+}
+
+type HandleListGithubAppAccessResp struct {
+	HasAccess bool     `json:"has_access"`
+	LoginName string   `json:"username,omitempty"`
+	Accounts  []string `json:"accounts,omitempty"`
+}
+
+func (app *App) HandleListGithubAppAccess(w http.ResponseWriter, r *http.Request) {
+	tok, err := app.getGithubUserTokenFromRequest(r)
+
+	if err != nil {
+		res := HandleListGithubAppAccessResp{
+			HasAccess: false,
+		}
+		json.NewEncoder(w).Encode(res)
+		return
+	}
+
+	client := github.NewClient(app.GithubProjectConf.Client(oauth2.NoContext, tok))
+
+	opts := &github.ListOptions{
+		PerPage: 100,
+		Page:    1,
+	}
+
+	res := HandleListGithubAppAccessResp{
+		HasAccess: true,
+	}
+
+	for {
+		orgs, pages, err := client.Organizations.List(context.Background(), "", opts)
+
+		if err != nil {
+			res := HandleListGithubAppAccessResp{
+				HasAccess: false,
+			}
+			json.NewEncoder(w).Encode(res)
+			return
+		}
+
+		for _, org := range orgs {
+			res.Accounts = append(res.Accounts, *org.Login)
+		}
+
+		if pages.NextPage == 0 {
+			break
+		}
+	}
+
+	AuthUser, _, err := client.Users.Get(context.Background(), "")
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	res.LoginName = *AuthUser.Login
+
+	// check if user has app installed in their account
+	Installation, err := app.Repo.GithubAppInstallation.ReadGithubAppInstallationByAccountID(*AuthUser.ID)
+
+	if err != nil && err != gorm.ErrRecordNotFound {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if Installation != nil {
+		res.Accounts = append(res.Accounts, *AuthUser.Login)
+	}
+
+	sort.Strings(res.Accounts)
+
+	json.NewEncoder(w).Encode(res)
+}
+
+// getGithubUserTokenFromRequest
+func (app *App) getGithubUserTokenFromRequest(r *http.Request) (*oauth2.Token, error) {
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		return nil, err
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	oauthInt, err := app.Repo.GithubAppOAuthIntegration.ReadGithubAppOauthIntegration(user.GithubAppIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		TokenType:    "Bearer",
+	}, nil
+}

+ 7 - 5
server/api/oauth_do_handler.go

@@ -76,11 +76,13 @@ func (app *App) HandleDOOAuthCallback(w http.ResponseWriter, r *http.Request) {
 	projID, _ := session.Values["project_id"].(uint)
 
 	oauthInt := &integrations.OAuthIntegration{
-		Client:       integrations.OAuthDigitalOcean,
-		UserID:       userID,
-		ProjectID:    projID,
-		AccessToken:  []byte(token.AccessToken),
-		RefreshToken: []byte(token.RefreshToken),
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		Client:    integrations.OAuthDigitalOcean,
+		UserID:    userID,
+		ProjectID: projID,
 	}
 
 	// create the oauth integration first

+ 98 - 5
server/api/oauth_github_handler.go

@@ -269,11 +269,13 @@ func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.
 	}
 
 	oauthInt := &integrations.OAuthIntegration{
-		Client:       integrations.OAuthGithub,
-		UserID:       userID,
-		ProjectID:    projectID,
-		AccessToken:  []byte(tok.AccessToken),
-		RefreshToken: []byte(tok.RefreshToken),
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(tok.AccessToken),
+			RefreshToken: []byte(tok.RefreshToken),
+		},
+		Client:    integrations.OAuthGithub,
+		UserID:    userID,
+		ProjectID: projectID,
 	}
 
 	// create the oauth integration first
@@ -294,3 +296,94 @@ func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.
 
 	return err
 }
+
+func (app *App) HandleGithubAppOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	fmt.Println("hello...")
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+		return
+	}
+
+	token, err := app.GithubAppConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil || !token.Valid() {
+		if session.Values["query_params"] != "" {
+			http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+		} else {
+			http.Redirect(w, r, "/dashboard", 302)
+		}
+		return
+	}
+
+	userID, err := app.getUserIDFromRequest(r)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user, err := app.Repo.User.ReadUser(userID)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	oauthInt := &integrations.GithubAppOAuthIntegration{
+		SharedOAuthModel: integrations.SharedOAuthModel{
+			AccessToken:  []byte(token.AccessToken),
+			RefreshToken: []byte(token.RefreshToken),
+		},
+		UserID: user.ID,
+	}
+
+	oauthInt, err = app.Repo.GithubAppOAuthIntegration.CreateGithubAppOAuthIntegration(oauthInt)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	user.GithubAppIntegrationID = oauthInt.ID
+
+	user, err = app.Repo.User.UpdateUser(user)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

+ 32 - 0
server/router/router.go

@@ -176,6 +176,32 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/integrations/github-app/webhook",
+				requestlog.NewHandler(a.HandleGithubAppEvent, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/authorize",
+				requestlog.NewHandler(a.HandleGithubAppAuthorize, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/install",
+				requestlog.NewHandler(a.HandleGithubAppInstall, l),
+			)
+
+			r.Method(
+				"GET",
+				"/integrations/github-app/access",
+				auth.BasicAuthenticate(
+					requestlog.NewHandler(a.HandleListGithubAppAccess, l),
+				),
+			)
+
 			// /api/templates routes
 			r.Method(
 				"GET",
@@ -215,6 +241,12 @@ func New(a *api.App) *chi.Mux {
 				requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 			)
 
+			r.Method(
+				"GET",
+				"/oauth/github-app/callback",
+				requestlog.NewHandler(a.HandleGithubAppOAuthCallback, l),
+			)
+
 			r.Method(
 				"GET",
 				"/oauth/login/google",