4b04bd017d7a36d460447d8552ecaf9c8a33db4a
max
  Tue Aug 4 02:51:56 2026 -0700
hgLogin: gate email-link sign-in and change-email behind login.emailLink (default off), refs #37929

Also: GitHub/OIDC token-format and robustness fixes, signed pending-identity to close
an account-takeover hole in the OAuth account chooser, account chooser for the email-link
flow, idx-based chooser to avoid a utf8/latin1 collation error on non-ASCII usernames, and
login/signup page UI polish (consistent buttons, fonts, cache-busted stylesheet, forgot
links, wording). refs #37984

diff --git src/hg/hgLogin/oauthLogin.c src/hg/hgLogin/oauthLogin.c
index 3056ae9c949..801cf1b9d52 100644
--- src/hg/hgLogin/oauthLogin.c
+++ src/hg/hgLogin/oauthLogin.c
@@ -244,43 +244,57 @@
 static struct jsonElement *httpGetJson(char *url, char *bearer)
 /* GET url with an Authorization: Bearer header (and a User-Agent, which GitHub requires) and
  * return the parsed JSON response, or NULL. */
 {
 struct dyString *header = dyStringNew(256);
 dyStringPrintf(header, "Authorization: Bearer %s\r\n", bearer);
 dyStringPrintf(header, "Accept: application/json\r\n");
 dyStringPrintf(header, "User-Agent: UCSC-Genome-Browser\r\n");
 char *body = httpRequest(url, "GET", header->string, NULL);
 dyStringFree(&header);
 struct jsonElement *json = jsonParseSafe(body);
 freeMem(body);
 return json;
 }
 
-static struct jsonElement *postForm(char *url, char *body)
-/* POST an x-www-form-urlencoded body and return the parsed JSON response, or NULL. */
+static char *formValue(char *body, char *name)
+/* Return the URL-decoded value of name in an x-www-form-urlencoded body, or NULL.  Allocd. */
 {
-struct dyString *header = dyStringNew(256);
-dyStringPrintf(header, "Content-Type: application/x-www-form-urlencoded\r\n");
-dyStringPrintf(header, "Accept: application/json\r\n");
-dyStringPrintf(header, "User-Agent: UCSC-Genome-Browser\r\n");
-dyStringPrintf(header, "Content-Length: %d\r\n", (int)strlen(body));
-char *respBody = httpRequest(url, "POST", header->string, body);
-dyStringFree(&header);
-struct jsonElement *json = jsonParseSafe(respBody);
-freeMem(respBody);
-return json;
+char *dupe = cloneString(body);
+char *result = NULL;
+int n = countChars(dupe, '&') + 1;
+char **pairs;
+AllocArray(pairs, n);
+n = chopByChar(dupe, '&', pairs, n);
+int i;
+for (i = 0;  i < n;  i++)
+    {
+    char *eq = strchr(pairs[i], '=');
+    if (eq == NULL)
+        continue;
+    *eq = '\0';
+    if (sameString(pairs[i], name))
+        {
+        char *val = eq + 1;
+        cgiDecode(val, val, strlen(val));
+        result = cloneString(val);
+        break;
+        }
+    }
+freeMem(pairs);
+freeMem(dupe);
+return result;
 }
 
 static void ensureEndpoints(struct oauthProvider *p)
 /* For an OIDC provider configured with only an issuer, fetch the discovery document once and
  * fill in any endpoints that were not set explicitly. */
 {
 if (p->discovered || !sameWord(p->type, "oidc") || isEmpty(p->issuer))
     return;
 p->discovered = TRUE;
 if (isNotEmpty(p->authUrl) && isNotEmpty(p->tokenUrl) && isNotEmpty(p->userinfoUrl))
     return;
 char url[1024];
 safef(url, sizeof(url), "%s/.well-known/openid-configuration", p->issuer);
 struct jsonElement *j = jsonParseSafe(httpRequest(url, "GET", "Accept: application/json\r\n", NULL));
 if (j == NULL)
@@ -314,79 +328,110 @@
 }
 
 static struct dyString *tokenExchangeBody(struct oauthProvider *p, char *code, char *redirectUri)
 /* Build the shared authorization_code token-exchange POST body. */
 {
 struct dyString *body = dyStringNew(512);
 dyStringPrintf(body, "grant_type=authorization_code");
 dyStringPrintf(body, "&code=%s", cgiEncode(code));
 dyStringPrintf(body, "&client_id=%s", cgiEncode(p->clientId));
 dyStringPrintf(body, "&client_secret=%s", cgiEncode(p->clientSecret));
 dyStringPrintf(body, "&redirect_uri=%s", cgiEncode(redirectUri));
 return body;
 }
 
 static char *tokenExchange(struct oauthProvider *p, char *code, char *redirectUri)
-/* Run the code->token exchange and return the access_token, or NULL. */
+/* Run the code->token exchange and return the access_token, or NULL.  The response may be
+ * JSON (Google, ORCID) or x-www-form-urlencoded (GitHub's default), so try both. */
 {
-struct dyString *body = tokenExchangeBody(p, code, redirectUri);
-struct jsonElement *tok = postForm(p->tokenUrl, body->string);
-dyStringFree(&body);
-if (tok == NULL)
+struct dyString *reqBody = tokenExchangeBody(p, code, redirectUri);
+struct dyString *header = dyStringNew(256);
+dyStringPrintf(header, "Content-Type: application/x-www-form-urlencoded\r\n");
+dyStringPrintf(header, "Accept: application/json\r\n");
+dyStringPrintf(header, "User-Agent: UCSC-Genome-Browser\r\n");
+dyStringPrintf(header, "Content-Length: %d\r\n", (int)reqBody->stringSize);
+char *resp = httpRequest(p->tokenUrl, "POST", header->string, reqBody->string);
+dyStringFree(&header);
+dyStringFree(&reqBody);
+if (isEmpty(resp))
     return NULL;
-return cloneString(jsonOptionalStringField(tok, "access_token", NULL));
+char *access = NULL;
+struct jsonElement *tok = jsonParseSafe(resp);
+if (tok != NULL)
+    access = cloneString(jsonOptionalStringField(tok, "access_token", NULL));
+if (isEmpty(access))
+    access = formValue(resp, "access_token");
+freeMem(resp);
+return access;
+}
+
+static boolean jsonFieldIsTrue(struct jsonElement *obj, char *field)
+/* Read a boolean-ish field tolerantly: a JSON boolean true, or the string "true"/"1".
+ * The OIDC spec says email_verified is a boolean, but some providers send it as a string;
+ * jsonOptionalBooleanField would abort on that, so read the type ourselves. */
+{
+struct jsonElement *el = jsonFindNamedField(obj, "", field);
+if (el == NULL)
+    return FALSE;
+if (el->type == jsonBoolean)
+    return el->val.jeBoolean;
+if (el->type == jsonString)
+    return sameWord(el->val.jeString, "true") || sameString(el->val.jeString, "1");
+return FALSE;
 }
 
 static struct oauthIdentity *oidcFetch(struct oauthProvider *p, char *code, char *redirectUri)
 /* OpenID Connect: exchange code, then read the standard claims from the userinfo endpoint.
  * Works directly over TLS with the provider, so we don't verify the id_token signature. */
 {
 char *accessToken = tokenExchange(p, code, redirectUri);
 if (isEmpty(accessToken))
     return NULL;
 struct jsonElement *info = httpGetJson(p->userinfoUrl, accessToken);
 if (info == NULL)
     return NULL;
 char *sub = jsonOptionalStringField(info, "sub", NULL);
 if (isEmpty(sub))
     return NULL;
 struct oauthIdentity *id;
 AllocVar(id);
 id->provider = cloneString(p->name);
 id->subject = cloneString(sub);
 id->email = cloneString(jsonOptionalStringField(info, "email", NULL));
-id->emailVerified = jsonOptionalBooleanField(info, "email_verified", FALSE);
+id->emailVerified = jsonFieldIsTrue(info, "email_verified");
 id->displayName = cloneString(jsonOptionalStringField(info, "name", NULL));
 return id;
 }
 
 static void githubBestEmail(char *accessToken, char **retEmail, boolean *retVerified)
 /* Query GitHub's /user/emails and return the primary verified email, if any. */
 {
 *retEmail = NULL;
 *retVerified = FALSE;
 struct jsonElement *emails = httpGetJson("https://api.github.com/user/emails", accessToken);
-if (emails == NULL)
+if (emails == NULL || emails->type != jsonList)
+    // GitHub returns an object (not an array) on error, e.g. a token without the user:email
+    // scope.  Treat that as "no email available" and let login proceed without one.
     return;
 struct slRef *list = jsonListVal(emails, "emails"), *ref;
 for (ref = list;  ref != NULL;  ref = ref->next)
     {
     struct jsonElement *el = ref->val;
-    if (jsonOptionalBooleanField(el, "primary", FALSE))
+    if (jsonFieldIsTrue(el, "primary"))
         {
         *retEmail = cloneString(jsonOptionalStringField(el, "email", NULL));
-        *retVerified = jsonOptionalBooleanField(el, "verified", FALSE);
+        *retVerified = jsonFieldIsTrue(el, "verified");
         return;
         }
     }
 }
 
 static struct oauthIdentity *githubFetch(struct oauthProvider *p, char *code, char *redirectUri)
 /* GitHub (plain OAuth2, not OIDC): exchange code, then read the profile from /user and the
  * primary verified email from /user/emails. */
 {
 char *accessToken = tokenExchange(p, code, redirectUri);
 if (isEmpty(accessToken))
     return NULL;
 struct jsonElement *info = httpGetJson(p->userinfoUrl, accessToken);
 if (info == NULL)
     return NULL;
@@ -404,33 +449,50 @@
 if (isEmpty(id->displayName))
     id->displayName = cloneString(jsonOptionalStringField(info, "login", NULL));
 githubBestEmail(accessToken, &id->email, &id->emailVerified);
 return id;
 }
 
 struct oauthIdentity *oauthFetchIdentity(char *name, char *code, char *redirectUri)
 /* Exchange code for tokens and fetch the authenticated identity, or NULL on any failure. */
 {
 struct oauthProvider *p = providerByName(name);
 if (p == NULL || isEmpty(code))
     return NULL;
 ensureEndpoints(p);
 if (isEmpty(p->tokenUrl) || isEmpty(p->userinfoUrl))
     return NULL;
+/* Catch any errAbort raised while reading unexpected JSON shapes from the provider (the
+ * jsonXxxVal accessors abort on a type mismatch), so a misbehaving provider yields a clean
+ * "login failed" rather than an error page. */
+struct oauthIdentity *id = NULL;
+struct errCatch *errCatch = errCatchNew();
+if (errCatchStart(errCatch))
+    {
     if (sameWord(p->type, "github"))
-    return githubFetch(p, code, redirectUri);
-return oidcFetch(p, code, redirectUri);
+        id = githubFetch(p, code, redirectUri);
+    else
+        id = oidcFetch(p, code, redirectUri);
+    }
+errCatchEnd(errCatch);
+if (errCatch->gotError)
+    {
+    fprintf(stderr, "hgLogin oauth: identity fetch for %s failed: %s\n", name, errCatch->message->string);
+    id = NULL;
+    }
+errCatchFree(&errCatch);
+return id;
 }
 
 void oauthIdentityFree(struct oauthIdentity **pId)
 /* Free an oauthIdentity. */
 {
 struct oauthIdentity *id = *pId;
 if (id != NULL)
     {
     freeMem(id->provider);
     freeMem(id->subject);
     freeMem(id->email);
     freeMem(id->displayName);
     freez(pId);
     }
 }