三个看起来相似、实际完全不同的链接

网页里经常能见到下面三类地址:

dingtalk:login

https://applink.dingtalk.com/page/link?url=...

https://id.example.com/authorize?
  response_type=code&client_id=...&redirect_uri=...

它们都能让界面从一个程序跳到另一个程序,因此很容易被笼统地解释成“浏览器识别协议并拉起 App”。这个说法只覆盖了表面现象,甚至会把完全不同的安全边界混在一起:

地址首要处理者点击时一定发 HTTP 请求吗它解决的问题
dingtalk:login浏览器策略 + 操作系统 URI 处理器把一段 URI 交给已注册的应用
https://... 普通网页浏览器 + Web 服务器获取网页或资源
Universal Link / Android App Link操作系统验证过的域名与 App 关联不一定同一 HTTPS 地址在“打开 App”和“打开网站”之间安全降级
OAuth authorize 地址浏览器 + 授权服务器让用户在可信 Web 上完成认证、授权与同意
OAuth 回调 URI浏览器、操作系统或网站后端取决于 scheme把一次授权事务的结果交还给发起方

先给出全文最重要的结论:

Deep Link 不是一种独立网络协议,浏览器登录也不是“拿一个特殊链接换 token”。前者是 URI 语法、浏览器导航策略、操作系统分发和 App 内路由的组合;后者是以浏览器为用户交互通道、以回调 URI 为关联通道、以授权码和 PKCE 为安全绑定的协议状态机。

也就是说,这个题目至少要分成四层研究:

流程图 1流程图 1

“Deep Link”只是对这些能力的产品统称,并不存在一份 RFC 能单独定义从网页点击到任意平台 App 页面打开的全部行为。

dingtalk:login 首先是一段 URI

RFC 3986给出的通用 URI 语法可以简化成:

URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ]

scheme 必须以英文字母开头,后面可以包含字母、数字、+-.。因此 dingtalk:login 在语法上完全成立,不需要写成 dingtalk://login

可以直接用浏览器采用的 WHATWG URL 模型观察它:

for (const value of [
  "dingtalk:login",
  "dingtalk://dingtalkclient/page/link?url=https%3A%2F%2Fexample.com",
]) {
  const url = new URL(value);
  console.table({
    href: url.href,
    protocol: url.protocol,
    host: url.host,
    pathname: url.pathname,
    search: url.search,
    origin: url.origin,
  });
}

关键结果是:

输入schemeauthority / hostpathorigin
dingtalk:logindingtalkloginnull
dingtalk://dingtalkclient/page/link?...dingtalkdingtalkclient/page/linknull

WHATWG URL Standard把第一种非 special scheme、没有 // 的形式建模为带 opaque path 的 URL。RFC 3986 使用 path-rootless 描述它的通用语法。两套术语的关注点不同,但都说明了同一件事:login 不是域名,也不是服务器地址。

这会直接影响代码。若服务端或客户端拿到 dingtalk:login 后检查 url.host === "login",结果一定不符合预期。dingtalk://login 才有 host,二者不是可以随意互换的“写法风格”。对于私有 scheme,冒号后的具体语义必须由 scheme 所有者自己定义。

scheme 不等于网络传输协议

大家习惯把 https:mailto:tel:dingtalk: 都叫“协议”,但 URI scheme 只负责标记后续内容应按哪套规则解释。

  • https: 通常进入 DNS、TCP/QUIC、TLS 和 HTTP 处理链。
  • mailto: 通常交给邮件处理器,自己并不发送邮件。
  • tel: 通常交给拨号界面,自己并不建立通话。
  • dingtalk: 通常交给本机注册的应用,自己不对应一个名叫 dingtalk 的网络服务器。

因此,点击 dingtalk:login 时通常看不到 DNS、TLS 或 HTTP 报文。浏览器 Network 面板没有请求,不是“请求太快没抓到”,而是这条路径本来就不走 Fetch/HTTP。

公共 scheme 与私有 scheme

IANA 维护URI Schemes 注册表。截至本文核对日期,dingtalk 不在其中;它是由产品和操作系统本地约定的私有 scheme,而不是 IANA 注册的互联网通用 scheme。

未注册不等于非法,但意味着它没有天然的全局所有权。RFC 7595建议私有 scheme 使用自己控制域名的反向形式,例如:

com.example.product:/open/order/123

这能降低重名概率,却不能阻止恶意 App 也声明同一个 scheme。命名唯一性和处理器所有权证明是两回事。

钉钉案例能证实到哪一层

钉钉很适合作为案例,因为公开资料、本机注册信息和私有实现之间恰好有一条清楚的边界。

我在 macOS 上检查已安装的钉钉 8.3.15:

plutil -p /Applications/DingTalk.app/Contents/Info.plist

其中可以看到:

"CFBundleURLTypes" => [
  {
    "CFBundleURLName" => "5ZSL2CJU2T.com.dingtalk.mac"
    "CFBundleURLSchemes" => [
      0 => "dingtalk"
    ]
  }
]

这是一条很强的本地证据:安装包通过 Apple 定义的 CFBundleURLTypes 声明自己能处理 dingtalk:。macOS Launch Services 发现应用后,将这类声明写入自己的应用绑定数据库;以后某个程序请求打开该 scheme,系统就能定位、启动或激活处理器并把原始 URI 交过去。Apple 的自定义 URL Scheme 文档Launch Services 概念描述的正是这套机制。

钉钉官方公开的旧版统一跳转协议也展示了 dingtalk://dingtalkclient/... 路由,页面现已明确标记为废弃;官方推荐的新入口是 https://applink.dingtalk.com/... 形式的 DingTalk AppLink

但公开资料没有定义 dingtalk:loginlogin 的稳定参数契约。这意味着可以确认:

  1. 它符合 URI 语法;
  2. 钉钉安装包注册了 dingtalk scheme;
  3. 系统能够把 URI 交给钉钉;
  4. 钉钉公开提供过同 scheme 的其他跳转路由。

不能仅凭这些证据断言 login 在每个平台、每个版本中一定执行哪段内部逻辑。那部分属于钉钉客户端路由器的私有实现。研究这类链接时,把“标准可推出的结论”和“反编译或观察得到的版本事实”分开,比猜一个听起来合理的内部流程更重要。

从网页点击到 App 收到 URI,中间发生了什么

以用户点击下面的链接为例:

<a href="dingtalk:login">打开钉钉</a>

典型调用链如下:

流程图 2流程图 2

浏览器做的是 hand-off,不是直接执行 App

WHATWG HTML 的非 Fetch scheme 导航算法明确为外部软件保留了 hand-off 步骤,同时要求浏览器考虑发起页面的 origin、瞬时用户激活、iframe sandbox 和安全提示。

所以“Chrome 能拉起钉钉”和“Safari 能拉起钉钉”并不是浏览器各自内置了一份钉钉路径。浏览器识别这是自己不能 Fetch 的 scheme,应用自身的注册和选择由操作系统完成;浏览器只决定当前网页是否有资格触发这次外部交接。

真实浏览器通常还会增加实现策略:

  • 要求由点击等用户手势触发;
  • 首次调用时显示“是否允许此网站打开某应用”;
  • 阻止定时器、隐藏 iframe 或连续重试制造的自动拉起;
  • 对跨源 iframe、sandbox iframe 和重定向链施加更严格限制;
  • 记住或撤销某个站点的外部协议权限;
  • 当没有处理器时显示错误、忽略导航或进入厂商定义的 fallback。

Chrome 在 Android 上的外部 Intent 文档明确说明:没有可解析目标、由无用户手势的 JavaScript 定时器触发,或从地址栏输入产生的页面重定向等场景,外部 App 可能不会启动。具体提示和限制是浏览器策略,不是 RFC 3986 的一部分,不能用一次 Chrome 实验外推所有平台。

必须走“导航”,不是 fetch

下面几种代码虽然都接收 URL,语义却不同:

// 用户导航:可以进入外部软件 hand-off 流程
location.href = "dingtalk:login";

// 超链接导航:最符合浏览器的用户激活模型
// <a href="dingtalk:login">打开 App</a>

// 资源获取:不是可移植的 App 拉起方式
await fetch("dingtalk:login");

Fetch Standard 只把 aboutblobdatafile 和 HTTP(S) 等列为 fetch scheme。fetch() 遇到普通私有 scheme 通常得到不支持 scheme 的网络错误;它不会因为系统中存在 URI 处理器就替网页启动 App。

为什么浏览器不能可靠告诉网页“App 是否安装”

如果网页可以静默枚举大量私有 scheme,就能把安装应用列表变成设备指纹。因此浏览器刻意限制外部调用、确认提示和可观测结果。

常见的旧式 fallback 是先跳私有 scheme,再用定时器跳下载页:

location.href = "com.example.product:/open";
setTimeout(() => {
  location.href = "https://example.com/download";
}, 1200);

它并不能证明安装状态。App 成功打开时页面计时器可能暂停;用户稍后回到浏览器,定时器又把他送进下载页。后台节流、确认弹窗、低性能设备和不同浏览器还会产生相反结果。visibilitychange 可以减少误判,却仍不是安装证明。

更稳妥的方向是使用经过域名验证的 HTTPS 链接,或者使用浏览器明确支持的 fallback,例如 Chrome Android intent: URI 中的 S.browser_fallback_url。不要用隐藏 iframe 高频探测私有 scheme。

操作系统怎样注册和分发 scheme

所有主流桌面和移动系统都遵循“应用声明能力,系统保存绑定,调用方请求系统打开 URI”这个模型,但声明位置和投递对象不同:

平台注册入口系统交给 App 的对象冲突行为
iOS / iPadOSInfo.plistCFBundleURLTypesURL open / scene 回调多 App 注册同 scheme 时目标不受应用保证
macOSApp bundle 的 CFBundleURLTypes,由 Launch Services 登记URL / GURL 激活事件按用户偏好与 Launch Services 规则选择
AndroidManifest 中 ACTION_VIEWDEFAULTBROWSABLE 的 intent filter含 data URI 的 Intent可能出现选择器;同名 scheme 可被其他 App 声明
Windows包清单 windows.protocol,或未打包应用的协议注册Protocol activation 参数用户选择默认处理器;多个应用可以注册
Web / PWAnavigator.registerProtocolHandler() 或 Web App Manifest浏览器把原 URI 填入 HTTPS handler URL仅允许安全列表或 web+...,且依赖浏览器与用户同意

Android 自定义 scheme 的最小声明类似这样:

<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="com.example.product" />
</intent-filter>

BROWSABLE 表示这个 Activity 接受来自浏览器等外部来源的调用。它同时意味着入口已经进入公开攻击面;App 收到 URI 后仍需做业务鉴权,不能因为 Intent 由系统投递就信任参数。

Web 的 registerProtocolHandler() 又是另一回事。HTML Standard 的 Custom Handlers只允许安全列表中的 scheme,或名称形如 web+example 的自定义 scheme;处理器本身必须是 HTTPS Web 页面。它可以让网页邮箱参与处理 mailto:,但不能让任意站点把 dingtalk: 静默改绑给自己,也不等同于原生 App 的安装清单。

Custom Scheme、验证型 HTTPS 链接与厂商跳转页

“能打开 App 的链接”至少有三种工程结构。

私有 Custom Scheme

com.example.product:/orders/123

优点是简单、不需要域名,旧系统也普遍支持。缺点是没有域名所有权验证、无 App 时通常没有自然网页 fallback,而且任意 App 都可能声明相同 scheme。

适合受控的 App-to-App 内部跳转和遗留兼容,不应作为新登录回调的首选。

操作系统验证过的 HTTPS 链接

https://app.example.com/orders/123

Apple 称为 Universal Links,Android 称为 Android App Links,Windows 称为 Apps for Websites。它们没有把整个 https: scheme 注册给某个 App,而是验证“某个域名允许某个已签名 App 处理特定链接”。

流程图 3流程图 3

Apple 的关联域名文档要求 App 带有 applinks:app.example.com entitlement,网站在以下位置提供无重定向的 JSON 文件:

https://app.example.com/.well-known/apple-app-site-association

Android 则在 Manifest 中使用 android:autoVerify="true",网站提供:

https://app.example.com/.well-known/assetlinks.json

文件将域名与 Android package name、签名证书 SHA-256 指纹关联。系统安装或更新应用时完成验证;点击发生时,已验证链接可直接路由到 App。Android 官方 App Links 验证文档还提供 pm get-app-links 等命令检查设备上的真实验证状态。

这带来一个容易忽略的结论:HTTPS 外形并不保证点击当下发生 HTTP 请求。 当操作系统已把链接交给 App,目标网页可能根本没有被访问;只有 fallback 到浏览器时才进入 DNS、TLS 和 HTTP。用于验证关联的 JSON 则可能在安装、更新或系统重新验证时提前请求。

验证型链接也不是绝对强制。用户可以更改默认打开设置;Apple 还会根据上下文尊重用户继续留在浏览器的意图,例如 Safari 中点击同域 Universal Link 时可能继续留在 Safari。测试必须覆盖入口上下文,而不是只测一个二维码扫描器。

厂商的 HTTPS 跳转页

还有一种常被也叫作 “AppLink” 的产品方案:先打开厂商控制的 HTTPS 页面,再由页面显示“打开 App / 下载 App”,或尝试 Custom Scheme。

钉钉的 AppLink 结构说明写明:链接在钉钉内会进入对应功能,在钉钉外会先打开网页提示下载或打开钉钉;低于最低支持版本时,网页还会尝试映射到 dingtalk://dingtalkclient/${path}。例如打开普通页面使用:

https://applink.dingtalk.com/page/link?url=<encoded-url>

这里的 “DingTalk AppLink” 是钉钉定义的产品协议名,不要仅凭名称把它等同于 Android App Links 规范,也不能据此推断它在每个平台一定通过系统级域名关联打开。可确认的公开契约是 HTTPS 落地页、钉钉内路由和官方描述的降级行为。

App 为什么要跳到浏览器登录

到这里为止,所有讨论只解决“把 URI 交给谁”,还没有解决身份认证。

一个 App 打开系统浏览器完成登录,通常采用 OAuth 2.0 Authorization Code Flow;如果目标是确认用户身份,还会使用 OpenID Connect,或者调用提供方自己的用户身份 API。

它有意把登录页面放到外部用户代理,而不是普通 WebView 中。RFC 8252:OAuth 2.0 for Native Apps要求原生应用通过 external user-agent 发起 OAuth 授权,原因包括:

  • 用户能看到并信任身份提供方的真实域名;
  • 宿主 App 不能注入脚本、读取密码或任意查看登录 DOM;
  • 浏览器可以复用已有 Cookie、Passkey、密码管理器和多因素认证;
  • 身份提供方能维持自己的风控与反钓鱼能力;
  • 多个 App 可以共享浏览器中的单点登录状态,而不共享凭据本身。

在 iOS 上,合适的入口是 ASWebAuthenticationSession;在 Android 上通常是浏览器提供的 Custom Tabs。它们的界面可能覆盖在 App 上方,但内容、Cookie 和安全边界仍由浏览器控制,不是宿主自由注入 JavaScript 的 WebView。Android 官方也明确建议第三方 “Sign in with …” 使用 Custom Tabs,以隔离第三方凭据。

浏览器能免输密码,通常是因为身份提供方域名已有登录 Cookie。App 不需要、也不应该读取这枚 Cookie。

浏览器完成认证和同意后,授权服务器返回一个短期、一次性的 authorization code;App 再通过独立 HTTPS 请求,用 code 换取 token。跨越浏览器与 App 边界的是授权响应 URI,不是浏览器 Cookie,也不应是长期 access token。

授权码 + PKCE 的完整回跳

下面是一条通用的原生 App 登录时序:

流程图 4流程图 4

发起授权时的参数大致如下:

https://id.example.com/authorize?
  response_type=code
  &client_id=native-client
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  &scope=openid%20profile
  &state=<high-entropy-random>
  &nonce=<high-entropy-random>
  &code_challenge=<base64url-sha256>
  &code_challenge_method=S256

回调只携带本次事务的结果:

https://app.example.com/oauth/callback?
  code=<short-lived-one-time-code>
  &state=<original-state>
  &iss=https%3A%2F%2Fid.example.com

然后 App 向 token endpoint 发送:

grant_type=authorization_code
client_id=native-client
code=<authorization-code>
redirect_uri=https://app.example.com/oauth/callback
code_verifier=<original-verifier>

PKCE 解决的不是“加密回调 URL”

RFC 7636定义的 PKCE 把授权码绑定到发起授权的客户端实例:

  1. App 生成高熵 code_verifier,只保存在本地待处理事务中;
  2. 授权请求只发送其 SHA-256 派生的 code_challenge
  3. token endpoint 只有在 code_verifier 与 challenge 匹配时才兑换 code。

即使恶意 App 抢到了 Custom Scheme 回调中的 code,没有 verifier 也无法兑换。PKCE 没有隐藏 URL,也不替代 TLS;它证明“来兑换 code 的客户端持有发起时的随机秘密”。

RFC 9700:OAuth 2.0 Security Best Current Practice已经把 PKCE 的建议扩展到各种 OAuth 客户端,而不只原生 App。应使用 S256,每次事务生成新 verifier,不能把固定字符串写进安装包。

statenonceiss 各管一件事

字段绑定对象主要防御
state浏览器回调与本地待处理事务登录 CSRF、回调串线,并恢复受控的本地上下文
PKCE verifier/challenge授权码与发起客户端实例code 被截获或注入后遭他人兑换
OIDC nonceID Token 与认证请求ID Token 重放和认证事务串线
OAuth iss响应与预期授权服务器多身份提供方场景中的 mix-up attack

不要把 state 直接做成任意 returnUrl。更安全的方式是在服务端或本地事务表中保存 state -> 受控目标 映射;回调只接收随机 state,目标页面必须来自 App 内路由或 HTTPS allowlist。

原生 App 不是能保守 client secret 的机密客户端

安装包中的共同 secret 最终可以被提取,因此不能靠在移动端硬编码 client_secret 证明 App 身份。原生 App 通常按 public client 注册,以 PKCE 保护 code;需要机密客户端能力时,让自己的后端持有 secret,并把 App 与后端之间的会话设计成另一条明确的安全边界。

如果某身份提供方只公布了需要 AppSecret、却没有 PKCE 的 Web 端流程,不应自行把 AppSecret 塞进原生应用。应使用该提供方正式支持的原生 SDK、验证型回调或后端代理方案。

厂商只支持机密 Web 客户端时,用 BFF 做回调桥

这时可以把“身份提供方回调”和“回到 App”分成两次受控交接:

流程图 5流程图 5

handoff code 应短期、一次性,与初始事务及客户端实例绑定。Deep Link 中不传身份提供方的 access token、refresh token 或 client secret。钉钉当前公开的登录用户访问凭证流程要求在换取凭证时提交 clientSecret,而该页没有声明 Native PKCE 回调协约;因此不应把这份 Web 文档自行外推成“可在 App 内直接存 secret”。

浏览器最后为什么又能回到 App

授权服务器在浏览器中完成流程后,返回 HTTP 重定向,例如:

HTTP/1.1 302 Found
Location: com.example.product:/oauth/callback?code=abc&state=xyz

浏览器先完成 HTTPS 请求与响应处理;随后发现 Location 指向非 Fetch scheme,于是再次进入前文的 external software hand-off。操作系统找到处理器,App 可能被冷启动,也可能在后台恢复。

OAuth 并没有创造一条绕过系统的新通道,它只是把回调 URI 纳入了一次受约束的授权事务。能打开 App 的 URL 只负责投递,state、PKCE、授权码一次性、redirect URI 注册和 token endpoint 校验才负责安全。

Apple 的 ASWebAuthenticationSession 还会让调用方预先声明 callback matcher。系统识别匹配回调后,将结果交给发起这次认证 session 的 App;这比任意浏览器页面直接打开同名 scheme 多了一层会话绑定。Android 则通常依赖 App Link / Intent 分发和 PKCE 完成相应保护。

三种原生回调方式

RFC 8252定义了三类回调:

方式示例适用场景主要风险与要求
Claimed HTTPShttps://app.example.com/oauth/callback支持域名关联的移动/桌面平台推荐;需证明域名与签名 App 关联
Private-use schemecom.example.product:/oauth/callback遗留平台或无法使用 claimed HTTPSscheme 可被抢注;必须使用反向域名命名和 PKCE
Loopbackhttp://127.0.0.1:{randomPort}/callback桌面原生应用仅监听 loopback,运行时随机端口,校验事务并尽快关闭监听器

优先顺序通常是验证型 HTTPS,其次才是 Custom Scheme。桌面应用没有稳定系统域名关联时,loopback 是标准化选择;不要绑定 0.0.0.0,也不要使用固定、可被其他进程抢占的端口。

Web 网站登录与 Native App 登录不是同一回调

第三方网站“使用某账号登录”时,redirect_uri 往往是网站自己的 HTTPS 后端:

流程图 6流程图 6

这里最终留在浏览器中,不需要拉起原生 App。网站拿到的也不是身份提供方 Cookie,而是通过后端 code exchange 验证的身份结果,再签发自己域名下的 session Cookie。

OAuth 负责授权,OIDC 才标准化“登录身份”

OAuth 2.0的核心目标是让客户端获得访问资源的授权。只拿到 access token,不自动等于安全完成了“这个人是谁”的登录协议。

OpenID Connect Core在 OAuth 2.0 之上增加身份层,使用 ID Token 和标准 claims 表达认证结果。OIDC 客户端还要校验签名、issaud、有效期和 nonce,不能只把 JWT 解码后相信其中字段。

有些服务商会使用名为 openid 的 scope,却通过自有接口返回用户身份,并不完整实现 OIDC Discovery、ID Token 和标准验证链。判断是否为 OIDC 要看完整契约,不能只看一个参数名。

钉钉当前的获取登录用户访问凭证文档展示了自己的 OAuth 页面:

https://login.dingtalk.com/oauth2/auth?
  redirect_uri=...
  &response_type=code
  &client_id=...
  &scope=openid
  &state=...
  &prompt=consent

成功后,钉钉按文档约定将 authCodestate 带回登记的回调地址,再由服务端换取用户访问凭证。参数名、scope 和 token API 是提供方契约;通用 OAuth/OIDC 客户端不能假定所有服务都使用完全相同的字段,也不能反过来假定钉钉文档未声明的 PKCE 能力一定存在。

扫码登录其实是跨设备事务绑定

PC 网页展示二维码、手机 App 扫码确认,是另一条常被误认为“Deep Link 登录”的链路。

它的通用参考模型是:

流程图 7流程图 7

二维码通常只需要携带一次事务的定位信息或签名数据,不应放用户名、密码、长期 access token。手机和 PC 也不必直接通信;双方分别通过 TLS 连接服务器,由服务器完成事务状态转换。

浏览器怎样获知“已扫码 / 已确认”可能使用轮询、长轮询、SSE 或 WebSocket,这是服务实现细节,不是扫码登录的安全本质。没有官方证据时,不应看到页面持续更新就断言一定采用 WebSocket。

钉钉官方的网页登录文档提供 DTFrameLogin 内嵌二维码方式:SDK 成功回调中会给出 redirectUrlauthCodestate,开发者可以跳转或直接处理 code;官方还要求嵌入二维码的页面与 redirect_uri 同源。公开文档并没有承诺浏览器内部用哪种实时通道收到扫码结果,因此能确认的是 OAuth 事务和前端回调契约,而不是它的内部消息实现。

不要把“有二维码”自动叫作 Device Flow

RFC 8628 的 OAuth Device Authorization Grant 是一份具体协议:受限设备先获得 device_codeuser_codeverification_uri 与可选的 verification_uri_complete,用户在另一设备完成确认,原设备按协议轮询 token endpoint。二维码可以编码完整验证 URI,但仍应向用户展示并核对 user_code,防止远程诱导批准错误设备。

它主要面向电视、打印机等输入受限或不便使用浏览器的设备,不是所有“扫码登录”的总称。钉钉公开的 DTFrameLogin 契约是成功回调 authCode,并未公开 RFC 8628 所规定的 device authorization response 与 token polling 契约;因此只能称其为扫码认证交互,不能据此断定它就是标准 Device Flow。

扫码登录必须特别防御二维码替换和 login CSRF:手机确认页要展示正在登录的站点、设备或业务,事务需短期、一次性并绑定发起浏览器;“扫到就直接登录”会让攻击者更容易诱导受害者批准错误会话。

一张图看清四种完整链路

流程图 8流程图 8

链路 A 只证明“某个处理器收到了输入”;链路 B 增加域名所有权与 App 身份关联;链路 C 再增加授权服务器、事务绑定和 code exchange;链路 D 则把用户确认放到另一台已经登录的设备上。它们不能互相替代。

最容易出问题的安全边界

1. Custom Scheme 被同名 App 抢占

Apple 官方明确说明多个 App 注册同一 scheme 时,目标并不由应用保证;Android 也允许多个 intent filter 匹配同一自定义 URI。攻击者可能截获 OAuth code、magic link 或业务参数。

优先使用验证型 HTTPS 链接。不得不用 Custom Scheme 做 OAuth 回调时,采用反向域名 scheme、Authorization Code + PKCE,并让 code 短期且只能使用一次。

2. 把“能打开 App”误当成“调用方可信”

任何网页、App、二维码或命令行都可以构造你的私有 URI。Windows 官方的 URI activation 安全说明甚至直接提醒:协议激活不提供可靠的调用者身份。

App 必须把所有入站 URI 当成不可信输入:

  • scheme、host、path 和 action 使用精确 allowlist;
  • 限制参数类型、长度、数量与字符集;
  • 解析后比较,不用 startsWith("https://trusted.example")
  • 敏感操作重新检查当前登录态和业务授权;
  • 删除、转账、发消息等不可逆操作必须二次确认;
  • 不把 URI 参数直接传给 Shell、SQL、文件路径、WebView 或脚本执行器。

Apple 和 Android Deep Link 安全指南都把参数校验与敏感状态复核列为核心防线。

3. 嵌套 URL 变成开放重定向

许多 Deep Link 会携带下一跳:

com.example.product:/open?url=https%3A%2F%2Fexample.com

如果 App 解码后直接交给浏览器或 WebView,攻击者就能替换成钓鱼站、javascript:file:intent: 或另一个私有 scheme。安全实现要把嵌套 URL 再次结构化解析,只允许 https 和明确 host,拒绝用户信息、异常端口及不需要的 fragment。

编码也要按层处理:外层 query 编码一次,取出参数后解码一次,再把它作为独立 URL 解析。反复 decodeURIComponent() 会把原本作为数据的 %2F%3F%252F 变成新的结构分隔符。

4. 回调 URI 过宽或 redirect URI 匹配宽松

授权服务器应预注册完整 redirect URI,并做精确匹配;通配域名、任意 query 重定向和开放 redirector 都可能泄漏 code。客户端收到回调后也要同时核对 scheme、host、path、state、预期 issuer 和本地待处理事务。

5. 把 token 放进前端回调

URL 可能进入浏览历史、系统日志、崩溃报告、代理日志、截图和 Referer 链。回调只传短期 code、state 和错误信息;access token、refresh token、密码和长期 magic credential 不应出现在 Deep Link 中。

Web callback 完成后应立即兑换 code、建立自己的 session,再跳转到不含敏感 query 的干净地址。日志默认脱敏 codestate、token、手机号和完整二维码内容。

回调响应建议设置 Cache-Control: no-storeReferrer-Policy: no-referrer,页面不加载第三方资源;消费 code 后用重定向或 history.replaceState() 清理地址栏,避免回调参数留在历史记录或 Referer 中。

6. 使用普通 WebView 承载第三方登录

普通 WebView 的宿主可以注入脚本、观察导航和自定义 TLS/证书行为,用户也难以判断真实域名。原生 OAuth 使用系统认证 session / Custom Tabs;只有自己控制的第一方账号页面,且明确评估凭据和 Cookie 边界后,才讨论是否使用 WebView。

7. 冷启动和重复回调破坏事务

回调到达时 App 可能已被系统杀死。只把 verifier 和 state 放在某个页面内存里,会导致成功登录后找不到原事务。另一方面,浏览器重试、用户返回或恶意调用可能让同一回调到达多次。

待处理事务应有安全的短期持久化,包含创建时间、预期 issuer、redirect URI、state 摘要和 verifier;成功或失败后原子消费。UI 路由必须幂等,过期、重复和不存在的事务都进入明确错误页,而不是默认登录成功。

下面是与平台无关的伪代码,重点不是语法,而是校验顺序:

type Route =
  | { kind: "order"; id: string }
  | { kind: "oauth-callback"; code: string; state: string };

function parseIncomingUri(raw: string): Route {
  if (raw.length > 4096) throw new Error("uri_too_long");

  const url = new URL(raw);
  if (url.username || url.password || url.hash) {
    throw new Error("unexpected_url_component");
  }

  // 这是 com.example.product:/...,没有 host。
  if (url.protocol !== "com.example.product:" || url.host !== "") {
    throw new Error("unexpected_handler");
  }

  if (url.pathname === "/orders/open") {
    const id = url.searchParams.get("id");
    if (!id || !/^[A-Z0-9]{1,32}$/.test(id)) {
      throw new Error("invalid_order_id");
    }
    return { kind: "order", id };
  }

  if (url.pathname === "/oauth/callback") {
    const code = url.searchParams.get("code");
    const state = url.searchParams.get("state");
    if (!code || !state) throw new Error("invalid_oauth_response");
    return { kind: "oauth-callback", code, state };
  }

  throw new Error("unknown_route");
}

真正执行 OAuth callback 时还要从事务存储中查 state、常量时间比较、验证过期时间和 issuer,然后携带正确 verifier 去 token endpoint。解析 URI 的成功不等于 OAuth 校验成功。

如果使用 com.example.product://oauth/callbackoauth 会落到 host,路由规则要相应改变。团队应选定一种 URI 形态并写成版本化契约,不要让 iOS、Android、Web 和服务端各自凭感觉拆字符串。

怎样把这套链路查清楚

排查时先问“当前停在哪一层”,不要一看到跳转失败就改 OAuth 配置。

第一层:URL 是否按预期解析

const u = new URL(input);
console.table({
  href: u.href,
  protocol: u.protocol,
  host: u.host,
  pathname: u.pathname,
  search: u.search,
  origin: u.origin,
});

重点检查 ://、单斜杠、host、path、大小写和 percent-encoding。不要用正则自己实现通用 URL parser。

第二层:操作系统是否存在处理器

macOS 可以检查 App 的 Info.plist,并在测试环境运行:

open 'com.example.product:/orders/open?id=A123'

Android 可以直接测试 Intent:

adb shell am start \
  -W \
  -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE \
  -d 'com.example.product:/orders/open?id=A123'

Windows PowerShell 可以使用:

Start-Process 'com.example.product:/orders/open?id=A123'

如果系统命令都不能投递,问题在安装清单、scheme 冲突或 OS 绑定,不在浏览器页面。

第三层:浏览器是否允许 hand-off

用真正的用户点击测试,记录浏览器版本、顶层/iframe、是否 sandbox、是否跨源重定向以及确认弹窗选择。Network 没有请求对 Custom Scheme 是正常现象;Console 中的 “unknown scheme” 或 “blocked external protocol” 才更有价值。

不要在同一次实验里同时使用点击、定时器、iframe 和 fallback,否则无法判断是哪条策略生效。

第四层:验证型 HTTPS 是否真的通过域名关联

Android:

adb shell pm verify-app-links --re-verify com.example.product
adb shell pm get-app-links com.example.product

同时检查:

https://app.example.com/.well-known/assetlinks.json

Apple 则检查 Associated Domains entitlement、Team ID / bundle ID 和:

https://app.example.com/.well-known/apple-app-site-association

两个文件都必须使用有效 HTTPS,并满足平台对 Content-Type、重定向和缓存的要求。浏览器最终打开网页时,不能只归因于“App 没装”;验证失败、路径不匹配、用户偏好和同域上下文都可能造成相同行为。

第五层:OAuth 事务是否首尾一致

为每次授权记录脱敏后的 transaction ID,并串起:

authorize-created
  -> browser-opened
  -> callback-received
  -> state-validated
  -> code-exchange-started
  -> token-validated
  -> transaction-consumed

日志记录结果码和耗时,不记录 code、verifier、token 或完整回调 URL。若浏览器已经回到 App,但 token endpoint 报错,继续调整 URI handler 不会解决 redirect_uri_mismatch、verifier 不匹配或 code 重复使用。

必须覆盖的验收矩阵

维度场景
安装状态未安装、已安装、升级后、卸载重装
App 生命周期冷启动、后台、前台、进程被杀后恢复
浏览器系统默认、主流第三方、隐私/临时会话
触发来源顶层点击、二维码、邮件、其他 App、iframe、HTTP 重定向
用户状态身份提供方已登录、未登录、MFA、取消、拒绝授权
URI正常、未知路由、超长、重复参数、双重编码、恶意嵌套 URL
处理器唯一处理器、同 scheme 冲突、用户修改默认打开方式
域名关联验证成功、文件缺失、签名错误、缓存旧文件、路径不匹配
OAuthstate 错误/过期、code 重放、PKCE 错误、issuer 混淆、回调到达两次
网络授权页断网、重定向中断、token exchange 超时、恢复重试

验收标准不应只写“成功拉起”或“登录成功”。至少要能回答:哪个组件拒绝了请求、拒绝原因是什么、用户如何恢复、同一 URI 是否会触发不可逆副作用、敏感参数是否进入日志。

方案选择速查

需求首选方案
自己域名的网页与 App 共用链接Universal Links / Android App Links / Apps for Websites
新建原生 OAuth 登录外部浏览器 + Authorization Code + PKCE + Claimed HTTPS 回调
桌面原生 OAuth,无可用域名关联外部浏览器 + 随机端口 loopback + PKCE
遗留 App-to-App 跳转反向域名 Custom Scheme,并严格校验输入
未安装时要自然展示内容标准 HTTPS 页面,不依赖计时器猜安装状态
Android Chrome 需要明确下载 fallback经评估后使用 intent: + browser_fallback_url
第三方账号 Web 登录HTTPS callback 到后端,后端兑换 code 并签发网站 session
原生 App 对接只支持 client secret 的提供方BFF 持有 secret,再用一次性 handoff code 回到 App
PC 二维码登录短期跨设备事务 + 手机明确确认 + 浏览器 session/code
PWA 处理自定义协议registerProtocolHandler() 支持的 web+...,接受浏览器兼容性限制

最后再看 dingtalk:login

现在可以逐层给出一份不越界的解释:

  1. dingtalk:login 是合法的非 HTTP URI;dingtalk 是 scheme,login 是无 authority 形式的 path,而不是域名。
  2. 网页点击它会发起导航,不会先访问某个 dingtalk 服务器。
  3. 浏览器按非 Fetch scheme 规则检查用户激活、沙箱和权限,再请求操作系统打开 URI。
  4. 钉钉安装包向操作系统注册了 dingtalk scheme,所以系统可以冷启动或激活钉钉并投递原始 URI。
  5. 钉钉内部 router 如何解释 login 是厂商私有、可能随版本变化的契约;公开资料没有给出时不能靠 URI 外形推导。
  6. 如果钉钉再打开 HTTPS 登录页,那是 App 请求系统浏览器处理 https:,进入另一条网络和身份协议链。
  7. 浏览器登录完成后,无论回到网站还是回到 App,都应通过受注册约束的 callback、短期 code、state 和提供方支持的安全机制完成,而不是把浏览器 Cookie 或长期 token 塞进 Deep Link。

真正需要记住的不是某个产品的路由字符串,而是下面这条判断链:

先解析 URI 结构
  -> 判断是导航还是 Fetch
  -> 判断浏览器是否 hand-off
  -> 判断 OS 如何验证并选择处理器
  -> 判断 App 如何校验和路由
  -> 若涉及登录,再单独验证 OAuth/OIDC 事务

只要不把这六层揉成一句“协议拉起”,类似微信、支付宝、Slack、邮件客户端、IDE、密码管理器和企业 SSO 的跳转问题,都可以用同一套方法研究。

参考资料