Skip to content

Commit fb93479

Browse files
committed
Support Chrome Web Store service account auth
1 parent 16955e4 commit fb93479

6 files changed

Lines changed: 155 additions & 11 deletions

File tree

.github/workflows/chrome-web-store-publish.yml

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,28 +46,43 @@ jobs:
4646

4747
- name: 校验 Chrome Web Store secrets
4848
env:
49+
CWS_ACCESS_TOKEN: ${{ secrets.CWS_ACCESS_TOKEN }}
4950
CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
5051
CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
5152
CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
53+
CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }}
5254
CWS_PUBLISHER_ID: ${{ secrets.CWS_PUBLISHER_ID }}
5355
CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
5456
run: |
5557
missing=()
5658
for name in \
57-
CWS_CLIENT_ID \
58-
CWS_CLIENT_SECRET \
59-
CWS_REFRESH_TOKEN \
6059
CWS_PUBLISHER_ID \
6160
CWS_EXTENSION_ID
6261
do
6362
if [ -z "${!name}" ]; then
6463
missing+=("${name}")
6564
fi
6665
done
66+
has_oauth="false"
67+
if [ -n "${CWS_CLIENT_ID}" ] && [ -n "${CWS_CLIENT_SECRET}" ] && [ -n "${CWS_REFRESH_TOKEN}" ]; then
68+
has_oauth="true"
69+
fi
70+
has_service_account="false"
71+
if [ -n "${CWS_SERVICE_ACCOUNT_JSON}" ]; then
72+
has_service_account="true"
73+
fi
74+
has_access_token="false"
75+
if [ -n "${CWS_ACCESS_TOKEN}" ]; then
76+
has_access_token="true"
77+
fi
6778
if [ "${#missing[@]}" -gt 0 ]; then
6879
printf 'Missing required repository secrets: %s\n' "${missing[*]}" >&2
6980
exit 1
7081
fi
82+
if [ "${has_oauth}" != "true" ] && [ "${has_service_account}" != "true" ] && [ "${has_access_token}" != "true" ]; then
83+
echo "Configure one Chrome Web Store auth method: CWS_SERVICE_ACCOUNT_JSON, CWS_ACCESS_TOKEN, or CWS_CLIENT_ID+CWS_CLIENT_SECRET+CWS_REFRESH_TOKEN" >&2
84+
exit 1
85+
fi
7186
7287
- name: 下载 Chrome extension release asset
7388
id: asset
@@ -99,9 +114,11 @@ jobs:
99114
100115
- name: 上传到 Chrome Web Store
101116
env:
117+
CWS_ACCESS_TOKEN: ${{ secrets.CWS_ACCESS_TOKEN }}
102118
CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
103119
CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
104120
CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
121+
CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }}
105122
CWS_PUBLISHER_ID: ${{ secrets.CWS_PUBLISHER_ID }}
106123
CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
107124
CHROME_EXTENSION_ZIP: ${{ steps.asset.outputs.chrome_extension_zip }}

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,11 @@ jobs:
100100
- name: 上传并提交 Chrome Web Store 审核
101101
if: ${{ inputs.publish_chrome_web_store }}
102102
env:
103+
CWS_ACCESS_TOKEN: ${{ secrets.CWS_ACCESS_TOKEN }}
103104
CWS_CLIENT_ID: ${{ secrets.CWS_CLIENT_ID }}
104105
CWS_CLIENT_SECRET: ${{ secrets.CWS_CLIENT_SECRET }}
105106
CWS_REFRESH_TOKEN: ${{ secrets.CWS_REFRESH_TOKEN }}
107+
CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }}
106108
CWS_PUBLISHER_ID: ${{ secrets.CWS_PUBLISHER_ID }}
107109
CWS_EXTENSION_ID: ${{ secrets.CWS_EXTENSION_ID }}
108110
CHROME_EXTENSION_ZIP: ${{ steps.package.outputs.chrome_extension_zip }}

docs/CHROME_WEB_STORE_LISTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Store Developer Dashboard still needs:
1313
- Store listing fields completed.
1414
- Privacy practices fields completed.
1515
- A privacy policy URL attached to the developer account or item.
16+
- The service account email added under the Chrome Web Store Developer Dashboard
17+
Account settings if CI/CD uses `CWS_SERVICE_ACCOUNT_JSON`.
1618
- The resulting Extension ID copied into the `CWS_EXTENSION_ID` GitHub secret.
1719

1820
## Store Listing

docs/CHROME_WEB_STORE_RELEASE.md

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,26 +67,37 @@ Chrome Web Store API v2 用于上传 extension zip,并可选提交审核。官
6767
- 首次提交 Dashboard 文案、权限说明和隐私字段时,先使用
6868
`docs/CHROME_WEB_STORE_LISTING.md` 里的 listing draft。
6969

70-
需要在 GitHub repository secrets 里配置:
70+
推荐使用 Chrome Web Store API v2 service account 给 CI/CD 授权。需要在
71+
Google Cloud 启用 Chrome Web Store API,创建 service account,并在 Chrome
72+
Web Store Developer Dashboard 的 Account 设置里添加该 service account email。
73+
官方限制是 publisher 当前只能添加一个 service account。
74+
75+
service account 路径需要在 GitHub repository secrets 里配置:
7176

7277
```text
73-
CWS_CLIENT_ID
74-
CWS_CLIENT_SECRET
75-
CWS_REFRESH_TOKEN
78+
CWS_SERVICE_ACCOUNT_JSON
7679
CWS_PUBLISHER_ID
7780
CWS_EXTENSION_ID
7881
```
7982

8083
可用 `gh` 写入:
8184

8285
```bash
83-
gh secret set CWS_CLIENT_ID
84-
gh secret set CWS_CLIENT_SECRET
85-
gh secret set CWS_REFRESH_TOKEN
86+
gh secret set CWS_SERVICE_ACCOUNT_JSON < /path/to/service-account.json
8687
gh secret set CWS_PUBLISHER_ID
8788
gh secret set CWS_EXTENSION_ID
8889
```
8990

91+
也可以继续使用 OAuth refresh token 路径;这种方式需要配置:
92+
93+
```text
94+
CWS_CLIENT_ID
95+
CWS_CLIENT_SECRET
96+
CWS_REFRESH_TOKEN
97+
CWS_PUBLISHER_ID
98+
CWS_EXTENSION_ID
99+
```
100+
90101
`CWS_REFRESH_TOKEN` 可以用本地 OAuth helper 生成。先在 Google Cloud 中为同一
91102
项目启用 Chrome Web Store API,创建 OAuth client,并确保 loopback redirect
92103
URI 可用:
@@ -115,6 +126,13 @@ CWS_REFRESH_TOKEN` 写入 GitHub repository secret。
115126
3. 调用 Chrome Web Store API v2 `upload`
116127
4. 上传成功后调用 `publish`,提交审核。
117128

129+
`scripts/publish-chrome-web-store.mjs` 的认证优先级是:
130+
131+
1. `CWS_ACCESS_TOKEN`:短期 access token,适合本地一次性验证。
132+
2. `CWS_SERVICE_ACCOUNT_JSON`:推荐的 CI/CD service account JSON key。
133+
3. `CWS_CLIENT_ID``CWS_CLIENT_SECRET``CWS_REFRESH_TOKEN`:OAuth refresh
134+
token fallback。
135+
118136
`chrome_publish_type` 默认为 `DEFAULT_PUBLISH`,通过审核后自动发布;如果想
119137
审核通过后再手动发布,选择 `STAGED_PUBLISH``chrome_deploy_percentage`
120138
留空时使用 Developer Dashboard 当前设置。
@@ -145,6 +163,8 @@ zip,可以显式填写完整 asset 文件名。
145163

146164
- Chrome Web Store API 使用指南:
147165
<https://developer.chrome.com/docs/webstore/using-api>
166+
- Chrome Web Store API service account:
167+
<https://developer.chrome.com/docs/webstore/service-accounts>
148168
- Chrome Web Store API v2 upload:
149169
<https://developer.chrome.com/docs/webstore/api/reference/rest/v2/media/upload>
150170
- Chrome Web Store API v2 publish:

docs/histories/2026-05/20260508-2105-chrome-web-store-release.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,5 @@ upload and publish behavior.
8282
- Added a Chrome Web Store listing and privacy practices draft based on the
8383
current manifest permissions and native host boundary, so the Dashboard setup
8484
is reproducible instead of only described in chat.
85+
- Added Chrome Web Store service account authentication support for CI/CD while
86+
keeping OAuth refresh tokens as a fallback path.

scripts/publish-chrome-web-store.mjs

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
#!/usr/bin/env node
22

33
import { readFile, writeFile } from "node:fs/promises";
4+
import { createSign } from "node:crypto";
45
import path from "node:path";
56
import process from "node:process";
67

78
const apiRoot = "https://chromewebstore.googleapis.com";
89
const oauthTokenUrl = "https://oauth2.googleapis.com/token";
10+
const chromeWebStoreScope = "https://www.googleapis.com/auth/chromewebstore";
911

1012
function readFlag(name, fallback = null) {
1113
const prefix = `--${name}=`;
@@ -24,6 +26,10 @@ function hasFlag(name) {
2426
return process.argv.includes(`--${name}`);
2527
}
2628

29+
function optionalEnv(name) {
30+
return process.env[name] ?? "";
31+
}
32+
2733
function env(name) {
2834
const value = process.env[name];
2935
if (!value) {
@@ -54,7 +60,44 @@ async function requestJson(url, options = {}) {
5460
return body;
5561
}
5662

57-
async function getAccessToken() {
63+
function base64UrlEncode(input) {
64+
return Buffer.from(input)
65+
.toString("base64")
66+
.replaceAll("+", "-")
67+
.replaceAll("/", "_")
68+
.replace(/=+$/, "");
69+
}
70+
71+
function signJwt(payload, privateKey) {
72+
const header = {
73+
alg: "RS256",
74+
typ: "JWT"
75+
};
76+
const encodedHeader = base64UrlEncode(JSON.stringify(header));
77+
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
78+
const signingInput = `${encodedHeader}.${encodedPayload}`;
79+
const signer = createSign("RSA-SHA256");
80+
signer.update(signingInput);
81+
signer.end();
82+
const signature = signer.sign(privateKey);
83+
return `${signingInput}.${base64UrlEncode(signature)}`;
84+
}
85+
86+
function readServiceAccountJson() {
87+
const raw = optionalEnv("CWS_SERVICE_ACCOUNT_JSON");
88+
if (!raw) {
89+
return null;
90+
}
91+
try {
92+
return JSON.parse(raw);
93+
} catch (error) {
94+
throw new Error(
95+
`CWS_SERVICE_ACCOUNT_JSON is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
96+
);
97+
}
98+
}
99+
100+
async function getOauthAccessToken() {
58101
const body = new URLSearchParams({
59102
client_id: env("CWS_CLIENT_ID"),
60103
client_secret: env("CWS_CLIENT_SECRET"),
@@ -74,6 +117,64 @@ async function getAccessToken() {
74117
return token.access_token;
75118
}
76119

120+
async function getServiceAccountAccessToken(serviceAccount) {
121+
const clientEmail = serviceAccount.client_email;
122+
const privateKey = serviceAccount.private_key;
123+
const tokenUri = serviceAccount.token_uri ?? oauthTokenUrl;
124+
if (typeof clientEmail !== "string" || clientEmail === "") {
125+
throw new Error("CWS_SERVICE_ACCOUNT_JSON must include client_email");
126+
}
127+
if (typeof privateKey !== "string" || privateKey === "") {
128+
throw new Error("CWS_SERVICE_ACCOUNT_JSON must include private_key");
129+
}
130+
const now = Math.floor(Date.now() / 1000);
131+
const assertion = signJwt(
132+
{
133+
iss: clientEmail,
134+
scope: chromeWebStoreScope,
135+
aud: tokenUri,
136+
exp: now + 3600,
137+
iat: now
138+
},
139+
privateKey
140+
);
141+
const token = await requestJson(tokenUri, {
142+
method: "POST",
143+
headers: {
144+
"Content-Type": "application/x-www-form-urlencoded"
145+
},
146+
body: new URLSearchParams({
147+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
148+
assertion
149+
})
150+
});
151+
if (!token.access_token) {
152+
throw new Error("Service account token response did not include access_token");
153+
}
154+
return token.access_token;
155+
}
156+
157+
async function getAccessToken() {
158+
const directAccessToken = optionalEnv("CWS_ACCESS_TOKEN");
159+
if (directAccessToken) {
160+
return directAccessToken;
161+
}
162+
const serviceAccount = readServiceAccountJson();
163+
if (serviceAccount) {
164+
return await getServiceAccountAccessToken(serviceAccount);
165+
}
166+
if (
167+
!optionalEnv("CWS_CLIENT_ID") ||
168+
!optionalEnv("CWS_CLIENT_SECRET") ||
169+
!optionalEnv("CWS_REFRESH_TOKEN")
170+
) {
171+
throw new Error(
172+
"Configure one Chrome Web Store auth method: CWS_ACCESS_TOKEN, CWS_SERVICE_ACCOUNT_JSON, or CWS_CLIENT_ID+CWS_CLIENT_SECRET+CWS_REFRESH_TOKEN"
173+
);
174+
}
175+
return await getOauthAccessToken();
176+
}
177+
77178
function itemName() {
78179
return `publishers/${env("CWS_PUBLISHER_ID")}/items/${env("CWS_EXTENSION_ID")}`;
79180
}

0 commit comments

Comments
 (0)