Merge pull request #46 from rackspace/reauth

Implement Reauth capability
diff --git a/acceptance/08-reauthentication.go b/acceptance/08-reauthentication.go
new file mode 100644
index 0000000..8b198c7
--- /dev/null
+++ b/acceptance/08-reauthentication.go
@@ -0,0 +1,65 @@
+package main
+
+import (
+	"fmt"
+	"flag"
+	"github.com/rackspace/gophercloud"
+)
+
+var quiet = flag.Bool("quiet", false, "Quiet mode for acceptance testing.  $? non-zero on error though.")
+var rgn = flag.String("r", "DFW", "Datacenter region to interrogate.")
+
+func main() {
+	provider, username, password := getCredentials()
+	flag.Parse()
+
+	// Authenticate initially against the service.
+	auth, err := gophercloud.Authenticate(
+		provider,
+		gophercloud.AuthOptions{
+			Username: username,
+			Password: password,
+			AllowReauth: true,		// This enables reauthentication.
+		},
+	)
+	if err != nil {
+		panic(err)
+	}
+
+	// Cache our initial authentication token.
+	token1 := auth.AuthToken()
+
+	// Acquire access to the cloud servers API.
+	servers, err := gophercloud.ServersApi(auth, gophercloud.ApiCriteria{
+		Name:      "cloudServersOpenStack",
+		Region:    *rgn,
+		VersionId: "2",
+		UrlChoice: gophercloud.PublicURL,
+	})
+	if err != nil {
+		panic(err)
+	}
+
+	// Just to confirm everything works, we should be able to list images without error.
+	_, err = servers.ListImages()
+	if err != nil {
+		panic(err)
+	}
+
+	// Revoke our current authentication token.
+	auth.Revoke(auth.AuthToken())
+
+	// Attempt to list images again.  This should _succeed_, because we enabled re-authentication.
+	_, err = servers.ListImages()
+	if err != nil {
+		panic(err)
+	}
+
+	// However, our new authentication token should differ.
+	token2 := auth.AuthToken()
+
+	if !*quiet {
+		fmt.Println("Old authentication token: ", token1)
+		fmt.Println("New authentication token: ", token2)
+	}
+}
diff --git a/authenticate.go b/authenticate.go
index 8b87e44..07a5611 100644
--- a/authenticate.go
+++ b/authenticate.go
@@ -16,6 +16,13 @@
 
 	// The TenantId field is optional for the Identity V2 API.
 	TenantId 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
 }
 
 // AuthContainer provides a JSON encoding wrapper for passing credentials to the Identity
@@ -44,6 +51,9 @@
 	Token          Token
 	ServiceCatalog []CatalogEntry
 	User           User
+	provider       Provider `json:"-"`
+	options        AuthOptions `json:"-"`
+	context        *Context `json:"-"`
 }
 
 // Token encapsulates an authentication token and when it expires.  It also includes
@@ -85,30 +95,18 @@
 	VersionId, VersionInfo, VersionList string
 }
 
-// Authenticate() grants access to the OpenStack-compatible provider API.
-//
-// Providers are identified through a unique key string.
-// See the RegisterProvider() method for more details.
-//
-// The supplied AuthOptions instance allows the client to specify only those credentials
-// relevant for the authentication request.  At present, support exists for OpenStack
-// Identity V2 API only; support for V3 will become available as soon as documentation for it
-// becomes readily available.
-//
-// For Identity V2 API requirements, you must provide at least the Username and Password
-// options.  The TenantId field is optional, and defaults to "".
-func (c *Context) Authenticate(provider string, options AuthOptions) (*Access, error) {
+// papersPlease contains the common logic between authentication and re-authentication.
+// The name, obviously a joke on the process of authentication, was chosen because
+// of how many other entities exist in the program containing the word Auth or Authorization.
+// I didn't need another one.
+func (c *Context) papersPlease(p Provider, options AuthOptions) (*Access, error) {
 	var access *Access
 
-	p, err := c.ProviderByName(provider)
-	if err != nil {
-		return nil, err
-	}
 	if (options.Username == "") || (options.Password == "") {
 		return nil, ErrCredentials
 	}
 
-	err = perigee.Post(p.AuthEndpoint, perigee.Options{
+	err := perigee.Post(p.AuthEndpoint, perigee.Options{
 		CustomClient: c.httpClient,
 		ReqBody: &AuthContainer{
 			Auth: Auth{
@@ -125,9 +123,50 @@
 			&access,
 		},
 	})
+	if err == nil {
+		access.options = options
+		access.provider = p
+		access.context = c
+	}
 	return access, err
 }
 
+// Authenticate() grants access to the OpenStack-compatible provider API.
+//
+// Providers are identified through a unique key string.
+// See the RegisterProvider() method for more details.
+//
+// The supplied AuthOptions instance allows the client to specify only those credentials
+// relevant for the authentication request.  At present, support exists for OpenStack
+// Identity V2 API only; support for V3 will become available as soon as documentation for it
+// becomes readily available.
+//
+// For Identity V2 API requirements, you must provide at least the Username and Password
+// options.  The TenantId field is optional, and defaults to "".
+func (c *Context) Authenticate(provider string, options AuthOptions) (*Access, error) {
+	p, err := c.ProviderByName(provider)
+	if err != nil {
+		return nil, err
+	}
+	return c.papersPlease(p, options)
+}
+
+// Reauthenticate attempts to reauthenticate using the configured access credentials, if
+// allowed.  This method takes no action unless your AuthOptions has the AllowReauth flag
+// set to true.
+func (a *Access) Reauthenticate() error {
+	var other *Access
+	var err error
+
+	if a.options.AllowReauth {
+		other, err = a.context.papersPlease(a.provider, a.options)
+		if err == nil {
+			*a = *other
+		}
+	}
+	return err
+}
+
 // See AccessProvider interface definition for details.
 func (a *Access) FirstEndpointUrlByCriteria(ac ApiCriteria) string {
 	ep := FindFirstEndpointByCriteria(a.ServiceCatalog, ac)
@@ -139,3 +178,15 @@
 func (a *Access) AuthToken() string {
 	return a.Token.Id
 }
+
+// See AccessProvider interface definition for details.
+func (a *Access) Revoke(tok string) error {
+	url := a.provider.AuthEndpoint + "/" + tok
+	err := perigee.Delete(url, perigee.Options{
+		MoreHeaders: map[string]string{
+			"X-Auth-Token": a.AuthToken(),
+		},
+		OkCodes: []int{204},
+	})
+	return err
+}
diff --git a/authenticate_test.go b/authenticate_test.go
index a19fec4..1d4d1f5 100644
--- a/authenticate_test.go
+++ b/authenticate_test.go
@@ -53,7 +53,7 @@
 `
 
 func TestAuthProvider(t *testing.T) {
-	tt := newTransport()
+	tt := newTransport().WithResponse(SUCCESSFUL_RESPONSE)
 	c := TestContext().UseCustomClient(&http.Client{
 		Transport: tt,
 	})
@@ -86,7 +86,7 @@
 }
 
 func TestTenantIdEncoding(t *testing.T) {
-	tt := newTransport()
+	tt := newTransport().WithResponse(SUCCESSFUL_RESPONSE)
 	c := TestContext().
 		UseCustomClient(&http.Client{
 		Transport: tt,
@@ -126,7 +126,7 @@
 func TestUserNameAndPassword(t *testing.T) {
 	c := TestContext().
 		WithProvider("provider", Provider{AuthEndpoint: "http://localhost/"}).
-		UseCustomClient(&http.Client{Transport: newTransport()})
+		UseCustomClient(&http.Client{Transport: newTransport().WithResponse(SUCCESSFUL_RESPONSE)})
 
 	credentials := []AuthOptions{
 		AuthOptions{},
@@ -212,3 +212,28 @@
 		return
 	}
 }
+
+func TestAuthenticationNeverReauths(t *testing.T) {
+	tt := newTransport().WithError(401)
+	c := TestContext().
+		UseCustomClient(&http.Client{Transport: tt}).
+		WithProvider("provider", Provider{AuthEndpoint: "http://localhost"})
+
+	_, err := c.Authenticate("provider", AuthOptions{Username: "u", Password: "p"})
+	if err == nil {
+		t.Error("Expected an error from a 401 Unauthorized response")
+		return
+	}
+
+	rc, _ := ActualResponseCode(err)
+	if rc != 401 {
+		t.Error("Expected a 401 error code")
+		return
+	}
+
+	err = tt.VerifyCalls(t, 1)
+	if err != nil {
+		// Test object already flagged.
+		return
+	}
+}
diff --git a/context.go b/context.go
index a946468..ebad36c 100644
--- a/context.go
+++ b/context.go
@@ -12,6 +12,10 @@
 	AuthEndpoint string
 }
 
+// ReauthHandlerFunc functions are responsible for somehow performing the task of
+// reauthentication.
+type ReauthHandlerFunc func(AccessProvider) error
+
 // Context structures encapsulate Gophercloud-global state in a manner which
 // facilitates easier unit testing.  As a user of this SDK, you'll never
 // have to use this structure, except when contributing new code to the SDK.
@@ -21,6 +25,17 @@
 
 	// httpClient refers to the current HTTP client interface to use.
 	httpClient *http.Client
+
+	// reauthHandler provides the functionality needed to re-authenticate
+	// if that feature is enabled.  Note: in order to allow for automatic
+	// re-authentication, the Context object will need to remember your
+	// username, password, and tenant ID as provided in the initial call
+	// to Authenticate().  If you do not desire this, you'll need to handle
+	// reauthentication yourself through other means.  Two methods exist:
+	// the first approach is to just handle errors yourself at the application
+	// layer, and the other is through a custom reauthentication handler
+	// set through the WithReauthHandler() method.
+	reauthHandler ReauthHandlerFunc
 }
 
 // TestContext yields a new Context instance, pre-initialized with a barren
@@ -34,6 +49,9 @@
 	return &Context{
 		providerMap: make(map[string]Provider),
 		httpClient:  &http.Client{},
+		reauthHandler: func(acc AccessProvider) error {
+			return acc.Reauthenticate()
+		},
 	}
 }
 
@@ -92,3 +110,15 @@
 
 	return gcp, nil
 }
+
+// WithReauthHandler configures the context to handle reauthentication attempts using the supplied
+// funtion.  By default, reauthentication happens by invoking Authenticate(), which is unlikely to be
+// useful in a unit test.
+//
+// Do not confuse this function with WithReauth()!  Although they work together to support reauthentication,
+// WithReauth() actually contains the decision-making logic to determine when to perform a reauth,
+// while WithReauthHandler() is used to configure what a reauth actually entails.
+func (c *Context) WithReauthHandler(f ReauthHandlerFunc) *Context {
+	c.reauthHandler = f
+	return c
+}
diff --git a/errors.go b/errors.go
index f113446..5ea3991 100644
--- a/errors.go
+++ b/errors.go
@@ -30,3 +30,8 @@
 // exists in the service catalog.  This can also happen if your tenant lacks
 // adequate permissions to access a given endpoint.
 var ErrEndpoint = fmt.Errorf("Missing endpoint, or insufficient privileges to access endpoint")
+
+// ErrError errors happen when you attempt to discover the response code
+// responsible for a previous request bombing with an error, but pass in an
+// error interface which doesn't belong to the web client.
+var ErrError = fmt.Errorf("Attempt to solicit actual HTTP response code from error entity which doesn't know")
diff --git a/global_context.go b/global_context.go
index c89ac17..9977aa8 100644
--- a/global_context.go
+++ b/global_context.go
@@ -1,5 +1,9 @@
 package gophercloud
 
+import (
+	"github.com/racker/perigee"
+)
+
 // globalContext is the, well, "global context."
 // Most of this SDK is written in a manner to facilitate easier testing,
 // which doesn't require all the configuration a real-world application would require.
@@ -46,3 +50,13 @@
 func ServersApi(acc AccessProvider, criteria ApiCriteria) (CloudServersProvider, error) {
 	return globalContext.ServersApi(acc, criteria)
 }
+
+// ActualResponseCode inspects a returned error, and discovers the actual response actual
+// response code that caused the error to be raised.
+func ActualResponseCode(e error) (int, error) {
+	err, ok := e.(*perigee.UnexpectedResponseCodeError)
+	if !ok {
+		return 0, ErrError
+	}
+	return err.Actual, nil
+}
\ No newline at end of file
diff --git a/images.go b/images.go
index 9126b93..cc3f6e2 100644
--- a/images.go
+++ b/images.go
@@ -7,13 +7,15 @@
 // See the CloudImagesProvider interface for details.
 func (gsp *genericServersProvider) ListImages() ([]Image, error) {
 	var is []Image
-	url := gsp.endpoint + "/images"
-	err := perigee.Get(url, perigee.Options{
-		CustomClient: gsp.context.httpClient,
-		Results:      &struct{ Images *[]Image }{&is},
-		MoreHeaders: map[string]string{
-			"X-Auth-Token": gsp.access.AuthToken(),
-		},
+	err := gsp.context.WithReauth(gsp.access, func() error {
+		url := gsp.endpoint + "/images"
+		return perigee.Get(url, perigee.Options{
+			CustomClient: gsp.context.httpClient,
+			Results:      &struct{ Images *[]Image }{&is},
+			MoreHeaders: map[string]string{
+				"X-Auth-Token": gsp.access.AuthToken(),
+			},
+		})
 	})
 	return is, err
 }
diff --git a/interfaces.go b/interfaces.go
index 6d9b3c7..b73a118 100644
--- a/interfaces.go
+++ b/interfaces.go
@@ -9,10 +9,16 @@
 	// field.
 	FirstEndpointUrlByCriteria(ApiCriteria) string
 
-	// TODO(sfalvo): get Token() to automatically renew the authentication token if it's near expiry.
-
 	// AuthToken provides a copy of the current authentication token for the user's credentials.
+	// Note that AuthToken() will not automatically refresh an expired token.
 	AuthToken() string
+
+	// Revoke allows you to terminate any program's access to the OpenStack API by token ID.
+	Revoke(string) error
+
+	// Reauthenticate attempts to acquire a new authentication token, if the feature is enabled by
+	// AuthOptions.AllowReauth.
+	Reauthenticate() error
 }
 
 // CloudServersProvider instances encapsulate a Cloud Servers API, should one exist in the service catalog
diff --git a/reauth.go b/reauth.go
new file mode 100644
index 0000000..5637ea2
--- /dev/null
+++ b/reauth.go
@@ -0,0 +1,23 @@
+package gophercloud
+
+import (
+	"github.com/racker/perigee"
+)
+
+// WithReauth wraps a Perigee request fragment with logic to perform re-authentication
+// if it's deemed necessary.
+//
+// Do not confuse this function with WithReauth()!  Although they work together to support reauthentication,
+// WithReauth() actually contains the decision-making logic to determine when to perform a reauth,
+// while WithReauthHandler() is used to configure what a reauth actually entails.
+func (c *Context) WithReauth(ap AccessProvider, f func() error) error {
+	err := f()
+	cause, ok := err.(*perigee.UnexpectedResponseCodeError)
+	if ok && cause.Actual == 401 {
+		err = c.reauthHandler(ap)
+		if err == nil {
+			err = f()
+		}
+	}
+	return err
+}
\ No newline at end of file
diff --git a/reauth_test.go b/reauth_test.go
new file mode 100644
index 0000000..e6ccb17
--- /dev/null
+++ b/reauth_test.go
@@ -0,0 +1,131 @@
+package gophercloud
+
+import (
+	"testing"
+	"github.com/racker/perigee"
+)
+
+// This reauth-handler does nothing, and returns no error.
+func doNothing(_ AccessProvider) error {
+	return nil
+}
+
+func TestOtherErrorsPropegate(t *testing.T) {
+	calls := 0
+	c := TestContext().WithReauthHandler(doNothing)
+
+	err := c.WithReauth(nil, func() error {
+		calls++
+		return &perigee.UnexpectedResponseCodeError{
+			Expected: []int{204},
+			Actual: 404,
+		}
+	})
+
+	if err == nil {
+		t.Error("Expected MyError to be returned; got nil instead.")
+		return
+	}
+	if _, ok := err.(*perigee.UnexpectedResponseCodeError); !ok {
+		t.Error("Expected UnexpectedResponseCodeError; got %#v", err)
+		return
+	}
+	if calls != 1 {
+		t.Errorf("Expected the body to be invoked once; found %d calls instead", calls)
+		return
+	}
+}
+
+func Test401ErrorCausesBodyInvokation2ndTime(t *testing.T) {
+	calls := 0
+	c := TestContext().WithReauthHandler(doNothing)
+
+	err := c.WithReauth(nil, func() error {
+		calls++
+		return &perigee.UnexpectedResponseCodeError{
+			Expected: []int{204},
+			Actual: 401,
+		}
+	})
+
+	if err == nil {
+		t.Error("Expected MyError to be returned; got nil instead.")
+		return
+	}
+	if calls != 2 {
+		t.Errorf("Expected the body to be invoked once; found %d calls instead", calls)
+		return
+	}
+}
+
+func TestReauthAttemptShouldHappen(t *testing.T) {
+	calls := 0
+	c := TestContext().WithReauthHandler(func(_ AccessProvider) error {
+		calls++
+		return nil
+	})
+	c.WithReauth(nil, func() error {
+		return &perigee.UnexpectedResponseCodeError{
+			Expected: []int{204},
+			Actual: 401,
+		}
+	})
+
+	if calls != 1 {
+		t.Errorf("Expected Reauthenticator to be called once; found %d instead", calls)
+		return
+	}
+}
+
+type MyError struct {}
+func (*MyError) Error() string {
+	return "MyError instance"
+}
+
+func TestReauthErrorShouldPropegate(t *testing.T) {
+	c := TestContext().WithReauthHandler(func(_ AccessProvider) error {
+		return &MyError{}
+	})
+
+	err := c.WithReauth(nil, func() error {
+		return &perigee.UnexpectedResponseCodeError{
+			Expected: []int{204},
+			Actual: 401,
+		}
+	})
+
+	if _, ok := err.(*MyError); !ok {
+		t.Errorf("Expected a MyError; got %#v", err)
+		return
+	}
+}
+
+type MyAccess struct {}
+func (my *MyAccess) FirstEndpointUrlByCriteria(ApiCriteria) string {
+	return ""
+}
+func (my *MyAccess) AuthToken() string {
+	return ""
+}
+func (my *MyAccess) Revoke(string) error {
+	return nil
+}
+func (my *MyAccess) Reauthenticate() error {
+	return nil
+}
+
+func TestReauthHandlerUsesSameAccessProvider(t *testing.T) {
+	fakeAccess := &MyAccess{}
+	c := TestContext().WithReauthHandler(func(acc AccessProvider) error {
+		if acc != fakeAccess {
+			t.Errorf("Expected acc = fakeAccess")
+		}
+		return nil
+	})
+	c.WithReauth(fakeAccess, func() error {
+		return &perigee.UnexpectedResponseCodeError{
+			Expected: []int{204},
+			Actual: 401,
+		}
+	})
+}
diff --git a/servers_test.go b/servers_test.go
index 46ba6db..60c71c8 100644
--- a/servers_test.go
+++ b/servers_test.go
@@ -20,6 +20,14 @@
 	return ""
 }
 
+func (ta *testAccess) Revoke(string) error {
+	return nil
+}
+
+func (ta *testAccess) Reauthenticate() error {
+	return nil
+}
+
 func TestGetServersApi(t *testing.T) {
 	c := TestContext().UseCustomClient(&http.Client{Transport: newTransport().WithResponse("Hello")})
 
diff --git a/transport_double.go b/transport_double.go
index b65d5db..e764c9a 100644
--- a/transport_double.go
+++ b/transport_double.go
@@ -5,6 +5,8 @@
 	"io/ioutil"
 	"net/http"
 	"strings"
+	"fmt"
+	"testing"
 )
 
 type transport struct {
@@ -12,6 +14,7 @@
 	response       string
 	expectTenantId bool
 	tenantIdFound  bool
+	status int
 }
 
 func (t *transport) RoundTrip(req *http.Request) (rsp *http.Response, err error) {
@@ -24,9 +27,17 @@
 
 	body := ioutil.NopCloser(strings.NewReader(t.response))
 
+	if t.status == 0 {
+		t.status = 200
+	}
+	statusMsg := "OK"
+	if (t.status < 200) || (299 < t.status) {
+		statusMsg = "Error"
+	}
+
 	rsp = &http.Response{
-		Status:           "200 OK",
-		StatusCode:       200,
+		Status:           fmt.Sprintf("%d %s", t.status, statusMsg),
+		StatusCode:       t.status,
 		Proto:            "HTTP/1.1",
 		ProtoMajor:       1,
 		ProtoMinor:       1,
@@ -72,5 +83,21 @@
 
 func (t *transport) WithResponse(r string) *transport {
 	t.response = r
+	t.status = 200
 	return t
 }
+
+func (t *transport) WithError(code int) *transport {
+	t.response = fmt.Sprintf("Error %d", code)
+	t.status = code
+	return t
+}
+
+func (t *transport) VerifyCalls(test *testing.T, n int) error {
+	if t.called != n {
+		err := fmt.Errorf("Expected Transport to be called %d times; found %d instead", n, t.called)
+		test.Error(err)
+		return err
+	}
+	return nil
+}
\ No newline at end of file