Merge remote-tracking branch 'upstream/v0.2.0' into identity-v3

Conflicts:
	acceptance/openstack/compute/tools_test.go
diff --git a/acceptance/openstack/client_test.go b/acceptance/openstack/client_test.go
new file mode 100644
index 0000000..9b12337
--- /dev/null
+++ b/acceptance/openstack/client_test.go
@@ -0,0 +1,38 @@
+// +build acceptance
+
+package openstack
+
+import (
+	"os"
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack"
+	"github.com/rackspace/gophercloud/openstack/utils"
+)
+
+func TestAuthenticatedClient(t *testing.T) {
+	// Obtain credentials from the environment.
+	ao, err := utils.AuthOptions()
+	if err != nil {
+		t.Fatalf("Unable to acquire credentials: %v", err)
+	}
+
+	client, err := openstack.AuthenticatedClient(ao)
+	if err != nil {
+		t.Fatalf("Unable to authenticate: %v", err)
+	}
+
+	if client.TokenID == "" {
+		t.Errorf("No token ID assigned to the client")
+	}
+
+	t.Logf("Client successfully acquired a token: %v", client.TokenID)
+
+	// Find the storage service in the service catalog.
+	storage, err := openstack.NewStorageV1(client, os.Getenv("OS_REGION_NAME"))
+	if err != nil {
+		t.Errorf("Unable to locate a storage service: %v", err)
+	} else {
+		t.Logf("Located a storage service at endpoint: [%s]", storage.Endpoint)
+	}
+}
diff --git a/acceptance/openstack/compute/tools_test.go b/acceptance/openstack/compute/tools_test.go
index 100ea5e..1acb824 100644
--- a/acceptance/openstack/compute/tools_test.go
+++ b/acceptance/openstack/compute/tools_test.go
@@ -9,6 +9,7 @@
 	"text/tabwriter"
 	"time"
 
+	"github.com/rackspace/gophercloud"
 	"github.com/rackspace/gophercloud/openstack/compute/v2/servers"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 	"github.com/rackspace/gophercloud/openstack/utils"
@@ -17,7 +18,7 @@
 var errTimeout = fmt.Errorf("Timeout.")
 
 type testState struct {
-	o              identity.AuthOptions
+	o              gophercloud.AuthOptions
 	a              identity.AuthResults
 	sc             *identity.ServiceCatalog
 	eps            []identity.Endpoint
@@ -45,7 +46,8 @@
 		return ts, err
 	}
 
-	ts.a, err = identity.Authenticate(ts.o)
+	client := &gophercloud.ServiceClient{Endpoint: ts.o.IdentityEndpoint}
+	ts.a, err = identity.Authenticate(client, ts.o)
 	if err != nil {
 		return ts, err
 	}
diff --git a/acceptance/openstack/identity/v3/endpoint_test.go b/acceptance/openstack/identity/v3/endpoint_test.go
new file mode 100644
index 0000000..b8c1dcc
--- /dev/null
+++ b/acceptance/openstack/identity/v3/endpoint_test.go
@@ -0,0 +1,87 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	endpoints3 "github.com/rackspace/gophercloud/openstack/identity/v3/endpoints"
+	services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+)
+
+func TestListEndpoints(t *testing.T) {
+	// Create a service client.
+	serviceClient := createAuthenticatedClient(t)
+	if serviceClient == nil {
+		return
+	}
+
+	// Use the service to list all available endpoints.
+	results, err := endpoints3.List(serviceClient, endpoints3.ListOpts{})
+	if err != nil {
+		t.Fatalf("Unexpected error while listing endpoints: %v", err)
+	}
+
+	err = gophercloud.EachPage(results, func(page gophercloud.Collection) bool {
+		t.Logf("--- Page ---")
+
+		for _, endpoint := range endpoints3.AsEndpoints(page) {
+			t.Logf("Endpoint: %8s %10s %9s %s",
+				endpoint.ID,
+				endpoint.Interface,
+				endpoint.Name,
+				endpoint.URL)
+		}
+
+		return true
+	})
+	if err != nil {
+		t.Errorf("Unexpected error while iterating endpoint pages: %v", err)
+	}
+}
+
+func TestNavigateCatalog(t *testing.T) {
+	// Create a service client.
+	client := createAuthenticatedClient(t)
+
+	// Discover the service we're interested in.
+	computeResults, err := services3.List(client, services3.ListOpts{ServiceType: "compute"})
+	if err != nil {
+		t.Fatalf("Unexpected error while listing services: %v", err)
+	}
+
+	allServices, err := gophercloud.AllPages(computeResults)
+	if err != nil {
+		t.Fatalf("Unexpected error while traversing service results: %v", err)
+	}
+
+	computeServices := services3.AsServices(allServices)
+
+	if len(computeServices) != 1 {
+		t.Logf("%d compute services are available at this endpoint.", len(computeServices))
+		return
+	}
+	computeService := computeServices[0]
+
+	// Enumerate the endpoints available for this service.
+	endpointResults, err := endpoints3.List(client, endpoints3.ListOpts{
+		Interface: gophercloud.InterfacePublic,
+		ServiceID: computeService.ID,
+	})
+
+	allEndpoints, err := gophercloud.AllPages(endpointResults)
+	if err != nil {
+		t.Fatalf("Unexpected error while listing endpoints: %v", err)
+	}
+
+	endpoints := endpoints3.AsEndpoints(allEndpoints)
+
+	if len(endpoints) != 1 {
+		t.Logf("%d endpoints are available for the service %v.", len(endpoints), computeService.Name)
+		return
+	}
+
+	endpoint := endpoints[0]
+	t.Logf("Success. The compute endpoint is at %s.", endpoint.URL)
+}
diff --git a/acceptance/openstack/identity/v3/identity_test.go b/acceptance/openstack/identity/v3/identity_test.go
new file mode 100644
index 0000000..ec184a0
--- /dev/null
+++ b/acceptance/openstack/identity/v3/identity_test.go
@@ -0,0 +1,39 @@
+package v3
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack"
+	"github.com/rackspace/gophercloud/openstack/utils"
+)
+
+func createAuthenticatedClient(t *testing.T) *gophercloud.ServiceClient {
+	// Obtain credentials from the environment.
+	ao, err := utils.AuthOptions()
+	if err != nil {
+		t.Fatalf("Unable to acquire credentials: %v", err)
+	}
+
+	// Trim out unused fields.
+	ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+
+	if ao.UserID == "" {
+		t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+		return nil
+	}
+
+	// Create a client and manually authenticate against v3.
+	providerClient, err := openstack.NewClient(ao.IdentityEndpoint)
+	if err != nil {
+		t.Fatalf("Unable to instantiate client: %v", err)
+	}
+
+	err = openstack.AuthenticateV3(providerClient, ao)
+	if err != nil {
+		t.Fatalf("Unable to authenticate against identity v3: %v", err)
+	}
+
+	// Create a service client.
+	return openstack.NewIdentityV3(providerClient)
+}
diff --git a/acceptance/openstack/identity/v3/service_test.go b/acceptance/openstack/identity/v3/service_test.go
new file mode 100644
index 0000000..00375e2
--- /dev/null
+++ b/acceptance/openstack/identity/v3/service_test.go
@@ -0,0 +1,35 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+)
+
+func TestListServices(t *testing.T) {
+	// Create a service client.
+	serviceClient := createAuthenticatedClient(t)
+	if serviceClient == nil {
+		return
+	}
+
+	// Use the client to list all available services.
+	results, err := services3.List(serviceClient, services3.ListOpts{})
+	if err != nil {
+		t.Fatalf("Unable to list services: %v", err)
+	}
+
+	err = gophercloud.EachPage(results, func(page gophercloud.Collection) bool {
+		t.Logf("--- Page ---")
+		for _, service := range services3.AsServices(page) {
+			t.Logf("Service: %32s %15s %10s %s", service.ID, service.Type, service.Name, *service.Description)
+		}
+		return true
+	})
+	if err != nil {
+		t.Errorf("Unexpected error traversing pages: %v", err)
+	}
+}
diff --git a/acceptance/openstack/identity/v3/token_test.go b/acceptance/openstack/identity/v3/token_test.go
new file mode 100644
index 0000000..d5f9ea6
--- /dev/null
+++ b/acceptance/openstack/identity/v3/token_test.go
@@ -0,0 +1,48 @@
+// +build acceptance
+
+package v3
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud/openstack"
+	tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+	"github.com/rackspace/gophercloud/openstack/utils"
+)
+
+func TestGetToken(t *testing.T) {
+	// Obtain credentials from the environment.
+	ao, err := utils.AuthOptions()
+	if err != nil {
+		t.Fatalf("Unable to acquire credentials: %v", err)
+	}
+
+	// Trim out unused fields. Skip if we don't have a UserID.
+	ao.Username, ao.TenantID, ao.TenantName = "", "", ""
+	if ao.UserID == "" {
+		t.Logf("Skipping identity v3 tests because no OS_USERID is present.")
+		return
+	}
+
+	// Create an unauthenticated client.
+	provider, err := openstack.NewClient(ao.IdentityEndpoint)
+	if err != nil {
+		t.Fatalf("Unable to instantiate client: %v", err)
+	}
+
+	// Create a service client.
+	service := openstack.NewIdentityV3(provider)
+
+	// Use the service to create a token.
+	result, err := tokens3.Create(service, ao, nil)
+	if err != nil {
+		t.Fatalf("Unable to get token: %v", err)
+	}
+
+	token, err := result.TokenID()
+	if err != nil {
+		t.Fatalf("Unable to extract token from response: %v", err)
+	}
+
+	t.Logf("Acquired token: %s", token)
+}
diff --git a/acceptance/openstack/identity_test.go b/acceptance/openstack/identity_test.go
index f9c26ae..8b6035d 100644
--- a/acceptance/openstack/identity_test.go
+++ b/acceptance/openstack/identity_test.go
@@ -4,11 +4,13 @@
 
 import (
 	"fmt"
-	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
-	"github.com/rackspace/gophercloud/openstack/utils"
 	"os"
 	"testing"
 	"text/tabwriter"
+
+	"github.com/rackspace/gophercloud"
+	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
+	"github.com/rackspace/gophercloud/openstack/utils"
 )
 
 type extractor func(*identity.Token) string
@@ -23,7 +25,8 @@
 	}
 
 	// Attempt to authenticate with them.
-	r, err := identity.Authenticate(ao)
+	client := &gophercloud.ServiceClient{Endpoint: ao.IdentityEndpoint + "/"}
+	r, err := identity.Authenticate(client, ao)
 	if err != nil {
 		t.Error(err)
 		return
@@ -39,7 +42,7 @@
 	// Authentication tokens have a variety of fields which might be of some interest.
 	// Let's print a few of them out.
 	table := map[string]extractor{
-		"ID":      func(t *identity.Token) string { return tok.Id },
+		"ID":      func(t *identity.Token) string { return tok.ID },
 		"Expires": func(t *identity.Token) string { return tok.Expires },
 	}
 
@@ -75,7 +78,7 @@
 		fmt.Printf("Endpoints for %s/%s\n", ce.Name, ce.Type)
 		fmt.Fprintln(w, "Version\tRegion\tTenant\tPublic URL\tInternal URL\t")
 		for _, ep := range ce.Endpoints {
-			fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t\n", ep.VersionId, ep.Region, ep.TenantId, ep.PublicURL, ep.InternalURL)
+			fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t\n", ep.VersionID, ep.Region, ep.TenantID, ep.PublicURL, ep.InternalURL)
 		}
 		w.Flush()
 	}
@@ -91,7 +94,8 @@
 	}
 
 	// Attempt to query extensions.
-	exts, err := identity.GetExtensions(ao)
+	client := &gophercloud.ServiceClient{Endpoint: ao.IdentityEndpoint + "/"}
+	exts, err := identity.GetExtensions(client, ao)
 	if err != nil {
 		t.Error(err)
 		return
diff --git a/acceptance/tools/tools.go b/acceptance/tools/tools.go
index 5891c7f..594bfba 100644
--- a/acceptance/tools/tools.go
+++ b/acceptance/tools/tools.go
@@ -5,18 +5,20 @@
 import (
 	"crypto/rand"
 	"fmt"
-	"github.com/rackspace/gophercloud/openstack/compute/servers"
-	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
-	"github.com/rackspace/gophercloud/openstack/utils"
 	"os"
 	"text/tabwriter"
 	"time"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/compute/servers"
+	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
+	"github.com/rackspace/gophercloud/openstack/utils"
 )
 
 var errTimeout = fmt.Errorf("Timeout.")
 
 type testState struct {
-	O              identity.AuthOptions
+	O              gophercloud.AuthOptions
 	A              identity.AuthResults
 	SC             *identity.ServiceCatalog
 	EPs            []identity.Endpoint
@@ -44,7 +46,8 @@
 		return ts, err
 	}
 
-	ts.A, err = identity.Authenticate(ts.O)
+	client := &gophercloud.ServiceClient{Endpoint: ts.O.IdentityEndpoint + "/"}
+	ts.A, err = identity.Authenticate(client, ts.O)
 	if err != nil {
 		return ts, err
 	}
diff --git a/auth_options.go b/auth_options.go
new file mode 100644
index 0000000..f9e6ee5
--- /dev/null
+++ b/auth_options.go
@@ -0,0 +1,36 @@
+package gophercloud
+
+// AuthOptions lets anyone calling Authenticate() supply the required access credentials.
+// Its fields are the union of those recognized by each identity implementation and provider.
+type AuthOptions struct {
+
+	// IdentityEndpoint specifies the HTTP endpoint offering the Identity API of the appropriate version.
+	// Required by the identity services, but often populated by a provider Client.
+	IdentityEndpoint string
+
+	// Username is required if using Identity V2 API.
+	// Consult with your provider's control panel to discover your account's username.
+	// In Identity V3, either UserID or a combination of Username and DomainID or DomainName.
+	Username, UserID string
+
+	// Exactly one of Password or ApiKey is required for the Identity V2 and V3 APIs.
+	// Consult with your provider's control panel to discover your account's preferred method of authentication.
+	Password, APIKey string
+
+	// At most one of DomainID and DomainName must be provided if using Username with Identity V3.
+	// Otherwise, either are optional.
+	DomainID, DomainName string
+
+	// The TenantID and TenantName fields are optional for the Identity V2 API.
+	// Some providers allow you to specify a TenantName instead of the TenantId.
+	// Some require both.  Your provider's authentication policies will determine
+	// how these fields influence authentication.
+	TenantID, TenantName string
+
+	// AllowReauth should be set to true if you grant permission for Gophercloud to
+	// cache your credentials in memory, and to allow Gophercloud to attempt to
+	// re-authenticate automatically if/when your token expires.  If you set it to
+	// false, it will not cache these settings, but re-authentication will not be
+	// possible.  This setting defaults to false.
+	AllowReauth bool
+}
diff --git a/auth_results.go b/auth_results.go
new file mode 100644
index 0000000..07e0fc7
--- /dev/null
+++ b/auth_results.go
@@ -0,0 +1,16 @@
+package gophercloud
+
+import "time"
+
+// AuthResults encapsulates the raw results from an authentication request. As OpenStack allows
+// extensions to influence the structure returned in ways that Gophercloud cannot predict at
+// compile-time, you should use type-safe accessors to work with the data represented by this type,
+// such as ServiceCatalog() and TokenID().
+type AuthResults interface {
+
+	// Retrieve the authentication token's value from the authentication response.
+	TokenID() (string, error)
+
+	// ExpiresAt retrieves the token's expiration time.
+	ExpiresAt() (time.Time, error)
+}
diff --git a/collections.go b/collections.go
new file mode 100644
index 0000000..443a000
--- /dev/null
+++ b/collections.go
@@ -0,0 +1,164 @@
+package gophercloud
+
+import (
+	"errors"
+
+	"github.com/racker/perigee"
+)
+
+var (
+	// ErrPageNotAvailable is returned from a Pager when a next or previous page is requested, but does not exist.
+	ErrPageNotAvailable = errors.New("The requested Collection page does not exist.")
+)
+
+// Collection describes the minimum functionality that any collection resource must implement to be able to use
+// the global paging and iteration functions.
+// Every resource that returns a list of multiple results must implement this functionality, whether or not it is paged.
+// In addition to the methods provided here, each collection should also provide an AsItem(Page) method that
+// casts the Page to its more specific type and returns the Page's contents as a slice.
+type Collection interface {
+
+	// Pager returns one of the concrete Pager implementations from this package, or a custom one.
+	// The style of Pager returned determines how the collection is paged.
+	Pager() Pager
+
+	// Concat the contents of another collection on to the end of this one.
+	// Return a new collection that contains elements from both.
+	Concat(Collection) Collection
+}
+
+// EachPage iterates through a Collection one page at a time.
+// The handler function will be invoked with a Collection containing each page.
+// If the handler returns true, iteration will continue. If it returns false, no more pages will be fetched.
+func EachPage(first Collection, handler func(Collection) bool) error {
+	p := first.Pager()
+	var err error
+	current := first
+
+	for {
+		if !handler(current) {
+			return nil
+		}
+
+		if !p.HasNextPage() {
+			return nil
+		}
+
+		current, err = p.NextPage()
+		if err != nil {
+			return err
+		}
+	}
+}
+
+// AllPages consolidates all pages reachable from a provided starting point into a single mega-Page.
+// Use this only when you know that the full set will always fit within memory.
+func AllPages(first Collection) (Collection, error) {
+	megaPage := first
+	isFirst := true
+
+	err := EachPage(first, func(page Collection) bool {
+		if isFirst {
+			isFirst = false
+		} else {
+			megaPage = megaPage.Concat(page)
+		}
+		return true
+	})
+
+	return megaPage, err
+}
+
+// Pager describes a specific paging idiom for a Collection resource.
+// Generally, to use a Pager, the Collection must also implement a more specialized interface than Collection.
+// Clients should not generally interact with Pagers directly.
+// Instead, use the more convenient collection traversal methods: AllPages and EachPage.
+type Pager interface {
+
+	// HasNextPage returns true if a call to NextPage will return an additional Page of results.
+	HasNextPage() bool
+
+	// NextPage returns the next Page in the sequence.
+	// Panics if no page is available, so always check HasNextPage first.
+	NextPage() (Collection, error)
+}
+
+// SinglePager is used by collections that are not actually paged.
+// It has no additional interface requirements for its host Page.
+type SinglePager struct{}
+
+// HasNextPage always reports false.
+func (p SinglePager) HasNextPage() bool {
+	return false
+}
+
+// NextPage always returns an ErrPageNotAvailable.
+func (p SinglePager) NextPage() (Collection, error) {
+	return nil, ErrPageNotAvailable
+}
+
+// PaginationLinks stores the `next` and `previous` links that are provided by some (but not all) paginated resources.
+type PaginationLinks struct {
+
+	// Next is the full URL to the next page of results, or nil if this is the last page.
+	Next *string `json:"next,omitempty"`
+
+	// Previous is the full URL to the previous page of results, or nil if this is the first page.
+	Previous *string `json:"previous,omitempty"`
+}
+
+// LinkCollection must be satisfied by a Page that uses a LinkPager.
+type LinkCollection interface {
+	Collection
+
+	// Service returns the client used to make further requests.
+	Service() *ServiceClient
+
+	// Links returns the pagination links from a single page.
+	Links() PaginationLinks
+
+	// Interpret an arbitrary JSON result as a new LinkCollection.
+	Interpret(interface{}) (LinkCollection, error)
+}
+
+// LinkPager implements paging for collections that provide a link structure in their response JSON.
+// It follows explicit `next` links and stops when the `next` link is "null".
+type LinkPager struct {
+	current LinkCollection
+}
+
+// NewLinkPager creates and initializes a pager for a LinkCollection.
+func NewLinkPager(first LinkCollection) *LinkPager {
+	return &LinkPager{current: first}
+}
+
+// HasNextPage checks the `next` link in the pagination data.
+func (p *LinkPager) HasNextPage() bool {
+	return p.current.Links().Next != nil
+}
+
+// NextPage follows the `next` link to construct the next page of data.
+func (p *LinkPager) NextPage() (Collection, error) {
+	url := p.current.Links().Next
+	if url == nil {
+		return nil, ErrPageNotAvailable
+	}
+
+	var response interface{}
+	_, err := perigee.Request("GET", *url, perigee.Options{
+		MoreHeaders: p.current.Service().Provider.AuthenticatedHeaders(),
+		Results:     &response,
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	interpreted, err := p.current.Interpret(response)
+	if err != nil {
+		return nil, err
+	}
+
+	p.current = interpreted
+	return interpreted, nil
+}
diff --git a/collections_test.go b/collections_test.go
new file mode 100644
index 0000000..4b0fdc5
--- /dev/null
+++ b/collections_test.go
@@ -0,0 +1,228 @@
+package gophercloud
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+// SinglePage sample and test cases.
+
+type SinglePageCollection struct {
+	results []int
+}
+
+func (c SinglePageCollection) Pager() Pager {
+	return SinglePager{}
+}
+
+func (c SinglePageCollection) Concat(other Collection) Collection {
+	panic("Concat should never be called on a single-paged collection.")
+}
+
+func AsSingleInts(c Collection) []int {
+	return c.(SinglePageCollection).results
+}
+
+var single = SinglePageCollection{
+	results: []int{1, 2, 3},
+}
+
+func TestEnumerateSinglePaged(t *testing.T) {
+	callCount := 0
+	EachPage(single, func(page Collection) bool {
+		callCount++
+
+		expected := []int{1, 2, 3}
+		actual := AsSingleInts(page)
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Expected %v, but was %v", expected, actual)
+		}
+		return true
+	})
+
+	if callCount != 1 {
+		t.Errorf("Callback was invoked %d times", callCount)
+	}
+}
+
+func TestAllSinglePaged(t *testing.T) {
+	r, err := AllPages(single)
+	if err != nil {
+		t.Fatalf("Unexpected error when iterating pages: %v", err)
+	}
+
+	expected := []int{1, 2, 3}
+	actual := AsSingleInts(r)
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %v, but was %v", expected, actual)
+	}
+}
+
+// LinkedPager sample and test cases.
+
+type LinkedCollection struct {
+	PaginationLinks
+
+	service *ServiceClient
+	results []int
+}
+
+func (c LinkedCollection) Pager() Pager {
+	return NewLinkPager(c)
+}
+
+func (c LinkedCollection) Concat(other Collection) Collection {
+	return LinkedCollection{
+		service: c.service,
+		results: append(c.results, AsLinkedInts(other)...),
+	}
+}
+
+func (c LinkedCollection) Links() PaginationLinks {
+	return c.PaginationLinks
+}
+
+func (c LinkedCollection) Service() *ServiceClient {
+	return c.service
+}
+
+func (c LinkedCollection) Interpret(response interface{}) (LinkCollection, error) {
+	casted, ok := response.([]interface{})
+	if ok {
+		asInts := make([]int, len(casted))
+		for index, item := range casted {
+			f := item.(float64)
+			asInts[index] = int(f)
+		}
+
+		var nextURL *string
+		switch asInts[0] {
+		case 4:
+			u := testhelper.Server.URL + "/foo?page=3&perPage=3"
+			nextURL = &u
+		case 7:
+			// Leave nextURL as nil.
+		default:
+			return nil, fmt.Errorf("Unexpected resultset: %#v", asInts)
+		}
+
+		result := LinkedCollection{
+			PaginationLinks: PaginationLinks{Next: nextURL},
+			service:         c.service,
+			results:         asInts,
+		}
+		return result, nil
+	}
+	return nil, errors.New("Wat")
+}
+
+func AsLinkedInts(results Collection) []int {
+	return results.(LinkedCollection).results
+}
+
+func createLinked() LinkedCollection {
+	nextURL := testhelper.Server.URL + "/foo?page=2&perPage=3"
+	return LinkedCollection{
+		PaginationLinks: PaginationLinks{Next: &nextURL},
+		service: &ServiceClient{
+			Provider: &ProviderClient{TokenID: "1234"},
+			Endpoint: testhelper.Endpoint(),
+		},
+		results: []int{1, 2, 3},
+	}
+}
+
+func setupLinkedResponses(t *testing.T) {
+	testhelper.Mux.HandleFunc("/foo", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "X-Auth-Token", "1234")
+		w.Header().Add("Content-Type", "application/json")
+
+		r.ParseForm()
+
+		pages := r.Form["page"]
+		if len(pages) != 1 {
+			t.Errorf("Endpoint called with unexpected page: %#v", r.Form)
+		}
+
+		switch pages[0] {
+		case "2":
+			fmt.Fprintf(w, `[4, 5, 6]`)
+		case "3":
+			fmt.Fprintf(w, `[7, 8, 9]`)
+		default:
+			t.Errorf("Endpoint called with unexpected page: %s", pages[0])
+		}
+	})
+}
+
+func TestEnumerateLinked(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	setupLinkedResponses(t)
+	lc := createLinked()
+
+	callCount := 0
+	err := EachPage(lc, func(page Collection) bool {
+		actual := AsLinkedInts(page)
+		t.Logf("Handler invoked with %v", actual)
+
+		var expected []int
+		switch callCount {
+		case 0:
+			expected = []int{1, 2, 3}
+		case 1:
+			expected = []int{4, 5, 6}
+		case 2:
+			expected = []int{7, 8, 9}
+		default:
+			t.Fatalf("Unexpected call count: %d", callCount)
+			return false
+		}
+
+		if !reflect.DeepEqual(expected, actual) {
+			t.Errorf("Call %d: Expected %#v, but was %#v", callCount, expected, actual)
+		}
+
+		callCount++
+		return true
+	})
+	if err != nil {
+		t.Errorf("Unexpected error for page iteration: %v", err)
+	}
+
+	if callCount != 3 {
+		t.Errorf("Expected 3 calls, but was %d", callCount)
+	}
+}
+
+func TestAllLinked(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	setupLinkedResponses(t)
+	lc := createLinked()
+
+	all, err := AllPages(lc)
+	if err != nil {
+		t.Fatalf("Unexpected error collection all linked pages: %v", err)
+	}
+
+	actual := AsLinkedInts(all)
+	expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %v, but was %v", expected, actual)
+	}
+
+	original := []int{1, 2, 3}
+	if !reflect.DeepEqual(AsLinkedInts(lc), original) {
+		t.Errorf("AllPages modified the original page, and now it contains: %v", lc)
+	}
+}
diff --git a/endpoint_search.go b/endpoint_search.go
new file mode 100644
index 0000000..061dbaa
--- /dev/null
+++ b/endpoint_search.go
@@ -0,0 +1,51 @@
+package gophercloud
+
+import "errors"
+
+var (
+	// ErrServiceNotFound is returned when no service matches the EndpointOpts.
+	ErrServiceNotFound = errors.New("No suitable service could be found in the service catalog.")
+
+	// ErrEndpointNotFound is returned when no available endpoints match the provided EndpointOpts.
+	ErrEndpointNotFound = errors.New("No suitable endpoint could be found in the service catalog.")
+)
+
+// Interface describes the accessibility of a specific service endpoint.
+type Interface string
+
+const (
+	// InterfaceAdmin makes an endpoint only available to administrators.
+	InterfaceAdmin Interface = "admin"
+
+	// InterfacePublic makes an endpoint available to everyone.
+	InterfacePublic Interface = "public"
+
+	// InterfaceInternal makes an endpoint only available within the cluster.
+	InterfaceInternal Interface = "internal"
+)
+
+// EndpointOpts contains options for finding an endpoint for an Openstack client.
+type EndpointOpts struct {
+
+	// Type is the service type for the client (e.g., "compute", "object-store").
+	// Type is a required field.
+	Type string
+
+	// Name is the service name for the client (e.g., "nova").
+	// Name is not a required field, but it is used if present.
+	// Services can have the same Type but a different Name, which is one example of when both Type and Name are needed.
+	Name string
+
+	// Region is the region in which the service resides.
+	// Region must be specified for services that span multiple regions.
+	Region string
+
+	// Interface is the visibility of the endpoint to be returned: InterfacePublic, InterfaceInternal, or InterfaceAdmin
+	// Interface is not required, and defaults to InterfacePublic.
+	// Not all interface types are accepted by all providers or identity services.
+	Interface Interface
+}
+
+// EndpointLocator is a function that describes how to locate a single endpoint from a service catalog for a specific ProviderClient.
+// It should be set during ProviderClient authentication and used to discover related ServiceClients.
+type EndpointLocator func(EndpointOpts) (string, error)
diff --git a/openstack/client.go b/openstack/client.go
new file mode 100644
index 0000000..3bbff12
--- /dev/null
+++ b/openstack/client.go
@@ -0,0 +1,297 @@
+package openstack
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/rackspace/gophercloud"
+	identity2 "github.com/rackspace/gophercloud/openstack/identity/v2"
+	endpoints3 "github.com/rackspace/gophercloud/openstack/identity/v3/endpoints"
+	services3 "github.com/rackspace/gophercloud/openstack/identity/v3/services"
+	tokens3 "github.com/rackspace/gophercloud/openstack/identity/v3/tokens"
+	"github.com/rackspace/gophercloud/openstack/utils"
+)
+
+const (
+	v20 = "v2.0"
+	v30 = "v3.0"
+)
+
+// NewClient prepares an unauthenticated ProviderClient instance.
+// Most users will probably prefer using the AuthenticatedClient function instead.
+// This is useful if you wish to explicitly control the version of the identity service that's used for authentication explicitly,
+// for example.
+func NewClient(endpoint string) (*gophercloud.ProviderClient, error) {
+	u, err := url.Parse(endpoint)
+	if err != nil {
+		return nil, err
+	}
+	hadPath := u.Path != ""
+	u.Path, u.RawQuery, u.Fragment = "", "", ""
+	base := u.String()
+
+	if !strings.HasSuffix(endpoint, "/") {
+		endpoint = endpoint + "/"
+	}
+	if !strings.HasSuffix(base, "/") {
+		base = base + "/"
+	}
+
+	if hadPath {
+		return &gophercloud.ProviderClient{
+			IdentityBase:     base,
+			IdentityEndpoint: endpoint,
+		}, nil
+	}
+
+	return &gophercloud.ProviderClient{
+		IdentityBase:     base,
+		IdentityEndpoint: "",
+	}, nil
+}
+
+// AuthenticatedClient logs in to an OpenStack cloud found at the identity endpoint specified by options, acquires a token, and
+// returns a Client instance that's ready to operate.
+// It first queries the root identity endpoint to determine which versions of the identity service are supported, then chooses
+// the most recent identity service available to proceed.
+func AuthenticatedClient(options gophercloud.AuthOptions) (*gophercloud.ProviderClient, error) {
+	client, err := NewClient(options.IdentityEndpoint)
+	if err != nil {
+		return nil, err
+	}
+
+	err = Authenticate(client, options)
+	if err != nil {
+		return nil, err
+	}
+	return client, nil
+}
+
+// Authenticate or re-authenticate against the most recent identity service supported at the provided endpoint.
+func Authenticate(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+	versions := []*utils.Version{
+		&utils.Version{ID: v20, Priority: 20, Suffix: "/v2.0/"},
+		&utils.Version{ID: v30, Priority: 30, Suffix: "/v3/"},
+	}
+
+	chosen, endpoint, err := utils.ChooseVersion(client.IdentityBase, client.IdentityEndpoint, versions)
+	if err != nil {
+		return err
+	}
+
+	switch chosen.ID {
+	case v20:
+		return v2auth(client, endpoint, options)
+	case v30:
+		return v3auth(client, endpoint, options)
+	default:
+		// The switch statement must be out of date from the versions list.
+		return fmt.Errorf("Unrecognized identity version: %s", chosen.ID)
+	}
+}
+
+// AuthenticateV2 explicitly authenticates against the identity v2 endpoint.
+func AuthenticateV2(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+	return v2auth(client, "", options)
+}
+
+func v2auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+	v2Client := NewIdentityV2(client)
+	if endpoint != "" {
+		v2Client.Endpoint = endpoint
+	}
+
+	result, err := identity2.Authenticate(v2Client, options)
+	if err != nil {
+		return err
+	}
+
+	token, err := identity2.GetToken(result)
+	if err != nil {
+		return err
+	}
+
+	client.TokenID = token.ID
+	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+		return v2endpointLocator(result, opts)
+	}
+
+	return nil
+}
+
+func v2endpointLocator(authResults identity2.AuthResults, opts gophercloud.EndpointOpts) (string, error) {
+	catalog, err := identity2.GetServiceCatalog(authResults)
+	if err != nil {
+		return "", err
+	}
+
+	entries, err := catalog.CatalogEntries()
+	if err != nil {
+		return "", err
+	}
+
+	// Extract Endpoints from the catalog entries that match the requested Type, Name if provided, and Region if provided.
+	var endpoints = make([]identity2.Endpoint, 0, 1)
+	for _, entry := range entries {
+		if (entry.Type == opts.Type) && (opts.Name == "" || entry.Name == opts.Name) {
+			for _, endpoint := range entry.Endpoints {
+				if opts.Region == "" || endpoint.Region == opts.Region {
+					endpoints = append(endpoints, endpoint)
+				}
+			}
+		}
+	}
+
+	// Report an error if the options were ambiguous.
+	if len(endpoints) == 0 {
+		return "", gophercloud.ErrEndpointNotFound
+	}
+	if len(endpoints) > 1 {
+		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
+	}
+
+	// Extract the appropriate URL from the matching Endpoint.
+	for _, endpoint := range endpoints {
+		switch opts.Interface {
+		case gophercloud.InterfacePublic:
+			return endpoint.PublicURL, nil
+		case gophercloud.InterfaceInternal:
+			return endpoint.InternalURL, nil
+		default:
+			return "", fmt.Errorf("Unexpected interface in endpoint query: %s", opts.Interface)
+		}
+	}
+
+	return "", gophercloud.ErrEndpointNotFound
+}
+
+// AuthenticateV3 explicitly authenticates against the identity v3 service.
+func AuthenticateV3(client *gophercloud.ProviderClient, options gophercloud.AuthOptions) error {
+	return v3auth(client, "", options)
+}
+
+func v3auth(client *gophercloud.ProviderClient, endpoint string, options gophercloud.AuthOptions) error {
+	// Override the generated service endpoint with the one returned by the version endpoint.
+	v3Client := NewIdentityV3(client)
+	if endpoint != "" {
+		v3Client.Endpoint = endpoint
+	}
+
+	result, err := tokens3.Create(v3Client, options, nil)
+	if err != nil {
+		return err
+	}
+
+	client.TokenID, err = result.TokenID()
+	if err != nil {
+		return err
+	}
+	client.EndpointLocator = func(opts gophercloud.EndpointOpts) (string, error) {
+		return v3endpointLocator(v3Client, opts)
+	}
+
+	return nil
+}
+
+func v3endpointLocator(v3Client *gophercloud.ServiceClient, opts gophercloud.EndpointOpts) (string, error) {
+	// Default Interface to InterfacePublic, if it isn't provided.
+	if opts.Interface == "" {
+		opts.Interface = gophercloud.InterfacePublic
+	}
+
+	// Discover the service we're interested in.
+	serviceResults, err := services3.List(v3Client, services3.ListOpts{ServiceType: opts.Type})
+	if err != nil {
+		return "", err
+	}
+
+	allServiceResults, err := gophercloud.AllPages(serviceResults)
+	if err != nil {
+		return "", err
+	}
+	allServices := services3.AsServices(allServiceResults)
+
+	if opts.Name != "" {
+		filtered := make([]services3.Service, 0, 1)
+		for _, service := range allServices {
+			if service.Name == opts.Name {
+				filtered = append(filtered, service)
+			}
+		}
+		allServices = filtered
+	}
+
+	if len(allServices) == 0 {
+		return "", gophercloud.ErrServiceNotFound
+	}
+	if len(allServices) > 1 {
+		return "", fmt.Errorf("Discovered %d matching services: %#v", len(allServices), allServices)
+	}
+
+	service := allServices[0]
+
+	// Enumerate the endpoints available for this service.
+	endpointResults, err := endpoints3.List(v3Client, endpoints3.ListOpts{
+		Interface: opts.Interface,
+		ServiceID: service.ID,
+	})
+	if err != nil {
+		return "", err
+	}
+	allEndpoints, err := gophercloud.AllPages(endpointResults)
+	if err != nil {
+		return "", err
+	}
+	endpoints := endpoints3.AsEndpoints(allEndpoints)
+
+	if opts.Name != "" {
+		filtered := make([]endpoints3.Endpoint, 0, 1)
+		for _, endpoint := range endpoints {
+			if opts.Region == "" || endpoint.Region == opts.Region {
+				filtered = append(filtered, endpoint)
+			}
+		}
+		endpoints = filtered
+	}
+
+	if len(endpoints) == 0 {
+		return "", gophercloud.ErrEndpointNotFound
+	}
+	if len(endpoints) > 1 {
+		return "", fmt.Errorf("Discovered %d matching endpoints: %#v", len(endpoints), endpoints)
+	}
+
+	endpoint := endpoints[0]
+
+	return endpoint.URL, nil
+}
+
+// NewIdentityV2 creates a ServiceClient that may be used to interact with the v2 identity service.
+func NewIdentityV2(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+	v2Endpoint := client.IdentityBase + "v2.0/"
+
+	return &gophercloud.ServiceClient{
+		Provider: client,
+		Endpoint: v2Endpoint,
+	}
+}
+
+// NewIdentityV3 creates a ServiceClient that may be used to access the v3 identity service.
+func NewIdentityV3(client *gophercloud.ProviderClient) *gophercloud.ServiceClient {
+	v3Endpoint := client.IdentityBase + "v3/"
+
+	return &gophercloud.ServiceClient{
+		Provider: client,
+		Endpoint: v3Endpoint,
+	}
+}
+
+// NewStorageV1 creates a ServiceClient that may be used with the v1 object storage package.
+func NewStorageV1(client *gophercloud.ProviderClient, region string) (*gophercloud.ServiceClient, error) {
+	url, err := client.EndpointLocator(gophercloud.EndpointOpts{Type: "object-store", Name: "swift"})
+	if err != nil {
+		return nil, err
+	}
+	return &gophercloud.ServiceClient{Provider: client, Endpoint: url}, nil
+}
diff --git a/openstack/client_test.go b/openstack/client_test.go
new file mode 100644
index 0000000..dd39e77
--- /dev/null
+++ b/openstack/client_test.go
@@ -0,0 +1,173 @@
+package openstack
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestAuthenticatedClientV3(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	const ID = "0123456789"
+
+	testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"versions": {
+					"values": [
+						{
+							"status": "stable",
+							"id": "v3.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						},
+						{
+							"status": "stable",
+							"id": "v2.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						}
+					]
+				}
+			}
+		`, testhelper.Endpoint()+"v3/", testhelper.Endpoint()+"v2.0/")
+	})
+
+	testhelper.Mux.HandleFunc("/v3/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("X-Subject-Token", ID)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{ "token": { "expires_at": "2013-02-02T18:30:59.000000Z" } }`)
+	})
+
+	options := gophercloud.AuthOptions{
+		UserID:           "me",
+		Password:         "secret",
+		IdentityEndpoint: testhelper.Endpoint(),
+	}
+	client, err := AuthenticatedClient(options)
+
+	if err != nil {
+		t.Fatalf("Unexpected error from AuthenticatedClient: %s", err)
+	}
+
+	if client.TokenID != ID {
+		t.Errorf("Expected token ID to be [%s], but was [%s]", ID, client.TokenID)
+	}
+}
+
+func TestAuthenticatedClientV2(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"versions": {
+					"values": [
+						{
+							"status": "experimental",
+							"id": "v3.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						},
+						{
+							"status": "stable",
+							"id": "v2.0",
+							"links": [
+								{ "href": "%s", "rel": "self" }
+							]
+						}
+					]
+				}
+			}
+		`, testhelper.Endpoint()+"v3/", testhelper.Endpoint()+"v2.0/")
+	})
+
+	testhelper.Mux.HandleFunc("/v2.0/tokens", func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `
+			{
+				"access": {
+					"token": {
+						"id": "01234567890"
+					},
+					"serviceCatalog": [
+						{
+							"name": "Cloud Servers",
+							"type": "compute",
+							"endpoints": [
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://compute.north.host.com/v1/t1000",
+									"internalURL": "https://compute.north.internal/v1/t1000",
+									"region": "North",
+									"versionId": "1",
+									"versionInfo": "https://compute.north.host.com/v1/",
+									"versionList": "https://compute.north.host.com/"
+								},
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://compute.north.host.com/v1.1/t1000",
+									"internalURL": "https://compute.north.internal/v1.1/t1000",
+									"region": "North",
+									"versionId": "1.1",
+									"versionInfo": "https://compute.north.host.com/v1.1/",
+									"versionList": "https://compute.north.host.com/"
+								}
+							],
+							"endpoints_links": []
+						},
+						{
+							"name": "Cloud Files",
+							"type": "object-store",
+							"endpoints": [
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://storage.north.host.com/v1/t1000",
+									"internalURL": "https://storage.north.internal/v1/t1000",
+									"region": "North",
+									"versionId": "1",
+									"versionInfo": "https://storage.north.host.com/v1/",
+									"versionList": "https://storage.north.host.com/"
+								},
+								{
+									"tenantId": "t1000",
+									"publicURL": "https://storage.south.host.com/v1/t1000",
+									"internalURL": "https://storage.south.internal/v1/t1000",
+									"region": "South",
+									"versionId": "1",
+									"versionInfo": "https://storage.south.host.com/v1/",
+									"versionList": "https://storage.south.host.com/"
+								}
+							]
+						}
+					]
+				}
+			}
+		`)
+	})
+
+	options := gophercloud.AuthOptions{
+		Username:         "me",
+		Password:         "secret",
+		IdentityEndpoint: testhelper.Endpoint(),
+	}
+	client, err := AuthenticatedClient(options)
+
+	if err != nil {
+		t.Fatalf("Unexpected error from AuthenticatedClient: %s", err)
+	}
+
+	if client.TokenID != "01234567890" {
+		t.Errorf("Expected token ID to be [01234567890], but was [%s]", client.TokenID)
+	}
+}
diff --git a/openstack/compute/v2/flavors/client.go b/openstack/compute/v2/flavors/client.go
index 5676d36..edeec66 100644
--- a/openstack/compute/v2/flavors/client.go
+++ b/openstack/compute/v2/flavors/client.go
@@ -2,18 +2,20 @@
 
 import (
 	"fmt"
-	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 	"net/url"
 	"strconv"
+
+	"github.com/rackspace/gophercloud"
+	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 )
 
 type Client struct {
 	endpoint  string
 	authority identity.AuthResults
-	options   identity.AuthOptions
+	options   gophercloud.AuthOptions
 }
 
-func NewClient(e string, a identity.AuthResults, ao identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, ao gophercloud.AuthOptions) *Client {
 	return &Client{
 		endpoint:  e,
 		authority: a,
@@ -56,6 +58,6 @@
 	}
 
 	return map[string]string{
-		"X-Auth-Token": t.Id,
+		"X-Auth-Token": t.ID,
 	}, nil
 }
diff --git a/openstack/compute/v2/images/client.go b/openstack/compute/v2/images/client.go
index 3390b99..6322b9c 100644
--- a/openstack/compute/v2/images/client.go
+++ b/openstack/compute/v2/images/client.go
@@ -1,16 +1,17 @@
 package images
 
 import (
+	"github.com/rackspace/gophercloud"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 )
 
 type Client struct {
 	endpoint  string
 	authority identity.AuthResults
-	options   identity.AuthOptions
+	options   gophercloud.AuthOptions
 }
 
-func NewClient(e string, a identity.AuthResults, ao identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, ao gophercloud.AuthOptions) *Client {
 	return &Client{
 		endpoint:  e,
 		authority: a,
@@ -29,6 +30,6 @@
 	}
 
 	return map[string]string{
-		"X-Auth-Token": t.Id,
+		"X-Auth-Token": t.ID,
 	}, nil
 }
diff --git a/openstack/compute/v2/servers/client.go b/openstack/compute/v2/servers/client.go
index 611ff22..8c79f94 100644
--- a/openstack/compute/v2/servers/client.go
+++ b/openstack/compute/v2/servers/client.go
@@ -2,6 +2,8 @@
 
 import (
 	"fmt"
+
+	"github.com/rackspace/gophercloud"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 )
 
@@ -9,12 +11,12 @@
 type Client struct {
 	endpoint  string
 	authority identity.AuthResults
-	options   identity.AuthOptions
+	options   gophercloud.AuthOptions
 	token     *identity.Token
 }
 
 // NewClient creates a new Client structure to use when issuing requests to the server.
-func NewClient(e string, a identity.AuthResults, o identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, o gophercloud.AuthOptions) *Client {
 	return &Client{
 		endpoint:  e,
 		authority: a,
@@ -87,5 +89,5 @@
 		}
 	}
 
-	return c.token.Id, err
+	return c.token.ID, err
 }
diff --git a/openstack/identity/v2/common_test.go b/openstack/identity/v2/common_test.go
index 18c5340..0260424 100644
--- a/openstack/identity/v2/common_test.go
+++ b/openstack/identity/v2/common_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 // Taken from: http://docs.openstack.org/api/openstack-identity-service/2.0/content/POST_authenticate_v2.0_tokens_.html
 const authResultsOK = `{
@@ -160,20 +160,20 @@
 					}
 				],
 				"namespace": "http://docs.openstack.org/identity/api/ext/OS-SIMPLE-CERT/v1.0",
-				"alias": "OS-SIMPLE-CERT",															
-				"description": "OpenStack simple certificate retrieval extension"	
-			},																
+				"alias": "OS-SIMPLE-CERT",
+				"description": "OpenStack simple certificate retrieval extension"
+			},
 			{
-				"updated": "2013-07-07T12:00:0-00:00",	
+				"updated": "2013-07-07T12:00:0-00:00",
 				"name": "OpenStack EC2 API",
 				"links": [
 					{
-						"href": "https://github.com/openstack/identity-api",	
-						"type": "text/html",				
-						"rel": "describedby"				
-					}	
+						"href": "https://github.com/openstack/identity-api",
+						"type": "text/html",
+						"rel": "describedby"
+					}
 				],
-				"namespace": "http://docs.openstack.org/identity/api/ext/OS-EC2/v1.0",	
+				"namespace": "http://docs.openstack.org/identity/api/ext/OS-EC2/v1.0",
 				"alias": "OS-EC2",
 				"description": "OpenStack EC2 Credentials backend."
 			}
diff --git a/openstack/identity/v2/doc.go b/openstack/identity/v2/doc.go
index 081950d..e8ab21b 100644
--- a/openstack/identity/v2/doc.go
+++ b/openstack/identity/v2/doc.go
@@ -1,5 +1,5 @@
 /*
-The Identity package provides convenient OpenStack Identity V2 API client access.
+Package v2 identity provides convenient OpenStack Identity V2 API client access.
 This package currently doesn't support the administrative access endpoints, but may appear in the future based on demand.
 
 Authentication
@@ -117,4 +117,4 @@
 		}
 	}
 */
-package identity
+package v2
diff --git a/openstack/identity/v2/errors.go b/openstack/identity/v2/errors.go
index efa7c85..0dd1172 100644
--- a/openstack/identity/v2/errors.go
+++ b/openstack/identity/v2/errors.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import "fmt"
 
diff --git a/openstack/identity/v2/extensions.go b/openstack/identity/v2/extensions.go
index 9cf9c78..df05a36 100644
--- a/openstack/identity/v2/extensions.go
+++ b/openstack/identity/v2/extensions.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import (
 	"github.com/mitchellh/mapstructure"
diff --git a/openstack/identity/v2/extensions_test.go b/openstack/identity/v2/extensions_test.go
index 3000fc0..47f7e06 100644
--- a/openstack/identity/v2/extensions_test.go
+++ b/openstack/identity/v2/extensions_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import (
 	"encoding/json"
diff --git a/openstack/identity/v2/requests.go b/openstack/identity/v2/requests.go
index dbd367e..bb068b6 100644
--- a/openstack/identity/v2/requests.go
+++ b/openstack/identity/v2/requests.go
@@ -1,7 +1,8 @@
-package identity
+package v2
 
 import (
 	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
 )
 
 // AuthResults encapsulates the raw results from an authentication request.
@@ -11,60 +12,25 @@
 // such as ServiceCatalog() and Token().
 type AuthResults map[string]interface{}
 
-// AuthOptions lets anyone calling Authenticate() supply the required access
-// credentials.  At present, only Identity V2 API support exists; therefore,
-// only Username, Password, and optionally, TenantId are provided.  If future
-// Identity API versions become available, alternative fields unique to those
-// versions may appear here.
-//
-// Endpoint specifies the HTTP endpoint offering the Identity V2 API.
-// Required.
-//
-// Username is required if using Identity V2 API.  Consult with your provider's
-// control panel to discover your account's username.
-//
-// At most one of Password or ApiKey is required if using Identity V2 API.
-// Consult with your provider's control panel to discover your account's
-// preferred method of authentication.
-//
-// The TenantId and TenantName fields are optional for the Identity V2 API.
-// Some providers allow you to specify a TenantName instead of the TenantId.
-// Some require both.  Your provider's authentication policies will determine
-// how these fields influence authentication.
-//
-// AllowReauth should be set to true if you grant permission for Gophercloud to
-// cache your credentials in memory, and to allow Gophercloud to attempt to
-// re-authenticate automatically if/when your token expires.  If you set it to
-// false, it will not cache these settings, but re-authentication will not be
-// possible.  This setting defaults to false.
-type AuthOptions struct {
-	Endpoint         string
-	Username         string
-	Password, ApiKey string
-	TenantId         string
-	TenantName       string
-	AllowReauth      bool
-}
-
 // Authenticate passes the supplied credentials to the OpenStack provider for authentication.
 // If successful, the caller may use Token() to retrieve the authentication token,
 // and ServiceCatalog() to retrieve the set of services available to the API user.
-func Authenticate(options AuthOptions) (AuthResults, error) {
+func Authenticate(c *gophercloud.ServiceClient, options gophercloud.AuthOptions) (AuthResults, error) {
 	type AuthContainer struct {
 		Auth auth `json:"auth"`
 	}
 
 	var ar AuthResults
 
-	if options.Endpoint == "" {
+	if c.Endpoint == "" {
 		return nil, ErrEndpoint
 	}
 
-	if (options.Username == "") || (options.Password == "" && options.ApiKey == "") {
+	if (options.Username == "") || (options.Password == "" && options.APIKey == "") {
 		return nil, ErrCredentials
 	}
 
-	url := options.Endpoint + "/tokens"
+	url := c.Endpoint + "tokens"
 	err := perigee.Post(url, perigee.Options{
 		ReqBody: &AuthContainer{
 			Auth: getAuthCredentials(options),
@@ -74,8 +40,8 @@
 	return ar, err
 }
 
-func getAuthCredentials(options AuthOptions) auth {
-	if options.ApiKey == "" {
+func getAuthCredentials(options gophercloud.AuthOptions) auth {
+	if options.APIKey == "" {
 		return auth{
 			PasswordCredentials: &struct {
 				Username string `json:"username"`
@@ -84,35 +50,35 @@
 				Username: options.Username,
 				Password: options.Password,
 			},
-			TenantId:   options.TenantId,
+			TenantID:   options.TenantID,
 			TenantName: options.TenantName,
 		}
-	} else {
-		return auth{
-			ApiKeyCredentials: &struct {
-				Username string `json:"username"`
-				ApiKey   string `json:"apiKey"`
-			}{
-				Username: options.Username,
-				ApiKey:   options.ApiKey,
-			},
-			TenantId:   options.TenantId,
-			TenantName: options.TenantName,
-		}
+	}
+	return auth{
+		APIKeyCredentials: &struct {
+			Username string `json:"username"`
+			APIKey   string `json:"apiKey"`
+		}{
+			Username: options.Username,
+			APIKey:   options.APIKey,
+		},
+		TenantID:   options.TenantID,
+		TenantName: options.TenantName,
 	}
 }
 
 type auth struct {
 	PasswordCredentials interface{} `json:"passwordCredentials,omitempty"`
-	ApiKeyCredentials   interface{} `json:"RAX-KSKEY:apiKeyCredentials,omitempty"`
-	TenantId            string      `json:"tenantId,omitempty"`
+	APIKeyCredentials   interface{} `json:"RAX-KSKEY:apiKeyCredentials,omitempty"`
+	TenantID            string      `json:"tenantId,omitempty"`
 	TenantName          string      `json:"tenantName,omitempty"`
 }
 
-func GetExtensions(options AuthOptions) (ExtensionsResult, error) {
+// GetExtensions returns the OpenStack extensions available from this service.
+func GetExtensions(c *gophercloud.ServiceClient, options gophercloud.AuthOptions) (ExtensionsResult, error) {
 	var exts ExtensionsResult
 
-	url := options.Endpoint + "/extensions"
+	url := c.Endpoint + "extensions"
 	err := perigee.Get(url, perigee.Options{
 		Results: &exts,
 	})
diff --git a/openstack/identity/v2/service_catalog.go b/openstack/identity/v2/service_catalog.go
index 035f671..22d0d7b 100644
--- a/openstack/identity/v2/service_catalog.go
+++ b/openstack/identity/v2/service_catalog.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import "github.com/mitchellh/mapstructure"
 
@@ -40,11 +40,11 @@
 //
 // In all cases, fields which aren't supported by the provider and service combined will assume a zero-value ("").
 type Endpoint struct {
-	TenantId    string
+	TenantID    string
 	PublicURL   string
 	InternalURL string
 	Region      string
-	VersionId   string
+	VersionID   string
 	VersionInfo string
 	VersionList string
 }
diff --git a/openstack/identity/v2/service_catalog_test.go b/openstack/identity/v2/service_catalog_test.go
index f810609..143b48f 100644
--- a/openstack/identity/v2/service_catalog_test.go
+++ b/openstack/identity/v2/service_catalog_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import (
 	"encoding/json"
@@ -45,8 +45,8 @@
 		return
 	}
 	for _, ep := range eps {
-		if strNotInStrList(ep.VersionId, "1", "1.1", "1.1") {
-			t.Errorf("Expected versionId field of compute resource to be one of 1 or 1.1")
+		if strNotInStrList(ep.VersionID, "1", "1.1", "1.1") {
+			t.Errorf("Expected versionID field of compute resource to be one of 1 or 1.1")
 			return
 		}
 	}
@@ -57,7 +57,7 @@
 		return
 	}
 	for _, ep := range eps {
-		if ep.VersionId != "1" {
+		if ep.VersionID != "1" {
 			t.Errorf("Expected only version 1 object store API version")
 			return
 		}
@@ -68,7 +68,7 @@
 		t.Errorf("Expected 1 endpoint for DNS-as-a-Service service")
 		return
 	}
-	if eps[0].VersionId != "2.0" {
+	if eps[0].VersionID != "2.0" {
 		t.Errorf("Expected version 2.0 of DNS-as-a-Service service")
 		return
 	}
diff --git a/openstack/identity/v2/token.go b/openstack/identity/v2/token.go
index d50cce0..df67d76 100644
--- a/openstack/identity/v2/token.go
+++ b/openstack/identity/v2/token.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import (
 	"github.com/mitchellh/mapstructure"
@@ -21,21 +21,21 @@
 //
 // TenantName provides a human-readable tenant name corresponding to the TenantId.
 type Token struct {
-	Id, Expires          string
-	TenantId, TenantName string
+	ID, Expires          string
+	TenantID, TenantName string
 }
 
-// GetToken, if successful, yields an unpacked collection of fields related to the user's access credentials, called a "token."
+// GetToken yields an unpacked collection of fields related to the user's access credentials, called a "token", if successful.
 // See the Token structure for more details.
 func GetToken(m AuthResults) (*Token, error) {
 	type (
 		Tenant struct {
-			Id   string
+			ID   string
 			Name string
 		}
 
 		TokenDesc struct {
-			Id      string `mapstructure:"id"`
+			ID      string `mapstructure:"id"`
 			Expires string `mapstructure:"expires"`
 			Tenant
 		}
@@ -55,9 +55,9 @@
 		return nil, err
 	}
 	td := &Token{
-		Id:         t.Id,
+		ID:         t.ID,
 		Expires:    t.Expires,
-		TenantId:   t.Tenant.Id,
+		TenantID:   t.Tenant.ID,
 		TenantName: t.Tenant.Name,
 	}
 	return td, nil
diff --git a/openstack/identity/v2/token_test.go b/openstack/identity/v2/token_test.go
index 5e96496..9770ed5 100644
--- a/openstack/identity/v2/token_test.go
+++ b/openstack/identity/v2/token_test.go
@@ -1,4 +1,4 @@
-package identity
+package v2
 
 import (
 	"encoding/json"
@@ -18,8 +18,8 @@
 		t.Error(err)
 		return
 	}
-	if tok.Id != "ab48a9efdfedb23ty3494" {
-		t.Errorf("Expected token \"ab48a9efdfedb23ty3494\"; got \"%s\" instead", tok.Id)
+	if tok.ID != "ab48a9efdfedb23ty3494" {
+		t.Errorf("Expected token \"ab48a9efdfedb23ty3494\"; got \"%s\" instead", tok.ID)
 		return
 	}
 }
diff --git a/openstack/identity/v3/endpoints/doc.go b/openstack/identity/v3/endpoints/doc.go
new file mode 100644
index 0000000..7d38ee3
--- /dev/null
+++ b/openstack/identity/v3/endpoints/doc.go
@@ -0,0 +1,3 @@
+// Package endpoints queries and manages service endpoints.
+// Reference: http://developer.openstack.org/api-ref-identity-v3.html#endpoints-v3
+package endpoints
diff --git a/openstack/identity/v3/endpoints/errors.go b/openstack/identity/v3/endpoints/errors.go
new file mode 100644
index 0000000..a8b00d4
--- /dev/null
+++ b/openstack/identity/v3/endpoints/errors.go
@@ -0,0 +1,21 @@
+package endpoints
+
+import "fmt"
+
+func requiredAttribute(attribute string) error {
+	return fmt.Errorf("You must specify %s for this endpoint.", attribute)
+}
+
+var (
+	// ErrInterfaceRequired is reported if an Endpoint is created without an Interface.
+	ErrInterfaceRequired = requiredAttribute("an interface")
+
+	// ErrNameRequired is reported if an Endpoint is created without a Name.
+	ErrNameRequired = requiredAttribute("a name")
+
+	// ErrURLRequired is reported if an Endpoint is created without a URL.
+	ErrURLRequired = requiredAttribute("a URL")
+
+	// ErrServiceIDRequired is reported if an Endpoint is created without a ServiceID.
+	ErrServiceIDRequired = requiredAttribute("a serviceID")
+)
diff --git a/openstack/identity/v3/endpoints/requests.go b/openstack/identity/v3/endpoints/requests.go
new file mode 100644
index 0000000..65d505a
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests.go
@@ -0,0 +1,174 @@
+package endpoints
+
+import (
+	"strconv"
+
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/utils"
+)
+
+// maybeString returns nil for empty strings and nil for empty.
+func maybeString(original string) *string {
+	if original != "" {
+		return &original
+	}
+	return nil
+}
+
+// EndpointOpts contains the subset of Endpoint attributes that should be used to create or update an Endpoint.
+type EndpointOpts struct {
+	Interface gophercloud.Interface
+	Name      string
+	Region    string
+	URL       string
+	ServiceID string
+}
+
+// Create inserts a new Endpoint into the service catalog.
+// Within EndpointOpts, Region may be omitted by being left as "", but all other fields are required.
+func Create(client *gophercloud.ServiceClient, opts EndpointOpts) (*Endpoint, error) {
+	// Redefined so that Region can be re-typed as a *string, which can be omitted from the JSON output.
+	type endpoint struct {
+		Interface string  `json:"interface"`
+		Name      string  `json:"name"`
+		Region    *string `json:"region,omitempty"`
+		URL       string  `json:"url"`
+		ServiceID string  `json:"service_id"`
+	}
+
+	type request struct {
+		Endpoint endpoint `json:"endpoint"`
+	}
+
+	type response struct {
+		Endpoint Endpoint `json:"endpoint"`
+	}
+
+	// Ensure that EndpointOpts is fully populated.
+	if opts.Interface == "" {
+		return nil, ErrInterfaceRequired
+	}
+	if opts.Name == "" {
+		return nil, ErrNameRequired
+	}
+	if opts.URL == "" {
+		return nil, ErrURLRequired
+	}
+	if opts.ServiceID == "" {
+		return nil, ErrServiceIDRequired
+	}
+
+	// Populate the request body.
+	reqBody := request{
+		Endpoint: endpoint{
+			Interface: string(opts.Interface),
+			Name:      opts.Name,
+			URL:       opts.URL,
+			ServiceID: opts.ServiceID,
+		},
+	}
+	reqBody.Endpoint.Region = maybeString(opts.Region)
+
+	var respBody response
+	_, err := perigee.Request("POST", getListURL(client), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &respBody,
+		OkCodes:     []int{201},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &respBody.Endpoint, nil
+}
+
+// ListOpts allows finer control over the the endpoints returned by a List call.
+// All fields are optional.
+type ListOpts struct {
+	Interface gophercloud.Interface
+	ServiceID string
+	Page      int
+	PerPage   int
+}
+
+// List enumerates endpoints in a paginated collection, optionally filtered by ListOpts criteria.
+func List(client *gophercloud.ServiceClient, opts ListOpts) (*EndpointList, error) {
+	q := make(map[string]string)
+	if opts.Interface != "" {
+		q["interface"] = string(opts.Interface)
+	}
+	if opts.ServiceID != "" {
+		q["service_id"] = opts.ServiceID
+	}
+	if opts.Page != 0 {
+		q["page"] = strconv.Itoa(opts.Page)
+	}
+	if opts.PerPage != 0 {
+		q["per_page"] = strconv.Itoa(opts.Page)
+	}
+
+	u := getListURL(client) + utils.BuildQuery(q)
+
+	var respBody EndpointList
+	_, err := perigee.Request("GET", u, perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		Results:     &respBody,
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &respBody, nil
+}
+
+// Update changes an existing endpoint with new data.
+// All fields are optional in the provided EndpointOpts.
+func Update(client *gophercloud.ServiceClient, endpointID string, opts EndpointOpts) (*Endpoint, error) {
+	type endpoint struct {
+		Interface *string `json:"interface,omitempty"`
+		Name      *string `json:"name,omitempty"`
+		Region    *string `json:"region,omitempty"`
+		URL       *string `json:"url,omitempty"`
+		ServiceID *string `json:"service_id,omitempty"`
+	}
+
+	type request struct {
+		Endpoint endpoint `json:"endpoint"`
+	}
+
+	type response struct {
+		Endpoint Endpoint `json:"endpoint"`
+	}
+
+	reqBody := request{Endpoint: endpoint{}}
+	reqBody.Endpoint.Interface = maybeString(string(opts.Interface))
+	reqBody.Endpoint.Name = maybeString(opts.Name)
+	reqBody.Endpoint.Region = maybeString(opts.Region)
+	reqBody.Endpoint.URL = maybeString(opts.URL)
+	reqBody.Endpoint.ServiceID = maybeString(opts.ServiceID)
+
+	var respBody response
+	_, err := perigee.Request("PATCH", getEndpointURL(client, endpointID), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		ReqBody:     &reqBody,
+		Results:     &respBody,
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &respBody.Endpoint, nil
+}
+
+// Delete removes an endpoint from the service catalog.
+func Delete(client *gophercloud.ServiceClient, endpointID string) error {
+	_, err := perigee.Request("DELETE", getEndpointURL(client, endpointID), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		OkCodes:     []int{204},
+	})
+	return err
+}
diff --git a/openstack/identity/v3/endpoints/requests_test.go b/openstack/identity/v3/endpoints/requests_test.go
new file mode 100644
index 0000000..a7088bb
--- /dev/null
+++ b/openstack/identity/v3/endpoints/requests_test.go
@@ -0,0 +1,233 @@
+package endpoints
+
+import (
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+const tokenID = "abcabcabcabc"
+
+func serviceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{TokenID: tokenID},
+		Endpoint: testhelper.Endpoint(),
+	}
+}
+
+func TestCreateSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "POST")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+		testhelper.TestJSONRequest(t, r, `
+      {
+        "endpoint": {
+          "interface": "public",
+          "name": "the-endiest-of-points",
+          "region": "underground",
+          "url": "https://1.2.3.4:9000/",
+          "service_id": "asdfasdfasdfasdf"
+        }
+      }
+    `)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `
+      {
+        "endpoint": {
+          "id": "12",
+          "interface": "public",
+          "links": {
+            "self": "https://localhost:5000/v3/endpoints/12"
+          },
+          "name": "the-endiest-of-points",
+          "region": "underground",
+          "service_id": "asdfasdfasdfasdf",
+          "url": "https://1.2.3.4:9000/"
+        }
+      }
+    `)
+	})
+
+	client := serviceClient()
+
+	result, err := Create(client, EndpointOpts{
+		Interface: gophercloud.InterfacePublic,
+		Name:      "the-endiest-of-points",
+		Region:    "underground",
+		URL:       "https://1.2.3.4:9000/",
+		ServiceID: "asdfasdfasdfasdf",
+	})
+	if err != nil {
+		t.Fatalf("Unable to create an endpoint: %v", err)
+	}
+
+	expected := &Endpoint{
+		ID:        "12",
+		Interface: gophercloud.InterfacePublic,
+		Name:      "the-endiest-of-points",
+		Region:    "underground",
+		ServiceID: "asdfasdfasdfasdf",
+		URL:       "https://1.2.3.4:9000/",
+	}
+
+	if !reflect.DeepEqual(result, expected) {
+		t.Errorf("Expected %#v, was %#v", expected, result)
+	}
+}
+
+func TestListEndpoints(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/endpoints", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		fmt.Fprintf(w, `
+			{
+				"endpoints": [
+					{
+						"id": "12",
+						"interface": "public",
+						"links": {
+							"self": "https://localhost:5000/v3/endpoints/12"
+						},
+						"name": "the-endiest-of-points",
+						"region": "underground",
+						"service_id": "asdfasdfasdfasdf",
+						"url": "https://1.2.3.4:9000/"
+					},
+					{
+						"id": "13",
+						"interface": "internal",
+						"links": {
+							"self": "https://localhost:5000/v3/endpoints/13"
+						},
+						"name": "shhhh",
+						"region": "underground",
+						"service_id": "asdfasdfasdfasdf",
+						"url": "https://1.2.3.4:9001/"
+					}
+				],
+				"links": {
+					"next": null,
+					"previous": null
+				}
+			}
+		`)
+	})
+
+	client := serviceClient()
+
+	actual, err := List(client, ListOpts{})
+	if err != nil {
+		t.Fatalf("Unexpected error listing endpoints: %v", err)
+	}
+
+	expected := &EndpointList{
+		Endpoints: []Endpoint{
+			Endpoint{
+				ID:        "12",
+				Interface: gophercloud.InterfacePublic,
+				Name:      "the-endiest-of-points",
+				Region:    "underground",
+				ServiceID: "asdfasdfasdfasdf",
+				URL:       "https://1.2.3.4:9000/",
+			},
+			Endpoint{
+				ID:        "13",
+				Interface: gophercloud.InterfaceInternal,
+				Name:      "shhhh",
+				Region:    "underground",
+				ServiceID: "asdfasdfasdfasdf",
+				URL:       "https://1.2.3.4:9001/",
+			},
+		},
+	}
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %#v, got %#v", expected, actual)
+	}
+}
+
+func TestUpdateEndpoint(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/endpoints/12", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "PATCH")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+		testhelper.TestJSONRequest(t, r, `
+		{
+	    "endpoint": {
+	      "name": "renamed",
+				"region": "somewhere-else"
+	    }
+		}
+	`)
+
+		fmt.Fprintf(w, `
+		{
+			"endpoint": {
+				"id": "12",
+				"interface": "public",
+				"links": {
+					"self": "https://localhost:5000/v3/endpoints/12"
+				},
+				"name": "renamed",
+				"region": "somewhere-else",
+				"service_id": "asdfasdfasdfasdf",
+				"url": "https://1.2.3.4:9000/"
+			}
+		}
+	`)
+	})
+
+	client := serviceClient()
+	actual, err := Update(client, "12", EndpointOpts{
+		Name:   "renamed",
+		Region: "somewhere-else",
+	})
+	if err != nil {
+		t.Fatalf("Unexpected error from Update: %v", err)
+	}
+
+	expected := &Endpoint{
+		ID:        "12",
+		Interface: gophercloud.InterfacePublic,
+		Name:      "renamed",
+		Region:    "somewhere-else",
+		ServiceID: "asdfasdfasdfasdf",
+		URL:       "https://1.2.3.4:9000/",
+	}
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %#v, was %#v", expected, actual)
+	}
+}
+
+func TestDeleteEndpoint(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/endpoints/34", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "DELETE")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	client := serviceClient()
+
+	err := Delete(client, "34")
+	if err != nil {
+		t.Fatalf("Unexpected error from Delete: %v", err)
+	}
+}
diff --git a/openstack/identity/v3/endpoints/results.go b/openstack/identity/v3/endpoints/results.go
new file mode 100644
index 0000000..940eebc
--- /dev/null
+++ b/openstack/identity/v3/endpoints/results.go
@@ -0,0 +1,70 @@
+package endpoints
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+)
+
+// Endpoint describes the entry point for another service's API.
+type Endpoint struct {
+	ID        string                `json:"id"`
+	Interface gophercloud.Interface `json:"interface"`
+	Name      string                `json:"name"`
+	Region    string                `json:"region"`
+	ServiceID string                `json:"service_id"`
+	URL       string                `json:"url"`
+}
+
+// EndpointList contains a page of Endpoint results.
+type EndpointList struct {
+	gophercloud.PaginationLinks `json:"links"`
+
+	client    *gophercloud.ServiceClient
+	Endpoints []Endpoint `json:"endpoints"`
+}
+
+// Pager marks EndpointList as paged by links.
+func (list EndpointList) Pager() gophercloud.Pager {
+	return gophercloud.NewLinkPager(list)
+}
+
+// Concat adds the contents of another Collection to this one.
+func (list EndpointList) Concat(other gophercloud.Collection) gophercloud.Collection {
+	return EndpointList{
+		client:    list.client,
+		Endpoints: append(list.Endpoints, AsEndpoints(other)...),
+	}
+}
+
+// Service returns the ServiceClient used to acquire this list.
+func (list EndpointList) Service() *gophercloud.ServiceClient {
+	return list.client
+}
+
+// Links accesses pagination information for the current page.
+func (list EndpointList) Links() gophercloud.PaginationLinks {
+	return list.PaginationLinks
+}
+
+// Interpret parses a follow-on JSON response as an additional page.
+func (list EndpointList) Interpret(json interface{}) (gophercloud.LinkCollection, error) {
+	mapped, ok := json.(map[string]interface{})
+	if !ok {
+		return nil, fmt.Errorf("Unexpected JSON response: %#v", json)
+	}
+
+	var result EndpointList
+	err := mapstructure.Decode(mapped, &result)
+	if err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+// AsEndpoints extracts an Endpoint slice from a Collection.
+// Panics if `list` was not returned from a List call.
+func AsEndpoints(list gophercloud.Collection) []Endpoint {
+	return list.(*EndpointList).Endpoints
+}
diff --git a/openstack/identity/v3/endpoints/urls.go b/openstack/identity/v3/endpoints/urls.go
new file mode 100644
index 0000000..011cc01
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls.go
@@ -0,0 +1,11 @@
+package endpoints
+
+import "github.com/rackspace/gophercloud"
+
+func getListURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("endpoints")
+}
+
+func getEndpointURL(client *gophercloud.ServiceClient, endpointID string) string {
+	return client.ServiceURL("endpoints", endpointID)
+}
diff --git a/openstack/identity/v3/endpoints/urls_test.go b/openstack/identity/v3/endpoints/urls_test.go
new file mode 100644
index 0000000..fe1fb4a
--- /dev/null
+++ b/openstack/identity/v3/endpoints/urls_test.go
@@ -0,0 +1,23 @@
+package endpoints
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+)
+
+func TestGetListURL(t *testing.T) {
+	client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+	url := getListURL(&client)
+	if url != "http://localhost:5000/v3/endpoints" {
+		t.Errorf("Unexpected list URL generated: [%s]", url)
+	}
+}
+
+func TestGetEndpointURL(t *testing.T) {
+	client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+	url := getEndpointURL(&client, "1234")
+	if url != "http://localhost:5000/v3/endpoints/1234" {
+		t.Errorf("Unexpected service URL generated: [%s]", url)
+	}
+}
diff --git a/openstack/identity/v3/services/doc.go b/openstack/identity/v3/services/doc.go
new file mode 100644
index 0000000..c4772c0
--- /dev/null
+++ b/openstack/identity/v3/services/doc.go
@@ -0,0 +1,4 @@
+/*
+Package services queries and manages the service catalog.
+*/
+package services
diff --git a/openstack/identity/v3/services/requests.go b/openstack/identity/v3/services/requests.go
new file mode 100644
index 0000000..e261192
--- /dev/null
+++ b/openstack/identity/v3/services/requests.go
@@ -0,0 +1,115 @@
+package services
+
+import (
+	"strconv"
+
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/openstack/utils"
+)
+
+type response struct {
+	Service Service `json:"service"`
+}
+
+// Create adds a new service of the requested type to the catalog.
+func Create(client *gophercloud.ServiceClient, serviceType string) (*Service, error) {
+	type request struct {
+		Type string `json:"type"`
+	}
+
+	req := request{Type: serviceType}
+	var resp response
+
+	_, err := perigee.Request("POST", getListURL(client), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		ReqBody:     &req,
+		Results:     &resp,
+		OkCodes:     []int{201},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp.Service, nil
+}
+
+// ListOpts allows you to query the List method.
+type ListOpts struct {
+	ServiceType string
+	PerPage     int
+	Page        int
+}
+
+// List enumerates the services available to a specific user.
+func List(client *gophercloud.ServiceClient, opts ListOpts) (*ServiceList, error) {
+	q := make(map[string]string)
+	if opts.ServiceType != "" {
+		q["type"] = opts.ServiceType
+	}
+	if opts.Page != 0 {
+		q["page"] = strconv.Itoa(opts.Page)
+	}
+	if opts.PerPage != 0 {
+		q["perPage"] = strconv.Itoa(opts.PerPage)
+	}
+	u := getListURL(client) + utils.BuildQuery(q)
+
+	var resp ServiceList
+	_, err := perigee.Request("GET", u, perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		Results:     &resp,
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp, nil
+}
+
+// Get returns additional information about a service, given its ID.
+func Get(client *gophercloud.ServiceClient, serviceID string) (*Service, error) {
+	var resp response
+	_, err := perigee.Request("GET", getServiceURL(client, serviceID), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		Results:     &resp,
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return nil, err
+	}
+	return &resp.Service, nil
+}
+
+// Update changes the service type of an existing service.s
+func Update(client *gophercloud.ServiceClient, serviceID string, serviceType string) (*Service, error) {
+	type request struct {
+		Type string `json:"type"`
+	}
+
+	req := request{Type: serviceType}
+
+	var resp response
+	_, err := perigee.Request("PATCH", getServiceURL(client, serviceID), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		ReqBody:     &req,
+		Results:     &resp,
+		OkCodes:     []int{200},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return &resp.Service, nil
+}
+
+// Delete removes an existing service.
+// It either deletes all associated endpoints, or fails until all endpoints are deleted.
+func Delete(client *gophercloud.ServiceClient, serviceID string) error {
+	_, err := perigee.Request("DELETE", getServiceURL(client, serviceID), perigee.Options{
+		MoreHeaders: client.Provider.AuthenticatedHeaders(),
+		OkCodes:     []int{204},
+	})
+	return err
+}
diff --git a/openstack/identity/v3/services/requests_test.go b/openstack/identity/v3/services/requests_test.go
new file mode 100644
index 0000000..9f43db3
--- /dev/null
+++ b/openstack/identity/v3/services/requests_test.go
@@ -0,0 +1,222 @@
+package services
+
+import (
+	"fmt"
+	"net/http"
+	"reflect"
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+const tokenID = "111111"
+
+func serviceClient() *gophercloud.ServiceClient {
+	return &gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: tokenID,
+		},
+		Endpoint: testhelper.Endpoint(),
+	}
+}
+
+func TestCreateSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "POST")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+		testhelper.TestJSONRequest(t, r, `{ "type": "compute" }`)
+
+		w.Header().Add("Content-Type", "application/json")
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{
+        "service": {
+          "description": "Here's your service",
+          "id": "1234",
+          "name": "InscrutableOpenStackProjectName",
+          "type": "compute"
+        }
+    }`)
+	})
+
+	client := serviceClient()
+
+	result, err := Create(client, "compute")
+	if err != nil {
+		t.Fatalf("Unexpected error from Create: %v", err)
+	}
+
+	if result.Description == nil || *result.Description != "Here's your service" {
+		t.Errorf("Service description was unexpected [%s]", result.Description)
+	}
+	if result.ID != "1234" {
+		t.Errorf("Service ID was unexpected [%s]", result.ID)
+	}
+	if result.Name != "InscrutableOpenStackProjectName" {
+		t.Errorf("Service name was unexpected [%s]", result.Name)
+	}
+	if result.Type != "compute" {
+		t.Errorf("Service type was unexpected [%s]", result.Type)
+	}
+}
+
+func TestListSinglePage(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/services", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"links": {
+					"next": null,
+					"previous": null
+				},
+				"services": [
+					{
+						"description": "Service One",
+						"id": "1234",
+						"name": "service-one",
+						"type": "identity"
+					},
+					{
+						"description": "Service Two",
+						"id": "9876",
+						"name": "service-two",
+						"type": "compute"
+					}
+				]
+			}
+		`)
+	})
+
+	client := serviceClient()
+
+	result, err := List(client, ListOpts{})
+	if err != nil {
+		t.Fatalf("Error listing services: %v", err)
+	}
+
+	collection, err := gophercloud.AllPages(result)
+	actual := AsServices(collection)
+
+	desc0 := "Service One"
+	desc1 := "Service Two"
+	expected := []Service{
+		Service{
+			Description: &desc0,
+			ID:          "1234",
+			Name:        "service-one",
+			Type:        "identity",
+		},
+		Service{
+			Description: &desc1,
+			ID:          "9876",
+			Name:        "service-two",
+			Type:        "compute",
+		},
+	}
+
+	if !reflect.DeepEqual(expected, actual) {
+		t.Errorf("Expected %#v, got %#v", expected, actual)
+	}
+}
+
+func TestGetSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"service": {
+						"description": "Service One",
+						"id": "12345",
+						"name": "service-one",
+						"type": "identity"
+				}
+			}
+		`)
+	})
+
+	client := serviceClient()
+
+	result, err := Get(client, "12345")
+	if err != nil {
+		t.Fatalf("Error fetching service information: %v", err)
+	}
+
+	if result.ID != "12345" {
+		t.Errorf("Unexpected service ID: %s", result.ID)
+	}
+	if *result.Description != "Service One" {
+		t.Errorf("Unexpected service description: [%s]", *result.Description)
+	}
+	if result.Name != "service-one" {
+		t.Errorf("Unexpected service name: [%s]", result.Name)
+	}
+	if result.Type != "identity" {
+		t.Errorf("Unexpected service type: [%s]", result.Type)
+	}
+}
+
+func TestUpdateSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "PATCH")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+		testhelper.TestJSONRequest(t, r, `{ "type": "lasermagic" }`)
+
+		w.Header().Add("Content-Type", "application/json")
+		fmt.Fprintf(w, `
+			{
+				"service": {
+						"id": "12345",
+						"type": "lasermagic"
+				}
+			}
+		`)
+	})
+
+	client := serviceClient()
+
+	result, err := Update(client, "12345", "lasermagic")
+	if err != nil {
+		t.Fatalf("Unable to update service: %v", err)
+	}
+
+	if result.ID != "12345" {
+
+	}
+}
+
+func TestDeleteSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	testhelper.Mux.HandleFunc("/services/12345", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "DELETE")
+		testhelper.TestHeader(t, r, "X-Auth-Token", tokenID)
+
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	client := serviceClient()
+
+	err := Delete(client, "12345")
+	if err != nil {
+		t.Fatalf("Unable to delete service: %v", err)
+	}
+}
diff --git a/openstack/identity/v3/services/results.go b/openstack/identity/v3/services/results.go
new file mode 100644
index 0000000..88510b4
--- /dev/null
+++ b/openstack/identity/v3/services/results.go
@@ -0,0 +1,68 @@
+package services
+
+import (
+	"fmt"
+
+	"github.com/mitchellh/mapstructure"
+	"github.com/rackspace/gophercloud"
+)
+
+// Service is the result of a list or information query.
+type Service struct {
+	Description *string `json:"description,omitempty"`
+	ID          string  `json:"id"`
+	Name        string  `json:"name"`
+	Type        string  `json:"type"`
+}
+
+// ServiceList is a collection of Services.
+type ServiceList struct {
+	gophercloud.PaginationLinks `json:"links"`
+
+	client   *gophercloud.ServiceClient
+	Services []Service `json:"services"`
+}
+
+// Pager indicates that the ServiceList is paginated by next and previous links.
+func (list ServiceList) Pager() gophercloud.Pager {
+	return gophercloud.NewLinkPager(list)
+}
+
+// Concat returns a new collection that's the result of appending a new collection at the end of this one.
+func (list ServiceList) Concat(other gophercloud.Collection) gophercloud.Collection {
+	return ServiceList{
+		client:   list.client,
+		Services: append(list.Services, AsServices(other)...),
+	}
+}
+
+// Service returns the ServiceClient used to acquire this list.
+func (list ServiceList) Service() *gophercloud.ServiceClient {
+	return list.client
+}
+
+// Links accesses pagination information for the current page.
+func (list ServiceList) Links() gophercloud.PaginationLinks {
+	return list.PaginationLinks
+}
+
+// Interpret parses a follow-on JSON response as an additional page.
+func (list ServiceList) Interpret(json interface{}) (gophercloud.LinkCollection, error) {
+	mapped, ok := json.(map[string]interface{})
+	if !ok {
+		return nil, fmt.Errorf("Unexpected JSON response: %#v", json)
+	}
+
+	var result ServiceList
+	err := mapstructure.Decode(mapped, &result)
+	if err != nil {
+		return nil, err
+	}
+	return result, nil
+}
+
+// AsServices extracts a slice of Services from a Collection acquired from List.
+// It panics if the Collection does not actually contain Services.
+func AsServices(results gophercloud.Collection) []Service {
+	return results.(*ServiceList).Services
+}
diff --git a/openstack/identity/v3/services/urls.go b/openstack/identity/v3/services/urls.go
new file mode 100644
index 0000000..3556238
--- /dev/null
+++ b/openstack/identity/v3/services/urls.go
@@ -0,0 +1,11 @@
+package services
+
+import "github.com/rackspace/gophercloud"
+
+func getListURL(client *gophercloud.ServiceClient) string {
+	return client.ServiceURL("services")
+}
+
+func getServiceURL(client *gophercloud.ServiceClient, serviceID string) string {
+	return client.ServiceURL("services", serviceID)
+}
diff --git a/openstack/identity/v3/services/urls_test.go b/openstack/identity/v3/services/urls_test.go
new file mode 100644
index 0000000..deded69
--- /dev/null
+++ b/openstack/identity/v3/services/urls_test.go
@@ -0,0 +1,23 @@
+package services
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+)
+
+func TestGetListURL(t *testing.T) {
+	client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+	url := getListURL(&client)
+	if url != "http://localhost:5000/v3/services" {
+		t.Errorf("Unexpected list URL generated: [%s]", url)
+	}
+}
+
+func TestGetServiceURL(t *testing.T) {
+	client := gophercloud.ServiceClient{Endpoint: "http://localhost:5000/v3/"}
+	url := getServiceURL(&client, "1234")
+	if url != "http://localhost:5000/v3/services/1234" {
+		t.Errorf("Unexpected service URL generated: [%s]", url)
+	}
+}
diff --git a/openstack/identity/v3/tokens/doc.go b/openstack/identity/v3/tokens/doc.go
new file mode 100644
index 0000000..02fce0d
--- /dev/null
+++ b/openstack/identity/v3/tokens/doc.go
@@ -0,0 +1,6 @@
+/*
+Package tokens defines operations performed on the token resource.
+
+Documentation: http://developer.openstack.org/api-ref-identity-v3.html#tokens-v3
+*/
+package tokens
diff --git a/openstack/identity/v3/tokens/errors.go b/openstack/identity/v3/tokens/errors.go
new file mode 100644
index 0000000..4476109
--- /dev/null
+++ b/openstack/identity/v3/tokens/errors.go
@@ -0,0 +1,72 @@
+package tokens
+
+import (
+	"errors"
+	"fmt"
+)
+
+func unacceptedAttributeErr(attribute string) error {
+	return fmt.Errorf("The base Identity V3 API does not accept authentication by %s", attribute)
+}
+
+func redundantWithTokenErr(attribute string) error {
+	return fmt.Errorf("%s may not be provided when authenticating with a TokenID", attribute)
+}
+
+func redundantWithUserID(attribute string) error {
+	return fmt.Errorf("%s may not be provided when authenticating with a UserID", attribute)
+}
+
+var (
+	// ErrAPIKeyProvided indicates that an APIKey was provided but can't be used.
+	ErrAPIKeyProvided = unacceptedAttributeErr("APIKey")
+
+	// ErrTenantIDProvided indicates that a TenantID was provided but can't be used.
+	ErrTenantIDProvided = unacceptedAttributeErr("TenantID")
+
+	// ErrTenantNameProvided indicates that a TenantName was provided but can't be used.
+	ErrTenantNameProvided = unacceptedAttributeErr("TenantName")
+
+	// ErrUsernameWithToken indicates that a Username was provided, but token authentication is being used instead.
+	ErrUsernameWithToken = redundantWithTokenErr("Username")
+
+	// ErrUserIDWithToken indicates that a UserID was provided, but token authentication is being used instead.
+	ErrUserIDWithToken = redundantWithTokenErr("UserID")
+
+	// ErrDomainIDWithToken indicates that a DomainID was provided, but token authentication is being used instead.
+	ErrDomainIDWithToken = redundantWithTokenErr("DomainID")
+
+	// ErrDomainNameWithToken indicates that a DomainName was provided, but token authentication is being used instead.s
+	ErrDomainNameWithToken = redundantWithTokenErr("DomainName")
+
+	// ErrUsernameOrUserID indicates that neither username nor userID are specified, or both are at once.
+	ErrUsernameOrUserID = errors.New("Exactly one of Username and UserID must be provided for password authentication")
+
+	// ErrDomainIDWithUserID indicates that a DomainID was provided, but unnecessary because a UserID is being used.
+	ErrDomainIDWithUserID = redundantWithUserID("DomainID")
+
+	// ErrDomainNameWithUserID indicates that a DomainName was provided, but unnecessary because a UserID is being used.
+	ErrDomainNameWithUserID = redundantWithUserID("DomainName")
+
+	// ErrDomainIDOrDomainName indicates that a username was provided, but no domain to scope it.
+	// It may also indicate that both a DomainID and a DomainName were provided at once.
+	ErrDomainIDOrDomainName = errors.New("You must provide exactly one of DomainID or DomainName to authenticate by Username")
+
+	// ErrMissingPassword indicates that no password was provided and no token is available.
+	ErrMissingPassword = errors.New("You must provide a password to authenticate")
+
+	// ErrScopeDomainIDOrDomainName indicates that a domain ID or Name was required in a Scope, but not present.
+	ErrScopeDomainIDOrDomainName = errors.New("You must provide exactly one of DomainID or DomainName in a Scope with ProjectName")
+
+	// ErrScopeProjectIDOrProjectName indicates that both a ProjectID and a ProjectName were provided in a Scope.
+	ErrScopeProjectIDOrProjectName = errors.New("You must provide at most one of ProjectID or ProjectName in a Scope")
+
+	// ErrScopeProjectIDAlone indicates that a ProjectID was provided with other constraints in a Scope.
+	ErrScopeProjectIDAlone = errors.New("ProjectID must be supplied alone in a Scope")
+
+	// ErrScopeDomainName indicates that a DomainName was provided alone in a Scope.
+	ErrScopeDomainName = errors.New("DomainName must be supplied with a ProjectName or ProjectID in a Scope.")
+
+	// ErrScopeEmpty indicates that no credentials were provided in a Scope.
+	ErrScopeEmpty = errors.New("You must provide either a Project or Domain in a Scope")
+)
diff --git a/openstack/identity/v3/tokens/requests.go b/openstack/identity/v3/tokens/requests.go
new file mode 100644
index 0000000..ab4ae05
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests.go
@@ -0,0 +1,291 @@
+package tokens
+
+import (
+	"github.com/racker/perigee"
+	"github.com/rackspace/gophercloud"
+)
+
+// Scope allows a created token to be limited to a specific domain or project.
+type Scope struct {
+	ProjectID   string
+	ProjectName string
+	DomainID    string
+	DomainName  string
+}
+
+func subjectTokenHeaders(c *gophercloud.ServiceClient, subjectToken string) map[string]string {
+	h := c.Provider.AuthenticatedHeaders()
+	h["X-Subject-Token"] = subjectToken
+	return h
+}
+
+// Create authenticates and either generates a new token, or changes the Scope of an existing token.
+func Create(c *gophercloud.ServiceClient, options gophercloud.AuthOptions, scope *Scope) (gophercloud.AuthResults, error) {
+	type domainReq struct {
+		ID   *string `json:"id,omitempty"`
+		Name *string `json:"name,omitempty"`
+	}
+
+	type projectReq struct {
+		Domain *domainReq `json:"domain,omitempty"`
+		Name   *string    `json:"name,omitempty"`
+		ID     *string    `json:"id,omitempty"`
+	}
+
+	type userReq struct {
+		ID       *string    `json:"id,omitempty"`
+		Name     *string    `json:"name,omitempty"`
+		Password string     `json:"password"`
+		Domain   *domainReq `json:"domain,omitempty"`
+	}
+
+	type passwordReq struct {
+		User userReq `json:"user"`
+	}
+
+	type tokenReq struct {
+		ID string `json:"id"`
+	}
+
+	type identityReq struct {
+		Methods  []string     `json:"methods"`
+		Password *passwordReq `json:"password,omitempty"`
+		Token    *tokenReq    `json:"token,omitempty"`
+	}
+
+	type scopeReq struct {
+		Domain  *domainReq  `json:"domain,omitempty"`
+		Project *projectReq `json:"project,omitempty"`
+	}
+
+	type authReq struct {
+		Identity identityReq `json:"identity"`
+		Scope    *scopeReq   `json:"scope,omitempty"`
+	}
+
+	type request struct {
+		Auth authReq `json:"auth"`
+	}
+
+	// Populate the request structure based on the provided arguments. Create and return an error
+	// if insufficient or incompatible information is present.
+	var req request
+
+	// Test first for unrecognized arguments.
+	if options.APIKey != "" {
+		return nil, ErrAPIKeyProvided
+	}
+	if options.TenantID != "" {
+		return nil, ErrTenantIDProvided
+	}
+	if options.TenantName != "" {
+		return nil, ErrTenantNameProvided
+	}
+
+	if options.Password == "" {
+		if c.Provider.TokenID != "" {
+			// Because we aren't using password authentication, it's an error to also provide any of the user-based authentication
+			// parameters.
+			if options.Username != "" {
+				return nil, ErrUsernameWithToken
+			}
+			if options.UserID != "" {
+				return nil, ErrUserIDWithToken
+			}
+			if options.DomainID != "" {
+				return nil, ErrDomainIDWithToken
+			}
+			if options.DomainName != "" {
+				return nil, ErrDomainNameWithToken
+			}
+
+			// Configure the request for Token authentication.
+			req.Auth.Identity.Methods = []string{"token"}
+			req.Auth.Identity.Token = &tokenReq{
+				ID: c.Provider.TokenID,
+			}
+		} else {
+			// If no password or token ID are available, authentication can't continue.
+			return nil, ErrMissingPassword
+		}
+	} else {
+		// Password authentication.
+		req.Auth.Identity.Methods = []string{"password"}
+
+		// At least one of Username and UserID must be specified.
+		if options.Username == "" && options.UserID == "" {
+			return nil, ErrUsernameOrUserID
+		}
+
+		if options.Username != "" {
+			// If Username is provided, UserID may not be provided.
+			if options.UserID != "" {
+				return nil, ErrUsernameOrUserID
+			}
+
+			// Either DomainID or DomainName must also be specified.
+			if options.DomainID == "" && options.DomainName == "" {
+				return nil, ErrDomainIDOrDomainName
+			}
+
+			if options.DomainID != "" {
+				if options.DomainName != "" {
+					return nil, ErrDomainIDOrDomainName
+				}
+
+				// Configure the request for Username and Password authentication with a DomainID.
+				req.Auth.Identity.Password = &passwordReq{
+					User: userReq{
+						Name:     &options.Username,
+						Password: options.Password,
+						Domain:   &domainReq{ID: &options.DomainID},
+					},
+				}
+			}
+
+			if options.DomainName != "" {
+				// Configure the request for Username and Password authentication with a DomainName.
+				req.Auth.Identity.Password = &passwordReq{
+					User: userReq{
+						Name:     &options.Username,
+						Password: options.Password,
+						Domain:   &domainReq{Name: &options.DomainName},
+					},
+				}
+			}
+		}
+
+		if options.UserID != "" {
+			// If UserID is specified, neither DomainID nor DomainName may be.
+			if options.DomainID != "" {
+				return nil, ErrDomainIDWithUserID
+			}
+			if options.DomainName != "" {
+				return nil, ErrDomainNameWithUserID
+			}
+
+			// Configure the request for UserID and Password authentication.
+			req.Auth.Identity.Password = &passwordReq{
+				User: userReq{ID: &options.UserID, Password: options.Password},
+			}
+		}
+	}
+
+	// Add a "scope" element if a Scope has been provided.
+	if scope != nil {
+		if scope.ProjectName != "" {
+			// ProjectName provided: either DomainID or DomainName must also be supplied.
+			// ProjectID may not be supplied.
+			if scope.DomainID == "" && scope.DomainName == "" {
+				return nil, ErrScopeDomainIDOrDomainName
+			}
+			if scope.ProjectID != "" {
+				return nil, ErrScopeProjectIDOrProjectName
+			}
+
+			if scope.DomainID != "" {
+				// ProjectName + DomainID
+				req.Auth.Scope = &scopeReq{
+					Project: &projectReq{
+						Name:   &scope.ProjectName,
+						Domain: &domainReq{ID: &scope.DomainID},
+					},
+				}
+			}
+
+			if scope.DomainName != "" {
+				// ProjectName + DomainName
+				req.Auth.Scope = &scopeReq{
+					Project: &projectReq{
+						Name:   &scope.ProjectName,
+						Domain: &domainReq{Name: &scope.DomainName},
+					},
+				}
+			}
+		} else if scope.ProjectID != "" {
+			// ProjectID provided. ProjectName, DomainID, and DomainName may not be provided.
+			if scope.DomainID != "" {
+				return nil, ErrScopeProjectIDAlone
+			}
+			if scope.DomainName != "" {
+				return nil, ErrScopeProjectIDAlone
+			}
+
+			// ProjectID
+			req.Auth.Scope = &scopeReq{
+				Project: &projectReq{ID: &scope.ProjectID},
+			}
+		} else if scope.DomainID != "" {
+			// DomainID provided. ProjectID, ProjectName, and DomainName may not be provided.
+			if scope.DomainName != "" {
+				return nil, ErrScopeDomainIDOrDomainName
+			}
+
+			// DomainID
+			req.Auth.Scope = &scopeReq{
+				Domain: &domainReq{ID: &scope.DomainID},
+			}
+		} else if scope.DomainName != "" {
+			return nil, ErrScopeDomainName
+		} else {
+			return nil, ErrScopeEmpty
+		}
+	}
+
+	var result TokenCreateResult
+	response, err := perigee.Request("POST", getTokenURL(c), perigee.Options{
+		ReqBody: &req,
+		Results: &result.response,
+		OkCodes: []int{201},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	// Extract the token ID from the response, if present.
+	result.tokenID = response.HttpResponse.Header.Get("X-Subject-Token")
+
+	return &result, nil
+}
+
+// Get validates and retrieves information about another token.
+func Get(c *gophercloud.ServiceClient, token string) (*TokenCreateResult, error) {
+	var result TokenCreateResult
+
+	response, err := perigee.Request("GET", getTokenURL(c), perigee.Options{
+		MoreHeaders: subjectTokenHeaders(c, token),
+		Results:     &result.response,
+		OkCodes:     []int{200, 203},
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Extract the token ID from the response, if present.
+	result.tokenID = response.HttpResponse.Header.Get("X-Subject-Token")
+
+	return &result, nil
+}
+
+// Validate determines if a specified token is valid or not.
+func Validate(c *gophercloud.ServiceClient, token string) (bool, error) {
+	response, err := perigee.Request("HEAD", getTokenURL(c), perigee.Options{
+		MoreHeaders: subjectTokenHeaders(c, token),
+		OkCodes:     []int{204, 404},
+	})
+	if err != nil {
+		return false, err
+	}
+
+	return response.StatusCode == 204, nil
+}
+
+// Revoke immediately makes specified token invalid.
+func Revoke(c *gophercloud.ServiceClient, token string) error {
+	_, err := perigee.Request("DELETE", getTokenURL(c), perigee.Options{
+		MoreHeaders: subjectTokenHeaders(c, token),
+		OkCodes:     []int{204},
+	})
+	return err
+}
diff --git a/openstack/identity/v3/tokens/requests_test.go b/openstack/identity/v3/tokens/requests_test.go
new file mode 100644
index 0000000..989c1c4
--- /dev/null
+++ b/openstack/identity/v3/tokens/requests_test.go
@@ -0,0 +1,514 @@
+package tokens
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+	"time"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+// authTokenPost verifies that providing certain AuthOptions and Scope results in an expected JSON structure.
+func authTokenPost(t *testing.T, options gophercloud.AuthOptions, scope *Scope, requestJSON string) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: "12345abcdef",
+		},
+		Endpoint: testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "POST")
+		testhelper.TestHeader(t, r, "Content-Type", "application/json")
+		testhelper.TestHeader(t, r, "Accept", "application/json")
+		testhelper.TestJSONRequest(t, r, requestJSON)
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{}`)
+	})
+
+	_, err := Create(&client, options, scope)
+	if err != nil {
+		t.Errorf("Create returned an error: %v", err)
+	}
+}
+
+func authTokenPostErr(t *testing.T, options gophercloud.AuthOptions, scope *Scope, includeToken bool, expectedErr error) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{},
+		Endpoint: testhelper.Endpoint(),
+	}
+	if includeToken {
+		client.Provider.TokenID = "abcdef123456"
+	}
+
+	_, err := Create(&client, options, scope)
+	if err == nil {
+		t.Errorf("Create did NOT return an error")
+	}
+	if err != expectedErr {
+		t.Errorf("Create returned an unexpected error: wanted %v, got %v", expectedErr, err)
+	}
+}
+
+func TestCreateUserIDAndPassword(t *testing.T) {
+	authTokenPost(t, gophercloud.AuthOptions{UserID: "me", Password: "squirrel!"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": { "id": "me", "password": "squirrel!" }
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateUsernameDomainIDPassword(t *testing.T) {
+	authTokenPost(t, gophercloud.AuthOptions{Username: "fakey", Password: "notpassword", DomainID: "abc123"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"domain": {
+								"id": "abc123"
+							},
+							"name": "fakey",
+							"password": "notpassword"
+						}
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateUsernameDomainNamePassword(t *testing.T) {
+	authTokenPost(t, gophercloud.AuthOptions{Username: "frank", Password: "swordfish", DomainName: "spork.net"}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"domain": {
+								"name": "spork.net"
+							},
+							"name": "frank",
+							"password": "swordfish"
+						}
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateTokenID(t *testing.T) {
+	authTokenPost(t, gophercloud.AuthOptions{}, nil, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["token"],
+					"token": {
+						"id": "12345abcdef"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateProjectIDScope(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{ProjectID: "123456"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"project": {
+						"id": "123456"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateDomainIDScope(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{DomainID: "1000"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"domain": {
+						"id": "1000"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateProjectNameAndDomainIDScope(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{ProjectName: "world-domination", DomainID: "1000"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"project": {
+						"domain": {
+							"id": "1000"
+						},
+						"name": "world-domination"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateProjectNameAndDomainNameScope(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "fenris", Password: "g0t0h311"}
+	scope := &Scope{ProjectName: "world-domination", DomainName: "evil-plans"}
+	authTokenPost(t, options, scope, `
+		{
+			"auth": {
+				"identity": {
+					"methods": ["password"],
+					"password": {
+						"user": {
+							"id": "fenris",
+							"password": "g0t0h311"
+						}
+					}
+				},
+				"scope": {
+					"project": {
+						"domain": {
+							"name": "evil-plans"
+						},
+						"name": "world-domination"
+					}
+				}
+			}
+		}
+	`)
+}
+
+func TestCreateExtractsTokenFromResponse(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{},
+		Endpoint: testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("X-Subject-Token", "aaa111")
+
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{}`)
+	})
+
+	options := gophercloud.AuthOptions{UserID: "me", Password: "shhh"}
+	result, err := Create(&client, options, nil)
+	if err != nil {
+		t.Errorf("Create returned an error: %v", err)
+	}
+
+	token, _ := result.TokenID()
+	if token != "aaa111" {
+		t.Errorf("Expected token to be aaa111, but was %s", token)
+	}
+}
+
+func TestCreateFailureEmptyAuth(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{}, nil, false, ErrMissingPassword)
+}
+
+func TestCreateFailureAPIKey(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{APIKey: "something"}, nil, false, ErrAPIKeyProvided)
+}
+
+func TestCreateFailureTenantID(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{TenantID: "something"}, nil, false, ErrTenantIDProvided)
+}
+
+func TestCreateFailureTenantName(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{TenantName: "something"}, nil, false, ErrTenantNameProvided)
+}
+
+func TestCreateFailureTokenIDUsername(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{Username: "something"}, nil, true, ErrUsernameWithToken)
+}
+
+func TestCreateFailureTokenIDUserID(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{UserID: "something"}, nil, true, ErrUserIDWithToken)
+}
+
+func TestCreateFailureTokenIDDomainID(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{DomainID: "something"}, nil, true, ErrDomainIDWithToken)
+}
+
+func TestCreateFailureTokenIDDomainName(t *testing.T) {
+	authTokenPostErr(t, gophercloud.AuthOptions{DomainName: "something"}, nil, true, ErrDomainNameWithToken)
+}
+
+func TestCreateFailureMissingUser(t *testing.T) {
+	options := gophercloud.AuthOptions{Password: "supersecure"}
+	authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID)
+}
+
+func TestCreateFailureBothUser(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Password: "supersecure",
+		Username: "oops",
+		UserID:   "redundancy",
+	}
+	authTokenPostErr(t, options, nil, false, ErrUsernameOrUserID)
+}
+
+func TestCreateFailureMissingDomain(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Password: "supersecure",
+		Username: "notuniqueenough",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName)
+}
+
+func TestCreateFailureBothDomain(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		Password:   "supersecure",
+		Username:   "someone",
+		DomainID:   "hurf",
+		DomainName: "durf",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainIDOrDomainName)
+}
+
+func TestCreateFailureUserIDDomainID(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		UserID:   "100",
+		Password: "stuff",
+		DomainID: "oops",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainIDWithUserID)
+}
+
+func TestCreateFailureUserIDDomainName(t *testing.T) {
+	options := gophercloud.AuthOptions{
+		UserID:     "100",
+		Password:   "sssh",
+		DomainName: "oops",
+	}
+	authTokenPostErr(t, options, nil, false, ErrDomainNameWithUserID)
+}
+
+func TestCreateFailureScopeProjectNameAlone(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectName: "notenough"}
+	authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName)
+}
+
+func TestCreateFailureScopeProjectNameAndID(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectName: "whoops", ProjectID: "toomuch", DomainID: "1234"}
+	authTokenPostErr(t, options, scope, false, ErrScopeProjectIDOrProjectName)
+}
+
+func TestCreateFailureScopeProjectIDAndDomainID(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectID: "toomuch", DomainID: "notneeded"}
+	authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone)
+}
+
+func TestCreateFailureScopeProjectIDAndDomainNAme(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{ProjectID: "toomuch", DomainName: "notneeded"}
+	authTokenPostErr(t, options, scope, false, ErrScopeProjectIDAlone)
+}
+
+func TestCreateFailureScopeDomainIDAndDomainName(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{DomainID: "toomuch", DomainName: "notneeded"}
+	authTokenPostErr(t, options, scope, false, ErrScopeDomainIDOrDomainName)
+}
+
+func TestCreateFailureScopeDomainNameAlone(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{DomainName: "notenough"}
+	authTokenPostErr(t, options, scope, false, ErrScopeDomainName)
+}
+
+func TestCreateFailureEmptyScope(t *testing.T) {
+	options := gophercloud.AuthOptions{UserID: "myself", Password: "swordfish"}
+	scope := &Scope{}
+	authTokenPostErr(t, options, scope, false, ErrScopeEmpty)
+}
+
+func TestGetRequest(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: "12345abcdef",
+		},
+		Endpoint: testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, "GET")
+		testhelper.TestHeader(t, r, "Content-Type", "application/json")
+		testhelper.TestHeader(t, r, "Accept", "application/json")
+		testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+		testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `
+			{ "token": { "expires_at": "2014-08-29T13:10:01.000000Z" } }
+		`)
+	})
+
+	result, err := Get(&client, "abcdef12345")
+	if err != nil {
+		t.Errorf("Info returned an error: %v", err)
+	}
+
+	expires, err := result.ExpiresAt()
+	if err != nil {
+		t.Errorf("Error extracting token expiration time: %v", err)
+	}
+
+	expected, _ := time.Parse(time.UnixDate, "Fri Aug 29 13:10:01 UTC 2014")
+	if expires != expected {
+		t.Errorf("Expected expiration time %s, but was %s", expected.Format(time.UnixDate), expires.Format(time.UnixDate))
+	}
+}
+
+func prepareAuthTokenHandler(t *testing.T, expectedMethod string, status int) gophercloud.ServiceClient {
+	client := gophercloud.ServiceClient{
+		Provider: &gophercloud.ProviderClient{
+			TokenID: "12345abcdef",
+		},
+		Endpoint: testhelper.Endpoint(),
+	}
+
+	testhelper.Mux.HandleFunc("/auth/tokens", func(w http.ResponseWriter, r *http.Request) {
+		testhelper.TestMethod(t, r, expectedMethod)
+		testhelper.TestHeader(t, r, "Content-Type", "application/json")
+		testhelper.TestHeader(t, r, "Accept", "application/json")
+		testhelper.TestHeader(t, r, "X-Auth-Token", "12345abcdef")
+		testhelper.TestHeader(t, r, "X-Subject-Token", "abcdef12345")
+
+		w.WriteHeader(status)
+	})
+
+	return client
+}
+
+func TestValidateRequestSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "HEAD", http.StatusNoContent)
+
+	ok, err := Validate(&client, "abcdef12345")
+	if err != nil {
+		t.Errorf("Unexpected error from Validate: %v", err)
+	}
+
+	if !ok {
+		t.Errorf("Validate returned false for a valid token")
+	}
+}
+
+func TestValidateRequestFailure(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "HEAD", http.StatusNotFound)
+
+	ok, err := Validate(&client, "abcdef12345")
+	if err != nil {
+		t.Errorf("Unexpected error from Validate: %v", err)
+	}
+
+	if ok {
+		t.Errorf("Validate returned true for an invalid token")
+	}
+}
+
+func TestValidateRequestError(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "HEAD", http.StatusUnauthorized)
+
+	_, err := Validate(&client, "abcdef12345")
+	if err == nil {
+		t.Errorf("Missing expected error from Validate")
+	}
+}
+
+func TestRevokeRequestSuccessful(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "DELETE", http.StatusNoContent)
+
+	err := Revoke(&client, "abcdef12345")
+	if err != nil {
+		t.Errorf("Unexpected error from Revoke: %v", err)
+	}
+}
+
+func TestRevokeRequestError(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	client := prepareAuthTokenHandler(t, "DELETE", http.StatusNotFound)
+
+	err := Revoke(&client, "abcdef12345")
+	if err == nil {
+		t.Errorf("Missing expected error from Revoke")
+	}
+}
diff --git a/openstack/identity/v3/tokens/results.go b/openstack/identity/v3/tokens/results.go
new file mode 100644
index 0000000..8e0f018
--- /dev/null
+++ b/openstack/identity/v3/tokens/results.go
@@ -0,0 +1,46 @@
+package tokens
+
+import (
+	"time"
+
+	"github.com/mitchellh/mapstructure"
+)
+
+// RFC3339Milli describes the time format used by identity API responses.
+const RFC3339Milli = "2006-01-02T15:04:05.999999Z"
+
+// TokenCreateResult contains the document structure returned from a Create call.
+type TokenCreateResult struct {
+	response map[string]interface{}
+	tokenID  string
+}
+
+// TokenID retrieves a token generated by a Create call from an token creation response.
+func (r *TokenCreateResult) TokenID() (string, error) {
+	return r.tokenID, nil
+}
+
+// ExpiresAt retrieves the token expiration time.
+func (r *TokenCreateResult) ExpiresAt() (time.Time, error) {
+	type tokenResp struct {
+		ExpiresAt string `mapstructure:"expires_at"`
+	}
+
+	type response struct {
+		Token tokenResp `mapstructure:"token"`
+	}
+
+	var resp response
+	err := mapstructure.Decode(r.response, &resp)
+	if err != nil {
+		return time.Time{}, err
+	}
+
+	// Attempt to parse the timestamp.
+	ts, err := time.Parse(RFC3339Milli, resp.Token.ExpiresAt)
+	if err != nil {
+		return time.Time{}, err
+	}
+
+	return ts, nil
+}
diff --git a/openstack/identity/v3/tokens/results_test.go b/openstack/identity/v3/tokens/results_test.go
new file mode 100644
index 0000000..669db61
--- /dev/null
+++ b/openstack/identity/v3/tokens/results_test.go
@@ -0,0 +1,37 @@
+package tokens
+
+import (
+	"testing"
+	"time"
+)
+
+func TestTokenID(t *testing.T) {
+	result := TokenCreateResult{tokenID: "1234"}
+
+	token, _ := result.TokenID()
+	if token != "1234" {
+		t.Errorf("Expected tokenID of 1234, got %s", token)
+	}
+}
+
+func TestExpiresAt(t *testing.T) {
+	resp := map[string]interface{}{
+		"token": map[string]string{
+			"expires_at": "2013-02-02T18:30:59.000000Z",
+		},
+	}
+
+	result := TokenCreateResult{
+		tokenID:  "1234",
+		response: resp,
+	}
+
+	expected, _ := time.Parse(time.UnixDate, "Sat Feb 2 18:30:59 UTC 2013")
+	actual, err := result.ExpiresAt()
+	if err != nil {
+		t.Errorf("Error extraction expiration time: %v", err)
+	}
+	if actual != expected {
+		t.Errorf("Expected expiration time %s, but was %s", expected.Format(time.UnixDate), actual.Format(time.UnixDate))
+	}
+}
diff --git a/openstack/identity/v3/tokens/urls.go b/openstack/identity/v3/tokens/urls.go
new file mode 100644
index 0000000..5b47c02
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls.go
@@ -0,0 +1,7 @@
+package tokens
+
+import "github.com/rackspace/gophercloud"
+
+func getTokenURL(c *gophercloud.ServiceClient) string {
+	return c.ServiceURL("auth", "tokens")
+}
diff --git a/openstack/identity/v3/tokens/urls_test.go b/openstack/identity/v3/tokens/urls_test.go
new file mode 100644
index 0000000..5ff8bc6
--- /dev/null
+++ b/openstack/identity/v3/tokens/urls_test.go
@@ -0,0 +1,21 @@
+package tokens
+
+import (
+	"testing"
+
+	"github.com/rackspace/gophercloud"
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+func TestTokenURL(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	client := gophercloud.ServiceClient{Endpoint: testhelper.Endpoint()}
+
+	expected := testhelper.Endpoint() + "auth/tokens"
+	actual := getTokenURL(&client)
+	if actual != expected {
+		t.Errorf("Expected URL %s, but was %s", expected, actual)
+	}
+}
diff --git a/openstack/storage/v1/client.go b/openstack/storage/v1/client.go
index a457a74..51312eb 100644
--- a/openstack/storage/v1/client.go
+++ b/openstack/storage/v1/client.go
@@ -2,6 +2,8 @@
 
 import (
 	"fmt"
+
+	"github.com/rackspace/gophercloud"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 )
 
@@ -9,12 +11,12 @@
 type Client struct {
 	endpoint  string
 	authority identity.AuthResults
-	options   identity.AuthOptions
+	options   gophercloud.AuthOptions
 	token     *identity.Token
 }
 
 // NewClient creates and returns a *Client.
-func NewClient(e string, a identity.AuthResults, o identity.AuthOptions) *Client {
+func NewClient(e string, a identity.AuthResults, o gophercloud.AuthOptions) *Client {
 	return &Client{
 		endpoint:  e,
 		authority: a,
@@ -64,5 +66,5 @@
 		}
 	}
 
-	return c.token.Id, err
+	return c.token.ID, err
 }
diff --git a/openstack/utils/choose_version.go b/openstack/utils/choose_version.go
new file mode 100644
index 0000000..753f8f8
--- /dev/null
+++ b/openstack/utils/choose_version.go
@@ -0,0 +1,114 @@
+package utils
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/racker/perigee"
+)
+
+// Version is a supported API version, corresponding to a vN package within the appropriate service.
+type Version struct {
+	ID       string
+	Suffix   string
+	Priority int
+}
+
+var goodStatus = map[string]bool{
+	"current":   true,
+	"supported": true,
+	"stable":    true,
+}
+
+// ChooseVersion queries the base endpoint of a API to choose the most recent non-experimental alternative from a service's
+// published versions.
+// It returns the highest-Priority Version among the alternatives that are provided, as well as its corresponding endpoint.
+func ChooseVersion(identityBase string, identityEndpoint string, recognized []*Version) (*Version, string, error) {
+	type linkResp struct {
+		Href string `json:"href"`
+		Rel  string `json:"rel"`
+	}
+
+	type valueResp struct {
+		ID     string     `json:"id"`
+		Status string     `json:"status"`
+		Links  []linkResp `json:"links"`
+	}
+
+	type versionsResp struct {
+		Values []valueResp `json:"values"`
+	}
+
+	type response struct {
+		Versions versionsResp `json:"versions"`
+	}
+
+	normalize := func(endpoint string) string {
+		if !strings.HasSuffix(endpoint, "/") {
+			return endpoint + "/"
+		}
+		return endpoint
+	}
+	identityEndpoint = normalize(identityEndpoint)
+
+	// If a full endpoint is specified, check version suffixes for a match first.
+	for _, v := range recognized {
+		if strings.HasSuffix(identityEndpoint, v.Suffix) {
+			return v, identityEndpoint, nil
+		}
+	}
+
+	var resp response
+	_, err := perigee.Request("GET", identityBase, perigee.Options{
+		Results: &resp,
+		OkCodes: []int{200, 300},
+	})
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	byID := make(map[string]*Version)
+	for _, version := range recognized {
+		byID[version.ID] = version
+	}
+
+	var highest *Version
+	var endpoint string
+
+	for _, value := range resp.Versions.Values {
+		href := ""
+		for _, link := range value.Links {
+			if link.Rel == "self" {
+				href = normalize(link.Href)
+			}
+		}
+
+		if matching, ok := byID[value.ID]; ok {
+			// Prefer a version that exactly matches the provided endpoint.
+			if href == identityEndpoint {
+				if href == "" {
+					return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", value.ID, identityBase)
+				}
+				return matching, href, nil
+			}
+
+			// Otherwise, find the highest-priority version with a whitelisted status.
+			if goodStatus[strings.ToLower(value.Status)] {
+				if highest == nil || matching.Priority > highest.Priority {
+					highest = matching
+					endpoint = href
+				}
+			}
+		}
+	}
+
+	if highest == nil {
+		return nil, "", fmt.Errorf("No supported version available from endpoint %s", identityBase)
+	}
+	if endpoint == "" {
+		return nil, "", fmt.Errorf("Endpoint missing in version %s response from %s", highest.ID, identityBase)
+	}
+
+	return highest, endpoint, nil
+}
diff --git a/openstack/utils/choose_version_test.go b/openstack/utils/choose_version_test.go
new file mode 100644
index 0000000..9552696
--- /dev/null
+++ b/openstack/utils/choose_version_test.go
@@ -0,0 +1,105 @@
+package utils
+
+import (
+	"fmt"
+	"net/http"
+	"testing"
+
+	"github.com/rackspace/gophercloud/testhelper"
+)
+
+func setupVersionHandler() {
+	testhelper.Mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprintf(w, `
+			{
+				"versions": {
+					"values": [
+						{
+							"status": "stable",
+							"id": "v3.0",
+							"links": [
+								{ "href": "%s/v3.0", "rel": "self" }
+							]
+						},
+						{
+							"status": "stable",
+							"id": "v2.0",
+							"links": [
+								{ "href": "%s/v2.0", "rel": "self" }
+							]
+						}
+					]
+				}
+			}
+		`, testhelper.Server.URL, testhelper.Server.URL)
+	})
+}
+
+func TestChooseVersion(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	setupVersionHandler()
+
+	v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "blarg"}
+	v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "hargl"}
+
+	v, endpoint, err := ChooseVersion(testhelper.Endpoint(), "", []*Version{v2, v3})
+
+	if err != nil {
+		t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+	}
+
+	if v != v3 {
+		t.Errorf("Expected %#v to win, but %#v did instead", v3, v)
+	}
+
+	expected := testhelper.Endpoint() + "v3.0/"
+	if endpoint != expected {
+		t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+	}
+}
+
+func TestChooseVersionOpinionatedLink(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+	setupVersionHandler()
+
+	v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "nope"}
+	v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "northis"}
+
+	v, endpoint, err := ChooseVersion(testhelper.Endpoint(), testhelper.Endpoint()+"v2.0/", []*Version{v2, v3})
+	if err != nil {
+		t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+	}
+
+	if v != v2 {
+		t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+	}
+
+	expected := testhelper.Endpoint() + "v2.0/"
+	if endpoint != expected {
+		t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+	}
+}
+
+func TestChooseVersionFromSuffix(t *testing.T) {
+	testhelper.SetupHTTP()
+	defer testhelper.TeardownHTTP()
+
+	v2 := &Version{ID: "v2.0", Priority: 2, Suffix: "/v2.0/"}
+	v3 := &Version{ID: "v3.0", Priority: 3, Suffix: "/v3.0/"}
+
+	v, endpoint, err := ChooseVersion(testhelper.Endpoint(), testhelper.Endpoint()+"v2.0/", []*Version{v2, v3})
+	if err != nil {
+		t.Fatalf("Unexpected error from ChooseVersion: %v", err)
+	}
+
+	if v != v2 {
+		t.Errorf("Expected %#v to win, but %#v did instead", v2, v)
+	}
+
+	expected := testhelper.Endpoint() + "v2.0/"
+	if endpoint != expected {
+		t.Errorf("Expected endpoint [%s], but was [%s] instead", expected, endpoint)
+	}
+}
diff --git a/openstack/utils/client.go b/openstack/utils/client.go
index d1f0359..f8a6c57 100644
--- a/openstack/utils/client.go
+++ b/openstack/utils/client.go
@@ -2,6 +2,8 @@
 
 import (
 	"fmt"
+
+	"github.com/rackspace/gophercloud"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 )
 
@@ -12,7 +14,7 @@
 	// Authority holds the results of authenticating against the Endpoint.
 	Authority identity.AuthResults
 	// Options holds the authentication options. Useful for auto-reauthentication.
-	Options identity.AuthOptions
+	Options gophercloud.AuthOptions
 }
 
 // EndpointOpts contains options for finding an endpoint for an Openstack Client.
@@ -40,12 +42,13 @@
 //			Name: "nova",
 //		})
 //		serversClient := servers.NewClient(c.Endpoint, c.Authority, c.Options)
-func NewClient(ao identity.AuthOptions, eo EndpointOpts) (Client, error) {
+func NewClient(ao gophercloud.AuthOptions, eo EndpointOpts) (Client, error) {
 	client := Client{
 		Options: ao,
 	}
 
-	ar, err := identity.Authenticate(ao)
+	c := &gophercloud.ServiceClient{Endpoint: ao.IdentityEndpoint + "/"}
+	ar, err := identity.Authenticate(c, ao)
 	if err != nil {
 		return client, err
 	}
diff --git a/openstack/utils/utils.go b/openstack/utils/utils.go
index 99b0977..1d09d9e 100644
--- a/openstack/utils/utils.go
+++ b/openstack/utils/utils.go
@@ -1,18 +1,19 @@
-// This package contains utilities which eases working with Gophercloud's OpenStack APIs.
+// Package utils contains utilities which eases working with Gophercloud's OpenStack APIs.
 package utils
 
 import (
 	"fmt"
-	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 	"os"
+
+	"github.com/rackspace/gophercloud"
 )
 
-var nilOptions = identity.AuthOptions{}
+var nilOptions = gophercloud.AuthOptions{}
 
 // ErrNoAuthUrl, ErrNoUsername, and ErrNoPassword errors indicate of the required OS_AUTH_URL, OS_USERNAME, or OS_PASSWORD
 // environment variables, respectively, remain undefined.  See the AuthOptions() function for more details.
 var (
-	ErrNoAuthUrl  = fmt.Errorf("Environment variable OS_AUTH_URL needs to be set.")
+	ErrNoAuthURL  = fmt.Errorf("Environment variable OS_AUTH_URL needs to be set.")
 	ErrNoUsername = fmt.Errorf("Environment variable OS_USERNAME needs to be set.")
 	ErrNoPassword = fmt.Errorf("Environment variable OS_PASSWORD needs to be set.")
 )
@@ -21,18 +22,21 @@
 // OS_* environment variables.  The following variables provide sources of truth: OS_AUTH_URL, OS_USERNAME,
 // OS_PASSWORD, OS_TENANT_ID, and OS_TENANT_NAME.  Of these, OS_USERNAME, OS_PASSWORD, and OS_AUTH_URL must
 // have settings, or an error will result.  OS_TENANT_ID and OS_TENANT_NAME are optional.
-func AuthOptions() (identity.AuthOptions, error) {
-	authUrl := os.Getenv("OS_AUTH_URL")
+func AuthOptions() (gophercloud.AuthOptions, error) {
+	authURL := os.Getenv("OS_AUTH_URL")
 	username := os.Getenv("OS_USERNAME")
+	userID := os.Getenv("OS_USERID")
 	password := os.Getenv("OS_PASSWORD")
-	tenantId := os.Getenv("OS_TENANT_ID")
+	tenantID := os.Getenv("OS_TENANT_ID")
 	tenantName := os.Getenv("OS_TENANT_NAME")
+	domainID := os.Getenv("OS_DOMAIN_ID")
+	domainName := os.Getenv("OS_DOMAIN_NAME")
 
-	if authUrl == "" {
-		return nilOptions, ErrNoAuthUrl
+	if authURL == "" {
+		return nilOptions, ErrNoAuthURL
 	}
 
-	if username == "" {
+	if username == "" && userID == "" {
 		return nilOptions, ErrNoUsername
 	}
 
@@ -40,18 +44,26 @@
 		return nilOptions, ErrNoPassword
 	}
 
-	ao := identity.AuthOptions{
-		Endpoint:   authUrl,
-		Username:   username,
-		Password:   password,
-		TenantId:   tenantId,
-		TenantName: tenantName,
+	ao := gophercloud.AuthOptions{
+		IdentityEndpoint: authURL,
+		UserID:           userID,
+		Username:         username,
+		Password:         password,
+		TenantID:         tenantID,
+		TenantName:       tenantName,
+		DomainID:         domainID,
+		DomainName:       domainName,
 	}
 
 	return ao, nil
 }
 
+// BuildQuery constructs the query section of a URI from a map.
 func BuildQuery(params map[string]string) string {
+	if len(params) == 0 {
+		return ""
+	}
+
 	query := "?"
 	for k, v := range params {
 		query += k + "=" + v + "&"
diff --git a/provider_client.go b/provider_client.go
new file mode 100644
index 0000000..2be665e
--- /dev/null
+++ b/provider_client.go
@@ -0,0 +1,29 @@
+package gophercloud
+
+// ProviderClient stores details that are required to interact with any services within a specific provider's API.
+//
+// Generally, you acquire a ProviderClient by calling the `NewClient()` method in the appropriate provider's child package,
+// providing whatever authentication credentials are required.
+type ProviderClient struct {
+
+	// IdentityBase is the front door to an openstack provider.
+	// Generally this will be populated when you authenticate.
+	// It should be the *root* resource of the identity service, not of a specific identity version.
+	IdentityBase string
+
+	// IdentityEndpoint is the originally requested identity endpoint.
+	// This may be a specific version of the identity service, in which case that endpoint is used rather than querying the
+	// version-negotiation endpoint.
+	IdentityEndpoint string
+
+	// TokenID is the most recently valid token issued.
+	TokenID string
+
+	// EndpointLocator describes how this provider discovers the endpoints for its constituent services.
+	EndpointLocator EndpointLocator
+}
+
+// AuthenticatedHeaders returns a map of HTTP headers that are common for all authenticated service requests.
+func (client *ProviderClient) AuthenticatedHeaders() map[string]string {
+	return map[string]string{"X-Auth-Token": client.TokenID}
+}
diff --git a/rackspace/monitoring/common.go b/rackspace/monitoring/common.go
index 42c31ca..900c86e 100644
--- a/rackspace/monitoring/common.go
+++ b/rackspace/monitoring/common.go
@@ -1,11 +1,12 @@
 package monitoring
 
 import (
+	"github.com/rackspace/gophercloud"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 )
 
 type Options struct {
 	Endpoint       string
-	AuthOptions    identity.AuthOptions
+	AuthOptions    gophercloud.AuthOptions
 	Authentication identity.AuthResults
 }
diff --git a/rackspace/monitoring/notificationPlans/requests.go b/rackspace/monitoring/notificationPlans/requests.go
index 37fb40b..d4878c4 100644
--- a/rackspace/monitoring/notificationPlans/requests.go
+++ b/rackspace/monitoring/notificationPlans/requests.go
@@ -2,6 +2,7 @@
 
 import (
 	"fmt"
+
 	"github.com/racker/perigee"
 	identity "github.com/rackspace/gophercloud/openstack/identity/v2"
 	"github.com/rackspace/gophercloud/rackspace/monitoring"
@@ -33,7 +34,7 @@
 		Results: &dr,
 		OkCodes: []int{204},
 		MoreHeaders: map[string]string{
-			"X-Auth-Token": tok.Id,
+			"X-Auth-Token": tok.ID,
 		},
 	})
 	return dr, err
diff --git a/service_client.go b/service_client.go
new file mode 100644
index 0000000..83ad69b
--- /dev/null
+++ b/service_client.go
@@ -0,0 +1,19 @@
+package gophercloud
+
+import "strings"
+
+// ServiceClient stores details required to interact with a specific service API implemented by a provider.
+// Generally, you'll acquire these by calling the appropriate `New` method on a ProviderClient.
+type ServiceClient struct {
+	// Provider is a reference to the provider that implements this service.
+	Provider *ProviderClient
+
+	// Endpoint is the base URL of the service's API, acquired from a service catalog.
+	// It MUST end with a /.
+	Endpoint string
+}
+
+// ServiceURL constructs a URL for a resource belonging to this provider.
+func (client *ServiceClient) ServiceURL(parts ...string) string {
+	return client.Endpoint + strings.Join(parts, "/")
+}
diff --git a/testhelper/doc.go b/testhelper/doc.go
new file mode 100644
index 0000000..25b4dfe
--- /dev/null
+++ b/testhelper/doc.go
@@ -0,0 +1,4 @@
+/*
+Package testhelper container methods that are useful for writing unit tests.
+*/
+package testhelper
diff --git a/testhelper/http_responses.go b/testhelper/http_responses.go
new file mode 100644
index 0000000..481a833
--- /dev/null
+++ b/testhelper/http_responses.go
@@ -0,0 +1,113 @@
+package testhelper
+
+import (
+	"encoding/json"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"reflect"
+	"testing"
+)
+
+var (
+	// Mux is a multiplexer that can be used to register handlers.
+	Mux *http.ServeMux
+
+	// Server is an in-memory HTTP server for testing.
+	Server *httptest.Server
+)
+
+// SetupHTTP prepares the Mux and Server.
+func SetupHTTP() {
+	Mux = http.NewServeMux()
+	Server = httptest.NewServer(Mux)
+}
+
+// TeardownHTTP releases HTTP-related resources.
+func TeardownHTTP() {
+	Server.Close()
+}
+
+// Endpoint returns a fake endpoint that will actually target the Mux.
+func Endpoint() string {
+	return Server.URL + "/"
+}
+
+// TestFormValues ensures that all the URL parameters given to the http.Request are the same as values.
+func TestFormValues(t *testing.T, r *http.Request, values map[string]string) {
+	want := url.Values{}
+	for k, v := range values {
+		want.Add(k, v)
+	}
+
+	r.ParseForm()
+	if !reflect.DeepEqual(want, r.Form) {
+		t.Errorf("Request parameters = %v, want %v", r.Form, want)
+	}
+}
+
+// TestMethod checks that the Request has the expected method (e.g. GET, POST).
+func TestMethod(t *testing.T, r *http.Request, expected string) {
+	if expected != r.Method {
+		t.Errorf("Request method = %v, expected %v", r.Method, expected)
+	}
+}
+
+// TestHeader checks that the header on the http.Request matches the expected value.
+func TestHeader(t *testing.T, r *http.Request, header string, expected string) {
+	if actual := r.Header.Get(header); expected != actual {
+		t.Errorf("Header %s = %s, expected %s", header, actual, expected)
+	}
+}
+
+// TestBody verifies that the request body matches an expected body.
+func TestBody(t *testing.T, r *http.Request, expected string) {
+	b, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		t.Errorf("Unable to read body: %v", err)
+	}
+	str := string(b)
+	if expected != str {
+		t.Errorf("Body = %s, expected %s", str, expected)
+	}
+}
+
+// TestJSONRequest verifies that the JSON payload of a request matches an expected structure, without asserting things about
+// whitespace or ordering.
+func TestJSONRequest(t *testing.T, r *http.Request, expected string) {
+	b, err := ioutil.ReadAll(r.Body)
+	if err != nil {
+		t.Errorf("Unable to read request body: %v", err)
+	}
+
+	var expectedJSON interface{}
+	err = json.Unmarshal([]byte(expected), &expectedJSON)
+	if err != nil {
+		t.Errorf("Unable to parse expected value as JSON: %v", err)
+	}
+
+	var actualJSON interface{}
+	err = json.Unmarshal(b, &actualJSON)
+	if err != nil {
+		t.Errorf("Unable to parse request body as JSON: %v", err)
+	}
+
+	if !reflect.DeepEqual(expectedJSON, actualJSON) {
+		prettyExpected, err := json.MarshalIndent(expectedJSON, "", "  ")
+		if err != nil {
+			t.Logf("Unable to pretty-print expected JSON: %v\n%s", err, expected)
+		} else {
+			t.Logf("Expected JSON:\n%s", prettyExpected)
+		}
+
+		prettyActual, err := json.MarshalIndent(actualJSON, "", "  ")
+		if err != nil {
+			t.Logf("Unable to pretty-print actual JSON: %v\n%s", err, b)
+		} else {
+			t.Logf("Actual JSON:\n%s", prettyActual)
+		}
+
+		t.Errorf("Response body did not contain the correct JSON.")
+	}
+}