feat: dynamic auth providers + Auth0 support

- Add /api/1/auth/providers endpoint that returns only configured providers
- Frontend fetches available providers dynamically instead of hardcoding
- Only providers with credentials set (KEY + SECRET) appear as login options
- Add Auth0 as a supported authentication provider
- Add python-jose[cryptography] dependency for Auth0 JWT verification
- Show helpful message when no providers are configured

This makes the login page modular - administrators only see buttons for
providers they've actually configured, avoiding confusion from dead buttons.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Brian Olson
2025-12-31 15:57:48 -05:00
parent 16847cea93
commit 3aa321aff8
8 changed files with 135 additions and 12 deletions

View File

@@ -35,7 +35,7 @@ RUN pip install -U setuptools cryptography==37.0.4 poetry==1.1.7
COPY backend/pyproject.toml backend/poetry.lock ./ COPY backend/pyproject.toml backend/poetry.lock ./
RUN poetry config virtualenvs.path /venv RUN poetry config virtualenvs.path /venv
RUN poetry install --no-dev --no-ansi --no-interaction 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/manage.py backend/gunicorn.conf.py ./
COPY backend/tabby tabby COPY backend/tabby tabby

View File

@@ -40,7 +40,20 @@ For SSH and Telnet, once logged in, enter your connection gateway address and au
* `DATABASE_URL` (required). * `DATABASE_URL` (required).
* `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in. * `APP_DIST_STORAGE`: a `file://`, `s3://`, or `gcs://` URL to store app distros in.
* `SOCIAL_AUTH_*_KEY` & `SOCIAL_AUTH_*_SECRET`: social login credentials, supported providers are `GITHUB`, `GITLAB`, `MICROSOFT_GRAPH` and `GOOGLE_OAUTH2`.
### Authentication Providers
Only providers with credentials configured will appear as login options. Set the following environment variables for each provider you want to enable:
| 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 | `SOCIAL_AUTH_MICROSOFT_GRAPH_KEY`, `SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET` |
| Auth0 | `SOCIAL_AUTH_AUTH0_DOMAIN`, `SOCIAL_AUTH_AUTH0_KEY`, `SOCIAL_AUTH_AUTH0_SECRET` |
For Auth0, set the callback URL to: `https://your-domain/api/1/auth/social/complete/auth0/`
## Adding Tabby app versions ## Adding Tabby app versions

View File

@@ -11,6 +11,7 @@ router.register(
urlpatterns = [ urlpatterns = [
path("api/1/auth/logout", auth.LogoutView.as_view()), 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/user", user.UserViewSet.as_view({"get": "retrieve", "put": "update"})),
path( path(
"api/1/gateways/choose", "api/1/gateways/choose",

View File

@@ -1,9 +1,73 @@
from django.conf import settings
from django.contrib.auth import logout from django.contrib.auth import logout
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView 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',
},
'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',
},
}
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)
return bool(key and secret)
class LogoutView(APIView): class LogoutView(APIView):
def post(self, request, format=None): def post(self, request, format=None):
logout(request) logout(request)
return Response(None) 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': config['name'],
'icon': config['icon'],
'cls': config['cls'],
})
return Response(providers)

View File

@@ -140,10 +140,15 @@ AUTHENTICATION_BACKENDS = (
"social_core.backends.azuread.AzureADOAuth2", "social_core.backends.azuread.AzureADOAuth2",
"social_core.backends.microsoft.MicrosoftOAuth2", "social_core.backends.microsoft.MicrosoftOAuth2",
"social_core.backends.google.GoogleOAuth2", "social_core.backends.google.GoogleOAuth2",
"social_core.backends.auth0.Auth0OAuth2",
"django.contrib.auth.backends.ModelBackend", "django.contrib.auth.backends.ModelBackend",
) )
SOCIAL_AUTH_GITHUB_SCOPE = ["read:user", "user:email"] 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_AUTH_PIPELINE = (
"social_core.pipeline.social_auth.social_details", "social_core.pipeline.social_auth.social_details",
"social_core.pipeline.social_auth.social_uid", "social_core.pipeline.social_auth.social_uid",
@@ -180,6 +185,9 @@ for key in [
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET", "SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET",
"SOCIAL_AUTH_MICROSOFT_GRAPH_KEY", "SOCIAL_AUTH_MICROSOFT_GRAPH_KEY",
"SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET", "SOCIAL_AUTH_MICROSOFT_GRAPH_SECRET",
"SOCIAL_AUTH_AUTH0_DOMAIN",
"SOCIAL_AUTH_AUTH0_KEY",
"SOCIAL_AUTH_AUTH0_SECRET",
"CONNECTION_GATEWAY_AUTH_CA", "CONNECTION_GATEWAY_AUTH_CA",
"CONNECTION_GATEWAY_AUTH_CERTIFICATE", "CONNECTION_GATEWAY_AUTH_CERTIFICATE",
"CONNECTION_GATEWAY_AUTH_KEY", "CONNECTION_GATEWAY_AUTH_KEY",

View File

@@ -1,9 +1,12 @@
.login-view(*ngIf='ready') .login-view(*ngIf='ready')
.buttons .buttons(*ngIf='providers.length')
a.btn( a.btn(
*ngFor='let provider of providers', *ngFor='let provider of providers',
[class]='provider.cls', [class]='provider.cls',
href='{{commonService.backendURL}}/api/1/auth/social/login/{{provider.id}}' 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}} span Log in with {{provider.name}}
.no-providers(*ngIf='!providers.length')
p No authentication providers configured.
p.text-muted Contact your administrator.

View File

@@ -23,3 +23,12 @@
margin: auto; margin: auto;
} }
} }
.no-providers {
text-align: center;
color: #6c757d;
p {
margin: 0.5rem 0;
}
}

View File

@@ -1,7 +1,25 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { HttpClient } from '@angular/common/http'
import { LoginService, CommonService } from 'src/common' import { LoginService, CommonService } from 'src/common'
import { faGithub, faGitlab, faGoogle, faMicrosoft } from '@fortawesome/free-brands-svg-icons' import { faGithub, faGitlab, faGoogle, faMicrosoft, 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<string, IconDefinition> = {
github: faGithub,
gitlab: faGitlab,
google: faGoogle,
microsoft: faMicrosoft,
key: faKey, // Used for Auth0 and other providers without brand icons
}
@Component({ @Component({
selector: 'login', selector: 'login',
@@ -11,20 +29,27 @@ import { faGithub, faGitlab, faGoogle, faMicrosoft } from '@fortawesome/free-bra
export class LoginComponent { export class LoginComponent {
loggedIn: any loggedIn: any
ready = false ready = false
providers: Array<Provider & { faIcon: IconDefinition }> = []
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' },
]
constructor ( constructor (
private http: HttpClient,
private loginService: LoginService, private loginService: LoginService,
public commonService: CommonService, public commonService: CommonService,
) { } ) { }
async ngOnInit () { async ngOnInit () {
// Fetch available providers from backend
try {
const providers = await this.http.get<Provider[]>('/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() await this.loginService.ready$.toPromise()
this.loggedIn = !!this.loginService.user this.loggedIn = !!this.loginService.user
this.ready = true this.ready = true