5f7a14fde8cdb24b01fc0e5eadba145bfd9ecb04 max Sat Jul 25 23:16:06 2026 -0700 hgLogin: social login (Google/ORCID), email login link, change email. refs #37929 diff --git src/hg/hgLogin/oauthLogin.c src/hg/hgLogin/oauthLogin.c new file mode 100644 index 00000000000..d3843ba01e6 --- /dev/null +++ src/hg/hgLogin/oauthLogin.c @@ -0,0 +1,237 @@ +/* oauthLogin - social login (Google, ORCID) 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. */ +{ +char name[128]; +safef(name, sizeof(name), "login.%s.%s", provider, field); +return cfgOption(name); +} + +static char *orcidBase() +/* Return the ORCID base URL, honoring login.orcid.sandbox for testing. */ +{ +if (cfgOptionBooleanDefault("login.orcid.sandbox", FALSE)) + return "https://sandbox.orcid.org"; +return "https://orcid.org"; +} + +boolean oauthProviderEnabled(char *provider) +/* Return TRUE if both clientId and clientSecret for provider are set in hg.conf. */ +{ +if (isEmpty(provider)) + return FALSE; +return isNotEmpty(oauthCfg(provider, "clientId")) && isNotEmpty(oauthCfg(provider, "clientSecret")); +} + +boolean oauthAnyProviderEnabled() +/* Return TRUE if at least one social login provider is configured. */ +{ +return oauthProviderEnabled(OAUTH_PROVIDER_GOOGLE) || oauthProviderEnabled(OAUTH_PROVIDER_ORCID); +} + +char *oauthLoginUrl(char *provider, char *redirectUri, char *state) +/* Return the provider's authorization-endpoint URL to redirect the browser to, or NULL. */ +{ +if (!oauthProviderEnabled(provider)) + return NULL; +char *clientId = oauthCfg(provider, "clientId"); +struct dyString *dy = dyStringNew(512); +if (sameString(provider, OAUTH_PROVIDER_GOOGLE)) + { + 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"); + } +else if (sameString(provider, OAUTH_PROVIDER_ORCID)) + { + dyStringPrintf(dy, "%s/oauth/authorize?response_type=code", orcidBase()); + dyStringPrintf(dy, "&scope=%s", cgiEncode("openid")); + } +else + { + dyStringFree(&dy); + 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); +} + +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. */ +{ +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); + } + } +errCatchEnd(errCatch); +if (errCatch->gotError) + { + fprintf(stderr, "hgLogin oauth: request to %s failed: %s\n", url, errCatch->message->string); + result = NULL; + } +errCatchFree(&errCatch); +return result; +} + +static struct jsonElement *jsonParseSafe(char *text) +/* 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 *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, "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. */ +{ +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, "&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. */ +{ +struct dyString *body = tokenExchangeBody(OAUTH_PROVIDER_GOOGLE, code, redirectUri); +struct jsonElement *tok = postForm("https://oauth2.googleapis.com/token", body->string); +dyStringFree(&body); +if (tok == NULL) + return NULL; +char *accessToken = jsonOptionalStringField(tok, "access_token", NULL); +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); +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->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. */ +{ +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) + return NULL; +char *orcid = jsonOptionalStringField(tok, "orcid", NULL); +if (isEmpty(orcid)) + return NULL; + +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)); +return id; +} + +struct oauthIdentity *oauthFetchIdentity(char *provider, char *code, char *redirectUri) +/* Exchange code for tokens and fetch the authenticated identity, or NULL on any failure. */ +{ +if (isEmpty(code) || !oauthProviderEnabled(provider)) + return NULL; +if (sameString(provider, OAUTH_PROVIDER_GOOGLE)) + return googleFetch(code, redirectUri); +if (sameString(provider, OAUTH_PROVIDER_ORCID)) + return orcidFetch(code, redirectUri); +return NULL; +} + +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); + } +}