"x/ref/services/identity": HTTP service for BlessUsingAccessToken
Currently the only mechanism to exchange an OAuth2 access token
for a blessing is a Vanadium RPC service. Unfortunately this makes
implementing our security model on other platforms (e.g., Mojo)
depend on the RPC system.
This CL adds a REST API to exchange an OAuth2 token for a blessing.
The API takes an OAuth2 token, the client's public key and caveats,
and returns a blessing bound to the provided public key for the
identity represented by the OAuth2 token.
The name of the resulting blessing is of the form
<idp>/<clientID>/<email> where <clientID> and <email> is the client ID
and email associated with the token respectively.
MultiPart: 1/2
Change-Id: I463fd24c2f77f03d4d1bb970336b025886368a08
diff --git a/services/identity/identity.vdl b/services/identity/identity.vdl
index 9ecba7b..978fbac 100644
--- a/services/identity/identity.vdl
+++ b/services/identity/identity.vdl
@@ -15,11 +15,14 @@
// though the Google implementation also has informative documentation at
// https://developers.google.com/accounts/docs/OAuth2
//
-// WARNING: There is no binding between the channel over which the access token
-// was obtained (typically https) and the channel used to make the RPC (a
-// vanadium virtual circuit).
-// Thus, if Mallory possesses the access token associated with Alice's account,
-// she may be able to obtain a blessing with Alice's name on it.
+// WARNING: There is no binding between the channel over which the access
+// token was obtained (typically https) and the channel used to make the RPC
+// (a vanadium virtual circuit). Thus, if Mallory possesses the access token
+// associated with Alice's account she may be able to obtain a blessing with
+// Alice's name on it.
+//
+// TODO(ataly): Get rid of this service once all clients have been
+// switched to use the HTTP OAuthBlessingHandler service.
type OAuthBlesser interface {
// BlessUsingAccessToken uses the provided access token to obtain the email
// address and returns a blessing along with the email address.
diff --git a/services/identity/identity.vdl.go b/services/identity/identity.vdl.go
index dd261eb..8aa9da2 100644
--- a/services/identity/identity.vdl.go
+++ b/services/identity/identity.vdl.go
@@ -48,11 +48,14 @@
// though the Google implementation also has informative documentation at
// https://developers.google.com/accounts/docs/OAuth2
//
-// WARNING: There is no binding between the channel over which the access token
-// was obtained (typically https) and the channel used to make the RPC (a
-// vanadium virtual circuit).
-// Thus, if Mallory possesses the access token associated with Alice's account,
-// she may be able to obtain a blessing with Alice's name on it.
+// WARNING: There is no binding between the channel over which the access
+// token was obtained (typically https) and the channel used to make the RPC
+// (a vanadium virtual circuit). Thus, if Mallory possesses the access token
+// associated with Alice's account she may be able to obtain a blessing with
+// Alice's name on it.
+//
+// TODO(ataly): Get rid of this service once all clients have been
+// switched to use the HTTP OAuthBlessingHandler service.
type OAuthBlesserClientMethods interface {
// BlessUsingAccessToken uses the provided access token to obtain the email
// address and returns a blessing along with the email address.
@@ -96,11 +99,14 @@
// though the Google implementation also has informative documentation at
// https://developers.google.com/accounts/docs/OAuth2
//
-// WARNING: There is no binding between the channel over which the access token
-// was obtained (typically https) and the channel used to make the RPC (a
-// vanadium virtual circuit).
-// Thus, if Mallory possesses the access token associated with Alice's account,
-// she may be able to obtain a blessing with Alice's name on it.
+// WARNING: There is no binding between the channel over which the access
+// token was obtained (typically https) and the channel used to make the RPC
+// (a vanadium virtual circuit). Thus, if Mallory possesses the access token
+// associated with Alice's account she may be able to obtain a blessing with
+// Alice's name on it.
+//
+// TODO(ataly): Get rid of this service once all clients have been
+// switched to use the HTTP OAuthBlessingHandler service.
type OAuthBlesserServerMethods interface {
// BlessUsingAccessToken uses the provided access token to obtain the email
// address and returns a blessing along with the email address.
@@ -166,7 +172,7 @@
var descOAuthBlesser = rpc.InterfaceDesc{
Name: "OAuthBlesser",
PkgPath: "v.io/x/ref/services/identity",
- Doc: "// OAuthBlesser exchanges OAuth access tokens for\n// an email address from an OAuth-based identity provider and uses the email\n// address obtained to bless the client.\n//\n// OAuth is described in RFC 6749 (http://tools.ietf.org/html/rfc6749),\n// though the Google implementation also has informative documentation at\n// https://developers.google.com/accounts/docs/OAuth2\n//\n// WARNING: There is no binding between the channel over which the access token\n// was obtained (typically https) and the channel used to make the RPC (a\n// vanadium virtual circuit).\n// Thus, if Mallory possesses the access token associated with Alice's account,\n// she may be able to obtain a blessing with Alice's name on it.",
+ Doc: "// OAuthBlesser exchanges OAuth access tokens for\n// an email address from an OAuth-based identity provider and uses the email\n// address obtained to bless the client.\n//\n// OAuth is described in RFC 6749 (http://tools.ietf.org/html/rfc6749),\n// though the Google implementation also has informative documentation at\n// https://developers.google.com/accounts/docs/OAuth2\n//\n// WARNING: There is no binding between the channel over which the access\n// token was obtained (typically https) and the channel used to make the RPC\n// (a vanadium virtual circuit). Thus, if Mallory possesses the access token\n// associated with Alice's account she may be able to obtain a blessing with\n// Alice's name on it.\n//\n// TODO(ataly): Get rid of this service once all clients have been\n// switched to use the HTTP OAuthBlessingHandler service.",
Methods: []rpc.MethodDesc{
{
Name: "BlessUsingAccessToken",
diff --git a/services/identity/identitylib/test_identityd.go b/services/identity/identitylib/test_identityd.go
index 054d5c5..1038239 100644
--- a/services/identity/identitylib/test_identityd.go
+++ b/services/identity/identitylib/test_identityd.go
@@ -68,14 +68,23 @@
}
}
+ mockClientID := "test-client-id"
+ mockClientName := "test-client"
+
auditor, reader := auditor.NewMockBlessingAuditor()
revocationManager := revocation.NewMockRevocationManager(ctx)
- oauthProvider := oauth.NewMockOAuth("testemail@example.com")
+ oauthProvider := oauth.NewMockOAuth("testemail@example.com", mockClientID)
params := blesser.OAuthBlesserParams{
OAuthProvider: oauthProvider,
BlessingDuration: duration,
RevocationManager: revocationManager,
+ AccessTokenClients: []oauth.AccessTokenClient{
+ oauth.AccessTokenClient{
+ Name: mockClientName,
+ ClientID: mockClientID,
+ },
+ },
}
s := server.NewIdentityServer(
diff --git a/services/identity/internal/blesser/oauth.go b/services/identity/internal/blesser/oauth.go
index 40138e6..980829e 100644
--- a/services/identity/internal/blesser/oauth.go
+++ b/services/identity/internal/blesser/oauth.go
@@ -18,20 +18,11 @@
"v.io/v23/security"
)
-type oauthBlesser struct {
- oauthProvider oauth.OAuthProvider
- authcodeClient struct{ ID, Secret string }
- accessTokenClients []oauth.AccessTokenClient
- duration time.Duration
- dischargerLocation string
- revocationManager revocation.RevocationManager
-}
-
-// OAuthBlesserParams represents all the parameters provided to NewOAuthBlesserServer
+// OAuthBlesserParams represents all the parameters provided to NewOAuthBlessingServer.
type OAuthBlesserParams struct {
// The OAuth provider that must have issued the access tokens accepted by ths service.
OAuthProvider oauth.OAuthProvider
- // The OAuth client IDs and names for the clients of the BlessUsingAccessToken RPCs.
+ // The OAuth client IDs and names for the clients of this service.
AccessTokenClients []oauth.AccessTokenClient
// The object name of the discharger service. If this is empty then revocation caveats will not be granted.
DischargerLocation string
@@ -41,6 +32,15 @@
BlessingDuration time.Duration
}
+type oauthBlesser struct {
+ oauthProvider oauth.OAuthProvider
+ authcodeClient struct{ ID, Secret string }
+ accessTokenClients []oauth.AccessTokenClient
+ duration time.Duration
+ dischargerLocation string
+ revocationManager revocation.RevocationManager
+}
+
// NewOAuthBlesserServer provides an identity.OAuthBlesserService that uses OAuth2
// access tokens to obtain the username of a client and provide blessings with that
// name.
@@ -48,6 +48,8 @@
// Blessings generated by this service carry a third-party revocation caveat if a
// RevocationManager is specified by the params or they carry an ExpiryCaveat that
// expires after the duration specified by the params.
+// TODO(ataly): Get rid of this service once all clients have been
+// switched to use the HTTP OAuthBlessingHandler service.
func NewOAuthBlesserServer(p OAuthBlesserParams) identity.OAuthBlesserServerStub {
return identity.OAuthBlesserServer(&oauthBlesser{
oauthProvider: p.OAuthProvider,
@@ -60,7 +62,14 @@
func (b *oauthBlesser) BlessUsingAccessToken(ctx *context.T, call rpc.ServerCall, accessToken string) (security.Blessings, string, error) {
var noblessings security.Blessings
- email, clientName, err := b.oauthProvider.GetEmailAndClientName(accessToken, b.accessTokenClients)
+ if len(b.accessTokenClients) == 0 {
+ return noblessings, "", fmt.Errorf("no expected AccessTokenClients specified")
+ }
+ email, clientID, err := b.oauthProvider.GetEmailAndClientID(accessToken)
+ if err != nil {
+ return noblessings, "", err
+ }
+ clientName, err := oauth.ClientName(clientID, b.accessTokenClients)
if err != nil {
return noblessings, "", err
}
@@ -69,7 +78,14 @@
func (b *oauthBlesser) BlessUsingAccessTokenWithCaveats(ctx *context.T, call rpc.ServerCall, accessToken string, caveats []security.Caveat) (security.Blessings, string, error) {
var noblessings security.Blessings
- email, clientName, err := b.oauthProvider.GetEmailAndClientName(accessToken, b.accessTokenClients)
+ if len(b.accessTokenClients) == 0 {
+ return noblessings, "", fmt.Errorf("no expected AccessTokenClients specified")
+ }
+ email, clientID, err := b.oauthProvider.GetEmailAndClientID(accessToken)
+ if err != nil {
+ return noblessings, "", err
+ }
+ clientName, err := oauth.ClientName(clientID, b.accessTokenClients)
if err != nil {
return noblessings, "", err
}
diff --git a/services/identity/internal/blesser/oauth_test.go b/services/identity/internal/blesser/oauth_test.go
index bb8941f..6b56a72 100644
--- a/services/identity/internal/blesser/oauth_test.go
+++ b/services/identity/internal/blesser/oauth_test.go
@@ -27,9 +27,17 @@
ctx, call = fakeContextAndCall(provider, user)
)
mockEmail := "testemail@example.com"
+ mockClientID := "test-client-id"
+ mockClientName := "test-client"
blesser := NewOAuthBlesserServer(OAuthBlesserParams{
- OAuthProvider: oauth.NewMockOAuth(mockEmail),
+ OAuthProvider: oauth.NewMockOAuth(mockEmail, mockClientID),
BlessingDuration: time.Hour,
+ AccessTokenClients: []oauth.AccessTokenClient{
+ oauth.AccessTokenClient{
+ Name: mockClientName,
+ ClientID: mockClientID,
+ },
+ },
})
b, extension, err := blesser.BlessUsingAccessToken(ctx, call, "test-access-token")
@@ -37,7 +45,7 @@
t.Errorf("BlessUsingAccessToken failed: %v", err)
}
- wantExtension := join(mockEmail, oauth.MockClient)
+ wantExtension := join(mockEmail, mockClientName)
if extension != wantExtension {
t.Errorf("got extension: %s, want: %s", extension, wantExtension)
}
@@ -69,9 +77,17 @@
ctx, call = fakeContextAndCall(provider, user)
)
mockEmail := "testemail@example.com"
+ mockClientID := "test-client-id"
+ mockClientName := "test-client"
blesser := NewOAuthBlesserServer(OAuthBlesserParams{
- OAuthProvider: oauth.NewMockOAuth(mockEmail),
+ OAuthProvider: oauth.NewMockOAuth(mockEmail, mockClientID),
BlessingDuration: time.Hour,
+ AccessTokenClients: []oauth.AccessTokenClient{
+ oauth.AccessTokenClient{
+ Name: mockClientName,
+ ClientID: mockClientID,
+ },
+ },
})
expiryCav, err := security.NewExpiryCaveat(time.Now().Add(time.Minute))
@@ -89,7 +105,7 @@
t.Errorf("BlessUsingAccessToken failed: %v", err)
}
- wantExtension := join(mockEmail, oauth.MockClient)
+ wantExtension := join(mockEmail, mockClientName)
if extension != wantExtension {
t.Errorf("got extension: %s, want: %s", extension, wantExtension)
}
diff --git a/services/identity/internal/handlers/bless.go b/services/identity/internal/handlers/bless.go
new file mode 100644
index 0000000..e7607c5
--- /dev/null
+++ b/services/identity/internal/handlers/bless.go
@@ -0,0 +1,160 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package handlers
+
+import (
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "v.io/v23"
+ "v.io/v23/context"
+ "v.io/v23/security"
+ "v.io/v23/vom"
+ "v.io/x/ref/services/identity/internal/blesser"
+ "v.io/x/ref/services/identity/internal/util"
+)
+
+const (
+ PublicKeyFormKey = "public_key"
+ AccessTokenFormKey = "token"
+ CaveatsFormKey = "caveats"
+)
+
+type accessTokenBlesser struct {
+ ctx *context.T
+ params blesser.OAuthBlesserParams
+}
+
+// NewOAuthBlessingHandler returns an http.Handler that uses OAuth2 access tokens
+// to obtain the username of the requestor and reponds with blessings for that username.
+//
+// The blessings are namespaced under the ClientID for the access token. In particular,
+// the name of the granted blessing is of the form <idp>/<clientID>/<email> where <idp>
+// is the name of the default blessings used by the identity provider.
+//
+// Blessings generated by this service carry a third-party revocation caveat if a
+// RevocationManager is specified by the params or they carry an ExpiryCaveat that
+// expires after the duration specified by the params.
+//
+// The handler expects the following parameters in the form data sent during a
+// request
+// - "public_key": Base64 DER encoded PKIX representation of the client's public key
+// - "caveats": Base64 VOM encoded list of caveats
+// - "token": OAuth2 access token
+//
+// WARNINGS:
+// - There is no binding between the channel over which the access token
+// was obtained and the channel used to make this request.
+// - There is no "proof of possession of private key" required by the server.
+// Thus, if Mallory (attacker) possesses the access token associated with Alice's
+// account (victim), she may be able to obtain a blessing with Alice's name on it
+// for any public key of her choice.
+func NewOAuthBlessingHandler(ctx *context.T, params blesser.OAuthBlesserParams) http.Handler {
+ return &accessTokenBlesser{ctx, params}
+}
+
+func (a *accessTokenBlesser) blessingCaveats(r *http.Request, p security.Principal) ([]security.Caveat, error) {
+ caveatsVom, err := base64.URLEncoding.DecodeString(r.FormValue(CaveatsFormKey))
+ if err != nil {
+ return nil, fmt.Errorf("failed to base64 decode caveats: %v", err)
+ }
+
+ var caveats []security.Caveat
+ if err := vom.Decode(caveatsVom, &caveats); err != nil {
+ return nil, fmt.Errorf("failed to VOM decode caveats: %v", err)
+ }
+
+ // TODO(suharshs, ataly): Should we ensure that we have at least a
+ // revocation or expiry caveat?
+ if len(caveats) == 0 {
+ var (
+ cav security.Caveat
+ err error
+ )
+ if a.params.RevocationManager != nil {
+ cav, err = a.params.RevocationManager.NewCaveat(p.PublicKey(), a.params.DischargerLocation)
+ } else {
+ cav, err = security.NewExpiryCaveat(time.Now().Add(a.params.BlessingDuration))
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to construct caveats: %v", err)
+ }
+ caveats = append(caveats, cav)
+ }
+ return caveats, nil
+
+}
+
+func (a *accessTokenBlesser) remotePublicKey(r *http.Request) (security.PublicKey, error) {
+ publicKeyVom, err := base64.URLEncoding.DecodeString(r.FormValue(PublicKeyFormKey))
+ if err != nil {
+ return nil, fmt.Errorf("failed to base64 decode public key: %v", err)
+ }
+ return security.UnmarshalPublicKey(publicKeyVom)
+}
+
+func (a *accessTokenBlesser) blessingExtension(r *http.Request) (string, error) {
+ email, clientID, err := a.params.OAuthProvider.GetEmailAndClientID(r.FormValue(AccessTokenFormKey))
+ if err != nil {
+ return "", err
+ }
+ // We use <clientID>/<email> as the extension in order to namespace the blessing under
+ // the <clientID>. This has the downside that the blessing cannot be used to act on
+ // behalf of the user, i.e., services access controlled to blessings matching"<idp>/<email>"
+ // would not authorize this blessing.
+ //
+ // The alternative is to use the extension <email>/<clientID> however this is risky as it
+ // may provide too much authority to the app, especially since we don't have a set of default
+ // caveats to apply to the blessing.
+ //
+ // TODO(ataly, ashankar): Think about changing to the extension <email>/<clientID>.
+ return strings.Join([]string{clientID, email}, security.ChainSeparator), nil
+}
+
+func (a *accessTokenBlesser) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ remoteKey, err := a.remotePublicKey(r)
+ if err != nil {
+ a.ctx.Info("Failed to decode public key [%v] for request %#v", err, r)
+ util.HTTPServerError(w, err)
+ return
+ }
+
+ p := v23.GetPrincipal(a.ctx)
+ with := p.BlessingStore().Default()
+
+ caveats, err := a.blessingCaveats(r, p)
+ if err != nil {
+ a.ctx.Info("Failed to constuct caveats for blessing [%v] for request %#v", err, r)
+ util.HTTPServerError(w, err)
+ return
+ }
+
+ extension, err := a.blessingExtension(r)
+ if err != nil {
+ a.ctx.Info("Failed to process access token [%v] for request %#v", err, r)
+ util.HTTPServerError(w, err)
+ return
+ }
+
+ blessings, err := p.Bless(remoteKey, with, extension, caveats[0], caveats[1:]...)
+ if err != nil {
+ a.ctx.Info("Failed to Bless [%v] for request %#v", err, r)
+ util.HTTPServerError(w, fmt.Errorf("Bless failed: %v", err))
+ return
+ }
+
+ blessingsVom, err := vom.Encode(blessings)
+ if err != nil {
+ a.ctx.Info("Failed to VOM encode blessings [%v] for request %#v", err, r)
+ util.HTTPServerError(w, fmt.Errorf("failed to VOM encode blessings: %v", err))
+ return
+ }
+ blessingsVomB64 := base64.URLEncoding.EncodeToString(blessingsVom)
+ w.Header().Set("Content-Type", "application/text")
+ w.Write([]byte(blessingsVomB64))
+}
diff --git a/services/identity/internal/handlers/handlers_test.go b/services/identity/internal/handlers/handlers_test.go
index 353c579..3740451 100644
--- a/services/identity/internal/handlers/handlers_test.go
+++ b/services/identity/internal/handlers/handlers_test.go
@@ -2,20 +2,32 @@
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
-package handlers
+package handlers_test
import (
+ "bytes"
"encoding/base64"
"encoding/json"
+ "io/ioutil"
"net/http"
"net/http/httptest"
+ "net/url"
"reflect"
"sort"
"testing"
+ "time"
+ "v.io/v23"
"v.io/v23/security"
+ "v.io/v23/vom"
+ _ "v.io/x/ref/runtime/factories/generic"
"v.io/x/ref/services/identity"
+ "v.io/x/ref/services/identity/internal/blesser"
+ "v.io/x/ref/services/identity/internal/handlers"
+ "v.io/x/ref/services/identity/internal/oauth"
+ "v.io/x/ref/services/identity/internal/revocation"
+ "v.io/x/ref/test"
"v.io/x/ref/test/testutil"
)
@@ -24,7 +36,7 @@
blessingNames := []string{"test-root"}
p := testutil.NewPrincipal(blessingNames...)
- ts := httptest.NewServer(BlessingRoot{p})
+ ts := httptest.NewServer(handlers.BlessingRoot{p})
defer ts.Close()
response, err := http.Get(ts.URL)
if err != nil {
@@ -56,3 +68,165 @@
t.Errorf("Response has incorrect public key. Got %v, want %v", got, want)
}
}
+
+func TestBlessUsingAccessToken(t *testing.T) {
+ var (
+ blesserPrin = testutil.NewPrincipal("blesser")
+ blesseePrin = testutil.NewPrincipal("blessee")
+
+ methodCav, _ = security.NewMethodCaveat("foo")
+ expiryCav, _ = security.NewExpiryCaveat(time.Now().Add(time.Hour))
+
+ mkReqURL = func(baseURLStr string, caveats []security.Caveat) string {
+ baseURL, err := url.Parse(baseURLStr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ caveatsVom, err := vom.Encode(caveats)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keyBytes, err := blesseePrin.PublicKey().MarshalBinary()
+ if err != nil {
+ t.Fatal(err)
+ }
+ params := url.Values{}
+ params.Add(handlers.CaveatsFormKey, base64.URLEncoding.EncodeToString(caveatsVom))
+ params.Add(handlers.PublicKeyFormKey, base64.URLEncoding.EncodeToString(keyBytes))
+ params.Add(handlers.AccessTokenFormKey, "mocktoken")
+ baseURL.RawQuery = params.Encode()
+ return baseURL.String()
+ }
+
+ decodeBlessings = func(blessingsVomB64 string) security.Blessings {
+ blessingsVom, err := base64.URLEncoding.DecodeString(blessingsVomB64)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var res security.Blessings
+ if err := vom.Decode(blessingsVom, &res); err != nil {
+ t.Fatal(err)
+ }
+ return res
+ }
+ )
+
+ ctx, shutdown := test.V23Init()
+ defer shutdown()
+ var err error
+ if ctx, err = v23.WithPrincipal(ctx, blesserPrin); err != nil {
+ t.Fatal(err)
+ }
+
+ // Make the blessee trust the blesser's roots
+ if err := blesseePrin.AddToRoots(blesserPrin.BlessingStore().Default()); err != nil {
+ t.Fatal(err)
+ }
+
+ testEmail := "foo@bar.com"
+ testClientID := "test-client-id"
+ revocationManager := revocation.NewMockRevocationManager(ctx)
+ oauthProvider := oauth.NewMockOAuth(testEmail, testClientID)
+
+ testcases := []struct {
+ params blesser.OAuthBlesserParams
+ caveats []security.Caveat
+ }{
+ {
+ blesser.OAuthBlesserParams{
+ OAuthProvider: oauthProvider,
+ BlessingDuration: 24 * time.Hour,
+ },
+ nil,
+ },
+ {
+ blesser.OAuthBlesserParams{
+ OAuthProvider: oauthProvider,
+ RevocationManager: revocationManager,
+ },
+ nil,
+ },
+ {
+ blesser.OAuthBlesserParams{
+ OAuthProvider: oauthProvider,
+ RevocationManager: revocationManager,
+ },
+ []security.Caveat{expiryCav, methodCav},
+ },
+ }
+ for _, testcase := range testcases {
+ ts := httptest.NewServer(handlers.NewOAuthBlessingHandler(ctx, testcase.params))
+ defer ts.Close()
+
+ response, err := http.Get(mkReqURL(ts.URL, testcase.caveats))
+ if err != nil {
+ t.Fatal(err)
+ }
+ blessingsVomB64, err := ioutil.ReadAll(response.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ blessings := decodeBlessings(string(blessingsVomB64))
+
+ // Blessing should be bound to the blessee.
+ if got, want := blessings.PublicKey(), blesseePrin.PublicKey(); !reflect.DeepEqual(got, want) {
+ t.Errorf("got blessings for public key %v, want blessings for public key %v", got, want)
+ }
+
+ // Verify the name and caveats on the blessings.
+ binfo := blesseePrin.BlessingsInfo(blessings)
+ if len(binfo) != 1 {
+ t.Errorf("got blessings with %d names, want blessings with 1 name", len(binfo))
+ }
+ wantName := "blesser" + security.ChainSeparator + testClientID + security.ChainSeparator + testEmail
+ caveats, ok := binfo[wantName]
+ if !ok {
+ t.Errorf("expected blessing with name %v, got none", wantName)
+ }
+
+ if len(testcase.caveats) > 0 {
+ // The blessing must have exactly those caveats that were provided in the request.
+ if !caveatsMatch(t, caveats, testcase.caveats) {
+ t.Errorf("got blessings with caveats %v, want blessings with caveats %v", caveats, testcase.caveats)
+ }
+ } else if len(caveats) != 1 {
+ t.Errorf("got blessings with %d caveats, want blessings with 1 caveats", len(caveats))
+ } else if testcase.params.RevocationManager != nil && caveats[0].Id != security.PublicKeyThirdPartyCaveat.Id {
+ // The blessing must have a third-party revocation caveat.
+ t.Errorf("got blessings with caveat (%v), want blessings with a PublicKeyThirdPartyCaveat", caveats[0].Id)
+ } else if testcase.params.RevocationManager == nil && caveats[0].Id != security.ExpiryCaveat.Id {
+ // The blessing must have an expiry caveat.
+ t.Errorf("got blessings with caveat (%v), want blessings with an ExpiryCaveat", caveats[0].Id)
+ }
+ }
+}
+
+type caveatsSorter struct {
+ caveats []security.Caveat
+ t *testing.T
+}
+
+func (c caveatsSorter) Len() int { return len(c.caveats) }
+func (c caveatsSorter) Swap(i, j int) { c.caveats[i], c.caveats[j] = c.caveats[j], c.caveats[i] }
+func (c caveatsSorter) Less(i, j int) bool {
+ b_i, err := vom.Encode(c.caveats[i])
+ if err != nil {
+ c.t.Fatal(err)
+ }
+ b_j, err := vom.Encode(c.caveats[j])
+ if err != nil {
+ c.t.Fatal(err)
+ }
+ return bytes.Compare(b_i, b_j) == -1
+}
+
+func caveatsMatch(t *testing.T, got, want []security.Caveat) bool {
+ if len(got) != len(want) {
+ return false
+ }
+ g, w := caveatsSorter{got, t}, caveatsSorter{want, t}
+ sort.Sort(g)
+ sort.Sort(w)
+ return reflect.DeepEqual(g, w)
+}
diff --git a/services/identity/internal/identityd_test/main.go b/services/identity/internal/identityd_test/main.go
index a34f35f..3b3b152 100644
--- a/services/identity/internal/identityd_test/main.go
+++ b/services/identity/internal/identityd_test/main.go
@@ -90,14 +90,23 @@
}
}
+ mockClientID := "test-client-id"
+ mockClientName := "test-client"
+
auditor, reader := auditor.NewMockBlessingAuditor()
revocationManager := revocation.NewMockRevocationManager(ctx)
- oauthProvider := oauth.NewMockOAuth(oauthEmail)
+ oauthProvider := oauth.NewMockOAuth(oauthEmail, mockClientID)
params := blesser.OAuthBlesserParams{
OAuthProvider: oauthProvider,
BlessingDuration: duration,
RevocationManager: revocationManager,
+ AccessTokenClients: []oauth.AccessTokenClient{
+ oauth.AccessTokenClient{
+ Name: mockClientName,
+ ClientID: mockClientID,
+ },
+ },
}
caveatSelector := caveats.NewMockCaveatSelector()
diff --git a/services/identity/internal/oauth/googleoauth.go b/services/identity/internal/oauth/googleoauth.go
index a2f20de..4f09b09 100644
--- a/services/identity/internal/oauth/googleoauth.go
+++ b/services/identity/internal/oauth/googleoauth.go
@@ -105,13 +105,9 @@
return gtoken.Email, nil
}
-// GetEmailAndClientName uses Google's tokeninfo API to verify that the token has been issued
-// for one of the provided 'accessTokenClients' and if so returns the email and client name
-// from the tokeninfo obtained.
-func (g *googleOAuth) GetEmailAndClientName(accessToken string, accessTokenClients []AccessTokenClient) (string, string, error) {
- if len(accessTokenClients) == 0 {
- return "", "", fmt.Errorf("no expected AccessTokenClients specified")
- }
+// GetEmailAndClientID uses Google's tokeninfo API to determine the email and clientID
+// associated with the token.
+func (g *googleOAuth) GetEmailAndClientID(accessToken string) (string, string, error) {
// As per https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken
// we obtain the 'info' for the token via an HTTP roundtrip to Google.
tokeninfo, err := http.Get(g.verifyURL + "access_token=" + accessToken)
@@ -136,25 +132,12 @@
if err := json.NewDecoder(tokeninfo.Body).Decode(&token); err != nil {
return "", "", fmt.Errorf("invalid JSON response from Google's tokeninfo API: %v", err)
}
- var client AccessTokenClient
- audienceMatch := false
- for _, c := range accessTokenClients {
- if token.Audience == c.ClientID {
- client = c
- audienceMatch = true
- break
- }
- }
- if !audienceMatch {
- g.ctx.Infof("Got access token [%+v], wanted one of client ids %v", token, accessTokenClients)
- return "", "", fmt.Errorf("token not meant for this purpose, confused deputy? https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken")
- }
// We check both "verified_email" and "email_verified" here because the token response sometimes
// contains one and sometimes contains the other.
if !token.VerifiedEmail && !token.EmailVerified {
return "", "", fmt.Errorf("email not verified")
}
- return token.Email, client.Name, nil
+ return token.Email, token.Audience, nil
}
func (g *googleOAuth) oauthConfig(redirectUrl string) *oauth2.Config {
diff --git a/services/identity/internal/oauth/mockoauth.go b/services/identity/internal/oauth/mockoauth.go
index 3a0e828..897fc69 100644
--- a/services/identity/internal/oauth/mockoauth.go
+++ b/services/identity/internal/oauth/mockoauth.go
@@ -4,15 +4,14 @@
package oauth
-const MockClient = "test-client"
-
// mockOAuth is a mock OAuthProvider for use in tests.
type mockOAuth struct {
- email string
+ email string
+ clientID string
}
-func NewMockOAuth(mockEmail string) OAuthProvider {
- return &mockOAuth{email: mockEmail}
+func NewMockOAuth(mockEmail, mockClientID string) OAuthProvider {
+ return &mockOAuth{email: mockEmail, clientID: mockClientID}
}
func (m *mockOAuth) AuthURL(redirectUrl string, state string, _ AuthURLApproval) string {
@@ -23,6 +22,6 @@
return m.email, nil
}
-func (m *mockOAuth) GetEmailAndClientName(string, []AccessTokenClient) (string, string, error) {
- return m.email, MockClient, nil
+func (m *mockOAuth) GetEmailAndClientID(string) (string, string, error) {
+ return m.email, m.clientID, nil
}
diff --git a/services/identity/internal/oauth/oauth_provider.go b/services/identity/internal/oauth/oauth_provider.go
index 8ba6239..1928a54 100644
--- a/services/identity/internal/oauth/oauth_provider.go
+++ b/services/identity/internal/oauth/oauth_provider.go
@@ -4,14 +4,6 @@
package oauth
-// AccessTokenClient represents a client of an OAuthProvider.
-type AccessTokenClient struct {
- // Descriptive name of the client.
- Name string
- // OAuth Client ID.
- ClientID string
-}
-
// Option to OAuthProvider.AuthURL controlling whether previously provided user consent can be re-used.
type AuthURLApproval bool
@@ -28,9 +20,6 @@
// ExchangeAuthCodeForEmail exchanges the provided authCode for the email of the
// authenticated user on behalf of the token has been issued.
ExchangeAuthCodeForEmail(authCode string, url string) (email string, err error)
- // GetEmailAndClientName verifies that the provided 'accessToken' is issued to one
- // of the provided accessTokenClients, and if so returns the email of the
- // authenticated user on behalf of whom the token has been issued, and also the
- // client name associated with the token.
- GetEmailAndClientName(accessToken string, accessTokenClients []AccessTokenClient) (email string, clientName string, err error)
+ // GetEmailAndClientID returns the email and clientID associated with the token.
+ GetEmailAndClientID(accessToken string) (email string, clientID string, err error)
}
diff --git a/services/identity/internal/oauth/utils.go b/services/identity/internal/oauth/utils.go
index 23b9fb5..cf4e341 100644
--- a/services/identity/internal/oauth/utils.go
+++ b/services/identity/internal/oauth/utils.go
@@ -10,6 +10,14 @@
"io"
)
+// AccessTokenClient represents a client of an OAuthProvider.
+type AccessTokenClient struct {
+ // Descriptive name of the client.
+ Name string
+ // OAuth Client ID.
+ ClientID string
+}
+
// ClientIDFromJSON parses JSON-encoded API access information in 'r' and returns
// the extracted ClientID.
// This JSON-encoded data is typically available as a download from the Google
@@ -52,6 +60,18 @@
return
}
+// ClientName checks if the provided clientID is present in one of the provided
+// 'clients' and if so returns the corresponding client name. It returns an error
+// otherwise.
+func ClientName(clientID string, clients []AccessTokenClient) (string, error) {
+ for _, c := range clients {
+ if clientID == c.ClientID {
+ return c.Name, nil
+ }
+ }
+ return "", fmt.Errorf("unrecognized client ID, confused deputy? https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken")
+}
+
func decodeAccessMapFromJSON(r io.Reader) (data map[string]interface{}, typ string, err error) {
var full map[string]interface{}
if err = json.NewDecoder(r).Decode(&full); err != nil {
diff --git a/services/identity/internal/server/identityd.go b/services/identity/internal/server/identityd.go
index e85a8a4..a14a6ba 100644
--- a/services/identity/internal/server/identityd.go
+++ b/services/identity/internal/server/identityd.go
@@ -139,7 +139,7 @@
ctx.Fatalf("macaroonKey generation failed: %v", err)
}
- rpcServer, published, err := s.setupServices(ctx, listenSpec, macaroonKey)
+ rpcServer, published, err := s.setupBlessingServices(ctx, listenSpec, macaroonKey)
if err != nil {
ctx.Fatalf("Failed to setup vanadium services for blessing: %v", err)
}
@@ -207,8 +207,9 @@
return names
}
-// Starts the blessing services and the discharging service on the same port.
-func (s *IdentityServer) setupServices(ctx *context.T, listenSpec *rpc.ListenSpec, macaroonKey []byte) (rpc.Server, []string, error) {
+// Starts the Vanadium and HTTP services for blessing, and the Vanadium service for discharging.
+// All Vanadium services are started on the same port.
+func (s *IdentityServer) setupBlessingServices(ctx *context.T, listenSpec *rpc.ListenSpec, macaroonKey []byte) (rpc.Server, []string, error) {
server, err := v23.NewServer(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to create new rpc.Server: %v", err)
@@ -228,11 +229,17 @@
} else {
rootedObjectAddr = s.rootedObjectAddrs[0].Name()
}
- dispatcher := newDispatcher(macaroonKey, oauthBlesserParams(s.oauthBlesserParams, rootedObjectAddr))
+
+ params := oauthBlesserParams(s.oauthBlesserParams, rootedObjectAddr)
+ dispatcher := newDispatcher(macaroonKey, params)
if err := server.ServeDispatcher(objectAddr, dispatcher); err != nil {
return nil, nil, fmt.Errorf("failed to start Vanadium services: %v", err)
}
- ctx.Infof("Blessing and discharger services will be published at %v", rootedObjectAddr)
+ ctx.Infof("Vanadium Blessing and discharger services will be published at %v", rootedObjectAddr)
+
+ // Start the HTTP Handler for the OAuth2 access token based blesser.
+ http.Handle("/auth/google/bless", handlers.NewOAuthBlessingHandler(ctx, params))
+
return server, []string{rootedObjectAddr}, nil
}