Repository: lnbits/lnbits

Issue:
[Feature request] Allow custom OIDC Providers for SSO
**Is your feature request related to a problem? Please describe.**
LNBits authentication has improved a lot after https://github.com/lnbits/lnbits/pull/2092. But, if an LNBits admin doesn't want to rely on native user and password, their only other options are relying on Github or Google as authentication providers. While that's definitely very user friendly for many people, it makes LNBits instances rely on Microsoft and Google. Not the most censorship resistant setup.

PR #2092 made the effort of implementing [OpenID Connect](https://openid.net/) (OIDC) integration for Github and Google as providers. Things could be taken one step further to allow for open source, self-hosted OIDC authentication providers like [Authentik](https://goauthentik.io/), [Keycloak](https://www.keycloak.org/) or [Authelia](https://www.authelia.com/).

In doing so, we would enable LNBits instance admins to easily use existing open source authentication providers to manage the authentication of their LNBits users with enterprise grade SSO. Communities with existing SSO solutions could also easily adopt LNBits and provide access to their users by simply integrating with their existing SSO provider.

For example, a small local community could provide users with LNBits accounts, Nextcloud accounts, a wiki and Matrix communication, while managing the authentication of all its users in a central service like Authentik.

**Describe the solution you'd like**
- An extension of the existing code for Google and Github SSO to allow custom OIDC providers.
- New environment variables in the `.env.example` file to allow LNBits instance admins to integrate with a custom provider by simply providing the required OIDC parameters (FQDN, client id, client secret, etc).
- Documentation to guide LNBits instance runners through integrating with popular OIDC providers like [Authentik](https://goauthentik.io/), [Keycloak](https://www.keycloak.org/) or [Authelia](https://www.authelia.com/).

**Describe alternatives you've considered**
NA.

**Additional context**
I currently lack the time and knowledge to get this implemented, but might be able to throw in some effort from March onward if it's still not implemented. If someone gives it a shot at implementing, I would be happy to help with testing and documenting the final solution in combination with Authentik.



Discussion:
+1 on Keycloak, am currently looking into adding that capability to LNbits as well
I'm no expert in auth protocols, but from what I see in the code, I think it could easily be generalized to any OIDC provider. That should cover most popular self-hosted SSO solutions AFAIK.
I will look into it next week. @motorina0 wanted to drop some of his implementation advice first.

Resolving patch:
diff --git a/lnbits/core/crud.py b/lnbits/core/crud.py
--- a/lnbits/core/crud.py
+++ b/lnbits/core/crud.py
@@ -1097,6 +1097,8 @@ async def get_admin_settings(is_super_user: bool = False) -> Optional[AdminSetti
         return None
     row_dict = dict(sets)
     row_dict.pop("super_user")
+    row_dict.pop("auth_all_methods")
+
     admin_settings = AdminSettings(
         is_super_user=is_super_user,
         lnbits_allowed_funding_sources=settings.lnbits_allowed_funding_sources,
diff --git a/lnbits/core/views/auth_api.py b/lnbits/core/views/auth_api.py
--- a/lnbits/core/views/auth_api.py
+++ b/lnbits/core/views/auth_api.py
@@ -1,10 +1,9 @@
-from typing import Optional
+import importlib
+from typing import Callable, Optional
 
 from fastapi import APIRouter, Depends, HTTPException, Request, status
 from fastapi.responses import JSONResponse, RedirectResponse
-from fastapi_sso.sso.base import OpenID
-from fastapi_sso.sso.github import GithubSSO
-from fastapi_sso.sso.google import GoogleSSO
+from fastapi_sso.sso.base import OpenID, SSOBase
 from loguru import logger
 from starlette.status import (
     HTTP_400_BAD_REQUEST,
@@ -94,72 +93,37 @@ async def login_usr(data: LoginUsr) -> JSONResponse:
         raise HTTPException(HTTP_500_INTERNAL_SERVER_ERROR, "Cannot login.")
 
 
-@auth_router.get("/api/v1/auth/google", description="Google SSO")
-async def login_with_google(request: Request, user_id: Optional[str] = None):
-    google_sso = _new_google_sso()
-    if not google_sso:
-        raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'Google' not allowed.")
+@auth_router.get("/api/v1/auth/{provider}", description="SSO Provider")
+async def login_with_sso_provider(
+    request: Request, provider: str, user_id: Optional[str] = None
+):
+    provider_sso = _new_sso(provider)
+    if not provider_sso:
+        raise HTTPException(
+            HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
+        )
 
-    google_sso.redirect_uri = str(request.base_url) + "api/v1/auth/google/token"
-    with google_sso:
+    provider_sso.redirect_uri = str(request.base_url) + f"api/v1/auth/{provider}/token"
+    with provider_sso:
         state = encrypt_internal_message(user_id)
-        return await google_sso.get_login_redirect(state=state)
-
-
-@auth_router.get("/api/v1/auth/github", description="Github SSO")
-async def login_with_github(request: Request, user_id: Optional[str] = None):
-    github_sso = _new_github_sso()
-    if not github_sso:
-        raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'GitHub' not allowed.")
+        return await provider_sso.get_login_redirect(state=state)
 
-    github_sso.redirect_uri = str(request.base_url) + "api/v1/auth/github/token"
-    with github_sso:
-        state = decrypt_internal_message(user_id)
-        return await github_sso.get_login_redirect(state=state)
 
-
-@auth_router.get(
-    "/api/v1/auth/google/token", description="Handle Google OAuth callback"
-)
-async def handle_google_token(request: Request) -> RedirectResponse:
-    google_sso = _new_google_sso()
-    if not google_sso:
-        raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'Google' not allowed.")
-
-    try:
-        with google_sso:
-            userinfo = await google_sso.verify_and_process(request)
-            assert userinfo is not None
-            user_id = decrypt_internal_message(google_sso.state)
-        request.session.pop("user", None)
-        return await _handle_sso_login(userinfo, user_id)
-    except HTTPException as e:
-        raise e
-    except ValueError as e:
-        raise HTTPException(HTTP_403_FORBIDDEN, str(e))
-    except Exception as e:
-        logger.debug(e)
+@auth_router.get("/api/v1/auth/{provider}/token", description="Handle OAuth callback")
+async def handle_oauth_token(request: Request, provider: str) -> RedirectResponse:
+    provider_sso = _new_sso(provider)
+    if not provider_sso:
         raise HTTPException(
-            HTTP_500_INTERNAL_SERVER_ERROR, "Cannot authenticate user with Google Auth."
+            HTTP_401_UNAUTHORIZED, f"Login by '{provider}' not allowed."
         )
 
-
-@auth_router.get(
-    "/api/v1/auth/github/token", description="Handle Github OAuth callback"
-)
-async def handle_github_token(request: Request) -> RedirectResponse:
-    github_sso = _new_github_sso()
-    if not github_sso:
-        raise HTTPException(HTTP_401_UNAUTHORIZED, "Login by 'GitHub' not allowed.")
-
     try:
-        with github_sso:
-            userinfo = await github_sso.verify_and_process(request)
+        with provider_sso:
+            userinfo = await provider_sso.verify_and_process(request)
             assert userinfo is not None
-            user_id = decrypt_internal_message(github_sso.state)
+            user_id = decrypt_internal_message(provider_sso.state)
         request.session.pop("user", None)
         return await _handle_sso_login(userinfo, user_id)
-
     except HTTPException as e:
         raise e
     except ValueError as e:
@@ -167,7 +131,8 @@ async def handle_github_token(request: Request) -> RedirectResponse:
     except Exception as e:
         logger.debug(e)
         raise HTTPException(
-            HTTP_500_INTERNAL_SERVER_ERROR, "Cannot authenticate user with GitHub Auth."
+            HTTP_500_INTERNAL_SERVER_ERROR,
+            f"Cannot authenticate user with {provider} Auth.",
         )
 
 
@@ -340,29 +305,44 @@ def _auth_redirect_response(path: str, email: str) -> RedirectResponse:
     return response
 
 
-def _new_google_sso() -> Optional[GoogleSSO]:
-    if not settings.is_auth_method_allowed(AuthMethods.google_auth):
-        return None
-    if not settings.is_google_auth_configured:
-        logger.warning("Google Auth allowed but not configured.")
-        return None
-    return GoogleSSO(
-        settings.google_client_id,
-        settings.google_client_secret,
-        None,
-        allow_insecure_http=True,
-    )
+def _new_sso(provider: str) -> Optional[SSOBase]:
+    try:
+        if not settings.is_auth_method_allowed(AuthMethods(f"{provider}-auth")):
+            return None
 
+        client_id = getattr(settings, f"{provider}_client_id", None)
+        client_secret = getattr(settings, f"{provider}_client_secret", None)
+        discovery_url = getattr(settings, f"{provider}_discovery_url", None)
 
-def _new_github_sso() -> Optional[GithubSSO]:
-    if not settings.is_auth_method_allowed(AuthMethods.github_auth):
-        return None
-    if not settings.is_github_auth_configured:
-        logger.warning("Github Auth allowed but not configured.")
-        return None
-    return GithubSSO(
-        settings.github_client_id,
-        settings.github_client_secret,
-        None,
-        allow_insecure_http=True,
-    )
+        if not client_id or not client_secret:
+            logger.warning(f"{provider} auth allowed but not configured.")
+            return None
+
+        SSOProviderClass = _find_auth_provider_class(provider)
+        ssoProvider = SSOProviderClass(
+            client_id, client_secret, None, allow_insecure_http=True
+        )
+        if (
+            discovery_url
+            and getattr(ssoProvider, "discovery_url", discovery_url) != discovery_url
+        ):
+            ssoProvider.discovery_url = discovery_url
+        return ssoProvider
+    except Exception as e:
+        logger.warning(e)
+
+    return None
+
+
+def _find_auth_provider_class(provider: str) -> Callable:
+    sso_modules = ["lnbits.core.sso", "fastapi_sso.sso"]
+    for module in sso_modules:
+        try:
+            provider_module = importlib.import_module(f"{module}.{provider}")
+            ProviderClass = getattr(provider_module, f"{provider.title()}SSO")
+            if ProviderClass:
+                return ProviderClass
+        except Exception:
+            pass
+
+    raise ValueError(f"No SSO provider found for '{provider}'.")
diff --git a/lnbits/settings.py b/lnbits/settings.py
--- a/lnbits/settings.py
+++ b/lnbits/settings.py
@@ -282,19 +282,11 @@ class GoogleAuthSettings(LNbitsSettings):
     google_client_id: str = Field(default="")
     google_client_secret: str = Field(default="")
 
-    @property
-    def is_google_auth_configured(self):
-        return self.google_client_id != "" and self.google_client_secret != ""
-
 
 class GitHubAuthSettings(LNbitsSettings):
     github_client_id: str = Field(default="")
     github_client_secret: str = Field(default="")
 
-    @property
-    def is_github_auth_configured(self):
-        return self.github_client_id != "" and self.github_client_secret != ""
-
 
 class EditableSettings(
     UsersSettings,


Test patch: