feat: 获取OAuth Token、获取OAuth用户信息、刷新OAuth令牌

endpoint实现
This commit is contained in:
Pan Qiancheng 2025-10-23 15:28:28 +08:00
parent 7baa7f808c
commit f824463a2a
2 changed files with 407 additions and 1 deletions

View File

@ -1,8 +1,9 @@
import wechat, { registerWeChatPublicEventCallback } from './wechat';
import oauth from './oauth';
export default {
...wechat,
...oauth,
};
export {

405
src/endpoints/oauth.ts Normal file
View File

@ -0,0 +1,405 @@
import { Endpoint } from "oak-domain/lib/types";
import { generateNewIdAsync } from "oak-domain/lib/utils/uuid";
import { randomUUID } from "crypto";
import { BRC } from "../types/RuntimeCxt";
import BackendRuntimeContext from "../context/BackendRuntimeContext";
import { EntityDict } from "../oak-app-domain";
import { applicationProjection, extraFileProjection } from "../types/Projection";
import { composeFileUrl } from "../utils/cos/index.backend";
const oauthTokenEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
name: "获取OAuth Token",
params: [],
method: 'post',
type: "free",
fn: async (contextBuilder, params, header, req, body) => {
const context = await contextBuilder()
const { client_id, client_secret, grant_type, code, redirect_uri } = body as { client_id: string, client_secret: string, grant_type: string, code?: string, redirect_uri?: string };
const [app] = await context.select("oauthApplication", {
data: {
id: 1,
clientSecret: 1,
},
filter: {
id: client_id,
}
}, {})
if (!app) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_client", error_description: "Client not found", success: false }
};
}
if (app.clientSecret !== client_secret) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_client", error_description: "Client secret mismatch", success: false }
};
}
// grant_type几种类型 目前只支持authorization_code
if (grant_type !== "authorization_code") {
await context.commit();
return {
statusCode: 400,
data: { error: "unsupported_grant_type", error_description: "Only authorization_code grant type is supported", success: false }
};
}
// 找code的记录
const [authCodeRecord] = await context.select("oauthAuthorizationCode", {
data: {
id: 1,
code: 1,
redirectUri: 1,
userId: 1,
expiresAt: 1,
usedAt: 1,
},
filter: {
code,
}
}, {})
// 找不到记录
if (!authCodeRecord) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_grant", error_description: "Invalid authorization code", success: false }
};
}
// 验证redirect_uri
if (authCodeRecord.redirectUri !== redirect_uri) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_grant", error_description: "Redirect URI mismatch", success: false }
};
}
// 验证过期
if (authCodeRecord.expiresAt as number < Date.now()) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_grant", error_description: "Authorization code expired", success: false }
};
}
// 验证是否已使用
if (authCodeRecord.usedAt) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_grant", error_description: "Authorization code already used", success: false }
};
}
// 创建accessToken
const genaccessToken = randomUUID();
const expiresIn = 3600; // 1 hour
const refreshToken = randomUUID();
const refreshTokenExpiresIn = 86400 * 30; // 30 days
// create
await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "create",
data: {
id: await generateNewIdAsync(),
accessToken: genaccessToken,
refreshToken: refreshToken,
userId: authCodeRecord.userId,
accessExpiresAt: Date.now() + expiresIn * 1000,
refreshExpiresAt: Date.now() + refreshTokenExpiresIn * 1000,
codeId: authCodeRecord.id,
}
}, {})
// 标记code为已使用
await context.operate("oauthAuthorizationCode", {
id: await generateNewIdAsync(),
action: "update",
data: {
usedAt: Date.now(),
},
filter: {
id: authCodeRecord.id,
}
}, {})
await context.commit();
return {
statusCode: 200,
data: {
access_token: genaccessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: refreshToken,
refresh_expires_in: refreshTokenExpiresIn,
success: true,
}
};
}
}
const oauthUserInfoEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
name: "获取OAuth用户信息",
params: [],
method: 'get',
type: "free",
fn: async (contextBuilder, params, header, req, body) => {
const context = await contextBuilder()
const token = header.authorization; // Bearer token
// Validate and decode the token
const decoded = validateToken(token);
if (decoded.error) {
return {
statusCode: 401,
data: { error: decoded.error, success: false }
}
}
// 获取token记录
const [tokenRecord] = await context.select("oauthToken", {
data: {
id: 1,
user: {
id: 1,
name: 1,
nickname: 1,
birth: 1,
gender: 1,
extraFile$entity: {
$entity: 'extraFile',
data: extraFileProjection,
filter: {
tag1: 'avatar',
}
}
},
accessExpiresAt: 1,
code: {
id: 1,
application: applicationProjection,
}
},
filter: {
accessToken: decoded.token,
}
}, {})
if (!tokenRecord) {
await context.commit();
return { statusCode: 401, data: { error: "Invalid token", success: false } };
}
if (tokenRecord.accessExpiresAt as number < Date.now()) {
await context.commit();
return { statusCode: 401, data: { error: "Token expired", success: false } };
}
if (!tokenRecord.user) {
await context.commit();
return { statusCode: 401, data: { error: "User not found", success: false } };
}
if (!tokenRecord.code || !tokenRecord.code.application) {
await context.commit();
return { statusCode: 401, data: { error: "Application not found", success: false } };
}
const extrafile = tokenRecord.user.extraFile$entity?.[0];
const application = tokenRecord.code.application;
let avatarUrl = '';
if (extrafile) {
avatarUrl = composeFileUrl(application, extrafile as EntityDict['extraFile']['Schema']);
}
// 更新最后使用日期
await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "update",
data: {
lastUsedAt: Date.now(),
},
filter: {
id: tokenRecord.id,
}
}, {});
await context.commit();
return {
statusCode: 200, data: {
userInfo: {
id: tokenRecord.user.id,
name: tokenRecord.user.name,
nickname: tokenRecord.user.nickname,
birth: tokenRecord.user.birth,
gender: tokenRecord.user.gender,
avatarUrl: avatarUrl,
},
error: null
}
};
}
}
function validateToken(token: string | undefined): {
token: string;
error: string | null;
} {
if (!token) {
return { token: "", error: "Missing authorization token" };
}
// Token validation logic here
if (!token.startsWith("Bearer ")) {
return { token: "", error: "Invalid token format" };
}
return { token: token.slice(7), error: null };
}
const refreshTokenEndpoint: Endpoint<EntityDict, BackendRuntimeContext<EntityDict>> = {
name: "刷新OAuth令牌",
params: [],
method: 'post',
type: "free",
fn: async (contextBuilder, params, header, req, body) => {
const { refresh_token, grant_type } = body as { refresh_token: string, grant_type: string };
const { authorization } = header
// 暂时只支持 refresh_token 模式
if (grant_type !== "refresh_token") {
return {
statusCode: 400,
data: { error: "unsupported_grant_type", error_description: "Only refresh_token grant type is supported", success: false }
};
}
// 根据 RFC 6749 规范,请求参数在 Body 中以表单格式application/x-www-form-urlencoded提交。
// authorization header 中包含 client_id 和 client_secret 的 Base64 编码
const decodedAuth = Buffer.from((authorization || '').split(' ')[1] || '', 'base64').toString('utf-8');
const [client_id, client_secret] = decodedAuth.split(':');
if (!client_id || !client_secret) {
return {
statusCode: 401,
data: { error: "invalid_client", error_description: "Missing client credentials", success: false }
}
}
if (!refresh_token) {
return {
statusCode: 400,
data: { error: "invalid_request", error_description: "Missing refresh_token", success: false }
}
}
const context = await contextBuilder()
const [oauthApp] = await context.select("oauthApplication", {
data: {
id: 1,
},
filter: {
clientSecret: client_secret,
id: client_id,
}
}, {});
if (!oauthApp) {
await context.commit();
return {
statusCode: 401,
data: { error: "invalid_client", error_description: "Client authentication failed", success: false }
}
}
const [tokenRecord] = await context.select("oauthToken", {
data: {
id: 1,
userId: 1,
accessToken: 1,
accessExpiresAt: 1,
refreshToken: 1,
refreshExpiresAt: 1,
code: {
applicationId: 1,
oauthApp: {
id: 1,
}
}
},
filter: {
refreshToken: refresh_token,
code: {
oauthAppId: oauthApp.id,
}
}
}, {});
if (!tokenRecord) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_grant", error_description: "Invalid refresh token", success: false }
}
}
if (tokenRecord.refreshExpiresAt as number < Date.now()) {
await context.commit();
return {
statusCode: 400,
data: { error: "invalid_grant", error_description: "Refresh token expired", success: false }
}
}
// 生成新的令牌
const newAccessToken = randomUUID();
const newRefreshToken = randomUUID();
const expiresIn = 3600; // 1 hour
const refreshTokenExpiresIn = 86400 * 30; // 30 days
await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "update",
data: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
accessExpiresAt: Date.now() + expiresIn * 1000,
refreshExpiresAt: Date.now() + refreshTokenExpiresIn * 1000,
},
filter: {
id: tokenRecord.id,
}
}, {});
await context.commit();
return {
statusCode: 200,
data: {
access_token: newAccessToken,
token_type: "Bearer",
expires_in: expiresIn,
refresh_token: newRefreshToken,
refresh_expires_in: refreshTokenExpiresIn,
success: true,
}
};
}
}
const endpoints: Record<string, Endpoint<EntityDict, BRC<EntityDict>>> = {
'oauth/access_token': oauthTokenEndpoint,
'oauth/userinfo': oauthUserInfoEndpoint,
'oauth/token': refreshTokenEndpoint,
}
export default endpoints;