Skip to content

Commit

Permalink
Account management works
Browse files Browse the repository at this point in the history
  • Loading branch information
marcinwyszynski committed Apr 6, 2021
1 parent 14d446b commit 5f09f87
Show file tree
Hide file tree
Showing 22 changed files with 1,165 additions and 3 deletions.
4 changes: 1 addition & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,10 @@
*.dll
*.so
*.dylib
spacelift-cli

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/
47 changes: 47 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package client

import (
"context"
"net/http"

"github.com/shurcooL/graphql"
"golang.org/x/oauth2"

"github.com/spacelift-io/spacelift-cli/client/session"
)

type client struct {
wraps *http.Client
session session.Session
}

func (c *client) Query(ctx context.Context, query interface{}, variables map[string]interface{}) error {
apiClient, err := c.apiClient(ctx)
if err != nil {
return nil
}

return apiClient.Query(ctx, query, variables)
}

func (c *client) Mutate(ctx context.Context, mutation interface{}, variables map[string]interface{}) error {
apiClient, err := c.apiClient(ctx)
if err != nil {
return nil
}

return apiClient.Mutate(ctx, mutation, variables)
}

func (c *client) apiClient(ctx context.Context) (*graphql.Client, error) {
bearerToken, err := c.session.BearerToken(ctx)
if err != nil {
return nil, err
}

return graphql.NewClient(c.session.Endpoint(), oauth2.NewClient(
context.WithValue(ctx, oauth2.HTTPClient, c.wraps), oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: bearerToken},
),
)), nil
}
11 changes: 11 additions & 0 deletions client/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package client

import "context"

type Client interface {
// Query executes a single GraphQL query request.
Query(context.Context, interface{}, map[string]interface{}) error

// Mutate executes a single GraphQL mutation request.
Mutate(context.Context, interface{}, map[string]interface{})
}
64 changes: 64 additions & 0 deletions client/session/api_key.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package session

import (
"context"
"fmt"
"net/http"
"time"

"github.com/shurcooL/graphql"
)

func FromAPIKey(ctx context.Context, client *http.Client) func(string, string, string) (Session, error) {
return func(endpoint, keyID, keySecret string) (Session, error) {
out := &apiKey{
apiToken: apiToken{
client: client,
endpoint: endpoint,
timer: time.Now,
},
keyID: keyID,
keySecret: keySecret,
}

if err := out.exchange(ctx); err != nil {
return nil, err
}

return out, nil
}
}

type apiKey struct {
apiToken
keyID, keySecret string
}

func (g *apiKey) BearerToken(ctx context.Context) (string, error) {
if !g.isFresh() {
if err := g.exchange(ctx); err != nil {
return "", err
}
}

return g.apiToken.BearerToken(ctx)
}

func (g *apiKey) exchange(ctx context.Context) error {
var mutation struct {
APIKeyUser user `graphql:"apiKeyUser(id: $id, secret: $secret)"`
}

variables := map[string]interface{}{
"id": graphql.ID(g.keyID),
"secret": graphql.String(g.keySecret),
}

if err := g.mutate(ctx, &mutation, variables); err != nil {
return fmt.Errorf("could not exchange API key and secret for token: %w", err)
}

g.setJWT(&mutation.APIKeyUser)

return nil
}
75 changes: 75 additions & 0 deletions client/session/api_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package session

import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"

"github.com/dgrijalva/jwt-go"
"github.com/shurcooL/graphql"
)

// If the token is about to expire, we'd rather exchange it now than risk having
// a stale one.
const timePadding = 30 * time.Second

// FromAPIToken creates a session from a ready API token.
func FromAPIToken(_ context.Context, client *http.Client) func(string) (Session, error) {
return func(token string) (Session, error) {
var claims jwt.StandardClaims

if jwt, err := jwt.ParseWithClaims(token, &claims, nil); jwt == nil && err != nil {
return nil, fmt.Errorf("could not parse the API token: %w", err)
}

return &apiToken{
client: client,
endpoint: claims.Audience,
jwt: token,
tokenValidUntil: time.Unix(claims.ExpiresAt, 0),
timer: time.Now,
}, nil
}
}

type apiToken struct {
client *http.Client
endpoint string
jwt string
tokenMutex sync.RWMutex
tokenValidUntil time.Time
timer func() time.Time
}

func (a *apiToken) BearerToken(ctx context.Context) (string, error) {
a.tokenMutex.RLock()
defer a.tokenMutex.RUnlock()

return a.jwt, nil
}

func (a *apiToken) Endpoint() string {
return strings.TrimRight(a.endpoint, "/") + "/graphql"
}

func (a *apiToken) isFresh() bool {
a.tokenMutex.RLock()
defer a.tokenMutex.RUnlock()

return a.timer().Add(timePadding).Before(a.tokenValidUntil)
}

func (a *apiToken) mutate(ctx context.Context, m interface{}, variables map[string]interface{}) error {
return graphql.NewClient(a.Endpoint(), a.client).Mutate(ctx, m, variables)
}

func (a *apiToken) setJWT(user *user) {
a.tokenMutex.Lock()
defer a.tokenMutex.Unlock()

a.jwt = user.JWT
a.tokenValidUntil = time.Unix(user.ValidUntil, 0)
}
12 changes: 12 additions & 0 deletions client/session/defaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package session

import (
"context"
"net/http"
)

// Defaults returns default context and HTTP client to use by clients that don't
// need any further configuration.
func Defaults() (context.Context, *http.Client) {
return context.Background(), http.DefaultClient
}
64 changes: 64 additions & 0 deletions client/session/from_environment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package session

import (
"context"
"fmt"
"net/http"
"os"
)

const (
// EnvSpaceliftAPIEndpoint represents the name of the environment variable
// pointing to the Spacelift API endpoint.
EnvSpaceliftAPIEndpoint = "SPACELIFT_API_ENDPOINT"

// EnvSpaceliftAPIKeyID represents the name of the environment variable
// pointing to the Spacelift API key ID.
EnvSpaceliftAPIKeyID = "SPACELIFT_API_KEY_ID"

// EnvSpaceliftAPIKeySecret represents the name of the environment variable
// pointing to the Spacelift API key secret.
EnvSpaceliftAPIKeySecret = "SPACELIFT_API_KEY_SECRET"

// EnvSpaceliftAPIToken represents the name of the environment variable
// pointing to the Spacelift API token.
EnvSpaceliftAPIToken = "SPACELIFT_API_TOKEN"

// EnvSpaceliftAPIGitHubToken represents the name of the environment variable
// pointing to the GitHub access token used to get the Spacelift API token.
EnvSpaceliftAPIGitHubToken = "SPACELIFT_API_GITHUB_TOKEN"
)

// FromEnvironment creates a Spacelift session from the environment.
func FromEnvironment(ctx context.Context, client *http.Client) func(func(string) (string, bool)) (Session, error) {
return func(lookup func(string) (string, bool)) (Session, error) {
if lookup == nil {
lookup = os.LookupEnv
}

if token, ok := lookup(EnvSpaceliftAPIToken); ok {
return FromAPIToken(ctx, client)(token)
}

endpoint, ok := lookup(EnvSpaceliftAPIEndpoint)
if !ok {
return nil, fmt.Errorf("%s missing from the environment", EnvSpaceliftAPIEndpoint)
}

if gitHubToken, ok := lookup(EnvSpaceliftAPIGitHubToken); ok {
return FromGitHubToken(ctx, client)(endpoint, gitHubToken)
}

keyID, ok := lookup(EnvSpaceliftAPIKeyID)
if !ok {
return nil, fmt.Errorf("%s missing from the environment", EnvSpaceliftAPIKeyID)
}

keySecret, ok := lookup(EnvSpaceliftAPIKeySecret)
if !ok {
return nil, fmt.Errorf("%s missing from the environment", EnvSpaceliftAPIKeySecret)
}

return FromAPIKey(ctx, client)(endpoint, keyID, keySecret)
}
}
50 changes: 50 additions & 0 deletions client/session/from_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package session

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"path"
)

const (
// SpaceliftConfigDirectory is the name of the Spacelift config directory.
SpaceliftConfigDirectory = ".spacelift"

// CurrentFileName is the name of the "current" Spacelift profile.
CurrentFileName = "current"

// CurrentFilePath is the path to the "current" Spacelift profile.
CurrentFilePath = SpaceliftConfigDirectory + "/" + CurrentFileName
)

// FromFile creates a session from credentials stored in a file.
func FromFile(ctx context.Context, client *http.Client) func(path string) (Session, error) {
return func(path string) (Session, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("could not open Spacelift credentials from %s: %w", path, err)
}
defer file.Close()

var out StoredCredentials
if err := json.NewDecoder(file).Decode(&out); err != nil {
return nil, fmt.Errorf("could not decode Spacelift credentials form %s: %w", path, err)
}

return out.Session(ctx, client)
}
}

// FromCurrentFile creates a session from credentials stored in the default
// current file.
func FromCurrentFile(ctx context.Context, client *http.Client) (Session, error) {
userHomeDir, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("could not find user home directory: %w", err)
}

return FromFile(ctx, client)(path.Join(userHomeDir, CurrentFilePath))
}
Loading

0 comments on commit 5f09f87

Please sign in to comment.