diff --git a/Dockerfile b/Dockerfile index dc7fbea..f2773d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,7 +35,7 @@ RUN pip install -U setuptools cryptography==37.0.4 poetry==1.1.7 COPY backend/pyproject.toml backend/poetry.lock ./ RUN poetry config virtualenvs.path /venv RUN poetry install --no-dev --no-ansi --no-interaction -RUN poetry run pip install -U setuptools psycopg2-binary $EXTRA_DEPS +RUN poetry run pip install -U setuptools psycopg2-binary python-jose[cryptography] $EXTRA_DEPS COPY backend/manage.py backend/gunicorn.conf.py ./ COPY backend/tabby tabby diff --git a/README.md b/README.md index c6fc7a6..1cb7f57 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,21 @@ Tabby Web serves the [Tabby Terminal](https://github.com/Eugeny/tabby) as a web | RAM | 2GB | | Disk | 10GB | +## Docker Build Requirements + +Building the Docker image requires significant resources due to the frontend compilation: + +| Resource | Minimum | Recommended | +|----------|---------|-------------| +| RAM | 2 GB | 4 GB | +| CPU | 2 cores | 4 cores | +| Disk | 5 GB | 10 GB | + +**Note:** The frontend build (webpack/Angular) is memory-intensive. If building on constrained systems (like Oracle Cloud Always Free tier with 1GB RAM), consider: +- Using pre-built images from a CI/CD pipeline +- Building on a larger machine and pushing to a registry +- Adding swap space (not recommended for production) + # Quickstart (using `docker-compose`) You'll need: @@ -94,19 +109,52 @@ For SSH and Telnet, once logged in, enter your connection gateway address and au * `DATABASE_URL` (required). * `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in. -### OAuth Providers +### Authentication Providers -Configure one or more OAuth providers for authentication: +Only providers with credentials configured will appear as login options. Set the following environment variables for each provider you want to enable: -| Provider | Variables | -|----------|-----------| +| Provider | Environment Variables | +|----------|----------------------| | GitHub | `SOCIAL_AUTH_GITHUB_KEY`, `SOCIAL_AUTH_GITHUB_SECRET` | | GitLab | `SOCIAL_AUTH_GITLAB_KEY`, `SOCIAL_AUTH_GITLAB_SECRET` | | Google | `SOCIAL_AUTH_GOOGLE_OAUTH2_KEY`, `SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET` | | Microsoft (multi-tenant) | `SOCIAL_AUTH_MICROSOFT_GRAPH_KEY`, `SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET` | | Azure AD (single-tenant) | `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY`, `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET`, `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID` | +| Auth0 | `SOCIAL_AUTH_AUTH0_DOMAIN`, `SOCIAL_AUTH_AUTH0_KEY`, `SOCIAL_AUTH_AUTH0_SECRET` | +| Generic OIDC | `SOCIAL_AUTH_OIDC_OIDC_ENDPOINT`, `SOCIAL_AUTH_OIDC_KEY`, `SOCIAL_AUTH_OIDC_SECRET` | -**Azure AD Single-Tenant:** Use this instead of Microsoft Graph if you want to restrict login to users from a specific Azure AD tenant (organization). Set `TENANT_ID` to your Azure AD Directory (tenant) ID. +For Auth0, set the callback URL to: `https://your-domain/api/1/auth/social/complete/auth0/` + +### Generic OIDC Provider + +The generic OIDC provider works with any OpenID Connect compliant identity provider, including: +- **Authentik** - Self-hosted identity provider +- **Authelia** - Self-hosted authentication server +- **Keycloak** - Open source identity management +- **Okta** - Enterprise identity platform +- And any other OIDC-compliant provider + +Configuration: +- `SOCIAL_AUTH_OIDC_OIDC_ENDPOINT`: The OIDC discovery endpoint (e.g., `https://authentik.example.com/application/o//`) +- `SOCIAL_AUTH_OIDC_KEY`: Client ID from your identity provider +- `SOCIAL_AUTH_OIDC_SECRET`: Client secret from your identity provider +- `SOCIAL_AUTH_OIDC_NAME` (optional): Custom button text (default: "SSO") + +Set the callback URL to: `https://your-domain/api/1/auth/social/complete/oidc/` + +**Note on MFA:** Multi-factor authentication is handled by your identity provider. Enable MFA in Authentik, Authelia, or your chosen provider to require 2FA for Tabby Web logins. + +### Azure AD Single-Tenant + +For organizations that want to restrict login to a specific Azure AD/Entra ID tenant (instead of allowing any Microsoft account), use the Azure AD single-tenant provider: + +- `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY`: Application (client) ID from Azure portal +- `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET`: Client secret +- `SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID`: Directory (tenant) ID + +Set the callback URL to: `https://your-domain/api/1/auth/social/complete/azuread-tenant-oauth2/` + +When registering your app in Azure portal, select "Accounts in this organizational directory only" for supported account types. ## Adding Tabby app versions diff --git a/backend/tabby/app/api/__init__.py b/backend/tabby/app/api/__init__.py index 819cf18..e6939ae 100644 --- a/backend/tabby/app/api/__init__.py +++ b/backend/tabby/app/api/__init__.py @@ -11,6 +11,7 @@ router.register( urlpatterns = [ path("api/1/auth/logout", auth.LogoutView.as_view()), + path("api/1/auth/providers", auth.ProvidersView.as_view()), path("api/1/user", user.UserViewSet.as_view({"get": "retrieve", "put": "update"})), path( "api/1/gateways/choose", diff --git a/backend/tabby/app/api/auth.py b/backend/tabby/app/api/auth.py index 72f91a7..bc21d04 100644 --- a/backend/tabby/app/api/auth.py +++ b/backend/tabby/app/api/auth.py @@ -1,9 +1,101 @@ +from django.conf import settings from django.contrib.auth import logout from rest_framework.response import Response from rest_framework.views import APIView +# Provider configuration: maps backend names to their display info +# and the environment variable prefix used to detect if they're configured +PROVIDER_CONFIG = { + 'github': { + 'name': 'GitHub', + 'icon': 'github', + 'cls': 'btn-primary', + 'env_prefix': 'SOCIAL_AUTH_GITHUB', + }, + 'gitlab': { + 'name': 'GitLab', + 'icon': 'gitlab', + 'cls': 'btn-warning', + 'env_prefix': 'SOCIAL_AUTH_GITLAB', + }, + 'google-oauth2': { + 'name': 'Google', + 'icon': 'google', + 'cls': 'btn-secondary', + 'env_prefix': 'SOCIAL_AUTH_GOOGLE_OAUTH2', + }, + 'microsoft-graph': { + 'name': 'Microsoft', + 'icon': 'microsoft', + 'cls': 'btn-light', + 'env_prefix': 'SOCIAL_AUTH_MICROSOFT_GRAPH', + }, + 'azuread-tenant-oauth2': { + 'name': 'Azure AD', + 'icon': 'microsoft', + 'cls': 'btn-light', + 'env_prefix': 'SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2', + }, + 'auth0': { + 'name': 'Auth0', + 'icon': 'key', # Using key icon as Auth0 doesn't have a FA brand icon + 'cls': 'btn-dark', + 'env_prefix': 'SOCIAL_AUTH_AUTH0', + }, + 'oidc': { + 'name': 'SSO', # Generic name, can be overridden via SOCIAL_AUTH_OIDC_NAME + 'icon': 'openid', # OpenID icon + 'cls': 'btn-info', + 'env_prefix': 'SOCIAL_AUTH_OIDC', + }, +} + + +def is_provider_configured(env_prefix: str) -> bool: + """Check if a provider has both KEY and SECRET configured.""" + key = getattr(settings, f'{env_prefix}_KEY', None) + secret = getattr(settings, f'{env_prefix}_SECRET', None) + # For Auth0, also need DOMAIN + if env_prefix == 'SOCIAL_AUTH_AUTH0': + domain = getattr(settings, f'{env_prefix}_DOMAIN', None) + return bool(key and secret and domain) + # For generic OIDC, also need OIDC_ENDPOINT + if env_prefix == 'SOCIAL_AUTH_OIDC': + endpoint = getattr(settings, f'{env_prefix}_OIDC_ENDPOINT', None) + return bool(key and secret and endpoint) + # For Azure AD Tenant (single-tenant), also need TENANT_ID + if env_prefix == 'SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2': + tenant_id = getattr(settings, f'{env_prefix}_TENANT_ID', None) + return bool(key and secret and tenant_id) + return bool(key and secret) + + +def get_provider_display_name(env_prefix: str, default_name: str) -> str: + """Get custom display name for a provider, if configured.""" + custom_name = getattr(settings, f'{env_prefix}_NAME', None) + return custom_name if custom_name else default_name + + class LogoutView(APIView): def post(self, request, format=None): logout(request) return Response(None) + + +class ProvidersView(APIView): + """Returns list of configured authentication providers.""" + + def get(self, request, format=None): + providers = [] + for provider_id, config in PROVIDER_CONFIG.items(): + if is_provider_configured(config['env_prefix']): + providers.append({ + 'id': provider_id, + 'name': get_provider_display_name( + config['env_prefix'], config['name'] + ), + 'icon': config['icon'], + 'cls': config['cls'], + }) + return Response(providers) diff --git a/backend/tabby/settings.py b/backend/tabby/settings.py index 22b3b50..a07a5fd 100644 --- a/backend/tabby/settings.py +++ b/backend/tabby/settings.py @@ -138,13 +138,19 @@ AUTHENTICATION_BACKENDS = ( "social_core.backends.github.GithubOAuth2", "social_core.backends.gitlab.GitLabOAuth2", "social_core.backends.azuread.AzureADOAuth2", - "social_core.backends.azuread_tenant.AzureADTenantOAuth2", # Single-tenant Azure AD + "social_core.backends.azuread_tenant.AzureADTenantOAuth2", "social_core.backends.microsoft.MicrosoftOAuth2", "social_core.backends.google.GoogleOAuth2", + "social_core.backends.auth0.Auth0OAuth2", + "social_core.backends.open_id_connect.OpenIdConnectAuth", "django.contrib.auth.backends.ModelBackend", ) SOCIAL_AUTH_GITHUB_SCOPE = ["read:user", "user:email"] +SOCIAL_AUTH_AUTH0_SCOPE = ["openid", "profile", "email"] +SOCIAL_AUTH_AUTH0_EXTRA_DATA = ["id_token"] +# Auth0 uses RS256 for ID tokens +SOCIAL_AUTH_ALLOWED_REDIRECT_HOSTS = ["*"] SOCIAL_AUTH_PIPELINE = ( "social_core.pipeline.social_auth.social_details", "social_core.pipeline.social_auth.social_uid", @@ -181,7 +187,13 @@ for key in [ "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "SOCIAL_AUTH_MICROSOFT_GRAPH_KEY", "SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET", - # Azure AD single-tenant (use instead of multi-tenant for org-only access) + "SOCIAL_AUTH_AUTH0_DOMAIN", + "SOCIAL_AUTH_AUTH0_KEY", + "SOCIAL_AUTH_AUTH0_SECRET", + "SOCIAL_AUTH_OIDC_OIDC_ENDPOINT", + "SOCIAL_AUTH_OIDC_KEY", + "SOCIAL_AUTH_OIDC_SECRET", + "SOCIAL_AUTH_OIDC_NAME", "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY", "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET", "SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID", diff --git a/frontend/src/login/components/login.component.pug b/frontend/src/login/components/login.component.pug index 5329189..1b3185f 100644 --- a/frontend/src/login/components/login.component.pug +++ b/frontend/src/login/components/login.component.pug @@ -1,9 +1,12 @@ .login-view(*ngIf='ready') - .buttons + .buttons(*ngIf='providers.length') a.btn( *ngFor='let provider of providers', [class]='provider.cls', href='{{commonService.backendURL}}/api/1/auth/social/login/{{provider.id}}' ) - fa-icon([icon]='provider.icon', [fixedWidth]='true') + fa-icon([icon]='provider.faIcon', [fixedWidth]='true') span Log in with {{provider.name}} + .no-providers(*ngIf='!providers.length') + p No authentication providers configured. + p.text-muted Contact your administrator. diff --git a/frontend/src/login/components/login.component.scss b/frontend/src/login/components/login.component.scss index c4cdcbe..8ca688e 100644 --- a/frontend/src/login/components/login.component.scss +++ b/frontend/src/login/components/login.component.scss @@ -23,3 +23,12 @@ margin: auto; } } + +.no-providers { + text-align: center; + color: #6c757d; + + p { + margin: 0.5rem 0; + } +} diff --git a/frontend/src/login/components/login.component.ts b/frontend/src/login/components/login.component.ts index 69ad589..dd86565 100644 --- a/frontend/src/login/components/login.component.ts +++ b/frontend/src/login/components/login.component.ts @@ -1,7 +1,26 @@ import { Component } from '@angular/core' +import { HttpClient } from '@angular/common/http' import { LoginService, CommonService } from 'src/common' -import { faGithub, faGitlab, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons' +import { faGithub, faGitlab, faGoogle, faMicrosoft, faOpenid, IconDefinition } from '@fortawesome/free-brands-svg-icons' +import { faKey } from '@fortawesome/free-solid-svg-icons' + +interface Provider { + id: string + name: string + icon: string + cls: string +} + +// Map icon names from backend to FontAwesome icons +const iconMap: Record = { + github: faGithub, + gitlab: faGitlab, + google: faGoogle, + microsoft: faMicrosoft, + openid: faOpenid, // Used for generic OIDC providers (Authentik, Authelia, Keycloak, etc.) + key: faKey, // Used for Auth0 and other providers without brand icons +} @Component({ selector: 'login', @@ -11,20 +30,27 @@ import { faGithub, faGitlab, faGoogle, faMicrosoft } from '@fortawesome/free-bra export class LoginComponent { loggedIn: any ready = false - - providers = [ - { name: 'GitHub', icon: faGithub, cls: 'btn-primary', id: 'github' }, - { name: 'GitLab', icon: faGitlab, cls: 'btn-warning', id: 'gitlab' }, - { name: 'Google', icon: faGoogle, cls: 'btn-secondary', id: 'google-oauth2' }, - { name: 'Microsoft', icon: faMicrosoft, cls: 'btn-light', id: 'microsoft-graph' }, - ] + providers: Array = [] constructor ( + private http: HttpClient, private loginService: LoginService, public commonService: CommonService, ) { } async ngOnInit () { + // Fetch available providers from backend + try { + const providers = await this.http.get('/api/1/auth/providers').toPromise() + this.providers = (providers || []).map(p => ({ + ...p, + faIcon: iconMap[p.icon] || faKey, + })) + } catch { + // Fallback to empty list if API fails + this.providers = [] + } + await this.loginService.ready$.toPromise() this.loggedIn = !!this.loginService.user this.ready = true