forked from EduCraft/curriculum-project-hub
334 lines
21 KiB
Markdown
334 lines
21 KiB
Markdown
# Feishu multi-app identity and event-routing primary sources
|
|
|
|
This note answers one narrow design question for the production SaaS audit:
|
|
what identity and routing boundaries apply when **every Hub Organization uses
|
|
its own Feishu App and bot**. External claims below use only first-party Feishu
|
|
Open Platform documentation. Statements labelled **Derived requirement** are
|
|
Hub design implications, not guarantees made by Feishu.
|
|
|
|
Research date: 2026-07-10.
|
|
|
|
## Executive answer
|
|
|
|
1. A customer-owned enterprise custom app is a single-tenant app: Feishu says
|
|
it is developed, reviewed, published, and used inside one tenant and cannot
|
|
be used by another organization. That fits “one Organization, one Feishu
|
|
App,” but it means the Hub is operating many independent app security and
|
|
identity domains, not one shared Feishu identity domain.
|
|
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
|
|
2. A bare `open_id` is **not** a SaaS-global user key. It identifies a user
|
|
inside one app, and the same user has different Open IDs in different apps.
|
|
`user_id` is tenant-scoped; it is stable across apps in one tenant but
|
|
differs across tenants. `union_id` crosses apps only for apps from the same
|
|
application provider/developer; it differs between providers.
|
|
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN),
|
|
[official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN),
|
|
[Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
|
|
3. OAuth must be routed to the Organization's app **before** authorization.
|
|
The authorization request contains that app's App ID as `client_id`; the
|
|
callback contains `code` and the echoed `state`, not an independently
|
|
trustworthy Organization choice. Exchanging the code requires the matching
|
|
app's `client_id` and `client_secret`, and the same `redirect_uri`.
|
|
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
|
|
[obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
|
|
4. A version 2 event or callback carries `header.app_id` and
|
|
`header.tenant_key` after parsing/decryption. An encrypted HTTP delivery,
|
|
however, exposes only an `encrypt` envelope before decryption, so a shared
|
|
endpoint cannot safely choose among many apps' Encrypt Keys from the body.
|
|
([callback structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview),
|
|
[encrypted Webhook delivery](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
|
|
5. A WebSocket/long-connection client is authenticated with one app's App ID
|
|
and App Secret. Therefore its local client instance is the pre-authenticated
|
|
source-app route. It is not a process-global “all customer apps” listener.
|
|
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
|
|
6. Feishu documents `chat_id` as the conversation identifier, but the cited
|
|
documentation does not promise that a raw `chat_id` is globally unique
|
|
across every tenant and app. Chat APIs explicitly reject an operator and
|
|
chat from different tenants and often require the app bot to be in the
|
|
chat. A bot is represented by its application's `app_id`.
|
|
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
|
|
[get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN),
|
|
[message resource](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
|
|
|
|
## 1. Application and tenant boundary
|
|
|
|
### Official facts
|
|
|
|
- `app_id` is the immutable identifier generated by Feishu for an application.
|
|
`tenant_key` is the enterprise identifier; one `tenant_key` corresponds to
|
|
one enterprise in actual use.
|
|
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology))
|
|
- An enterprise custom app is restricted to one tenant. Its developers,
|
|
administrators, and users belong to that tenant, and another organization
|
|
cannot use it. A store app is the contrasting model that can be installed by
|
|
multiple tenants.
|
|
([custom apps versus store apps](https://open.feishu.cn/document/home/app-types-introduction/self-built-apps-and-store-apps))
|
|
- Access tokens tell Feishu which app/user is calling and which tenant's data
|
|
is being accessed. A `tenant_access_token` calls as the application and its
|
|
data range is determined by that application's permissions.
|
|
([access-token overview](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
|
|
- A custom app obtains its `tenant_access_token` using that app's `app_id` and
|
|
`app_secret`.
|
|
([custom-app tenant access token](https://open.feishu.cn/document/server-docs/authentication-management/access-token/tenant_access_token_internal?lang=zh-CN))
|
|
|
|
### Derived requirements
|
|
|
|
- Model a durable `FeishuAppConnection` (or equivalent) owned by exactly one
|
|
Hub Organization. At minimum it binds the internal connection ID,
|
|
`organization_id`, `app_id`, expected `tenant_key`, encrypted `app_secret`,
|
|
event/callback credentials, lifecycle state, and credential version.
|
|
- Do not select Feishu credentials from a process-global default. Every OAuth
|
|
exchange, token cache entry, OpenAPI call, Webhook verification, and
|
|
long-connection client must start from the Organization's connection.
|
|
- Pin the first verified `tenant_key` to the connection. Later login, event,
|
|
and callback data must match both the expected `app_id` and pinned
|
|
`tenant_key`; unknown or mismatched pairs must be rejected rather than
|
|
auto-attached to another Organization.
|
|
|
|
## 2. User identifier scopes
|
|
|
|
### Official scope matrix
|
|
|
|
| Identifier | Feishu's documented scope | Cross-app/tenant behavior | Availability caveat |
|
|
| --- | --- | --- | --- |
|
|
| `open_id` | User identity inside one application | The same user has a different `open_id` in different apps | The normal app-relative user identifier |
|
|
| `user_id` | User identity inside one tenant | Stable across all apps in the same tenant; different in tenant A and tenant B | Reading it is a sensitive field permission and is limited to custom apps in the cited user API |
|
|
| `union_id` | User identity across apps from the same application provider/developer | Same across that provider's apps; different across different providers | It does not make unrelated customer-owned apps one identity domain |
|
|
| `tenant_key` | Enterprise identity | One key corresponds to one enterprise | Returned by login and carried in events/callbacks |
|
|
|
|
Sources:
|
|
|
|
- Feishu's current user resource overview states that `user_id` is tenant
|
|
identity, `open_id` is app identity, and `union_id` is identity across apps
|
|
developed by the same application provider. It expressly says the same
|
|
user's Open ID differs between apps.
|
|
([user resource overview](https://open.feishu.cn/document/server-docs/contact-v3/user/field-overview?lang=zh-CN))
|
|
- Feishu's API parameter text is more explicit: the same user's User ID differs
|
|
between tenants but remains the same across apps within one tenant; the same
|
|
user's Union ID is the same under one developer and different under another.
|
|
([official ID parameter definitions](https://open.feishu.cn/document/server-docs/contact-v3/department/search?lang=zh-CN))
|
|
- Feishu describes Union ID as the identity linking one user across multiple
|
|
applications supplied by the same application service provider.
|
|
([Union ID guide](https://open.feishu.cn/document/uAjLw4CM/ugTN1YjL4UTN24CO1UjN/trouble-shooting/how-to-obtain-union-id))
|
|
- Feishu's common-error guidance says not to use an Open ID obtained from app A
|
|
in app B, and even a test app and its production app produce different Open
|
|
IDs for the same user. It also reports cross-tenant errors for both User ID
|
|
and Union ID use.
|
|
([common errors](https://open.feishu.cn/document/ukTMukTMukTM/ugjM14COyUjL4ITN?op_tracking=hc))
|
|
- The current login user-info API returns `open_id`, `union_id`, optional
|
|
sensitive `user_id`, and `tenant_key` from a `user_access_token`.
|
|
([get login user information](https://open.feishu.cn/document/server-docs/authentication-management/login-state-management/get?lang=zh-CN))
|
|
|
|
### Answer: can different customer Apps share one global `open_id` key?
|
|
|
|
**No.** This directly contradicts Feishu's documented Open ID semantics. Two
|
|
customer-owned custom apps are different applications, so the same person has
|
|
different Open IDs in them. Because those apps are independently owned custom
|
|
apps, the Hub also has no documented basis to assume they are applications of
|
|
one application provider and therefore no basis to auto-merge on `union_id`.
|
|
`user_id` cannot solve cross-customer identity either because it changes across
|
|
tenants.
|
|
|
|
Derived identity model:
|
|
|
|
- The safe external login key is `(feishu_app_connection_id, open_id)`; retain
|
|
`tenant_key`, `user_id`, and `union_id` as scoped attributes, not as an
|
|
unqualified SaaS-global primary key.
|
|
- If the product needs one Hub human account to participate in multiple
|
|
Organizations, represent each Feishu login as a separate external identity
|
|
attached through an explicit, separately verified account-linking flow. Do
|
|
not silently merge on Open ID, Union ID, email, phone, or display name.
|
|
- Do not make `user_id` mandatory for login unless every customer app is
|
|
required to obtain its sensitive field permission. `open_id` and
|
|
`tenant_key` are returned by the user-info flow without using `user_id` as
|
|
the login key.
|
|
- Replacing an Organization's Feishu App changes the application identity
|
|
domain and therefore its users' Open IDs. Treat app replacement as an
|
|
explicit connection migration/re-link operation, not an in-place secret
|
|
edit.
|
|
|
|
## 3. OAuth login must be app-routed
|
|
|
|
### Official facts
|
|
|
|
- The authorization URL requires `client_id`, which is the selected
|
|
application's App ID. `redirect_uri` must already appear in that
|
|
application's security settings. On success Feishu redirects with `code`
|
|
and, when supplied, the original `state`.
|
|
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code))
|
|
- Feishu says `state` maintains request/callback context and must be compared
|
|
to prevent CSRF. Its official sample rejects a callback whose state differs
|
|
from the stored state.
|
|
([obtain authorization code](https://open.feishu.cn/document/common-capabilities/sso/api/obtain-oauth-code),
|
|
[user-access-token sample](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
|
|
- The code lasts five minutes and can be used once. Token exchange requires
|
|
`client_id`, `client_secret`, `code`, and, when used during authorization,
|
|
the same `redirect_uri`. Feishu defines error 20024 for a code/refresh token
|
|
that does not match the supplied client ID and error 20071 for a mismatched
|
|
redirect URI.
|
|
([obtain user access token](https://open.feishu.cn/document/authentication-management/access-token/get-user-access-token))
|
|
- Only redirect URLs configured for the selected application pass Feishu's
|
|
security check; an app may configure multiple URLs.
|
|
([access-token acquisition flow](https://open.feishu.cn/document/server-docs/api-call-guide/calling-process/get-access-token))
|
|
|
|
### Derived request flow
|
|
|
|
1. Begin at an Organization-qualified route or invitation, resolve exactly one
|
|
active `FeishuAppConnection`, and create a short-lived, one-time server-side
|
|
login transaction bound to that Organization, connection, redirect URI,
|
|
desired return path, and PKCE verifier if used.
|
|
2. Put only a high-entropy opaque transaction nonce in OAuth `state`; generate
|
|
the authorization URL with that connection's `app_id` as `client_id`.
|
|
3. On callback, atomically consume and validate `state` before selecting
|
|
credentials. The callback's untrusted query parameters must never be
|
|
allowed to choose another Organization or App.
|
|
4. Exchange `code` with the connection's `app_id`, decrypted `app_secret`, and
|
|
the exact stored redirect URI. Then call user-info and require its
|
|
`tenant_key` to equal the connection's pinned tenant.
|
|
5. Resolve or create the membership through
|
|
`(feishu_app_connection_id, open_id)`. Never search all Organizations for a
|
|
bare Open ID and accept the first match.
|
|
|
|
A single physical callback URL may be configured independently in multiple
|
|
apps, but a shared URL does not remove the need for state-bound app routing.
|
|
|
|
## 4. HTTP event and callback routing
|
|
|
|
### Official facts
|
|
|
|
- A version 2 callback header contains the application's Verification Token,
|
|
`event_type`, `tenant_key` (the callback application's tenant), and `app_id`
|
|
(the callback application's App ID).
|
|
([callback overview and structure](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/callback-overview))
|
|
- Version 2 event examples likewise place `app_id` and `tenant_key` in the
|
|
event header.
|
|
([user-leaves-chat event](https://open.feishu.cn/document/server-docs/group/chat-member/event/deleted-2))
|
|
- Verification Token is an application validation identifier. Feishu says the
|
|
server can compare the event's token with that application's token to check
|
|
that a request is for the specified app. With an Encrypt Key, the request
|
|
signature is calculated from request timestamp, nonce, that app's Encrypt
|
|
Key, and the original request body.
|
|
([receive and verify events](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/encrypt-key-encryption-configuration-case))
|
|
- When an Encrypt Key is configured, the HTTP request body visible before
|
|
decryption is an envelope containing only `{"encrypt":"..."}`; `app_id`
|
|
and `tenant_key` are inside the ciphertext.
|
|
([send events to developer server](https://open.feishu.cn/document/event-subscription-guide/event-subscriptions/event-subscription-configure-/choose-a-subscription-mode/send-notifications-to-developers-server?lang=zh-CN))
|
|
|
|
### Derived routing and verification requirements
|
|
|
|
- Give every Feishu connection a stable, unguessable callback route such as a
|
|
connection-specific path token. Resolve the connection from that route,
|
|
load exactly its Encrypt Key/Verification Token, verify and decrypt, then
|
|
require the authenticated payload's `app_id` and `tenant_key` to match the
|
|
route's connection.
|
|
- For an unencrypted common endpoint, `header.app_id` may identify a candidate
|
|
connection, but it is untrusted until that connection's signature/token has
|
|
been verified. Never route the side effect solely from an unverified body.
|
|
- A single encrypted URL shared by every customer app has no documented
|
|
pre-decryption app selector. Trying every tenant's secret is not an identity
|
|
protocol. Use connection-specific routing instead.
|
|
- Namespace delivery deduplication by the authenticated connection (for
|
|
example `(connection_id, event_id)`), and dispatch business work only after
|
|
app/tenant verification. Unknown, disabled, or mismatched connections fail
|
|
closed and produce an auditable security log.
|
|
|
|
## 5. Long-connection routing
|
|
|
|
### Official facts
|
|
|
|
- Feishu's SDK creates a WebSocket client with one app's required `APP_ID` and
|
|
`APP_SECRET`. Events subscribed by that application are then delivered over
|
|
that channel. Authentication is performed when the connection is
|
|
established.
|
|
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
|
|
- Long-connection event subscription supports enterprise custom apps, not
|
|
store apps. Each app may establish at most 50 connections. If one app has
|
|
multiple clients, delivery is cluster-mode rather than broadcast: one random
|
|
client receives a message.
|
|
([long-connection event subscription](https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case?lang=zh-CN))
|
|
- Long-connection callbacks use the same per-app App ID/App Secret client
|
|
construction and have the same cluster-mode single-recipient behavior.
|
|
([long-connection callback subscription](https://open.feishu.cn/document/event-subscription-guide/callback-subscription/step-1-choose-a-subscription-mode/configure-callback-request-address?lang=zh-CN))
|
|
|
|
### Derived routing requirements
|
|
|
|
- Create one logical client binding per enabled `FeishuAppConnection`, even if
|
|
an in-process supervisor manages many clients. Attach immutable
|
|
`organization_id` and `connection_id` context to each handler; the client
|
|
that received the frame is the pre-authenticated application route.
|
|
- Never infer the receiving Organization by looking up a bare actor Open ID or
|
|
chat ID. First use the authenticated client binding; then cross-check any
|
|
delivered `app_id`/`tenant_key` fields available in the event structure.
|
|
- Scaling several replicas for the same app does not create duplicate
|
|
broadcast processing according to Feishu's contract, but delivery still
|
|
requires application-level idempotency and health ownership. Capacity must
|
|
count clients per app against the documented 50-connection ceiling.
|
|
|
|
## 6. Bot, chat, and message identifiers
|
|
|
|
### Official facts
|
|
|
|
- In the message resource, a sender can be a user or an application. For an
|
|
application sender, `id_type` is `app_id`; the sender also carries a
|
|
`tenant_key`. Thus the Feishu bot identity used by messaging is its
|
|
application identity, not a Hub-global bot ID.
|
|
([message management overview](https://open.feishu.cn/document/server-docs/im-v1/message/intro))
|
|
- `chat_id` identifies a conversation, including direct and group chats.
|
|
Feishu's group resource also exposes the chat's `tenant_key`.
|
|
([common parameters](https://open.feishu.cn/document/ukTMukTMukTM/uYTM5UjL2ETO14iNxkTN/terminology),
|
|
[group management overview](https://open.feishu.cn/document/server-docs/group/chat/intro?lang=zh-CN))
|
|
- Chat APIs require the operator to be in the same tenant as an internal chat;
|
|
common errors include `Operator and chat can NOT be in different tenants`.
|
|
Many operations additionally require the calling app's bot to be in the
|
|
group and reject an app that is unavailable in that tenant.
|
|
([get chat members](https://open.feishu.cn/document/server-docs/group/chat-member/get?lang=zh-CN))
|
|
- The first bot direct-chat event calls `chat_id` the conversation ID between
|
|
**that robot and user** and includes `app_id`, `tenant_key`, and app-scoped
|
|
user Open IDs in the same event.
|
|
([user and bot first conversation](https://open.feishu.cn/document/server-docs/group/chat-member/event/bot-events?lang=zh-CN))
|
|
|
|
### What the official sources do not establish
|
|
|
|
The cited documentation calls `chat_id` a conversation/group identifier but
|
|
does not state that its value is globally unique across every Feishu tenant and
|
|
every independently created app. It does establish tenant and bot-membership
|
|
authorization boundaries. Under the repository rule that unspecified scope
|
|
must not be assumed, raw global uniqueness would be an unsupported inference.
|
|
|
|
Derived storage/call rules:
|
|
|
|
- Store chats as `(feishu_app_connection_id, chat_id)` and retain observed
|
|
`tenant_key` and chat type. Do the same for other resource identifiers unless
|
|
Feishu explicitly contracts a wider scope.
|
|
- Always call chat/message APIs with the token obtained from the chat's bound
|
|
connection. Never retry an authorization failure with another Organization's
|
|
token.
|
|
- Treat bot direct chats as connection-specific. A `chat_id` for user X's
|
|
conversation with Organization A's bot is not evidence of a conversation
|
|
with Organization B's bot.
|
|
- For group chats, two customer apps may both be members of a real Feishu
|
|
group, but that does not authorize the Hub to move configuration, Projects,
|
|
or memberships between Organizations. The authenticated connection remains
|
|
the tenant route and Hub Organization remains the authorization root.
|
|
|
|
## Minimum invariants for implementation review
|
|
|
|
1. `FeishuIdentity` uniqueness is `(connection_id, open_id)`, never bare
|
|
`open_id`.
|
|
2. OAuth `state` resolves one server-side transaction bound to one Organization
|
|
and connection; code exchange uses only that connection's credentials and
|
|
stored redirect URI.
|
|
3. Login accepts the returned identity only when `tenant_key` matches the
|
|
connection's pinned tenant.
|
|
4. HTTP Webhooks choose a connection-specific verification/decryption context
|
|
before trusting payload routing fields; authenticated `app_id` and
|
|
`tenant_key` must match it.
|
|
5. Every long-connection handler carries the local connection/Organization
|
|
binding of the App ID/App Secret client that received the frame.
|
|
6. Bot, chat, event-deduplication, token-cache, and user identifiers are all
|
|
namespaced by the Feishu connection unless Feishu explicitly guarantees a
|
|
wider scope.
|
|
7. A credential, app, or tenant mismatch is a hard failure with traceable
|
|
logging; there is no fallback to a default app or search across tenant
|
|
secrets.
|