Passed
Push — master ( 29ff15...04cfac )
by Tolga
01:32 queued 19s
created

oidc.*Authn.getKeyWithRetry   A

Complexity

Conditions 5

Size

Total Lines 35
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 18
nop 2
dl 0
loc 35
rs 9.0333
c 0
b 0
f 0
1
package oidc
2
3
import (
4
	"context"
5
	"encoding/json"
6
	"errors"
7
	"fmt"
8
	"io"
9
	"log/slog"
10
	"net/http"
11
	"strings"
12
	"time"
13
14
	"github.com/lestrrat-go/backoff/v2"
15
16
	"github.com/golang-jwt/jwt/v4"
17
	grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/auth"
18
	"github.com/hashicorp/go-retryablehttp"
19
	"github.com/lestrrat-go/jwx/jwk"
20
21
	"github.com/Permify/permify/internal/config"
22
	base "github.com/Permify/permify/pkg/pb/base/v1"
23
)
24
25
// Authenticator - Interface for oidc authenticator
26
type Authenticator interface {
27
	Authenticate(ctx context.Context) error
28
}
29
30
type Authn struct {
31
	// URL of the issuer. This is typically the base URL of the identity provider.
32
	IssuerURL string
33
	// Audience for which the token is intended. It must match the audience in the JWT.
34
	Audience string
35
	// URL of the JSON Web Key Set (JWKS). This URL hosts public keys used to verify JWT signatures.
36
	JwksURI string
37
	// Pointer to an AutoRefresh object from the JWKS library. It helps in automatically refreshing the JWKS at predefined intervals.
38
	jwksSet *jwk.AutoRefresh
39
	// List of valid signing methods. Specifies which signing algorithms are considered valid for the JWTs.
40
	validMethods []string
41
	// Pointer to a JWT parser object. This is used to parse and validate the JWT tokens.
42
	jwtParser *jwt.Parser
43
	// Duration of the interval between retries for the backoff policy.
44
	backoffInterval time.Duration
45
	// Maximum number of retries for the backoff policy.
46
	backoffMaxRetries int
47
}
48
49
// NewOidcAuthn initializes a new instance of the Authn struct with OpenID Connect (OIDC) configuration.
50
// It takes in a context for managing cancellation and a configuration object. It returns a pointer to an Authn instance or an error.
51
func NewOidcAuthn(ctx context.Context, conf config.Oidc) (*Authn, error) {
52
	// Create a new HTTP client with retry capabilities. This client is used for making HTTP requests, particularly for fetching OIDC configuration.
53
	client := retryablehttp.NewClient()
54
	client.Logger = SlogAdapter{Logger: slog.Default()}
55
56
	// Fetch the OIDC configuration from the issuer's well-known configuration endpoint.
57
	oidcConf, err := fetchOIDCConfiguration(client.StandardClient(), strings.TrimSuffix(conf.Issuer, "/")+"/.well-known/openid-configuration")
58
	if err != nil {
59
		// If there is an error fetching the OIDC configuration, return nil and the error.
60
		return nil, fmt.Errorf("failed to fetch OIDC configuration: %w", err)
0 ignored issues
show
introduced by
unrecognized printf verb 'w'
Loading history...
61
	}
62
63
	// Set up automatic refresh of the JSON Web Key Set (JWKS) to ensure the public keys are always up-to-date.
64
	ar := jwk.NewAutoRefresh(ctx)                                                                                              // Create a new AutoRefresh instance for the JWKS.
65
	ar.Configure(oidcConf.JWKsURI, jwk.WithHTTPClient(client.StandardClient()), jwk.WithRefreshInterval(conf.RefreshInterval)) // Configure the auto-refresh parameters.
66
67
	// Initialize the Authn struct with the OIDC configuration details and other relevant settings.
68
	oidc := &Authn{
69
		IssuerURL:         conf.Issuer,                                            // URL of the token issuer.
70
		Audience:          conf.Audience,                                          // Intended audience of the token.
71
		JwksURI:           oidcConf.JWKsURI,                                       // URL of the JWKS endpoint.
72
		validMethods:      conf.ValidMethods,                                      // List of acceptable signing methods for the tokens.
73
		jwtParser:         jwt.NewParser(jwt.WithValidMethods(conf.ValidMethods)), // JWT parser configured with the valid signing methods.
74
		jwksSet:           ar,                                                     // Set the JWKS auto-refresh instance.
75
		backoffInterval:   conf.BackoffInterval,
76
		backoffMaxRetries: conf.BackoffMaxRetries,
77
	}
78
79
	// Attempt to fetch the JWKS immediately to ensure it's available and valid.
80
	_, err = oidc.jwksSet.Fetch(ctx, oidc.JwksURI)
81
	if err != nil {
82
		// If there is an error fetching the JWKS, return nil and the error.
83
		return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
0 ignored issues
show
introduced by
unrecognized printf verb 'w'
Loading history...
84
	}
85
86
	// Return the initialized OIDC authentication object and no error.
87
	return oidc, nil
88
}
89
90
// Authenticate validates the JWT token found in the authorization header of the incoming request.
91
// It uses the OIDC configuration to validate the token against the issuer's public keys.
92
func (oidc *Authn) Authenticate(requestContext context.Context) error {
93
	// Extract the authorization header from the metadata of the incoming gRPC request.
94
	authHeader, err := grpcauth.AuthFromMD(requestContext, "Bearer")
95
	if err != nil {
96
		// Log the error if the authorization header is missing or does not start with "Bearer"
97
		slog.Error("failed to extract authorization header from gRPC request", "error", err)
98
		// Return an error indicating the missing or incorrect bearer token
99
		return errors.New(base.ErrorCode_ERROR_CODE_MISSING_BEARER_TOKEN.String())
100
	}
101
102
	// Log the successful extraction of the authorization header for debugging purposes.
103
	// Remember, do not log the actual content of authHeader as it might contain sensitive information.
104
	slog.Debug("Successfully extracted authorization header from gRPC request")
105
106
	// Parse and validate the JWT token extracted from the authorization header.
107
	parsedToken, err := oidc.jwtParser.Parse(authHeader, func(token *jwt.Token) (interface{}, error) {
108
		slog.Info("starting JWT parsing and validation.")
109
110
		// Retrieve the key ID from the JWT header and find the corresponding key in the JWKS.
111
		if keyID, ok := token.Header["kid"].(string); ok {
112
			return oidc.getKeyWithRetry(keyID, requestContext)
113
		}
114
		slog.Error("jwt does not contain a key ID")
115
		// If the JWT does not contain a key ID, return an error.
116
		return nil, errors.New("kid must be specified in the token header")
117
	})
118
	if err != nil {
119
		// Log that the token parsing or validation failed
120
		slog.Error("token parsing or validation failed", "error", err)
121
		// If token parsing or validation fails, return an error indicating the token is invalid.
122
		return errors.New(base.ErrorCode_ERROR_CODE_INVALID_BEARER_TOKEN.String())
123
	}
124
125
	// Ensure the token is valid.
126
	if !parsedToken.Valid {
127
		// Log that the parsed token was not valid
128
		slog.Warn("parsed token is invalid")
129
		// Return an error indicating the invalid token
130
		return errors.New(base.ErrorCode_ERROR_CODE_INVALID_BEARER_TOKEN.String())
131
	}
132
133
	// Extract the claims from the token.
134
	claims, ok := parsedToken.Claims.(jwt.MapClaims)
135
	if !ok {
136
		// Log that the claims were in an incorrect format
137
		slog.Warn("token claims are in an incorrect format")
138
		// Return an error
139
		return errors.New(base.ErrorCode_ERROR_CODE_INVALID_CLAIMS.String())
140
	}
141
142
	slog.Debug("extracted token claims", "claims", claims)
143
144
	// Verify the issuer of the token matches the expected issuer.
145
	if ok := claims.VerifyIssuer(oidc.IssuerURL, true); !ok {
146
		// Log that the issuer did not match the expected issuer
147
		slog.Warn("token issuer is invalid", "expected", oidc.IssuerURL, "actual", claims["iss"])
148
		// Return an error
149
		return errors.New(base.ErrorCode_ERROR_CODE_INVALID_ISSUER.String())
150
	}
151
152
	// Verify the audience of the token matches the expected audience.
153
	if ok := claims.VerifyAudience(oidc.Audience, true); !ok {
154
		// Log that the audience did not match the expected audience
155
		slog.Warn("token audience is invalid", "expected", oidc.Audience, "actual", claims["aud"])
156
		// Return an error
157
		return errors.New(base.ErrorCode_ERROR_CODE_INVALID_AUDIENCE.String())
158
	}
159
160
	// Log that the token's issuer and audience were successfully validated
161
	slog.Info("token validation succeeded")
162
163
	// If all validations pass, return nil indicating the token is valid.
164
	return nil
165
}
166
167
// getKeyWithRetry attempts to retrieve the key for the given keyID with retries using a constant backoff strategy.
168
func (oidc *Authn) getKeyWithRetry(keyID string, ctx context.Context) (interface{}, error) {
169
	// Attempt to fetch the key.
170
	rawKey, err := oidc.fetchKey(keyID, ctx)
171
	if err == nil {
172
		// If the key is fetched successfully, return it.
173
		return rawKey, nil
174
	}
175
176
	// Configure the backoff policy with a constant strategy.
177
	backoffPolicy := backoff.Constant(
178
		backoff.WithInterval(oidc.backoffInterval),
179
		backoff.WithJitterFactor(0.5), // Jitter factor to randomize intervals
180
		backoff.WithMaxRetries(oidc.backoffMaxRetries),
181
	)
182
183
	// Start the backoff policy.
184
	b := backoffPolicy.Start(ctx)
185
	for backoff.Continue(b) {
186
		// Log a warning and refresh JWKS if there was an error.
187
		slog.Warn("retrying to fetch JWKS due to error: ", err)
188
		// Refresh the JWKS before the next retry.
189
		if _, refreshErr := oidc.jwksSet.Refresh(ctx, oidc.JwksURI); refreshErr != nil {
190
			slog.Error("failed to refresh JWKS", "error", refreshErr)
191
			return nil, refreshErr
192
		}
193
		// Attempt to fetch the key.
194
		rawKey, err := oidc.fetchKey(keyID, ctx)
195
		if err == nil {
196
			// If the key is fetched successfully, return it.
197
			return rawKey, nil
198
		}
199
	}
200
201
	// If all retries are exhausted, return an error.
202
	return nil, errors.New("key ID not found in JWKS after retries")
203
}
204
205
// fetchKey attempts to fetch the JWKS and retrieve the key for the given keyID.
206
func (oidc *Authn) fetchKey(keyID string, ctx context.Context) (interface{}, error) {
207
	// Log the attempt to find the key.
208
	slog.Debug("attempting to find key in JWKS", "kid", keyID)
209
210
	// Fetch the JWKS from the configured URI.
211
	jwks, err := oidc.jwksSet.Fetch(ctx, oidc.JwksURI)
212
	if err != nil {
213
		// Log an error and return if fetching fails.
214
		slog.Error("failed to fetch JWKS", "uri", oidc.JwksURI, "error", err)
215
		return nil, fmt.Errorf("failed to fetch JWKS: %w", err)
0 ignored issues
show
introduced by
unrecognized printf verb 'w'
Loading history...
216
	}
217
218
	// Log a successful fetch of the JWKS.
219
	slog.Info("successfully fetched JWKS")
220
221
	// Attempt to find the key in the fetched JWKS using the key ID.
222
	if key, found := jwks.LookupKeyID(keyID); found {
223
		var k interface{}
224
		// Convert the key to a usable format.
225
		if err := key.Raw(&k); err != nil {
226
			// Log an error and return if conversion fails.
227
			slog.Error("failed to get raw public key", "kid", keyID, "error", err)
228
			return nil, fmt.Errorf("failed to get raw public key: %w", err)
0 ignored issues
show
introduced by
unrecognized printf verb 'w'
Loading history...
229
		}
230
		// Log a successful retrieval of the raw public key.
231
		slog.Debug("successfully obtained raw public key", "key", k)
232
		return k, nil // Return the public key for JWT signature verification.
233
	}
234
	// Log an error if the key ID is not found in the JWKS.
235
	slog.Error("key ID not found in JWKS", "kid", keyID)
236
	return nil, fmt.Errorf("kid %s not found", keyID)
237
}
238
239
// Config holds OpenID Connect (OIDC) configuration details.
240
type Config struct {
241
	// Issuer is the OIDC provider's unique identifier URL.
242
	Issuer string `json:"issuer"`
243
	// JWKsURI is the URL to the JSON Web Key Set (JWKS) provided by the OIDC issuer.
244
	JWKsURI string `json:"jwks_uri"`
245
}
246
247
// fetchOIDCConfiguration sends an HTTP request to the given URL to fetch the OpenID Connect (OIDC) configuration.
248
// It requires an HTTP client and the URL from which to fetch the configuration.
249
func fetchOIDCConfiguration(client *http.Client, url string) (*Config, error) {
250
	// Send an HTTP GET request to the provided URL to fetch the OIDC configuration.
251
	// This typically points to the well-known configuration endpoint of the OIDC provider.
252
	body, err := doHTTPRequest(client, url)
253
	if err != nil {
254
		// If there is an error in fetching the configuration (network error, bad response, etc.), return nil and the error.
255
		return nil, err
256
	}
257
258
	// Parse the JSON response body into an OIDC Config struct.
259
	// This involves unmarshalling the JSON into a struct that matches the expected fields of the OIDC configuration.
260
	oidcConfig, err := parseOIDCConfiguration(body)
261
	if err != nil {
262
		// If there is an error in parsing the JSON response (missing fields, incorrect format, etc.), return nil and the error.
263
		return nil, err
264
	}
265
266
	// Return the parsed OIDC configuration and nil as the error (indicating success).
267
	return oidcConfig, nil
268
}
269
270
// doHTTPRequest makes an HTTP GET request to the specified URL and returns the response body.
271
func doHTTPRequest(client *http.Client, url string) ([]byte, error) {
272
	// Log the attempt to create a new HTTP GET request
273
	slog.Debug("creating new HTTP GET request", "url", url)
274
275
	// Create a new HTTP GET request.
276
	req, err := http.NewRequest("GET", url, nil)
277
	if err != nil {
278
		// Log the error if creating the HTTP request fails
279
		slog.Error("failed to create HTTP request", "url", url, "error", err)
280
		return nil, fmt.Errorf("failed to create HTTP request for OIDC configuration: %s", err)
281
	}
282
283
	// Log the execution of the HTTP request
284
	slog.Debug("executing HTTP request", "url", url)
285
286
	// Send the request using the configured HTTP client.
287
	res, err := client.Do(req)
288
	if err != nil {
289
		// Log the error if executing the HTTP request fails
290
		slog.Error("failed to execute HTTP request", "url", url, "error", err)
291
		return nil, fmt.Errorf("failed to execute HTTP request for OIDC configuration: %s", err)
292
	}
293
294
	// Log the HTTP status code of the response
295
	slog.Debug("received HTTP response", "status_code", res.StatusCode, "url", url)
296
297
	// Ensure the response body is closed after reading.
298
	defer res.Body.Close()
299
300
	// Check if the HTTP status code indicates success.
301
	if res.StatusCode != http.StatusOK {
302
		// Log the unexpected status code
303
		slog.Warn("received unexpected status code", "status_code", res.StatusCode, "url", url)
304
		return nil, fmt.Errorf("received unexpected status code (%d) while fetching OIDC configuration", res.StatusCode)
305
	}
306
307
	// Log the attempt to read the response body
308
	slog.Debug("reading response body", "url", url)
309
310
	// Read the response body.
311
	body, err := io.ReadAll(res.Body)
312
	if err != nil {
313
		// Log the error if reading the response body fails
314
		slog.Error("failed to read response body", "url", url, "error", err)
315
		return nil, fmt.Errorf("failed to read response body from OIDC configuration request: %s", err)
316
	}
317
318
	// Log the successful retrieval of the response body
319
	slog.Debug("successfully read response body", "url", url, "response_length", len(body))
320
321
	// Return the response body.
322
	return body, nil
323
}
324
325
// parseOIDCConfiguration decodes the OIDC configuration from the given JSON body.
326
func parseOIDCConfiguration(body []byte) (*Config, error) {
327
	var oidcConfig Config
328
	// Attempt to unmarshal the JSON body into the oidcConfig struct.
329
	if err := json.Unmarshal(body, &oidcConfig); err != nil {
330
		// Log the error if unmarshalling
331
		slog.Error("failed to unmarshal OIDC configuration", "error", err)
332
		return nil, fmt.Errorf("failed to decode OIDC configuration: %s", err)
333
	}
334
	// Log the successful decoding of OIDC configuration
335
	slog.Debug("successfully decoded OIDC configuration")
336
337
	if oidcConfig.Issuer == "" {
338
		// Log missing issuer value
339
		slog.Warn("missing issuer value in OIDC configuration")
340
		return nil, errors.New("issuer value is required but missing in OIDC configuration")
341
	}
342
343
	if oidcConfig.JWKsURI == "" {
344
		// Log missing JWKsURI value
345
		slog.Warn("missing JWKsURI value in OIDC configuration")
346
		return nil, errors.New("JWKsURI value is required but missing in OIDC configuration")
347
	}
348
349
	// Log the successful parsing of the OIDC configuration
350
	slog.Info("successfully parsed OIDC configuration", "issuer", oidcConfig.Issuer, "jwks_uri", oidcConfig.JWKsURI)
351
352
	// Return the successfully parsed configuration.
353
	return &oidcConfig, nil
354
}
355