Skip to content

Commit

Permalink
Upstash integration backend (#4597)
Browse files Browse the repository at this point in the history
Co-authored-by: Stefan McShane <[email protected]>
  • Loading branch information
Feroze Mohideen and stefanmcshane authored May 2, 2024
1 parent 3e5320e commit 64a9add
Show file tree
Hide file tree
Showing 18 changed files with 488 additions and 0 deletions.
198 changes: 198 additions & 0 deletions api/server/handlers/oauth_callback/upstash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package oauth_callback

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/porter-dev/porter/api/server/handlers"
"github.com/porter-dev/porter/api/server/shared"
"github.com/porter-dev/porter/api/server/shared/apierrors"
"github.com/porter-dev/porter/api/server/shared/config"
"github.com/porter-dev/porter/internal/models/integrations"
"github.com/porter-dev/porter/internal/telemetry"
)

// OAuthCallbackUpstashHandler is the handler responding to the upstash oauth callback
type OAuthCallbackUpstashHandler struct {
handlers.PorterHandlerReadWriter
}

// UpstashApiKeyEndpoint is the endpoint to fetch the upstash developer api key
// nolint:gosec // Not a security key
const UpstashApiKeyEndpoint = "https://api.upstash.com/apikey"

// NewOAuthCallbackUpstashHandler generates a new OAuthCallbackUpstashHandler
func NewOAuthCallbackUpstashHandler(
config *config.Config,
decoderValidator shared.RequestDecoderValidator,
writer shared.ResultWriter,
) *OAuthCallbackUpstashHandler {
return &OAuthCallbackUpstashHandler{
PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
}
}

// ServeHTTP gets the upstash oauth token from the callback code, uses it to create a developer api token, then creates a new upstash integration
func (p *OAuthCallbackUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "serve-oauth-callback-upstash")
defer span.End()

r = r.Clone(ctx)

session, err := p.Config().Store.Get(r, p.Config().ServerConf.CookieName)
if err != nil {
err = telemetry.Error(ctx, span, err, "session could not be retrieved")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

if _, ok := session.Values["state"]; !ok {
err = telemetry.Error(ctx, span, nil, "state not found in session")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

if r.URL.Query().Get("state") != session.Values["state"] {
err = telemetry.Error(ctx, span, nil, "state does not match")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

projID, ok := session.Values["project_id"].(uint)
if !ok {
err = telemetry.Error(ctx, span, nil, "project id not found in session")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}
telemetry.WithAttributes(span,
telemetry.AttributeKV{Key: "project-id", Value: projID},
)

if projID == 0 {
err = telemetry.Error(ctx, span, nil, "project id not found in session")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

code := r.URL.Query().Get("code")
if code == "" {
err = telemetry.Error(ctx, span, nil, "code not found in query params")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
return
}

token, err := p.Config().UpstashConf.Exchange(ctx, code)
if err != nil {
err = telemetry.Error(ctx, span, err, "exchange failed")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
return
}

if !token.Valid() {
err = telemetry.Error(ctx, span, nil, "invalid token")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusForbidden))
return
}

// make an http call to https://api.upstash.com/apikey with authorization: bearer <access_token>
// to get the api key
apiKey, err := fetchUpstashApiKey(ctx, token.AccessToken)
if err != nil {
err = telemetry.Error(ctx, span, err, "error fetching upstash api key")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

oauthInt := integrations.UpstashIntegration{
SharedOAuthModel: integrations.SharedOAuthModel{
AccessToken: []byte(token.AccessToken),
RefreshToken: []byte(token.RefreshToken),
Expiry: token.Expiry,
},
ProjectID: projID,
DeveloperApiKey: []byte(apiKey),
}

_, err = p.Repo().UpstashIntegration().Insert(ctx, oauthInt)
if err != nil {
err = telemetry.Error(ctx, span, err, "error creating oauth integration")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

redirect := "/dashboard"
if redirectStr, ok := session.Values["redirect_uri"].(string); ok && redirectStr != "" {
redirectURI, err := url.Parse(redirectStr)
if err == nil {
redirect = fmt.Sprintf("%s?%s", redirectURI.Path, redirectURI.RawQuery)
}
}
http.Redirect(w, r, redirect, http.StatusFound)
}

// UpstashApiKeyRequest is the request body to fetch the upstash developer api key
type UpstashApiKeyRequest struct {
Name string `json:"name"`
}

// UpstashApiKeyResponse is the response body to fetch the upstash developer api key
type UpstashApiKeyResponse struct {
ApiKey string `json:"api_key"`
}

func fetchUpstashApiKey(ctx context.Context, accessToken string) (string, error) {
ctx, span := telemetry.NewSpan(ctx, "fetch-upstash-api-key")
defer span.End()

data := UpstashApiKeyRequest{
Name: fmt.Sprintf("PORTER_API_KEY_%d", time.Now().Unix()),
}

jsonData, err := json.Marshal(data)
if err != nil {
return "", telemetry.Error(ctx, span, err, "error marshalling request body")
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, UpstashApiKeyEndpoint, bytes.NewBuffer(jsonData))
if err != nil {
return "", telemetry.Error(ctx, span, err, "error creating request")
}

// Set the Authorization header
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", telemetry.Error(ctx, span, err, "error sending request")
}
defer resp.Body.Close() // nolint: errcheck
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "status-code", Value: resp.StatusCode})
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "read-response-body-error", Value: err.Error()})
}
telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "response-body", Value: string(body)})
return "", telemetry.Error(ctx, span, nil, "unexpected status code")
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", telemetry.Error(ctx, span, err, "error reading response body")
}

var responseData UpstashApiKeyResponse
err = json.Unmarshal(body, &responseData)
if err != nil {
return "", telemetry.Error(ctx, span, err, "error unmarshalling response body")
}

return responseData.ApiKey, nil
}
51 changes: 51 additions & 0 deletions api/server/handlers/project_oauth/upstash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package project_oauth

import (
"net/http"

"github.com/porter-dev/porter/internal/telemetry"

"golang.org/x/oauth2"

"github.com/porter-dev/porter/api/server/handlers"
"github.com/porter-dev/porter/api/server/shared"
"github.com/porter-dev/porter/api/server/shared/apierrors"
"github.com/porter-dev/porter/api/server/shared/config"
"github.com/porter-dev/porter/internal/oauth"
)

// ProjectOAuthUpstashHandler is the handler which redirects to the upstash oauth flow
type ProjectOAuthUpstashHandler struct {
handlers.PorterHandlerReadWriter
}

// NewProjectOAuthUpstashHandler generates a new ProjectOAuthUpstashHandler
func NewProjectOAuthUpstashHandler(
config *config.Config,
decoderValidator shared.RequestDecoderValidator,
writer shared.ResultWriter,
) *ProjectOAuthUpstashHandler {
return &ProjectOAuthUpstashHandler{
PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
}
}

// ServeHTTP populates the oauth session with state and project id then redirects the user to the upstash oauth flow
func (p *ProjectOAuthUpstashHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx, span := telemetry.NewSpan(r.Context(), "serve-project-oauth-upstash")
defer span.End()

r = r.Clone(ctx)

state := oauth.CreateRandomState()

if err := p.PopulateOAuthSession(ctx, w, r, state, true, false, "", 0); err != nil {
err = telemetry.Error(ctx, span, err, "population oauth session failed")
p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
return
}

url := p.Config().UpstashConf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("audience", "upstash-api"))

http.Redirect(w, r, url, http.StatusFound)
}
24 changes: 24 additions & 0 deletions api/server/router/oauth_callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,30 @@ func GetOAuthCallbackRoutes(
Router: r,
})

// GET /api/oauth/upstash/callback -> oauth_callback.NewOAuthCallbackUpstashHandler
upstashEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Verb: types.APIVerbGet,
Method: types.HTTPVerbGet,
Path: &types.Path{
Parent: basePath,
RelativePath: relPath + "/upstash/callback",
},
},
)

upstashHandler := oauth_callback.NewOAuthCallbackUpstashHandler(
config,
factory.GetDecoderValidator(),
factory.GetResultWriter(),
)

routes = append(routes, &router.Route{
Endpoint: upstashEndpoint,
Handler: upstashHandler,
Router: r,
})

// GET /api/oauth/digitalocean/callback -> oauth_callback.NewOAuthCallbackDOHandler
doEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Expand Down
28 changes: 28 additions & 0 deletions api/server/router/project_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,34 @@ func getProjectOAuthRoutes(
Router: r,
})

// GET /api/projects/{project_id}/oauth/upstash -> project_integration.NewProjectOAuthUpstashHandler
upstashEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Verb: types.APIVerbGet,
Method: types.HTTPVerbGet,
Path: &types.Path{
Parent: basePath,
RelativePath: relPath + "/upstash",
},
Scopes: []types.PermissionScope{
types.UserScope,
types.ProjectScope,
},
},
)

upstashHandler := project_oauth.NewProjectOAuthUpstashHandler(
config,
factory.GetDecoderValidator(),
factory.GetResultWriter(),
)

routes = append(routes, &router.Route{
Endpoint: upstashEndpoint,
Handler: upstashHandler,
Router: r,
})

// GET /api/projects/{project_id}/oauth/digitalocean -> project_integration.NewProjectOAuthDOHandler
doEndpoint := factory.NewAPIEndpoint(
&types.APIRequestMetadata{
Expand Down
3 changes: 3 additions & 0 deletions api/server/shared/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ type Config struct {
// SlackConf is the configuration for a Slack OAuth client
SlackConf *oauth2.Config

// UpstashConf is the configuration for an Upstash OAuth client
UpstashConf oauth2.Config

// WSUpgrader upgrades HTTP connections to websocket connections
WSUpgrader *websocket.Upgrader

Expand Down
3 changes: 3 additions & 0 deletions api/server/shared/config/env/envconfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ type ServerConf struct {
SlackClientID string `env:"SLACK_CLIENT_ID"`
SlackClientSecret string `env:"SLACK_CLIENT_SECRET"`

UpstashEnabled bool `env:"UPSTASH_ENABLED,default=false"`
UpstashClientID string `env:"UPSTASH_CLIENT_ID"`

BillingPrivateKey string `env:"BILLING_PRIVATE_KEY"`
BillingPrivateServerURL string `env:"BILLING_PRIVATE_URL"`
BillingPublicServerURL string `env:"BILLING_PUBLIC_URL"`
Expand Down
11 changes: 11 additions & 0 deletions api/server/shared/config/loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,17 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
res.Logger.Info().Msg("Created Slack client")
}

if sc.UpstashEnabled && sc.UpstashClientID != "" {
res.Logger.Info().Msg("Creating Upstash client")
res.UpstashConf = oauth.NewUpstashClient(&oauth.Config{
ClientID: sc.UpstashClientID,
ClientSecret: "",
Scopes: []string{"offline_access"},
BaseURL: sc.ServerURL,
})
res.Logger.Info().Msg("Created Upstash client")
}

res.WSUpgrader = &websocket.Upgrader{
WSUpgrader: &gorillaws.Upgrader{
ReadBufferSize: 1024,
Expand Down
1 change: 1 addition & 0 deletions api/types/project_integration.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (
OAuthDigitalOcean OAuthIntegrationClient = "do"
OAuthGoogle OAuthIntegrationClient = "google"
OAuthGitlab OAuthIntegrationClient = "gitlab"
OAuthUpstash OAuthIntegrationClient = "upstash"
)

// OAuthIntegrationClient is the name of an OAuth mechanism client
Expand Down
14 changes: 14 additions & 0 deletions internal/models/integrations/upstash.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package integrations

import "gorm.io/gorm"

// UpstashIntegration is an integration for the Upstash service
type UpstashIntegration struct {
gorm.Model

ProjectID uint `json:"project_id"`

SharedOAuthModel

DeveloperApiKey []byte `json:"developer_api_key"`
}
14 changes: 14 additions & 0 deletions internal/oauth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ func NewSlackClient(cfg *Config) *oauth2.Config {
}
}

// NewUpstashClient creates a new oauth2.Config for Upstash
func NewUpstashClient(cfg *Config) oauth2.Config {
return oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: "https://auth.upstash.com/authorize",
TokenURL: "https://auth.upstash.com/oauth/token",
},
RedirectURL: cfg.BaseURL + "/api/oauth/upstash/callback",
Scopes: cfg.Scopes,
}
}

func CreateRandomState() string {
b := make([]byte, 16)
rand.Read(b)
Expand Down
Loading

0 comments on commit 64a9add

Please sign in to comment.