跳到主要内容

H5网页接入飞书:免登与OAuth登录

· 阅读需 7 分钟

最近做的 H5 页面需要在飞书里直接打开就能免登录访问,在浏览器里打开则走飞书 OAuth 授权。整体是前端 + Java 后端的组合,简单记录下。

整体思路​

登录分两种场景,最后都统一走后端同一个接口换 token:

  1. 飞书内打开:通过飞书 JS-SDK 的 requestAccess 免登接口,静默拿到 code,用户无感知。
  2. 浏览器打开:跳飞书 OAuth 授权页,用户点同意后回调页拿到 code。

拿到 code 后统一调后端 feishuLogin:后端用 code 换 user_access_token,再拉取用户信息,校验通过就给前端发业务 token。

前置准备​

在飞书开放平台创建企业自建应用:

  • 拿到 App ID / App Secret
  • 开通需要的权限 scope: contact:user.employee_id:readonly
  • 配置重定向 URI(OAuth 回调地址)

前端​

判断是否在飞书内​

用 UA 判断是不是在飞书里打开的:

export function isInFeishu() {
const ua = navigator.userAgent.toLowerCase()
return ua.includes('lark') || ua.includes('feishu')
}

飞书内:requestAccess 免登​

参考:H5 JSAPI 总览 - 客户端 API - 开发文档 - 飞书开放平台

加载飞书SDK:

// SDK 加载超时时间(毫秒),防止 CDN 挂起导致 Promise 永不 settle
const SDK_LOAD_TIMEOUT = 10000
// 加载中的 Promise 缓存:避免重复注入 script;加载失败后置空以便重试
let sdkLoadPromise = null

/** 动态加载飞书 JS-SDK */
function loadFeishuSDK() {
// 已加载或加载中:复用同一个 Promise,避免重复注入 script
if (sdkLoadPromise) {
return sdkLoadPromise
}

sdkLoadPromise = new Promise((resolve, reject) => {
// 同时校验实际用到的 window.tt,与调用方保持一致
if (window.h5sdk && window.tt) {
resolve(window.h5sdk)
return
}

const script = document.createElement('script')
script.src = 'https://lf-scm-cn.feishucdn.com/lark/op/h5-js-sdk-1.5.30.js'

let settled = false
let timer = null

const cleanup = () => {
clearTimeout(timer)
if (script.parentNode) {
script.parentNode.removeChild(script)
}
}

const fail = () => {
if (settled) return
settled = true
cleanup()
reject(new Error('飞书 SDK 加载失败'))
}

script.onload = () => {
if (settled) return
settled = true
clearTimeout(timer)
if (window.h5sdk && window.tt) {
resolve(window.h5sdk)
} else {
reject(new Error('飞书 SDK 加载失败'))
}
}
script.onerror = fail

// 超时兜底
timer = setTimeout(fail, SDK_LOAD_TIMEOUT)

document.head.appendChild(script)
}).catch(err => {
// 加载失败清空缓存,下次调用(重新登录)可重新注入
sdkLoadPromise = null
throw err
})

return sdkLoadPromise
}

加载完SDK后调 requestAccess,静默拿到 code。接口说明见飞书文档:

async function getCodeInFeishu() {
await loadFeishuSDK()
return new Promise((resolve, reject) => {
const onSuccess = res => {
console.log('[飞书免登] 获取 code 成功:', res)
if (res && res.code) {
resolve(res.code)
} else {
reject(new Error('飞书免登获取 code 失败'))
}
}
const onFail = error => {
console.error('[飞书免登] 获取 code 失败:', error)
reject(new Error((error && error.errString) || '飞书免登获取 code 失败'))
}

// 客户端版本过低或 JSSDK 过旧时,降级到 requestAuthCode
const callRequestAuthCode = () => {
window.tt.requestAuthCode({
appId: FEISHU_APP_ID,
success: onSuccess,
fail: onFail
})
}

if (window.tt && typeof window.tt.requestAccess === 'function') {
window.tt.requestAccess({
appID: FEISHU_APP_ID,
scopeList: ["contact:user.employee_id:readonly"],
success: onSuccess,
fail: error => {
console.error('[飞书免登] requestAccess 失败:', error)
if (error && error.errno === 103) {
// 客户端版本过低,不支持 requestAccess,降级到 requestAuthCode
callRequestAuthCode()
} else {
onFail(error)
}
}
})
} else {
// JSSDK 版本过低,不支持 requestAccess,直接调用 requestAuthCode
callRequestAuthCode()
}
})
}

浏览器内:OAuth 授权​

不在飞书里就跳 OAuth 授权页。先拼好授权 URL 跳过去:

const oauthUrl =
'https://accounts.feishu.cn/open-apis/authen/v1/authorize' +
`?client_id=${FEISHU_APP_ID}` +
'&response_type=code' +
`&redirect_uri=${redirectUri}` +
`&scope=${encodeURIComponent(FEISHU_SCOPE)}` +
`&state=${state}`

console.log('[飞书免登] 跳转 OAuth 授权:', oauthUrl)
window.location.replace(oauthUrl)

用户同意后飞书会带 code 和 state 跳回回调页。回调页处理 code,清理 URL 上的 code 要趁早,在换 token 之前,防止页面重载时重复使用。文档见获取 OAuth code:

async function handleFeishuCallback() {
// 先从 URL 上取 code,清理要趁早(before loginWithCode)
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const state = urlParams.get('state')

// 无论有没有 code,先把 URL 清理干净,防止页面重载时再次使用
cleanUrlParams()

if (!code) {
status.value = 'error'
errorMsg.value = 'URL 上缺少 code 参数,请重新授权'
return
}

try {
status.value = 'loading'
errorMsg.value = ''

const token = await loginWithCode(code, window.location.origin + '/callback')
if (token) {
// 登录成功,跳回原始页面
if (state) {
window.location.replace(decodeURIComponent(state))
} else {
router.replace('/')
}
}
} catch (err) {
console.error('[Callback] 登录失败:', err)
status.value = 'error'
errorMsg.value = err.message || '未知错误'
}
}

换 token​

两种流程拿到 code 后都调 loginWithCode,走后端接口换业务 token:

export async function loginWithCode(code, redirectUri) {
const url = redirectUri || ''
console.log('[飞书免登] 用 code 换取 token:', code, 'redirectUri:', url)
const res = await feishuLogin(code, url)
console.log('[飞书免登] 后端登录接口返回数据为:', JSON.stringify(res.data?.data))
const token = res.data?.data?.token || res.token
const userInfo = res.data?.data?.staffInfo || res.userInfo
if (token) {
setToken(token)
sessionStorage.setItem('userInfo', JSON.stringify(userInfo))
console.log('[飞书免登] 登录成功,token 已存储')
return token
}
throw new Error('后端未返回 token')
}

后端​

后端是 Spring Boot,用 RestTemplate 调飞书接口。

用 code 换 user_access_token​

public FeishuOauthTokenRes feishuOauthToken(String code, String redirectUri) throws BizException {
if (redirectUri == null) {
redirectUri = "";
}
String url = "https://accounts.feishu.cn/oauth/v3/token";

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

Map<String, Object> params = new HashMap<>();
params.put("grant_type", "authorization_code");
params.put("client_id", appId);
params.put("client_secret", appSecret);
params.put("code", code);
params.put("redirect_uri", redirectUri);
params.put("code_verifier", "");
params.put("scope", "");

HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(params, headers);

FeishuOauthTokenRes res;
try {
res = restTemplate.postForObject(url, requestEntity, FeishuOauthTokenRes.class);
} catch (Exception e) {
log.error("调用飞书授权token接口失败, code={}", code, e);
throw new BizException("获取飞书token失败");
}

if (res == null || res.getCode() == null || res.getCode() != 0) {
log.error("获取飞书token失败, response={}", res);
throw new BizException("获取飞书token失败");
}
return res;
}

拉取用户信息​

private FeishuUserInfoData feishuUserInfo(String accessToken) {
String url = "https://open.feishu.cn/open-apis/authen/v1/user_info";

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

HttpEntity<Void> requestEntity = new HttpEntity<>(headers);

FeishuApiRet<FeishuUserInfoData> res;
try {
ResponseEntity<FeishuApiRet<FeishuUserInfoData>> response = restTemplate.exchange(url, HttpMethod.GET, requestEntity, new ParameterizedTypeReference<>() {
});
res = response.getBody();
} catch (Exception e) {
log.error("调用飞书用户信息接口失败, accessToken={}", accessToken, e);
return null;
}

if (res == null || res.getCode() == null || res.getCode() != 0 || res.getData() == null) {
log.error("获取飞书用户信息失败, response={}", res);
return null;
}
return res.getData();
}

登录主流程​

飞书认证成功后,给前端发放业务 token:

public FeishuLoginRes login(String code, String redirectUri) throws BizException {
FeishuLoginRes res = new FeishuLoginRes();

var fsToken = feishuOauthToken(code, redirectUri);
if (fsToken == null || StringUtils.isBlank(fsToken.getAccessToken())) {
throw new BizException("获取飞书token失败");
}

var fsUserInfo = feishuUserInfo(fsToken.getAccessToken());
if (fsUserInfo == null || StringUtils.isBlank(fsUserInfo.getUserId())) {
throw new BizException("获取飞书用户信息失败");
}

// ... 飞书认证成功,发放 token
res.setToken(token);
return res;
}

踩坑记录​

redirectUri​

OAuth 流程必须传 redirectUri。 免登的 requestAccess 不需要 redirectUri,但 OAuth 换 token 时必须带上,否则飞书直接报错:

400 Bad Request: "{"error":"invalid_request","error_description":"The request is missing a required parameter: redirect_uri.","code":20063}"

所以后端参数上免登场景可以传空,OAuth 场景一定要把回调地址传过去。

其他注意点:

  • URL 上的 code 要及时清理,在换 token 之前就清,防止页面刷新时拿着旧的 code 再走一次流程。
  • code 是一次性的,换过一次 token 后不能再用。

飞书 sdk 加载​

在飞书客户端上试运行时出现问题,但飞书客户端又没有开发者工具。不过可以使用飞书的网页远程调试工具来排查,我就遇到了飞书SDK加载的问题,原因是SDK地址写成了:

https://lf3-cdn-tos.bytegoofy.com/obj/goofy/locl/lark/external_js_sdk/h5-js-sdk-1.2.21.js

其实应该是:

https://lf-scm-cn.feishucdn.com/lark/op/h5-js-sdk-1.5.30.js