Skip to content

Commit 1faa95a

Browse files
kent-hydraclaude
andcommitted
refactor: align Azure userinfo handling with Microsoft's OIDC claims
Verified against Microsoft's docs that graph.microsoft.com/oidc/userinfo returns only sub/name/.../email — it does NOT return preferred_username or email_verified. Accordingly: - Drop the dead preferred_username fallback and email_verified conditional (neither claim is ever present in the userinfo response). - Collapse azureUserInfoResponse into a single azureUserInfo struct. - Set EmailVerified=true explicitly, with a comment: the email is sourced from the Azure AD directory and Hatchet's authz middleware blocks unverified users, matching the Google/GitHub providers. - Check the userinfo HTTP status so a Graph failure surfaces a clear error instead of masquerading as "azure account must have an email". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d61fab9 commit 1faa95a

1 file changed

Lines changed: 21 additions & 41 deletions

File tree

api/v1/server/handlers/users/azure_oauth_callback.go

Lines changed: 21 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,16 @@ func (u *UserService) upsertAzureUserFromToken(ctx context.Context, config *serv
109109
ExpiresAt: &expiresAt,
110110
}
111111

112+
// Azure AD's OIDC userinfo endpoint does not return an email_verified claim, and
113+
// Hatchet's authz middleware blocks users whose email is unverified. The email is
114+
// sourced from the Azure AD directory (not self-asserted), so we treat it as verified,
115+
// consistent with the Google/GitHub providers whose OAuth users are always verified.
112116
user, err := u.config.V1.User().GetUserByEmail(ctx, aInfo.Email)
113117

114118
switch err {
115119
case nil:
116120
user, err = u.config.V1.User().UpdateUser(ctx, user.ID, &v1.UpdateUserOpts{
117-
EmailVerified: v1.BoolPtr(aInfo.EmailVerified),
121+
EmailVerified: v1.BoolPtr(true),
118122
Name: v1.StringPtr(aInfo.Name),
119123
OAuth: oauthOpts,
120124
})
@@ -125,7 +129,7 @@ func (u *UserService) upsertAzureUserFromToken(ctx context.Context, config *serv
125129
case pgx.ErrNoRows:
126130
user, err = u.config.V1.User().CreateUser(ctx, &v1.CreateUserOpts{
127131
Email: aInfo.Email,
128-
EmailVerified: v1.BoolPtr(aInfo.EmailVerified),
132+
EmailVerified: v1.BoolPtr(true),
129133
Name: v1.StringPtr(aInfo.Name),
130134
OAuth: oauthOpts,
131135
})
@@ -142,27 +146,14 @@ func (u *UserService) upsertAzureUserFromToken(ctx context.Context, config *serv
142146

143147
var ErrAzureNoEmail = fmt.Errorf("azure account must have an email")
144148

149+
// azureUserInfo holds the claims we consume from the Microsoft identity platform
150+
// OIDC userinfo endpoint. Per the Microsoft docs that endpoint returns only sub,
151+
// name, family_name, given_name, picture and (with the "email" scope) email — it
152+
// does not return email_verified or preferred_username, so we don't model those.
145153
type azureUserInfo struct {
146-
Email string
147-
EmailVerified bool
148-
Sub string
149-
Name string
150-
}
151-
152-
// azureUserInfoResponse mirrors the claims returned by the Microsoft identity
153-
// platform OIDC userinfo endpoint. The `email` claim is only present when the
154-
// "email" scope is granted and the account has a mail attribute; when it is
155-
// absent we fall back to `preferred_username`, which for work/school accounts
156-
// is the UPN (typically the user's email address).
157-
type azureUserInfoResponse struct {
158-
Sub string `json:"sub"`
159-
Name string `json:"name"`
160-
Email string `json:"email"`
161-
PreferredUsername string `json:"preferred_username"`
162-
// email_verified is not consistently returned by Azure AD. When absent we
163-
// treat the email as verified, since the user has authenticated against the
164-
// Azure AD directory. When Azure explicitly returns false, we honor it.
165-
EmailVerified *bool `json:"email_verified"`
154+
Sub string `json:"sub"`
155+
Name string `json:"name"`
156+
Email string `json:"email"`
166157
}
167158

168159
func getAzureUserInfoFromToken(tok *oauth2.Token) (*azureUserInfo, error) {
@@ -191,32 +182,21 @@ func getAzureUserInfoFromToken(tok *oauth2.Token) (*azureUserInfo, error) {
191182
return nil, fmt.Errorf("failed reading response body: %s", err.Error())
192183
}
193184

185+
if response.StatusCode != http.StatusOK {
186+
return nil, fmt.Errorf("userinfo endpoint returned status %d: %s", response.StatusCode, string(contents))
187+
}
188+
194189
// parse contents into Azure userinfo claims
195-
resp := &azureUserInfoResponse{}
196-
err = json.Unmarshal(contents, &resp)
190+
aInfo := &azureUserInfo{}
191+
err = json.Unmarshal(contents, &aInfo)
197192

198193
if err != nil {
199194
return nil, fmt.Errorf("failed parsing response body: %s", err.Error())
200195
}
201196

202-
email := resp.Email
203-
if email == "" {
204-
email = resp.PreferredUsername
205-
}
206-
207-
if email == "" {
197+
if aInfo.Email == "" {
208198
return nil, ErrAzureNoEmail
209199
}
210200

211-
emailVerified := true
212-
if resp.EmailVerified != nil {
213-
emailVerified = *resp.EmailVerified
214-
}
215-
216-
return &azureUserInfo{
217-
Email: email,
218-
EmailVerified: emailVerified,
219-
Sub: resp.Sub,
220-
Name: resp.Name,
221-
}, nil
201+
return aInfo, nil
222202
}

0 commit comments

Comments
 (0)