Skip to content
Open
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: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ upload.sh
/tests/
.env
sensitive_info_result.txt
.env.e2e
.env_e2e
.env.e2e.example
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ it as a link for the user to open in Feishu/Lark.
```python
import lark_oapi as lark

## ClientAssertion Keyless Mode

For self-built apps that use an external signing service, the SDK can fetch
tenant tokens with `client_assertion` instead of `app_secret`. The SDK does not
generate, parse, sign, or store JWT private keys; your provider supplies the
final assertion string.

```python
import os

import lark_oapi as lark
from lark_oapi.core.client_assertion import ClientAssertionToken


class EnvClientAssertionProvider:
def retrieve_token(self, aud: str) -> ClientAssertionToken:
return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"])


client = lark.Client.builder() \
.app_id(os.environ["LARK_APP_ID"]) \
.client_assertion_provider(EnvClientAssertionProvider()) \
.build()
```

If you use a custom OpenAPI domain, also configure `oauth_base_url(...)` so the
SDK can derive the OAuth audience correctly. Keyless mode is for self-built
apps only and does not support AppAccessToken-only APIs.

## Channel Module

def on_qr_code(info):
print(info["url"])
Expand Down
29 changes: 29 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,35 @@ request = CreateMessageRequest.builder() \
response = client.im.v1.message.create(request)
```

## ClientAssertion 无密钥模式

自建应用如果通过外部签发服务提供 `client_assertion`,SDK 可以在不配置
`app_secret` 的情况下换取 tenant token。SDK 不生成、不解析、不签名 JWT,
也不保存私钥;provider 只需要返回最终的 assertion 字符串。

```python
import os

import lark_oapi as lark
from lark_oapi.core.client_assertion import ClientAssertionToken


class EnvClientAssertionProvider:
def retrieve_token(self, aud: str) -> ClientAssertionToken:
return ClientAssertionToken(os.environ["LARK_CLIENT_ASSERTION"])


client = lark.Client.builder() \
.app_id(os.environ["LARK_APP_ID"]) \
.client_assertion_provider(EnvClientAssertionProvider()) \
.build()
```

如果使用自定义 OpenAPI 域名,需要同时配置 `oauth_base_url(...)`,以便 SDK
正确生成 OAuth audience。无密钥模式仅支持自建应用,不支持只依赖
AppAccessToken 的 API。

## Channel 模块
## 一键创建应用

`lark_oapi.register_app` 基于 OAuth device flow 创建应用。SDK 会在
Expand Down
11 changes: 11 additions & 0 deletions lark_oapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from .core import logger, JSON
from .core.model import *
from .core.token import TokenManager, verify
from .core.access_token import AccessToken
from .core.http import Transport
from .api.auth.service import AuthService
from .api.event.service import EventService
Expand Down Expand Up @@ -155,6 +156,7 @@ def __init__(self) -> None:
self.performance: Optional[PerformanceService] = None
self.security_and_compliance: Optional[SecurityAndComplianceService] = None
self.speech_to_text: Optional[SpeechToTextService] = None
self.access_token: Optional[AccessToken] = None

@staticmethod
def builder() -> "ClientBuilder":
Expand Down Expand Up @@ -229,6 +231,14 @@ def app_secret(self, app_secret: str) -> "ClientBuilder":
self._config.app_secret = app_secret
return self

def client_assertion_provider(self, provider) -> "ClientBuilder":
self._config.client_assertion_provider = provider
return self

def oauth_base_url(self, oauth_base_url: str) -> "ClientBuilder":
self._config.oauth_base_url = oauth_base_url
return self

def domain(self, domain: str) -> "ClientBuilder":
_validate_domain(domain)
self._config.domain = domain
Expand Down Expand Up @@ -331,6 +341,7 @@ def build(self) -> Client:
client.performance = PerformanceService(self._config)
client.security_and_compliance = SecurityAndComplianceService(self._config)
client.speech_to_text = SpeechToTextService(self._config)
client.access_token = AccessToken(self._config)

return client

Expand Down
1 change: 1 addition & 0 deletions lark_oapi/core/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .cache import ICache
from .client_assertion import *
from .const import *
from .enum import *
from .env_var import *
Expand Down
2 changes: 2 additions & 0 deletions lark_oapi/core/access_token/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .client import *
from .model import *
116 changes: 116 additions & 0 deletions lark_oapi/core/access_token/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import json
from typing import Dict, Optional

from lark_oapi.core.client_assertion import build_proxy_url, resolve_oauth_aud, resolve_oauth_base_url
from lark_oapi.core.const import (
APPLICATION_JSON,
CLIENT_ASSERTION_TYPE_JWT_BEARER,
CONTENT_TYPE,
ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY,
ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED,
ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY,
GRANT_TYPE_AUTHORIZATION_CODE,
GRANT_TYPE_REFRESH_TOKEN,
OAUTH_TOKEN_URI,
UTF_8,
X_TARGET_SERVICE,
)
from lark_oapi.core.enum import HttpMethod
from lark_oapi.core.exception import AccessTokenException, ClientAssertionException
from lark_oapi.core.http import Transport
from lark_oapi.core.model import BaseRequest, Config, RequestOption
from lark_oapi.core.utils import Strings
from .model import AccessTokenResponse, value_if_not_empty


class AccessToken(object):
def __init__(self, config: Config) -> None:
self._config = config

def retrieve_by_authorization_code(
self,
code: str,
redirect_uri: Optional[str] = None,
code_verifier: Optional[str] = None,
scope: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> AccessTokenResponse:
return self._do_request(
{
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"scope": scope,
},
headers=headers,
)

def refresh(
self,
refresh_token: str,
scope: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> AccessTokenResponse:
return self._do_request(
{
"grant_type": GRANT_TYPE_REFRESH_TOKEN,
"refresh_token": refresh_token,
"scope": scope,
},
headers=headers,
)

def _do_request(self, body: Dict[str, object], headers: Optional[Dict[str, str]] = None) -> AccessTokenResponse:
oauth_base_url = resolve_oauth_base_url(self._config)
aud = resolve_oauth_aud(self._config)
request_url = oauth_base_url + OAUTH_TOKEN_URI
body = {k: v for k, v in body.items() if v is not None}
body["client_id"] = self._config.app_id
option = RequestOption()
if headers:
option.headers.update(headers)

if self._config.client_assertion_provider is not None:
try:
assertion_token = self._config.client_assertion_provider.retrieve_token(aud)
except Exception as e:
raise ClientAssertionException(ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED, str(e))
if assertion_token is None or Strings.is_empty(assertion_token.value):
raise ClientAssertionException(ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY, "client assertion token is empty")
body["client_assertion_type"] = CLIENT_ASSERTION_TYPE_JWT_BEARER
body["client_assertion"] = assertion_token.value
if assertion_token.target_info is not None:
request_url = build_proxy_url(assertion_token.target_info, OAUTH_TOKEN_URI)
option.headers[X_TARGET_SERVICE] = aud
elif Strings.is_not_empty(self._config.app_secret):
body["client_secret"] = self._config.app_secret
else:
raise ClientAssertionException(
ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY,
"AppSecret and ClientAssertionProvider cannot both be empty for AccessToken APIs",
)

req = BaseRequest()
req.http_method = HttpMethod.POST
req.uri = request_url
req.headers = {CONTENT_TYPE: APPLICATION_JSON}
req.body = body
raw = Transport.execute(self._config, req, option)
resp = json.loads(str(raw.content, UTF_8))
if raw.status_code != 200:
raise AccessTokenException(
raw.status_code,
resp.get("code") or 0,
resp.get("error") or "",
resp.get("error_description") or "",
)
return AccessTokenResponse(
access_token=value_if_not_empty(resp.get("access_token")),
token_type=value_if_not_empty(resp.get("token_type")),
expires_in=value_if_not_empty(resp.get("expires_in")),
refresh_token=value_if_not_empty(resp.get("refresh_token")),
refresh_token_expires_in=value_if_not_empty(resp.get("refresh_token_expires_in")),
scope=value_if_not_empty(resp.get("scope")),
raw=raw,
)
19 changes: 19 additions & 0 deletions lark_oapi/core/access_token/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from dataclasses import dataclass
from typing import Optional

from lark_oapi.core.model import RawResponse


@dataclass
class AccessTokenResponse:
access_token: Optional[str] = None
token_type: Optional[str] = None
expires_in: Optional[int] = None
refresh_token: Optional[str] = None
refresh_token_expires_in: Optional[int] = None
scope: Optional[str] = None
raw: Optional[RawResponse] = None


def value_if_not_empty(value):
return value if value not in ("", 0, None) else None
66 changes: 66 additions & 0 deletions lark_oapi/core/client_assertion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from dataclasses import dataclass
from typing import Optional, Protocol
from urllib.parse import urlparse

from lark_oapi.core.const import FEISHU_DOMAIN, FEISHU_OAUTH_DOMAIN, LARK_DOMAIN, LARK_OAUTH_DOMAIN


@dataclass
class TargetInfo:
target_service: str
target_prefix: str = ""


@dataclass
class ClientAssertionToken:
value: str
target_info: Optional[TargetInfo] = None


class ClientAssertionProvider(Protocol):
def retrieve_token(self, aud: str) -> ClientAssertionToken:
raise NotImplementedError


def _normalize_base_url(base_url: str) -> str:
if "://" not in base_url:
base_url = "https://" + base_url
return base_url.rstrip("/")


def extract_aud_from_url(raw_url: str) -> str:
if "://" not in raw_url:
raw_url = "https://" + raw_url
parsed = urlparse(raw_url)
if parsed.netloc:
return parsed.netloc
if parsed.path and "/" not in parsed.path:
return parsed.path
raise ValueError(f"invalid url: {raw_url}")


def resolve_oauth_base_url(config) -> str:
oauth_base_url = getattr(config, "oauth_base_url", None)
if oauth_base_url:
return _normalize_base_url(oauth_base_url)

aud = extract_aud_from_url(config.domain)
if aud == extract_aud_from_url(FEISHU_DOMAIN):
return FEISHU_OAUTH_DOMAIN
if aud == extract_aud_from_url(LARK_DOMAIN):
return LARK_OAUTH_DOMAIN
raise ValueError(
"OAuthBaseUrl is not configured. When domain is set to a non-default value "
"(neither open.feishu.cn nor open.larksuite.com), configure oauth_base_url explicitly."
)


def resolve_oauth_aud(config) -> str:
return extract_aud_from_url(resolve_oauth_base_url(config))


def build_proxy_url(target_info: TargetInfo, api_path: str) -> str:
target_service = target_info.target_service
if "://" not in target_service:
target_service = "https://" + target_service
return target_service + target_info.target_prefix + api_path
17 changes: 17 additions & 0 deletions lark_oapi/core/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@
# Domain
FEISHU_DOMAIN = "https://open.feishu.cn"
LARK_DOMAIN = "https://open.larksuite.com"
FEISHU_OAUTH_DOMAIN = "https://accounts.feishu.cn"
LARK_OAUTH_DOMAIN = "https://accounts.larksuite.com"

# Header
USER_AGENT = "User-Agent"
AUTHORIZATION = "Authorization"
X_TARGET_SERVICE = "X-Target-Service"
X_TT_LOGID = "X-Tt-Logid"
X_REQUEST_ID = "X-Request-Id"
CONTENT_TYPE = "Content-Type"
Expand All @@ -24,3 +27,17 @@
URL_VERIFICATION = "url_verification"

UTF_8 = "UTF-8"

# OAuth / ClientAssertion
OAUTH_TOKEN_URI = "/oauth/v3/token"
GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
GRANT_TYPE_REFRESH_TOKEN = "refresh_token"
GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"
CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"

# ClientAssertion error codes, aligned with oapi-sdk-go.
ERR_CODE_CLIENT_ASSERTION_PROVIDER_NOT_CONFIGURED = 7100
ERR_CODE_CLIENT_ASSERTION_TOKEN_EMPTY = 7101
ERR_CODE_CLIENT_ASSERTION_RETRIEVE_FAILED = 7102
ERR_CODE_CLIENT_ASSERTION_MODE_NOT_SUPPORTED = 7103
ERR_CODE_APP_SECRET_AND_CLIENT_ASSERTION_EMPTY = 7104
23 changes: 23 additions & 0 deletions lark_oapi/core/exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@ def __init__(self, msg: str):
self.msg: str = msg


class ClientAssertionException(Exception):
def __init__(self, code: int, msg: str):
super().__init__(msg)
self.code = code
self.msg = msg

def __str__(self):
return f"{self.code}: {self.msg}"


class AccessTokenException(Exception):
def __init__(self, status_code: int, code: int, error: str, error_description: str):
super().__init__(error_description or error or "access token request failed")
self.status_code = status_code
self.code = code
self.error = error
self.error_description = error_description

def __str__(self):
msg = self.error_description or self.error or "access token request failed"
return f"statusCode:{self.status_code}, code:{self.code}, msg:{msg}"


class ObtainAccessTokenException(Exception):
def __init__(self, desc: str, code: int, msg: str):
self.desc = desc
Expand Down
Loading