1// Copyright 2020 Google LLC.
2// Use of this source code is governed by a BSD-style
3// license that can be found in the LICENSE file.
4
5package idtoken
6
7import (
8	"context"
9	"encoding/json"
10	"fmt"
11	"net/http"
12
13	"cloud.google.com/go/compute/metadata"
14	"golang.org/x/oauth2"
15	"golang.org/x/oauth2/google"
16
17	"google.golang.org/api/internal"
18	"google.golang.org/api/option"
19	"google.golang.org/api/option/internaloption"
20	htransport "google.golang.org/api/transport/http"
21)
22
23// ClientOption is aliased so relevant options are easily found in the docs.
24
25// ClientOption is for configuring a Google API client or transport.
26type ClientOption = option.ClientOption
27
28// NewClient creates a HTTP Client that automatically adds an ID token to each
29// request via an Authorization header. The token will have have the audience
30// provided and be configured with the supplied options. The parameter audience
31// may not be empty.
32func NewClient(ctx context.Context, audience string, opts ...ClientOption) (*http.Client, error) {
33	var ds internal.DialSettings
34	for _, opt := range opts {
35		opt.Apply(&ds)
36	}
37	if err := ds.Validate(); err != nil {
38		return nil, err
39	}
40	if ds.NoAuth {
41		return nil, fmt.Errorf("idtoken: option.WithoutAuthentication not supported")
42	}
43	if ds.APIKey != "" {
44		return nil, fmt.Errorf("idtoken: option.WithAPIKey not supported")
45	}
46	if ds.TokenSource != nil {
47		return nil, fmt.Errorf("idtoken: option.WithTokenSource not supported")
48	}
49
50	ts, err := NewTokenSource(ctx, audience, opts...)
51	if err != nil {
52		return nil, err
53	}
54	// Skip DialSettings validation so added TokenSource will not conflict with user
55	// provided credentials.
56	opts = append(opts, option.WithTokenSource(ts), internaloption.SkipDialSettingsValidation())
57	t, err := htransport.NewTransport(ctx, http.DefaultTransport, opts...)
58	if err != nil {
59		return nil, err
60	}
61	return &http.Client{Transport: t}, nil
62}
63
64// NewTokenSource creates a TokenSource that returns ID tokens with the audience
65// provided and configured with the supplied options. The parameter audience may
66// not be empty.
67func NewTokenSource(ctx context.Context, audience string, opts ...ClientOption) (oauth2.TokenSource, error) {
68	if audience == "" {
69		return nil, fmt.Errorf("idtoken: must supply a non-empty audience")
70	}
71	var ds internal.DialSettings
72	for _, opt := range opts {
73		opt.Apply(&ds)
74	}
75	if err := ds.Validate(); err != nil {
76		return nil, err
77	}
78	if ds.TokenSource != nil {
79		return nil, fmt.Errorf("idtoken: option.WithTokenSource not supported")
80	}
81	if ds.ImpersonationConfig != nil {
82		return nil, fmt.Errorf("idtoken: option.WithImpersonatedCredentials not supported")
83	}
84	return newTokenSource(ctx, audience, &ds)
85}
86
87func newTokenSource(ctx context.Context, audience string, ds *internal.DialSettings) (oauth2.TokenSource, error) {
88	creds, err := internal.Creds(ctx, ds)
89	if err != nil {
90		return nil, err
91	}
92	if len(creds.JSON) > 0 {
93		return tokenSourceFromBytes(ctx, creds.JSON, audience, ds)
94	}
95	// If internal.Creds did not return a response with JSON fallback to the
96	// metadata service as the creds.TokenSource is not an ID token.
97	if metadata.OnGCE() {
98		return computeTokenSource(audience, ds)
99	}
100	return nil, fmt.Errorf("idtoken: couldn't find any credentials")
101}
102
103func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds *internal.DialSettings) (oauth2.TokenSource, error) {
104	if err := isServiceAccount(data); err != nil {
105		return nil, err
106	}
107	cfg, err := google.JWTConfigFromJSON(data, ds.GetScopes()...)
108	if err != nil {
109		return nil, err
110	}
111
112	customClaims := ds.CustomClaims
113	if customClaims == nil {
114		customClaims = make(map[string]interface{})
115	}
116	customClaims["target_audience"] = audience
117
118	cfg.PrivateClaims = customClaims
119	cfg.UseIDToken = true
120
121	ts := cfg.TokenSource(ctx)
122	tok, err := ts.Token()
123	if err != nil {
124		return nil, err
125	}
126	return oauth2.ReuseTokenSource(tok, ts), nil
127}
128
129func isServiceAccount(data []byte) error {
130	if len(data) == 0 {
131		return fmt.Errorf("idtoken: credential provided is 0 bytes")
132	}
133	var f struct {
134		Type string `json:"type"`
135	}
136	if err := json.Unmarshal(data, &f); err != nil {
137		return err
138	}
139	if f.Type != "service_account" {
140		return fmt.Errorf("idtoken: credential must be service_account, found %q", f.Type)
141	}
142	return nil
143}
144
145// WithCustomClaims optionally specifies custom private claims for an ID token.
146func WithCustomClaims(customClaims map[string]interface{}) ClientOption {
147	return withCustomClaims(customClaims)
148}
149
150type withCustomClaims map[string]interface{}
151
152func (w withCustomClaims) Apply(o *internal.DialSettings) {
153	o.CustomClaims = w
154}
155
156// WithCredentialsFile returns a ClientOption that authenticates
157// API calls with the given service account or refresh token JSON
158// credentials file.
159func WithCredentialsFile(filename string) ClientOption {
160	return option.WithCredentialsFile(filename)
161}
162
163// WithCredentialsJSON returns a ClientOption that authenticates
164// API calls with the given service account or refresh token JSON
165// credentials.
166func WithCredentialsJSON(p []byte) ClientOption {
167	return option.WithCredentialsJSON(p)
168}
169
170// WithHTTPClient returns a ClientOption that specifies the HTTP client to use
171// as the basis of communications. This option may only be used with services
172// that support HTTP as their communication transport. When used, the
173// WithHTTPClient option takes precedent over all other supplied options.
174func WithHTTPClient(client *http.Client) ClientOption {
175	return option.WithHTTPClient(client)
176}
177