乐于分享
好东西不私藏

OpenHands 源码-用户认证与权限管理

OpenHands 源码-用户认证与权限管理

OpenHands 源码解析系列

第 9 讲:用户认证与权限管理

基于 OpenHands 源码 · 2026-08-05

一、认证架构总览

OpenHands 的认证体系分为两个世界:

🔹 OSS 模式(开源版):单用户,无多租户,使用 DefaultUserAuth

🔹 SaaS 模式(企业版):多租户 + Keycloak SSO,使用 SaasUserAuth

两者共享同一个抽象基类 UserAuth,通过配置切换实现。

📦 核心文件

openhands/app_server/user_auth/user_auth.py — 抽象基类

openhands/app_server/user_auth/default_user_auth.py — OSS 默认实现

enterprise/server/auth/saas_user_auth.py — SaaS Keycloak 实现

enterprise/server/auth/authorization.py — 权限模型

enterprise/server/routes/auth.py — 认证路由

二、UserAuth 抽象基类

UserAuth 是整个认证系统的核心抽象。它定义了所有认证实现必须提供的接口:

📄 openhands/app_server/user_auth/user_auth.py (第 30-125 行)

class AuthType(Enum):
    COOKIE = 'cookie'
    BEARER = 'bearer'


class UserAuth(ABC):
    """Abstract base class for user authentication.

    This is an extension point in OpenHands that allows applications to provide their own
    authentication mechanisms. Applications can substitute their own implementation by:
    1. Creating a class that inherits from UserAuth
    2. Implementing all required methods
    3. Setting server_config.user_auth_class to the fully qualified name of the class

    The class is instantiated via get_impl() in openhands.app_server.shared.py.
    """

    _settings: Settings | None
    _resolved_settings: Settings | None = None

    @abstractmethod
    async def get_user_id(self) -> str | None:
        """Get the unique identifier for the current user"""

    @abstractmethod
    async def get_user_email(self) -> str | None:
        """Get the email for the current user"""

    @abstractmethod
    async def get_access_token(self) -> SecretStr | None:
        """Get the access token for the current user"""

    @abstractmethod
    async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
        """Get the provider tokens for the current user."""

    @abstractmethod
    async def get_user_settings_store(self) -> SettingsStore:
        """Get the settings store for the current user."""

    @abstractmethod
    async def get_secrets_store(self) -> SecretsStore:
        """Get secrets store"""

    @abstractmethod
    async def get_secrets(self) -> Secrets | None:
        """Get the user's secrets"""

    @abstractmethod
    async def get_mcp_api_key(self) -> str | None:
        """Get an mcp api key for the user"""

    def get_auth_type(self) -> AuthType | None:
        return None

设计要点:

UserAuth 不是一个简单的认证器,它是一个用户上下文的聚合器。它同时管理:

🔹 用户身份(ID、邮箱、访问令牌)

🔹 用户设置(SettingsStore)

🔹 用户密钥(SecretsStore)

🔹 第三方 Provider Token(GitHub、GitLab 等)

三、动态实例化机制

OpenHands 没有硬编码 UserAuth 的具体实现,而是通过 配置驱动 + 动态导入 的方式实例化:

📄 openhands/app_server/user_auth/user_auth.py (第 151-168 行)

async def get_user_auth(request: Request) -> UserAuth:
    user_auth: UserAuth | None = getattr(request.state, 'user_auth', None)
    if user_auth:
        return user_auth
    impl_name = server_config.user_auth_class
    impl = get_impl(UserAuth, impl_name)
    user_auth = await impl.get_instance(request)
    if user_auth is None:
        raise ValueError('Failed to get user auth instance')
    request.state.user_auth = user_auth
    return user_auth


async def get_for_user(user_id: str) -> UserAuth:
    impl_name = server_config.user_auth_class
    impl = get_impl(UserAuth, impl_name)
    user_auth = await impl.get_for_user(user_id)
    return user_auth

关键流程:

🔹 缓存:先从 request.state.user_auth 取,避免重复创建

🔹 配置驱动server_config.user_auth_class 存储完整类名(如 enterprise.server.auth.saas_user_auth.SaasUserAuth

🔹 动态导入get_impl() 根据字符串路径动态加载类

🔹 工厂方法get_instance(request) 从 HTTP 请求中提取认证信息并构造实例

四、OSS 模式:DefaultUserAuth

开源版的默认实现非常简单 — 它不支持多租户,所有用户相关方法返回 None

📄 openhands/app_server/user_auth/default_user_auth.py (第 24-92 行)

@dataclass
class DefaultUserAuth(UserAuth):
    """Default user authentication mechanism"""

    _settings: Settings | None = None
    _settings_store: SettingsStore | None = None
    _secrets_store: SecretsStore | None = None
    _secrets: Secrets | None = None

    async def get_user_id(self) -> str | None:
        """The default implementation does not support multi tenancy,
        so user_id is always None"""
        return None

    async def get_user_email(self) -> str | None:
        """The default implementation does not support multi tenancy,
        so email is always None"""
        return None

    async def get_access_token(self) -> SecretStr | None:
        """The default implementation does not support multi tenancy,
        so access_token is always None"""
        return None

    @classmethod
    async def get_for_user(cls, user_id: str) -> UserAuth:
        assert user_id == 'root'
        return DefaultUserAuth()

简洁即力量:

DefaultUserAuth 只有 92 行代码。它用 assert user_id == 'root' 声明了 OSS 模式只存在一个"超级用户"。Settings 和 Secrets 通过单例 Store 管理,不区分用户。

五、SaaS 模式:Keycloak 认证体系

企业版使用 Keycloak 作为统一身份提供商,支持 GitHub、Google、GitLab、SAML 等多种认证源:

📄 enterprise/server/auth/saas_user_auth.py (第 57-93 行)

@dataclass
class SaasUserAuth(UserAuth):
    refresh_token: SecretStr
    user_id: str
    email: str | None = None
    email_verified: bool | None = None
    access_token: SecretStr | None = None
    provider_tokens: PROVIDER_TOKEN_TYPE | None = None
    refreshed: bool = False
    settings_store: SaasSettingsStore | None = None
    secrets_store: SaasSecretsStore | None = None
    _settings: Settings | None = None
    _resolved_settings: Settings | None = None
    _secrets: Secrets | None = None
    accepted_tos: bool | None = None
    auth_type: AuthType = AuthType.COOKIE
    # API key context fields - populated when authenticated via API key
    api_key_org_id: UUID | None = None
    api_key_id: int | None = None
    api_key_name: str | None = None
    # Organization context fields - populated lazily via get_org_info()
    _org_id: str | None = None
    _org_name: str | None = None
    _role: str | None = None
    _permissions: list[str] | None = None
    _org_info_loaded: bool = False
    # Per-request X-Org-Id header (raw, unvalidated)
    _x_org_id_header: str | None = None
    # Trusted server-side override used by background resolver contexts
    effective_org_id_override: UUID | None = None
    # Cached result of get_effective_org_id()
    _effective_org_id: UUID | None = None
    _effective_org_id_resolved: bool = False

SaasUserAuth 比 DefaultUserAuth 多了约 800 行代码,核心差异:

🔹 Keycloak Token 管理:access_token + refresh_token 双令牌机制

🔹 组织上下文:每个用户可属于多个组织,支持 X-Org-Id 头切换

🔹 API Key 认证:支持 API Key 替代 Cookie 认证

🔹 角色与权限:与组织角色系统深度集成

六、认证路由与 OAuth 流程

认证路由定义在 enterprise/server/routes/auth.py,共 1254 行,是 OpenHands 最大的单个源文件之一:

📄 enterprise/server/routes/auth.py (第 80-131 行)

api_router = APIRouter(prefix='/api')
oauth_router = APIRouter(prefix='/oauth')

token_manager = TokenManager()


def set_response_cookie(
    request: Request,
    response: Response,
    keycloak_access_token: str,
    keycloak_refresh_token: str,
    secure: bool = True,
    accepted_tos: bool = False,
):
    # Create a signed JWT token
    cookie_data = {
        'access_token': keycloak_access_token,
        'refresh_token': keycloak_refresh_token,
        'accepted_tos': accepted_tos,
    }
    from storage.encrypt_utils import get_jwt_service

    signed_token = get_jwt_service().create_jws_token(
        cookie_data, expires_in=timedelta(weeks=1)
    )

    # Set secure cookie with signed token. The value can exceed the
    # browser's 4096-byte single-cookie cap for users with large Keycloak
    # claim sets, so write it through the chunked-cookie helper...
    set_chunked_cookie(
        response,
        'keycloak_auth',
        signed_token,
        domain=get_cookie_domain(),
        secure=secure,
        httponly=True,
        samesite=get_cookie_samesite(),
    )

Cookie 写入流程:

🔹 Keycloak 返回 access_token + refresh_token

🔹 打包成 cookie_data 字典

🔹 用 JWS 签名(有效期 1 周)

🔹 通过 set_chunked_cookie 写入(支持分块)

七、Cookie 分块机制

这是一个精妙的工程细节 — 浏览器对单个 Cookie 大小限制约 4096 字节,但 Keycloak 的 claim set 可能很大:

📄 enterprise/server/auth/cookie_chunking.py (第 1-52 行)

"""Chunked cookie helpers for the keycloak_auth session cookie.

Browsers cap a single cookie at ~4096 bytes (name + value + attributes).
The keycloak_auth cookie wraps a signed JWS containing the Keycloak
access and refresh tokens, and that can exceed the cap for users with
large claim sets (long emails, many realm roles, several allowed-origins
entries). Chrome silently drops an oversized cookie, which shows up as an
endless login loop...
"""

CHUNK_SIZE = 3000
MAX_CHUNKS = 8


def read_chunked_cookie(request: Request, key: str) -> str | None:
    """Reassemble a possibly-chunked cookie value, or None if absent.

    Concatenates key, key_1, key_2 ... in order, stopping at
    the first missing index. A plain single cookie reads back unchanged.
    """
    first = request.cookies.get(key)
    if first is None:
        return None
    parts = [first]
    for i in range(1, MAX_CHUNKS):
        part = request.cookies.get(_chunk_key(key, i))
        if part is None:
            break
        parts.append(part)
    return ''.join(parts)

为什么要分块?

Chrome 遇到超大 Cookie 会静默丢弃,不会报错。用户表现为"无限登录循环" — OAuth 回调成功但 Cookie 从未到达下一个请求。分块方案将值拆成 keycloak_authkeycloak_auth_1keycloak_auth_2 ... 最多 8 块(24KB),远大于任何实际 Token。

八、权限模型:RBAC + ABAC 混合

OpenHands 的权限系统定义在 enterprise/server/auth/authorization.py,采用角色-权限映射

📄 enterprise/server/auth/authorization.py (第 48-108 行)

class Permission(str, Enum):
    """Permissions that can be assigned to roles."""

    # Secrets
    MANAGE_SECRETS = 'manage_secrets'

    # MCP
    MANAGE_MCP = 'manage_mcp'

    # Integrations
    MANAGE_INTEGRATIONS = 'manage_integrations'
    MANAGE_INTEGRATION_PROVIDERS = 'manage_integration_providers'

    # Application Settings
    MANAGE_APPLICATION_SETTINGS = 'manage_application_settings'

    # API Keys
    MANAGE_API_KEYS = 'manage_api_keys'

    # LLM Settings
    VIEW_LLM_SETTINGS = 'view_llm_settings'
    EDIT_LLM_SETTINGS = 'edit_llm_settings'

    # Billing
    VIEW_BILLING = 'view_billing'
    ADD_CREDITS = 'add_credits'

    # Organization Members
    INVITE_USER_TO_ORGANIZATION = 'invite_user_to_organization'
    CHANGE_USER_ROLE_MEMBER = 'change_user_role:member'
    CHANGE_USER_ROLE_ADMIN = 'change_user_role:admin'
    CHANGE_USER_ROLE_OWNER = 'change_user_role:owner'

    # Organization Management
    VIEW_ORG_SETTINGS = 'view_org_settings'
    CHANGE_ORGANIZATION_NAME = 'change_organization_name'
    DELETE_ORGANIZATION = 'delete_organization'
    CREATE_ORGANIZATION = 'create_organization'

    # Manage Automations
    MANAGE_AUTOMATIONS = 'manage_automations'

    # User provisioning
    PROVISION_USER = 'provision_user'

    # Organization Conversations (Admin/Owner only)
    VIEW_ORG_CONVERSATIONS = 'view_org_conversations'

    # Instance-level super-role administration
    MANAGE_SUPER_ADMINS = 'manage_super_admins'

共定义了 24 种细粒度权限,覆盖密钥、MCP、集成、设置、计费、成员管理、组织管理等所有领域。

九、三级角色体系

权限按角色分组,OpenHands 使用双层角色模型

📄 enterprise/server/auth/authorization.py (第 144-226 行)

# Base permission mappings for the regular (org-scoped) roles.
ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
    RoleName.OWNER: frozenset([
        # Full access to everything
        Permission.MANAGE_SECRETS,
        Permission.MANAGE_MCP,
        Permission.MANAGE_INTEGRATIONS,
        Permission.MANAGE_APPLICATION_SETTINGS,
        Permission.MANAGE_API_KEYS,
        Permission.VIEW_LLM_SETTINGS,
        Permission.EDIT_LLM_SETTINGS,
        Permission.VIEW_BILLING,
        Permission.ADD_CREDITS,
        Permission.INVITE_USER_TO_ORGANIZATION,
        Permission.CHANGE_USER_ROLE_MEMBER,
        Permission.CHANGE_USER_ROLE_ADMIN,
        Permission.CHANGE_USER_ROLE_OWNER,
        Permission.VIEW_ORG_SETTINGS,
        Permission.EDIT_ORG_SETTINGS,
        Permission.CHANGE_ORGANIZATION_NAME,
        Permission.DELETE_ORGANIZATION,
        Permission.MANAGE_ORG_CLAIMS,
        Permission.MANAGE_AUTOMATIONS,
        Permission.PROVISION_USER,
        Permission.VIEW_ORG_CONVERSATIONS,
        Permission.MANAGE_INTEGRATION_PROVIDERS,
    ]),
    RoleName.ADMIN: frozenset([
        # Almost everything except org deletion/rename
        Permission.MANAGE_SECRETS,
        Permission.MANAGE_MCP,
        Permission.MANAGE_INTEGRATIONS,
        ... (20 permissions total)
    ]),
    RoleName.MEMBER: frozenset([
        # Read + basic management
        Permission.MANAGE_SECRETS,
        Permission.MANAGE_MCP,
        Permission.MANAGE_INTEGRATIONS,
        Permission.MANAGE_APPLICATION_SETTINGS,
        Permission.MANAGE_API_KEYS,
        Permission.VIEW_ORG_SETTINGS,
        Permission.VIEW_LLM_SETTINGS,
        Permission.MANAGE_AUTOMATIONS,
    ]),
}
角色 权限数 关键差异
Owner 22 可删除组织、改名、分配 Owner 角色
Admin 20 无法删除组织、改名、分配 Owner
Member 8 只读设置 + 基础密钥/MCP 管理

此外还有超级角色(Super Role)机制:

📄 enterprise/server/auth/authorization.py (第 233-248 行)

SUPER_ROLE_PERMISSIONS: dict[RoleName, frozenset[Permission]] = {
    RoleName.OWNER: frozenset(),
    RoleName.ADMIN: frozenset([
        Permission.CREATE_ORGANIZATION,
        Permission.PROVISION_USER,
        Permission.MANAGE_SUPER_ADMINS,
    ]),
    RoleName.MEMBER: frozenset(),
}

关键设计:超级角色(如 superadmin)存储在 user.role_id,跨组织生效;普通角色存储在 org_member.role_id,仅作用于特定组织。两者权限不互相继承

十、权限检查执行流程

权限检查通过 FastAPI 依赖注入实现:

📄 enterprise/server/auth/authorization.py (第 337-383 行)

def has_permission(
    user_role: Role, permission: Permission, *, is_super: bool = False
) -> bool:
    if is_super:
        return permission in get_super_role_permissions(user_role.name)
    return permission in get_role_permissions(user_role.name)


async def authorize_permission(
    request: Request, user_id: str, permission: Permission
) -> None:
    """Enforce that user_id has permission in the request's target org.

    Raises HTTPException(403) otherwise. Mirrors the org-role then
    super-role fallback used by require_permission.
    """
    from server.auth.org_context import resolve_target_org_id_for_permission_check

    org_id = await resolve_target_org_id_for_permission_check(request)
    user_role = await get_user_org_role(user_id, org_id)
    if user_role and has_permission(user_role, permission):
        return
    super_role = await get_user_super_role(user_id)
    if super_role and has_permission(super_role, permission, is_super=True):
        return
    raise HTTPException(
        status_code=status.HTTP_403_FORBIDDEN,
        detail=f'Missing required permission: {permission.value}',
    )

检查顺序:

🔹 第一步:从请求解析目标组织 ID(X-Org-Id 头或默认组织)

🔹 第二步:查 org_member 表获取用户在当前组织的角色

🔹 第三步:查 user.role_id 获取超级角色(兜底)

🔹 第四步:任一角色拥有权限即通过,否则返回 403

十一、用户授权器:白名单/黑名单

除了角色权限,OpenHands 还有用户准入控制机制:

📄 enterprise/storage/user_authorization.py (第 11-46 行)

class UserAuthorizationType(str, Enum):
    """Type of user authorization rule."""
    WHITELIST = 'whitelist'
    BLACKLIST = 'blacklist'


class UserAuthorization(Base):
    """Stores user authorization rules based on email patterns
    and provider types.

    Supports:
    - Email pattern matching using SQL LIKE (e.g., '%@openhands.dev')
    - Provider type filtering (e.g., 'github', 'gitlab')
    - Whitelist/Blacklist rules

    When email_pattern is NULL, the rule matches all emails.
    When provider_type is NULL, the rule matches all providers.
    """
    __tablename__ = 'user_authorizations'
    id: Mapped[int] = mapped_column(Identity(), primary_key=True)
    email_pattern: Mapped[str | None] = mapped_column(String, nullable=True)
    provider_type: Mapped[str | None] = mapped_column(String, nullable=True)
    type: Mapped[str] = mapped_column(String, nullable=False)
    created_at: Mapped[datetime] = mapped_column(...)
    updated_at: Mapped[datetime] = mapped_column(...)

授权检查在 DefaultUserAuthorizer 中执行:

📄 enterprise/server/auth/user/default_user_authorizer.py (第 32-79 行)

async def authorize_user(
    self, user_info: KeycloakUserInfo
) -> UserAuthorizationResponse:
    user_id = user_info.sub
    email = user_info.email
    provider_type = user_info.identity_provider
    try:
        if not email:
            return UserAuthorizationResponse(
                success=False, error_detail='missing_email'
            )

        if self.prevent_duplicates:
            has_duplicate = await token_manager.check_duplicate_base_email(
                email, user_id
            )
            if has_duplicate:
                return UserAuthorizationResponse(
                    success=False, error_detail='duplicate_email'
                )

        # Check authorization rules (whitelist takes precedence)
        base_email = extract_base_email(email)
        auth_type = await UserAuthorizationStore.get_authorization_type(
            base_email, provider_type
        )

        if auth_type == UserAuthorizationType.WHITELIST:
            return UserAuthorizationResponse(success=True)

        if auth_type == UserAuthorizationType.BLACKLIST:
            return UserAuthorizationResponse(success=False, error_detail='blocked')

检查顺序:邮箱校验 → 重复邮箱检测 → 白名单优先 → 黑名单兜底

十二、沙箱会话认证

沙箱端点的认证独立于用户认证,使用 Session API Key 机制:

📄 openhands/app_server/sandbox/session_auth.py (第 37-100 行)

async def validate_session_key(session_api_key: str | None) -> SandboxInfo:
    """Validate an X-Session-API-Key and return the associated sandbox.

    Security:
        This function enforces that session API keys are only valid for RUNNING
        sandboxes. This is a critical security measure to prevent leaked keys
        from being used to access user secrets after a sandbox has been paused,
        stopped, or deleted.

    Raises:
        HTTPException(401): if the key is missing or does not map to a sandbox.
        HTTPException(401): if the sandbox is not in RUNNING state.
        HTTPException(401): in SAAS mode if the sandbox has no owning user.
    """
    if not session_api_key:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            detail='X-Session-API-Key header is required',
        )

    # Use admin context to look up sandbox by session key
    state = InjectorState()
    setattr(state, USER_CONTEXT_ATTR, ADMIN)

    async with get_sandbox_service(state) as sandbox_service:
        sandbox_info = await sandbox_service.get_sandbox_by_session_api_key(
            session_api_key
        )

    if sandbox_info is None:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED, detail='Invalid session API key'
        )

    # Security: Reject session keys for non-running sandboxes.
    if sandbox_info.status != SandboxStatus.RUNNING:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            detail='Sandbox is not running',
        )

    return sandbox_info

安全设计亮点:

Session API Key 只对 RUNNING 状态的沙箱有效。沙箱暂停、停止或删除后,即使 Key 泄露也无法访问用户密钥。这是一个"最小暴露窗口"的安全策略。

十三、认证错误体系

定义了 7 种认证异常类型,覆盖常见失败场景:

📄 enterprise/server/auth/auth_error.py (第 1-46 行)

class AuthError(Exception):
    """Generic auth error"""
    pass

class NoCredentialsError(AuthError):
    """Error when no authentication was provided"""
    pass

class EmailNotVerifiedError(AuthError):
    """Error when email is not verified"""
    pass

class BearerTokenError(AuthError):
    """Error when decoding a bearer token"""
    pass

class CookieError(AuthError):
    """Error when decoding an auth cookie"""
    pass

class TosNotAcceptedError(AuthError):
    """Error when decoding an auth cookie"""
    pass

class ExpiredError(AuthError):
    """Error when a token has expired (Usually the refresh token)"""
    pass

class TokenRefreshError(AuthError):
    """Error when token refresh fails due to timeout or lock contention"""
    pass

十四、认证架构总结

OpenHands 认证架构全景

┌─────────────────────────────────────────────────────┐
│                  认证请求入口                          │
│              (Cookie / Bearer / API Key)              │
└──────────────────────┬──────────────────────────────┘
                       │
          ┌────────────▼─────────────┐
          │    SetAuthCookie         │  ← 中间件层
          │       Middleware         │
          └────────────┬─────────────┘
                       │
          ┌────────────▼─────────────┐
          │      UserAuth            │  ← 抽象层 (ABC)
          │   (get_impl 动态加载)     │
          └────┬────────────┬────────┘
               │            │
    ┌──────────▼───┐  ┌────▼──────────┐
    │  OSS 模式     │  │   SaaS 模式     │
    │DefaultUserAuth│  │ SaasUserAuth   │
    │  (单用户)     │  │ (Keycloak SSO)  │
    └──────────────┘  └────┬──────────┘
                          │
               ┌──────────▼──────────┐
               │   权限检查层          │
               │ authorization.py    │
               │                     │
               │  Org Role (RBAC)    │
               │  + Super Role       │
               │  + Whitelist/Black  │
               └─────────────────────┘

核心设计原则:

🔹 抽象先行:UserAuth ABC 定义了统一的认证接口

🔹 配置驱动:通过 server_config.user_auth_class 切换实现

🔹 双层角色:组织级角色 + 实例级超级角色,互不继承

🔹 最小暴露:Session Key 仅在沙箱运行时有效

🔹 工程细节:Cookie 分块、Token 刷新、速率限制等生产级考量

📚 系列导航

← 第 8 讲:Skill 系统

→ 第 10 讲:Webhook 与事件回调机制

关注公众号「AI技术推荐官」获取更多源码解析内容