Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -189,5 +189,6 @@ robot_results/
output.xml
log.html
report.html
config/service_configs.yaml
config/kubeconfigs
config/wiring.yaml
config/service_configs.yaml
1 change: 1 addition & 0 deletions compose/backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ services:
- ../config:/config # Mount config directory (read-write for feature flags)
- ../compose:/compose # Mount compose files for service management
- ../mycelia:/mycelia # Mount mycelia for building mycelia-backend service
- ../openmemory:/openmemory # Mount openmemory for building openmemory services
- /app/__pycache__
- /app/.pytest_cache
- /app/.venv # Mask host .venv - container uses its own venv from image
Expand Down
22 changes: 19 additions & 3 deletions compose/openmemory-compose.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# OpenMemory (mem0) service definition
# Graph-based memory with MCP support
# Environment variables are passed directly via docker compose subprocess env
#
# Image Strategy:
# - Submodule checked out: Builds from local source (./openmemory/openmemory)
# - Submodule missing: Pulls pre-built images from ghcr.io/ushadow-io/u-mem0-*
# - Uses pull_policy: build to prefer local builds when context exists

# =============================================================================
# USHADOW METADATA (ignored by Docker, read by ushadow backend)
Expand All @@ -23,8 +28,11 @@ x-ushadow:
services:
mem0:
image: ghcr.io/ushadow-io/u-mem0-api:v1.0.4
build:
context: ${PROJECT_ROOT:-..}/openmemory/openmemory/api
dockerfile: Dockerfile
pull_policy: build # Build from source if openmemory submodule exists, otherwise pull from GHCR
container_name: ${COMPOSE_PROJECT_NAME:-ushadow}-mem0
pull_policy: always
# Requires qdrant from infra (started via infra_services in x-ushadow)
ports:
- "${OPENMEMORY_PORT:-8765}:8765"
Expand Down Expand Up @@ -65,13 +73,21 @@ services:

mem0-ui:
image: ghcr.io/ushadow-io/u-mem0-ui:v1.0.4
build:
context: ${PROJECT_ROOT:-..}/openmemory/openmemory/ui
dockerfile: Dockerfile
args:
# Use placeholder values so entrypoint can replace them at runtime
- NEXT_PUBLIC_USER_ID=NEXT_PUBLIC_USER_ID
- NEXT_PUBLIC_API_URL=NEXT_PUBLIC_API_URL
pull_policy: build # Build from source if openmemory submodule exists, otherwise pull from GHCR
container_name: ${COMPOSE_PROJECT_NAME:-ushadow}-mem0-ui
ports:
- "3002:3000"
environment:
- VITE_API_URL=http://localhost:${OPENMEMORY_PORT:-8765}
- API_URL=http://mem0:8765
- NEXT_PUBLIC_USER_ID=${ADMIN_EMAIL:-admin@example.com}
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8765}
- API_URL=http://mem0:8765
networks:
- ushadow-network
depends_on:
Expand Down
7 changes: 4 additions & 3 deletions compose/parakeet-compose.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
services:
parakeet-asr:
build:
context: .
dockerfile: Dockerfile_Parakeet
context: ../parakeet
dockerfile: Dockerfile
args:
PYTORCH_CUDA_VERSION: ${PYTORCH_CUDA_VERSION:-cu126}
image: parakeet-asr:latest
Expand All @@ -22,7 +22,8 @@ services:
capabilities: [gpu]
environment:
- HF_HOME=/models
- PARAKEET_MODEL=$PARAKEET_MODEL
- HF_TOKEN=${HF_TOKEN:-}
- PARAKEET_MODEL=${PARAKEET_MODEL:-arakeet-tdt-0.6b-v2}
# Enhanced chunking configuration
- CHUNKING_ENABLED=${CHUNKING_ENABLED:-true}
- CHUNK_DURATION_SECONDS=${CHUNK_DURATION_SECONDS:-30.0}
Expand Down
4 changes: 2 additions & 2 deletions config/feature_flags.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ flags:

# Service Configs - Show custom service instance configurations
service_configs:
enabled: false
enabled: true
description: "Show custom service config instances in the Services tab (multi-instance
per template)"
type: release
Expand All @@ -103,7 +103,7 @@ flags:

# Legacy Services Page - Old Docker service configuration page
legacy_services_page:
enabled: false
enabled: true
description: "Legacy Services page (replaced by Instances page) - Docker service
configuration"
type: release
Expand Down
52 changes: 49 additions & 3 deletions config/keycloak/realm-export.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,11 @@
]
}
],
"defaultDefaultClientScopes": ["openid", "profile", "email"],
"defaultDefaultClientScopes": [
"openid",
"profile",
"email"
],
"clients": [
{
"clientId": "ushadow-frontend",
Expand All @@ -174,12 +178,14 @@
"redirectUris": [
"http://localhost:3000/oauth/callback",
"http://localhost:*/oauth/callback",
"tauri://oauth-callback"
"tauri://oauth-callback",
"ushadow://oauth/callback"
],
"webOrigins": [
"http://localhost:3000",
"http://localhost:*",
"tauri://localhost"
"tauri://localhost",
"ushadow://localhost"
],
"attributes": {
"pkce.code.challenge.method": "S256",
Expand All @@ -198,6 +204,46 @@
}
}
]
},
{
"clientId": "ushadow-mobile",
"name": "Ushadow Mobile",
"description": "Ushadow mobile application (React Native)",
"enabled": true,
"publicClient": true,
"protocol": "openid-connect",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"authorizationServicesEnabled": false,
"fullScopeAllowed": true,
"redirectUris": [
"ushadow://oauth/callback",
"exp://*/--/oauth/callback",
"exp://localhost:*/--/oauth/callback"
],
"webOrigins": [
"ushadow://localhost",
"exp://*"
],
"attributes": {
"pkce.code.challenge.method": "S256",
"post.logout.redirect.uris": "ushadow://##exp://*"
},
"protocolMappers": [
{
"name": "sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-sub-mapper",
"consentRequired": false,
"config": {
"access.token.claim": "true",
"id.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [],
Expand Down
20 changes: 17 additions & 3 deletions scripts/build-push-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,25 @@ case "$SERVICE" in
build_and_push \
"mycelia" \
"mycelia/backend/Dockerfile" \
"mycelia-backend"
"mycelia/backend"

# Build frontend (context is mycelia root, use prod Dockerfile)
build_and_push \
"mycelia" \
"mycelia/frontend/Dockerfile.prod" \
"mycelia/frontend"

# Build python worker (context is mycelia root, Dockerfile is in python/)
build_and_push \
"mycelia" \
"mycelia/python/Dockerfile" \
"mycelia/python-worker"

info "============================================="
info "Mycelia images pushed successfully!"
info " ${REGISTRY}/mycelia-backend:${TAG}"
info " ${REGISTRY}/mycelia/backend:${TAG}"
info " ${REGISTRY}/mycelia/frontend:${TAG}"
info " ${REGISTRY}/mycelia/python-worker:${TAG}"
info "============================================="
;;

Expand Down Expand Up @@ -180,7 +194,7 @@ case "$SERVICE" in
echo "Available services:"
echo " ushadow - Build ushadow backend + frontend"
echo " chronicle - Build Chronicle backend + workers + webui"
echo " mycelia - Build Mycelia backend"
echo " mycelia - Build Mycelia backend + frontend + python-worker"
echo " openmemory - Build OpenMemory server"
echo ""
echo "Examples:"
Expand Down
52 changes: 36 additions & 16 deletions ushadow/backend/src/config/keycloak_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,35 +139,55 @@ def get_keycloak_openid(client_id: Optional[str] = None) -> KeycloakOpenID:

Args:
client_id: Client ID to use (defaults to frontend_client_id from config)
If provided, creates a NEW instance (no caching for specific clients)

Returns:
KeycloakOpenID instance
"""
global _keycloak_openid

if _keycloak_openid is None:
settings = get_settings()

# Internal URL for backend-to-Keycloak communication
# Resolved by OmegaConf: ${oc.env:KEYCLOAK_URL,http://keycloak:8080}
internal_url = settings.get_sync("keycloak.url", "http://keycloak:8080")
settings = get_settings()

# Use provided client_id or default to frontend
if client_id is None:
client_id = settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend")
# Internal URL for backend-to-Keycloak communication
internal_url = settings.get_sync("keycloak.url", "http://keycloak:8080")
app_realm = settings.get_sync("keycloak.realm", "ushadow")

client_secret = settings.get_sync("keycloak.backend_client_secret")
# Determine if this is a public or confidential client
# Public clients (frontend, mobile) = no secret (browser/app can't protect secrets)
# Confidential clients (backend) = with secret (server-side can protect secrets)
def is_public_client(cid: str) -> bool:
frontend_client_id = settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend")
mobile_client_id = settings.get_sync("keycloak.mobile_client_id", "ushadow-mobile")
return cid in [frontend_client_id, mobile_client_id]

# If client_id provided, create NEW instance (don't use cache)
if client_id is not None:
if is_public_client(client_id):
client_secret = None # Public client - no secret
logger.info(f"[KC-SETTINGS] Creating public client instance (no secret): {client_id}")
else:
# Confidential client (backend)
client_secret = settings.get_sync("keycloak.backend_client_secret")
logger.info(f"[KC-SETTINGS] Creating confidential client instance (with secret): {client_id}")

return KeycloakOpenID(
server_url=internal_url,
realm_name=app_realm,
client_id=client_id,
client_secret_key=client_secret,
)

# OpenID operations use the application realm (ushadow), not master
app_realm = settings.get_sync("keycloak.realm", "ushadow")
# No client_id provided - use cached default frontend instance (public client)
if _keycloak_openid is None:
default_client_id = settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend")

logger.info(f"[KC-SETTINGS] Initializing KeycloakOpenID for client: {client_id}")
logger.info(f"[KC-SETTINGS] Initializing default KeycloakOpenID (public): {default_client_id}")

_keycloak_openid = KeycloakOpenID(
server_url=internal_url,
realm_name=app_realm, # Use application realm for token operations
client_id=client_id,
client_secret_key=client_secret,
realm_name=app_realm,
client_id=default_client_id,
client_secret_key=None, # Frontend is public - no secret
)

return _keycloak_openid
Expand Down
61 changes: 56 additions & 5 deletions ushadow/backend/src/routers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ class TokenExchangeRequest(BaseModel):
code: str = Field(..., description="Authorization code from Keycloak")
code_verifier: str = Field(..., description="PKCE code verifier")
redirect_uri: str = Field(..., description="Redirect URI used in authorization request")
client_id: Optional[str] = Field(None, description="OAuth client ID (defaults to frontend client)")


class TokenExchangeResponse(BaseModel):
Expand Down Expand Up @@ -437,14 +438,17 @@ async def exchange_code_for_tokens(request: TokenExchangeRequest):
from src.services.keycloak_client import get_keycloak_client
from keycloak.exceptions import KeycloakError

logger.info(f"[TOKEN-EXCHANGE] Request received with client_id={request.client_id}")

try:
kc_client = get_keycloak_client()

# Exchange authorization code for tokens
tokens = kc_client.exchange_code_for_tokens(
code=request.code,
redirect_uri=request.redirect_uri,
code_verifier=request.code_verifier
code_verifier=request.code_verifier,
client_id=request.client_id
)

logger.info("[TOKEN-EXCHANGE] ✓ Successfully exchanged code for tokens")
Expand Down Expand Up @@ -484,6 +488,10 @@ async def refresh_access_token(request: TokenRefreshRequest):

Standard OAuth 2.0 refresh token flow using python-keycloak.

IMPORTANT: Extracts the issuer from the refresh token to ensure
we use the same Keycloak URL that issued the token (handles multi-domain
scenarios where browser uses Tailscale hostname but backend sees IP).

Args:
request: Contains refresh token

Expand All @@ -494,14 +502,55 @@ async def refresh_access_token(request: TokenRefreshRequest):
401: If refresh token is invalid or expired
503: If Keycloak is unreachable
"""
from src.services.keycloak_client import get_keycloak_client
from keycloak import KeycloakOpenID
from keycloak.exceptions import KeycloakError
from src.config.keycloak_settings import get_keycloak_connection, get_keycloak_config
import jwt
from urllib.parse import urlparse

try:
kc_client = get_keycloak_client()
# Decode refresh token WITHOUT validation to extract issuer
# This is safe - we're only reading metadata, not trusting the token yet
decoded = jwt.decode(request.refresh_token, options={"verify_signature": False})
issuer = decoded.get("iss")

if not issuer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Refresh token missing issuer claim"
)

# Extract server_url from issuer (removes /realms/xxx suffix)
# e.g., "https://orange.spangled-kettle.ts.net/realms/ushadow" -> "https://orange.spangled-kettle.ts.net"
if "/realms/" in issuer:
server_url = issuer.split("/realms/")[0]
else:
server_url = issuer

# CRITICAL: Translate public URLs to internal Docker network URLs
# When backend is inside Docker, external URLs (localhost/Tailscale) don't work
kc_config = get_keycloak_config()
issuer_parsed = urlparse(server_url)
public_parsed = urlparse(kc_config["public_url"])

if issuer_parsed.hostname == public_parsed.hostname and issuer_parsed.port == public_parsed.port:
# Issuer matches public URL -> use internal Docker network URL
server_url = kc_config["url"]
logger.info(f"[TOKEN-REFRESH] Translated public → internal: {server_url}")
else:
logger.info(f"[TOKEN-REFRESH] Using issuer URL: {server_url}")

# Create KeycloakOpenID client with the SAME URL that issued the token
# IMPORTANT: Use application realm (ushadow), NOT admin realm (master)
keycloak_openid = KeycloakOpenID(
server_url=server_url,
realm_name=kc_config["realm"], # Application realm where tokens are issued
client_id=kc_config["frontend_client_id"], # Public client (no secret needed)
client_secret_key=None, # Explicit: public client has no secret
)

# Refresh token
tokens = kc_client.refresh_token(request.refresh_token)
# Refresh token using the correct issuer URL
tokens = keycloak_openid.refresh_token(request.refresh_token)

logger.info("[TOKEN-REFRESH] ✓ Successfully refreshed access token")

Expand All @@ -520,6 +569,8 @@ async def refresh_access_token(request: TokenRefreshRequest):
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Token refresh failed: {str(e)}"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[TOKEN-REFRESH] Unexpected error: {e}", exc_info=True)
raise HTTPException(
Expand Down
Loading
Loading