948e76a68b4a62e4f6f90244eeb266342406ce53
max
  Wed Sep 16 03:03:36 2026 -0700
hgLogin: make trusting a provider's unverified email a per-provider setting

Accepting an address a provider had not marked verified was applied to every
provider, to keep CILogon usable: it sends the address it got from the user's
institution but leaves email_verified at 0, even for a real institutional sign-in.
Extending that to everyone was too much. GitHub hands over a primary address whose
owner never confirmed it, and any provider a mirror adds to hg.conf was treated the
same way, so an address nobody had checked was enough to be signed in to an existing
account that used it.

New login.oauth.<name>.trustEmail, off unless an admin sets it, says that a named
provider's address may be taken without email_verified. It belongs on a provider that
reads the address from somewhere the user cannot type into, which is what CILogon
does and what GitHub does not.

Where it is off and the provider did not verify the address, the address is dropped
rather than refused, and the sign-in proceeds as one that arrived with no address at
all, which is already the ORCID case: the user is asked for an address and the account
does not work until they open the link mailed to it. Dropping it also keeps it out of
the account matching in resolveIdentity, so it cannot reach an existing account.

Documented in product/mirrorManual.txt next to the other provider settings, with the
CILogon line mirrors will need, and in oauthLogin.h with the rest of the keys.

diff --git src/hg/hgLogin/oauthLogin.c src/hg/hgLogin/oauthLogin.c
index c554f4f9a2d..2c7a625f20f 100644
--- src/hg/hgLogin/oauthLogin.c
+++ src/hg/hgLogin/oauthLogin.c
@@ -17,52 +17,66 @@
 
 struct oauthProvider
 /* One configured social login provider, built from hg.conf. */
     {
     struct oauthProvider *next;
     char *name;             /* short key, used in URLs and gbMemberIdentity.provider */
     char *label;            /* button label */
     char *type;             /* "oidc" or "github" */
     char *clientId;
     char *clientSecret;
     char *authUrl;          /* authorization endpoint */
     char *tokenUrl;         /* token endpoint */
     char *userinfoUrl;      /* userinfo endpoint */
     char *scopes;           /* space-separated scopes */
     char *issuer;           /* OIDC issuer, for endpoint discovery */
+    boolean trustEmail;     /* accept this provider's address without email_verified */
     boolean discovered;     /* TRUE once discovery has run (avoid repeating) */
     };
 
 /* Provider list is built once per process and cached.  Nothing here is freed: like the rest
  * of hgLogin these live for the life of the (short) CGI request. */
 static struct oauthProvider *providerCache = NULL;
 static boolean providerCacheDone = FALSE;
 
 static char *provCfg(char *name, char *field)
 /* Return hg.conf login.oauth.<name>.<field>, falling back to the older login.<name>.<field>. */
 {
 char key[256];
 safef(key, sizeof(key), "login.oauth.%s.%s", name, field);
 char *val = cfgOption(key);
 if (isEmpty(val))
     {
     safef(key, sizeof(key), "login.%s.%s", name, field);
     val = cfgOption(key);
     }
 return val;
 }
 
+static boolean provCfgBoolean(char *name, char *field, boolean def)
+/* Like provCfg, but read as a boolean.  cfgOptionBooleanDefault takes yes/no, on/off and
+ * true/false, and errAborts on anything else, so a typo in hg.conf is reported rather than
+ * silently taken as off. */
+{
+char key[256];
+safef(key, sizeof(key), "login.oauth.%s.%s", name, field);
+if (cfgOption(key) != NULL)
+    return cfgOptionBooleanDefault(key, def);
+safef(key, sizeof(key), "login.%s.%s", name, field);
+return cfgOptionBooleanDefault(key, def);
+}
+
 static char *cfgTrim(char *name, char *field)
 /* Like provCfg, but with surrounding whitespace removed and NULL for empty.  A stray trailing
  * space in an hg.conf value (e.g. on an issuer or endpoint) would otherwise corrupt the URLs
  * built from it. */
 {
 char *val = provCfg(name, field);
 if (isEmpty(val))
     return NULL;
 return trimSpaces(cloneString(val));
 }
 
 static void fillBuiltinDefaults(struct oauthProvider *p)
 /* For well-known provider names, fill in type/label/endpoints/scopes that were not set
  * explicitly in hg.conf. */
 {
@@ -98,30 +112,35 @@
 static struct oauthProvider *newProvider(char *name)
 /* Build a provider from its hg.conf block, or NULL if clientId/clientSecret are missing. */
 {
 struct oauthProvider *p;
 AllocVar(p);
 p->name = cloneString(name);
 p->label = cfgTrim(name, "label");
 p->type = cfgTrim(name, "type");
 p->clientId = cfgTrim(name, "clientId");
 p->clientSecret = cfgTrim(name, "clientSecret");
 p->authUrl = cfgTrim(name, "authUrl");
 p->tokenUrl = cfgTrim(name, "tokenUrl");
 p->userinfoUrl = cfgTrim(name, "userinfoUrl");
 p->scopes = cfgTrim(name, "scopes");
 p->issuer = cfgTrim(name, "issuer");
+/* Off unless an admin turns it on for a named provider.  It says "this provider's address is
+ * as good as a verified one", which is true of a federation that gets the address from the
+ * user's own institution and never lets them type it, and is not true in general -- GitHub,
+ * for one, will hand over a primary address its owner has never confirmed. */
+p->trustEmail = provCfgBoolean(name, "trustEmail", FALSE);
 fillBuiltinDefaults(p);
 if (isEmpty(p->type))
     p->type = "oidc";
 if (isEmpty(p->label))
     p->label = p->name;
 if (isEmpty(p->scopes))
     p->scopes = "openid email profile";
 if (isEmpty(p->clientId) || isEmpty(p->clientSecret))
     return NULL;
 return p;
 }
 
 static void addProviderName(struct slName **pList, char *name)
 /* Append name to the list if not already present and not blank. */
 {
@@ -492,30 +511,42 @@
 struct errCatch *errCatch = errCatchNew();
 if (errCatchStart(errCatch))
     {
     if (sameWord(p->type, "github"))
         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);
+/* An address the provider did not mark verified is only a string it is holding for the user,
+ * and the user may well have typed it themselves.  Unless hg.conf vouches for this provider,
+ * forget it and let hgLogin treat the sign-in as one that came with no address at all: it then
+ * asks for one and confirms it by mail, the same as ORCID, which releases none.  Dropping it
+ * rather than flagging it also keeps it out of the account matching in resolveIdentity, where
+ * an unverified address would otherwise be enough to reach somebody else's account. */
+if ((id != NULL) && isNotEmpty(id->email) && !id->emailVerified && !p->trustEmail)
+    {
+    fprintf(stderr, "hgLogin oauth: %s did not verify the address it released; asking the user "
+        "for one instead (set login.oauth.%s.trustEmail=on to accept it)\n", name, name);
+    freez(&id->email);
+    }
 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);
     }