← 中文资源

简体中文版本

OAuth PKCE 常见陷阱

PKCE 用于阻止授权码被截获,但弱验证器处理、plain 挑战和缺失 state 校验会让它形同虚设。

阅读英文原文

PKCE 防的是什么

授权码流程中,同设备上的恶意应用可以注册自定义 URI scheme,截获携带 code 的重定向。PKCE 增加一个截获者没有的秘密:客户端在授权时发送随机 verifier 的哈希,在换取令牌时发送原始 verifier,单独拿到 code 毫无用处。

常见错误

第一类错误是在授权请求带了 challenge 的情况下,接受没有 code_verifier 的令牌请求,PKCE 变成装饰。第二类是允许 code_challenge_method=plain,此时 challenge 等于 verifier,看到授权请求的攻击者就掌握了秘密;应强制 S256。

第三类是在换取令牌时新生成 verifier,永远无法与已保存的 challenge 匹配。其他问题包括省略 state,留下登录 CSRF 和 mix-up 攻击空间;注册通配或共享 redirect URI;允许 code 复用;以及把 verifier 和 code 一起写进日志。

何时使用与取舍

所有授权码客户端都应使用 PKCE,包括机密 Web 客户端,因为它能防御通过开放重定向注入 code。公共客户端无法保存 client secret,风险最高。隐式流程和密码流程应淘汰,而不是打补丁。

PKCE 增加客户端状态管理,需要跨标签页和应用重启正确保存 verifier。原生应用仍需 claimed HTTPS 或已验证的应用链接,因为任何应用都能注册自定义 scheme。服务端必须拒绝不匹配,而不是降级发令牌。

示例与常见误解

攻击者的应用注册 com.example.app://callback 并截获 code,但没有 verifier,换取时收到 invalid_grant。如果服务端接受缺失 verifier,这个 code 就能换到令牌。

常见误解是 PKCE 用于认证客户端。它把 code 绑定到发起流程的客户端实例,客户端认证是另一项独立控制。相关概念:OAuth 2.0 授权码流程与 PKCE、OAuth 作用域、OpenID Connect。

代码示例

type PathTree = Map<string, PathTree>;

const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);

export function omitDeep<Value>(value: Value, paths: readonly string[]): Value {
  if (!Array.isArray(paths)) throw new TypeError("paths must be an array");
  if (paths.length === 0) return value;
  const tree: PathTree = new Map();
  for (const path of paths) {
    if (typeof path !== "string" || path.trim() === "") {
      throw new TypeError("paths must be non-empty strings");
    }
    const segments = path.split(".").map((segment) => segment.trim());
    if (segments.some((segment) => segment === "")) throw new Error(`invalid path "${path}"`);
    for (const segment of segments) {
      if (FORBIDDEN_KEYS.has(segment)) throw new Error(`path "${path}" contains a forbidden key`);
    }
    insert(tree, segments);
  }
  return clone(value, tree) as Value;
}

function insert(tree: PathTree, segments: readonly string[]): void {
  let node = tree;
  for (const segment of segments) {
    let child = node.get(segment);
    if (child === undefined) {
      child = new Map();
      node.set(segment, child);
    }
    node = child;
  }
}

function clone(value: unknown, tree: PathTree): unknown {
  if (Array.isArray(value)) {
    return value.map((item, index) => {
      const child = tree.get(String(index));
      if (child !== undefined && child.size === 0) return undefined;
      return clone(item, child ?? tree);
    });
  }
  if (value !== null && typeof value === "object") {
    const prototype = Object.getPrototypeOf(value);
    if (prototype !== Object.prototype && prototype !== null) {
      throw new TypeError("omitDeep only supports plain objects and arrays");
    }
    const output: Record<string, unknown> = {};
    for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
      const child = tree.get(key);
      if (child !== undefined && child.size === 0) continue;
      output[key] = clone(item, child ?? new Map());
    }
    return output;
  }
  return value;
}

const source = {
  id: 1,
  password: "secret",
  profile: { email: "[email protected]", token: "abc", nested: { keep: true } },
  items: [{ id: 1, token: "a" }, { id: 2, token: "b" }],
};
const sanitized = omitDeep(source, ["password", "profile.token", "items.token"]);
console.log(JSON.stringify(sanitized));
console.log(JSON.stringify(source));
function assertSecret(secret: string): void {
  if (typeof secret !== "string" || secret.length === 0) {
    throw new TypeError("secret must be a non-empty string");
  }
}

export async function deriveAesKey(
  secret: string,
  salt: Uint8Array,
  info: string,
): Promise<CryptoKey> {
  assertSecret(secret);
  if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
  if (typeof info !== "string") throw new TypeError("info must be a string");
  const material = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    "HKDF",
    false,
    ["deriveKey"],
  );
  return crypto.subtle.deriveKey(
    { name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
    material,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"],
  );
}

export async function deriveBytes(
  secret: string,
  salt: Uint8Array,
  info: string,
  length = 32,
): Promise<Uint8Array> {
  assertSecret(secret);
  if (!(salt instanceof Uint8Array)) throw new TypeError("salt must be a Uint8Array");
  if (typeof info !== "string") throw new TypeError("info must be a string");
  if (!Number.isInteger(length) || length < 1 || length > 255) {
    throw new RangeError("length must be an integer between 1 and 255");
  }
  const material = await crypto.subtle.importKey(
    "raw",
    new TextEncoder().encode(secret),
    "HKDF",
    false,
    ["deriveBits"],
  );
  const bits = await crypto.subtle.deriveBits(
    { name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
    material,
    length * 8,
  );
  return new Uint8Array(bits);
}