Passed
Pull Request — master (#1180)
by Tolga
02:22
created

oidc.OIDCSlogAdapter.Error   A

Complexity

Conditions 1

Size

Total Lines 2
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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