git.iko.soy/typeable/oauth2-proxy
on master help sign in
Azure token refresh (#754)
0e119d7Alexander Block
CHANGELOG.md+7
@@ -23,9 +23,16 @@
23 via the login url. If this option was used in the past, behavior will change with this release as it will
23 via the login url. If this option was used in the past, behavior will change with this release as it will
24 affect the tokens returned by Azure. In the past, the tokens were always for `https://graph.microsoft.com` (the default)
24 affect the tokens returned by Azure. In the past, the tokens were always for `https://graph.microsoft.com` (the default)
25 and will now be for the configured resource (if it exists, otherwise it will run into errors)
25 and will now be for the configured resource (if it exists, otherwise it will run into errors)
26+- [#754](https://github.com/oauth2-proxy/oauth2-proxy/pull/754) The Azure provider now has token refresh functionality implemented. This means that there won't
27+ be any redirects in the browser anymore when tokens expire, but instead a token refresh is initiated
28+ in the background, which leads to new tokens being returned in the cookies.
29+ - Please note that `--cookie-refresh` must be 0 (the default) or equal to the token lifespan configured in Azure AD to make
30+ Azure token refresh reliable. Setting this value to 0 means that it relies on the provider implementation
31+ to decide if a refresh is required.
26
32
27## Changes since v6.1.1
33## Changes since v6.1.1
28
34
35+- [#754](https://github.com/oauth2-proxy/oauth2-proxy/pull/754) Azure token refresh (@codablock)
29- [#825](https://github.com/oauth2-proxy/oauth2-proxy/pull/825) Fix code coverage reporting on GitHub actions(@JoelSpeed)
36- [#825](https://github.com/oauth2-proxy/oauth2-proxy/pull/825) Fix code coverage reporting on GitHub actions(@JoelSpeed)
30- [#796](https://github.com/oauth2-proxy/oauth2-proxy/pull/796) Deprecate GetUserName & GetEmailAdress for EnrichSessionState (@NickMeves)
37- [#796](https://github.com/oauth2-proxy/oauth2-proxy/pull/796) Deprecate GetUserName & GetEmailAdress for EnrichSessionState (@NickMeves)
31- [#705](https://github.com/oauth2-proxy/oauth2-proxy/pull/705) Add generic Header injectors for upstream request and response headers (@JoelSpeed)
38- [#705](https://github.com/oauth2-proxy/oauth2-proxy/pull/705) Add generic Header injectors for upstream request and response headers (@JoelSpeed)
providers/azure.go+66
@@ -4,6 +4,7 @@ import (
4 "bytes"
4 "bytes"
5 "context"
5 "context"
6 "errors"
6 "errors"
7+ "fmt"
7 "net/http"
8 "net/http"
8 "net/url"
9 "net/url"
9 "time"
10 "time"
@@ -74,6 +75,9 @@ func NewAzureProvider(p *ProviderData) *AzureProvider {
74 if p.ProtectedResource == nil || p.ProtectedResource.String() == "" {
75 if p.ProtectedResource == nil || p.ProtectedResource.String() == "" {
75 p.ProtectedResource = azureDefaultProtectResourceURL
76 p.ProtectedResource = azureDefaultProtectResourceURL
76 }
77 }
78+ if p.ValidateURL == nil || p.ValidateURL.String() == "" {
79+ p.ValidateURL = p.ProfileURL
80+ }
77
81
78 return &AzureProvider{
82 return &AzureProvider{
79 ProviderData: p,
83 ProviderData: p,
@@ -103,6 +107,7 @@ func overrideTenantURL(current, defaultURL *url.URL, tenant, path string) {
103 }
107 }
104}
108}
105
109
110+// Redeem exchanges the OAuth2 authentication token for an ID token
106func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
111func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (s *sessions.SessionState, err error) {
107 if code == "" {
112 if code == "" {
108 err = errors.New("missing code")
113 err = errors.New("missing code")
@@ -123,6 +128,7 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (s
123 params.Add("resource", p.ProtectedResource.String())
128 params.Add("resource", p.ProtectedResource.String())
124 }
129 }
125
130
131+ // blindly try json and x-www-form-urlencoded
126 var jsonResponse struct {
132 var jsonResponse struct {
127 AccessToken string `json:"access_token"`
133 AccessToken string `json:"access_token"`
128 RefreshToken string `json:"refresh_token"`
134 RefreshToken string `json:"refresh_token"`
@@ -151,6 +157,61 @@ func (p *AzureProvider) Redeem(ctx context.Context, redirectURL, code string) (s
151 RefreshToken: jsonResponse.RefreshToken,
157 RefreshToken: jsonResponse.RefreshToken,
152 }
158 }
153 return
159 return
160+
161+}
162+
163+// RefreshSessionIfNeeded checks if the session has expired and uses the
164+// RefreshToken to fetch a new ID token if required
165+func (p *AzureProvider) RefreshSessionIfNeeded(ctx context.Context, s *sessions.SessionState) (bool, error) {
166+ if s == nil || s.ExpiresOn.After(time.Now()) || s.RefreshToken == "" {
167+ return false, nil
168+ }
169+
170+ origExpiration := s.ExpiresOn
171+
172+ err := p.redeemRefreshToken(ctx, s)
173+ if err != nil {
174+ return false, fmt.Errorf("unable to redeem refresh token: %v", err)
175+ }
176+
177+ fmt.Printf("refreshed id token %s (expired on %s)\n", s, origExpiration)
178+ return true, nil
179+}
180+
181+func (p *AzureProvider) redeemRefreshToken(ctx context.Context, s *sessions.SessionState) (err error) {
182+ params := url.Values{}
183+ params.Add("client_id", p.ClientID)
184+ params.Add("client_secret", p.ClientSecret)
185+ params.Add("refresh_token", s.RefreshToken)
186+ params.Add("grant_type", "refresh_token")
187+
188+ var jsonResponse struct {
189+ AccessToken string `json:"access_token"`
190+ RefreshToken string `json:"refresh_token"`
191+ ExpiresOn int64 `json:"expires_on,string"`
192+ IDToken string `json:"id_token"`
193+ }
194+
195+ err = requests.New(p.RedeemURL.String()).
196+ WithContext(ctx).
197+ WithMethod("POST").
198+ WithBody(bytes.NewBufferString(params.Encode())).
199+ SetHeader("Content-Type", "application/x-www-form-urlencoded").
200+ Do().
201+ UnmarshalInto(&jsonResponse)
202+
203+ if err != nil {
204+ return
205+ }
206+
207+ now := time.Now()
208+ expires := time.Unix(jsonResponse.ExpiresOn, 0)
209+ s.AccessToken = jsonResponse.AccessToken
210+ s.IDToken = jsonResponse.IDToken
211+ s.RefreshToken = jsonResponse.RefreshToken
212+ s.CreatedAt = &now
213+ s.ExpiresOn = &expires
214+ return
154}
215}
155
216
156func makeAzureHeader(accessToken string) http.Header {
217func makeAzureHeader(accessToken string) http.Header {
@@ -219,3 +280,8 @@ func (p *AzureProvider) GetLoginURL(redirectURI, state string) string {
219 a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
280 a := makeLoginURL(p.ProviderData, redirectURI, state, extraParams)
220 return a.String()
281 return a.String()
221}
282}
283+
284+// ValidateSessionState validates the AccessToken
285+func (p *AzureProvider) ValidateSessionState(ctx context.Context, s *sessions.SessionState) bool {
286+ return validateToken(ctx, p, s.AccessToken, makeAzureHeader(s.AccessToken))
287+}
providers/azure_test.go+48−2
@@ -8,6 +8,8 @@ import (
8 "testing"
8 "testing"
9 "time"
9 "time"
10
10
11+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
12+
11 . "github.com/onsi/gomega"
13 . "github.com/onsi/gomega"
12 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/assert"
13)
15)
@@ -42,7 +44,7 @@ func TestNewAzureProvider(t *testing.T) {
42 g.Expect(providerData.LoginURL.String()).To(Equal("https://login.microsoftonline.com/common/oauth2/authorize"))
44 g.Expect(providerData.LoginURL.String()).To(Equal("https://login.microsoftonline.com/common/oauth2/authorize"))
43 g.Expect(providerData.RedeemURL.String()).To(Equal("https://login.microsoftonline.com/common/oauth2/token"))
45 g.Expect(providerData.RedeemURL.String()).To(Equal("https://login.microsoftonline.com/common/oauth2/token"))
44 g.Expect(providerData.ProfileURL.String()).To(Equal("https://graph.microsoft.com/v1.0/me"))
46 g.Expect(providerData.ProfileURL.String()).To(Equal("https://graph.microsoft.com/v1.0/me"))
45− g.Expect(providerData.ValidateURL.String()).To(Equal(""))
47+ g.Expect(providerData.ValidateURL.String()).To(Equal("https://graph.microsoft.com/v1.0/me"))
46 g.Expect(providerData.Scope).To(Equal("openid"))
48 g.Expect(providerData.Scope).To(Equal("openid"))
47}
49}
48
50
@@ -97,7 +99,7 @@ func TestAzureSetTenant(t *testing.T) {
97 p.Data().ProfileURL.String())
99 p.Data().ProfileURL.String())
98 assert.Equal(t, "https://graph.microsoft.com",
100 assert.Equal(t, "https://graph.microsoft.com",
99 p.Data().ProtectedResource.String())
101 p.Data().ProtectedResource.String())
100− assert.Equal(t, "", p.Data().ValidateURL.String())
102+ assert.Equal(t, "https://graph.microsoft.com/v1.0/me", p.Data().ValidateURL.String())
101 assert.Equal(t, "openid", p.Data().Scope)
103 assert.Equal(t, "openid", p.Data().Scope)
102}
104}
103
105
@@ -220,3 +222,47 @@ func TestAzureProviderProtectedResourceConfigured(t *testing.T) {
220 result := p.GetLoginURL("https://my.test.app/oauth", "")
222 result := p.GetLoginURL("https://my.test.app/oauth", "")
221 assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test"))
223 assert.Contains(t, result, "resource="+url.QueryEscape("http://my.resource.test"))
222}
224}
225+
226+func TestAzureProviderGetsTokensInRedeem(t *testing.T) {
227+ b := testAzureBackend(`{ "access_token": "some_access_token", "refresh_token": "some_refresh_token", "expires_on": "1136239445", "id_token": "some_id_token" }`)
228+ defer b.Close()
229+ timestamp, _ := time.Parse(time.RFC3339, "2006-01-02T22:04:05Z")
230+ bURL, _ := url.Parse(b.URL)
231+ p := testAzureProvider(bURL.Host)
232+
233+ session, err := p.Redeem(context.Background(), "http://redirect/", "code1234")
234+ assert.Equal(t, nil, err)
235+ assert.NotEqual(t, session, nil)
236+ assert.Equal(t, "some_access_token", session.AccessToken)
237+ assert.Equal(t, "some_refresh_token", session.RefreshToken)
238+ assert.Equal(t, "some_id_token", session.IDToken)
239+ assert.Equal(t, timestamp, session.ExpiresOn.UTC())
240+}
241+
242+func TestAzureProviderNotRefreshWhenNotExpired(t *testing.T) {
243+ p := testAzureProvider("")
244+
245+ expires := time.Now().Add(time.Duration(1) * time.Hour)
246+ session := &sessions.SessionState{AccessToken: "some_access_token", RefreshToken: "some_refresh_token", IDToken: "some_id_token", ExpiresOn: &expires}
247+ refreshNeeded, err := p.RefreshSessionIfNeeded(context.Background(), session)
248+ assert.Equal(t, nil, err)
249+ assert.False(t, refreshNeeded)
250+}
251+
252+func TestAzureProviderRefreshWhenExpired(t *testing.T) {
253+ b := testAzureBackend(`{ "access_token": "new_some_access_token", "refresh_token": "new_some_refresh_token", "expires_on": "32693148245", "id_token": "new_some_id_token" }`)
254+ defer b.Close()
255+ timestamp, _ := time.Parse(time.RFC3339, "3006-01-02T22:04:05Z")
256+ bURL, _ := url.Parse(b.URL)
257+ p := testAzureProvider(bURL.Host)
258+
259+ expires := time.Now().Add(time.Duration(-1) * time.Hour)
260+ session := &sessions.SessionState{AccessToken: "some_access_token", RefreshToken: "some_refresh_token", IDToken: "some_id_token", ExpiresOn: &expires}
261+ _, err := p.RefreshSessionIfNeeded(context.Background(), session)
262+ assert.Equal(t, nil, err)
263+ assert.NotEqual(t, session, nil)
264+ assert.Equal(t, "new_some_access_token", session.AccessToken)
265+ assert.Equal(t, "new_some_refresh_token", session.RefreshToken)
266+ assert.Equal(t, "new_some_id_token", session.IDToken)
267+ assert.Equal(t, timestamp, session.ExpiresOn.UTC())
268+}