Skip to content

Commit

Permalink
Merge pull request #1361 from research-software-directory/1359-linked…
Browse files Browse the repository at this point in the history
…in-auth

1359 linkedin auth
  • Loading branch information
ewan-escience authored Jan 10, 2025
2 parents a9bb6fd + 11e9852 commit 38ad159
Show file tree
Hide file tree
Showing 13 changed files with 286 additions and 84 deletions.
61 changes: 38 additions & 23 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,10 @@ RSD_ENVIRONMENT=prod
# consumed by services: authentication, frontend (api/fe)
# provide a list of supported OpenID auth providers
# the values should be separated by semicolon (;)
# Allowed values are: SURFCONEXT, HELMHOLTZID, ORCID or LOCAL
# Allowed values are: SURFCONEXT, HELMHOLTZID, ORCID, AZURE, LINKEDIN or LOCAL
# if env value is not provided default provider is set to be SURFCONEXT
# if you add the value "LOCAL", then local accounts are enabled, USE THIS FOR TESTING PURPOSES ONLY
RSD_AUTH_PROVIDERS=SURFCONEXT;ORCID;AZURE;LOCAL
RSD_AUTH_PROVIDERS=SURFCONEXT;ORCID;AZURE;LINKEDIN;LOCAL

# consumed by services: authentication, frontend (api/fe)
# provide a list of supported OpenID auth providers for coupling with the user's RSD account
Expand All @@ -91,27 +91,27 @@ RSD_AUTH_COUPLE_PROVIDERS=ORCID
#[email protected];[email protected]

# SURFCONEXT - TEST ENVIRONMENT
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
SURFCONEXT_CLIENT_ID=www.research-software.nl
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
SURFCONEXT_REDIRECT=http://localhost/auth/login/surfconext
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
SURFCONEXT_WELL_KNOWN_URL=https://connect.test.surfconext.nl/.well-known/openid-configuration
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
SURFCONEXT_SCOPES=openid
# consumed by: frontend/utils/loginHelpers
# consumed by: frontend/pages/api/fe/auth/
SURFCONEXT_RESPONSE_MODE=form_post

# Helmholtz ID
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
HELMHOLTZID_CLIENT_ID=rsd-dev
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
HELMHOLTZID_REDIRECT=http://localhost/auth/login/HELMHOLTZID
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
HELMHOLTZID_WELL_KNOWN_URL=https://login-dev.helmholtz.de/oauth2/.well-known/openid-configuration
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
HELMHOLTZID_SCOPES=openid+profile+email+eduperson_principal_name
# consumed by: frontend/utils/loginHelpers
# consumed by: frontend/pages/api/fe/auth/
HELMHOLTZID_RESPONSE_MODE=query
# consumed by: authentication
# uncomment if you want to allow users from non-Helmholtz centres or social IdPs:
Expand All @@ -123,29 +123,29 @@ HELMHOLTZID_RESPONSE_MODE=query
# HELMHOLTZID_ALLOW_LIST=

# ORCID
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
ORCID_CLIENT_ID=APP-4D4D69ASWTYOI9QI
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
ORCID_REDIRECT=http://www.localhost/auth/login/orcid
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
ORCID_REDIRECT_COUPLE=http://www.localhost/auth/couple/orcid
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
ORCID_WELL_KNOWN_URL=https://sandbox.orcid.org/.well-known/openid-configuration
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
ORCID_SCOPES=openid
# consumed by: frontend/utils/loginHelpers
# consumed by: frontend/pages/api/fe/auth/
ORCID_RESPONSE_MODE=query

# AZURE ACTIVE DIRECTORY
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
AZURE_CLIENT_ID=
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
AZURE_REDIRECT=http://localhost/auth/login/azure
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
AZURE_WELL_KNOWN_URL=
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
AZURE_SCOPES=openid+email+profile
# consumed by: authentication, frontend/utils/loginHelpers
# consumed by: authentication, frontend/pages/api/fe/auth/
AZURE_LOGIN_PROMPT=select_account
# consumed by: frontend
# the name displayed to users when multiple providers are configured
Expand All @@ -157,6 +157,17 @@ AZURE_DESCRIPTION_HTML="Sign in with your institutional credentials"
# the organisation recorded for users logged in via this provider
AZURE_ORGANISATION=

# LINKEDIN
# consumed by: authentication, frontend/pages/api/fe/auth/
LINKEDIN_CLIENT_ID=
# consumed by: authentication, frontend/pages/api/fe/auth/
LINKEDIN_REDIRECT=http://localhost/auth/login/linkedin
# consumed by: authentication, frontend/pages/api/fe/auth/
LINKEDIN_WELL_KNOWN_URL=https://www.linkedin.com/oauth/.well-known/openid-configuration


# ---- PUBLIC SCRAPER ENV VARIABLES -------------

# max requests to the GitHub API per run, runs 10 times per hour
# optional, comment out if not available, a default of 6 will be used
# consumed by: scrapers
Expand Down Expand Up @@ -211,6 +222,10 @@ AUTH_ORCID_CLIENT_SECRET=
# consumed by services: authentication
AUTH_AZURE_CLIENT_SECRET=

# LinkedIn
# consumed by services: authentication
AUTH_LINKEDIN_CLIENT_SECRET=

# consumed by: scrapers
# optional, comment out if not available, should be of the form username:token
# obtain the secret from GITHUB dashboard
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-FileCopyrightText: 2022 - 2024 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2022 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
// SPDX-FileCopyrightText: 2022 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 - 2025 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2022 - 2025 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ) <[email protected]>
// SPDX-FileCopyrightText: 2022 dv4all
Expand All @@ -26,10 +26,10 @@ private Config() {

private static Collection<String> rsdAuthCoupleProviders() {
return Optional.ofNullable(System.getenv("RSD_AUTH_COUPLE_PROVIDERS"))
.map(String::toUpperCase)
.map(s -> s.split(";"))
.map(Set::of)
.orElse(Collections.emptySet());
.map(String::toUpperCase)
.map(s -> s.split(";"))
.map(Set::of)
.orElse(Collections.emptySet());
}

public static boolean isDevEnv() {
Expand All @@ -42,10 +42,10 @@ public static boolean isDevEnv() {

private static Collection<String> rsdLoginProviders() {
return Optional.ofNullable(System.getenv("RSD_AUTH_PROVIDERS"))
.map(String::toUpperCase)
.map(s -> s.split(";"))
.map(Set::of)
.orElse(Collections.emptySet());
.map(String::toUpperCase)
.map(s -> s.split(";"))
.map(Set::of)
.orElse(Collections.emptySet());
}

public static boolean isLocalLoginEnabled() {
Expand Down Expand Up @@ -73,6 +73,10 @@ public static boolean isAzureLoginEnabled() {
return rsdLoginProviders().contains("AZURE");
}

public static boolean isLinkedinLoginEnabled() {
return rsdLoginProviders().contains("LINKEDIN");
}

public static String userMailWhitelist() {
return System.getenv("RSD_AUTH_USER_MAIL_WHITELIST");
}
Expand All @@ -99,10 +103,6 @@ public static String surfconextClientSecret() {
return System.getenv("AUTH_SURFCONEXT_CLIENT_SECRET");
}

public static String surfconextScopes() {
return System.getenv("SURFCONEXT_SCOPES");
}


// Helmholtz ID
public static String helmholtzIdRedirect() {
Expand All @@ -127,7 +127,7 @@ public static String helmholtzIdScopes() {

public static boolean helmholtzIdAllowExternalUsers() {
return Boolean.parseBoolean(
System.getenv("HELMHOLTZID_ALLOW_EXTERNAL_USERS")
System.getenv("HELMHOLTZID_ALLOW_EXTERNAL_USERS")
);
}

Expand All @@ -137,7 +137,7 @@ public static String helmholtzIdAllowList() {

public static boolean helmholtzIdUseAllowList() {
return Boolean.parseBoolean(
System.getenv("HELMHOLTZID_USE_ALLOW_LIST")
System.getenv("HELMHOLTZID_USE_ALLOW_LIST")
);
}

Expand All @@ -163,9 +163,6 @@ public static String orcidClientSecret() {
return System.getenv("AUTH_ORCID_CLIENT_SECRET");
}

public static String orcidScopes() {
return System.getenv("ORCID_SCOPES");
}

// Azure Active Directory
public static String azureRedirect() {
Expand All @@ -184,11 +181,25 @@ public static String azureClientSecret() {
return System.getenv("AUTH_AZURE_CLIENT_SECRET");
}

public static String azureScopes() {
return System.getenv("AZURE_SCOPES");
}

public static String azureOrganisation() {
return System.getenv("AZURE_ORGANISATION");
}


// LinkedIn
public static String linkedinRedirect() {
return System.getenv("LINKEDIN_REDIRECT");
}

public static String linkedinClientId() {
return System.getenv("LINKEDIN_CLIENT_ID");
}

public static String linkedinWellknown() {
return System.getenv("LINKEDIN_WELL_KNOWN_URL");
}

public static String linkedinClientSecret() {
return System.getenv("AUTH_LINKEDIN_CLIENT_SECRET");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// SPDX-FileCopyrightText: 2025 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2025 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

package nl.esciencecenter.rsd.authentication;

import com.auth0.jwt.JWT;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.google.gson.JsonParser;

import java.io.IOException;
import java.net.URI;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

public class LinkedinLogin implements Login {

private final String code;
private final String redirectUrl;

public LinkedinLogin(String code, String redirectUrl) {
this.code = Objects.requireNonNull(code);
this.redirectUrl = Objects.requireNonNull(redirectUrl);
}

@Override
public OpenIdInfo openidInfo() throws IOException, InterruptedException, RsdResponseException {
Map<String, String> form = createForm();
String tokenResponse = getTokensFromLinkedin(form);
String idToken = extractIdToken(tokenResponse);

DecodedJWT idJwt = JWT.decode(idToken);
String subject = idJwt.getSubject();
String name = idJwt.getClaim("name").asString();
if (name == null) {
String givenName = idJwt.getClaim("given_name").asString();
String familyName = idJwt.getClaim("family_name").asString();
if (givenName != null && familyName != null) name = givenName + " " + familyName;
else if (familyName != null) name = familyName;
else if (givenName != null) name = givenName;
}
String email = idJwt.getClaim("email").asString();
Map<String, List<String>> emptyData = Collections.emptyMap();
return new OpenIdInfo(subject, name, email, null, emptyData);
}

private Map<String, String> createForm() {
Map<String, String> form = new HashMap<>();
form.put("code", code);
form.put("grant_type", "authorization_code");
form.put("redirect_uri", redirectUrl);
form.put("client_id", Config.linkedinClientId());
form.put("client_secret", Config.linkedinClientSecret());
return form;
}

private String getTokensFromLinkedin(Map<String, String> form) throws IOException, InterruptedException, RsdResponseException {
URI tokenEndpoint = Utils.getTokenUrlFromWellKnownUrl(URI.create(Config.linkedinWellknown()));
return Utils.postForm(tokenEndpoint, form);
}

private String extractIdToken(String response) {
return JsonParser.parseString(response).getAsJsonObject().getAsJsonPrimitive("id_token").getAsString();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-FileCopyrightText: 2021 - 2024 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2021 - 2024 Netherlands eScience Center
// SPDX-FileCopyrightText: 2021 - 2025 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2021 - 2025 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 - 2024 Helmholtz Centre Potsdam - GFZ German Research Centre for Geosciences
// SPDX-FileCopyrightText: 2022 Dusan Mijatovic (dv4all)
// SPDX-FileCopyrightText: 2022 Matthias Rüster (GFZ) <[email protected]>
Expand All @@ -14,6 +14,7 @@
import com.auth0.jwt.interfaces.DecodedJWT;
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.HttpStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -185,6 +186,16 @@ public static void main(String[] args) {
});
}

if (Config.isLinkedinLoginEnabled()) {
app.get("login/linkedin", ctx -> {
String code = ctx.queryParam("code");
String redirectUrl = Config.linkedinRedirect();
OpenIdInfo linkedinInfo = new LinkedinLogin(code, redirectUrl).openidInfo();
AccountInfo accountInfo = new PostgrestAccount().account(linkedinInfo, OpenidProvider.linkedin);
createAndSetToken(ctx, accountInfo);
});
}

app.get("/refresh", ctx -> {
try {
String tokenToVerify = ctx.cookie("rsd_token");
Expand All @@ -210,19 +221,19 @@ public static void main(String[] args) {

app.exception(RsdAuthenticationException.class, (ex, ctx) -> {
setLoginFailureCookie(ctx, ex.getMessage());
ctx.redirect(LOGIN_FAILED_PATH);
ctx.redirect(LOGIN_FAILED_PATH, HttpStatus.SEE_OTHER);
});

app.exception(RuntimeException.class, (ex, ctx) -> {
LOGGER.error("RuntimeException", ex);
setLoginFailureCookie(ctx, "Something unexpected went wrong, please try again or contact us.");
ctx.redirect(LOGIN_FAILED_PATH);
ctx.redirect(LOGIN_FAILED_PATH, HttpStatus.SEE_OTHER);
});

app.exception(Exception.class, (ex, ctx) -> {
LOGGER.error("Exception", ex);
setLoginFailureCookie(ctx, "Something unexpected went wrong, please try again or contact us.");
ctx.redirect(LOGIN_FAILED_PATH);
ctx.redirect(LOGIN_FAILED_PATH, HttpStatus.SEE_OTHER);
});
}

Expand All @@ -245,9 +256,9 @@ static void setRedirectFromCookie(Context ctx) {
String returnPath = ctx.cookie("rsd_pathname");
if (returnPath != null && !returnPath.isBlank()) {
returnPath = returnPath.trim();
ctx.redirect(returnPath);
ctx.redirect(returnPath, HttpStatus.SEE_OTHER);
} else {
ctx.redirect("/");
ctx.redirect("/", HttpStatus.SEE_OTHER);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-FileCopyrightText: 2022 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2022 Netherlands eScience Center
// SPDX-FileCopyrightText: 2022 - 2025 Ewan Cahen (Netherlands eScience Center) <[email protected]>
// SPDX-FileCopyrightText: 2022 - 2025 Netherlands eScience Center
//
// SPDX-License-Identifier: Apache-2.0

Expand All @@ -10,5 +10,6 @@ public enum OpenidProvider {
surfconext,
helmholtz,
orcid,
azure
azure,
linkedin
}
Loading

0 comments on commit 38ad159

Please sign in to comment.