services/identity: Reorganize identityd to only expose the binaries
and modules subcommand. Move everything else into internal.
We move TestIdentityd modules subcommand into identity package
to allow us to unexpose most of the implementation.
MultiPart: 1/3
Change-Id: If084f2156c556f2eee11c934ef257e79406953fa
diff --git a/services/identity/internal/auditor/blessing_auditor.go b/services/identity/internal/auditor/blessing_auditor.go
new file mode 100644
index 0000000..607db64
--- /dev/null
+++ b/services/identity/internal/auditor/blessing_auditor.go
@@ -0,0 +1,140 @@
+package auditor
+
+import (
+ "database/sql"
+ "fmt"
+ "strings"
+ "time"
+
+ "v.io/v23/security"
+ "v.io/v23/vom"
+ "v.io/x/ref/security/audit"
+)
+
+// BlessingLogReader provides the Read method to read audit logs.
+// Read returns a channel of BlessingEntrys whose extension matches the provided email.
+type BlessingLogReader interface {
+ Read(email string) <-chan BlessingEntry
+}
+
+// BlessingEntry contains important logged information about a blessed principal.
+type BlessingEntry struct {
+ Email string
+ Caveats []security.Caveat
+ Timestamp time.Time // Time when the blesings were created.
+ RevocationCaveatID string
+ Blessings security.Blessings
+ DecodeError error
+}
+
+// NewSQLBlessingAuditor returns an auditor for wrapping a principal with, and a BlessingLogReader
+// for reading the audits made by that auditor. The config is used to construct the connection
+// to the SQL database that the auditor and BlessingLogReader use.
+func NewSQLBlessingAuditor(sqlDB *sql.DB) (audit.Auditor, BlessingLogReader, error) {
+ db, err := newSQLDatabase(sqlDB, "BlessingAudit")
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to create sql db: %v", err)
+ }
+ auditor, reader := &blessingAuditor{db}, &blessingLogReader{db}
+ return auditor, reader, nil
+}
+
+type blessingAuditor struct {
+ db database
+}
+
+func (a *blessingAuditor) Audit(entry audit.Entry) error {
+ if entry.Method != "Bless" {
+ return nil
+ }
+ dbentry, err := newDatabaseEntry(entry)
+ if err != nil {
+ return err
+ }
+ return a.db.Insert(dbentry)
+}
+
+type blessingLogReader struct {
+ db database
+}
+
+func (r *blessingLogReader) Read(email string) <-chan BlessingEntry {
+ c := make(chan BlessingEntry)
+ go r.sendAuditEvents(c, email)
+ return c
+}
+
+func (r *blessingLogReader) sendAuditEvents(dst chan<- BlessingEntry, email string) {
+ defer close(dst)
+ dbch := r.db.Query(email)
+ for dbentry := range dbch {
+ dst <- newBlessingEntry(dbentry)
+ }
+}
+
+func newDatabaseEntry(entry audit.Entry) (databaseEntry, error) {
+ d := databaseEntry{timestamp: entry.Timestamp}
+ extension, ok := entry.Arguments[2].(string)
+ if !ok {
+ return d, fmt.Errorf("failed to extract extension")
+ }
+ // Find the first email component
+ for _, n := range strings.Split(extension, security.ChainSeparator) {
+ // HACK ALERT: An email is the first entry to end up with
+ // a single "@" in it
+ if strings.Count(n, "@") == 1 {
+ d.email = n
+ break
+ }
+ }
+ if len(d.email) == 0 {
+ return d, fmt.Errorf("failed to extract email address from extension %q", extension)
+ }
+ var caveats []security.Caveat
+ for _, arg := range entry.Arguments[3:] {
+ if cav, ok := arg.(security.Caveat); !ok {
+ return d, fmt.Errorf("failed to extract Caveat")
+ } else {
+ caveats = append(caveats, cav)
+ }
+ }
+ var blessings security.Blessings
+ if blessings, ok = entry.Results[0].(security.Blessings); !ok {
+ return d, fmt.Errorf("failed to extract result blessing")
+ }
+ var err error
+ if d.blessings, err = vom.Encode(blessings); err != nil {
+ return d, err
+ }
+ if d.caveats, err = vom.Encode(caveats); err != nil {
+ return d, err
+ }
+ return d, nil
+}
+
+func newBlessingEntry(dbentry databaseEntry) BlessingEntry {
+ if dbentry.decodeErr != nil {
+ return BlessingEntry{DecodeError: dbentry.decodeErr}
+ }
+ b := BlessingEntry{
+ Email: dbentry.email,
+ Timestamp: dbentry.timestamp,
+ }
+ if err := vom.Decode(dbentry.blessings, &b.Blessings); err != nil {
+ return BlessingEntry{DecodeError: fmt.Errorf("failed to decode blessings: %s", err)}
+ }
+ if err := vom.Decode(dbentry.caveats, &b.Caveats); err != nil {
+ return BlessingEntry{DecodeError: fmt.Errorf("failed to decode caveats: %s", err)}
+ }
+ b.RevocationCaveatID = revocationCaveatID(b.Caveats)
+ return b
+}
+
+func revocationCaveatID(caveats []security.Caveat) string {
+ for _, cav := range caveats {
+ if tp := cav.ThirdPartyDetails(); tp != nil {
+ return tp.ID()
+ }
+ }
+ return ""
+}
diff --git a/services/identity/internal/auditor/blessing_auditor_test.go b/services/identity/internal/auditor/blessing_auditor_test.go
new file mode 100644
index 0000000..7e413b6
--- /dev/null
+++ b/services/identity/internal/auditor/blessing_auditor_test.go
@@ -0,0 +1,110 @@
+package auditor
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "v.io/v23/security"
+ vsecurity "v.io/x/ref/security"
+ "v.io/x/ref/security/audit"
+)
+
+func TestBlessingAuditor(t *testing.T) {
+ auditor, reader := NewMockBlessingAuditor()
+
+ p, err := vsecurity.NewPrincipal()
+ if err != nil {
+ t.Fatalf("failed to create principal: %v", err)
+ }
+ expiryCaveat := newCaveat(security.ExpiryCaveat(time.Now().Add(time.Hour)))
+ revocationCaveat := newThirdPartyCaveat(t, p)
+
+ tests := []struct {
+ Extension string
+ Email string
+ Caveats []security.Caveat
+ RevocationCaveatID string
+ Blessings security.Blessings
+ }{
+ {
+ Extension: "foo@bar.com/nocaveats/bar@baz.com",
+ Email: "foo@bar.com",
+ RevocationCaveatID: "",
+ Blessings: newBlessing(t, p, "test/foo@bar.com/nocaveats/bar@baz.com"),
+ },
+ {
+ Extension: "users/foo@bar.com/caveat",
+ Email: "foo@bar.com",
+ Caveats: []security.Caveat{expiryCaveat},
+ RevocationCaveatID: "",
+ Blessings: newBlessing(t, p, "test/foo@bar.com/caveat"),
+ },
+ {
+ Extension: "special/guests/foo@bar.com/caveatAndRevocation",
+ Email: "foo@bar.com",
+ Caveats: []security.Caveat{expiryCaveat, revocationCaveat},
+ RevocationCaveatID: revocationCaveat.ThirdPartyDetails().ID(),
+ Blessings: newBlessing(t, p, "test/foo@bar.com/caveatAndRevocation"),
+ },
+ }
+
+ for _, test := range tests {
+ args := []interface{}{nil, nil, test.Extension}
+ for _, cav := range test.Caveats {
+ args = append(args, cav)
+ }
+ if err := auditor.Audit(audit.Entry{
+ Method: "Bless",
+ Arguments: args,
+ Results: []interface{}{test.Blessings},
+ }); err != nil {
+ t.Errorf("Failed to audit Blessing %v: %v", test.Blessings, err)
+ }
+ ch := reader.Read("query")
+ got := <-ch
+ if got.Email != test.Email {
+ t.Errorf("got %v, want %v", got.Email, test.Email)
+ }
+ if !reflect.DeepEqual(got.Caveats, test.Caveats) {
+ t.Errorf("got %#v, want %#v", got.Caveats, test.Caveats)
+ }
+ if got.RevocationCaveatID != test.RevocationCaveatID {
+ t.Errorf("got %v, want %v", got.RevocationCaveatID, test.RevocationCaveatID)
+ }
+ if !reflect.DeepEqual(got.Blessings, test.Blessings) {
+ t.Errorf("got %v, want %v", got.Blessings, test.Blessings)
+ }
+ var extra bool
+ for _ = range ch {
+ // Drain the channel to prevent the producer goroutines from being leaked.
+ extra = true
+ }
+ if extra {
+ t.Errorf("Got more entries that expected for test %+v", test)
+ }
+ }
+}
+
+func newThirdPartyCaveat(t *testing.T, p security.Principal) security.Caveat {
+ tp, err := security.NewPublicKeyCaveat(p.PublicKey(), "location", security.ThirdPartyRequirements{}, newCaveat(security.MethodCaveat("method")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tp
+}
+
+func newBlessing(t *testing.T, p security.Principal, name string) security.Blessings {
+ b, err := p.BlessSelf(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+func newCaveat(caveat security.Caveat, err error) security.Caveat {
+ if err != nil {
+ panic(err)
+ }
+ return caveat
+}
diff --git a/services/identity/internal/auditor/mock_auditor.go b/services/identity/internal/auditor/mock_auditor.go
new file mode 100644
index 0000000..f5fc888
--- /dev/null
+++ b/services/identity/internal/auditor/mock_auditor.go
@@ -0,0 +1,32 @@
+package auditor
+
+import (
+ "reflect"
+ "v.io/x/ref/security/audit"
+)
+
+func NewMockBlessingAuditor() (audit.Auditor, BlessingLogReader) {
+ db := &mockDatabase{}
+ return &blessingAuditor{db}, &blessingLogReader{db}
+}
+
+type mockDatabase struct {
+ NextEntry databaseEntry
+}
+
+func (db *mockDatabase) Insert(entry databaseEntry) error {
+ db.NextEntry = entry
+ return nil
+}
+
+func (db *mockDatabase) Query(email string) <-chan databaseEntry {
+ c := make(chan databaseEntry)
+ go func() {
+ var empty databaseEntry
+ if !reflect.DeepEqual(db.NextEntry, empty) {
+ c <- db.NextEntry
+ }
+ close(c)
+ }()
+ return c
+}
diff --git a/services/identity/internal/auditor/sql_database.go b/services/identity/internal/auditor/sql_database.go
new file mode 100644
index 0000000..81cda29
--- /dev/null
+++ b/services/identity/internal/auditor/sql_database.go
@@ -0,0 +1,77 @@
+package auditor
+
+import (
+ "database/sql"
+ "fmt"
+ "time"
+
+ "v.io/x/lib/vlog"
+)
+
+type database interface {
+ Insert(entry databaseEntry) error
+ Query(email string) <-chan databaseEntry
+}
+
+type databaseEntry struct {
+ email string
+ caveats, blessings []byte
+ timestamp time.Time
+ decodeErr error
+}
+
+// newSQLDatabase returns a SQL implementation of the database interface.
+// If the table does not exist it creates it.
+func newSQLDatabase(db *sql.DB, table string) (database, error) {
+ createStmt, err := db.Prepare(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s ( Email NVARCHAR(256), Caveats BLOB, Timestamp DATETIME, Blessings BLOB );", table))
+ if err != nil {
+ return nil, err
+ }
+ if _, err = createStmt.Exec(); err != nil {
+ return nil, err
+ }
+ insertStmt, err := db.Prepare(fmt.Sprintf("INSERT INTO %s (Email, Caveats, Timestamp, Blessings) VALUES (?, ?, ?, ?)", table))
+ if err != nil {
+ return nil, err
+ }
+ queryStmt, err := db.Prepare(fmt.Sprintf("SELECT Email, Caveats, Timestamp, Blessings FROM %s WHERE Email=?", table))
+ return sqlDatabase{insertStmt, queryStmt}, err
+}
+
+// Table with 4 columns:
+// (1) Email = string email of the Blessee.
+// (2) Caveats = vom encoded caveats
+// (3) Blessings = vom encoded resulting blessings.
+// (4) Timestamp = time that the blessing happened.
+type sqlDatabase struct {
+ insertStmt, queryStmt *sql.Stmt
+}
+
+func (s sqlDatabase) Insert(entry databaseEntry) error {
+ _, err := s.insertStmt.Exec(entry.email, entry.caveats, entry.timestamp, entry.blessings)
+ return err
+}
+
+func (s sqlDatabase) Query(email string) <-chan databaseEntry {
+ c := make(chan databaseEntry)
+ go s.sendDatabaseEntries(email, c)
+ return c
+}
+
+func (s sqlDatabase) sendDatabaseEntries(email string, dst chan<- databaseEntry) {
+ defer close(dst)
+ rows, err := s.queryStmt.Query(email)
+ if err != nil {
+ vlog.Errorf("query failed %v", err)
+ dst <- databaseEntry{decodeErr: fmt.Errorf("Failed to query for all audits: %v", err)}
+ return
+ }
+ for rows.Next() {
+ var dbentry databaseEntry
+ if err = rows.Scan(&dbentry.email, &dbentry.caveats, &dbentry.timestamp, &dbentry.blessings); err != nil {
+ vlog.Errorf("scan of row failed %v", err)
+ dbentry.decodeErr = fmt.Errorf("failed to read sql row, %s", err)
+ }
+ dst <- dbentry
+ }
+}
diff --git a/services/identity/internal/auditor/sql_database_test.go b/services/identity/internal/auditor/sql_database_test.go
new file mode 100644
index 0000000..bbd2fa6
--- /dev/null
+++ b/services/identity/internal/auditor/sql_database_test.go
@@ -0,0 +1,53 @@
+package auditor
+
+import (
+ "github.com/DATA-DOG/go-sqlmock"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestSQLDatabaseQuery(t *testing.T) {
+ db, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("failed to create new mock database stub: %v", err)
+ }
+ columns := []string{"Email", "Caveat", "Timestamp", "Blessings"}
+ sqlmock.ExpectExec("CREATE TABLE IF NOT EXISTS tableName (.+)").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ d, err := newSQLDatabase(db, "tableName")
+ if err != nil {
+ t.Fatalf("failed to create SQLDatabase: %v", err)
+ }
+
+ entry := databaseEntry{
+ email: "email",
+ caveats: []byte("caveats"),
+ timestamp: time.Now(),
+ blessings: []byte("blessings"),
+ }
+ sqlmock.ExpectExec("INSERT INTO tableName (.+) VALUES (.+)").
+ WithArgs(entry.email, entry.caveats, entry.timestamp, entry.blessings).
+ WillReturnResult(sqlmock.NewResult(0, 1)) // no insert id, 1 affected row
+ if err := d.Insert(entry); err != nil {
+ t.Errorf("failed to insert into SQLDatabase: %v", err)
+ }
+
+ // Test the querying.
+ sqlmock.ExpectQuery("SELECT Email, Caveats, Timestamp, Blessings FROM tableName").
+ WithArgs(entry.email).
+ WillReturnRows(sqlmock.NewRows(columns).AddRow(entry.email, entry.caveats, entry.timestamp, entry.blessings))
+ ch := d.Query(entry.email)
+ if res := <-ch; !reflect.DeepEqual(res, entry) {
+ t.Errorf("got %#v, expected %#v", res, entry)
+ }
+
+ var extra bool
+ for _ = range ch {
+ // Drain the channel to prevent the producer goroutines from being leaked.
+ extra = true
+ }
+ if extra {
+ t.Errorf("Got more entries that expected")
+ }
+}
diff --git a/services/identity/internal/blesser/macaroon.go b/services/identity/internal/blesser/macaroon.go
new file mode 100644
index 0000000..638a98c
--- /dev/null
+++ b/services/identity/internal/blesser/macaroon.go
@@ -0,0 +1,46 @@
+package blesser
+
+import (
+ "fmt"
+ "time"
+
+ "v.io/x/ref/services/identity"
+ "v.io/x/ref/services/identity/internal/oauth"
+ "v.io/x/ref/services/identity/internal/util"
+
+ "v.io/v23/rpc"
+ "v.io/v23/security"
+ "v.io/v23/vom"
+)
+
+type macaroonBlesser struct {
+ key []byte
+}
+
+// NewMacaroonBlesserServer provides an identity.MacaroonBlesser Service that generates blessings
+// after unpacking a BlessingMacaroon.
+func NewMacaroonBlesserServer(key []byte) identity.MacaroonBlesserServerStub {
+ return identity.MacaroonBlesserServer(&macaroonBlesser{key})
+}
+
+func (b *macaroonBlesser) Bless(call rpc.ServerCall, macaroon string) (security.Blessings, error) {
+ var empty security.Blessings
+ inputs, err := util.Macaroon(macaroon).Decode(b.key)
+ if err != nil {
+ return empty, err
+ }
+ var m oauth.BlessingMacaroon
+ if err := vom.Decode(inputs, &m); err != nil {
+ return empty, err
+ }
+ if time.Now().After(m.Creation.Add(time.Minute * 5)) {
+ return empty, fmt.Errorf("macaroon has expired")
+ }
+ if call.LocalPrincipal() == nil {
+ return empty, fmt.Errorf("server misconfiguration: no authentication happened")
+ }
+ if len(m.Caveats) == 0 {
+ m.Caveats = []security.Caveat{security.UnconstrainedUse()}
+ }
+ return call.LocalPrincipal().Bless(call.RemoteBlessings().PublicKey(), call.LocalBlessings(), m.Name, m.Caveats[0], m.Caveats[1:]...)
+}
diff --git a/services/identity/internal/blesser/macaroon_test.go b/services/identity/internal/blesser/macaroon_test.go
new file mode 100644
index 0000000..0babad5
--- /dev/null
+++ b/services/identity/internal/blesser/macaroon_test.go
@@ -0,0 +1,71 @@
+package blesser
+
+import (
+ "crypto/rand"
+ "reflect"
+ "testing"
+ "time"
+
+ "v.io/x/ref/services/identity/internal/oauth"
+ "v.io/x/ref/services/identity/internal/util"
+
+ "v.io/v23/security"
+ "v.io/v23/vom"
+)
+
+func TestMacaroonBlesser(t *testing.T) {
+ var (
+ key = make([]byte, 16)
+ provider, user = newPrincipal(), newPrincipal()
+ cOnlyMethodFoo = newCaveat(security.MethodCaveat("Foo"))
+ context = &serverCall{
+ p: provider,
+ local: blessSelf(provider, "provider"),
+ remote: blessSelf(user, "self-signed-user"),
+ }
+ )
+ if _, err := rand.Read(key); err != nil {
+ t.Fatal(err)
+ }
+ blesser := NewMacaroonBlesserServer(key)
+
+ m := oauth.BlessingMacaroon{Creation: time.Now().Add(-1 * time.Hour), Name: "foo"}
+ wantErr := "macaroon has expired"
+ if _, err := blesser.Bless(context, newMacaroon(t, key, m)); err == nil || err.Error() != wantErr {
+ t.Errorf("Bless(...) failed with error: %v, want: %v", err, wantErr)
+ }
+ m = oauth.BlessingMacaroon{Creation: time.Now(), Name: "user", Caveats: []security.Caveat{cOnlyMethodFoo}}
+ b, err := blesser.Bless(context, newMacaroon(t, key, m))
+ if err != nil {
+ t.Errorf("Bless failed: %v", err)
+ }
+
+ if !reflect.DeepEqual(b.PublicKey(), user.PublicKey()) {
+ t.Errorf("Received blessing for public key %v. Client:%v, Blesser:%v", b.PublicKey(), user.PublicKey(), provider.PublicKey())
+ }
+
+ // When the user does not recognize the provider, it should not see any strings for
+ // the client's blessings.
+ if got := user.BlessingsInfo(b); got != nil {
+ t.Errorf("Got blessing with info %v, want nil", got)
+ }
+ // But once it recognizes the provider, it should see exactly the name
+ // "provider/user" for the caveat cOnlyMethodFoo.
+ user.AddToRoots(b)
+ binfo := user.BlessingsInfo(b)
+ if num := len(binfo); num != 1 {
+ t.Errorf("Got blessings with %d names, want exactly one name", num)
+ }
+ wantName := "provider/user"
+ if cavs := binfo[wantName]; !reflect.DeepEqual(cavs, []security.Caveat{cOnlyMethodFoo}) {
+ t.Errorf("BlessingsInfo %v does not have name %s for the caveat %v", binfo, wantName)
+ }
+}
+
+func newMacaroon(t *testing.T, key []byte, m oauth.BlessingMacaroon) string {
+ encMac, err := vom.Encode(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(util.NewMacaroon(key, encMac))
+}
diff --git a/services/identity/internal/blesser/oauth.go b/services/identity/internal/blesser/oauth.go
new file mode 100644
index 0000000..bebfe01
--- /dev/null
+++ b/services/identity/internal/blesser/oauth.go
@@ -0,0 +1,101 @@
+package blesser
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "v.io/x/ref/services/identity"
+ "v.io/x/ref/services/identity/internal/oauth"
+ "v.io/x/ref/services/identity/internal/revocation"
+ "v.io/x/ref/services/identity/internal/util"
+
+ "v.io/v23/rpc"
+ "v.io/v23/security"
+)
+
+type oauthBlesser struct {
+ oauthProvider oauth.OAuthProvider
+ authcodeClient struct{ ID, Secret string }
+ accessTokenClients []oauth.AccessTokenClient
+ duration time.Duration
+ emailClassifier *util.EmailClassifier
+ dischargerLocation string
+ revocationManager revocation.RevocationManager
+}
+
+// OAuthBlesserParams represents all the parameters provided to NewOAuthBlesserServer
+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.
+ AccessTokenClients []oauth.AccessTokenClient
+ // Determines prefixes used for blessing extensions based on email address.
+ EmailClassifier *util.EmailClassifier
+ // The object name of the discharger service. If this is empty then revocation caveats will not be granted.
+ DischargerLocation string
+ // The revocation manager that generates caveats and manages revocation.
+ RevocationManager revocation.RevocationManager
+ // The duration for which blessings will be valid. (Used iff RevocationManager is nil).
+ BlessingDuration time.Duration
+}
+
+// NewOAuthBlesserServer provides an identity.OAuthBlesserService that uses OAuth2
+// access tokens to obtain the username of a client and provide blessings with that
+// name.
+//
+// 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.
+func NewOAuthBlesserServer(p OAuthBlesserParams) identity.OAuthBlesserServerStub {
+ return identity.OAuthBlesserServer(&oauthBlesser{
+ oauthProvider: p.OAuthProvider,
+ duration: p.BlessingDuration,
+ emailClassifier: p.EmailClassifier,
+ dischargerLocation: p.DischargerLocation,
+ revocationManager: p.RevocationManager,
+ accessTokenClients: p.AccessTokenClients,
+ })
+}
+
+func (b *oauthBlesser) BlessUsingAccessToken(call rpc.ServerCall, accessToken string) (security.Blessings, string, error) {
+ var noblessings security.Blessings
+ email, clientName, err := b.oauthProvider.GetEmailAndClientName(accessToken, b.accessTokenClients)
+ if err != nil {
+ return noblessings, "", err
+ }
+ return b.bless(call, email, clientName)
+}
+
+func (b *oauthBlesser) bless(call rpc.ServerCall, email, clientName string) (security.Blessings, string, error) {
+ var noblessings security.Blessings
+ self := call.LocalPrincipal()
+ if self == nil {
+ return noblessings, "", fmt.Errorf("server error: no authentication happened")
+ }
+ var caveat security.Caveat
+ var err error
+ if b.revocationManager != nil {
+ caveat, err = b.revocationManager.NewCaveat(self.PublicKey(), b.dischargerLocation)
+ } else {
+ caveat, err = security.ExpiryCaveat(time.Now().Add(b.duration))
+ }
+ if err != nil {
+ return noblessings, "", err
+ }
+ extension := strings.Join([]string{
+ b.emailClassifier.Classify(email),
+ email,
+ // Append clientName (e.g., "android", "chrome") to the email and then bless under that.
+ // Since blessings issued by this process do not have many caveats on them and typically
+ // have a large expiry duration, we include the clientName in the extension so that
+ // servers can explicitly distinguish these clients while specifying authorization policies
+ // (say, via AccessLists).
+ clientName,
+ }, security.ChainSeparator)
+ blessing, err := self.Bless(call.RemoteBlessings().PublicKey(), call.LocalBlessings(), extension, caveat)
+ if err != nil {
+ return noblessings, "", err
+ }
+ return blessing, extension, nil
+}
diff --git a/services/identity/internal/blesser/oauth_test.go b/services/identity/internal/blesser/oauth_test.go
new file mode 100644
index 0000000..d00a15c
--- /dev/null
+++ b/services/identity/internal/blesser/oauth_test.go
@@ -0,0 +1,56 @@
+package blesser
+
+import (
+ "reflect"
+ "testing"
+ "time"
+
+ "v.io/x/ref/services/identity/internal/oauth"
+
+ "v.io/v23/security"
+)
+
+func TestOAuthBlesser(t *testing.T) {
+ var (
+ provider, user = newPrincipal(), newPrincipal()
+ context = &serverCall{
+ p: provider,
+ local: blessSelf(provider, "provider"),
+ remote: blessSelf(user, "self-signed-user"),
+ }
+ )
+ blesser := NewOAuthBlesserServer(OAuthBlesserParams{
+ OAuthProvider: oauth.NewMockOAuth(),
+ BlessingDuration: time.Hour,
+ })
+
+ b, extension, err := blesser.BlessUsingAccessToken(context, "test-access-token")
+ if err != nil {
+ t.Errorf("BlessUsingAccessToken failed: %v", err)
+ }
+
+ wantExtension := "users" + security.ChainSeparator + oauth.MockEmail + security.ChainSeparator + oauth.MockClient
+ if extension != wantExtension {
+ t.Errorf("got extension: %s, want: %s", extension, wantExtension)
+ }
+
+ if !reflect.DeepEqual(b.PublicKey(), user.PublicKey()) {
+ t.Errorf("Received blessing for public key %v. Client:%v, Blesser:%v", b.PublicKey(), user.PublicKey(), provider.PublicKey())
+ }
+
+ // When the user does not recognize the provider, it should not see any strings for
+ // the client's blessings.
+ if got := user.BlessingsInfo(b); got != nil {
+ t.Errorf("Got blessing with info %v, want nil", got)
+ }
+ // But once it recognizes the provider, it should see exactly the name
+ // "provider/testemail@google.com/test-client".
+ user.AddToRoots(b)
+ binfo := user.BlessingsInfo(b)
+ if num := len(binfo); num != 1 {
+ t.Errorf("Got blessings with %d names, want exactly one name", num)
+ }
+ if _, ok := binfo["provider"+security.ChainSeparator+wantExtension]; !ok {
+ t.Errorf("BlessingsInfo %v does not have name %s", binfo, wantExtension)
+ }
+}
diff --git a/services/identity/internal/blesser/util_test.go b/services/identity/internal/blesser/util_test.go
new file mode 100644
index 0000000..58ad919
--- /dev/null
+++ b/services/identity/internal/blesser/util_test.go
@@ -0,0 +1,43 @@
+package blesser
+
+import (
+ vsecurity "v.io/x/ref/security"
+
+ "v.io/v23/rpc"
+ "v.io/v23/security"
+)
+
+type serverCall struct {
+ rpc.StreamServerCall
+ method string
+ p security.Principal
+ local, remote security.Blessings
+}
+
+func (c *serverCall) Method() string { return c.method }
+func (c *serverCall) LocalPrincipal() security.Principal { return c.p }
+func (c *serverCall) LocalBlessings() security.Blessings { return c.local }
+func (c *serverCall) RemoteBlessings() security.Blessings { return c.remote }
+
+func newPrincipal() security.Principal {
+ p, err := vsecurity.NewPrincipal()
+ if err != nil {
+ panic(err)
+ }
+ return p
+}
+
+func blessSelf(p security.Principal, name string) security.Blessings {
+ b, err := p.BlessSelf(name)
+ if err != nil {
+ panic(err)
+ }
+ return b
+}
+
+func newCaveat(c security.Caveat, err error) security.Caveat {
+ if err != nil {
+ panic(err)
+ }
+ return c
+}
diff --git a/services/identity/internal/caveats/browser_caveat_selector.go b/services/identity/internal/caveats/browser_caveat_selector.go
new file mode 100644
index 0000000..cb2ab4f
--- /dev/null
+++ b/services/identity/internal/caveats/browser_caveat_selector.go
@@ -0,0 +1,226 @@
+package caveats
+
+import (
+ "fmt"
+ "html/template"
+ "net/http"
+ "strings"
+ "time"
+)
+
+type browserCaveatSelector struct{}
+
+// NewBrowserCaveatSelector returns a caveat selector that renders a form in the
+// to accept user caveat selections.
+func NewBrowserCaveatSelector() CaveatSelector {
+ return &browserCaveatSelector{}
+}
+
+func (s *browserCaveatSelector) Render(blessingExtension, state, redirectURL string, w http.ResponseWriter, r *http.Request) error {
+ tmplargs := struct {
+ Extension string
+ CaveatList []string
+ Macaroon, MacaroonURL string
+ }{blessingExtension, []string{"ExpiryCaveat", "MethodCaveat"}, state, redirectURL}
+ w.Header().Set("Context-Type", "text/html")
+ if err := tmplSelectCaveats.Execute(w, tmplargs); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *browserCaveatSelector) ParseSelections(r *http.Request) (caveats []CaveatInfo, state string, additionalExtension string, err error) {
+ if caveats, err = s.caveats(r); err != nil {
+ return
+ }
+ state = r.FormValue("macaroon")
+ additionalExtension = r.FormValue("blessingExtension")
+ return
+}
+
+func (s *browserCaveatSelector) caveats(r *http.Request) ([]CaveatInfo, error) {
+ if err := r.ParseForm(); err != nil {
+ return nil, err
+ }
+ var caveats []CaveatInfo
+ // Fill in the required caveat.
+ switch required := r.FormValue("requiredCaveat"); required {
+ case "Expiry":
+ expiry, err := newExpiryCaveatInfo(r.FormValue("expiry"), r.FormValue("timezoneOffset"))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create ExpiryCaveat: %v", err)
+ }
+ caveats = append(caveats, expiry)
+ case "Revocation":
+ revocation := newRevocationCaveatInfo()
+ caveats = append(caveats, revocation)
+ default:
+ return nil, fmt.Errorf("%q is not a valid required caveat", required)
+ }
+ if len(caveats) != 1 {
+ return nil, fmt.Errorf("server does not allow for un-restricted blessings")
+ }
+
+ // And find any additional ones
+ for i, cavName := range r.Form["caveat"] {
+ var err error
+ var caveat CaveatInfo
+ switch cavName {
+ case "ExpiryCaveat":
+ if caveat, err = newExpiryCaveatInfo(r.Form[cavName][i], r.FormValue("timezoneOffset")); err != nil {
+ return nil, fmt.Errorf("unable to create caveat %s: %v", cavName, err)
+ }
+ case "MethodCaveat":
+ if caveat, err = newMethodCaveatInfo(strings.Split(r.Form[cavName][i], ",")); err != nil {
+ return nil, fmt.Errorf("unable to create caveat %s: %v", cavName, err)
+ }
+ case "none":
+ continue
+ default:
+ return nil, fmt.Errorf("unable to create caveat %s: caveat does not exist", cavName)
+ }
+ caveats = append(caveats, caveat)
+ }
+ return caveats, nil
+}
+
+func newExpiryCaveatInfo(timestamp, utcOffset string) (CaveatInfo, error) {
+ var empty CaveatInfo
+ t, err := time.Parse("2006-01-02T15:04", timestamp)
+ if err != nil {
+ return empty, fmt.Errorf("parseTime failed: %v", err)
+ }
+ // utcOffset is returned as minutes from JS, so we need to parse it to a duration.
+ offset, err := time.ParseDuration(utcOffset + "m")
+ if err != nil {
+ return empty, fmt.Errorf("failed to parse duration: %v", err)
+ }
+ return CaveatInfo{"Expiry", []interface{}{t.Add(offset)}}, nil
+}
+
+func newMethodCaveatInfo(methods []string) (CaveatInfo, error) {
+ if len(methods) < 1 {
+ return CaveatInfo{}, fmt.Errorf("must pass at least one method")
+ }
+ var ifaces []interface{}
+ for _, m := range methods {
+ ifaces = append(ifaces, m)
+ }
+ return CaveatInfo{"Method", ifaces}, nil
+}
+
+func newRevocationCaveatInfo() CaveatInfo {
+ return CaveatInfo{Type: "Revocation"}
+}
+
+var tmplSelectCaveats = template.Must(template.New("bless").Parse(`<!doctype html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Blessings: Select caveats</title>
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css">
+<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/css/toastr.min.css">
+<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.7.0/moment.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
+<script src="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/js/toastr.min.js"></script>
+<script>
+ // TODO(suharshs): Move this and other JS/CSS to an assets directory in identity server.
+ $(document).ready(function() {
+ $('.caveatInput').hide(); // Hide all the inputs at start.
+
+ // When a caveat selector changes show the corresponding input box.
+ $('body').on('change', '.caveats', function (){
+ // Grab the div encapsulating the select and the corresponding inputs.
+ var caveatSelector = $(this).parents(".caveatRow");
+ // Hide the visible inputs and show the selected one.
+ caveatSelector.find('.caveatInput').hide();
+ caveatSelector.find('#'+$(this).val()).show();
+ });
+
+ // Upon clicking the '+' button a new caveat selector should appear.
+ $('body').on('click', '.addCaveat', function() {
+ var selector = $(this).parents(".caveatRow");
+ var newSelector = selector.clone();
+ // Hide all inputs since nothing is selected in this clone.
+ newSelector.find('.caveatInput').hide();
+ selector.after(newSelector);
+ // Change the '+' button to a '-' button.
+ $(this).replaceWith('<button type="button" class="btn btn-danger btn-sm removeCaveat">-</button>')
+ });
+
+ // Upon clicking the '-' button caveats should be removed.
+ $('body').on('click', '.removeCaveat', function() {
+ $(this).parents('.caveatRow').remove();
+ });
+
+ // Get the timezoneOffset for the server to create a correct expiry caveat.
+ // The offset is the minutes between UTC and local time.
+ var d = new Date();
+ $('#timezoneOffset').val(d.getTimezoneOffset());
+
+ // Set the datetime picker to have a default value of one day from now.
+ var m = moment().add(1, 'd').format("YYYY-MM-DDTHH:MM")
+ $('#expiry').val(m);
+ $('#ExpiryCaveat').val(m);
+ });
+</script>
+</head>
+<body class="container">
+<form class="form-horizontal" method="POST" id="caveats-form" name="input" action="{{.MacaroonURL}}" role="form">
+<h2 class="form-signin-heading">{{.Extension}}</h2>
+<input type="text" class="hidden" name="macaroon" value="{{.Macaroon}}">
+<div class="form-group form-group-lg">
+ <label class="col-sm-2" for="blessing-extension">Extension</label>
+ <div class="col-sm-10">
+ <input name="blessingExtension" type="text" class="form-control" id="blessing-extension" placeholder="(optional) name of the device/application for which the blessing is being sought, e.g. homelaptop">
+ <input type="text" class="hidden" id="timezoneOffset" name="timezoneOffset">
+ </div>
+</div>
+<div class="form-group form-group-lg">
+ <label class="col-sm-2" for="required-caveat">Expiration</label>
+ <div class="col-sm-10" class="input-group" name="required-caveat">
+ <div class="radio">
+ <label>
+ <input type="radio" name="requiredCaveat" id="requiredCaveat" value="Revocation" checked>
+ When explicitly revoked
+ </label>
+ </div>
+ <div class="radio">
+ <div class="input-group">
+ <input type="radio" name="requiredCaveat" id="requiredCaveat" value="Expiry">
+ <input type="datetime-local" id="expiry" name="expiry">
+ </div>
+ </div>
+ </div>
+</div>
+<h4 class="form-signin-heading">Additional caveats</h4>
+<span class="help-text">Optional additional restrictions on the use of the blessing</span>
+<div class="caveatRow row">
+ <div class="col-md-4">
+ <select name="caveat" class="form-control caveats">
+ <option value="none" selected="selected">Select a caveat.</option>
+ {{ $caveatList := .CaveatList }}
+ {{range $index, $name := $caveatList}}
+ <option name="{{$name}}" value="{{$name}}">{{$name}}</option>
+ {{end}}
+ </select>
+ </div>
+ <div class="col-md-7">
+ {{range $index, $name := $caveatList}}
+ {{if eq $name "ExpiryCaveat"}}
+ <input type="datetime-local" class="form-control caveatInput" id="{{$name}}" name="{{$name}}">
+ {{else if eq $name "MethodCaveat"}}
+ <input type="text" id="{{$name}}" class="form-control caveatInput" name="{{$name}}" placeholder="comma-separated method list">
+ {{end}}
+ {{end}}
+ </div>
+ <div class="col-md-1">
+ <button type="button" class="btn btn-info btn-sm addCaveat">+</button>
+ </div>
+</div>
+<br/>
+<button class="btn btn-lg btn-primary btn-block" type="submit">Bless</button>
+</form>
+</body>
+</html>`))
diff --git a/services/identity/internal/caveats/caveat_factory.go b/services/identity/internal/caveats/caveat_factory.go
new file mode 100644
index 0000000..51bda64
--- /dev/null
+++ b/services/identity/internal/caveats/caveat_factory.go
@@ -0,0 +1,92 @@
+package caveats
+
+import (
+ "fmt"
+ "time"
+
+ "v.io/x/ref/services/identity/internal/revocation"
+
+ "v.io/v23/security"
+)
+
+type CaveatFactory interface {
+ New(caveatInfo CaveatInfo) (security.Caveat, error)
+}
+
+type CaveatInfo struct {
+ Type string
+ Args []interface{}
+}
+
+type caveatFactory map[string]func(args ...interface{}) (security.Caveat, error)
+
+func NewCaveatFactory() CaveatFactory {
+ return caveatFactory{
+ "Expiry": expiryCaveat,
+ "Method": methodCaveat,
+ "Revocation": revocationCaveat,
+ }
+}
+
+func (c caveatFactory) New(caveatInfo CaveatInfo) (security.Caveat, error) {
+ fact, exists := c[caveatInfo.Type]
+ if !exists {
+ return security.Caveat{}, fmt.Errorf("caveat %s does not exist in CaveatFactory", caveatInfo.Type)
+ }
+ return fact(caveatInfo.Args...)
+}
+
+func expiryCaveat(args ...interface{}) (security.Caveat, error) {
+ var empty security.Caveat
+ if len(args) != 1 {
+ return empty, fmt.Errorf("expiry caveat: must input exactly one time argument")
+ }
+ t, ok := args[0].(time.Time)
+ if !ok {
+ return empty, fmt.Errorf("expiry caveat: received arg of type %T, expected time.Time", args[0])
+ }
+ return security.ExpiryCaveat(t)
+}
+
+func methodCaveat(args ...interface{}) (security.Caveat, error) {
+ var empty security.Caveat
+ if len(args) < 1 {
+ return empty, fmt.Errorf("method caveat requires at least one argument")
+ }
+ methods, err := interfacesToStrings(args)
+ if err != nil {
+ return empty, fmt.Errorf("method caveat: %v", err)
+ }
+ return security.MethodCaveat(methods[0], methods[1:]...)
+}
+
+func interfacesToStrings(args []interface{}) (s []string, err error) {
+ for _, arg := range args {
+ a, ok := arg.(string)
+ if !ok {
+ return nil, fmt.Errorf("received arg of type %T, expected string", arg)
+ }
+ s = append(s, a)
+ }
+ return s, nil
+}
+
+func revocationCaveat(args ...interface{}) (security.Caveat, error) {
+ var empty security.Caveat
+ if len(args) != 3 {
+ return empty, fmt.Errorf("revocation caveat: must input a revocation manager, publickey, and discharge location")
+ }
+ revocationManager, ok := args[0].(revocation.RevocationManager)
+ if !ok {
+ return empty, fmt.Errorf("revocation caveat: received args of type %T, expected revocation.RevocationManager", args[0])
+ }
+ publicKey, ok := args[1].(security.PublicKey)
+ if !ok {
+ return empty, fmt.Errorf("revocation caveat: received args of type %T, expected security.PublicKey", args[1])
+ }
+ dischargerLocation, ok := args[2].(string)
+ if !ok {
+ return empty, fmt.Errorf("revocation caveat: received args of type %T, expected string", args[2])
+ }
+ return revocationManager.NewCaveat(publicKey, dischargerLocation)
+}
diff --git a/services/identity/internal/caveats/caveat_selector.go b/services/identity/internal/caveats/caveat_selector.go
new file mode 100644
index 0000000..8876a55
--- /dev/null
+++ b/services/identity/internal/caveats/caveat_selector.go
@@ -0,0 +1,19 @@
+package caveats
+
+import (
+ "net/http"
+)
+
+// CaveatSelector is used to render a web page where the user can select caveats
+// to be added to a blessing being granted
+type CaveatSelector interface {
+ // Render renders the caveat input form. When the user has completed inputing caveats,
+ // Render should redirect to the specified redirect route.
+ // blessingExtension is the extension used for the blessings that is being caveated.
+ // state is any state passed by the caller (e.g., for CSRF mitigation) and is returned by ParseSelections.
+ // redirectRoute is the route to be returned to.
+ Render(blessingExtension, state, redirectURL string, w http.ResponseWriter, r *http.Request) error
+ // ParseSelections parse the users choices of Caveats, and returns the information needed to create them,
+ // the state passed to Render, and any additionalExtension selected by the user to further extend the blessing.
+ ParseSelections(r *http.Request) (caveats []CaveatInfo, state string, additionalExtension string, err error)
+}
diff --git a/services/identity/internal/caveats/mock_caveat_selector.go b/services/identity/internal/caveats/mock_caveat_selector.go
new file mode 100644
index 0000000..6c20637
--- /dev/null
+++ b/services/identity/internal/caveats/mock_caveat_selector.go
@@ -0,0 +1,35 @@
+package caveats
+
+import (
+ "net/http"
+ "time"
+)
+
+type mockCaveatSelector struct {
+ state string
+}
+
+// NewMockCaveatSelector returns a CaveatSelector that always returns a default set
+// of caveats: [exprity caveat with a 1h expiry, revocation caveat, and a method caveat
+// for methods "methodA" and "methodB"] and the additional extension: "test-extension"
+// This selector is only meant to be used during testing.
+func NewMockCaveatSelector() CaveatSelector {
+ return &mockCaveatSelector{}
+}
+
+func (s *mockCaveatSelector) Render(_, state, redirectURL string, w http.ResponseWriter, r *http.Request) error {
+ s.state = state
+ http.Redirect(w, r, redirectURL, http.StatusFound)
+ return nil
+}
+
+func (s *mockCaveatSelector) ParseSelections(r *http.Request) (caveats []CaveatInfo, state string, additionalExtension string, err error) {
+ caveats = []CaveatInfo{
+ CaveatInfo{"Revocation", []interface{}{}},
+ CaveatInfo{"Expiry", []interface{}{time.Now().Add(time.Hour)}},
+ CaveatInfo{"Method", []interface{}{"methodA", "methodB"}},
+ }
+ state = s.state
+ additionalExtension = "test-extension"
+ return
+}
diff --git a/services/identity/internal/handlers/blessing_root.go b/services/identity/internal/handlers/blessing_root.go
new file mode 100644
index 0000000..1e2753c
--- /dev/null
+++ b/services/identity/internal/handlers/blessing_root.go
@@ -0,0 +1,100 @@
+package handlers
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+
+ "v.io/v23/security"
+ "v.io/v23/vom"
+ "v.io/x/ref/services/identity/internal/util"
+)
+
+// BlessingRoot is an http.Handler implementation that renders the server's
+// blessing names and public key in a json string.
+type BlessingRoot struct {
+ P security.Principal
+}
+
+// Cached response so we don't have to bless and encode every time somebody
+// hits this route.
+var cachedResponseJson []byte
+
+func (b BlessingRoot) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if cachedResponseJson != nil {
+ respondJson(w, cachedResponseJson)
+ return
+ }
+
+ // Get the blessing names of the local principal.
+ var names []string
+ for n, _ := range b.P.BlessingsInfo(b.P.BlessingStore().Default()) {
+ names = append(names, n)
+ }
+ if len(names) == 0 {
+ util.HTTPServerError(w, fmt.Errorf("Could not get default blessing name"))
+ return
+ }
+
+ // TODO(nlacasse,ashankar,ataly): The following line is a HACK. It
+ // marshals the public key of the *root* of the blessing chain, rather
+ // than the public key of the principal itself.
+ //
+ // We do this because the identity server is expected to be
+ // self-signed, and the javascript tests were breaking when the
+ // identity server is run with a blessing like test/child.
+ //
+ // Once this issue is resolved, delete the following line and uncomment
+ // the block below it.
+ der, err := rootPublicKey(b.P.BlessingStore().Default())
+ if err != nil {
+ util.HTTPServerError(w, err)
+ return
+ }
+ //der, err := b.P.PublicKey().MarshalBinary()
+ //if err != nil {
+ // util.HTTPServerError(w, err)
+ // return
+ //}
+ str := base64.URLEncoding.EncodeToString(der)
+
+ // TODO(suharshs): Ideally this struct would be BlessingRootResponse but vdl does
+ // not currently allow field annotations. Once those are allowed, then use that
+ // here.
+ rootInfo := struct {
+ Names []string `json:"names"`
+ PublicKey string `json:"publicKey"`
+ }{
+ Names: names,
+ PublicKey: str,
+ }
+
+ res, err := json.Marshal(rootInfo)
+ if err != nil {
+ util.HTTPServerError(w, err)
+ return
+ }
+
+ cachedResponseJson = res
+ respondJson(w, res)
+}
+
+func respondJson(w http.ResponseWriter, res []byte) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write(res)
+}
+
+// Circuitious route to obtain the certificate chain because the use
+// of security.MarshalBlessings is discouraged.
+func rootPublicKey(b security.Blessings) ([]byte, error) {
+ data, err := vom.Encode(b)
+ if err != nil {
+ return nil, fmt.Errorf("malformed Blessings: %v", err)
+ }
+ var wire security.WireBlessings
+ if err := vom.Decode(data, &wire); err != nil {
+ return nil, fmt.Errorf("malformed WireBlessings: %v", err)
+ }
+ return wire.CertificateChains[0][0].PublicKey, nil
+}
diff --git a/services/identity/internal/handlers/handlers_test.go b/services/identity/internal/handlers/handlers_test.go
new file mode 100644
index 0000000..64c5596
--- /dev/null
+++ b/services/identity/internal/handlers/handlers_test.go
@@ -0,0 +1,53 @@
+package handlers
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "sort"
+ "testing"
+
+ "v.io/v23/security"
+
+ "v.io/x/ref/services/identity"
+ tsecurity "v.io/x/ref/test/security"
+)
+
+func TestBlessingRoot(t *testing.T) {
+ blessingNames := []string{"test-blessing-name-1", "test-blessing-name-2"}
+ p := tsecurity.NewPrincipal(blessingNames...)
+
+ ts := httptest.NewServer(BlessingRoot{p})
+ defer ts.Close()
+ response, err := http.Get(ts.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dec := json.NewDecoder(response.Body)
+ var res identity.BlessingRootResponse
+ if err := dec.Decode(&res); err != nil {
+ t.Fatal(err)
+ }
+
+ // Check that the names are correct.
+ sort.Strings(blessingNames)
+ sort.Strings(res.Names)
+ if !reflect.DeepEqual(res.Names, blessingNames) {
+ t.Errorf("Response has incorrect name. Got %v, want %v", res.Names, blessingNames)
+ }
+
+ // Check that the public key is correct.
+ gotMarshalled, err := base64.URLEncoding.DecodeString(res.PublicKey)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := security.UnmarshalPublicKey(gotMarshalled)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := p.PublicKey(); !reflect.DeepEqual(got, want) {
+ t.Errorf("Response has incorrect public key. Got %v, want %v", got, want)
+ }
+}
diff --git a/services/identity/internal/oauth/googleoauth.go b/services/identity/internal/oauth/googleoauth.go
new file mode 100644
index 0000000..a543d5e
--- /dev/null
+++ b/services/identity/internal/oauth/googleoauth.go
@@ -0,0 +1,193 @@
+package oauth
+
+import (
+ "encoding/json"
+ "fmt"
+ "golang.org/x/oauth2"
+ "net/http"
+ "os"
+
+ "v.io/x/lib/vlog"
+)
+
+// googleOAuth implements the OAuthProvider interface with google oauth 2.0.
+type googleOAuth struct {
+ // client_id and client_secret registered with the Google Developer
+ // Console for API access.
+ clientID, clientSecret string
+ scope, authURL, tokenURL string
+ // URL used to verify google tokens.
+ // (From https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
+ // and https://developers.google.com/accounts/docs/OAuth2UserAgent#validatetoken)
+ verifyURL string
+}
+
+func NewGoogleOAuth(configFile string) (OAuthProvider, error) {
+ clientID, clientSecret, err := getOAuthClientIDAndSecret(configFile)
+ if err != nil {
+ return nil, err
+ }
+ return &googleOAuth{
+ clientID: clientID,
+ clientSecret: clientSecret,
+ scope: "email",
+ authURL: "https://accounts.google.com/o/oauth2/auth",
+ tokenURL: "https://accounts.google.com/o/oauth2/token",
+ verifyURL: "https://www.googleapis.com/oauth2/v1/tokeninfo?",
+ }, nil
+}
+
+func (g *googleOAuth) AuthURL(redirectUrl, state string) string {
+ return g.oauthConfig(redirectUrl).AuthCodeURL(state)
+}
+
+// ExchangeAuthCodeForEmail exchanges the authorization code (which must
+// have been obtained with scope=email) for an OAuth token and then uses Google's
+// tokeninfo API to extract the email address from that token.
+func (g *googleOAuth) ExchangeAuthCodeForEmail(authcode string, url string) (string, error) {
+ config := g.oauthConfig(url)
+ t, err := config.Exchange(oauth2.NoContext, authcode)
+ if err != nil {
+ return "", fmt.Errorf("failed to exchange authorization code for token: %v", err)
+ }
+
+ if !t.Valid() {
+ return "", fmt.Errorf("oauth2 token invalid")
+ }
+ // Ideally, would validate the token ourselves without an HTTP roundtrip.
+ // However, for now, as per:
+ // https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
+ // pay an HTTP round-trip to have Google do this.
+ idToken, ok := t.Extra("id_token").(string)
+ if !ok {
+ return "", fmt.Errorf("no GoogleIDToken found in OAuth token")
+ }
+ // The GoogleIDToken is currently validated by sending an HTTP request to
+ // googleapis.com. This adds a round-trip and service may be denied by
+ // googleapis.com if this handler becomes a breakout success and receives tons
+ // of traffic. If either is a concern, the GoogleIDToken can be validated
+ // without an additional HTTP request.
+ // See: https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
+ tinfo, err := http.Get(g.verifyURL + "id_token=" + idToken)
+ if err != nil {
+ return "", fmt.Errorf("failed to talk to GoogleIDToken verifier (%q): %v", g.verifyURL, err)
+ }
+ if tinfo.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to verify GoogleIDToken: %s", tinfo.Status)
+ }
+ var gtoken token
+ if err := json.NewDecoder(tinfo.Body).Decode(>oken); err != nil {
+ return "", fmt.Errorf("invalid JSON response from Google's tokeninfo API: %v", err)
+ }
+ // We check both "verified_email" and "email_verified" here because the token response sometimes
+ // contains one and sometimes contains the other.
+ if !gtoken.VerifiedEmail && !gtoken.EmailVerified {
+ return "", fmt.Errorf("email not verified: %#v", gtoken)
+ }
+ if gtoken.Issuer != "accounts.google.com" {
+ return "", fmt.Errorf("invalid issuer: %v", gtoken.Issuer)
+ }
+ if gtoken.Audience != config.ClientID {
+ return "", fmt.Errorf("unexpected audience(%v) in GoogleIDToken", gtoken.Audience)
+ }
+ 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")
+ }
+ // 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)
+ if err != nil {
+ return "", "", fmt.Errorf("unable to use token: %v", err)
+ }
+ if tokeninfo.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("unable to verify access token, OAuth2 TokenInfo endpoint responded with StatusCode: %v", tokeninfo.StatusCode)
+ }
+ // tokeninfo contains a JSON-encoded struct
+ var token struct {
+ IssuedTo string `json:"issued_to"`
+ Audience string `json:"audience"`
+ UserID string `json:"user_id"`
+ Scope string `json:"scope"`
+ ExpiresIn int64 `json:"expires_in"`
+ Email string `json:"email"`
+ VerifiedEmail bool `json:"verified_email"`
+ EmailVerified bool `json:"email_verified"`
+ AccessType string `json:"access_type"`
+ }
+ 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 {
+ vlog.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
+}
+
+func (g *googleOAuth) oauthConfig(redirectUrl string) *oauth2.Config {
+ return &oauth2.Config{
+ ClientID: g.clientID,
+ ClientSecret: g.clientSecret,
+ RedirectURL: redirectUrl,
+ Scopes: []string{g.scope},
+ Endpoint: oauth2.Endpoint{
+ AuthURL: g.authURL,
+ TokenURL: g.tokenURL,
+ },
+ }
+}
+
+func getOAuthClientIDAndSecret(configFile string) (clientID, clientSecret string, err error) {
+ f, err := os.Open(configFile)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to open %q: %v", configFile, err)
+ }
+ defer f.Close()
+ clientID, clientSecret, err = ClientIDAndSecretFromJSON(f)
+ if err != nil {
+ return "", "", fmt.Errorf("failed to decode JSON in %q: %v", configFile, err)
+ }
+ return clientID, clientSecret, nil
+}
+
+// IDToken JSON message returned by Google's verification endpoint.
+//
+// This differs from the description in:
+// https://developers.google.com/accounts/docs/OAuth2Login#obtainuserinfo
+// because the Google tokeninfo endpoint
+// (https://www.googleapis.com/oauth2/v1/tokeninfo?id_token=XYZ123)
+// mentioned in:
+// https://developers.google.com/accounts/docs/OAuth2Login#validatinganidtoken
+// seems to return the following JSON message.
+type token struct {
+ Issuer string `json:"issuer"`
+ IssuedTo string `json:"issued_to"`
+ Audience string `json:"audience"`
+ UserID string `json:"user_id"`
+ ExpiresIn int64 `json:"expires_in"`
+ IssuedAt int64 `json:"issued_at"`
+ Email string `json:"email"`
+ VerifiedEmail bool `json:"verified_email"`
+ EmailVerified bool `json:"email_verified"`
+}
diff --git a/services/identity/internal/oauth/handler.go b/services/identity/internal/oauth/handler.go
new file mode 100644
index 0000000..52f9fa9
--- /dev/null
+++ b/services/identity/internal/oauth/handler.go
@@ -0,0 +1,411 @@
+// Package oauth implements an http.Handler that has two main purposes
+// listed below:
+//
+// (1) Uses OAuth to authenticate and then renders a page that
+// displays all the blessings that were provided for that Google user.
+// The client calls the /listblessings route which redirects to listblessingscallback which
+// renders the list.
+// (2) Performs the oauth flow for seeking a blessing using the principal tool
+// located at v.io/x/ref/cmd/principal.
+// The seek blessing flow works as follows:
+// (a) Client (principal tool) hits the /seekblessings route.
+// (b) /seekblessings performs oauth with a redirect to /seekblessingscallback.
+// (c) Client specifies desired caveats in the form that /seekblessingscallback displays.
+// (d) Submission of the form sends caveat information to /sendmacaroon.
+// (e) /sendmacaroon sends a macaroon with blessing information to client
+// (via a redirect to an HTTP server run by the tool).
+// (f) Client invokes bless rpc with macaroon.
+
+package oauth
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net"
+ "net/http"
+ "net/url"
+ "path"
+ "strings"
+ "time"
+
+ "v.io/v23/security"
+ "v.io/v23/vom"
+ "v.io/x/lib/vlog"
+ "v.io/x/ref/services/identity/internal/auditor"
+ "v.io/x/ref/services/identity/internal/caveats"
+ "v.io/x/ref/services/identity/internal/revocation"
+ "v.io/x/ref/services/identity/internal/util"
+)
+
+const (
+ clientIDCookie = "VeyronHTTPIdentityClientID"
+
+ ListBlessingsRoute = "listblessings"
+ listBlessingsCallbackRoute = "listblessingscallback"
+ revokeRoute = "revoke"
+ SeekBlessingsRoute = "seekblessings"
+ addCaveatsRoute = "addcaveats"
+ sendMacaroonRoute = "sendmacaroon"
+)
+
+type HandlerArgs struct {
+ // The principal to use.
+ Principal security.Principal
+ // The Key that is used for creating and verifying macaroons.
+ // This needs to be common between the handler and the MacaroonBlesser service.
+ MacaroonKey []byte
+ // URL at which the hander is installed.
+ // e.g. http://host:port/google/
+ Addr string
+ // BlessingLogReder is needed for reading audit logs.
+ BlessingLogReader auditor.BlessingLogReader
+ // The RevocationManager is used to revoke blessings granted with a revocation caveat.
+ // If nil, then revocation caveats cannot be added to blessings and an expiration caveat
+ // will be used instead.
+ RevocationManager revocation.RevocationManager
+ // The object name of the discharger service.
+ DischargerLocation string
+ // MacaroonBlessingService is the object name to which macaroons create by this HTTP
+ // handler can be exchanged for a blessing.
+ MacaroonBlessingService string
+ // EmailClassifier is used to decide the prefix used for blessing extensions.
+ // For example, if EmailClassifier.Classify("foo@bar.com") returns "guests",
+ // then the email foo@bar.com will receive the blessing "guests/foo@bar.com".
+ EmailClassifier *util.EmailClassifier
+ // OAuthProvider is used to authenticate and get a blessee email.
+ OAuthProvider OAuthProvider
+ // CaveatSelector is used to obtain caveats from the user when seeking a blessing.
+ CaveatSelector caveats.CaveatSelector
+}
+
+// BlessingMacaroon contains the data that is encoded into the macaroon for creating blessings.
+type BlessingMacaroon struct {
+ Creation time.Time
+ Caveats []security.Caveat
+ Name string
+}
+
+func redirectURL(baseURL, suffix string) string {
+ if !strings.HasSuffix(baseURL, "/") {
+ baseURL += "/"
+ }
+ return baseURL + suffix
+}
+
+// NewHandler returns an http.Handler that expects to be rooted at args.Addr
+// and can be used to authenticate with args.OAuthProvider, mint a new
+// identity and bless it with the OAuthProvider email address.
+func NewHandler(args HandlerArgs) (http.Handler, error) {
+ csrfCop, err := util.NewCSRFCop()
+ if err != nil {
+ return nil, fmt.Errorf("NewHandler failed to create csrfCop: %v", err)
+ }
+ return &handler{
+ args: args,
+ csrfCop: csrfCop,
+ }, nil
+}
+
+type handler struct {
+ args HandlerArgs
+ csrfCop *util.CSRFCop
+}
+
+func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ switch path.Base(r.URL.Path) {
+ case ListBlessingsRoute:
+ h.listBlessings(w, r)
+ case listBlessingsCallbackRoute:
+ h.listBlessingsCallback(w, r)
+ case revokeRoute:
+ h.revoke(w, r)
+ case SeekBlessingsRoute:
+ h.seekBlessings(w, r)
+ case addCaveatsRoute:
+ h.addCaveats(w, r)
+ case sendMacaroonRoute:
+ h.sendMacaroon(w, r)
+ default:
+ util.HTTPBadRequest(w, r, nil)
+ }
+}
+
+func (h *handler) listBlessings(w http.ResponseWriter, r *http.Request) {
+ csrf, err := h.csrfCop.NewToken(w, r, clientIDCookie, nil)
+ if err != nil {
+ vlog.Infof("Failed to create CSRF token[%v] for request %#v", err, r)
+ util.HTTPServerError(w, fmt.Errorf("failed to create new token: %v", err))
+ return
+ }
+ http.Redirect(w, r, h.args.OAuthProvider.AuthURL(redirectURL(h.args.Addr, listBlessingsCallbackRoute), csrf), http.StatusFound)
+}
+
+func (h *handler) listBlessingsCallback(w http.ResponseWriter, r *http.Request) {
+ if err := h.csrfCop.ValidateToken(r.FormValue("state"), r, clientIDCookie, nil); err != nil {
+ vlog.Infof("Invalid CSRF token: %v in request: %#v", err, r)
+ util.HTTPBadRequest(w, r, fmt.Errorf("Suspected request forgery: %v", err))
+ return
+ }
+ email, err := h.args.OAuthProvider.ExchangeAuthCodeForEmail(r.FormValue("code"), redirectURL(h.args.Addr, listBlessingsCallbackRoute))
+ if err != nil {
+ util.HTTPBadRequest(w, r, err)
+ return
+ }
+
+ type tmplentry struct {
+ Timestamp time.Time
+ Caveats []security.Caveat
+ RevocationTime time.Time
+ Blessed security.Blessings
+ Token string
+ Error error
+ }
+ tmplargs := struct {
+ Log chan tmplentry
+ Email, RevokeRoute string
+ }{
+ Log: make(chan tmplentry),
+ Email: email,
+ RevokeRoute: revokeRoute,
+ }
+ entrych := h.args.BlessingLogReader.Read(email)
+
+ w.Header().Set("Context-Type", "text/html")
+ // This MaybeSetCookie call is needed to ensure that a cookie is created. Since the
+ // header cannot be changed once the body is written to, this needs to be called first.
+ if _, err = h.csrfCop.MaybeSetCookie(w, r, clientIDCookie); err != nil {
+ vlog.Infof("Failed to set CSRF cookie[%v] for request %#v", err, r)
+ util.HTTPServerError(w, err)
+ return
+ }
+ go func(ch chan tmplentry) {
+ defer close(ch)
+ for entry := range entrych {
+ tmplEntry := tmplentry{
+ Error: entry.DecodeError,
+ Timestamp: entry.Timestamp,
+ Caveats: entry.Caveats,
+ Blessed: entry.Blessings,
+ }
+ if len(entry.RevocationCaveatID) > 0 && h.args.RevocationManager != nil {
+ if revocationTime := h.args.RevocationManager.GetRevocationTime(entry.RevocationCaveatID); revocationTime != nil {
+ tmplEntry.RevocationTime = *revocationTime
+ } else {
+ caveatID := base64.URLEncoding.EncodeToString([]byte(entry.RevocationCaveatID))
+ if tmplEntry.Token, err = h.csrfCop.NewToken(w, r, clientIDCookie, caveatID); err != nil {
+ vlog.Errorf("Failed to create CSRF token[%v] for request %#v", err, r)
+ tmplEntry.Error = fmt.Errorf("server error: unable to create revocation token")
+ }
+ }
+ }
+ ch <- tmplEntry
+ }
+ }(tmplargs.Log)
+ if err := tmplViewBlessings.Execute(w, tmplargs); err != nil {
+ vlog.Errorf("Unable to execute audit page template: %v", err)
+ util.HTTPServerError(w, err)
+ }
+}
+
+func (h *handler) revoke(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ const (
+ success = `{"success": "true"}`
+ failure = `{"success": "false"}`
+ )
+ if h.args.RevocationManager == nil {
+ vlog.Infof("no provided revocation manager")
+ w.Write([]byte(failure))
+ return
+ }
+
+ content, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ vlog.Infof("Failed to parse request: %s", err)
+ w.Write([]byte(failure))
+ return
+ }
+ var requestParams struct {
+ Token string
+ }
+ if err := json.Unmarshal(content, &requestParams); err != nil {
+ vlog.Infof("json.Unmarshal failed : %s", err)
+ w.Write([]byte(failure))
+ return
+ }
+
+ var caveatID string
+ if caveatID, err = h.validateRevocationToken(requestParams.Token, r); err != nil {
+ vlog.Infof("failed to validate token for caveat: %s", err)
+ w.Write([]byte(failure))
+ return
+ }
+ if err := h.args.RevocationManager.Revoke(caveatID); err != nil {
+ vlog.Infof("Revocation failed: %s", err)
+ w.Write([]byte(failure))
+ return
+ }
+
+ w.Write([]byte(success))
+ return
+}
+
+func (h *handler) validateRevocationToken(Token string, r *http.Request) (string, error) {
+ var encCaveatID string
+ if err := h.csrfCop.ValidateToken(Token, r, clientIDCookie, &encCaveatID); err != nil {
+ return "", fmt.Errorf("invalid CSRF token: %v in request: %#v", err, r)
+ }
+ caveatID, err := base64.URLEncoding.DecodeString(encCaveatID)
+ if err != nil {
+ return "", fmt.Errorf("decode caveatID failed: %v", err)
+ }
+ return string(caveatID), nil
+}
+
+type seekBlessingsMacaroon struct {
+ RedirectURL, State string
+}
+
+func validLoopbackURL(u string) (*url.URL, error) {
+ netURL, err := url.Parse(u)
+ if err != nil {
+ return nil, fmt.Errorf("invalid url: %v", err)
+ }
+ // Remove the port from the netURL.Host.
+ host, _, err := net.SplitHostPort(netURL.Host)
+ // Check if its localhost or loopback ip
+ if host == "localhost" {
+ return netURL, nil
+ }
+ urlIP := net.ParseIP(host)
+ if urlIP.IsLoopback() {
+ return netURL, nil
+ }
+ return nil, fmt.Errorf("invalid loopback url")
+}
+
+func (h *handler) seekBlessings(w http.ResponseWriter, r *http.Request) {
+ redirect := r.FormValue("redirect_url")
+ if _, err := validLoopbackURL(redirect); err != nil {
+ vlog.Infof("seekBlessings failed: invalid redirect_url: %v", err)
+ util.HTTPBadRequest(w, r, fmt.Errorf("invalid redirect_url: %v", err))
+ return
+ }
+ outputMacaroon, err := h.csrfCop.NewToken(w, r, clientIDCookie, seekBlessingsMacaroon{
+ RedirectURL: redirect,
+ State: r.FormValue("state"),
+ })
+ if err != nil {
+ vlog.Infof("Failed to create CSRF token[%v] for request %#v", err, r)
+ util.HTTPServerError(w, fmt.Errorf("failed to create new token: %v", err))
+ return
+ }
+ http.Redirect(w, r, h.args.OAuthProvider.AuthURL(redirectURL(h.args.Addr, addCaveatsRoute), outputMacaroon), http.StatusFound)
+}
+
+type addCaveatsMacaroon struct {
+ ToolRedirectURL, ToolState, Email string
+}
+
+func (h *handler) addCaveats(w http.ResponseWriter, r *http.Request) {
+ var inputMacaroon seekBlessingsMacaroon
+ if err := h.csrfCop.ValidateToken(r.FormValue("state"), r, clientIDCookie, &inputMacaroon); err != nil {
+ util.HTTPBadRequest(w, r, fmt.Errorf("Suspected request forgery: %v", err))
+ return
+ }
+ email, err := h.args.OAuthProvider.ExchangeAuthCodeForEmail(r.FormValue("code"), redirectURL(h.args.Addr, addCaveatsRoute))
+ if err != nil {
+ util.HTTPBadRequest(w, r, err)
+ return
+ }
+ outputMacaroon, err := h.csrfCop.NewToken(w, r, clientIDCookie, addCaveatsMacaroon{
+ ToolRedirectURL: inputMacaroon.RedirectURL,
+ ToolState: inputMacaroon.State,
+ Email: email,
+ })
+ if err != nil {
+ vlog.Infof("Failed to create caveatForm token[%v] for request %#v", err, r)
+ util.HTTPServerError(w, fmt.Errorf("failed to create new token: %v", err))
+ return
+ }
+ if err := h.args.CaveatSelector.Render(email, outputMacaroon, redirectURL(h.args.Addr, sendMacaroonRoute), w, r); err != nil {
+ vlog.Errorf("Unable to invoke render caveat selector: %v", err)
+ util.HTTPServerError(w, err)
+ }
+}
+
+func (h *handler) sendMacaroon(w http.ResponseWriter, r *http.Request) {
+ var inputMacaroon addCaveatsMacaroon
+ caveatInfos, macaroonString, blessingExtension, err := h.args.CaveatSelector.ParseSelections(r)
+ if err != nil {
+ util.HTTPBadRequest(w, r, fmt.Errorf("failed to parse blessing information: %v", err))
+ return
+ }
+ if err := h.csrfCop.ValidateToken(macaroonString, r, clientIDCookie, &inputMacaroon); err != nil {
+ util.HTTPBadRequest(w, r, fmt.Errorf("suspected request forgery: %v", err))
+ return
+ }
+
+ caveats, err := h.caveats(caveatInfos)
+ if err != nil {
+ util.HTTPBadRequest(w, r, fmt.Errorf("failed to create caveats: %v", err))
+ return
+ }
+ parts := []string{
+ h.args.EmailClassifier.Classify(inputMacaroon.Email),
+ inputMacaroon.Email,
+ }
+ if len(blessingExtension) > 0 {
+ parts = append(parts, blessingExtension)
+ }
+ if len(caveats) == 0 {
+ util.HTTPBadRequest(w, r, fmt.Errorf("server disallows attempts to bless with no caveats"))
+ return
+ }
+ m := BlessingMacaroon{
+ Creation: time.Now(),
+ Caveats: caveats,
+ Name: strings.Join(parts, security.ChainSeparator),
+ }
+ macBytes, err := vom.Encode(m)
+ if err != nil {
+ util.HTTPServerError(w, fmt.Errorf("failed to encode BlessingsMacaroon: %v", err))
+ return
+ }
+ // Construct the url to send back to the tool.
+ baseURL, err := validLoopbackURL(inputMacaroon.ToolRedirectURL)
+ if err != nil {
+ util.HTTPBadRequest(w, r, fmt.Errorf("invalid ToolRedirectURL: %v", err))
+ return
+ }
+ marshalKey, err := h.args.Principal.PublicKey().MarshalBinary()
+ if err != nil {
+ util.HTTPServerError(w, fmt.Errorf("failed to marshal public key: %v", err))
+ return
+ }
+ encKey := base64.URLEncoding.EncodeToString(marshalKey)
+ params := url.Values{}
+ params.Add("macaroon", string(util.NewMacaroon(h.args.MacaroonKey, macBytes)))
+ params.Add("state", inputMacaroon.ToolState)
+ params.Add("object_name", h.args.MacaroonBlessingService)
+ params.Add("root_key", encKey)
+ baseURL.RawQuery = params.Encode()
+ http.Redirect(w, r, baseURL.String(), http.StatusFound)
+}
+
+func (h *handler) caveats(caveatInfos []caveats.CaveatInfo) (cavs []security.Caveat, err error) {
+ caveatFactories := caveats.NewCaveatFactory()
+ for _, caveatInfo := range caveatInfos {
+ if caveatInfo.Type == "Revocation" {
+ caveatInfo.Args = []interface{}{h.args.RevocationManager, h.args.Principal.PublicKey(), h.args.DischargerLocation}
+ }
+ cav, err := caveatFactories.New(caveatInfo)
+ if err != nil {
+ return nil, err
+ }
+ cavs = append(cavs, cav)
+ }
+ return
+}
diff --git a/services/identity/internal/oauth/mockoauth.go b/services/identity/internal/oauth/mockoauth.go
new file mode 100644
index 0000000..8c87df4
--- /dev/null
+++ b/services/identity/internal/oauth/mockoauth.go
@@ -0,0 +1,25 @@
+package oauth
+
+const (
+ MockEmail = "testemail@google.com"
+ MockClient = "test-client"
+)
+
+// mockOAuth is a mock OAuthProvider for use in tests.
+type mockOAuth struct{}
+
+func NewMockOAuth() OAuthProvider {
+ return &mockOAuth{}
+}
+
+func (m *mockOAuth) AuthURL(redirectUrl string, state string) string {
+ return redirectUrl + "?state=" + state
+}
+
+func (m *mockOAuth) ExchangeAuthCodeForEmail(string, string) (string, error) {
+ return MockEmail, nil
+}
+
+func (m *mockOAuth) GetEmailAndClientName(string, []AccessTokenClient) (string, string, error) {
+ return MockEmail, MockClient, nil
+}
diff --git a/services/identity/internal/oauth/oauth_provider.go b/services/identity/internal/oauth/oauth_provider.go
new file mode 100644
index 0000000..ac11802
--- /dev/null
+++ b/services/identity/internal/oauth/oauth_provider.go
@@ -0,0 +1,24 @@
+package oauth
+
+// AccessTokenClient represents a client of an OAuthProvider.
+type AccessTokenClient struct {
+ // Descriptive name of the client.
+ Name string
+ // OAuth Client ID.
+ ClientID string
+}
+
+// OAuthProvider authenticates users to the identity server via the OAuth2 Web Server flow.
+type OAuthProvider interface {
+ // AuthURL is the URL the user must visit in order to authenticate with the OAuthProvider.
+ // After authentication, the user will be re-directed to redirectURL with the provided state.
+ AuthURL(redirectUrl string, state string) (url string)
+ // 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)
+}
diff --git a/services/identity/internal/oauth/utils.go b/services/identity/internal/oauth/utils.go
new file mode 100644
index 0000000..0c99fbf
--- /dev/null
+++ b/services/identity/internal/oauth/utils.go
@@ -0,0 +1,65 @@
+package oauth
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+)
+
+// 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
+// API Access console for your application
+// (https://code.google.com/apis/console).
+func ClientIDFromJSON(r io.Reader) (id string, err error) {
+ var data map[string]interface{}
+ var typ string
+ if data, typ, err = decodeAccessMapFromJSON(r); err != nil {
+ return
+ }
+ var ok bool
+ if id, ok = data["client_id"].(string); !ok {
+ err = fmt.Errorf("%s.client_id not found", typ)
+ return
+ }
+ return
+}
+
+// ClientIDAndSecretFromJSON parses JSON-encoded API access information in 'r'
+// and returns the extracted ClientID and ClientSecret.
+// This JSON-encoded data is typically available as a download from the Google
+// API Access console for your application
+// (https://code.google.com/apis/console).
+func ClientIDAndSecretFromJSON(r io.Reader) (id, secret string, err error) {
+ var data map[string]interface{}
+ var typ string
+ if data, typ, err = decodeAccessMapFromJSON(r); err != nil {
+ return
+ }
+ var ok bool
+ if id, ok = data["client_id"].(string); !ok {
+ err = fmt.Errorf("%s.client_id not found", typ)
+ return
+ }
+ if secret, ok = data["client_secret"].(string); !ok {
+ err = fmt.Errorf("%s.client_secret not found", typ)
+ return
+ }
+ return
+}
+
+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 {
+ return
+ }
+ var ok bool
+ typ = "web"
+ if data, ok = full[typ].(map[string]interface{}); !ok {
+ typ = "installed"
+ if data, ok = full[typ].(map[string]interface{}); !ok {
+ err = fmt.Errorf("web or installed configuration not found")
+ }
+ }
+ return
+}
diff --git a/services/identity/internal/oauth/utils_test.go b/services/identity/internal/oauth/utils_test.go
new file mode 100644
index 0000000..2fc1704
--- /dev/null
+++ b/services/identity/internal/oauth/utils_test.go
@@ -0,0 +1,20 @@
+package oauth
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestClientIDAndSecretFromJSON(t *testing.T) {
+ json := `{"web":{"auth_uri":"https://accounts.google.com/o/oauth2/auth","client_secret":"SECRET","token_uri":"https://accounts.google.com/o/oauth2/token","client_email":"EMAIL","redirect_uris":["http://redirecturl"],"client_id":"ID","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","javascript_origins":["http://javascriptorigins"]}}`
+ id, secret, err := ClientIDAndSecretFromJSON(strings.NewReader(json))
+ if err != nil {
+ t.Error(err)
+ }
+ if id != "ID" {
+ t.Errorf("Got %q want %q", id, "ID")
+ }
+ if secret != "SECRET" {
+ t.Errorf("Got %q want %q", secret, "SECRET")
+ }
+}
diff --git a/services/identity/internal/oauth/view_blessings_template.go b/services/identity/internal/oauth/view_blessings_template.go
new file mode 100644
index 0000000..66334a7
--- /dev/null
+++ b/services/identity/internal/oauth/view_blessings_template.go
@@ -0,0 +1,118 @@
+package oauth
+
+import "html/template"
+
+var tmplViewBlessings = template.Must(template.New("auditor").Parse(`<!doctype html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Blessings for {{.Email}}</title>
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
+<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/css/toastr.min.css">
+<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.7.0/moment.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
+<script src="//ajax.googleapis.com/ajax/libs/jqueryui/1.11.0/jquery-ui.min.js"></script>
+<script src="//cdnjs.cloudflare.com/ajax/libs/toastr.js/latest/js/toastr.min.js"></script>
+<script>
+function setTimeText(elem) {
+ var timestamp = elem.data("unixtime");
+ var m = moment(timestamp*1000.0);
+ var style = elem.data("style");
+ if (style === "absolute") {
+ elem.html("<a href='#'>" + m.format("dd, MMM Do YYYY, h:mm:ss a") + "</a>");
+ elem.data("style", "fromNow");
+ } else {
+ elem.html("<a href='#'>" + m.fromNow() + "</a>");
+ elem.data("style", "absolute");
+ }
+}
+
+$(document).ready(function() {
+ $(".unixtime").each(function() {
+ // clicking the timestamp should toggle the display format.
+ $(this).click(function() { setTimeText($(this)); });
+ setTimeText($(this));
+ });
+
+ // Setup the revoke buttons click events.
+ $(".revoke").click(function() {
+ var revokeButton = $(this);
+ $.ajax({
+ url: "/google/{{.RevokeRoute}}",
+ type: "POST",
+ data: JSON.stringify({
+ "Token": revokeButton.val()
+ })
+ }).done(function(data) {
+ if (data.success == "false") {
+ failMessage(revokeButton);
+ return;
+ }
+ revokeButton.replaceWith("<div>Just Revoked!</div>");
+ }).fail(function(xhr, textStatus){
+ failMessage(revokeButton);
+ console.error('Bad request: %s', status, xhr)
+ });
+ });
+});
+
+function failMessage(revokeButton) {
+ revokeButton.parent().parent().fadeIn(function(){
+ $(this).addClass("bg-danger");
+ });
+ toastr.options.closeButton = true;
+ toastr.error('Unable to revoke identity!', 'Error!')
+}
+
+</script>
+</head>
+<body>
+<div class="container">
+<h3>Blessing log for {{.Email}}</h3>
+<table class="table table-bordered table-hover table-responsive">
+<thead>
+ <tr>
+ <th>Blessed as</th>
+ <th>Public Key</th>
+ <th>Issued</th>
+ <th>Caveats</th>
+ <th>Revoked</th>
+ </tr>
+</thead>
+<tbody>
+{{range .Log}}
+ {{if .Error}}
+ <tr class="bg-danger">
+ <td colspan="5">Failed to read audit log: Error: {{.Error}}</td>
+ </tr>
+ {{else}}
+ <tr>
+ <td>{{.Blessed}}</td>
+ <td>{{.Blessed.PublicKey}}</td>
+ <td><div class="unixtime" data-unixtime={{.Timestamp.Unix}}>{{.Timestamp.String}}</div></td>
+ <td>
+ {{range .Caveats}}
+ {{.}}</br>
+ {{end}}
+ </td>
+ <td>
+ {{ if .Token }}
+ <button class="revoke" value="{{.Token}}">Revoke</button>
+ {{ else if not .RevocationTime.IsZero }}
+ <div class="unixtime" data-unixtime={{.RevocationTime.Unix}}>{{.RevocationTime.String}}</div>
+ {{ end }}
+ </td>
+ </tr>
+ {{end}}
+{{else}}
+ <tr>
+ <td colspan=5>No blessings issued</td>
+ </tr>
+{{end}}
+</tbody>
+</table>
+<hr/>
+</div>
+</body>
+</html>`))
diff --git a/services/identity/internal/revocation/caveat.vdl b/services/identity/internal/revocation/caveat.vdl
new file mode 100644
index 0000000..a3f46f4
--- /dev/null
+++ b/services/identity/internal/revocation/caveat.vdl
@@ -0,0 +1,17 @@
+package revocation
+
+import (
+ "v.io/v23/uniqueid"
+ "v.io/v23/security"
+)
+
+// NotRevokedCaveat is used to implement revocation.
+// It validates iff the parameter is not included in a list of blacklisted
+// values.
+//
+// The third-party discharging service checks this revocation caveat against a
+// database of blacklisted (revoked) keys before issuing a discharge.
+const NotRevokedCaveat = security.CaveatDescriptor{
+ Id: uniqueid.Id{0x4b, 0x46, 0x5c, 0x56, 0x37, 0x79, 0xd1, 0x3b, 0x7b, 0xa3, 0xa7, 0xd6, 0xa5, 0x34, 0x80, 0x0},
+ ParamType: typeobject([]byte),
+}
diff --git a/services/identity/internal/revocation/caveat.vdl.go b/services/identity/internal/revocation/caveat.vdl.go
new file mode 100644
index 0000000..7c20495
--- /dev/null
+++ b/services/identity/internal/revocation/caveat.vdl.go
@@ -0,0 +1,41 @@
+// This file was auto-generated by the vanadium vdl tool.
+// Source: caveat.vdl
+
+package revocation
+
+import (
+ // VDL system imports
+ "v.io/v23/vdl"
+
+ // VDL user imports
+ "v.io/v23/security"
+ "v.io/v23/uniqueid"
+)
+
+// NotRevokedCaveat is used to implement revocation.
+// It validates iff the parameter is not included in a list of blacklisted
+// values.
+//
+// The third-party discharging service checks this revocation caveat against a
+// database of blacklisted (revoked) keys before issuing a discharge.
+var NotRevokedCaveat = security.CaveatDescriptor{
+ Id: uniqueid.Id{
+ 75,
+ 70,
+ 92,
+ 86,
+ 55,
+ 121,
+ 209,
+ 59,
+ 123,
+ 163,
+ 167,
+ 214,
+ 165,
+ 52,
+ 128,
+ 0,
+ },
+ ParamType: vdl.TypeOf([]byte(nil)),
+}
diff --git a/services/identity/internal/revocation/mock_revocation_manager.go b/services/identity/internal/revocation/mock_revocation_manager.go
new file mode 100644
index 0000000..560a553
--- /dev/null
+++ b/services/identity/internal/revocation/mock_revocation_manager.go
@@ -0,0 +1,35 @@
+package revocation
+
+import (
+ "time"
+)
+
+func NewMockRevocationManager() RevocationManager {
+ revocationDB = &mockDatabase{make(map[string][]byte), make(map[string]*time.Time)}
+ return &revocationManager{}
+}
+
+type mockDatabase struct {
+ tpCavIDToRevCavID map[string][]byte
+ revCavIDToTimestamp map[string]*time.Time
+}
+
+func (m *mockDatabase) InsertCaveat(thirdPartyCaveatID string, revocationCaveatID []byte) error {
+ m.tpCavIDToRevCavID[thirdPartyCaveatID] = revocationCaveatID
+ return nil
+}
+
+func (m *mockDatabase) Revoke(thirdPartyCaveatID string) error {
+ timestamp := time.Now()
+ m.revCavIDToTimestamp[string(m.tpCavIDToRevCavID[thirdPartyCaveatID])] = ×tamp
+ return nil
+}
+
+func (m *mockDatabase) IsRevoked(revocationCaveatID []byte) (bool, error) {
+ _, exists := m.revCavIDToTimestamp[string(revocationCaveatID)]
+ return exists, nil
+}
+
+func (m *mockDatabase) RevocationTime(thirdPartyCaveatID string) (*time.Time, error) {
+ return m.revCavIDToTimestamp[string(m.tpCavIDToRevCavID[thirdPartyCaveatID])], nil
+}
diff --git a/services/identity/internal/revocation/revocation_manager.go b/services/identity/internal/revocation/revocation_manager.go
new file mode 100644
index 0000000..a3068a8
--- /dev/null
+++ b/services/identity/internal/revocation/revocation_manager.go
@@ -0,0 +1,97 @@
+// Package revocation provides tools to create and manage revocation caveats.
+package revocation
+
+import (
+ "crypto/rand"
+ "database/sql"
+ "fmt"
+ "sync"
+ "time"
+
+ "v.io/v23/security"
+)
+
+// RevocationManager persists information for revocation caveats to provided discharges and allow for future revocations.
+type RevocationManager interface {
+ NewCaveat(discharger security.PublicKey, dischargerLocation string) (security.Caveat, error)
+ Revoke(caveatID string) error
+ GetRevocationTime(caveatID string) *time.Time
+}
+
+// revocationManager persists information for revocation caveats to provided discharges and allow for future revocations.
+type revocationManager struct{}
+
+// NewRevocationManager returns a RevocationManager that persists information about
+// revocationCaveats in a SQL database and allows for revocation and caveat creation.
+// This function can only be called once because of the use of global variables.
+func NewRevocationManager(sqlDB *sql.DB) (RevocationManager, error) {
+ revocationLock.Lock()
+ defer revocationLock.Unlock()
+ if revocationDB != nil {
+ return nil, fmt.Errorf("NewRevocationManager can only be called once")
+ }
+ var err error
+ revocationDB, err = newSQLDatabase(sqlDB, "RevocationCaveatInfo")
+ if err != nil {
+ return nil, err
+ }
+ return &revocationManager{}, nil
+}
+
+var revocationDB database
+var revocationLock sync.RWMutex
+
+// NewCaveat returns a security.Caveat constructed with a ThirdPartyCaveat for which discharges will be
+// issued iff Revoke has not been called for the returned caveat.
+func (r *revocationManager) NewCaveat(discharger security.PublicKey, dischargerLocation string) (security.Caveat, error) {
+ var empty security.Caveat
+ var revocation [16]byte
+ if _, err := rand.Read(revocation[:]); err != nil {
+ return empty, err
+ }
+ notRevoked, err := security.NewCaveat(NotRevokedCaveat, revocation[:])
+ if err != nil {
+ return empty, err
+ }
+ cav, err := security.NewPublicKeyCaveat(discharger, dischargerLocation, security.ThirdPartyRequirements{}, notRevoked)
+ if err != nil {
+ return empty, err
+ }
+ if err = revocationDB.InsertCaveat(cav.ThirdPartyDetails().ID(), revocation[:]); err != nil {
+ return empty, err
+ }
+ return cav, nil
+}
+
+// Revoke disables discharges from being issued for the provided third-party caveat.
+func (r *revocationManager) Revoke(caveatID string) error {
+ return revocationDB.Revoke(caveatID)
+}
+
+// GetRevocationTimestamp returns the timestamp at which a caveat was revoked.
+// If the caveat wasn't revoked returns nil
+func (r *revocationManager) GetRevocationTime(caveatID string) *time.Time {
+ timestamp, err := revocationDB.RevocationTime(caveatID)
+ if err != nil {
+ return nil
+ }
+ return timestamp
+}
+
+func isRevoked(_ security.Call, _ security.CallSide, key []byte) error {
+ revocationLock.RLock()
+ if revocationDB == nil {
+ revocationLock.RUnlock()
+ return fmt.Errorf("missing call to NewRevocationManager")
+ }
+ revocationLock.RUnlock()
+ revoked, err := revocationDB.IsRevoked(key)
+ if revoked {
+ return fmt.Errorf("revoked")
+ }
+ return err
+}
+
+func init() {
+ security.RegisterCaveatValidator(NotRevokedCaveat, isRevoked)
+}
diff --git a/services/identity/internal/revocation/revocation_test.go b/services/identity/internal/revocation/revocation_test.go
new file mode 100644
index 0000000..d924c8c
--- /dev/null
+++ b/services/identity/internal/revocation/revocation_test.go
@@ -0,0 +1,71 @@
+package revocation
+
+import (
+ "testing"
+
+ _ "v.io/x/ref/profiles"
+ services "v.io/x/ref/services/security"
+ "v.io/x/ref/services/security/discharger"
+ "v.io/x/ref/test"
+
+ "v.io/v23"
+ "v.io/v23/context"
+ "v.io/v23/security"
+)
+
+func revokerSetup(t *testing.T, ctx *context.T) (dischargerKey security.PublicKey, dischargerEndpoint string, revoker RevocationManager, closeFunc func()) {
+ revokerService := NewMockRevocationManager()
+ dischargerServer, err := v23.NewServer(ctx)
+ if err != nil {
+ t.Fatalf("r.NewServer: %s", err)
+ }
+ dischargerEPs, err := dischargerServer.Listen(v23.GetListenSpec(ctx))
+ if err != nil {
+ t.Fatalf("dischargerServer.Listen failed: %v", err)
+ }
+ dischargerServiceStub := services.DischargerServer(discharger.NewDischarger())
+ if err := dischargerServer.Serve("", dischargerServiceStub, nil); err != nil {
+ t.Fatalf("dischargerServer.Serve revoker: %s", err)
+ }
+ return v23.GetPrincipal(ctx).PublicKey(),
+ dischargerEPs[0].Name(),
+ revokerService,
+ func() {
+ dischargerServer.Stop()
+ }
+}
+
+func TestDischargeRevokeDischargeRevokeDischarge(t *testing.T) {
+ ctx, shutdown := test.InitForTest()
+ defer shutdown()
+
+ dcKey, dc, revoker, closeFunc := revokerSetup(t, ctx)
+ defer closeFunc()
+
+ discharger := services.DischargerClient(dc)
+ caveat, err := revoker.NewCaveat(dcKey, dc)
+ if err != nil {
+ t.Fatalf("failed to create revocation caveat: %s", err)
+ }
+ tp := caveat.ThirdPartyDetails()
+ if tp == nil {
+ t.Fatalf("failed to extract third party details from caveat %v", caveat)
+ }
+
+ var impetus security.DischargeImpetus
+ if _, err := discharger.Discharge(ctx, caveat, impetus); err != nil {
+ t.Fatalf("failed to get discharge: %s", err)
+ }
+ if err := revoker.Revoke(tp.ID()); err != nil {
+ t.Fatalf("failed to revoke: %s", err)
+ }
+ if _, err := discharger.Discharge(ctx, caveat, impetus); err == nil {
+ t.Fatalf("got a discharge for a revoked caveat: %s", err)
+ }
+ if err := revoker.Revoke(tp.ID()); err != nil {
+ t.Fatalf("failed to revoke again: %s", err)
+ }
+ if _, err := discharger.Discharge(ctx, caveat, impetus); err == nil {
+ t.Fatalf("got a discharge for a doubly revoked caveat: %s", err)
+ }
+}
diff --git a/services/identity/internal/revocation/sql_database.go b/services/identity/internal/revocation/sql_database.go
new file mode 100644
index 0000000..cfe5633
--- /dev/null
+++ b/services/identity/internal/revocation/sql_database.go
@@ -0,0 +1,80 @@
+package revocation
+
+import (
+ "database/sql"
+ "encoding/hex"
+ "fmt"
+ "time"
+)
+
+type database interface {
+ InsertCaveat(thirdPartyCaveatID string, revocationCaveatID []byte) error
+ Revoke(thirdPartyCaveatID string) error
+ IsRevoked(revocationCaveatID []byte) (bool, error)
+ RevocationTime(thirdPartyCaveatID string) (*time.Time, error)
+}
+
+// Table with 3 columns:
+// (1) ThirdPartyCaveatID= string thirdPartyCaveatID.
+// (2) RevocationCaveatID= hex encoded revcationCaveatID.
+// (3) RevocationTime= time (if any) that the Caveat was revoked.
+type sqlDatabase struct {
+ insertCaveatStmt, revokeStmt, isRevokedStmt, revocationTimeStmt *sql.Stmt
+}
+
+func (s *sqlDatabase) InsertCaveat(thirdPartyCaveatID string, revocationCaveatID []byte) error {
+ _, err := s.insertCaveatStmt.Exec(thirdPartyCaveatID, hex.EncodeToString(revocationCaveatID))
+ return err
+}
+
+func (s *sqlDatabase) Revoke(thirdPartyCaveatID string) error {
+ _, err := s.revokeStmt.Exec(time.Now(), thirdPartyCaveatID)
+ return err
+}
+
+func (s *sqlDatabase) IsRevoked(revocationCaveatID []byte) (bool, error) {
+ rows, err := s.isRevokedStmt.Query(hex.EncodeToString(revocationCaveatID))
+ if err != nil {
+ return false, err
+ }
+ return rows.Next(), nil
+}
+
+func (s *sqlDatabase) RevocationTime(thirdPartyCaveatID string) (*time.Time, error) {
+ rows, err := s.revocationTimeStmt.Query(thirdPartyCaveatID)
+ if err != nil {
+ return nil, err
+ }
+ if rows.Next() {
+ var timestamp time.Time
+ if err := rows.Scan(×tamp); err != nil {
+ return nil, err
+ }
+ return ×tamp, nil
+ }
+ return nil, fmt.Errorf("the caveat (%v) was not revoked", thirdPartyCaveatID)
+}
+
+func newSQLDatabase(db *sql.DB, table string) (database, error) {
+ createStmt, err := db.Prepare(fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s ( ThirdPartyCaveatID NVARCHAR(255), RevocationCaveatID NVARCHAR(255), RevocationTime DATETIME, PRIMARY KEY (ThirdPartyCaveatID), KEY (RevocationCaveatID) );", table))
+ if err != nil {
+ return nil, err
+ }
+ if _, err = createStmt.Exec(); err != nil {
+ return nil, err
+ }
+ insertCaveatStmt, err := db.Prepare(fmt.Sprintf("INSERT INTO %s (ThirdPartyCaveatID, RevocationCaveatID, RevocationTime) VALUES (?, ?, NULL)", table))
+ if err != nil {
+ return nil, err
+ }
+ revokeStmt, err := db.Prepare(fmt.Sprintf("UPDATE %s SET RevocationTime=? WHERE ThirdPartyCaveatID=?", table))
+ if err != nil {
+ return nil, err
+ }
+ isRevokedStmt, err := db.Prepare(fmt.Sprintf("SELECT 1 FROM %s WHERE RevocationCaveatID=? AND RevocationTime IS NOT NULL", table))
+ if err != nil {
+ return nil, err
+ }
+ revocationTimeStmt, err := db.Prepare(fmt.Sprintf("SELECT RevocationTime FROM %s WHERE ThirdPartyCaveatID=?", table))
+ return &sqlDatabase{insertCaveatStmt, revokeStmt, isRevokedStmt, revocationTimeStmt}, err
+}
diff --git a/services/identity/internal/revocation/sql_database_test.go b/services/identity/internal/revocation/sql_database_test.go
new file mode 100644
index 0000000..de9171b
--- /dev/null
+++ b/services/identity/internal/revocation/sql_database_test.go
@@ -0,0 +1,73 @@
+package revocation
+
+import (
+ "encoding/hex"
+ "github.com/DATA-DOG/go-sqlmock"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func TestSQLDatabase(t *testing.T) {
+ db, err := sqlmock.New()
+ if err != nil {
+ t.Fatalf("failed to create new mock database stub: %v", err)
+ }
+ columns := []string{"ThirdPartyCaveatID", "RevocationCaveatID", "RevocationTime"}
+ sqlmock.ExpectExec("CREATE TABLE IF NOT EXISTS tableName (.+)").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ d, err := newSQLDatabase(db, "tableName")
+ if err != nil {
+ t.Fatalf("failed to create SQLDatabase: %v", err)
+ }
+
+ tpCavID, revCavID := "tpCavID", []byte("revCavID")
+ tpCavID2, revCavID2 := "tpCavID2", []byte("revCavID2")
+ encRevCavID := hex.EncodeToString(revCavID)
+ encRevCavID2 := hex.EncodeToString(revCavID2)
+ sqlmock.ExpectExec("INSERT INTO tableName (.+) VALUES (.+)").
+ WithArgs(tpCavID, encRevCavID).
+ WillReturnResult(sqlmock.NewResult(0, 1)) // no insert id, 1 affected row
+ if err := d.InsertCaveat(tpCavID, revCavID); err != nil {
+ t.Errorf("failed to InsertCaveat into SQLDatabase: %v", err)
+ }
+
+ sqlmock.ExpectExec("INSERT INTO tableName (.+) VALUES (.+)").
+ WithArgs(tpCavID2, encRevCavID2).
+ WillReturnResult(sqlmock.NewResult(0, 1)) // no insert id, 1 affected row
+ if err := d.InsertCaveat(tpCavID2, revCavID2); err != nil {
+ t.Errorf("second InsertCaveat into SQLDatabase failed: %v", err)
+ }
+
+ // Test Revocation
+ sqlmock.ExpectExec("UPDATE tableName SET RevocationTime=.+").
+ WillReturnResult(sqlmock.NewResult(0, 1))
+ if err := d.Revoke(tpCavID); err != nil {
+ t.Errorf("failed to Revoke Caveat: %v", err)
+ }
+
+ // Test IsRevoked returns true.
+ sqlmock.ExpectQuery("SELECT 1 FROM tableName").
+ WithArgs(encRevCavID).
+ WillReturnRows(sqlmock.NewRows(columns).AddRow(1, 1, 1))
+ if revoked, err := d.IsRevoked(revCavID); err != nil || !revoked {
+ t.Errorf("expected revCavID to be revoked: err: (%v)", err)
+ }
+
+ // Test IsRevoked returns false.
+ sqlmock.ExpectQuery("SELECT 1 FROM tableName").
+ WithArgs(encRevCavID2).
+ WillReturnRows(sqlmock.NewRows(columns))
+ if revoked, err := d.IsRevoked(revCavID2); err != nil || revoked {
+ t.Errorf("expected revCavID to not be revoked: err: (%v)", err)
+ }
+
+ // Test RevocationTime.
+ revocationTime := time.Now()
+ sqlmock.ExpectQuery("SELECT RevocationTime FROM tableName").
+ WithArgs(tpCavID).
+ WillReturnRows(sqlmock.NewRows([]string{"RevocationTime"}).AddRow(revocationTime))
+ if got, err := d.RevocationTime(tpCavID); err != nil || !reflect.DeepEqual(*got, revocationTime) {
+ t.Errorf("got %v, expected %v: err : %v", got, revocationTime, err)
+ }
+}
diff --git a/services/identity/internal/server/identityd.go b/services/identity/internal/server/identityd.go
new file mode 100644
index 0000000..d6bfe6b
--- /dev/null
+++ b/services/identity/internal/server/identityd.go
@@ -0,0 +1,323 @@
+// HTTP server that uses OAuth to create security.Blessings objects.
+package server
+
+import (
+ "crypto/rand"
+ "fmt"
+ "html/template"
+ mrand "math/rand"
+ "net"
+ "net/http"
+ "reflect"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "v.io/v23"
+ "v.io/v23/context"
+ "v.io/v23/naming"
+ "v.io/v23/rpc"
+ "v.io/v23/security"
+ "v.io/v23/verror"
+ "v.io/x/lib/vlog"
+
+ "v.io/x/ref/lib/signals"
+ "v.io/x/ref/security/audit"
+ "v.io/x/ref/services/identity/internal/auditor"
+ "v.io/x/ref/services/identity/internal/blesser"
+ "v.io/x/ref/services/identity/internal/caveats"
+ "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/services/identity/internal/util"
+ services "v.io/x/ref/services/security"
+ "v.io/x/ref/services/security/discharger"
+)
+
+const (
+ // TODO(ataly, ashankar, suharshs): The name "google" for the oauthBlesserService does
+ // not seem appropriate given our modular construction of the identity server. The
+ // oauthBlesserService can use any oauthProvider of its choosing, i.e., it does not
+ // always have to be "google". One option would be change the value to "oauth". This
+ // would also make the name analogous to that of macaroonService. Note that this option
+ // also requires changing the extension.
+ oauthBlesserService = "google"
+ macaroonService = "macaroon"
+ dischargerService = "discharger"
+)
+
+type IdentityServer struct {
+ oauthProvider oauth.OAuthProvider
+ auditor audit.Auditor
+ blessingLogReader auditor.BlessingLogReader
+ revocationManager revocation.RevocationManager
+ oauthBlesserParams blesser.OAuthBlesserParams
+ caveatSelector caveats.CaveatSelector
+ emailClassifier *util.EmailClassifier
+ rootedObjectAddrs []naming.Endpoint
+}
+
+// NewIdentityServer returns a IdentityServer that:
+// - uses oauthProvider to authenticate users
+// - auditor and blessingLogReader to audit the root principal and read audit logs
+// - revocationManager to store revocation data and grant discharges
+// - oauthBlesserParams to configure the identity.OAuthBlesser service
+func NewIdentityServer(oauthProvider oauth.OAuthProvider, auditor audit.Auditor, blessingLogReader auditor.BlessingLogReader, revocationManager revocation.RevocationManager, oauthBlesserParams blesser.OAuthBlesserParams, caveatSelector caveats.CaveatSelector, emailClassifier *util.EmailClassifier) *IdentityServer {
+ return &IdentityServer{
+ oauthProvider,
+ auditor,
+ blessingLogReader,
+ revocationManager,
+ oauthBlesserParams,
+ caveatSelector,
+ emailClassifier,
+ nil,
+ }
+}
+
+// findUnusedPort finds an unused port and returns it. Of course, no guarantees
+// are made that the port will actually be available by the time the caller
+// gets around to binding to it. If no port can be found, (0, nil) is returned.
+// If an error occurs while creating a socket, that error is returned and the
+// other return value is 0.
+func findUnusedPort() (int, error) {
+ random := mrand.New(mrand.NewSource(time.Now().UnixNano()))
+ for i := 0; i < 1000; i++ {
+ fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, syscall.IPPROTO_TCP)
+ if err != nil {
+ return 0, err
+ }
+
+ port := int(1024 + random.Int31n(64512))
+ sa := &syscall.SockaddrInet4{Port: port}
+ err = syscall.Bind(fd, sa)
+ syscall.Close(fd)
+ if err == nil {
+ return port, nil
+ }
+ }
+
+ return 0, nil
+}
+
+func (s *IdentityServer) Serve(ctx *context.T, listenSpec *rpc.ListenSpec, host, httpaddr, tlsconfig string) {
+ ctx, err := v23.SetPrincipal(ctx, audit.NewPrincipal(
+ v23.GetPrincipal(ctx), s.auditor))
+ if err != nil {
+ vlog.Panic(err)
+ }
+ httphost, httpport, err := net.SplitHostPort(httpaddr)
+ if err != nil || httpport == "0" {
+ httpportNum, err := findUnusedPort()
+ if err != nil {
+ vlog.Panic(err)
+ }
+ httpaddr = net.JoinHostPort(httphost, strconv.Itoa(httpportNum))
+ }
+ rpcServer, _, externalAddr := s.Listen(ctx, listenSpec, host, httpaddr, tlsconfig)
+ fmt.Printf("HTTP_ADDR=%s\n", externalAddr)
+ if len(s.rootedObjectAddrs) > 0 {
+ fmt.Printf("NAME=%s\n", s.rootedObjectAddrs[0].Name())
+ }
+ <-signals.ShutdownOnSignals(ctx)
+ if err := rpcServer.Stop(); err != nil {
+ vlog.Errorf("Failed to stop rpc server: %v", err)
+ }
+}
+
+func (s *IdentityServer) Listen(ctx *context.T, listenSpec *rpc.ListenSpec, host, httpaddr, tlsconfig string) (rpc.Server, []string, string) {
+ // Setup handlers
+
+ // json-encoded public key and blessing names of this server
+ principal := v23.GetPrincipal(ctx)
+ http.Handle("/blessing-root", handlers.BlessingRoot{principal})
+
+ macaroonKey := make([]byte, 32)
+ if _, err := rand.Read(macaroonKey); err != nil {
+ vlog.Fatalf("macaroonKey generation failed: %v", err)
+ }
+
+ rpcServer, published, err := s.setupServices(ctx, listenSpec, macaroonKey)
+ if err != nil {
+ vlog.Fatalf("Failed to setup vanadium services for blessing: %v", err)
+ }
+
+ externalHttpaddr := httpaddress(host, httpaddr)
+
+ n := "/google/"
+ h, err := oauth.NewHandler(oauth.HandlerArgs{
+ Principal: principal,
+ MacaroonKey: macaroonKey,
+ Addr: fmt.Sprintf("%s%s", externalHttpaddr, n),
+ BlessingLogReader: s.blessingLogReader,
+ RevocationManager: s.revocationManager,
+ DischargerLocation: naming.JoinAddressName(published[0], dischargerService),
+ MacaroonBlessingService: naming.JoinAddressName(published[0], macaroonService),
+ OAuthProvider: s.oauthProvider,
+ CaveatSelector: s.caveatSelector,
+ EmailClassifier: s.emailClassifier,
+ })
+ if err != nil {
+ vlog.Fatalf("Failed to create HTTP handler for oauth authentication: %v", err)
+ }
+ http.Handle(n, h)
+
+ http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ args := struct {
+ Self security.Blessings
+ GoogleServers, DischargeServers []string
+ ListBlessingsRoute string
+ }{
+ Self: principal.BlessingStore().Default(),
+ }
+ if s.revocationManager != nil {
+ args.DischargeServers = appendSuffixTo(published, dischargerService)
+ }
+ var emptyParams blesser.OAuthBlesserParams
+ if !reflect.DeepEqual(s.oauthBlesserParams, emptyParams) {
+ args.GoogleServers = appendSuffixTo(published, oauthBlesserService)
+ }
+ if s.blessingLogReader != nil {
+ args.ListBlessingsRoute = oauth.ListBlessingsRoute
+ }
+ if err := tmpl.Execute(w, args); err != nil {
+ vlog.Info("Failed to render template:", err)
+ }
+ })
+ vlog.Infof("Running HTTP server at: %v", externalHttpaddr)
+ go runHTTPSServer(httpaddr, tlsconfig)
+ return rpcServer, published, externalHttpaddr
+}
+
+func appendSuffixTo(objectname []string, suffix string) []string {
+ names := make([]string, len(objectname))
+ for i, o := range objectname {
+ names[i] = naming.JoinAddressName(o, suffix)
+ }
+ 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) {
+ server, err := v23.NewServer(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to create new rpc.Server: %v", err)
+ }
+
+ principal := v23.GetPrincipal(ctx)
+ objectAddr := naming.Join("identity", fmt.Sprintf("%v", principal.BlessingStore().Default()))
+ var rootedObjectAddr string
+ if eps, err := server.Listen(*listenSpec); err != nil {
+ defer server.Stop()
+ return nil, nil, fmt.Errorf("server.Listen(%v) failed: %v", *listenSpec, err)
+ } else if nsroots := v23.GetNamespace(ctx).Roots(); len(nsroots) >= 1 {
+ rootedObjectAddr = naming.Join(nsroots[0], objectAddr)
+ s.rootedObjectAddrs = eps
+ } else {
+ rootedObjectAddr = eps[0].Name()
+ s.rootedObjectAddrs = eps
+ }
+ dispatcher := newDispatcher(macaroonKey, oauthBlesserParams(s.oauthBlesserParams, rootedObjectAddr))
+ if err := server.ServeDispatcher(objectAddr, dispatcher); err != nil {
+ return nil, nil, fmt.Errorf("failed to start Vanadium services: %v", err)
+ }
+ vlog.Infof("Blessing and discharger services will be published at %v", rootedObjectAddr)
+ return server, []string{rootedObjectAddr}, nil
+}
+
+// newDispatcher returns a dispatcher for both the blessing and the
+// discharging service.
+func newDispatcher(macaroonKey []byte, blesserParams blesser.OAuthBlesserParams) rpc.Dispatcher {
+ d := dispatcher(map[string]interface{}{
+ macaroonService: blesser.NewMacaroonBlesserServer(macaroonKey),
+ dischargerService: services.DischargerServer(discharger.NewDischarger()),
+ oauthBlesserService: blesser.NewOAuthBlesserServer(blesserParams),
+ })
+ // Set up the glob invoker.
+ var children []string
+ for k, _ := range d {
+ children = append(children, k)
+ }
+ d[""] = rpc.ChildrenGlobberInvoker(children...)
+ return d
+}
+
+type allowEveryoneAuthorizer struct{}
+
+func (allowEveryoneAuthorizer) Authorize(*context.T) error { return nil }
+
+type dispatcher map[string]interface{}
+
+func (d dispatcher) Lookup(suffix string) (interface{}, security.Authorizer, error) {
+ if invoker := d[suffix]; invoker != nil {
+ return invoker, allowEveryoneAuthorizer{}, nil
+ }
+ return nil, nil, verror.New(verror.ErrNoExist, nil, suffix)
+}
+
+func oauthBlesserParams(inputParams blesser.OAuthBlesserParams, servername string) blesser.OAuthBlesserParams {
+ inputParams.DischargerLocation = naming.Join(servername, dischargerService)
+ return inputParams
+}
+
+func runHTTPSServer(addr, tlsconfig string) {
+ if len(tlsconfig) == 0 {
+ vlog.Fatal("Please set the --tlsconfig flag")
+ }
+ paths := strings.Split(tlsconfig, ",")
+ if len(paths) != 2 {
+ vlog.Fatalf("Could not parse --tlsconfig. Must have exactly two components, separated by a comma")
+ }
+ vlog.Infof("Starting HTTP server with TLS using certificate [%s] and private key [%s] at https://%s", paths[0], paths[1], addr)
+ if err := http.ListenAndServeTLS(addr, paths[0], paths[1], nil); err != nil {
+ vlog.Fatalf("http.ListenAndServeTLS failed: %v", err)
+ }
+}
+
+func httpaddress(host, httpaddr string) string {
+ _, port, err := net.SplitHostPort(httpaddr)
+ if err != nil {
+ vlog.Fatalf("Failed to parse %q: %v", httpaddr, err)
+ }
+ return fmt.Sprintf("https://%s:%v", host, port)
+}
+
+var tmpl = template.Must(template.New("main").Parse(`<!doctype html>
+<html>
+<head>
+<meta charset="UTF-8">
+<title>Vanadium Identity Server</title>
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<link rel="stylesheet" href="//netdna.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css">
+</head>
+<body>
+<div class="container">
+<div class="page-header"><h2>{{.Self}}</h2><h4>A Vanadium Blessing Provider</h4></div>
+<div class="well">
+This is a Vanadium identity provider that provides blessings with the name prefix <mark>{{.Self}}</mark>.
+<br/>
+The public key of this provider is {{.Self.PublicKey}}.
+<br/>
+The root names and public key (in DER encoded <a href="http://en.wikipedia.org/wiki/X.690#DER_encoding">format</a>)
+are available in a <a class="btn btn-xs btn-primary" href="/blessing-root">JSON</a> object.
+</div>
+
+<div class="well">
+<ul>
+{{if .GoogleServers}}
+<li>Blessings (using Google OAuth to fetch an email address) are provided via Vanadium RPCs to: <tt>{{range .GoogleServers}}{{.}}{{end}}</tt></li>
+{{end}}
+{{if .DischargeServers}}
+<li>RevocationCaveat Discharges are provided via Vanadium RPCs to: <tt>{{range .DischargeServers}}{{.}}{{end}}</tt></li>
+{{end}}
+{{if .ListBlessingsRoute}}
+<li>You can <a class="btn btn-xs btn-primary" href="/google/{{.ListBlessingsRoute}}">enumerate</a> blessings provided with your
+email address.</li>
+{{end}}
+</ul>
+</div>
+
+</div>
+</body>
+</html>`))
diff --git a/services/identity/internal/util/certs.go b/services/identity/internal/util/certs.go
new file mode 100644
index 0000000..ca57cca
--- /dev/null
+++ b/services/identity/internal/util/certs.go
@@ -0,0 +1,29 @@
+package util
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// WriteCertAndKey creates a certificate and private key for a given host and
+// duration and writes them to cert.pem and key.pem in tmpdir. It returns the
+// locations of the files, or an error if one is encountered.
+func WriteCertAndKey(host string, duration time.Duration) (string, string, error) {
+ listCmd := exec.Command("go", "list", "-f", "{{.Dir}}", "crypto/tls")
+ output, err := listCmd.Output()
+ if err != nil {
+ return "", "", fmt.Errorf("%s failed: %v", strings.Join(listCmd.Args, " "), err)
+ }
+ tmpDir := os.TempDir()
+ generateCertFile := filepath.Join(strings.TrimSpace(string(output)), "generate_cert.go")
+ generateCertCmd := exec.Command("go", "run", generateCertFile, "--host", host, "--duration", duration.String())
+ generateCertCmd.Dir = tmpDir
+ if err := generateCertCmd.Run(); err != nil {
+ return "", "", fmt.Errorf("Could not generate key and cert: %v", err)
+ }
+ return filepath.Join(tmpDir, "cert.pem"), filepath.Join(tmpDir, "key.pem"), nil
+}
diff --git a/services/identity/internal/util/classify.go b/services/identity/internal/util/classify.go
new file mode 100644
index 0000000..33e2e95
--- /dev/null
+++ b/services/identity/internal/util/classify.go
@@ -0,0 +1,70 @@
+package util
+
+import (
+ "fmt"
+ "strings"
+ "sync"
+)
+
+const defaultClass = "users"
+
+// EmailClassifier classifies/categorizes email addresses based on the domain.
+type EmailClassifier struct {
+ mu sync.RWMutex
+ m map[string]string
+}
+
+// Classify returns the classification of email.
+func (c *EmailClassifier) Classify(email string) string {
+ if c == nil {
+ return defaultClass
+ }
+ parts := strings.Split(email, "@")
+ if len(parts) != 2 {
+ return defaultClass
+ }
+ domain := parts[1]
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if class := c.m[domain]; len(class) > 0 {
+ return class
+ }
+ return defaultClass
+}
+
+// Set implements flag.Value.
+//
+// value should be a comma-separated list of <domain>=<class> pairs.
+func (c *EmailClassifier) Set(value string) error {
+ m := make(map[string]string)
+ for _, entry := range strings.Split(value, ",") {
+ pair := strings.Split(entry, "=")
+ if len(pair) != 2 {
+ return fmt.Errorf("invalid pair %q: must be in <domain>=<class> format", entry)
+ }
+ domain := strings.TrimSpace(pair[0])
+ class := strings.TrimSpace(pair[1])
+ if len(domain) == 0 {
+ return fmt.Errorf("empty domain in %q", entry)
+ }
+ if len(class) == 0 {
+ return fmt.Errorf("empty class in %q", entry)
+ }
+ m[domain] = class
+ }
+ c.mu.Lock()
+ c.m = m
+ c.mu.Unlock()
+ return nil
+}
+
+// Get implements flag.Getter.
+func (c *EmailClassifier) Get() interface{} {
+ return c
+}
+
+func (c *EmailClassifier) String() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return fmt.Sprintf("%v", c.m)
+}
diff --git a/services/identity/internal/util/classify_test.go b/services/identity/internal/util/classify_test.go
new file mode 100644
index 0000000..6077ee4
--- /dev/null
+++ b/services/identity/internal/util/classify_test.go
@@ -0,0 +1,28 @@
+package util
+
+import (
+ "flag"
+ "testing"
+)
+
+func TestEmailClassifier(t *testing.T) {
+ fs := flag.NewFlagSet("TestEmailClassifier", flag.PanicOnError)
+ var c EmailClassifier
+ fs.Var(&c, "myflag", "my usage")
+ if err := fs.Parse([]string{"--myflag", "foo.com=internal,bar.com=external"}); err != nil {
+ t.Fatal(err)
+ }
+ tests := []struct {
+ in, out string
+ }{
+ {"batman@foo.com", "internal"},
+ {"bugsbunny@foo.com.com", "users"},
+ {"daffyduck@bar.com", "external"},
+ {"joker@other.com", "users"},
+ }
+ for _, test := range tests {
+ if got := c.Classify(test.in); got != test.out {
+ t.Errorf("%q: Got %q, want %q", test.in, got, test.out)
+ }
+ }
+}
diff --git a/services/identity/internal/util/common_test.go b/services/identity/internal/util/common_test.go
new file mode 100644
index 0000000..cc41d3b
--- /dev/null
+++ b/services/identity/internal/util/common_test.go
@@ -0,0 +1,20 @@
+package util
+
+import "net/http"
+
+type iface interface {
+ Method()
+}
+type impl struct{ Content string }
+
+func (i *impl) Method() {}
+
+var _ iface = (*impl)(nil)
+
+func newRequest() *http.Request {
+ r, err := http.NewRequest("GET", "http://does-not-matter", nil)
+ if err != nil {
+ panic(err)
+ }
+ return r
+}
diff --git a/services/identity/internal/util/csrf.go b/services/identity/internal/util/csrf.go
new file mode 100644
index 0000000..8bd317f
--- /dev/null
+++ b/services/identity/internal/util/csrf.go
@@ -0,0 +1,136 @@
+package util
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "time"
+
+ "v.io/v23/vom"
+ "v.io/x/lib/vlog"
+)
+
+const (
+ cookieLen = 16
+ keyLength = 16
+)
+
+// CSRFCop implements utilities for generating and validating tokens for
+// cross-site-request-forgery prevention (also called XSRF).
+type CSRFCop struct {
+ key []byte
+}
+
+func (c *CSRFCop) keyForCookie(cookie []byte) []byte {
+ hm := hmac.New(sha256.New, c.key)
+ hm.Write(cookie)
+ return hm.Sum(nil)
+}
+
+func NewCSRFCop() (*CSRFCop, error) {
+ key := make([]byte, keyLength)
+ if _, err := rand.Read(key); err != nil {
+ return nil, fmt.Errorf("newCSRFCop failed: %v", err)
+ }
+ return &CSRFCop{key}, nil
+}
+
+// NewToken creates an anti-cross-site-request-forgery, aka CSRF aka XSRF token
+// with some data bound to it that can be obtained by ValidateToken.
+// It returns an error if the token could not be created.
+func (c *CSRFCop) NewToken(w http.ResponseWriter, r *http.Request, cookieName string, data interface{}) (string, error) {
+ cookieValue, err := c.MaybeSetCookie(w, r, cookieName)
+ if err != nil {
+ return "", fmt.Errorf("bad cookie: %v", err)
+ }
+ var encData []byte
+ if data != nil {
+ if encData, err = vom.Encode(data); err != nil {
+ return "", err
+ }
+ }
+ return string(NewMacaroon(c.keyForCookie(cookieValue), encData)), nil
+}
+
+// ValidateToken checks the validity of the provided CSRF token for the
+// provided request, and extracts the data encoded in the token into 'decoded'.
+// If the token is invalid, return an error. This error should not be shown to end users,
+// it is meant for the consumption by the server process only.
+func (c *CSRFCop) ValidateToken(token string, req *http.Request, cookieName string, decoded interface{}) error {
+ cookie, err := req.Cookie(cookieName)
+ if err != nil {
+ return err
+ }
+ cookieValue, err := decodeCookieValue(cookie.Value)
+ if err != nil {
+ return fmt.Errorf("invalid cookie")
+ }
+ encodedInput, err := Macaroon(token).Decode(c.keyForCookie(cookieValue))
+ if err != nil {
+ return err
+ }
+ if decoded != nil {
+ if err := vom.Decode(encodedInput, decoded); err != nil {
+ return fmt.Errorf("invalid token data: %v", err)
+ }
+ }
+ return nil
+}
+
+func (*CSRFCop) MaybeSetCookie(w http.ResponseWriter, req *http.Request, cookieName string) ([]byte, error) {
+ cookie, err := req.Cookie(cookieName)
+ switch err {
+ case nil:
+ if v, err := decodeCookieValue(cookie.Value); err == nil {
+ return v, nil
+ }
+ vlog.Infof("Invalid cookie: %#v, err: %v. Regenerating one.", cookie, err)
+ case http.ErrNoCookie:
+ // Intentionally blank: Cookie will be generated below.
+ default:
+ vlog.Infof("Error decoding cookie %q in request: %v. Regenerating one.", cookieName, err)
+ }
+ cookie, v := newCookie(cookieName)
+ if cookie == nil || v == nil {
+ return nil, fmt.Errorf("failed to create cookie")
+ }
+ http.SetCookie(w, cookie)
+ // We need to add the cookie to the request also to prevent repeatedly resetting cookies on multiple
+ // calls from the same request.
+ req.AddCookie(cookie)
+ return v, nil
+}
+
+func newCookie(cookieName string) (*http.Cookie, []byte) {
+ b := make([]byte, cookieLen)
+ if _, err := rand.Read(b); err != nil {
+ vlog.Errorf("newCookie failed: %v", err)
+ return nil, nil
+ }
+ return &http.Cookie{
+ Name: cookieName,
+ Value: b64encode(b),
+ Expires: time.Now().Add(time.Hour * 24),
+ HttpOnly: true,
+ Secure: true,
+ Path: "/",
+ }, b
+}
+
+func decodeCookieValue(v string) ([]byte, error) {
+ b, err := b64decode(v)
+ if err != nil {
+ return nil, err
+ }
+ if len(b) != cookieLen {
+ return nil, fmt.Errorf("invalid cookie length[%d]", len(b))
+ }
+ return b, nil
+}
+
+// Shorthands.
+func b64encode(b []byte) string { return base64.URLEncoding.EncodeToString(b) }
+func b64decode(s string) ([]byte, error) { return base64.URLEncoding.DecodeString(s) }
diff --git a/services/identity/internal/util/csrf_test.go b/services/identity/internal/util/csrf_test.go
new file mode 100644
index 0000000..a6b81d4
--- /dev/null
+++ b/services/identity/internal/util/csrf_test.go
@@ -0,0 +1,145 @@
+package util
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+const (
+ cookieName = "VeyronCSRFTestCookie"
+ failCookieName = "FailCookieName"
+)
+
+func TestCSRFTokenWithoutCookie(t *testing.T) {
+ r := newRequest()
+ c, err := NewCSRFCop()
+ if err != nil {
+ t.Fatalf("NewCSRFCop failed: %v", err)
+ }
+ w := httptest.NewRecorder()
+ tok, err := c.NewToken(w, r, cookieName, nil)
+ if err != nil {
+ t.Errorf("NewToken failed: %v", err)
+ }
+ cookie, err := cookieVal(w, cookieName)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(cookie) == 0 {
+ t.Errorf("Cookie should have been set. Request: [%v], Response: [%v]", r, w)
+ }
+ // Cookie needs to be present for validation
+ r.AddCookie(&http.Cookie{Name: cookieName, Value: cookie})
+ if err := c.ValidateToken(tok, r, cookieName, nil); err != nil {
+ t.Error("CSRF token failed validation:", err)
+ }
+
+ w = httptest.NewRecorder()
+ if _, err = c.MaybeSetCookie(w, r, failCookieName); err != nil {
+ t.Error("failed to create cookie: ", err)
+ }
+ cookie, err = cookieVal(w, failCookieName)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(cookie) == 0 {
+ t.Errorf("Cookie should have been set. Request: [%v], Response: [%v]", r, w)
+ }
+
+ if err := c.ValidateToken(tok, r, failCookieName, nil); err == nil {
+ t.Error("CSRF token should have failed validation")
+ }
+}
+
+func TestCSRFTokenWithCookie(t *testing.T) {
+ r := newRequest()
+ c, err := NewCSRFCop()
+ if err != nil {
+ t.Fatalf("NewCSRFCop failed: %v", err)
+ }
+ w := httptest.NewRecorder()
+ r.AddCookie(&http.Cookie{Name: cookieName, Value: "u776AC7hf794pTtGVlO50w=="})
+ tok, err := c.NewToken(w, r, cookieName, nil)
+ if err != nil {
+ t.Errorf("NewToken failed: %v", err)
+ }
+ cookie, err := cookieVal(w, cookieName)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(cookie) > 0 {
+ t.Errorf("Cookie should not be set when it is already present. Request: [%v], Response: [%v]", r, w)
+ }
+ if err := c.ValidateToken(tok, r, cookieName, nil); err != nil {
+ t.Error("CSRF token failed validation:", err)
+ }
+
+ r.AddCookie(&http.Cookie{Name: failCookieName, Value: "u864AC7gf794pTtCAlO40w=="})
+ if err := c.ValidateToken(tok, r, failCookieName, nil); err == nil {
+ t.Error("CSRF token should have failed validation")
+ }
+}
+
+func TestCSRFTokenWithData(t *testing.T) {
+ r := newRequest()
+ c, err := NewCSRFCop()
+ if err != nil {
+ t.Fatalf("NewCSRFCop failed: %v", err)
+ }
+ w := httptest.NewRecorder()
+ r.AddCookie(&http.Cookie{Name: cookieName, Value: "u776AC7hf794pTtGVlO50w=="})
+ tok, err := c.NewToken(w, r, cookieName, 1)
+ if err != nil {
+ t.Errorf("NewToken failed: %v", err)
+ }
+ cookie, err := cookieVal(w, cookieName)
+ if err != nil {
+ t.Error(err)
+ }
+ if len(cookie) > 0 {
+ t.Errorf("Cookie should not be set when it is already present. Request: [%v], Response: [%v]", r, w)
+ }
+ var got int
+ if err := c.ValidateToken(tok, r, cookieName, &got); err != nil {
+ t.Error("CSRF token failed validation:", err)
+ }
+ if want := 1; got != want {
+ t.Errorf("Got %v, want %v", got, want)
+ }
+
+ r.AddCookie(&http.Cookie{Name: failCookieName, Value: "u864AC7gf794pTtCAlO40w=="})
+ if err := c.ValidateToken(tok, r, failCookieName, &got); err == nil {
+ t.Error("CSRF token should have failed validation")
+ }
+}
+
+func cookieVal(w *httptest.ResponseRecorder, cookieName string) (string, error) {
+ cookie := w.Header().Get("Set-Cookie")
+ if len(cookie) == 0 {
+ return "", nil
+ }
+ var (
+ val string
+ httpOnly, secure bool
+ )
+ for _, part := range strings.Split(cookie, "; ") {
+ switch {
+ case strings.HasPrefix(part, cookieName):
+ val = strings.TrimPrefix(part, cookieName+"=")
+ case part == "HttpOnly":
+ httpOnly = true
+ case part == "Secure":
+ secure = true
+ }
+ }
+ if !httpOnly {
+ return "", fmt.Errorf("cookie for name %v is not HttpOnly", cookieName)
+ }
+ if !secure {
+ return "", fmt.Errorf("cookie for name %v is not Secure", cookieName)
+ }
+ return val, nil
+}
diff --git a/services/identity/internal/util/doc.go b/services/identity/internal/util/doc.go
new file mode 100644
index 0000000..6b1bf62
--- /dev/null
+++ b/services/identity/internal/util/doc.go
@@ -0,0 +1,2 @@
+// Package util implements miscellaneous utility functions needed by the identity HTTP server.
+package util
diff --git a/services/identity/internal/util/macaroon.go b/services/identity/internal/util/macaroon.go
new file mode 100644
index 0000000..2d4a6cd
--- /dev/null
+++ b/services/identity/internal/util/macaroon.go
@@ -0,0 +1,42 @@
+package util
+
+import (
+ "bytes"
+ "crypto/hmac"
+ "crypto/sha256"
+ "fmt"
+)
+
+// Macaroon encapsulates an arbitrary slice of data with an HMAC for integrity protection.
+// Term borrowed from http://research.google.com/pubs/pub41892.html.
+type Macaroon string
+
+// NewMacaroon creates an opaque token that encodes "data".
+//
+// Input can be extracted from the returned token only if the key provided to NewMacaroon is known.
+func NewMacaroon(key, data []byte) Macaroon {
+ return Macaroon(b64encode(append(data, computeHMAC(key, data)...)))
+}
+
+// Decode returns the input if the macaroon is decodable with the provided key.
+func (m Macaroon) Decode(key []byte) (input []byte, err error) {
+ decoded, err := b64decode(string(m))
+ if err != nil {
+ return nil, err
+ }
+ if len(decoded) < sha256.Size {
+ return nil, fmt.Errorf("invalid macaroon, too small")
+ }
+ data := decoded[:len(decoded)-sha256.Size]
+ decodedHMAC := decoded[len(decoded)-sha256.Size:]
+ if !bytes.Equal(decodedHMAC, computeHMAC(key, data)) {
+ return nil, fmt.Errorf("invalid macaroon, HMAC does not match")
+ }
+ return data, nil
+}
+
+func computeHMAC(key, data []byte) []byte {
+ hm := hmac.New(sha256.New, key)
+ hm.Write(data)
+ return hm.Sum(nil)
+}
diff --git a/services/identity/internal/util/macaroon_test.go b/services/identity/internal/util/macaroon_test.go
new file mode 100644
index 0000000..7a711d1
--- /dev/null
+++ b/services/identity/internal/util/macaroon_test.go
@@ -0,0 +1,73 @@
+package util
+
+import (
+ "bytes"
+ "crypto/rand"
+ "testing"
+)
+
+func TestMacaroon(t *testing.T) {
+ var (
+ key = randBytes(t)
+ incorrectKey = randBytes(t)
+ input = randBytes(t)
+ m = NewMacaroon(key, input)
+ )
+
+ // Test incorrect key.
+ decoded, err := m.Decode(incorrectKey)
+ if err == nil {
+ t.Errorf("m.Decode should have failed")
+ }
+ if decoded != nil {
+ t.Errorf("decoded value should be nil when decode fails")
+ }
+
+ // Test correct key.
+ if decoded, err = m.Decode(key); err != nil {
+ t.Errorf("m.Decode should have succeeded")
+ }
+ if !bytes.Equal(decoded, input) {
+ t.Errorf("decoded value should equal input")
+ }
+}
+
+func TestBadMacaroon(t *testing.T) {
+ var (
+ key = []byte{0xd4, 0x4f, 0x6b, 0x5c, 0xf2, 0x5f, 0xc4, 0x3, 0x68, 0x34, 0x15, 0xc6, 0x26, 0xc5, 0x1, 0x8a}
+ tests = []Macaroon{
+ "Hkd_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_2m4r-", // valid
+ "Hkd_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_2m4r", // truncated
+ "Hkd_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_2m4r=", // truncated content but valid base64
+ "Hkd_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_2m4r--", // extended
+ "Hkd_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_2m4r-A=", // extended content but valid base64
+ "AAA_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_2m4r-", // modified data
+ "Hkd_PqB1ekct6eH1rTntfJYFgYpGFQpM7z0Ur8cuAcVQscWa-FNV4_kTfC_XXXX-", // modified HMAC
+ "", // zero value
+ }
+ )
+ // Test data above was generated by:
+ //{
+ // key := randBytes(t)
+ // t.Logf("key=%#v\ntests = []Macaroon{\n\t%q, // valid\n}", key, NewMacaroon(key, randBytes(t)))
+ //}
+
+ // Make sure that "valid" is indeed valid!
+ if data, err := tests[0].Decode(key); err != nil || data == nil {
+ t.Fatalf("Bad test data: Got (%v, %v), want (<non-empty>, nil)", data, err)
+ }
+ // And all others are not:
+ for idx, test := range tests[1:] {
+ if _, err := test.Decode(key); err == nil {
+ t.Errorf("Should have failed to decode invalid macaroon #%d", idx)
+ }
+ }
+}
+
+func randBytes(t *testing.T) []byte {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ t.Fatalf("bytes creation failed: %v", err)
+ }
+ return b
+}
diff --git a/services/identity/internal/util/write.go b/services/identity/internal/util/write.go
new file mode 100644
index 0000000..f333c67
--- /dev/null
+++ b/services/identity/internal/util/write.go
@@ -0,0 +1,71 @@
+package util
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+
+ "v.io/x/lib/vlog"
+)
+
+// HTTPBadRequest sends an HTTP 400 error on 'w' and renders a pretty page.
+// If err is not nil, it also renders the string representation of err in the response page.
+func HTTPBadRequest(w http.ResponseWriter, req *http.Request, err error) {
+ w.WriteHeader(http.StatusBadRequest)
+ if e := tmplBadRequest.Execute(w, badRequestData{Request: requestString(req), Error: err}); e != nil {
+ vlog.Errorf("Failed to execute Bad Request Template:", e)
+ }
+}
+
+// ServerError sends an HTTP 500 error on 'w' and renders a pretty page that
+// also has the string representation of err.
+func HTTPServerError(w http.ResponseWriter, err error) {
+ w.WriteHeader(http.StatusInternalServerError)
+ if e := tmplServerError.Execute(w, err); e != nil {
+ vlog.Errorf("Failed to execute Server Error template:", e)
+ }
+}
+
+var (
+ tmplBadRequest = template.Must(template.New("Bad Request").Parse(`<!doctype html>
+<html>
+<head>
+<meta charset="UTF8">
+<title>Bad Request</title>
+</head>
+<body>
+<h1>Bad Request</h1>
+{{with $data := .}}
+{{if $data.Error}}Error: {{$data.Error}}{{end}}
+<pre>
+{{$data.Request}}
+</pre>
+{{end}}
+</body>
+</html>`))
+
+ tmplServerError = template.Must(template.New("Server Error").Parse(`<!doctype html>
+<html>
+<head>
+<meta charset="UTF8">
+<title>Server Error</title>
+</head>
+<body>
+<h1>Oops! Error at the server</h1>
+Error: {{.}}
+<br/>
+Ask the server administrator to check the server logs
+</body>
+</html>`))
+)
+
+func requestString(r *http.Request) string {
+ var buf bytes.Buffer
+ r.Write(&buf)
+ return buf.String()
+}
+
+type badRequestData struct {
+ Request string
+ Error error
+}
diff --git a/services/identity/internal/util/write_test.go b/services/identity/internal/util/write_test.go
new file mode 100644
index 0000000..22d75e0
--- /dev/null
+++ b/services/identity/internal/util/write_test.go
@@ -0,0 +1,23 @@
+package util
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestBadRequest(t *testing.T) {
+ w := httptest.NewRecorder()
+ HTTPBadRequest(w, newRequest(), nil)
+ if got, want := w.Code, http.StatusBadRequest; got != want {
+ t.Errorf("Got %d, want %d", got, want)
+ }
+}
+
+func TestServerError(t *testing.T) {
+ w := httptest.NewRecorder()
+ HTTPServerError(w, nil)
+ if got, want := w.Code, http.StatusInternalServerError; got != want {
+ t.Errorf("Got %d, want %d", got, want)
+ }
+}