222 lines
6.4 KiB
TypeScript
222 lines
6.4 KiB
TypeScript
|
|
import { Prisma } from "@fenghuo/db";
|
|||
|
|
|
|||
|
|
// apps/web/lib/oidc-client.ts
|
|||
|
|
interface OIDCTokenResponse {
|
|||
|
|
access_token: string;
|
|||
|
|
token_type: string;
|
|||
|
|
expires_in: number;
|
|||
|
|
refresh_token?: string;
|
|||
|
|
id_token?: string;
|
|||
|
|
scope: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OIDCUserInfo {
|
|||
|
|
sub: string;
|
|||
|
|
username: string;
|
|||
|
|
email: string;
|
|||
|
|
name: string;
|
|||
|
|
department?: string;
|
|||
|
|
roles?: string[];
|
|||
|
|
[key: string]: any;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface OIDCError {
|
|||
|
|
error: string;
|
|||
|
|
error_description?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export class OIDCClient {
|
|||
|
|
private baseUrl: string;
|
|||
|
|
private clientId: string;
|
|||
|
|
private clientSecret?: string;
|
|||
|
|
|
|||
|
|
constructor() {
|
|||
|
|
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; // 这里配置oidc服务器的地址
|
|||
|
|
this.clientId = process.env.NEXT_PUBLIC_OIDC_CLIENT_ID || 'web-app'; // 这里配置对应的id
|
|||
|
|
this.clientSecret = process.env.NEXT_PUBLIC_OIDC_CLIENT_SECRET;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 使用用户名密码登录获取访问令牌
|
|||
|
|
*/
|
|||
|
|
async loginWithPassword(username: string, password: string): Promise<OIDCTokenResponse> {
|
|||
|
|
const response = await fetch(`${this.baseUrl}/auth/token`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|||
|
|
},
|
|||
|
|
body: new URLSearchParams({
|
|||
|
|
grant_type: 'password',
|
|||
|
|
username,
|
|||
|
|
password,
|
|||
|
|
client_id: this.clientId,
|
|||
|
|
scope: 'openid profile email',
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error: OIDCError = await response.json();
|
|||
|
|
throw new Error(error.error_description || error.error || '登录失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return response.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 使用刷新令牌获取新的访问令牌
|
|||
|
|
*/
|
|||
|
|
async refreshToken(refreshToken: string): Promise<OIDCTokenResponse> {
|
|||
|
|
const response = await fetch(`${this.baseUrl}/auth/token`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|||
|
|
'Authorization': this.clientSecret ?
|
|||
|
|
`Basic ${btoa(`${this.clientId}:${this.clientSecret}`)}` : '',
|
|||
|
|
},
|
|||
|
|
body: new URLSearchParams({
|
|||
|
|
grant_type: 'refresh_token',
|
|||
|
|
refresh_token: refreshToken,
|
|||
|
|
client_id: this.clientId,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error: OIDCError = await response.json();
|
|||
|
|
throw new Error(error.error_description || error.error || '刷新令牌失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return response.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 获取用户信息
|
|||
|
|
*/
|
|||
|
|
async getUserInfo(accessToken: string): Promise<OIDCUserInfo> {
|
|||
|
|
const response = await fetch(`${this.baseUrl}/auth/userinfo`, {
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${accessToken}`,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error: OIDCError = await response.json();
|
|||
|
|
throw new Error(error.error_description || error.error || '获取用户信息失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return response.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注销登录
|
|||
|
|
*/
|
|||
|
|
async logout(accessToken?: string): Promise<void> {
|
|||
|
|
if (accessToken) {
|
|||
|
|
await fetch(`${this.baseUrl}/auth/revoke`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|||
|
|
'Authorization': `Bearer ${accessToken}`,
|
|||
|
|
},
|
|||
|
|
body: new URLSearchParams({
|
|||
|
|
token: accessToken,
|
|||
|
|
token_type_hint: 'access_token',
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 注册新用户
|
|||
|
|
*/
|
|||
|
|
async register(userData: Prisma.UserCreateArgs): Promise<{ success: boolean; message: string }> {
|
|||
|
|
const response = await fetch(`${this.baseUrl}/trpc/user.create`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
},
|
|||
|
|
body: JSON.stringify({
|
|||
|
|
json: userData,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const errorData = await response.json();
|
|||
|
|
throw new Error(errorData.error?.message || '注册失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await response.json();
|
|||
|
|
return {
|
|||
|
|
success: true,
|
|||
|
|
message: '注册成功',
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 构建授权 URL(用于跳转到登录页面)
|
|||
|
|
*/
|
|||
|
|
buildAuthorizationUrl(options: {
|
|||
|
|
redirectUri: string;
|
|||
|
|
state?: string;
|
|||
|
|
scope?: string;
|
|||
|
|
nonce?: string;
|
|||
|
|
}): string {
|
|||
|
|
const { redirectUri, state = this.generateState(), scope = 'openid profile email', nonce = this.generateNonce() } = options;
|
|||
|
|
|
|||
|
|
const params = new URLSearchParams({
|
|||
|
|
response_type: 'code',
|
|||
|
|
client_id: this.clientId,
|
|||
|
|
redirect_uri: redirectUri,
|
|||
|
|
scope,
|
|||
|
|
state,
|
|||
|
|
nonce,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return `${this.baseUrl}/auth/auth?${params.toString()}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处理授权回调(交换授权码获取令牌)
|
|||
|
|
*/
|
|||
|
|
async handleAuthorizationCallback(
|
|||
|
|
authorizationCode: string,
|
|||
|
|
redirectUri: string,
|
|||
|
|
state?: string
|
|||
|
|
): Promise<OIDCTokenResponse> {
|
|||
|
|
const response = await fetch(`${this.baseUrl}/auth/token`, {
|
|||
|
|
method: 'POST',
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|||
|
|
},
|
|||
|
|
body: new URLSearchParams({
|
|||
|
|
grant_type: 'authorization_code',
|
|||
|
|
code: authorizationCode,
|
|||
|
|
redirect_uri: redirectUri,
|
|||
|
|
client_id: this.clientId,
|
|||
|
|
...(this.clientSecret && { client_secret: this.clientSecret }),
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!response.ok) {
|
|||
|
|
const error: OIDCError = await response.json();
|
|||
|
|
throw new Error(error.error_description || error.error || '授权码交换失败');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return response.json();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成随机状态值(防CSRF)
|
|||
|
|
*/
|
|||
|
|
private generateState(): string {
|
|||
|
|
return btoa(Math.random().toString(36).substr(2, 9));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 生成随机 nonce 值
|
|||
|
|
*/
|
|||
|
|
private generateNonce(): string {
|
|||
|
|
return btoa(Math.random().toString(36).substr(2, 9));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export const oidcClient = new OIDCClient();
|