Skip to content

Commit

Permalink
feat(lib): Create Auth tokens using inst.Access()
Browse files Browse the repository at this point in the history
  • Loading branch information
b5 committed Mar 4, 2021
1 parent 12397c4 commit 3be7af2
Show file tree
Hide file tree
Showing 11 changed files with 285 additions and 23 deletions.
13 changes: 3 additions & 10 deletions auth/key/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
logger "github.com/ipfs/go-log"
"github.com/libp2p/go-libp2p-core/crypto"
peer "github.com/libp2p/go-libp2p-core/peer"
"github.com/multiformats/go-multihash"
)

var log = logger.Logger("key")
Expand Down Expand Up @@ -38,15 +37,9 @@ func IDFromPubKey(pubKey crypto.PubKey) (string, error) {
return "", fmt.Errorf("identity: public key is required")
}

pubkeybytes, err := pubKey.Bytes()
id, err := peer.IDFromPublicKey(pubKey)
if err != nil {
return "", fmt.Errorf("getting pubkey bytes: %s", err.Error())
}

mh, err := multihash.Sum(pubkeybytes, multihash.SHA2_256, 32)
if err != nil {
return "", fmt.Errorf("summing pubkey: %s", err.Error())
return "", err
}

return mh.B58String(), nil
return id.Pretty(), err
}
102 changes: 93 additions & 9 deletions auth/token/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import (

jwt "github.com/dgrijalva/jwt-go"
"github.com/libp2p/go-libp2p-core/crypto"
"github.com/libp2p/go-libp2p-core/peer"
"github.com/qri-io/qfs"
"github.com/qri-io/qri/auth/key"
"github.com/qri-io/qri/profile"
)

Expand All @@ -36,6 +38,7 @@ type Token = jwt.Token
// Claims is a JWT Claims object
type Claims struct {
*jwt.StandardClaims
// TODO(b5): this needs to be replaced with a profileID
Username string `json:"username"`
}

Expand All @@ -44,6 +47,83 @@ func Parse(tokenString string, tokens Source) (*Token, error) {
return jwt.Parse(tokenString, tokens.VerificationKey)
}

// NewPrivKeyAuthToken creates a JWT token string suitable for making requests
// authenticated as the given private key
func NewPrivKeyAuthToken(pk crypto.PrivKey, ttl time.Duration) (string, error) {
signingMethod, err := jwtSigningMethod(pk)
if err != nil {
return "", err
}

t := jwt.New(signingMethod)

id, err := key.IDFromPrivKey(pk)
if err != nil {
return "", err
}

rawPrivBytes, err := pk.Raw()
if err != nil {
return "", err
}
signKey, err := x509.ParsePKCS1PrivateKey(rawPrivBytes)
if err != nil {
return "", err
}

var exp int64
if ttl != time.Duration(0) {
exp = Timestamp().Add(ttl).In(time.UTC).Unix()
}

// set our claims
t.Claims = &Claims{
StandardClaims: &jwt.StandardClaims{
Issuer: id,
Subject: id,
// set the expire time
// see http://tools.ietf.org/html/draft-ietf-oauth-json-web-token-20#section-4.1.4
ExpiresAt: exp,
},
}

// Creat token string
return t.SignedString(signKey)
}

// ParseAuthToken will parse, validate and return a token
func ParseAuthToken(tokenString string, keystore key.Store) (*Token, error) {
claims := &Claims{}
return jwt.ParseWithClaims(tokenString, claims, func(t *Token) (interface{}, error) {
pid, err := peer.Decode(claims.Issuer)
if err != nil {
return nil, err
}
pubKey := keystore.PubKey(pid)
if pubKey == nil {
for _, pid := range keystore.IDsWithKeys() {
fmt.Printf("key %s has a pid\n", pid)
}
return nil, fmt.Errorf("cannot verify key. missing public key for id %s", claims.Issuer)
}
rawPubBytes, err := pubKey.Raw()
if err != nil {
return nil, err
}

verifyKeyiface, err := x509.ParsePKIXPublicKey(rawPubBytes)
if err != nil {
return nil, err
}

verifyKey, ok := verifyKeyiface.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("public key is not an RSA key. got type: %T", verifyKeyiface)
}
return verifyKey, nil
})
}

// Source creates tokens, and provides a verification key for all tokens
// it creates
//
Expand All @@ -69,17 +149,11 @@ var _ Source = (*pkSource)(nil)
// NewPrivKeySource creates an authentication interface backed by a single
// private key. Intended for a node running as remote, or providing a public API
func NewPrivKeySource(privKey crypto.PrivKey) (Source, error) {
methodStr := ""
keyType := privKey.Type().String()
switch keyType {
case "RSA":
methodStr = "RS256"
default:
return nil, fmt.Errorf("unsupported key type for token creation: %q", keyType)
signingMethod, err := jwtSigningMethod(privKey)
if err != nil {
return nil, err
}

signingMethod := jwt.GetSigningMethod(methodStr)

rawPrivBytes, err := privKey.Raw()
if err != nil {
return nil, err
Expand Down Expand Up @@ -304,3 +378,13 @@ func (st *qfsStore) save(ctx context.Context) error {
st.path = path
return nil
}

func jwtSigningMethod(pk crypto.PrivKey) (jwt.SigningMethod, error) {
keyType := pk.Type().String()
switch keyType {
case "RSA":
return jwt.GetSigningMethod("RS256"), nil
default:
return nil, fmt.Errorf("unsupported key type for token creation: %q", keyType)
}
}
24 changes: 24 additions & 0 deletions auth/token/token_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"

"github.com/qri-io/qfs"
"github.com/qri-io/qri/auth/key"
testkeys "github.com/qri-io/qri/auth/key/test"
"github.com/qri-io/qri/auth/token"
token_spec "github.com/qri-io/qri/auth/token/spec"
Expand Down Expand Up @@ -64,3 +65,26 @@ func TestTokenStore(t *testing.T) {
return ts
})
}

func TestNewPrivKeyAuthToken(t *testing.T) {
// create a token from a private key
kd := testkeys.GetKeyData(0)
str, err := token.NewPrivKeyAuthToken(kd.PrivKey, 0)
if err != nil {
t.Fatal(err)
}

// prove we can parse a token with a store that only has a public key
ks, err := key.NewMemStore()
if err != nil {
t.Fatal(err)
}
if err := ks.AddPubKey(kd.KeyID, kd.PrivKey.GetPublic()); err != nil {
t.Fatal(err)
}

_, err = token.ParseAuthToken(str, ks)
if err != nil {
t.Fatal(err)
}
}
90 changes: 90 additions & 0 deletions lib/access.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package lib

import (
"context"
"fmt"
"time"

"github.com/qri-io/qri/auth/token"
"github.com/qri-io/qri/profile"
)

// AccessMethods is a group of methods for access control & user authentication
type AccessMethods struct {
d dispatcher
}

// Name returns the name of this method group
func (m AccessMethods) Name() string {
return "access"
}

// Access returns the authentication that Instance has registered
func (inst *Instance) Access() AccessMethods {
return AccessMethods{d: inst}
}

// CreateAuthTokenParams are input parameters for Access().CreateAuthToken
type CreateAuthTokenParams struct {
GranteeUsername string
GranteeProfileID string
TTL time.Duration
}

// SetNonZeroDefaults uses default token time-to-live if one isn't set
func (p *CreateAuthTokenParams) SetNonZeroDefaults() {
if p.TTL == 0 {
p.TTL = token.DefaultTokenTTL
}
}

// Valid checks if the profile in question is valid
func (p *CreateAuthTokenParams) Valid() error {
if p.GranteeUsername == "" && p.GranteeProfileID == "" {
return fmt.Errorf("either grantee username or profile is required")
}
return nil
}

// CreateAuthToken constructs a JWT string token suitable for making OAuth
// requests as the grantee user. Creating an access token requires a stored
// private key for the grantee.
// Callers can provide either GranteeUsername OR GranteeProfileID
func (m AccessMethods) CreateAuthToken(ctx context.Context, p *CreateAuthTokenParams) (string, error) {
res, err := m.d.Dispatch(ctx, dispatchMethodName(m, "createauthtoken"), p)
if s, ok := res.(string); ok {
return s, err
}
return "", err
}

// accessImpl is the backing implementation for AccessMethods
type accessImpl struct{}

func (accessImpl) CreateAuthToken(scp scope, p *CreateAuthTokenParams) (string, error) {
var (
grantee *profile.Profile
err error
)

if p.GranteeProfileID != "" {
id, err := profile.IDB58Decode(p.GranteeProfileID)
if err != nil {
return "", err
}
if grantee, err = scp.Profiles().GetProfile(id); err != nil {
return "", err
}
} else if p.GranteeUsername != "" {
if grantee, err = profile.ResolveUsername(scp.Profiles(), p.GranteeUsername); err != nil {
return "", err
}
}

pk := grantee.PrivKey
if pk == nil {
return "", fmt.Errorf("cannot create token for %q (id: %s), private key is required", grantee.Peername, grantee.ID.String())
}

return token.NewPrivKeyAuthToken(pk, p.TTL)
}
30 changes: 30 additions & 0 deletions lib/access_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package lib

import (
"context"
"testing"

"github.com/qri-io/qri/auth/token"
)

func TestAccessCreateAuthToken(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
inst, cleanup := NewMemTestInstance(ctx, t)
defer cleanup()

// create an authentication token using the owner profile
p := &CreateAuthTokenParams{
GranteeUsername: inst.cfg.Profile.Peername,
}
s, err := inst.Access().CreateAuthToken(ctx, p)
if err != nil {
t.Fatal(err)
}

// prove we can parse & validate that token
_, err = token.ParseAuthToken(s, inst.keystore)
if err != nil {
t.Fatal(err)
}
}
1 change: 1 addition & 0 deletions lib/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func (inst *Instance) RegisterMethods() {
// TODO(dustmop): Change registerOne to take both the MethodSet and the Impl, validate
// that their signatures agree.
inst.registerOne("fsi", &FSIImpl{}, reg)
inst.registerOne("access", accessImpl{}, reg)
inst.regMethods = &regMethodSet{reg: reg}
}

Expand Down
2 changes: 1 addition & 1 deletion lib/lib.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ func NewInstance(ctx context.Context, repoPath string, opts ...Option) (qri *Ins
// If configuration does not have a path assigned, but the repo has a path and
// is stored on the filesystem, add that path to the configuration.
if cfg.Repo.Type == "fs" && cfg.Path() == "" {
cfg.SetPath(filepath.Join(repoPath, "config.yal"))
cfg.SetPath(filepath.Join(repoPath, "config.yaml"))
}

inst := &Instance{
Expand Down
31 changes: 31 additions & 0 deletions lib/lib_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,34 @@ func TestNewInstanceWithAccessControlPolicy(t *testing.T) {
t.Errorf("expected no policy enforce error, got: %s", err)
}
}

// NewMemTestInstance creates an in-memory instance
// TODO(b5): currently "NewInstance" hard-requires a repo-path, even if we can
// provide a configuration that specifies entirely in-memory stores. We should
// make it possible to create fully in-memory Instances using NewInstance,
// but for now I'm working around it with a temp directory & cleanup function
func NewMemTestInstance(ctx context.Context, t *testing.T) (inst *Instance, cleanup func()) {
t.Helper()
tmpPath, err := ioutil.TempDir("", "qri_test_mem_instance")
if err != nil {
t.Fatal(err)
}

cfg := testcfg.DefaultConfigForTesting()
cfg.Filesystems = []qfs.Config{
{Type: "mem"},
{Type: "local"},
}
cfg.Repo.Type = "mem"
if err := cfg.WriteToFile(filepath.Join(tmpPath, "config.yaml")); err != nil {
t.Fatal(err)
}

// TODO(b5): I'd like to be able to do this:
// if inst, err = NewInstance(ctx, "", OptConfig(cfg)); err != nil {
if inst, err = NewInstance(ctx, tmpPath); err != nil {
t.Fatal(err)
}

return inst, func() { os.RemoveAll(tmpPath) }
}
6 changes: 6 additions & 0 deletions lib/scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/qri-io/qri/dsref"
"github.com/qri-io/qri/event"
"github.com/qri-io/qri/fsi"
"github.com/qri-io/qri/profile"
"github.com/qri-io/qri/repo"
)

Expand Down Expand Up @@ -53,6 +54,11 @@ func (s *scope) Dscache() *dscache.Dscache {
return s.inst.Dscache()
}

// Profiles accesses the profile store
func (s *scope) Profiles() profile.Store {
return s.inst.profiles
}

// ParseAndResolveRef parses a reference and resolves it
func (s *scope) ParseAndResolveRef(ctx context.Context, refStr, source string) (dsref.Ref, string, error) {
return s.inst.ParseAndResolveRef(ctx, refStr, source)
Expand Down
Loading

0 comments on commit 3be7af2

Please sign in to comment.