f85553903a3f87b3029f94e49f0a7d1bb805445b
max
  Mon Aug 3 12:52:33 2026 -0700
hgLogin: configurable OIDC providers, GitHub login, top-level email-link button, sign-in wording. refs #37984

diff --git src/hg/hgLogin/hgLogin.c src/hg/hgLogin/hgLogin.c
index fdb8ee643e7..f7cc0fdc79f 100644
--- src/hg/hgLogin/hgLogin.c
+++ src/hg/hgLogin/hgLogin.c
@@ -52,32 +52,34 @@
 char *errMsg = NULL;    /* Error message to show user when form data rejected */
 char brwName[64];
 char brwAddr[256];
 char signature[256];
 char returnAddr[256];
 char *hgLoginUrl = NULL; /* full absolute URL to hgLogin as seen from browser,
     e.g. http://genome.ucsc.edu/cgi-bin/hgLogin. Can be a relative URL /cgi-bin/hgLogin if
     hg.conf login.relativeLink is on. */
 boolean pwdEyeIconEnabled = TRUE; /* show/hide eye icon on password fields;
     set from hg.conf login.pwdEyeIcon in doMiddle() */
 
 /* for earlyBotCheck() function at the beginning of main() */
 #define delayFraction   1.0    /* standard penalty is 1.0 for most CGIs */
 
 /* Forward declarations for functions used before their definitions. */
-static void printSocialButtons();
+static void printSocialButtons(boolean dividerAbove, boolean dividerBelow);
+static void printEmailLinkButton();
 static void printUsernameNote();
+void emailLinkPage(struct sqlConnection *conn);
 void displayLoginPage(struct sqlConnection *conn);
 void displayAccHelpPage(struct sqlConnection *conn);
 void completeAccountPage(struct sqlConnection *conn);
 void sendEmailLink(struct sqlConnection *conn);
 
 /* ---- Global helper functions ---- */
 char *browserName()
 /* Return the browser name like 'UCSC Genome Browser' */
 {
 if isEmpty(cfgOption(CFG_LOGIN_BROWSER_NAME))
     return cloneString("NULL_browserName");
 else
     return cloneString(cfgOption(CFG_LOGIN_BROWSER_NAME));
 }
 
@@ -797,31 +799,32 @@
     "</span>"
     "</div>"
     "\n"
     "<div class=\"formControls\">"
     "   <input type=\"submit\" name=\"hgLogin.do.displayLogin\" value=\"Login\" class=\"largeButton\">"
     "    &nbsp;<a href=\"%s\">Cancel</a>"
     "</div>"
     , getReturnToURL());
 if (pwdEyeIconEnabled)
     {
     printPwdToggleJS();
     jsOnEventById("click", "pwdEyeIcon", "togglePwdVisibility('password','pwdEyeSlash');");
     }
 cartSaveSession(cart);
 hPrintf("</form>\n");
-printSocialButtons();
+printEmailLinkButton();
+printSocialButtons(TRUE, FALSE);
 hPrintf(
     "<div id=\"helpBox\">"
     "<a href=\"%s?hgLogin.do.displayAccHelpPage=1\">Forgot username, password or sign in with an email link</a><br>"
     "Need an account? <a href=\"%s?hgLogin.do.signupPage=1\">Sign up</a>.<br>"
     "</div><!-- END - helpBox -->"
     "</div><!-- END - loginBox -->"
     "\n"
     "\n"
     "</body>"
     "</html>", hgLoginUrl, hgLoginUrl);
 }
 
 void activateAccount(struct sqlConnection *conn)
 /* activate account */
 {
@@ -1084,34 +1087,36 @@
 cartRemove(cart, "hgLogin_newEmail1");
 cartRemove(cart, "hgLogin_newEmail2");
 hPrintf("<div class=\"centeredContainer formBox\"><h2>%s</h2>", brwName);
 hPrintf("<h3>Your email address has been changed.</h3>");
 hPrintf("<p>Your email address is now <b>%s</b>.</p></div>", email1);
 returnToURL(1500);
 }
 
 void signupPage(struct sqlConnection *conn)
 /* draw the signup page */
 {
 hPrintf("<div id=\"signUpBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf(
     "<p>Signing up enables you to save multiple sessions and to share your sessions with others.</p>"
-    "Already have an account? <a href=\"%s?hgLogin.do.displayLoginPage=1\">Login</a>.<br>"
-    "\n", hgLoginUrl);
-printSocialButtons();
-hPrintf("<h3>Sign Up</h3>"
+    "\n");
+hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.displayLoginPage=1\">"
+    "Sign in with username and password</a>", hgLoginUrl);
+printEmailLinkButton();
+printSocialButtons(TRUE, TRUE);
+hPrintf("<h3>Sign Up Using Email</h3>"
     "<form method=\"post\" action=\"%s\" name=\"mainForm\">"
     "<span style='color:red;'>%s</span>"
     "\n", hgLoginUrl, errMsg ? errMsg : "");
 printUsernameNote();
 hPrintf("<div class=\"inputGroup\">"
     "<label for=\"userName\">Username</label>"
     "<input type=text name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"userName\">"
     "</div>"
     "\n"
     "<div class=\"inputGroup\">"
     "<label for=\"emailAddr\">Email address</label>"
     "<input type=text name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailAddr\">"
     "</div>"
     "\n"
     "<div class=\"inputGroup\">"
@@ -1138,31 +1143,31 @@
     "</span>"
     "</div>"
     "\n"
     "<div class=\"inputGroup\">"
     "<label for=\"passwordCheck\">Re-enter Password</label>"
     "<span style=\"position:relative; display:inline-block;\">"
     "<input type=password name=\"hgLogin_password2\" value=\"%s\" size=\"30\" id=\"passwordCheck\">",
     cartUsualString(cart, "hgLogin_password2", ""));
 printPwdEyeIcon("signupPwCheckEyeIcon", "signupPwCheckEyeSlash");
 hPrintf(
     "</span>"
     "\n"
     "</div>"
     "\n"
     "<div class=\"formControls\">"
-    "    <input type=\"submit\" name=\"hgLogin.do.signup\" value=\"Sign Up\" class=\"largeButton\"> &nbsp; "
+    "    <input type=\"submit\" name=\"hgLogin.do.signup\" value=\"Sign Up using Email\" class=\"largeButton\"> &nbsp; "
     "    <a href=\"%s\">Cancel</a>"
     "</div>"
     "</form>"
     "</div><!-- END - signUpBox -->",
     getReturnToURL());
 if (pwdEyeIconEnabled)
     {
     printPwdToggleJS();
     jsOnEventById("click", "signupPwEyeIcon", "togglePwdVisibility('password','signupPwEyeSlash');");
     jsOnEventById("click", "signupPwCheckEyeIcon", "togglePwdVisibility('passwordCheck','signupPwCheckEyeSlash');");
     }
 cartSaveSession(cart);
 }
 
 void signup(struct sqlConnection *conn)
@@ -1500,47 +1505,60 @@
     "<p align=\"left\">"
     "</p>"
     "<span style='color:red;'></span>"
     "\n");
 struct dyString *javascript = dyStringNew(1024);
 struct slName *newCookies = loginLogoutUser(), *sl;
 for (sl = newCookies;  sl != NULL;  sl = sl->next)
     dyStringPrintf(javascript, " document.cookie = '%s';", sl->name);
 jsInline(javascript->string);
 /* return to "returnto" URL */
 returnToURL(150);
 }
 
 /* ---- Social login (OAuth) and passwordless email-link login ---- */
 
-static void printSocialButtons()
-/* Print sign-in buttons for any enabled social login providers.  Prints nothing if no
+static void printSocialButtons(boolean dividerAbove, boolean dividerBelow)
+/* Print sign-in buttons for any enabled social login providers, optionally bracketed by "or"
+ * dividers.  The login page uses only the top divider (separating the buttons from the
+ * password form above); the signup page uses both, so the buttons sit in their own section
+ * between the "already have an account" link and the email signup form.  Prints nothing if no
  * provider is configured, so mirrors without OAuth credentials are unaffected. */
 {
 if (!oauthAnyProviderEnabled())
     return;
-hPrintf("<div class=\"socialLogin\">"
-    "<div class=\"orDivider\"><span>or</span></div>");
-if (oauthProviderEnabled(OAUTH_PROVIDER_GOOGLE))
-    hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.oauthStart=1&provider=google\">"
-            "Sign in with Google</a>", hgLoginUrl);
-if (oauthProviderEnabled(OAUTH_PROVIDER_ORCID))
-    hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.oauthStart=1&provider=orcid\">"
-            "Sign in with ORCID</a>", hgLoginUrl);
+hPrintf("<div class=\"socialLogin\">");
+if (dividerAbove)
+    hPrintf("<div class=\"orDivider\"><span>or</span></div>");
+struct slName *prov, *providers = oauthProviderNames();
+for (prov = providers;  prov != NULL;  prov = prov->next)
+    hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.oauthStart=1&provider=%s\">"
+            "Sign in with %s</a>",
+            hgLoginUrl, cgiEncode(prov->name), oauthProviderLabel(prov->name));
+if (dividerBelow)
+    hPrintf("<div class=\"orDivider\"><span>or</span></div>");
 hPrintf("</div>");
 }
 
+static void printEmailLinkButton()
+/* Print a grey button that opens the passwordless email-link login page.  Always available
+ * (this is core hgLogin functionality, independent of any OAuth configuration). */
+{
+hPrintf("<a class=\"socialButton\" href=\"%s?hgLogin.do.emailLinkPage=1\">"
+    "Sign in with an email link</a>", hgLoginUrl);
+}
+
 static void printUsernameNote()
 /* Print a short hint, shown wherever a new username is chosen, explaining that the username
  * shows up in every short link the user later creates, so it should be short and easy to type. */
 {
 hPrintf("<p style=\"font-size:0.9em\">Note: your username becomes part of every short link "
     "you create later (for example <code>%s/s/<b>username</b>/MySession</code>), so choose "
     "something short and easy to type.</p>", brwAddr);
 }
 
 static void loginAndReturn(char *userName, uint idx)
 /* Set the permanent login cookies for userName and bounce back to the returnto URL.
  * Every login method (password, social, email link) funnels through here, so they all
  * produce the same long-lived login cookies. */
 {
 hPrintf("<h2>%s</h2>", brwName);
@@ -1552,31 +1570,31 @@
 }
 
 static void createIdentityTable(struct sqlConnection *conn)
 /* Create the gbMemberIdentity table if it does not exist.  On a mirror whose central db
  * is read-only this may fail; social login simply won't work there (and won't be enabled
  * without client secrets anyway), so ignore any error. */
 {
 if (sqlTableExists(conn, "gbMemberIdentity"))
     return;
 struct errCatch *errCatch = errCatchNew();
 if (errCatchStart(errCatch))
     {
     struct dyString *dy = sqlDyStringCreate(
         "CREATE TABLE IF NOT EXISTS gbMemberIdentity ("
         "idx int unsigned NOT NULL,"
-        "provider varchar(16) NOT NULL,"
+        "provider varchar(64) NOT NULL,"
         "subject varchar(255) NOT NULL,"
         "email varchar(255) NOT NULL default '',"
         "created DATETIME NOT NULL,"
         "lastUse DATETIME NOT NULL,"
         "UNIQUE KEY provSub (provider, subject),"
         "INDEX(idx))");
     sqlUpdate(conn, dyStringContents(dy));
     dyStringFree(&dy);
     }
 errCatchEnd(errCatch);
 errCatchFree(&errCatch);
 }
 
 static boolean userNameTaken(struct sqlConnection *conn, char *userName)
 /* Return TRUE if userName already exists in gbMembers. */
@@ -1671,31 +1689,31 @@
 char *name = cartUsualString(cart, "oauth_pending_name", "");
 if (isEmpty(provider))
     {
     displayLoginPage(conn);
     return;
     }
 char *suggested = cartUsualString(cart, "hgLogin_userName", "");
 if (isEmpty(suggested))
     suggested = suggestUsername(conn, email, name);
 
 hPrintf("<div id=\"completeAccountBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf("<h3>Choose a username</h3>");
 hPrintf("<p>You signed in with %s. Pick a username for your new %s account. "
     "You can change the suggested name below.</p>",
-    sameString(provider, OAUTH_PROVIDER_ORCID) ? "ORCID" : "Google", brwName);
+    oauthProviderLabel(provider), brwName);
 printUsernameNote();
 hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : "");
 hPrintf("<form method=\"post\" action=\"%s\" name=\"completeAccountForm\">", hgLoginUrl);
 hPrintf("<div class=\"inputGroup\">"
     "<label for=\"userName\">Username</label>"
     "<input type=\"text\" name=\"hgLogin_userName\" value=\"%s\" size=\"30\" id=\"userName\">"
     "</div>", suggested);
 hPrintf("<div class=\"inputGroup\">"
     "<label for=\"emailAddr\">Email address</label>"
     "<input type=\"text\" name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailAddr\">"
     "</div>", email);
 hPrintf("<div class=\"formControls\">"
     "<input type=\"submit\" name=\"hgLogin.do.completeAccount\" value=\"Create Account\" class=\"largeButton\">"
     " &nbsp;<a href=\"%s\">Cancel</a>"
     "</div></form></div><!-- END - completeAccountBox -->", getReturnToURL());
@@ -1790,31 +1808,31 @@
 if (isEmpty(provider) || isEmpty(email))
     {
     displayLoginPage(conn);
     return;
     }
 char query[512];
 sqlSafef(query, sizeof(query),
     "SELECT * FROM gbMembers WHERE email='%s' ORDER BY idx", email);
 struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m;
 
 hPrintf("<div id=\"chooseAccountBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf("<h3>Choose an account</h3>");
 hPrintf("<p>The email address <b>%s</b> is associated with more than one %s account. "
     "Select the account you would like to sign in to; your %s login will be linked to it.</p>",
-    email, brwName, sameString(provider, OAUTH_PROVIDER_ORCID) ? "ORCID" : "Google");
+    email, brwName, oauthProviderLabel(provider));
 hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : "");
 hPrintf("<form method=\"post\" action=\"%s\" name=\"chooseAccountForm\">", hgLoginUrl);
 hPrintf("<div class=\"inputGroup\">");
 boolean first = TRUE;
 for (m = list;  m != NULL;  m = m->next)
     {
     hPrintf("<div class=\"acctHelpSection\">"
         "<input name=\"hgLogin_chosenUser\" type=\"radio\" value=\"%s\" id=\"acct_%u\"%s>"
         "<label for=\"acct_%u\" class=\"radioLabel\">%s</label></div>",
         m->userName, m->idx, first ? " checked" : "", m->idx, m->userName);
     first = FALSE;
     }
 hPrintf("</div>");
 hPrintf("<div class=\"formControls\">"
     "<input type=\"submit\" name=\"hgLogin.do.chooseAccount\" value=\"Sign In\" class=\"largeButton\">"
@@ -1969,30 +1987,51 @@
     return;
     }
 char *code = cgiUsualString("code", "");
 struct oauthIdentity *id = oauthFetchIdentity(provider, code, hgLoginUrl);
 if (id == NULL)
     {
     freez(&errMsg);
     errMsg = cloneString("We could not complete the social login. Please try again.");
     displayLoginPage(conn);
     return;
     }
 resolveIdentity(conn, id);
 oauthIdentityFree(&id);
 }
 
+void emailLinkPage(struct sqlConnection *conn)
+/* Standalone page that asks for an email address and sends a one-time login link. */
+{
+hPrintf("<div id=\"emailLinkBox\" class=\"centeredContainer formBox\">"
+    "<h2>%s</h2>", brwName);
+hPrintf("<h3>Sign in with an email link</h3>");
+hPrintf("<p>Enter your email address and we'll send you a link that signs you in without a "
+    "password. This is handy on a computer where you don't have your password saved.</p>");
+hPrintf("<span style='color:red;'>%s</span>", errMsg ? errMsg : "");
+hPrintf("<form method=\"post\" action=\"%s\" name=\"emailLinkForm\">", hgLoginUrl);
+hPrintf("<div class=\"inputGroup\">"
+    "<label for=\"emailLink\">Email address</label>"
+    "<input type=\"text\" name=\"hgLogin_email\" value=\"%s\" size=\"30\" id=\"emailLink\">"
+    "</div>", cartUsualString(cart, "hgLogin_email", ""));
+hPrintf("<div class=\"formControls\">"
+    "<input type=\"submit\" name=\"hgLogin.do.sendEmailLink\" value=\"Send login link\" class=\"largeButton\">"
+    " &nbsp;<a href=\"%s\">Cancel</a>"
+    "</div></form></div><!-- END - emailLinkBox -->", getReturnToURL());
+cartSaveSession(cart);
+}
+
 void displayLoginLinkSuccess()
 /* Confirmation shown after a passwordless login link is (possibly) emailed.  Phrased so it
  * does not reveal whether an account exists for the address. */
 {
 char *email = cartUsualString(cart, "hgLogin_sendMailTo", "");
 hPrintf("<div id=\"confirmationBox\" class=\"centeredContainer formBox\">"
     "<h2>%s</h2>", brwName);
 hPrintf("<p id=\"confirmationMsg\" class=\"confirmationTxt\">If an account exists for "
     "<B>%s</B>, a login link has been sent to that address.<BR><BR>"
     "Click the link in that email to sign in &mdash; no password needed. "
     "The link works once and expires in one hour.</p>", email);
 hPrintf("<p>If you don't see the email, please check your spam folder.</p>");
 hPrintf("<p><a href=\"%s?hgLogin.do.displayLoginPage=1\">Return to Login</a></p>\n", hgLoginUrl);
 cartRemove(cart, "hgLogin_email");
 cartRemove(cart, "hgLogin_sendMailTo");
@@ -2013,31 +2052,31 @@
     "Someone (probably you, from IP address %s) requested a login link for the %s account "
     "\"%s\".\nClick the link below to sign in without a password. It works once and expires "
     "in one hour:\n\n%s\n\nIf you did not request this, you can safely ignore this email.\n\n%s\n%s",
     remoteAddr, brwName, username, url, signature, returnAddr);
 sendActMailOut(email, subject, msg);
 }
 
 void sendEmailLink(struct sqlConnection *conn)
 /* Generate and email a one-time passwordless login link to the address on file. */
 {
 char *email = cartUsualString(cart, "hgLogin_email", "");
 if (isEmpty(email) || spc_email_isvalid(email) == 0)
     {
     freez(&errMsg);
     errMsg = cloneString("Please enter a valid email address.");
-    displayAccHelpPage(conn);
+    emailLinkPage(conn);
     return;
     }
 char query[512];
 sqlSafef(query, sizeof(query),
     "SELECT * FROM gbMembers WHERE email='%s' OR recovEmail='%s'", email, email);
 struct gbMembers *list = gbMembersLoadByQuery(conn, query), *m;
 for (m = list; m != NULL; m = m->next)
     {
     char *token = makeRandomKey(128+33);
     char *tokenMD5 = generateTokenMD5(token);
     sqlSafef(query, sizeof(query),
         "UPDATE gbMembers SET loginToken='%s', "
         "loginTokenExpires=DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE idx=%u",
         tokenMD5, m->idx);
     sqlUpdate(conn, query);
@@ -2111,30 +2150,32 @@
 safecpy(brwAddr,sizeof(brwAddr), browserAddr());
 safecpy(signature,sizeof(signature), mailSignature());
 safecpy(returnAddr,sizeof(returnAddr), mailReturnAddr());
 pwdEyeIconEnabled = cfgOptionBooleanDefault(CFG_LOGIN_PWD_EYE_ICON, TRUE);
 
 // A provider's OAuth redirect back to us carries 'code' and 'state' but none of our own
 // hgLogin.do.* variables, so detect it up front.
 if (cgiOptionalString("code") != NULL && cgiOptionalString("state") != NULL)
     oauthReturn(conn);
 else if (cartVarExists(cart, "hgLogin.do.oauthStart"))
     oauthStart(conn);
 else if (cartVarExists(cart, "hgLogin.do.completeAccount"))
     completeAccount(conn);
 else if (cartVarExists(cart, "hgLogin.do.chooseAccount"))
     chooseAccount(conn);
+else if (cartVarExists(cart, "hgLogin.do.emailLinkPage"))
+    emailLinkPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.sendEmailLink"))
     sendEmailLink(conn);
 else if (cartVarExists(cart, "hgLogin.do.emailLogin"))
     emailLogin(conn);
 else if (cartVarExists(cart, "hgLogin.do.changePasswordPage"))
     changePasswordPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.changePassword"))
     changePassword(conn);
 else if (cartVarExists(cart, "hgLogin.do.changeEmailPage"))
     changeEmailPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.changeEmail"))
     changeEmail(conn);
 else if (cartVarExists(cart, "hgLogin.do.displayAccHelpPage"))
     displayAccHelpPage(conn);
 else if (cartVarExists(cart, "hgLogin.do.accountHelp"))