Skip to content

Commit 6a45f26

Browse files
authored
feat(auth): add base auth package (#8465)
This package provides: - A TokenProvider interface - A Token type - A standard auth Error type - Configuration and TokenProvider for 2L0 oauth2 flows - Configuration and TokenProvider for 3L0 oauth2 flows - A means to cache tokens This code has been adapted from the golang oauth2 repo. In particular the types here are analogous to: oauth2, authhandler, and jwt packages. It should feel familiar for anyone who as worked with that library before, but it only provides a subset of the features that we require for our client libraries and auth stack as defined by our AIPs.
1 parent 14aa857 commit 6a45f26

14 files changed

+2083
-18
lines changed

.release-please-manifest-individual.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"auth": "0.0.0",
23
"bigquery": "1.54.0",
34
"bigtable": "1.19.0",
45
"datastore": "1.14.0",

auth/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# auth
2+
3+
This module is currently EXPERIMENTAL and under active development. It is not
4+
yet intended to be used.

auth/auth.go

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
// Copyright 2023 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"io"
22+
"net/http"
23+
"net/url"
24+
"strings"
25+
"sync"
26+
"time"
27+
28+
"cloud.google.com/go/auth/internal"
29+
"cloud.google.com/go/auth/internal/jwt"
30+
)
31+
32+
const (
33+
// Parameter keys for AuthCodeURL method to support PKCE.
34+
codeChallengeKey = "code_challenge"
35+
codeChallengeMethodKey = "code_challenge_method"
36+
37+
// Parameter key for Exchange method to support PKCE.
38+
codeVerifierKey = "code_verifier"
39+
40+
defaultExpiryDelta = 10 * time.Second
41+
)
42+
43+
var (
44+
defaultGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"
45+
defaultHeader = &jwt.Header{Algorithm: jwt.HeaderAlgRSA256, Type: jwt.HeaderType}
46+
47+
// for testing
48+
timeNow = time.Now
49+
)
50+
51+
// TokenProvider specifies an interface for anything that can return a token.
52+
type TokenProvider interface {
53+
// Token returns a Token or an error.
54+
// The Token returned must be safe to use
55+
// concurrently.
56+
// The returned Token must not be modified.
57+
// The context provided must be sent along to any requests that are made in
58+
// the implementing code.
59+
Token(context.Context) (*Token, error)
60+
}
61+
62+
// Token holds the credential token used to authorized requests. All fields are
63+
// considered read-only.
64+
type Token struct {
65+
// Value is the token used to authorize requests. It is usually an access
66+
// token but may be other types of tokens such as ID tokens in some flows.
67+
Value string
68+
// Type is the type of token Value is. If uninitialized, it should be
69+
// assumed to be a "Bearer" token.
70+
Type string
71+
// Expiry is the time the token is set to expire.
72+
Expiry time.Time
73+
// Metadata may include, but is not limited to, the body of the token
74+
// response returned by the server.
75+
Metadata map[string]interface{} // TODO(codyoss): maybe make a method to flatten metadata to avoid []string for url.Values
76+
}
77+
78+
// IsValid reports that a [Token] is non-nil, has a [Token.Value], and has not
79+
// expired. A token is considered expired if [Token.Expiry] has passed or will
80+
// pass in the next 10 seconds.
81+
func (t *Token) IsValid() bool {
82+
return t.isValidWithEarlyExpiry(defaultExpiryDelta)
83+
}
84+
85+
func (t *Token) isValidWithEarlyExpiry(earlyExpiry time.Duration) bool {
86+
if t == nil || t.Value == "" {
87+
return false
88+
}
89+
if t.Expiry.IsZero() {
90+
return true
91+
}
92+
return !t.Expiry.Round(0).Add(-earlyExpiry).Before(timeNow())
93+
}
94+
95+
// CachedTokenProviderOptions provided options for configuring a
96+
// CachedTokenProvider.
97+
type CachedTokenProviderOptions struct {
98+
// DisableAutoRefresh makes the TokenProvider always return the same token,
99+
// even if it is expired.
100+
DisableAutoRefresh bool
101+
// ExpireEarly configures the amount of time before a token expires, that it
102+
// should be refreshed. If unset, the default value is 10 seconds.
103+
ExpireEarly time.Duration
104+
}
105+
106+
func (ctpo *CachedTokenProviderOptions) autoRefresh() bool {
107+
if ctpo == nil {
108+
return true
109+
}
110+
return !ctpo.DisableAutoRefresh
111+
}
112+
113+
func (ctpo *CachedTokenProviderOptions) expireEarly() time.Duration {
114+
if ctpo == nil {
115+
return defaultExpiryDelta
116+
}
117+
return ctpo.ExpireEarly
118+
}
119+
120+
// NewCachedTokenProvider wraps a [TokenProvider] to cache the tokens returned
121+
// by the underlying provider. By default it will refresh tokens ten seconds
122+
// before they expire, but this time can be configured with the optional
123+
// options.
124+
func NewCachedTokenProvider(tp TokenProvider, opts *CachedTokenProviderOptions) TokenProvider {
125+
if ctp, ok := tp.(*cachedTokenProvider); ok {
126+
return ctp
127+
}
128+
return &cachedTokenProvider{
129+
tp: tp,
130+
autoRefresh: opts.autoRefresh(),
131+
expireEarly: opts.expireEarly(),
132+
}
133+
}
134+
135+
type cachedTokenProvider struct {
136+
tp TokenProvider
137+
autoRefresh bool
138+
expireEarly time.Duration
139+
140+
mu sync.Mutex
141+
cachedToken *Token
142+
}
143+
144+
func (c *cachedTokenProvider) Token(ctx context.Context) (*Token, error) {
145+
c.mu.Lock()
146+
defer c.mu.Unlock()
147+
if c.cachedToken.IsValid() || !c.autoRefresh {
148+
return c.cachedToken, nil
149+
}
150+
t, err := c.tp.Token(ctx)
151+
if err != nil {
152+
return nil, err
153+
}
154+
c.cachedToken = t
155+
return t, nil
156+
}
157+
158+
// Error is a error associated with retrieving a [Token]. It can hold useful
159+
// additional details for debugging.
160+
type Error struct {
161+
// Response is the HTTP response associated with error. The body will always
162+
// be already closed and consumed.
163+
Response *http.Response
164+
// Body is the HTTP response body.
165+
Body []byte
166+
// Err is the underlying wrapped error.
167+
Err error
168+
169+
// code returned in the token response
170+
code string
171+
// description returned in the token response
172+
description string
173+
// uri returned in the token response
174+
uri string
175+
}
176+
177+
func (r *Error) Error() string {
178+
if r.code != "" {
179+
s := fmt.Sprintf("auth: %q", r.code)
180+
if r.description != "" {
181+
s += fmt.Sprintf(" %q", r.description)
182+
}
183+
if r.uri != "" {
184+
s += fmt.Sprintf(" %q", r.uri)
185+
}
186+
return s
187+
}
188+
return fmt.Sprintf("auth: cannot fetch token: %v\nResponse: %s", r.Response.StatusCode, r.Body)
189+
}
190+
191+
// Temporary returns true if the error is considered temporary and may be able
192+
// to be retried.
193+
func (e *Error) Temporary() bool {
194+
if e.Response == nil {
195+
return false
196+
}
197+
sc := e.Response.StatusCode
198+
return sc == http.StatusInternalServerError || sc == http.StatusServiceUnavailable || sc == http.StatusRequestTimeout || sc == http.StatusTooManyRequests
199+
}
200+
201+
func (e *Error) Unwrap() error {
202+
return e.Err
203+
}
204+
205+
// Style describes how the token endpoint wants to receive the ClientID and
206+
// ClientSecret.
207+
type Style int
208+
209+
const (
210+
// StyleUnknown means the value has not been initiated. Sending this in
211+
// a request will cause the token exchange to fail.
212+
StyleUnknown Style = iota
213+
// StyleInParams sends client info in the body of a POST request.
214+
StyleInParams
215+
// StyleInHeader sends client info using Basic Authorization header.
216+
StyleInHeader
217+
)
218+
219+
// Options2LO is the configuration settings for doing a 2-legged JWT OAuth2 flow.
220+
type Options2LO struct {
221+
// Email is the OAuth2 client ID. This value is set as the "iss" in the
222+
// JWT.
223+
Email string
224+
// PrivateKey contains the contents of an RSA private key or the
225+
// contents of a PEM file that contains a private key. It is used to sign
226+
// the JWT created.
227+
PrivateKey []byte
228+
// PrivateKeyID is the ID of the key used to sign the JWT. It is used as the
229+
// "kid" in the JWT header.
230+
PrivateKeyID string
231+
// Subject is the used for to impersonate a user. It is used as the "sub" in
232+
// the JWT.m Optional.
233+
Subject string
234+
// Scopes specifies requested permissions for the token. Optional.
235+
Scopes []string
236+
// TokenURL is th URL the JWT is sent to.
237+
TokenURL string
238+
// Expires specifies the lifetime of the token.
239+
Expires time.Duration
240+
// Audience specifies the "aud" in the JWT. Optional.
241+
Audience string
242+
// PrivateClaims allows specifying any custom claims for the JWT. Optional.
243+
PrivateClaims map[string]interface{}
244+
245+
// Client is the client to be used to make the underlying token requests.
246+
// Optional.
247+
Client *http.Client
248+
// UseIDToken requests that the token returned be an ID token if one is
249+
// returned from the server. Optional.
250+
UseIDToken bool
251+
}
252+
253+
func (c *Options2LO) client() *http.Client {
254+
if c.Client != nil {
255+
return c.Client
256+
}
257+
return internal.CloneDefaultClient()
258+
}
259+
260+
// New2LOTokenProvider returns a [TokenProvider] from the provided options.
261+
func New2LOTokenProvider(opts *Options2LO) (TokenProvider, error) {
262+
// TODO(codyoss): add validation
263+
return tokenProvider2LO{opts: opts, Client: opts.client()}, nil
264+
}
265+
266+
type tokenProvider2LO struct {
267+
opts *Options2LO
268+
Client *http.Client
269+
}
270+
271+
func (tp tokenProvider2LO) Token(ctx context.Context) (*Token, error) {
272+
pk, err := internal.ParseKey(tp.opts.PrivateKey)
273+
if err != nil {
274+
return nil, err
275+
}
276+
claimSet := &jwt.Claims{
277+
Iss: tp.opts.Email,
278+
Scope: strings.Join(tp.opts.Scopes, " "),
279+
Aud: tp.opts.TokenURL,
280+
AdditionalClaims: tp.opts.PrivateClaims,
281+
Sub: tp.opts.Subject,
282+
}
283+
if t := tp.opts.Expires; t > 0 {
284+
claimSet.Exp = time.Now().Add(t).Unix()
285+
}
286+
if aud := tp.opts.Audience; aud != "" {
287+
claimSet.Aud = aud
288+
}
289+
h := *defaultHeader
290+
h.KeyID = tp.opts.PrivateKeyID
291+
payload, err := jwt.EncodeJWS(&h, claimSet, pk)
292+
if err != nil {
293+
return nil, err
294+
}
295+
v := url.Values{}
296+
v.Set("grant_type", defaultGrantType)
297+
v.Set("assertion", payload)
298+
resp, err := tp.Client.PostForm(tp.opts.TokenURL, v)
299+
if err != nil {
300+
return nil, fmt.Errorf("auth: cannot fetch token: %w", err)
301+
}
302+
defer resp.Body.Close()
303+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
304+
if err != nil {
305+
return nil, fmt.Errorf("auth: cannot fetch token: %w", err)
306+
}
307+
if c := resp.StatusCode; c < http.StatusOK || c >= http.StatusMultipleChoices {
308+
return nil, &Error{
309+
Response: resp,
310+
Body: body,
311+
}
312+
}
313+
// tokenRes is the JSON response body.
314+
var tokenRes struct {
315+
AccessToken string `json:"access_token"`
316+
TokenType string `json:"token_type"`
317+
IDToken string `json:"id_token"`
318+
ExpiresIn int64 `json:"expires_in"`
319+
}
320+
if err := json.Unmarshal(body, &tokenRes); err != nil {
321+
return nil, fmt.Errorf("auth: cannot fetch token: %w", err)
322+
}
323+
token := &Token{
324+
Value: tokenRes.AccessToken,
325+
Type: tokenRes.TokenType,
326+
}
327+
token.Metadata = make(map[string]interface{})
328+
json.Unmarshal(body, &token.Metadata) // no error checks for optional fields
329+
330+
if secs := tokenRes.ExpiresIn; secs > 0 {
331+
token.Expiry = time.Now().Add(time.Duration(secs) * time.Second)
332+
}
333+
if v := tokenRes.IDToken; v != "" {
334+
// decode returned id token to get expiry
335+
claimSet, err := jwt.DecodeJWS(v)
336+
if err != nil {
337+
return nil, fmt.Errorf("auth: error decoding JWT token: %w", err)
338+
}
339+
token.Expiry = time.Unix(claimSet.Exp, 0)
340+
}
341+
if tp.opts.UseIDToken {
342+
if tokenRes.IDToken == "" {
343+
return nil, fmt.Errorf("auth: response doesn't have JWT token")
344+
}
345+
token.Value = tokenRes.IDToken
346+
}
347+
return token, nil
348+
}

0 commit comments

Comments
 (0)