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/oauthLogin.c src/hg/hgLogin/oauthLogin.c
index d3843ba01e6..3056ae9c949 100644
--- src/hg/hgLogin/oauthLogin.c
+++ src/hg/hgLogin/oauthLogin.c
@@ -1,94 +1,215 @@
-/* oauthLogin - social login (Google, ORCID) for hgLogin via OAuth 2.0 / OpenID Connect.
+/* oauthLogin - social login for hgLogin via OAuth 2.0 / OpenID Connect.
  * See oauthLogin.h for the hg.conf configuration. */
 
 /* Copyright (C) 2026 The Regents of the University of California
  * See kent/LICENSE or http://genome.ucsc.edu/license/ for licensing information. */
 
 #include "common.h"
 #include "cheapcgi.h"
 #include "hgConfig.h"
 #include "dystring.h"
 #include "errCatch.h"
 #include "net.h"
 #include "htmlPage.h"
 #include "jsonParse.h"
 #include "oauthLogin.h"
 
-static char *oauthCfg(char *provider, char *field)
-/* Return the hg.conf value for login.<provider>.<field>, or NULL.  Do not free. */
+struct oauthProvider
+/* One configured social login provider, built from hg.conf. */
     {
-char name[128];
-safef(name, sizeof(name), "login.%s.%s", provider, field);
-return cfgOption(name);
+    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 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 char *orcidBase()
-/* Return the ORCID base URL, honoring login.orcid.sandbox for testing. */
+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. */
+{
+if (sameWord(p->name, "google"))
     {
-if (cfgOptionBooleanDefault("login.orcid.sandbox", FALSE))
-    return "https://sandbox.orcid.org";
-return "https://orcid.org";
+    if (isEmpty(p->type))        p->type = "oidc";
+    if (isEmpty(p->label))       p->label = "Google";
+    if (isEmpty(p->authUrl))     p->authUrl = "https://accounts.google.com/o/oauth2/v2/auth";
+    if (isEmpty(p->tokenUrl))    p->tokenUrl = "https://oauth2.googleapis.com/token";
+    if (isEmpty(p->userinfoUrl)) p->userinfoUrl = "https://openidconnect.googleapis.com/v1/userinfo";
+    if (isEmpty(p->scopes))      p->scopes = "openid email profile";
+    }
+else if (sameWord(p->name, "orcid"))
+    {
+    if (isEmpty(p->type))        p->type = "oidc";
+    if (isEmpty(p->label))       p->label = "ORCID";
+    if (isEmpty(p->authUrl))     p->authUrl = "https://orcid.org/oauth/authorize";
+    if (isEmpty(p->tokenUrl))    p->tokenUrl = "https://orcid.org/oauth/token";
+    if (isEmpty(p->userinfoUrl)) p->userinfoUrl = "https://orcid.org/oauth/userinfo";
+    if (isEmpty(p->scopes))      p->scopes = "openid";
+    }
+else if (sameWord(p->name, "github"))
+    {
+    if (isEmpty(p->type))        p->type = "github";
+    if (isEmpty(p->label))       p->label = "GitHub";
+    if (isEmpty(p->authUrl))     p->authUrl = "https://github.com/login/oauth/authorize";
+    if (isEmpty(p->tokenUrl))    p->tokenUrl = "https://github.com/login/oauth/access_token";
+    if (isEmpty(p->userinfoUrl)) p->userinfoUrl = "https://api.github.com/user";
+    if (isEmpty(p->scopes))      p->scopes = "read:user user:email";
+    }
 }
 
-boolean oauthProviderEnabled(char *provider)
-/* Return TRUE if both clientId and clientSecret for provider are set in hg.conf. */
+static struct oauthProvider *newProvider(char *name)
+/* Build a provider from its hg.conf block, or NULL if clientId/clientSecret are missing. */
 {
-if (isEmpty(provider))
-    return FALSE;
-return isNotEmpty(oauthCfg(provider, "clientId")) && isNotEmpty(oauthCfg(provider, "clientSecret"));
+struct oauthProvider *p;
+AllocVar(p);
+p->name = cloneString(name);
+p->label = cloneString(provCfg(name, "label"));
+p->type = cloneString(provCfg(name, "type"));
+p->clientId = cloneString(provCfg(name, "clientId"));
+p->clientSecret = cloneString(provCfg(name, "clientSecret"));
+p->authUrl = cloneString(provCfg(name, "authUrl"));
+p->tokenUrl = cloneString(provCfg(name, "tokenUrl"));
+p->userinfoUrl = cloneString(provCfg(name, "userinfoUrl"));
+p->scopes = cloneString(provCfg(name, "scopes"));
+p->issuer = cloneString(provCfg(name, "issuer"));
+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;
 }
 
-boolean oauthAnyProviderEnabled()
-/* Return TRUE if at least one social login provider is configured. */
+static void addProviderName(struct slName **pList, char *name)
+/* Append name to the list if not already present and not blank. */
 {
-return oauthProviderEnabled(OAUTH_PROVIDER_GOOGLE) || oauthProviderEnabled(OAUTH_PROVIDER_ORCID);
+if (isEmpty(name))
+    return;
+if (!slNameInList(*pList, name))
+    slNameAddTail(pList, name);
 }
 
-char *oauthLoginUrl(char *provider, char *redirectUri, char *state)
-/* Return the provider's authorization-endpoint URL to redirect the browser to, or NULL. */
+static struct oauthProvider *loadProviders()
+/* Build the provider list from hg.conf: the login.oauth.providers list plus any of the
+ * well-known names (google/orcid/github) that carry credentials via the legacy keys. */
 {
-if (!oauthProviderEnabled(provider))
-    return NULL;
-char *clientId = oauthCfg(provider, "clientId");
-struct dyString *dy = dyStringNew(512);
-if (sameString(provider, OAUTH_PROVIDER_GOOGLE))
+struct slName *names = NULL;
+struct slName *listed = slNameListFromComma(cfgOption("login.oauth.providers")), *n;
+for (n = listed;  n != NULL;  n = n->next)
+    addProviderName(&names, trimSpaces(n->name));
+char *known[] = {"google", "orcid", "github"};
+int i;
+for (i = 0;  i < ArraySize(known);  i++)
+    if (isNotEmpty(provCfg(known[i], "clientId")))
+        addProviderName(&names, known[i]);
+
+struct oauthProvider *list = NULL;
+for (n = names;  n != NULL;  n = n->next)
     {
-    dyStringPrintf(dy, "https://accounts.google.com/o/oauth2/v2/auth?response_type=code");
-    dyStringPrintf(dy, "&scope=%s", cgiEncode("openid email profile"));
-    dyStringPrintf(dy, "&prompt=select_account");
+    struct oauthProvider *p = newProvider(n->name);
+    if (p != NULL)
+        slAddHead(&list, p);
+    }
+slReverse(&list);
+return list;
 }
-else if (sameString(provider, OAUTH_PROVIDER_ORCID))
+
+static struct oauthProvider *getProviders()
+/* Return the cached provider list, building it on first use. */
+{
+if (!providerCacheDone)
     {
-    dyStringPrintf(dy, "%s/oauth/authorize?response_type=code", orcidBase());
-    dyStringPrintf(dy, "&scope=%s", cgiEncode("openid"));
+    providerCache = loadProviders();
+    providerCacheDone = TRUE;
     }
-else
+return providerCache;
+}
+
+static struct oauthProvider *providerByName(char *name)
+/* Return the configured provider with this name, or NULL. */
 {
-    dyStringFree(&dy);
+struct oauthProvider *p;
+for (p = getProviders();  p != NULL;  p = p->next)
+    if (sameString(p->name, name))
+        return p;
 return NULL;
 }
-dyStringPrintf(dy, "&client_id=%s", cgiEncode(clientId));
-dyStringPrintf(dy, "&redirect_uri=%s", cgiEncode(redirectUri));
-dyStringPrintf(dy, "&state=%s", cgiEncode(state));
-return dyStringCannibalize(&dy);
+
+boolean oauthAnyProviderEnabled()
+/* Return TRUE if at least one social login provider is configured. */
+{
+return (getProviders() != NULL);
+}
+
+boolean oauthProviderEnabled(char *name)
+/* Return TRUE if the named provider is configured. */
+{
+return (isNotEmpty(name) && providerByName(name) != NULL);
+}
+
+struct slName *oauthProviderNames()
+/* Return the short names of all configured providers, in hg.conf order. */
+{
+struct slName *names = NULL;
+struct oauthProvider *p;
+for (p = getProviders();  p != NULL;  p = p->next)
+    slNameAddTail(&names, p->name);
+return names;
 }
 
+char *oauthProviderLabel(char *name)
+/* Return the display label for a provider (falls back to the name). */
+{
+struct oauthProvider *p = providerByName(name);
+return (p != NULL) ? p->label : name;
+}
+
+/* ---- HTTP helpers ---- */
+
 static char *httpRequest(char *url, char *method, char *header, char *body)
-/* Make an HTTP(S) request and return the response body (allocd), or NULL on failure.
- * header holds extra request headers (each terminated with \r\n); body is the request
- * payload for POST (may be NULL).  Network errors are caught and turned into NULL. */
+/* Make an HTTP(S) request and return the response body (allocd), or NULL on failure. */
 {
 char *result = NULL;
 struct errCatch *errCatch = errCatchNew();
 if (errCatchStart(errCatch))
     {
     int sd = netOpenHttpExt(url, method, header);
     if (sd >= 0)
         {
         if (isNotEmpty(body))
             mustWriteFd(sd, body, strlen(body));
         struct dyString *dy = netSlurpFile(sd);
         close(sd);
         struct htmlPage *page = htmlPageParse(url, dyStringCannibalize(&dy));
         if (page != NULL && isNotEmpty(page->htmlText))
             result = cloneString(page->htmlText);
@@ -108,130 +229,208 @@
 /* Parse JSON, returning NULL instead of aborting on malformed input. */
 {
 if (isEmpty(text))
     return NULL;
 struct jsonElement *json = NULL;
 struct errCatch *errCatch = errCatchNew();
 if (errCatchStart(errCatch))
     json = jsonParse(text);
 errCatchEnd(errCatch);
 if (errCatch->gotError)
     json = NULL;
 errCatchFree(&errCatch);
 return json;
 }
 
+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. */
 {
 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;
 }
 
-static struct dyString *tokenExchangeBody(char *provider, char *code, char *redirectUri)
-/* Build the shared authorization_code token-exchange POST body for provider. */
+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)
+    return;
+if (isEmpty(p->authUrl))
+    p->authUrl = cloneString(jsonOptionalStringField(j, "authorization_endpoint", NULL));
+if (isEmpty(p->tokenUrl))
+    p->tokenUrl = cloneString(jsonOptionalStringField(j, "token_endpoint", NULL));
+if (isEmpty(p->userinfoUrl))
+    p->userinfoUrl = cloneString(jsonOptionalStringField(j, "userinfo_endpoint", NULL));
+}
+
+char *oauthLoginUrl(char *name, char *redirectUri, char *state)
+/* Return the provider's authorization URL to redirect the browser to, or NULL. */
+{
+struct oauthProvider *p = providerByName(name);
+if (p == NULL)
+    return NULL;
+ensureEndpoints(p);
+if (isEmpty(p->authUrl))
+    return NULL;
+struct dyString *dy = dyStringNew(512);
+dyStringPrintf(dy, "%s?response_type=code", p->authUrl);
+dyStringPrintf(dy, "&scope=%s", cgiEncode(p->scopes));
+dyStringPrintf(dy, "&client_id=%s", cgiEncode(p->clientId));
+dyStringPrintf(dy, "&redirect_uri=%s", cgiEncode(redirectUri));
+dyStringPrintf(dy, "&state=%s", cgiEncode(state));
+if (sameWord(p->name, "google"))
+    dyStringPrintf(dy, "&prompt=select_account");
+return dyStringCannibalize(&dy);
+}
+
+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(oauthCfg(provider, "clientId")));
-dyStringPrintf(body, "&client_secret=%s", cgiEncode(oauthCfg(provider, "clientSecret")));
+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 struct oauthIdentity *googleFetch(char *code, char *redirectUri)
-/* Complete the Google code exchange and fetch the user's identity, or NULL on failure. */
+static char *tokenExchange(struct oauthProvider *p, char *code, char *redirectUri)
+/* Run the code->token exchange and return the access_token, or NULL. */
 {
-struct dyString *body = tokenExchangeBody(OAUTH_PROVIDER_GOOGLE, code, redirectUri);
-struct jsonElement *tok = postForm("https://oauth2.googleapis.com/token", body->string);
+struct dyString *body = tokenExchangeBody(p, code, redirectUri);
+struct jsonElement *tok = postForm(p->tokenUrl, body->string);
 dyStringFree(&body);
 if (tok == NULL)
     return NULL;
-char *accessToken = jsonOptionalStringField(tok, "access_token", NULL);
+return cloneString(jsonOptionalStringField(tok, "access_token", NULL));
+}
+
+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;
-
-/* Fetch user info directly from Google over TLS.  Because the response comes straight
- * from Google, we don't need to verify the id_token's JWT signature ourselves. */
-struct dyString *header = dyStringNew(256);
-dyStringPrintf(header, "Authorization: Bearer %s\r\n", accessToken);
-dyStringPrintf(header, "Accept: application/json\r\n");
-char *infoText = httpRequest("https://openidconnect.googleapis.com/v1/userinfo", "GET",
-                             header->string, NULL);
-dyStringFree(&header);
-struct jsonElement *info = jsonParseSafe(infoText);
-freeMem(infoText);
+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(OAUTH_PROVIDER_GOOGLE);
+id->provider = cloneString(p->name);
 id->subject = cloneString(sub);
 id->email = cloneString(jsonOptionalStringField(info, "email", NULL));
 id->emailVerified = jsonOptionalBooleanField(info, "email_verified", FALSE);
 id->displayName = cloneString(jsonOptionalStringField(info, "name", NULL));
 return id;
 }
 
-static struct oauthIdentity *orcidFetch(char *code, char *redirectUri)
-/* Complete the ORCID code exchange and read the identity from the token response, or NULL.
- * ORCID's token response carries the ORCID iD ('orcid') and the user's name directly;
- * it does not release an email address, so identity->email stays NULL. */
+static void githubBestEmail(char *accessToken, char **retEmail, boolean *retVerified)
+/* Query GitHub's /user/emails and return the primary verified email, if any. */
 {
-char tokenUrl[256];
-safef(tokenUrl, sizeof(tokenUrl), "%s/oauth/token", orcidBase());
-struct dyString *body = tokenExchangeBody(OAUTH_PROVIDER_ORCID, code, redirectUri);
-struct jsonElement *tok = postForm(tokenUrl, body->string);
-dyStringFree(&body);
-if (tok == NULL)
+*retEmail = NULL;
+*retVerified = FALSE;
+struct jsonElement *emails = httpGetJson("https://api.github.com/user/emails", accessToken);
+if (emails == NULL)
+    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))
+        {
+        *retEmail = cloneString(jsonOptionalStringField(el, "email", NULL));
+        *retVerified = jsonOptionalBooleanField(el, "verified", FALSE);
+        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;
-char *orcid = jsonOptionalStringField(tok, "orcid", NULL);
-if (isEmpty(orcid))
+struct jsonElement *info = httpGetJson(p->userinfoUrl, accessToken);
+if (info == NULL)
     return NULL;
+struct jsonElement *idEl = jsonFindNamedField(info, "", "id");
+if (idEl == NULL)
+    return NULL;
+char subject[64];
+safef(subject, sizeof(subject), "%lld", (long long)jsonNumberVal(idEl, "id"));
 
 struct oauthIdentity *id;
 AllocVar(id);
-id->provider = cloneString(OAUTH_PROVIDER_ORCID);
-id->subject = cloneString(orcid);
-id->email = NULL;
-id->emailVerified = FALSE;
-id->displayName = cloneString(jsonOptionalStringField(tok, "name", NULL));
+id->provider = cloneString(p->name);
+id->subject = cloneString(subject);
+id->displayName = cloneString(jsonOptionalStringField(info, "name", NULL));
+if (isEmpty(id->displayName))
+    id->displayName = cloneString(jsonOptionalStringField(info, "login", NULL));
+githubBestEmail(accessToken, &id->email, &id->emailVerified);
 return id;
 }
 
-struct oauthIdentity *oauthFetchIdentity(char *provider, char *code, char *redirectUri)
+struct oauthIdentity *oauthFetchIdentity(char *name, char *code, char *redirectUri)
 /* Exchange code for tokens and fetch the authenticated identity, or NULL on any failure. */
 {
-if (isEmpty(code) || !oauthProviderEnabled(provider))
+struct oauthProvider *p = providerByName(name);
+if (p == NULL || isEmpty(code))
     return NULL;
-if (sameString(provider, OAUTH_PROVIDER_GOOGLE))
-    return googleFetch(code, redirectUri);
-if (sameString(provider, OAUTH_PROVIDER_ORCID))
-    return orcidFetch(code, redirectUri);
+ensureEndpoints(p);
+if (isEmpty(p->tokenUrl) || isEmpty(p->userinfoUrl))
     return NULL;
+if (sameWord(p->type, "github"))
+    return githubFetch(p, code, redirectUri);
+return oidcFetch(p, code, redirectUri);
 }
 
 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);
     }
 }