4b04bd017d7a36d460447d8552ecaf9c8a33db4a max Tue Aug 4 02:51:56 2026 -0700 hgLogin: gate email-link sign-in and change-email behind login.emailLink (default off), refs #37929 Also: GitHub/OIDC token-format and robustness fixes, signed pending-identity to close an account-takeover hole in the OAuth account chooser, account chooser for the email-link flow, idx-based chooser to avoid a utf8/latin1 collation error on non-ASCII usernames, and login/signup page UI polish (consistent buttons, fonts, cache-busted stylesheet, forgot links, wording). refs #37984 diff --git src/hg/hgLogin/oauthLogin.c src/hg/hgLogin/oauthLogin.c index 3056ae9c949..801cf1b9d52 100644 --- src/hg/hgLogin/oauthLogin.c +++ src/hg/hgLogin/oauthLogin.c @@ -244,43 +244,57 @@ 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. */ +static char *formValue(char *body, char *name) +/* Return the URL-decoded value of name in an x-www-form-urlencoded body, or NULL. Allocd. */ { -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; +char *dupe = cloneString(body); +char *result = NULL; +int n = countChars(dupe, '&') + 1; +char **pairs; +AllocArray(pairs, n); +n = chopByChar(dupe, '&', pairs, n); +int i; +for (i = 0; i < n; i++) + { + char *eq = strchr(pairs[i], '='); + if (eq == NULL) + continue; + *eq = '\0'; + if (sameString(pairs[i], name)) + { + char *val = eq + 1; + cgiDecode(val, val, strlen(val)); + result = cloneString(val); + break; + } + } +freeMem(pairs); +freeMem(dupe); +return result; } 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) @@ -314,79 +328,110 @@ } 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(p->clientId)); dyStringPrintf(body, "&client_secret=%s", cgiEncode(p->clientSecret)); dyStringPrintf(body, "&redirect_uri=%s", cgiEncode(redirectUri)); return body; } static char *tokenExchange(struct oauthProvider *p, char *code, char *redirectUri) -/* Run the code->token exchange and return the access_token, or NULL. */ +/* Run the code->token exchange and return the access_token, or NULL. The response may be + * JSON (Google, ORCID) or x-www-form-urlencoded (GitHub's default), so try both. */ { -struct dyString *body = tokenExchangeBody(p, code, redirectUri); -struct jsonElement *tok = postForm(p->tokenUrl, body->string); -dyStringFree(&body); -if (tok == NULL) +struct dyString *reqBody = tokenExchangeBody(p, code, redirectUri); +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)reqBody->stringSize); +char *resp = httpRequest(p->tokenUrl, "POST", header->string, reqBody->string); +dyStringFree(&header); +dyStringFree(&reqBody); +if (isEmpty(resp)) return NULL; -return cloneString(jsonOptionalStringField(tok, "access_token", NULL)); +char *access = NULL; +struct jsonElement *tok = jsonParseSafe(resp); +if (tok != NULL) + access = cloneString(jsonOptionalStringField(tok, "access_token", NULL)); +if (isEmpty(access)) + access = formValue(resp, "access_token"); +freeMem(resp); +return access; +} + +static boolean jsonFieldIsTrue(struct jsonElement *obj, char *field) +/* Read a boolean-ish field tolerantly: a JSON boolean true, or the string "true"/"1". + * The OIDC spec says email_verified is a boolean, but some providers send it as a string; + * jsonOptionalBooleanField would abort on that, so read the type ourselves. */ +{ +struct jsonElement *el = jsonFindNamedField(obj, "", field); +if (el == NULL) + return FALSE; +if (el->type == jsonBoolean) + return el->val.jeBoolean; +if (el->type == jsonString) + return sameWord(el->val.jeString, "true") || sameString(el->val.jeString, "1"); +return FALSE; } 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; 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(p->name); id->subject = cloneString(sub); id->email = cloneString(jsonOptionalStringField(info, "email", NULL)); -id->emailVerified = jsonOptionalBooleanField(info, "email_verified", FALSE); +id->emailVerified = jsonFieldIsTrue(info, "email_verified"); id->displayName = cloneString(jsonOptionalStringField(info, "name", NULL)); return id; } static void githubBestEmail(char *accessToken, char **retEmail, boolean *retVerified) /* Query GitHub's /user/emails and return the primary verified email, if any. */ { *retEmail = NULL; *retVerified = FALSE; struct jsonElement *emails = httpGetJson("https://api.github.com/user/emails", accessToken); -if (emails == NULL) +if (emails == NULL || emails->type != jsonList) + // GitHub returns an object (not an array) on error, e.g. a token without the user:email + // scope. Treat that as "no email available" and let login proceed without one. 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)) + if (jsonFieldIsTrue(el, "primary")) { *retEmail = cloneString(jsonOptionalStringField(el, "email", NULL)); - *retVerified = jsonOptionalBooleanField(el, "verified", FALSE); + *retVerified = jsonFieldIsTrue(el, "verified"); 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; struct jsonElement *info = httpGetJson(p->userinfoUrl, accessToken); if (info == NULL) return NULL; @@ -404,33 +449,50 @@ if (isEmpty(id->displayName)) id->displayName = cloneString(jsonOptionalStringField(info, "login", NULL)); githubBestEmail(accessToken, &id->email, &id->emailVerified); return id; } struct oauthIdentity *oauthFetchIdentity(char *name, char *code, char *redirectUri) /* Exchange code for tokens and fetch the authenticated identity, or NULL on any failure. */ { struct oauthProvider *p = providerByName(name); if (p == NULL || isEmpty(code)) return NULL; ensureEndpoints(p); if (isEmpty(p->tokenUrl) || isEmpty(p->userinfoUrl)) return NULL; +/* Catch any errAbort raised while reading unexpected JSON shapes from the provider (the + * jsonXxxVal accessors abort on a type mismatch), so a misbehaving provider yields a clean + * "login failed" rather than an error page. */ +struct oauthIdentity *id = NULL; +struct errCatch *errCatch = errCatchNew(); +if (errCatchStart(errCatch)) + { if (sameWord(p->type, "github")) - return githubFetch(p, code, redirectUri); -return oidcFetch(p, code, redirectUri); + 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); +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); } }