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); } }