Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

GNIP 94: Generic and pluggable OIDC SocialAccount Provider for GeoNode #11185

Closed
3 of 5 tasks
afabiani opened this issue Jun 21, 2023 · 5 comments
Closed
3 of 5 tasks
Assignees
Labels
gnip A GeoNodeImprovementProcess Issue security Pull requests that address a security vulnerability

Comments

@afabiani
Copy link
Member

afabiani commented Jun 21, 2023

GNIP 94: Generic and pluggable OIDC SocialAccount Provider for GeoNode

Overview

Currently GeoNode provides by default 2 quite old and outdated SOCIALACCOUNT providers, LinkedIn and Facebook, which are based on OAuth2 and make use of very old plugins.

Today the reference protocol is OIDC, and GeoNode users increasingly need to be able to link their accounts to providers that support and implement this technology. Two of the most commonly used providers are Google and Microsoft Azure, for example.

The purpose of this proposal is to revise the SocialAccount and SocialProvider classes in GeoNode in order to not only make them capable of handling the more modern OIDC protocol but also to take advantage of some of its features, such as extracting user information from the id_token after validating its origin, among others.

Furthermore, a significant benefit of this technology is that the protocol is now a widely respected standard among all providers. This allows us to create dynamic, modular, and scalable structures that enable GeoNode to handle almost all use cases through a few configuration parameters.

Proposed By

@afabiani

Assigned to Release

This proposal is for GeoNode 4.1.2+.

State

  • Under Discussion
  • In Progress
  • Completed
  • Rejected
  • Deferred

Proposal

The geonode_openid_connect social account provider

This will be the generic class which will allow us handling the OIDC social login

class GenericOpenIDConnectProvider(OAuth2Provider):
    id = "geonode_openid_connect"
    name = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("NAME", "GeoNode OpenIDConnect")
    account_class = import_class_module(
        getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
        .get(PROVIDER_ID, {})
        .get(
            "ACCOUNT_CLASS",
            "geonode.people.socialaccount.providers.geonode_openid_connect.provider.GenericOpenIDConnectProviderAccount",
        )
    )

    def get_default_scope(self):
        scope = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("SCOPE", "")
        return scope

    def get_auth_params(self, request, action):
        ret = super(GenericOpenIDConnectProvider, self).get_auth_params(request, action)
        if action == AuthAction.REAUTHENTICATE:
            ret["prompt"] = (
                getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
                .get(PROVIDER_ID, {})
                .get("AUTH_PARAMS", {})
                .get("prompt", "")
            )
        return ret

    def extract_uid(self, data):
        return data.get("sub", data.get("id"))

    def extract_common_fields(self, data):
        _common_fields = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("COMMON_FIELDS", {})
        return _common_fields

    def extract_email_addresses(self, data):
        addresses = []
        email = data.get("email")
        if email:
            addresses.append(
                EmailAddress(
                    email=email,
                    verified=data.get("email_verified", False),
                    primary=True,
                )
            )
        return addresses

Notice that the class is fully customizable, from the name, the account class to the common fields that we would like to extract from the JSON user-info response.

All those parameters can be driver by the GeoNode settings

A fully pluggable Provider configuration

From the settings point of view, configuring the provider will simply require to fill the common customizable properties of the OIDC authority.

As an instance, the following ones are two sample configurations for Google and Microsofr Azure

INSTALLED_APPS += ("geonode.people.socialaccount.providers.geonode_openid_connect",)

_AZURE_TENANT_ID = os.getenv("MICROSOFT_TENANT_ID", "")
_AZURE_SOCIALACCOUNT_PROVIDER = {
    "NAME": "Microsoft Azure",
    "SCOPE": [
        "User.Read",
        "openid",
    ],
    "AUTH_PARAMS": {
        "access_type": "online",
        "prompt": "select_account",
    },
    "COMMON_FIELDS": {"email": "mail", "last_name": "surname", "first_name": "givenName"},
    "ACCOUNT_CLASS": "allauth.socialaccount.providers.azure.provider.AzureAccount",
    "ACCESS_TOKEN_URL": f"https://login.microsoftonline.com/{_AZURE_TENANT_ID}/oauth2/v2.0/token",
    "AUTHORIZE_URL": f"https://login.microsoftonline.com/{_AZURE_TENANT_ID}/oauth2/v2.0/authorize",
    "PROFILE_URL": "https://graph.microsoft.com/v1.0/me",
}

_GOOGLE_SOCIALACCOUNT_PROVIDER = {
    "NAME": "Google",
    "SCOPE": [
        "profile",
        "email",
    ],
    "AUTH_PARAMS": {
        "access_type": "online",
        "prompt": "select_account consent",
    },
    "COMMON_FIELDS": {"email": "email", "last_name": "family_name", "first_name": "given_name"},
    "ACCOUNT_CLASS": "allauth.socialaccount.providers.google.provider.GoogleAccount",
    "ACCESS_TOKEN_URL": "https://oauth2.googleapis.com/token",
    "AUTHORIZE_URL": "https://accounts.google.com/o/oauth2/v2/auth",
    "ID_TOKEN_ISSUER": "https://accounts.google.com",
    "OAUTH_PKCE_ENABLED": True,
}

SOCIALACCOUNT_PROVIDERS = {
    "geonode_openid_connect": _AZURE_SOCIALACCOUNT_PROVIDER,
}

By updating the SOCIALACCOUNT_PROVIDERS dictionary you can easily switch from a provider to another.

Notice how their configuration is mostly the same. The only values the user should provide are the correct OIDC endpoints specific to the authority we would like to use.

The default AccountAdapter

Within the GeoNode classes will be available also a generic account provider which is defined like this

SOCIALACCOUNT_ADAPTER = os.environ.get("SOCIALACCOUNT_ADAPTER", "geonode.people.adapters.GenericOpenIDConnectAdapter")

SOCIALACCOUNT_PROFILE_EXTRACTORS = {
    "geonode_openid_connect": "geonode.people.profileextractors.OpenIDExtractor",
}

The GenericOpenIDConnectAdapter extends few common login methods which will allow us to seemlessly extract the user information both from the UserInfoURI and the IDToken

PROVIDER_ID = getattr(settings, "SOCIALACCOUNT_OIDC_PROVIDER", "geonode_openid_connect")

ACCESS_TOKEN_URL = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("ACCESS_TOKEN_URL", "")

AUTHORIZE_URL = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("AUTHORIZE_URL", "")

PROFILE_URL = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("PROFILE_URL", "")

ID_TOKEN_ISSUER = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {}).get(PROVIDER_ID, {}).get("ID_TOKEN_ISSUER", "")


class GenericOpenIDConnectAdapter(OAuth2Adapter, SocialAccountAdapter):
    provider_id = PROVIDER_ID
    access_token_url = ACCESS_TOKEN_URL
    authorize_url = AUTHORIZE_URL
    profile_url = PROFILE_URL
    id_token_issuer = ID_TOKEN_ISSUER

    def complete_login(self, request, app, token, response, **kwargs):
        extra_data = {}
        if self.profile_url:
            headers = {"Authorization": "Bearer {0}".format(token.token)}
            resp = requests.get(self.profile_url, headers=headers)
            profile_data = resp.json()
            extra_data.update(profile_data)
        elif "id_token" in response:
            try:
                extra_data = jwt.decode(
                    response["id_token"],
                    # Since the token was received by direct communication
                    # protected by TLS between this library and Google, we
                    # are allowed to skip checking the token signature
                    # according to the OpenID Connect Core 1.0
                    # specification.
                    # https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation
                    options={
                        "verify_signature": False,
                        "verify_iss": True,
                        "verify_aud": True,
                        "verify_exp": True,
                    },
                    issuer=self.id_token_issuer,
                    audience=app.client_id,
                )
            except jwt.PyJWTError as e:
                raise OAuth2Error("Invalid id_token") from e
        login = self.get_provider().sociallogin_from_response(request, extra_data)
        return login

    def save_user(self, request, sociallogin, form=None):
        user = super(SocialAccountAdapter, self).save_user(request, sociallogin, form=form)
        extractor = get_data_extractor(sociallogin.account.provider)
        try:
            groups = extractor.extract_groups(sociallogin.account.extra_data) or extractor.extract_roles(
                sociallogin.account.extra_data
            )
            # check here if user is member already of other groups and remove it form the ones that are not declared here...
            for groupprofile in user.group_list_all():
                groupprofile.leave(user)
            for group_name in groups:
                groupprofile = GroupProfile.objects.filter(slug=group_name).first()
                if groupprofile:
                    groupprofile.join(user)
        except (AttributeError, NotImplementedError):
            pass  # extractor doesn't define a method for extracting field
        return user

The complete_login method will check whether we want to extract the user extra-info Json data from the id_token or from the user-info endpoint.

The save_user method will extract the commond fields from the Json data by making a mapping between the Json properties and the GeoNode UserProfile model.

Moreover it will check if the returned data declares some Groups or Roles the user belongs to. In that case it will check if the corresponding GroupProfile exists already on the GeoNode database and automatically assign the user to it.

Backwards Compatibility

No backwards compatibility.

Future evolution

Add more sample configurations for the most popular OIDC providers.

Feedback

  • None yet.

Voting

Project Steering Committee:

  • Alessio Fabiani:
  • Francesco Bartoli:
  • Giovanni Allegri: +1
  • Toni Schoenbuchner: +1
  • Florian Hoedt: +1

Links

Remove unused links below.

@afabiani afabiani added gnip A GeoNodeImprovementProcess Issue security Pull requests that address a security vulnerability labels Jun 21, 2023
@afabiani afabiani self-assigned this Jun 21, 2023
@gannebamm
Copy link
Contributor

Science view here: Will this enable a simple configuration of ORCID as an identity provider? See: https://info.orcid.org/orcid-openid-connect-and-implicit-authentication/

It seems like it is.

@afabiani
Copy link
Member Author

@gannebamm in theory yes

@phardy-egis
Copy link

phardy-egis commented Jun 22, 2023

Will this enable the possiblity of plugging Geonode to an authentication provider such as keycloak ?

@afabiani
Copy link
Member Author

Will this enable the possiblity of plugging Geonode to an authentication provider such as keycloak ?

If you use it as an OIDC provider yes.

@giohappy giohappy changed the title GNIP-93: Generic and pluggable OIDC SocialAccount Provider for GeoNode GNIP-94: Generic and pluggable OIDC SocialAccount Provider for GeoNode Jul 4, 2023
@giohappy giohappy changed the title GNIP-94: Generic and pluggable OIDC SocialAccount Provider for GeoNode GNIP 94: Generic and pluggable OIDC SocialAccount Provider for GeoNode Jul 4, 2023
@afabiani
Copy link
Member Author

Documentation available here: GeoNode/documentation#277

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
gnip A GeoNodeImprovementProcess Issue security Pull requests that address a security vulnerability
Projects
None yet
Development

No branches or pull requests

3 participants