Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

App service support for MI #537

Open
wants to merge 17 commits into
base: andyohart/managed-identity
Choose a base branch
from
128 changes: 123 additions & 5 deletions apps/internal/oauth/ops/accesstokens/accesstokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,18 +757,41 @@ func TestTokenResponseUnmarshal(t *testing.T) {
jwtDecoder: jwtDecoderFake,
},
{
desc: "Success",
desc: "Success: ExpiresOn as Unix timestamp number, expires_in present",
payload: fmt.Sprintf(`
{
"access_token": "secret",
"expires_on": %d,
"expires_in": "3600",
"ext_expires_in": 86399,
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`, time.Now().Add(time.Hour).Unix()),
want: TokenResponse{
AccessToken: "secret",
ExpiresOn: internalTime.DurationTime{T: time.Now().Add(time.Hour)}, // from expires_on
ExtExpiresOn: internalTime.DurationTime{T: time.Unix(86399, 0)},
GrantedScopes: Scopes{Slice: []string{"openid", "profile"}},
ClientInfo: ClientInfo{
UID: "uid",
UTID: "utid",
},
},
jwtDecoder: jwtDecoderFake,
},
{
desc: "Success: ExpiresOn as ISO 8601 string",
payload: `
{
"access_token": "secret",
"expires_in": 86399,
"expires_on": "2024-12-31T23:59:59Z",
"ext_expires_in": 86399,
"client_info": {"uid": "uid","utid": "utid"},
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`,
want: TokenResponse{
AccessToken: "secret",
ExpiresOn: internalTime.DurationTime{T: time.Unix(86399, 0)},
ExpiresOn: internalTime.DurationTime{T: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)},
ExtExpiresOn: internalTime.DurationTime{T: time.Unix(86399, 0)},
GrantedScopes: Scopes{Slice: []string{"openid", "profile"}},
ClientInfo: ClientInfo{
Expand All @@ -778,6 +801,99 @@ func TestTokenResponseUnmarshal(t *testing.T) {
},
jwtDecoder: jwtDecoderFake,
},
{
desc: "Success: ExpiresOn as MM/dd/yyyy HH:mm:ss string",
payload: `
{
"access_token": "secret",
"expires_on": "12/31/2024 23:59:59",
"ext_expires_in": 86399,
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`,
want: TokenResponse{
AccessToken: "secret",
ExpiresOn: internalTime.DurationTime{T: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)},
ExtExpiresOn: internalTime.DurationTime{T: time.Unix(86399, 0)},
GrantedScopes: Scopes{Slice: []string{"openid", "profile"}},
ClientInfo: ClientInfo{
UID: "uid",
UTID: "utid",
},
},
jwtDecoder: jwtDecoderFake,
},
{
desc: "Success: ExpiresOn as yyyy-MM-dd HH:mm:ss string",
payload: `
{
"access_token": "secret",
"expires_on": "2024-12-31 23:59:59",
"ext_expires_in": 86399,
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`,
want: TokenResponse{
AccessToken: "secret",
ExpiresOn: internalTime.DurationTime{T: time.Date(2024, 12, 31, 23, 59, 59, 0, time.UTC)},
ExtExpiresOn: internalTime.DurationTime{T: time.Unix(86399, 0)},
GrantedScopes: Scopes{Slice: []string{"openid", "profile"}},
ClientInfo: ClientInfo{
UID: "uid",
UTID: "utid",
},
},
jwtDecoder: jwtDecoderFake,
},
{
desc: "Success: ExpiresOn empty, fallback to ExpiresIn",
payload: `
{
"access_token": "secret",
"expires_on": "",
"expires_in": 3600,
"ext_expires_in": 86399,
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`,
want: TokenResponse{
AccessToken: "secret",
ExpiresOn: internalTime.DurationTime{T: time.Now().Add(time.Hour)},
ExtExpiresOn: internalTime.DurationTime{T: time.Unix(86399, 0)},
GrantedScopes: Scopes{Slice: []string{"openid", "profile"}},
ClientInfo: ClientInfo{
UID: "uid",
UTID: "utid",
},
},
jwtDecoder: jwtDecoderFake,
},
{
desc: "Error: Missing both expires_on and expires_in",
payload: `
{
"access_token": "secret",
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`,
want: TokenResponse{},
err: true,
jwtDecoder: jwtDecoderFake,
},
{
desc: "Error: Invalid ExpiresOn format",
payload: `
{
"access_token": "secret",
"expires_on": "invalid-date-format",
"ext_expires_in": 86399,
"client_info": {"uid": "uid","utid": "utid"},
"scope": "openid profile"
}`,
want: TokenResponse{},
err: true,
jwtDecoder: jwtDecoderFake,
},
}

for _, test := range tests {
Expand All @@ -795,7 +911,9 @@ func TestTokenResponseUnmarshal(t *testing.T) {
case err != nil:
continue
}

if got.ExpiresOn.T.Unix() != test.want.ExpiresOn.T.Unix() {
t.Errorf("TestCreateTokenResponse: got %v, want %v", got.ExpiresOn.T.Unix(), test.want.ExpiresOn.T.Unix())
}
// Note: IncludeUnexported prevents minor differences in time.Time due to internal fields.
if diff := (&pretty.Config{IncludeUnexported: false}).Compare(test.want, got); diff != "" {
t.Errorf("TestCreateTokenResponse: -want/+got:\n%s", diff)
Expand Down
71 changes: 69 additions & 2 deletions apps/internal/oauth/ops/accesstokens/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -173,14 +174,80 @@ type TokenResponse struct {
FamilyID string `json:"foci"`
IDToken IDToken `json:"id_token"`
ClientInfo ClientInfo `json:"client_info"`
ExpiresOn internalTime.DurationTime `json:"expires_in"`
ExpiresOn internalTime.DurationTime `json:"-"`
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DurationTime exists to calculate ExpiresOn from expires_in. That doesn't help now that you want to consider a possible value of expires_on as well, and you're disabling unmarshaling for this field anyway. So, why not make the type here simply time.Time?

ExtExpiresOn internalTime.DurationTime `json:"ext_expires_in"`
GrantedScopes Scopes `json:"scope"`
DeclinedScopes []string // This is derived

AdditionalFields map[string]interface{}
scopesComputed bool
}

func (tr *TokenResponse) UnmarshalJSON(data []byte) error {
type Alias TokenResponse
aux := &struct {
ExpiresIn json.Number `json:"expires_in,omitempty"`
ExpiresOn interface{} `json:"expires_on,omitempty"`
*Alias
}{
Alias: (*Alias)(tr),
}

// Unmarshal the JSON data into the aux struct
if err := json.Unmarshal(data, &aux); err != nil {
return err
}

parseDuration := func(num json.Number) (int64, error) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How is this better than DurationTime.UnmarshalJSON i.e., what do you gain by unmarshaling expires_in to json.Number instead of DurationTime?

if num == "" {
return 0, nil
}
return num.Int64()
}

// Function to parse different date formats
parseExpiresOn := func(expiresOn string) (time.Time, error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls add a comment pointing at AzureAD/microsoft-authentication-library-for-dotnet#4963 to explain when this can occur.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm overlooking it, but that issue doesn't mention the platform returning the unsupported format

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CX who reported this didn't make it very clear, it seems to be container + App Service.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be good to know what the linked PR added support for so that support is easier to maintain. Setting that aside, the linked PR added ISO 8601 parsing. Why does this PR have two additional formats?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We asked, the CX didn't answer. Ultimately, Azure SDK for .NET did not have this problem so it was clearly a regression.

Not objecting to supporting only ISO 8601. The .NET code uses a general-purpose DateTime parser btw https://github.com/AzureAD/microsoft-authentication-library-for-dotnet/pull/4964/files#diff-a7d2338916806e48fb637fd9caad6095fec8537a96d157bc2ea47ab64903fef9R93

I assume Azure.SDK for .NET did the same.

var formats = []string{
"01/02/2006 15:04:05", // MM/dd/yyyy HH:mm:ss
"2006-01-02 15:04:05", // yyyy-MM-dd HH:mm:ss
time.RFC3339Nano, // ISO 8601 (with nanosecond precision)
}

scopesComputed bool
for _, format := range formats {
if t, err := time.Parse(format, expiresOn); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("invalid ExpiresOn format: %s", expiresOn)
}

if expiresOnStr, ok := aux.ExpiresOn.(string); ok {
if ts, err := strconv.ParseInt(expiresOnStr, 10, 64); err == nil {
tr.ExpiresOn = internalTime.DurationTime{T: time.Unix(ts, 0)}
return nil
}
if expiresOnStr != "" {
if t, err := parseExpiresOn(expiresOnStr); err != nil {
return err
} else {
tr.ExpiresOn = internalTime.DurationTime{T: t}
return nil
}
}
}

// Check if ExpiresOn is a number (Unix timestamp or ISO 8601)
if expiresOnNum, ok := aux.ExpiresOn.(float64); ok {
tr.ExpiresOn = internalTime.DurationTime{T: time.Unix(int64(expiresOnNum), 0)}
return nil
}
if duration, err := parseDuration(aux.ExpiresIn); err != nil {
return err
} else if duration > 0 {
tr.ExpiresOn = internalTime.DurationTime{T: time.Now().Add(time.Duration(duration) * time.Second)}
return nil
}
return errors.New("expires_in and expires_on are both missing or invalid")
}

// ComputeScope computes the final scopes based on what was granted by the server and
Expand Down
Loading
Loading