跳到主要内容

Scratch / CCW 扩展

CaelLabID 提供了 Scratch 3.0 / TurboWarp / CCW(Gandi) 的 OAuth 登录扩展,让你的 Scratch 项目可以直接使用 CaelLabID 账号登录。

工作原理

Scratch 运行环境无法直接完成 OAuth 流程(不能处理 redirect),因此需要一个 PHP 代理中转:

Scratch/CCW 扩展
↓ 1. 打开授权页面(用户浏览器)
↓ 2. 用户授权后,CaelLabID 回调到 PHP 代理
↓ 3. PHP 代理用 code 换 token,获取用户信息
↓ 4. Scratch 扩展轮询 PHP 代理获取结果
用户登录成功,拿到 user ID

环境要求

组件要求
PHP>= 8.3,需要 curl 扩展
Web 服务器支持 HTTPS
Scratch 环境Scratch 3.0 / TurboWarp / CCW(Gandi)

第一步:部署 PHP 代理

Scratch/php/ 目录下的三个文件上传到你的服务器:

php/
├── auth.php # 授权入口(创建 session 文件)
├── callback.php # OAuth 回调(用 code 换 token)
└── result.php # 轮询结果接口

确保服务器可写 sessions/ 目录(callback.php 会自动创建)。

配置

修改 callback.php 中的三项配置:

$CLIENT_ID = '你的 Client ID';
$CLIENT_SECRET = '你的 Client Secret';
$REDIRECT_URI = 'https://你的域名/路径/callback.php';
提示

Client ID 和 Secret 在 开放平台 创建应用后获取。

redirect_uri 必须完全一致

$REDIRECT_URI 必须与授权请求中使用的 redirect_uri 完全一致(包括协议、域名、路径),否则 Token 交换会失败。

文件说明

文件作用HTTP 方法
auth.php创建 session 文件,存储 stateGET
callback.php接收 CaelLabID 回调,换 token,获取用户信息GET(CaelLabID 重定向)
result.php前端轮询此接口获取登录结果GET

Session 存储

  • Session 以 JSON 文件形式存储在 sessions/ 目录
  • 文件名:{state}.json
  • 超过 10 分钟自动删除
  • 完成或出错后立即删除

第二步:加载扩展

CCW (Gandi)

  1. 打开 CCW
  2. 点击「扩展」→「自定义扩展」
  3. 加载 extension-ccw.js 文件

Scratch 3.0 / TurboWarp

  1. 打开 Scratch 或 TurboWarp
  2. 加载 extension.js 文件

extension.js 同时兼容 Scratch 3.0、TurboWarp 和 CCW,会自动检测运行环境。

第三步:使用积木

配置积木

积木说明默认值
设置代理地址PHP 代理服务地址https://id.caellab.com/demo/sc
设置 Client ID应用的 Client ID内置默认值
设置 Client Secret应用的 Client Secret内置默认值
信息

如果不调用设置积木,扩展会使用内置的默认值(CaelLab 官方 Demo 应用)。如果你的应用有自己的 Client ID,务必先调用设置积木。

登录积木

积木类型说明
使用 CaelLabID 登录命令弹出授权页面,开始登录流程
正在登录?布尔登录流程进行中返回 true
登录链接报告器返回当前授权页面 URL
登录错误报告器返回错误信息,成功时为空

用户信息积木

积木类型说明
用户 ID报告器返回登录用户的唯一标识(sub
已登录?布尔已登录返回 true
退出登录命令清除本地登录状态

链接积木

积木类型说明
使用教程报告器返回本文档链接
开放平台报告器返回开放平台链接

典型使用流程

当绿旗被点击
设置代理地址 [https://id.caellab.com/demo/sc]
设置 Client ID [你的 Client ID]
设置 Client Secret [你的 Client Secret]
使用 CaelLabID 登录
重复执行直到 <(已登录?)>
// 等待登录完成
end
说出 (连接 (你好) (用户 ID)) 持续 (2) 秒

检查登录状态

如果 <(已登录?)>) 那么
说出 (连接 [欢迎回来, ] (用户 ID))
否则
说出 [请先登录]
end

处理登录错误

如果 <(登录错误) != []>) 那么
说出 (连接 [登录失败: ] (登录错误))
end

自建代理

如果你想搭建自己的 PHP 代理(不使用官方 Demo),步骤如下:

  1. 部署三个 PHP 文件到你的 HTTPS 服务器
  2. callback.php 中填入你的 Client ID / Secret / Redirect URI
  3. 在 Scratch 扩展中使用「设置代理地址」积木指向你的服务器

代理地址格式

https://你的域名/路径

扩展会自动拼接 /callback.php 作为回调地址,所以你的 callback.php 必须位于该路径下。

自建时的 redirect_uri

确保 $REDIRECT_URI 与扩展自动生成的一致:

https://你的域名/路径/callback.php

常见问题

登录后没有拿到用户 ID

  • 检查 sessions/ 目录是否有写入权限
  • 检查 callback.php 中的 CLIENT_IDCLIENT_SECRET 是否正确
  • 确认 REDIRECT_URI 与扩展使用的完全一致

登录超时(5 分钟)

  • 用户可能没有在授权页面完成操作
  • 检查 CaelLabID 授权页面是否正常加载
  • 确认网络连接正常

CORS 错误

PHP 代理已设置 Access-Control-Allow-Origin: *。如果你的项目遇到 CORS 问题,检查是否有其他中间件或服务器配置覆盖了此头。

开源与许可证

本扩展是开源项目,源码托管在 GitHub:

👉 CaelLabIDOAuth-Scratch

本项目基于 CaelLab BY-SA Code License 开源。你可以自由使用和修改,但分发时必须保持开源并署名。

仓库结构

CaelLabIDOAuth-Scratch/
├── php/
│ ├── auth.php # 授权入口
│ ├── callback.php # OAuth 回调
│ └── result.php # 轮询结果
├── extension.js # 通用版(Scratch 3.0 / TurboWarp / CCW)
├── extension-ccw.js # CCW 专用版
├── demo.js # 示例
├── LICENSE # 许可证
└── README.md

源代码

以下为项目完整源代码,也可从 GitHub 仓库 直接下载。

extension.js(通用版)

兼容 Scratch 3.0 / TurboWarp / CCW(Gandi),自动检测运行环境。

// CaelLabID OAuth 扩展 - 兼容 Scratch 3.0 / TurboWarp / CCW(Gandi)
// CaelLab BY-SA Code License
// Copyright (c) 2026 Yunyun @ CaelLab

(function (Scratch) {
'use strict';

const DEFAULT_PROXY_URL = 'https://id.caellab.com/demo/sc';
const CAELLAB_BASE = 'https://id.caellab.com';
const extId = 'caellab_auth';

const ICON_SVG = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="48" fill="#4a90d9"/><text x="50" y="65" text-anchor="middle" font-size="50" font-family="Arial" fill="white" font-weight="bold">C</text></svg>');

const L10N = {
'zh-cn': {
[`${extId}.setProxy`]: '设置代理地址 [URL]',
[`${extId}.setClientId`]: '设置 Client ID [ID]',
[`${extId}.setClientSecret`]: '设置 Client Secret [SECRET]',
[`${extId}.login`]: '使用 CaelLabID 登录',
[`${extId}.loginUrl`]: '登录链接',
[`${extId}.loggingIn`]: '正在登录?',
[`${extId}.loginError`]: '登录错误',
[`${extId}.userId`]: '用户 ID',
[`${extId}.loggedIn`]: '已登录?',
[`${extId}.logout`]: '退出登录',
[`${extId}.openDocs`]: '📖 使用教程',
[`${extId}.openPlatform`]: '🏠 开放平台'
},
en: {
[`${extId}.setProxy`]: 'Set proxy URL [URL]',
[`${extId}.setClientId`]: 'Set Client ID [ID]',
[`${extId}.setClientSecret`]: 'Set Client Secret [SECRET]',
[`${extId}.login`]: 'Login with CaelLabID',
[`${extId}.loginUrl`]: 'Login URL',
[`${extId}.loggingIn`]: 'Logging in?',
[`${extId}.loginError`]: 'Login error',
[`${extId}.userId`]: 'User ID',
[`${extId}.loggedIn`]: 'Logged in?',
[`${extId}.logout`]: 'Logout',
[`${extId}.openDocs`]: '📖 Tutorial',
[`${extId}.openPlatform`]: '🏠 Platform'
}
};

function _msg(key) {
const lang = (navigator.language || 'zh-cn').toLowerCase().startsWith('en') ? 'en' : 'zh-cn';
return (L10N[lang] && L10N[lang][key]) || (L10N['zh-cn'] && L10N['zh-cn'][key]) || key;
}

// ---- 共享的类 ----
class CaelLabAuth {
constructor(runtime) {
this.runtime = runtime;
this.proxyUrl = DEFAULT_PROXY_URL;
this.clientId = 'app_619a5612aa02113bc6eddc80';
this.clientSecret = 'ebde00c24c8d1486450c1e9b7124c46efe6edeb2fe0110f2f4d36ad484e06283';
this.userId = '';
this.accessToken = '';
this.loggingIn = false;
this._loginDone = false;
this._loginError = '';
this._currentState = '';
this._authUrl = '';
}

_getBlockType(type) {
// CCW 用字符串,Scratch 用常量
if (typeof Scratch !== 'undefined' && Scratch.BlockType) {
const map = {
'command': Scratch.BlockType.COMMAND,
'reporter': Scratch.BlockType.REPORTER,
'Boolean': Scratch.BlockType.BOOLEAN,
'button': Scratch.BlockType.BUTTON
};
return map[type] || Scratch.BlockType.COMMAND;
}
return type; // CCW 直接用字符串
}

_getArgType(type) {
if (typeof Scratch !== 'undefined' && Scratch.ArgumentType) {
return type === 'string' ? Scratch.ArgumentType.STRING : type;
}
return type;
}

_getBlocks() {
const bt = (t) => this._getBlockType(t);
const at = (t) => this._getArgType(t);

const blocks = [
{
opcode: 'setProxyUrl',
blockType: bt('command'),
text: _msg(`${extId}.setProxy`),
arguments: { URL: { type: at('string'), defaultValue: DEFAULT_PROXY_URL } }
},
{
opcode: 'setClientId',
blockType: bt('command'),
text: _msg(`${extId}.setClientId`),
arguments: { ID: { type: at('string'), defaultValue: 'app_619a5612aa02113bc6eddc80' } }
},
{
opcode: 'setClientSecret',
blockType: bt('command'),
text: _msg(`${extId}.setClientSecret`),
arguments: { SECRET: { type: at('string'), defaultValue: 'ebde00c24c8d1486450c1e9b7124c46efe6edeb2fe0110f2f4d36ad484e06283' } }
},
'---',
{
opcode: 'login',
blockType: bt('command'),
text: _msg(`${extId}.login`)
},
{
opcode: 'getLoginUrl',
blockType: bt('reporter'),
text: _msg(`${extId}.loginUrl`)
},
{
opcode: 'isLoggingIn',
blockType: bt('Boolean'),
text: _msg(`${extId}.loggingIn`)
},
{
opcode: 'getLoginError',
blockType: bt('reporter'),
text: _msg(`${extId}.loginError`)
},
'---',
{
opcode: 'getUserId',
blockType: bt('reporter'),
text: _msg(`${extId}.userId`)
},
{
opcode: 'isLoggedIn',
blockType: bt('Boolean'),
text: _msg(`${extId}.loggedIn`)
},
'---',
{
opcode: 'logout',
blockType: bt('command'),
text: _msg(`${extId}.logout`)
},
'---',
{
opcode: 'openDocs',
blockType: bt('reporter'),
text: _msg(`${extId}.openDocs`)
},
{
opcode: 'openPlatform',
blockType: bt('reporter'),
text: _msg(`${extId}.openPlatform`)
}
];

return blocks;
}

getInfo() {
const isCCW = typeof Scratch === 'undefined' || !Scratch.BlockType;
return {
id: extId,
name: 'CaelLabID Auth',
...(isCCW ? { blockIconURI: ICON_SVG, menuIconURI: ICON_SVG } : {}),
color1: '#4a90d9',
color2: '#357abd',
blocks: this._getBlocks()
};
}

setProxyUrl(args) { this.proxyUrl = args.URL.replace(/\/+$/, ''); }
setClientId(args) { this.clientId = args.ID; }
setClientSecret(args) { this.clientSecret = args.SECRET; }
getLoginUrl() { return this._authUrl || '请先点击登录'; }
isLoggingIn() { return this.loggingIn; }
getLoginError() { return this._loginError; }
getUserId() { return this.userId; }
isLoggedIn() { return !!this.userId; }
logout() {
this.userId = '';
this.accessToken = '';
this._loginDone = false;
this._loginError = '';
}

openDocs() { return 'https://id.caellab.com/docs/'; }
openPlatform() { return 'https://id.caellab.com/open'; }

_randomState() {
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}

login() {
if (this.loggingIn) return;
this.loggingIn = true;
this._loginDone = false;

const state = this._randomState();
this._currentState = state;

this._authUrl = `${CAELLAB_BASE}/oauth/authorize?${new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.proxyUrl.replace(/\/+$/, '') + '/callback.php',
scope: 'openid',
state: state
})}`;

// 尝试弹窗
try { window.open(this._authUrl, '_blank'); } catch (e) {}
try { if (typeof Scratch !== 'undefined' && Scratch.openWindow) Scratch.openWindow(this._authUrl); } catch (e) {}

this._loginError = '等待授权...';

fetch(`${this.proxyUrl}/auth.php?state=${state}`).catch(() => {});
this._pollForResult(state);
}

async _pollForResult(state) {
const startTime = Date.now();
const poll = async () => {
if (this._loginDone || Date.now() - startTime > 300000) {
if (!this._loginDone) {
this._loginError = '登录超时(5分钟)';
this.loggingIn = false;
}
return;
}
try {
const res = await fetch(`${this.proxyUrl}/result.php?state=${state}`);
if (res.ok) {
const data = await res.json();
if (data.status === 'done' || data.status === 'error') {
this._handleResult(data);
return;
}
}
} catch (e) {}
setTimeout(poll, 2000);
};
poll();
}

_handleResult(data) {
if (data.status === 'done') {
this.userId = data.user.sub || data.user.id || '';
this.accessToken = data.access_token || '';
this._loginDone = true;
this._loginError = '';
} else if (data.status === 'error') {
this._loginError = data.error || '登录失败';
}
this.loggingIn = false;
}
}

// ---- 检测环境并注册 ----
const isScratch = typeof Scratch !== 'undefined' && Scratch.extensions;
const isCCW = typeof window !== 'undefined' && typeof window.runtime !== 'undefined';

// CCW 注册
if (isCCW) {
window.tempExt = {
Extension: CaelLabAuth,
info: {
name: `${extId}.name`,
description: `${extId}.descp`,
extensionId: extId,
iconURL: ICON_SVG,
insetIconURL: ICON_SVG,
featured: true,
disabled: false,
collaborator: 'Yunyun @ CCW'
},
l10n: L10N
};
}

// Scratch / TurboWarp 注册
if (isScratch) {
Scratch.extensions.register(new CaelLabAuth());
}

})(typeof Scratch !== 'undefined' ? Scratch : undefined);

extension-ccw.js(CCW 专用版)

仅适用于 CCW(Gandi),使用 CCW 的 translate API 和 BlockType 常量。

// CaelLabID OAuth 扩展 - CCW (Gandi) 版
// CaelLab BY-SA Code License
// Copyright (c) 2026 Yunyun @ CaelLab

(function (_Scratch) {
'use strict';

const { BlockType, ArgumentType, extensions, translate, runtime } = _Scratch;

const DEFAULT_PROXY_URL = 'https://id.caellab.com/demo/sc';
const CAELLAB_BASE = 'https://id.caellab.com';
const extId = 'caellabid';

const ICON_SVG = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="48" fill="#4a90d9"/><text x="50" y="65" text-anchor="middle" font-size="50" font-family="Arial" fill="white" font-weight="bold">C</text></svg>');

translate.setup({
zh: {
[`${extId}.name`]: 'CaelLabID 登录',
[`${extId}.setProxy`]: '设置代理地址 [URL]',
[`${extId}.setClientId`]: '设置 Client ID [ID]',
[`${extId}.setClientSecret`]: '设置 Client Secret [SECRET]',
[`${extId}.login`]: '使用 CaelLabID 登录',
[`${extId}.loginUrl`]: '登录链接',
[`${extId}.loggingIn`]: '正在登录?',
[`${extId}.loginError`]: '登录错误',
[`${extId}.userId`]: '用户 ID',
[`${extId}.loggedIn`]: '已登录?',
[`${extId}.logout`]: '退出登录',
[`${extId}.openDocs`]: '📖 使用教程',
[`${extId}.openPlatform`]: '🏠 开放平台'
},
en: {
[`${extId}.name`]: 'CaelLabID Auth',
[`${extId}.setProxy`]: 'Set proxy URL [URL]',
[`${extId}.setClientId`]: 'Set Client ID [ID]',
[`${extId}.setClientSecret`]: 'Set Client Secret [SECRET]',
[`${extId}.login`]: 'Login with CaelLabID',
[`${extId}.loginUrl`]: 'Login URL',
[`${extId}.loggingIn`]: 'Logging in?',
[`${extId}.loginError`]: 'Login error',
[`${extId}.userId`]: 'User ID',
[`${extId}.loggedIn`]: 'Logged in?',
[`${extId}.logout`]: 'Logout',
[`${extId}.openDocs`]: '📖 Tutorial',
[`${extId}.openPlatform`]: '🏠 Platform'
}
});

class CaelLabAuth {
constructor(_runtime) {
this._runtime = _runtime;
this.proxyUrl = DEFAULT_PROXY_URL;
this.clientId = 'app_619a5612aa02113bc6eddc80';
this.clientSecret = 'ebde00c24c8d1486450c1e9b7124c46efe6edeb2fe0110f2f4d36ad484e06283';
this.userId = '';
this.accessToken = '';
this.loggingIn = false;
this._loginDone = false;
this._loginError = '';
this._currentState = '';
this._authUrl = '';
}

getInfo() {
return {
id: extId,
name: translate({ id: `${extId}.name` }),
blockIconURI: ICON_SVG,
menuIconURI: ICON_SVG,
color1: '#4a90d9',
color2: '#357abd',
blocks: [
{
opcode: 'setProxyUrl',
blockType: BlockType.COMMAND,
text: translate({ id: `${extId}.setProxy` }),
arguments: {
URL: {
type: ArgumentType.STRING,
defaultValue: DEFAULT_PROXY_URL
}
}
},
{
opcode: 'setClientId',
blockType: BlockType.COMMAND,
text: translate({ id: `${extId}.setClientId` }),
arguments: {
ID: {
type: ArgumentType.STRING,
defaultValue: 'app_619a5612aa02113bc6eddc80'
}
}
},
{
opcode: 'setClientSecret',
blockType: BlockType.COMMAND,
text: translate({ id: `${extId}.setClientSecret` }),
arguments: {
SECRET: {
type: ArgumentType.STRING,
defaultValue: 'ebde00c24c8d1486450c1e9b7124c46efe6edeb2fe0110f2f4d36ad484e06283'
}
}
},
'---',
{
opcode: 'login',
blockType: BlockType.COMMAND,
text: translate({ id: `${extId}.login` })
},
{
opcode: 'getLoginUrl',
blockType: BlockType.REPORTER,
text: translate({ id: `${extId}.loginUrl` })
},
{
opcode: 'isLoggingIn',
blockType: BlockType.BOOLEAN,
text: translate({ id: `${extId}.loggingIn` })
},
{
opcode: 'getLoginError',
blockType: BlockType.REPORTER,
text: translate({ id: `${extId}.loginError` })
},
'---',
{
opcode: 'getUserId',
blockType: BlockType.REPORTER,
text: translate({ id: `${extId}.userId` })
},
{
opcode: 'isLoggedIn',
blockType: BlockType.BOOLEAN,
text: translate({ id: `${extId}.loggedIn` })
},
'---',
{
opcode: 'logout',
blockType: BlockType.COMMAND,
text: translate({ id: `${extId}.logout` })
},
'---',
{
opcode: 'openDocs',
blockType: BlockType.REPORTER,
text: translate({ id: `${extId}.openDocs` })
},
{
opcode: 'openPlatform',
blockType: BlockType.REPORTER,
text: translate({ id: `${extId}.openPlatform` })
}
]
};
}

// ---- 积木实现 ----
setProxyUrl(args) { this.proxyUrl = args.URL.replace(/\/+$/, ''); }
setClientId(args) { this.clientId = args.ID; }
setClientSecret(args) { this.clientSecret = args.SECRET; }
getLoginUrl() { return this._authUrl || '请先点击登录'; }
isLoggingIn() { return this.loggingIn; }
getLoginError() { return this._loginError; }
getUserId() { return this.userId; }
isLoggedIn() { return !!this.userId; }
logout() {
this.userId = '';
this.accessToken = '';
this._loginDone = false;
this._loginError = '';
}

openDocs() { return 'https://id.caellab.com/docs/'; }
openPlatform() { return 'https://id.caellab.com/open'; }

_randomState() {
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}

login() {
if (this.loggingIn) return;
this.loggingIn = true;
this._loginDone = false;

const state = this._randomState();
this._currentState = state;

this._authUrl = `${CAELLAB_BASE}/oauth/authorize?${new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.proxyUrl.replace(/\/+$/, '') + '/callback.php',
scope: 'openid',
state: state
})}`;

window.open(this._authUrl, '_blank');

this._loginError = '等待授权...';

fetch(`${this.proxyUrl}/auth.php?state=${state}`).catch(() => {});

this._pollForResult(state);
}

async _pollForResult(state) {
const startTime = Date.now();
const poll = async () => {
if (this._loginDone || Date.now() - startTime > 300000) {
if (!this._loginDone) {
this._loginError = '登录超时(5分钟)';
this.loggingIn = false;
}
return;
}
try {
const res = await fetch(`${this.proxyUrl}/result.php?state=${state}`);
if (res.ok) {
const data = await res.json();
if (data.status === 'done' || data.status === 'error') {
this._handleResult(data);
return;
}
}
} catch (e) {}
setTimeout(poll, 2000);
};
poll();
}

_handleResult(data) {
if (data.status === 'done') {
this.userId = data.user.sub || data.user.id || '';
this.accessToken = data.access_token || '';
this._loginDone = true;
this._loginError = '';
} else if (data.status === 'error') {
this._loginError = data.error || '登录失败';
}
this.loggingIn = false;
}
}

extensions.register(new CaelLabAuth(runtime));

}(Scratch));

php/auth.php

授权入口,创建 session 文件并存储 state。

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}

// ============ 配置 ============
$CLIENT_ID = 'app_619a5612aa02113bc6eddc80';

// 支持从 URL 接收 state(客户端生成),或自动生成
$state = $_GET['state'] ?? '';
if (!$state || !preg_match('/^[a-f0-9]{32}$/', $state)) {
$state = bin2hex(openssl_random_pseudo_bytes(16));
}

// 存储 state
$sessionsDir = __DIR__ . '/sessions';
if (!is_dir($sessionsDir)) {
mkdir($sessionsDir, 0777, true);
}

$data = ['status' => 'pending', 'time' => time()];
$sessionFile = $sessionsDir . '/' . $state . '.json';

if (file_put_contents($sessionFile, json_encode($data)) === false) {
echo json_encode(['error' => '无法写入session文件,请检查目录权限']);
exit;
}

// 如果客户端只是来创建 session(传了 state 但不需要返回 authUrl)
if (isset($_GET['state'])) {
echo json_encode(['ok' => true, 'state' => $state]);
exit;
}

// 构造授权 URL(直接返回给客户端用)
$params = http_build_query([
'response_type' => 'code',
'client_id' => $CLIENT_ID,
'redirect_uri' => 'https://id.caellab.com/demo/sc/callback.php',
'scope' => 'openid',
'state' => $state
]);

$authUrl = "https://id.caellab.com/oauth/authorize?" . $params;

echo json_encode(['authUrl' => $authUrl, 'state' => $state]);

php/callback.php

OAuth 回调,用授权码换取 Token 并获取用户信息。

<?php
// ============ 配置 ============
$CLIENT_ID = 'app_619a5612aa02113bc6eddc80';
$CLIENT_SECRET = 'ebde00c24c8d1486450c1e9b7124c46efe6edeb2fe0110f2f4d36ad484e06283';
$REDIRECT_URI = 'https://id.caellab.com/demo/sc/callback.php';

// 获取回调参数
$code = $_GET['code'] ?? '';
$state = $_GET['state'] ?? '';
$error = $_GET['error'] ?? '';

// state 文件路径
$sessionsDir = __DIR__ . '/sessions';
if (!is_dir($sessionsDir)) {
mkdir($sessionsDir, 0777, true);
}
$sessionFile = $sessionsDir . '/' . $state . '.json';

// 验证 state 格式
if (!$state || !preg_match('/^[a-f0-9]{32}$/', $state)) {
echo '<html><body><h2>无效的请求</h2></body></html>';
exit;
}

// session 文件可能还没创建(auth.php 请求失败),直接创建
if (!file_exists($sessionFile)) {
file_put_contents($sessionFile, json_encode(['status' => 'pending', 'time' => time()]));
}

// 用户拒绝授权
if ($error) {
$data = ['status' => 'error', 'error' => $error, 'time' => time()];
file_put_contents($sessionFile, json_encode($data));
echo '<html><body><h2>授权已取消</h2></body></html>';
exit;
}

// 用 code 换 token(用 cURL 替代 file_get_contents,更可靠)
$ch = curl_init('https://id.caellab.com/oauth/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $REDIRECT_URI,
'client_id' => $CLIENT_ID,
'client_secret' => $CLIENT_SECRET
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded'],
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 10
]);

$tokenRaw = curl_exec($ch);
$curlErr = curl_error($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// cURL 本身失败(网络/SSL)
if ($tokenRaw === false) {
$data = ['status' => 'error', 'error' => 'token请求失败: ' . ($curlErr ?: '未知'), 'time' => time()];
file_put_contents($sessionFile, json_encode($data));
echo '<html><body><h2>登录失败</h2><pre>cURL错误: ' . htmlspecialchars($curlErr) . '\nHTTP: ' . $httpCode . '</pre></body></html>';
exit;
}

$tokenRes = json_decode($tokenRaw);

// 获取失败
if (!$tokenRes || isset($tokenRes->error)) {
$errorMsg = $tokenRes->error ?? $tokenRes->error_description ?? ('HTTP ' . $httpCode);
$data = ['status' => 'error', 'error' => 'token获取失败: ' . $errorMsg, 'time' => time()];
file_put_contents($sessionFile, json_encode($data));
echo '<html><body><h2>登录失败</h2><pre>' . htmlspecialchars($tokenRaw) . '</pre></body></html>';
exit;
}

// 获取用户信息
$userRes = json_decode(file_get_contents('https://id.caellab.com/api/userinfo', false, stream_context_create([
'http' => [
'header' => 'Authorization: Bearer ' . $tokenRes->access_token
]
])));

// 存储结果
$data = [
'status' => 'done',
'user' => $userRes,
'time' => time()
];
file_put_contents($sessionFile, json_encode($data));

// 返回页面,通过 postMessage 通知 opener
?>
<!DOCTYPE html>
<html>
<head><title>登录成功</title></head>
<body>
<h2>登录成功!</h2>
<p>可以关闭此页面。</p>
<script>
try {
if (window.opener) {
window.opener.postMessage({
type: 'caellab-login',
status: 'done',
state: '<?php echo htmlspecialchars($state); ?>'
}, '*');
}
} catch(e) {}
window.close();
</script>
</body>
</html>

php/result.php

前端轮询此接口获取登录结果。

<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, OPTIONS');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
exit;
}

$state = $_GET['state'] ?? '';

// 验证 state 格式
if (!$state || !preg_match('/^[a-f0-9]{32}$/', $state)) {
http_response_code(400);
echo json_encode(['error' => 'invalid state']);
exit;
}

$sessionFile = __DIR__ . '/sessions/' . $state . '.json';

// 验证 state
if (!file_exists($sessionFile)) {
http_response_code(404);
echo json_encode(['error' => 'session not found']);
exit;
}

$content = file_get_contents($sessionFile);
$data = json_decode($content, true);

if (!$data) {
http_response_code(500);
echo json_encode(['error' => 'invalid session data']);
exit;
}

// 超过 10 分钟自动删除
if (time() - ($data['time'] ?? 0) > 600) {
unlink($sessionFile);
http_response_code(404);
echo json_encode(['error' => 'session expired']);
exit;
}

echo json_encode($data);

// 如果已完成或出错,删除文件
if (($data['status'] ?? '') === 'done' || ($data['status'] ?? '') === 'error') {
unlink($sessionFile);
}

LICENSE

CaelLab BY-SA Code License

Copyright (c) 2026 Yunyun @ CaelLab

Permission is hereby granted, free of charge, to any person obtaining a copy
of this work (the "Work"), to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Work, for personal, commercial, or
non-commercial purposes, subject to the following conditions:

1. Source Availability: If you distribute the Work, or any derivative work
based on the Work, you must make the complete corresponding source code
available under the terms of this same license.

2. License Preservation: The above copyright notice and this permission notice
shall be included in all copies or substantial portions of the Work.

3. ShareAlike: Any distributed derivative work must be licensed under the
CaelLab BY-SA Code License.

THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.