接入手机 App
做完后,App 通过自己的登录客户端(client)用 BES 账号登录并选租户(tenant),令牌绑定设备上的密钥;App 只连登录地址,不带证书。
| 环境 | 登录地址 | 带证书调用的接口地址 |
|---|---|---|
local | https://id.local.globalbes.sg | https://id.local.globalbes.sg:19443 |
test | https://id.test.globalbes.sg | https://id.test.globalbes.sg:9443 |
staging | 待定 | 待定 |
prod | 待定 | 待定 |
Sign in to send requests to see which of these steps are done. Sign in
登记 App 的登录客户端
在哪里做:测试台
登记 App 的登录客户端 bim-app,得到它的 client_id。App 打开登录页和换令牌时带上。
在测试台点本步的 Client Register 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"request_ref": "register-bim-app",
"client_id": "bim-app",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"deployment_version": "v1",
"display_name": "BIM app",
"kind": "public_native",
"auth_method": "none",
"redirect_uris": [
"com.example.bim://callback"
],
"scopes": [
"openid",
"profile"
],
"dpop_required": true,
"token_profile": "native"
}
返回:
{
"client_id": "bim-app",
"display_name": "BIM app",
"kind": "public_native",
"auth_method": "none",
"redirect_uris": [
"com.example.bim://callback"
],
"post_logout_redirect_uris": [],
"backchannel_logout_uri": null,
"scopes": [
"openid",
"profile"
],
"dpop_required": true,
"token_profile": "native",
"service_account_id": null,
"status": "pending",
"version": 0,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"public_key_jwk": null,
"deployment_version": "v1",
"client_secret": null
}
- 和产品(product)的网页登录客户端分开登记。
kind: public_native、auth_method: none、dpop_required: true、token_profile: native,没有密钥。redirect_uris写 App 的回调,例如自定义 schemecom.example.bim://callback。
错误:
| HTTP | 错误码 | 处理 |
|---|---|---|
| 409 | CLIENT_ID_CONFLICT |
这个 client_id 已被占用,换一个。 |
启用 App 的登录客户端
在哪里做:测试台
把 bim-app 改成 active,得到可用的登录客户端。App 从此能登录。
在测试台点本步的 Client Set Status 标签,按下面的值填表(id 用前面步骤返回的),点 Send request。
填表的值:
{
"client_id": "bim-app",
"request_ref": "activate-bim-app",
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"status": "active"
}
返回:
{
"client_id": "bim-app",
"display_name": "BIM app",
"kind": "public_native",
"auth_method": "none",
"redirect_uris": [
"com.example.bim://callback"
],
"post_logout_redirect_uris": [],
"backchannel_logout_uri": null,
"scopes": [
"openid",
"profile"
],
"dpop_required": true,
"token_profile": "native",
"service_account_id": null,
"status": "active",
"version": 1,
"product_id": "b41d9a70-6c2e-4a0f-8e7d-52a9c3f0d614",
"public_key_jwk": null,
"deployment_version": "v1"
}
- App 会话最长 30 天,7 天不用就结束。
做完这一部分,手里有:App 的 client_id(bim-app),状态 active。App 登录时用它,没有密钥。
生成设备密钥
在哪里做:App 代码
App 首次启动时在设备上生成一对密钥,得到 dpop_jkt。登录时带上,之后每个请求都用这把密钥签名。
- 首次启动时在设备的安全硬件(Android Keystore / iOS Secure Enclave)生成 EC P-256 密钥对,整个安装期间保留。
- 公钥的 JWK 指纹(RFC 7638,base64url,43 个字符)就是
dpop_jkt。
打开登录页
在哪里做:App 代码
App 用系统浏览器打开 BES 登录页。用户登录完,App 的回调收到 code,下一步用它换令牌。
用系统浏览器(Custom Tabs / ASWebAuthenticationSession)打开:
GET https://id.test.globalbes.sg/authorize?response_type=code&client_id=bim-app&redirect_uri=com.example.bim%3A%2F%2Fcallback&scope=openid%20profile&state=<state>&nonce=<nonce>&code_challenge=<code_challenge>&code_challenge_method=S256&dpop_jkt=<dpop_jkt>
state、nonce、code_challenge(只支持S256)和dpop_jkt都必填。code_challenge= BASE64URL(SHA256(code_verifier))。
用 code 换令牌
在哪里做:App 代码
带 DPoP 证明用 code 换令牌,得到绑定设备密钥的 access_token 和 refresh_token。存在设备上,选租户和调接口时用。
不带登录客户端认证,带 DPoP 证明:
POST https://id.test.globalbes.sg/oauth/token Content-Type: application/x-www-form-urlencoded DPoP: <dpop_proof> grant_type=authorization_code&client_id=bim-app&code=<code>&code_verifier=<code_verifier>&redirect_uri=com.example.bim%3A%2F%2Fcallback
DPoP 证明是用设备密钥签名的 JWT:
- header:
typ为dpop+jwt,alg为ES256,jwk为公钥(ktyEC、crvP-256、x、y)。 htm:本次请求的 HTTP 方法,例如POST。htu:本次请求的完整地址,不含查询串,用https://id.test.globalbes.sg拼,例如https://id.test.globalbes.sg/oauth/token。iat:当前时间(秒),接受 60 秒前到 5 秒后。jti:每个请求一个新的随机值,重复的被拒;重试也要新证明。ath:带access_token的请求要有,值是这个令牌的 SHA-256,base64url。- 之后带令牌的请求用
Authorization: DPoP <access_token>加新的证明。
刷新令牌
在哪里做:App 代码
access_token 过期前带 DPoP 证明换一对新令牌。存下新的 refresh_token,下次刷新用。
POST https://id.test.globalbes.sg/oauth/token Content-Type: application/x-www-form-urlencoded DPoP: <dpop_proof> grant_type=refresh_token&client_id=bim-app&refresh_token=<refresh_token>
- 证明用同一把设备密钥签名。
- 每个
refresh_token只能用一次,存下新的那个。
做完这一部分,手里有:设备密钥、绑定它的 access_token 和 refresh_token。之后每个请求都用它们。
列出可进的租户
在哪里做:App 代码
带令牌列出此人在本产品能进的租户和项目,得到租户列表。App 用它显示自己的选择页。
GETSession Tenant List程序里发这个请求(id 用前面步骤返回的):
带:用户的 access_token
请求:
GET https://id.test.globalbes.sg/api/sessions/tenants Authorization: DPoP <access_token> DPoP: <dpop_proof>
返回:
HTTP/1.1 200 OK
{
"tenants": [
{
"tenant_id": "7c0e2a5e-3b1f-4d6a-9a53-1f2f6d0c8a11",
"tenant_code": "topaz",
"display_name": "Topaz Construction",
"projects": [
{
"project_id": "e2f7a1c4-0d35-4b8e-a6c9-7d18f4b2a903",
"display_name": "Harbour Tower"
}
],
"mfa_required": false
}
]
}
- 每个请求带
Authorization: DPoP <access_token>和新的 DPoP 证明,证明的htm、htu对应本次请求,ath对应这个令牌。 - App 登录客户端(
public_native)必须提供 DPoP;机密 Web 客户端由服务端持有令牌,不要求 DPoP。 - 项目级产品每个租户列出可进的项目;租户级产品
projects为空。 mfa_required: true的租户要比密码更强的登录。
选定租户
在哪里做:App 代码
用户选好后,App 发选定的租户,得到 context_id。调产品接口时带上,产品转给 session.validate。
程序里发这个请求(id 用前面步骤返回的):
带:用户的 access_token
请求:
POST https://id.test.globalbes.sg/api/sessions/tenants/select
Authorization: DPoP <access_token>
DPoP: <dpop_proof>
Content-Type: application/json
{
"tenant_id": "7c0e2a5e-3b1f-4d6a-9a53-1f2f6d0c8a11",
"project_id": "e2f7a1c4-0d35-4b8e-a6c9-7d18f4b2a903"
}
返回:
HTTP/1.1 200 OK
{
"context_id": "0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68",
"tenant_id": "7c0e2a5e-3b1f-4d6a-9a53-1f2f6d0c8a11",
"project_id": "e2f7a1c4-0d35-4b8e-a6c9-7d18f4b2a903",
"expires_at": "2026-01-01T20:00:00+00:00"
}
context_id存在会话里。- 同一租户再选一次返回同一个
context_id。 expires_at是上限,每次使用仍会检查。
做完这一部分,手里有:选定租户的 context_id,App 调产品接口时带上。
结束登录
在哪里做:App 代码
用户退出时,App 带令牌结束在 Control 里的会话,令牌失效。然后删掉设备上的令牌。
POSTSession Logout程序里发这个请求(id 用前面步骤返回的):
带:用户的 access_token
请求:
POST https://id.test.globalbes.sg/api/sessions/logout
Authorization: DPoP <access_token>
DPoP: <dpop_proof>
Content-Type: application/json
{
"login_scope": "client"
}
返回:
HTTP/1.1 204 No Content
login_scope: client只结束 App 的会话;product结束此人在本产品的全部会话。- 然后删掉设备上的令牌;设备密钥保留,下次登录还用。
关闭租户上下文
在哪里做:App 代码
切换租户时关掉旧的 context_id,不登出。之后用新选的 context_id。
程序里发这个请求(id 用前面步骤返回的):
带:用户的 access_token
请求:
DELETE https://id.test.globalbes.sg/api/sessions/contexts/0f6b8d2e-4a17-4c93-9e5b-a1d27c4f3b68 Authorization: DPoP <access_token> DPoP: <dpop_proof>
返回:
HTTP/1.1 204 No Content
做完这一部分,手里有:App 的会话已结束,设备上的令牌删掉,设备密钥保留。