222 lines
6.4 KiB
TypeScript
Executable File
222 lines
6.4 KiB
TypeScript
Executable File
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(); |