Compare commits

...

5 Commits

Author SHA1 Message Date
aviator-app[bot]
4c676683ac
Merge branch 'main' into 401_investigation 2024-02-17 23:28:12 +00:00
Abhishek Pandey
ca12138f13 Exclude requestor 401 logs 2024-02-16 18:56:02 -08:00
Abhishek Pandey
1afd20b67f Fix comment 2024-02-16 18:24:14 -08:00
Abhishek Pandey
813fc9f5c5 Fix lint 2024-02-16 18:21:43 -08:00
Abhishek Pandey
7a64ac0a80 Log token lifetimes on 401 errors 2024-02-16 18:14:01 -08:00
6 changed files with 328 additions and 3 deletions

View File

@ -1,10 +1,13 @@
package jwt
import (
"context"
"time"
"github.com/alcionai/clues"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/alcionai/corso/src/pkg/logger"
)
// IsJWTExpired checks if the JWT token is past expiry by analyzing the
@ -37,3 +40,51 @@ func IsJWTExpired(
return expired, nil
}
// GetJWTLifetime returns the issued at(iat) and expiration time(exp) claims
// present in the JWT token. These are optional claims and may not be present
// in the token. Absence is not reported as an error.
//
// An error is returned if the supplied token is malformed. Times are returned
// in UTC to have parity with graph responses.
func GetJWTLifetime(
ctx context.Context,
rawToken string,
) (time.Time, time.Time, error) {
var (
issuedAt time.Time
expiresAt time.Time
)
p := jwt.NewParser()
token, _, err := p.ParseUnverified(rawToken, &jwt.RegisteredClaims{})
if err != nil {
logger.CtxErr(ctx, err).Debug("parsing jwt token")
return time.Time{}, time.Time{}, clues.Wrap(err, "invalid jwt")
}
exp, err := token.Claims.GetExpirationTime()
if err != nil {
logger.CtxErr(ctx, err).Debug("extracting exp claim")
return time.Time{}, time.Time{}, clues.Wrap(err, "getting token expiry time")
}
iat, err := token.Claims.GetIssuedAt()
if err != nil {
logger.CtxErr(ctx, err).Debug("extracting iat claim")
return time.Time{}, time.Time{}, clues.Wrap(err, "getting token issued at time")
}
// Absence of iat or exp claims is not reported as an error by jwt library as these
// are optional as per spec.
if iat != nil {
issuedAt = iat.UTC()
}
if exp != nil {
expiresAt = exp.UTC()
}
return issuedAt, expiresAt, nil
}

View File

@ -113,3 +113,134 @@ func (suite *JWTUnitSuite) TestIsJWTExpired() {
})
}
}
func (suite *JWTUnitSuite) TestGetJWTLifetime() {
// Set of time values to be used in the tests.
// Truncate to seconds for comparisons since jwt tokens have second
// level precision.
idToTime := map[string]time.Time{
"T0": time.Now().UTC().Add(-time.Hour).Truncate(time.Second),
"T1": time.Now().UTC().Truncate(time.Second),
"T2": time.Now().UTC().Add(time.Hour).Truncate(time.Second),
}
table := []struct {
name string
getToken func() (string, error)
expectFunc func(t *testing.T, iat time.Time, exp time.Time)
expectErr assert.ErrorAssertionFunc
}{
{
name: "alive token",
getToken: func() (string, error) {
return createJWTToken(
jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(idToTime["T0"]),
ExpiresAt: jwt.NewNumericDate(idToTime["T1"]),
})
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Equal(t, idToTime["T0"], iat)
assert.Equal(t, idToTime["T1"], exp)
},
expectErr: assert.NoError,
},
// Test with a token which is not generated using the go-jwt lib.
// This is a long lived token which is valid for 100 years.
{
name: "alive raw token with iat and exp claims",
getToken: func() (string, error) {
return rawToken, nil
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Less(t, iat, time.Now(), "iat should be in the past")
assert.Greater(t, exp, time.Now(), "exp should be in the future")
},
expectErr: assert.NoError,
},
// Regardless of whether the token is expired or not, we should be able to
// extract the iat and exp claims from it without error.
{
name: "expired token",
getToken: func() (string, error) {
return createJWTToken(
jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(idToTime["T1"]),
ExpiresAt: jwt.NewNumericDate(idToTime["T0"]),
})
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Equal(t, idToTime["T1"], iat)
assert.Equal(t, idToTime["T0"], exp)
},
expectErr: assert.NoError,
},
{
name: "missing iat claim",
getToken: func() (string, error) {
return createJWTToken(
jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(idToTime["T2"]),
})
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Equal(t, time.Time{}, iat)
assert.Equal(t, idToTime["T2"], exp)
},
expectErr: assert.NoError,
},
{
name: "missing exp claim",
getToken: func() (string, error) {
return createJWTToken(
jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(idToTime["T0"]),
})
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Equal(t, idToTime["T0"], iat)
assert.Equal(t, time.Time{}, exp)
},
expectErr: assert.NoError,
},
{
name: "both claims missing",
getToken: func() (string, error) {
return createJWTToken(jwt.RegisteredClaims{})
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Equal(t, time.Time{}, iat)
assert.Equal(t, time.Time{}, exp)
},
expectErr: assert.NoError,
},
{
name: "malformed token",
getToken: func() (string, error) {
return "header.claims.signature", nil
},
expectFunc: func(t *testing.T, iat time.Time, exp time.Time) {
assert.Equal(t, time.Time{}, iat)
assert.Equal(t, time.Time{}, exp)
},
expectErr: assert.Error,
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
token, err := test.getToken()
require.NoError(t, err)
iat, exp, err := GetJWTLifetime(ctx, token)
test.expectErr(t, err)
test.expectFunc(t, iat, exp)
})
}
}

View File

@ -146,7 +146,7 @@ func (hw httpWrapper) Request(
resp, err := hw.client.Do(req)
if err == nil {
logResp(ictx, resp)
logResp(ictx, resp, req)
return resp, nil
}

View File

@ -5,9 +5,12 @@ import (
"net/http"
"net/http/httputil"
"os"
"strings"
"time"
"github.com/alcionai/clues"
"github.com/alcionai/corso/src/internal/common/jwt"
"github.com/alcionai/corso/src/internal/common/pii"
"github.com/alcionai/corso/src/pkg/logger"
)
@ -28,7 +31,7 @@ func shouldLogRespBody(resp *http.Response) bool {
resp.StatusCode > 399
}
func logResp(ctx context.Context, resp *http.Response) {
func logResp(ctx context.Context, resp *http.Response, req *http.Request) {
var (
log = logger.Ctx(ctx)
respClass = resp.StatusCode / 100
@ -45,6 +48,25 @@ func logResp(ctx context.Context, resp *http.Response) {
return
}
// Log bearer token iat and exp claims if we hit 401s. This is purely for
// debugging purposes and will be removed in the future.
if resp.StatusCode == http.StatusUnauthorized {
errs := []any{"graph api error: " + resp.Status}
// As per MSFT docs, the token may have a special format and may not always
// validate as a JWT. Hence log token lifetime in a best effort manner only.
iat, exp, err := getTokenLifetime(ctx, req)
if err != nil {
errs = append(errs, " getting token lifetime: ", err)
}
log.With("response", getRespDump(ctx, resp, logBody)).
With("token issued at", iat, "token expires at", exp).
Error(errs...)
return
}
// Log api calls according to api debugging configurations.
switch respClass {
case 2:
@ -91,3 +113,32 @@ func getReqCtx(req *http.Request) context.Context {
"url", logURL,
"request_content_len", req.ContentLength)
}
// GetTokenLifetime extracts the JWT token embedded in the request and returns
// the token's issue and expiration times. The token is expected to be in the
// "Authorization" header, with a "Bearer " prefix. If the token is not present
// or is malformed, an error is returned.
func getTokenLifetime(
ctx context.Context,
req *http.Request,
) (time.Time, time.Time, error) {
if req == nil {
return time.Time{}, time.Time{}, clues.New("nil request")
}
// Don't throw an error if auth header is absent. This is to prevent
// unnecessary noise in the logs for requests served by the http requestor
// client. These requests may be preauthenticated and may not carry auth headers.
rawToken := req.Header.Get("Authorization")
if len(rawToken) == 0 {
return time.Time{}, time.Time{}, nil
}
// Strip the "Bearer " prefix from the token. This prefix is guaranteed to be
// present as per msft docs. But even if it's not, the jwt lib will handle
// malformed tokens gracefully and return an error.
rawToken = strings.TrimPrefix(rawToken, "Bearer ")
iat, exp, err := jwt.GetJWTLifetime(ctx, rawToken)
return iat, exp, clues.Stack(err).OrNil()
}

View File

@ -130,7 +130,7 @@ func (mw *LoggingMiddleware) Intercept(
"resp_status_code", resp.StatusCode,
"resp_content_len", resp.ContentLength)
logResp(ctx, resp)
logResp(ctx, resp, req)
return resp, err
}

View File

@ -505,3 +505,95 @@ func (suite *MiddlewareUnitSuite) TestLimiterConsumption() {
})
}
}
const (
// Raw test token valid for 100 years.
rawToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9." +
"eyJuYmYiOiIxNjkxODE5NTc5IiwiZXhwIjoiMzk0NTUyOTE3OSIsImVuZHBvaW50dXJsTGVuZ3RoIjoiMTYw" +
"IiwiaXNsb29wYmFjayI6IlRydWUiLCJ2ZXIiOiJoYXNoZWRwcm9vZnRva2VuIiwicm9sZXMiOiJhbGxmaWxl" +
"cy53cml0ZSBhbGxzaXRlcy5mdWxsY29udHJvbCBhbGxwcm9maWxlcy5yZWFkIiwidHQiOiIxIiwiYWxnIjoi" +
"SFMyNTYifQ" +
".signature"
)
// Tests getTokenLifetime
func (suite *MiddlewareUnitSuite) TestGetTokenLifetime() {
table := []struct {
name string
request *http.Request
expectErr assert.ErrorAssertionFunc
}{
{
name: "nil request",
request: nil,
expectErr: assert.Error,
},
// Test that we don't throw an error if auth header is absent.
// This is to prevent unnecessary noise in logs for requestor http client.
{
name: "no authorization header",
request: &http.Request{
Header: http.Header{},
},
expectErr: assert.NoError,
},
{
name: "well formed auth header with token",
request: &http.Request{
Header: http.Header{
"Authorization": []string{"Bearer " + rawToken},
},
},
expectErr: assert.NoError,
},
{
name: "Missing Bearer prefix but valid token",
request: &http.Request{
Header: http.Header{
"Authorization": []string{rawToken},
},
},
expectErr: assert.NoError,
},
{
name: "invalid token",
request: &http.Request{
Header: http.Header{
"Authorization": []string{"Bearer " + "invalid"},
},
},
expectErr: assert.Error,
},
{
name: "valid prefix but empty token",
request: &http.Request{
Header: http.Header{
"Authorization": []string{"Bearer "},
},
},
expectErr: assert.Error,
},
{
name: "Invalid prefix but valid token",
request: &http.Request{
Header: http.Header{
"Authorization": []string{"Bearer" + rawToken},
},
},
expectErr: assert.Error,
},
}
for _, test := range table {
suite.Run(test.name, func() {
t := suite.T()
ctx, flush := tester.NewContext(t)
defer flush()
// iat, exp specific tests are in jwt package.
_, _, err := getTokenLifetime(ctx, test.request)
test.expectErr(t, err, clues.ToCore(err))
})
}
}