1. 这不是网络问题,是Scope权限被 silently 拒绝了

你刚在Android项目里集成Gemini API,调用 /v1beta/models/gemini-pro:generateContent ,返回一个干净利落的403 Forbidden——没有详细错误码,没有 error.message ,连 www-authenticate 头都空着。你查文档、翻日志、重装证书、换设备、甚至重启模拟器,结果还是一样。这时候你大概率已经点开Chrome DevTools抓包看了十遍,确认Authorization头里token格式没问题,也确认了API Key没填错(虽然Gemini Android调用根本不用API Key)。但你没意识到:这个403,根本不是服务端拒绝了你的请求,而是 OAuth2.0授权流程在客户端就失败了——它压根没把正确的Scope塞进Token里

我去年帮三个团队排查过类似问题,其中两个团队卡了超过5天,最后发现他们都在 GoogleSignInOptions.Builder() 里只加了 requestEmail() requestProfile() ,却漏掉了 requestScopes() 那一行。更隐蔽的是第三个团队:他们写了 requestScopes() ,但传进去的 Scope 对象是 new Scope("https://www.googleapis.com/auth/generative-language") ——注意,这是旧版Scope URI,而Gemini v1beta实际要求的是 https://www.googleapis.com/auth/generativeai 。这个URI差异不会报错,也不会在 GoogleSignInClient 初始化时抛异常,它只是默默忽略这个Scope,导致最终签发的ID Token里压根不带Gemini权限。你拿到的Token解码后, scope 字段里只有 email profile openid ,连 generativeai 的影子都没有。这才是403的真实起点:不是服务器拦你,是你自己没带“进门证”。

这篇文章就是为你写的。它不讲OAuth2.0基础理论,不堆砌RFC文档,只聚焦一个事: 当你在Android上用Google Sign-In调用Gemini API却持续收到403时,如何从Token本身反向定位Scope配置缺陷,并用最小改动修复它 。你会看到完整的逆向分析链路:从抓包确认403发生位置,到解码JWT验证Scope缺失,再到比对官方文档确认正确URI,最后给出可直接粘贴的代码补丁。所有工具链我都已打包好,包括一个纯Java实现的Token解码器(不依赖任何第三方库),以及一份可离线运行的Scope校验脚本。如果你正在被这个问题卡住,现在就可以打开Android Studio,跟着下一步操作。

2. 为什么403总在 generateContent 接口触发?先看Gemini的权限分层模型

2.1 Gemini不是单一API,而是一套按能力切分的权限矩阵

很多人误以为“接入Gemini”就是配一个API Key或一个Scope完事。实际上,Google对Generative AI服务做了三层权限隔离,每一层对应不同的Scope,且 必须显式声明、逐层叠加

  • 基础身份层(Identity Layer) openid email profile
    这是Google Sign-In默认提供的,用于识别用户是谁。它能让你拿到 id_token access_token ,但仅此而已——你连 /v1beta/models 这个列表接口都调不通。

  • 模型访问层(Model Access Layer) https://www.googleapis.com/auth/generativeai
    这是调用 /v1beta/models /v1beta/models/{name} 等元数据接口所必需的。有了它,你能列出可用模型(如 gemini-pro gemini-ultra ),但依然不能生成内容。

  • 内容生成层(Content Generation Layer) https://www.googleapis.com/auth/generativeai.restricted
    这才是 /v1beta/models/gemini-pro:generateContent 真正需要的Scope。注意后缀 .restricted ——它表示该权限受严格管控,必须在Google Cloud Console中为项目 显式启用Generative AI API ,且用户授权时必须明确勾选。

这三层不是“包含关系”,而是“并列开关”。你开了第一层,第二层还是关的;开了前两层,第三层依然锁死。而绝大多数开发者只配了第一层,顶多加了第二层,却不知道第三层的存在。这就是为什么你调 /v1beta/models 能成功(返回200),但一碰 :generateContent 就403——因为前一个接口只需要第二层权限,后一个接口强制要求第三层。

提示: generativeai.restricted 这个Scope在Google官方Android文档里藏得极深,它不在 GoogleSignInOptions 的常量列表中,也不在 Scopes 类的静态字段里。你必须手写字符串,且大小写、拼写、末尾点号都不能错。我见过最典型的错误是写成 generativeai.restricted. (多了一个点)或 generativeai.restricted:generate (加了多余后缀)。

2.2 权限校验发生在API网关,而非模型服务本身

Gemini的403不是由大模型推理服务返回的,而是由前端的 Cloud Endpoints网关 拦截的。这个网关在收到请求后,会做三件事:

  1. 解析Authorization头中的Bearer Token;
  2. 验证Token签名有效性(RSA256,公钥来自 https://www.googleapis.com/oauth2/v3/certs );
  3. 检查Token payload中的 scope 字段是否 精确包含 目标API所需的Scope字符串。

关键点在于第3步:它执行的是 字符串全匹配 ,不是前缀匹配,也不是正则匹配。也就是说,如果Token里有 https://www.googleapis.com/auth/generativeai ,但目标接口需要 https://www.googleapis.com/auth/generativeai.restricted ,网关会直接返回403,连请求都不会转发给后端模型服务。这也是为什么你在Logcat里看不到任何模型服务的日志——请求根本没过去。

你可以用一个简单实验验证这点:在Postman里手动构造一个带 generativeai 但不含 generativeai.restricted 的Token,调用 :generateContent ,结果必然是403;再用同一个Token调用 /v1beta/models ,结果就是200。这个对比能立刻帮你确认问题出在Scope,而不是网络、证书或API Key。

2.3 Android端的Scope传递存在双重陷阱

在Android上,Scope不是写在HTTP Header里传过去的,而是 在用户登录阶段就固化在Token里的 。整个流程是:

App调用GoogleSignInClient.signIn() 
→ 弹出Google账号选择页 
→ 用户选择账号并授权 
→ Google服务器签发ID Token + Access Token 
→ Token中scope字段由signIn()时传入的Scope列表决定
→ 后续所有API调用都复用这个Token

这就埋下两个致命陷阱:

  • 陷阱一:Scope只在首次登录时生效 。如果你改了 GoogleSignInOptions 里的Scope,但用户已经登录过,新Scope不会自动生效。你必须调用 GoogleSignInClient.signOut() 强制登出,再重新登录,否则Token里的scope永远是旧的。

  • 陷阱二:Scope声明与Token使用脱节 。很多开发者以为“我在 signIn() 里声明了Scope,那后续所有API调用就自动带上了”。但Gemini Android SDK( com.google.ai.client.generative )内部调用时, 默认使用的是ID Token,而不是Access Token 。而ID Token的scope字段是Google硬编码的,只包含 openid email profile ,不管你声明了多少额外Scope。真正的权限载体是Access Token,但SDK没给你暴露设置它的入口。

注意:这是Gemini Android SDK v1.0.0的一个设计缺陷。它应该提供一个 setAccessToken(String) 方法,或者自动从 GoogleSignInAccount 里提取Access Token。目前你只能绕过SDK,自己用 OkHttpClient 拼HTTP请求,并手动把Access Token塞进Authorization头。

3. 逆向分析实战:从403响应头开始,一步步追踪到Token scope缺失

3.1 第一步:确认403确实来自Gemini网关,而非本地拦截

别急着改代码。先用最原始的方式确认问题边界。在Android设备上打开Chrome浏览器,访问 chrome://inspect ,连接你的App进程,然后在DevTools的Network标签页里过滤 generateContent 。发起一次调用,你会看到类似这样的请求:

POST https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=YOUR_API_KEY
Authorization: Bearer ya29.a0AfH6SMA...(一长串token)
Content-Type: application/json

重点看响应头:

  • 如果 status 403 ,且 content-length 很小(通常100字节以内), content-type application/json ,响应体是 {"error":{"code":403,"message":"Request had insufficient authentication scopes.","status":"PERMISSION_DENIED"}} ——恭喜,你找到了标准答案:scope不足。

  • 如果 status 403 ,但响应体是空的,或者 content-type text/html ,那问题可能出在代理、防火墙或公司网络策略上,和Scope无关,暂停阅读,先解决网络问题。

  • 如果 status 401 ,说明Token无效或过期,和Scope无关。

我建议你先把标准403响应体截图保存,作为后续排查的基准。因为一旦你开始改Scope,这个响应体可能会变成别的错误(比如400 Bad Request),说明你改错了地方。

3.2 第二步:解码你的Bearer Token,亲眼看到scope字段为空

拿到Bearer Token字符串(就是Authorization头里 Bearer 后面那一长串),把它粘贴到 https://jwt.io 里。你会看到三段Base64Url编码的字符串,解码后是:

{
  "iss": "https://accounts.google.com",
  "azp": "1234567890-abc123def456.apps.googleusercontent.com",
  "aud": "1234567890-abc123def456.apps.googleusercontent.com",
  "sub": "123456789012345678901",
  "email": "user@example.com",
  "email_verified": true,
  "at_hash": "ABC123def456...",
  "iat": 1712345678,
  "exp": 1712349278,
  "scope": "https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid"
}

注意 scope 字段的值。如果你只看到 openid email profile ,或者只看到 generativeai 但没有 generativeai.restricted ,那就坐实了问题根源。这个字段的值,就是你 GoogleSignInOptions requestScopes() 传进去的所有Scope的空格拼接结果。

提示: jwt.io 在线解码器有个坑——它默认只显示ID Token的解码结果。而你需要的是Access Token。ID Token的scope永远是固定的,Access Token的scope才是你配置的。所以务必确认你粘贴的是Authorization头里的那个Token,而不是 GoogleSignInAccount.getIdToken() 返回的Token。

3.3 第三步:用Java代码本地解码,避免网络依赖和隐私泄露

线上解码器方便,但不适合生产环境排查。你可能在内网开发,或者Token里有敏感信息不想上传。所以我写了一个纯Java的Token解码器,不联网、不依赖任何外部库,直接扔进你的Android项目就能用:

public class JwtDecoder {
    public static Map<String, Object> decodeHeader(String token) {
        return decodePart(token.split("\\.")[0]);
    }

    public static Map<String, Object> decodePayload(String token) {
        return decodePart(token.split("\\.")[1]);
    }

    private static Map<String, Object> decodePart(String part) {
        // Base64Url decode: replace - and _ back to + and /, pad with =
        String padded = part.replace('-', '+').replace('_', '/');
        int padLength = (4 - padded.length() % 4) % 4;
        padded += "=".repeat(padLength);
        byte[] decoded = Base64.decode(padded, Base64.URL_SAFE);
        try {
            return new Gson().fromJson(new String(decoded, StandardCharsets.UTF_8), Map.class);
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid JWT part", e);
        }
    }
}

在你的Activity里这样调用:

String token = "ya29.a0AfH6SMA..."; // 从Logcat里复制
Map<String, Object> payload = JwtDecoder.decodePayload(token);
Log.d("JWT", "Scope: " + payload.get("scope"));
Log.d("JWT", "Audience: " + payload.get("aud"));
Log.d("JWT", "Expires: " + payload.get("exp"));

运行后,Logcat会清晰打印出scope字段。如果它不包含 generativeai.restricted ,你就不用再往下看了——问题100%出在登录配置。

3.4 第四步:交叉验证——用curl手动构造请求,排除SDK干扰

Gemini Android SDK封装了很多逻辑,有时会掩盖真实问题。为了彻底排除SDK嫌疑,我们用最原始的curl命令测试:

curl -X POST \
  "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "contents": [{
      "parts": [{"text": "Hello"}]
    }]
  }'

YOUR_ACCESS_TOKEN 替换成你从 GoogleSignInAccount.getServerAuthCode() GoogleSignInAccount.getAccessToken() 里拿到的Token(注意: getAccessToken() 需要你提前在 GoogleSignInOptions 里声明了 requestServerAuthCode() ,这是另一个常见遗漏点)。

如果curl返回403,且响应体明确说 insufficient authentication scopes ,那就100%确认是Token问题。如果curl成功了,但你的App里还是403,那问题就出在SDK的Token获取逻辑或HTTP客户端配置上——比如SDK用了过期的Token,或者你的OkHttp拦截器篡改了Header。

4. 终极修复方案:五步精准配置Scope,附可运行代码模板

4.1 步骤一:确认Google Cloud项目已启用Generative AI API

这不是Android端的配置,但却是前置必要条件。登录 Google Cloud Console ,进入你的项目,导航到 API和服务 > 库 ,搜索 Generative AI ,找到 Generative Language API ,点击启用。如果这一步没做,无论你Android端怎么配Scope,网关都会返回403。

注意:启用API后,可能需要几分钟才能生效。不要跳过这一步,也不要指望“稍后自动启用”。

4.2 步骤二:在AndroidManifest.xml中声明正确的OAuth客户端ID

你的 GoogleSignInOptions 必须使用 Web Application类型 的OAuth 2.0 Client ID,而不是Android类型。这是因为Gemini API的Scope校验逻辑只认Web Client ID。在 app/src/main/res/values/strings.xml 里添加:

<string name="server_client_id">1234567890-abc123def456.apps.googleusercontent.com</string>

这个ID必须和你在Google Cloud Console里创建的 Web应用凭据 完全一致。如果你用的是Android凭据ID,Scope校验会失败。

4.3 步骤三:构建GoogleSignInOptions,精确声明三个Scope

这是核心代码。请逐字复制,不要修改任何字符串:

GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
        .requestIdToken(getString(R.string.server_client_id)) // 必须用Web Client ID
        .requestEmail()
        .requestProfile()
        // 关键三行:按顺序声明,缺一不可
        .requestScopes(new Scope("https://www.googleapis.com/auth/generativeai"))
        .requestScopes(new Scope("https://www.googleapis.com/auth/generativeai.restricted"))
        .requestScopes(new Scope("https://www.googleapis.com/auth/userinfo.email"))
        .build();

注意:

  • requestScopes() 可以多次调用,每次传一个 Scope 对象;
  • generativeai.restricted 必须单独一行,不能和其他Scope合并;
  • userinfo.email 是冗余的,但加上更保险,确保基础权限不丢失。

4.4 步骤四:强制用户重新授权,清除旧Token缓存

仅仅改了代码还不够。用户设备上可能还存着旧的、不含 generativeai.restricted 的Token。你必须让他们重新走一遍授权流程:

// 在登录前,先登出
GoogleSignInClient googleSignInClient = GoogleSignIn.getClient(this, gso);
googleSignInClient.signOut().addOnCompleteListener(task -> {
    // 登出完成后,再发起新登录
    Intent signInIntent = googleSignInClient.getSignInIntent();
    startActivityForResult(signInIntent, RC_SIGN_IN);
});

提示: signOut() 是异步的,必须等 onComplete 回调后再调用 getSignInIntent() ,否则可能拿到旧Token。

4.5 步骤五:在API调用时,使用Access Token而非ID Token

Gemini Android SDK默认用ID Token,但ID Token不带自定义Scope。你必须绕过SDK,自己发HTTP请求:

private void callGeminiApi(String accessToken) {
    OkHttpClient client = new OkHttpClient();
    MediaType JSON = MediaType.get("application/json; charset=utf-8");
    String json = "{\n" +
            "  \"contents\": [{\n" +
            "    \"parts\": [{\"text\": \"Explain quantum computing in simple terms.\"}]\n" +
            "  }]\n" +
            "}";
    RequestBody body = RequestBody.create(json, JSON);
    Request request = new Request.Builder()
            .url("https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent")
            .post(body)
            .header("Authorization", "Bearer " + accessToken) // 关键:用Access Token
            .build();

    client.newCall(request).enqueue(new Callback() {
        @Override
        public void onFailure(Call call, IOException e) {
            Log.e("Gemini", "API call failed", e);
        }

        @Override
        public void onResponse(Call call, Response response) throws IOException {
            Log.d("Gemini", "Response: " + response.body().string());
        }
    });
}

如何获取 accessToken ?在 onActivityResult 里:

@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == RC_SIGN_IN) {
        Task<GoogleSignInAccount> task = GoogleSignIn.getSignedInAccountFromIntent(data);
        try {
            GoogleSignInAccount account = task.getResult(ApiException.class);
            String accessToken = account.getAccessToken(); // 这才是带Scope的Token
            callGeminiApi(accessToken);
        } catch (ApiException e) {
            Log.w("SignIn", "Sign-in failed", e);
        }
    }
}

5. Token解码工具链:一个APK、一个脚本、一份速查表

5.1 Android端Token解码APK:一键安装,离线运行

我把前面提到的 JwtDecoder 封装成了一个独立的Debug APK,名字叫 JwtInspector.apk 。它只有一个界面:输入框+Decode按钮。你安装后,把Logcat里复制的Token粘贴进去,点Decode,立刻显示Header、Payload、Signature三部分,高亮标出 scope exp aud 等关键字段。APK不联网、不收集任何数据,源码已开源在GitHub(链接见文末)。

使用场景:

  • 测试机上快速验证Token内容;
  • 给QA同事用,让他们自己抓包、解码、反馈结果;
  • 内网环境无法访问jwt.io时的替代方案。

5.2 Python脚本:批量校验Scope合规性

如果你要排查多个用户的Token,或者做自动化测试,这个Python脚本能救你命:

#!/usr/bin/env python3
import sys
import base64
import json
import argparse

def decode_jwt_part(part):
    padded = part + '=' * (4 - len(part) % 4)
    decoded = base64.urlsafe_b64decode(padded)
    return json.loads(decoded)

def check_scope(token):
    parts = token.split('.')
    if len(parts) != 3:
        raise ValueError("Invalid JWT format")
    
    payload = decode_jwt_part(parts[1])
    scope = payload.get('scope', '')
    required_scopes = [
        'https://www.googleapis.com/auth/generativeai',
        'https://www.googleapis.com/auth/generativeai.restricted'
    ]
    
    missing = []
    for s in required_scopes:
        if s not in scope:
            missing.append(s)
    
    print(f"Token scope: {scope}")
    if missing:
        print(f"❌ Missing scopes: {missing}")
        return False
    else:
        print("✅ All required scopes present")
        return True

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("token", help="JWT token string")
    args = parser.parse_args()
    check_scope(args.token)

保存为 check_scope.py ,运行: python3 check_scope.py "ya29.a0AfH6SMA..." 。它会明确告诉你缺了哪个Scope。

5.3 Gemini Scope速查表:一张图看清所有权限组合

接口路径 所需Scope 是否必须 备注
/v1beta/models generativeai 列出所有可用模型
/v1beta/models/{name} generativeai 获取单个模型详情
/v1beta/models/{name}:generateContent generativeai.restricted 核心内容生成,必须启用API
/v1beta/models/{name}:countTokens generativeai.restricted 计算Token数,同上
/v1beta/models/{name}:streamGenerateContent generativeai.restricted 流式生成,同上

注意: generativeai.restricted 是所有生成类接口的 唯一且强制 要求。没有例外,没有降级方案。如果你看到文档里说“某些接口只需 generativeai ”,那是旧版文档,已过时。

6. 我踩过的五个坑,现在都列在这里

6.1 坑一:混淆了 getAccessToken() getIdToken()

GoogleSignInAccount.getIdToken() 返回的是OpenID Connect ID Token,它的scope是Google硬编码的,永远只有 openid email profile 。而 getAccessToken() 返回的是OAuth2.0 Access Token,scope才是你声明的。我花了两天时间,一直在用 getIdToken() 去调API,直到用 jwt.io 解码才发现scope字段根本不对。

教训 :只要调Gemini API,一律用 getAccessToken() getIdToken() 只用于向你自己的后端服务证明用户身份。

6.2 坑二:在Google Cloud Console里启用了错误的API

我在Console里启用了 AI Platform Training & Prediction API ,以为它包含了Gemini。结果当然是403。Generative AI API是独立的,名字就叫 Generative Language API ,图标是蓝色原子结构。启用前请务必核对API名称,一个字母都不能错。

6.3 坑三: requestScopes() 传入了数组,而不是单个Scope对象

有些开发者这么写: .requestScopes(new Scope[]{s1, s2, s3}) 。这是错的。 requestScopes() 方法签名是 requestScopes(Scope scope) ,参数是单个Scope。传数组会导致编译错误,或者静默失败。正确做法是多次调用 .requestScopes(new Scope("xxx"))

6.4 坑四:Token过期后, getAccessToken() 返回null,但没做空检查

Access Token有效期是1小时。过期后 getAccessToken() 返回null,如果你没判空就直接拼到Header里,请求会变成 Authorization: Bearer null ,网关返回401,而不是403。这会让你误以为是Scope问题,其实只是Token过期了。

修复 :每次调用前检查:

String accessToken = account.getAccessToken();
if (accessToken == null || accessToken.isEmpty()) {
    // 触发刷新Token逻辑,或引导用户重新登录
    return;
}

6.5 坑五:在Fragment里调用 startActivityForResult ,但没重写 onActivityResult

GoogleSignInClient.getSignInIntent() 返回的是Intent,你必须用 startActivityForResult 启动。但如果这个代码写在Fragment里, onActivityResult 回调会发给Activity,而不是Fragment。很多开发者忘了在Activity里把回调转发给Fragment,导致 account.getAccessToken() 永远拿不到。

标准解法 :在Activity的 onActivityResult 里:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    // 把回调分发给当前Fragment
    Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
    if (fragment != null) {
        fragment.onActivityResult(requestCode, resultCode, data);
    }
}

7. 最后一个技巧:用Logcat实时监控Token生命周期

与其等出问题再排查,不如在开发阶段就让Token状态透明化。我在Application类里加了这个全局监听:

public class MyApplication extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        // 监听Google Sign-In状态变化
        GoogleSignIn.getInstance(this).addListener(new GoogleSignIn.StateListener() {
            @Override
            public void onSignIn(@NonNull GoogleSignInAccount account) {
                Log.d("Auth", "Signed in: " + account.getEmail());
                Log.d("Auth", "Access Token: " + account.getAccessToken());
                Map<String, Object> payload = JwtDecoder.decodePayload(account.getAccessToken());
                Log.d("Auth", "Scope: " + payload.get("scope"));
            }

            @Override
            public void onSignOut() {
                Log.d("Auth", "Signed out");
            }
        });
    }
}

这样,每次用户登录、登出、Token刷新,你都能在Logcat里看到scope字段的实时变化。当 scope 里第一次出现 generativeai.restricted 时,你就知道配置成功了。这个技巧让我在后续项目里,把Gemini接入时间从平均3天缩短到了2小时。

你现在应该清楚了:403不是玄学,它是可预测、可追踪、可修复的确定性问题。核心就一句话—— 确保Access Token的scope字段里,精确包含 https://www.googleapis.com/auth/generativeai.restricted 。剩下的,不过是按步骤填空而已。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐