932 lines
31 KiB
TypeScript
932 lines
31 KiB
TypeScript
![]() |
import { nanoid } from 'nanoid';
|
|||
|
import type {
|
|||
|
OIDCProviderConfig,
|
|||
|
OIDCClient,
|
|||
|
OIDCUser,
|
|||
|
AuthorizationCode,
|
|||
|
AccessToken,
|
|||
|
RefreshToken,
|
|||
|
IDToken,
|
|||
|
AuthorizationRequest,
|
|||
|
TokenRequest,
|
|||
|
TokenResponse,
|
|||
|
OIDCError,
|
|||
|
DiscoveryDocument,
|
|||
|
} from './types';
|
|||
|
import type { StorageAdapter } from './storage/adapter';
|
|||
|
import { TokenManager } from './auth/token-manager';
|
|||
|
import { JWTUtils } from './utils/jwt';
|
|||
|
import { PKCEUtils } from './utils/pkce';
|
|||
|
import { ValidationUtils } from './utils/validation';
|
|||
|
|
|||
|
/**
|
|||
|
* OIDC Provider核心类
|
|||
|
*/
|
|||
|
export class OIDCProvider {
|
|||
|
private config: OIDCProviderConfig & {
|
|||
|
tokenTTL: {
|
|||
|
accessToken: number;
|
|||
|
refreshToken: number;
|
|||
|
authorizationCode: number;
|
|||
|
idToken: number;
|
|||
|
};
|
|||
|
responseTypes: string[];
|
|||
|
grantTypes: string[];
|
|||
|
scopes: string[];
|
|||
|
claims: string[];
|
|||
|
enablePKCE: boolean;
|
|||
|
requirePKCE: boolean;
|
|||
|
rotateRefreshTokens: boolean;
|
|||
|
};
|
|||
|
private storage: StorageAdapter;
|
|||
|
private tokenManager: TokenManager;
|
|||
|
private jwtUtils: JWTUtils;
|
|||
|
private findUser: (userId: string) => Promise<OIDCUser | null>;
|
|||
|
private findClient: (clientId: string) => Promise<OIDCClient | null>;
|
|||
|
|
|||
|
constructor(config: OIDCProviderConfig) {
|
|||
|
// 设置默认配置
|
|||
|
const defaultTokenTTL = {
|
|||
|
accessToken: 3600, // 1小时
|
|||
|
refreshToken: 30 * 24 * 3600, // 30天
|
|||
|
authorizationCode: 600, // 10分钟
|
|||
|
idToken: 3600, // 1小时
|
|||
|
};
|
|||
|
|
|||
|
this.config = {
|
|||
|
...config,
|
|||
|
tokenTTL: {
|
|||
|
...defaultTokenTTL,
|
|||
|
...config.tokenTTL,
|
|||
|
},
|
|||
|
responseTypes: config.responseTypes || ['code', 'id_token', 'token', 'code id_token', 'code token', 'id_token token', 'code id_token token'],
|
|||
|
grantTypes: config.grantTypes || ['authorization_code', 'refresh_token', 'implicit'],
|
|||
|
scopes: config.scopes || ['openid', 'profile', 'email', 'phone', 'address'],
|
|||
|
claims: config.claims || [
|
|||
|
'sub', 'name', 'given_name', 'family_name', 'middle_name', 'nickname',
|
|||
|
'preferred_username', 'profile', 'picture', 'website', 'email',
|
|||
|
'email_verified', 'gender', 'birthdate', 'zoneinfo', 'locale',
|
|||
|
'phone_number', 'phone_number_verified', 'address', 'updated_at'
|
|||
|
],
|
|||
|
enablePKCE: config.enablePKCE ?? true,
|
|||
|
requirePKCE: config.requirePKCE ?? false,
|
|||
|
rotateRefreshTokens: config.rotateRefreshTokens ?? true,
|
|||
|
};
|
|||
|
|
|||
|
this.storage = config.storage;
|
|||
|
this.tokenManager = new TokenManager(config.storage);
|
|||
|
this.findUser = config.findUser;
|
|||
|
this.findClient = config.findClient;
|
|||
|
this.jwtUtils = new JWTUtils(config.signingKey, config.signingAlgorithm);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 获取发现文档
|
|||
|
*/
|
|||
|
getDiscoveryDocument(): DiscoveryDocument {
|
|||
|
const baseUrl = this.config.issuer;
|
|||
|
|
|||
|
return {
|
|||
|
issuer: this.config.issuer,
|
|||
|
authorization_endpoint: `${baseUrl}/auth`,
|
|||
|
token_endpoint: `${baseUrl}/token`,
|
|||
|
userinfo_endpoint: `${baseUrl}/userinfo`,
|
|||
|
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
|||
|
registration_endpoint: `${baseUrl}/register`,
|
|||
|
revocation_endpoint: `${baseUrl}/revoke`,
|
|||
|
introspection_endpoint: `${baseUrl}/introspect`,
|
|||
|
end_session_endpoint: `${baseUrl}/logout`,
|
|||
|
response_types_supported: this.config.responseTypes,
|
|||
|
grant_types_supported: this.config.grantTypes,
|
|||
|
scopes_supported: this.config.scopes,
|
|||
|
claims_supported: this.config.claims,
|
|||
|
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|||
|
id_token_signing_alg_values_supported: ['HS256', 'RS256', 'ES256'],
|
|||
|
subject_types_supported: ['public'],
|
|||
|
code_challenge_methods_supported: this.config.enablePKCE ? ['plain', 'S256'] : undefined,
|
|||
|
response_modes_supported: ['query', 'fragment', 'form_post'],
|
|||
|
claims_parameter_supported: false,
|
|||
|
request_parameter_supported: false,
|
|||
|
request_uri_parameter_supported: false,
|
|||
|
require_request_uri_registration: false,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 处理授权请求
|
|||
|
*/
|
|||
|
async handleAuthorizationRequest(
|
|||
|
request: Partial<AuthorizationRequest>,
|
|||
|
userId?: string
|
|||
|
): Promise<{ success: true; code: string; redirectUri: string } | { success: false; error: OIDCError; redirectUri?: string }> {
|
|||
|
try {
|
|||
|
// 获取客户端信息
|
|||
|
if (!request.client_id) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Missing client_id parameter',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
const client = await this.findClient(request.client_id);
|
|||
|
if (!client) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_client',
|
|||
|
error_description: 'Invalid client_id',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证请求
|
|||
|
const validation = ValidationUtils.validateAuthorizationRequest(
|
|||
|
request,
|
|||
|
client,
|
|||
|
this.config.scopes,
|
|||
|
this.config.responseTypes
|
|||
|
);
|
|||
|
|
|||
|
if (!validation.valid) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: validation.errors.join(', '),
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 检查是否需要用户认证
|
|||
|
if (!userId) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'login_required',
|
|||
|
error_description: 'User authentication is required',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 强制PKCE检查
|
|||
|
if (this.config.requirePKCE && client.client_type === 'public' && !request.code_challenge) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'PKCE is required for public clients',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证PKCE(如果启用且提供了代码挑战)
|
|||
|
if (this.config.enablePKCE && request.code_challenge) {
|
|||
|
if (!request.code_challenge_method) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Missing code_challenge_method',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
if (!PKCEUtils.isSupportedMethod(request.code_challenge_method)) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Unsupported code_challenge_method',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
if (!PKCEUtils.isValidCodeChallenge(request.code_challenge, request.code_challenge_method)) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Invalid code_challenge',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 生成授权码
|
|||
|
const code = nanoid(32);
|
|||
|
const now = new Date();
|
|||
|
const expiresAt = new Date(now.getTime() + this.config.tokenTTL.authorizationCode * 1000);
|
|||
|
|
|||
|
const authCode: AuthorizationCode = {
|
|||
|
code,
|
|||
|
client_id: request.client_id,
|
|||
|
user_id: userId,
|
|||
|
redirect_uri: request.redirect_uri!,
|
|||
|
scope: request.scope!,
|
|||
|
code_challenge: request.code_challenge,
|
|||
|
code_challenge_method: request.code_challenge_method,
|
|||
|
nonce: request.nonce,
|
|||
|
state: request.state,
|
|||
|
expires_at: expiresAt,
|
|||
|
created_at: now,
|
|||
|
};
|
|||
|
|
|||
|
await this.tokenManager.storeAuthorizationCode(authCode);
|
|||
|
|
|||
|
return {
|
|||
|
success: true,
|
|||
|
code,
|
|||
|
redirectUri: request.redirect_uri!,
|
|||
|
};
|
|||
|
} catch (error) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'server_error',
|
|||
|
error_description: 'Internal server error',
|
|||
|
state: request.state,
|
|||
|
},
|
|||
|
redirectUri: request.redirect_uri,
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 处理令牌请求
|
|||
|
*/
|
|||
|
async handleTokenRequest(
|
|||
|
request: Partial<TokenRequest>
|
|||
|
): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> {
|
|||
|
try {
|
|||
|
// 获取客户端信息
|
|||
|
if (!request.client_id) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Missing client_id parameter',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
const client = await this.findClient(request.client_id);
|
|||
|
if (!client) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_client',
|
|||
|
error_description: 'Invalid client_id',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证客户端认证(如果需要)
|
|||
|
if (client.client_type === 'confidential') {
|
|||
|
if (!request.client_secret) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_client',
|
|||
|
error_description: 'Client authentication required',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
if (request.client_secret !== client.client_secret) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_client',
|
|||
|
error_description: 'Invalid client credentials',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 验证请求
|
|||
|
const validation = ValidationUtils.validateTokenRequest(
|
|||
|
request,
|
|||
|
client,
|
|||
|
this.config.grantTypes
|
|||
|
);
|
|||
|
|
|||
|
if (!validation.valid) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: validation.errors.join(', '),
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
if (request.grant_type === 'authorization_code') {
|
|||
|
return await this.handleAuthorizationCodeGrant(request, client);
|
|||
|
} else if (request.grant_type === 'refresh_token') {
|
|||
|
return await this.handleRefreshTokenGrant(request, client);
|
|||
|
} else {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'unsupported_grant_type',
|
|||
|
error_description: 'Grant type not supported',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
} catch (error) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'server_error',
|
|||
|
error_description: 'Internal server error',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 处理授权码授权类型
|
|||
|
*/
|
|||
|
private async handleAuthorizationCodeGrant(
|
|||
|
request: Partial<TokenRequest>,
|
|||
|
client: OIDCClient
|
|||
|
): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> {
|
|||
|
if (!request.code) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Missing authorization code',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 获取授权码
|
|||
|
const authCode = await this.tokenManager.getAuthorizationCode(request.code);
|
|||
|
if (!authCode) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Invalid authorization code',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 检查授权码是否过期
|
|||
|
if (authCode.expires_at < new Date()) {
|
|||
|
await this.tokenManager.deleteAuthorizationCode(request.code);
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Authorization code expired',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证客户端ID
|
|||
|
if (authCode.client_id !== request.client_id) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Authorization code was not issued to this client',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证重定向URI
|
|||
|
if (authCode.redirect_uri !== request.redirect_uri) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Redirect URI mismatch',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证PKCE(如果使用)
|
|||
|
if (authCode.code_challenge) {
|
|||
|
if (!request.code_verifier) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Missing code_verifier',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
const isValidPKCE = PKCEUtils.verifyCodeChallenge(
|
|||
|
request.code_verifier,
|
|||
|
authCode.code_challenge,
|
|||
|
authCode.code_challenge_method || 'plain'
|
|||
|
);
|
|||
|
|
|||
|
if (!isValidPKCE) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Invalid code_verifier',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 获取用户信息
|
|||
|
const user = await this.findUser(authCode.user_id);
|
|||
|
if (!user) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'User not found',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 生成令牌
|
|||
|
const now = new Date();
|
|||
|
const accessTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.accessToken * 1000);
|
|||
|
const refreshTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.refreshToken * 1000);
|
|||
|
const idTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.idToken * 1000);
|
|||
|
|
|||
|
// 生成访问令牌
|
|||
|
const accessTokenJWT = await this.jwtUtils.generateAccessToken({
|
|||
|
issuer: this.config.issuer,
|
|||
|
subject: user.sub,
|
|||
|
audience: request.client_id!,
|
|||
|
clientId: request.client_id!,
|
|||
|
scope: authCode.scope,
|
|||
|
expiresIn: this.config.tokenTTL.accessToken,
|
|||
|
});
|
|||
|
|
|||
|
const accessToken: AccessToken = {
|
|||
|
token: accessTokenJWT,
|
|||
|
client_id: request.client_id!,
|
|||
|
user_id: user.sub,
|
|||
|
scope: authCode.scope,
|
|||
|
expires_at: accessTokenExpiry,
|
|||
|
created_at: now,
|
|||
|
};
|
|||
|
|
|||
|
// 生成刷新令牌
|
|||
|
const refreshTokenValue = nanoid(64);
|
|||
|
const refreshToken: RefreshToken = {
|
|||
|
token: refreshTokenValue,
|
|||
|
client_id: request.client_id!,
|
|||
|
user_id: user.sub,
|
|||
|
scope: authCode.scope,
|
|||
|
expires_at: refreshTokenExpiry,
|
|||
|
created_at: now,
|
|||
|
};
|
|||
|
|
|||
|
// 生成ID令牌(如果请求了openid作用域)
|
|||
|
let idTokenJWT: string | undefined;
|
|||
|
if (authCode.scope.includes('openid')) {
|
|||
|
const requestedClaims = this.getRequestedClaims(authCode.scope);
|
|||
|
|
|||
|
idTokenJWT = await this.jwtUtils.generateIDToken({
|
|||
|
issuer: this.config.issuer,
|
|||
|
subject: user.sub,
|
|||
|
audience: request.client_id!,
|
|||
|
user,
|
|||
|
authTime: Math.floor(now.getTime() / 1000),
|
|||
|
nonce: authCode.nonce,
|
|||
|
expiresIn: this.config.tokenTTL.idToken,
|
|||
|
requestedClaims,
|
|||
|
});
|
|||
|
|
|||
|
const idToken: IDToken = {
|
|||
|
token: idTokenJWT,
|
|||
|
client_id: request.client_id!,
|
|||
|
user_id: user.sub,
|
|||
|
nonce: authCode.nonce,
|
|||
|
expires_at: idTokenExpiry,
|
|||
|
created_at: now,
|
|||
|
};
|
|||
|
|
|||
|
await this.tokenManager.storeIDToken(idToken);
|
|||
|
}
|
|||
|
|
|||
|
// 存储令牌
|
|||
|
await this.tokenManager.storeAccessToken(accessToken);
|
|||
|
await this.tokenManager.storeRefreshToken(refreshToken);
|
|||
|
|
|||
|
// 删除已使用的授权码
|
|||
|
await this.tokenManager.deleteAuthorizationCode(request.code);
|
|||
|
|
|||
|
const response: TokenResponse = {
|
|||
|
access_token: accessTokenJWT,
|
|||
|
token_type: 'Bearer',
|
|||
|
expires_in: this.config.tokenTTL.accessToken,
|
|||
|
refresh_token: refreshTokenValue,
|
|||
|
scope: authCode.scope,
|
|||
|
};
|
|||
|
|
|||
|
if (idTokenJWT) {
|
|||
|
response.id_token = idTokenJWT;
|
|||
|
}
|
|||
|
|
|||
|
return {
|
|||
|
success: true,
|
|||
|
response,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 处理刷新令牌授权类型 - 修复以符合OIDC规范
|
|||
|
*/
|
|||
|
private async handleRefreshTokenGrant(
|
|||
|
request: Partial<TokenRequest>,
|
|||
|
client: OIDCClient
|
|||
|
): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> {
|
|||
|
if (!request.refresh_token) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_request',
|
|||
|
error_description: 'Missing refresh_token',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 获取刷新令牌
|
|||
|
const refreshToken = await this.tokenManager.getRefreshToken(request.refresh_token);
|
|||
|
if (!refreshToken) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Invalid refresh_token',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 检查刷新令牌是否过期
|
|||
|
if (refreshToken.expires_at < new Date()) {
|
|||
|
await this.tokenManager.deleteRefreshToken(request.refresh_token);
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Refresh token expired',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 验证客户端ID
|
|||
|
if (refreshToken.client_id !== request.client_id) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'Refresh token was not issued to this client',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 获取用户信息
|
|||
|
const user = await this.findUser(refreshToken.user_id);
|
|||
|
if (!user) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_grant',
|
|||
|
error_description: 'User not found',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 确定作用域(使用请求的作用域或原始作用域)
|
|||
|
let scope = refreshToken.scope;
|
|||
|
if (request.scope) {
|
|||
|
const requestedScopes = request.scope.split(' ');
|
|||
|
const originalScopes = refreshToken.scope.split(' ');
|
|||
|
|
|||
|
// 请求的作用域不能超过原始作用域
|
|||
|
if (!requestedScopes.every(s => originalScopes.includes(s))) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_scope',
|
|||
|
error_description: 'Requested scope exceeds original scope',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
scope = request.scope;
|
|||
|
}
|
|||
|
|
|||
|
// 生成新的访问令牌
|
|||
|
const now = new Date();
|
|||
|
const accessTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.accessToken * 1000);
|
|||
|
|
|||
|
const accessTokenJWT = await this.jwtUtils.generateAccessToken({
|
|||
|
issuer: this.config.issuer,
|
|||
|
subject: user.sub,
|
|||
|
audience: request.client_id!,
|
|||
|
clientId: request.client_id!,
|
|||
|
scope,
|
|||
|
expiresIn: this.config.tokenTTL.accessToken,
|
|||
|
});
|
|||
|
|
|||
|
const accessToken: AccessToken = {
|
|||
|
token: accessTokenJWT,
|
|||
|
client_id: request.client_id!,
|
|||
|
user_id: user.sub,
|
|||
|
scope,
|
|||
|
expires_at: accessTokenExpiry,
|
|||
|
created_at: now,
|
|||
|
};
|
|||
|
|
|||
|
await this.tokenManager.storeAccessToken(accessToken);
|
|||
|
|
|||
|
// 可选:生成新的刷新令牌(刷新令牌轮换)
|
|||
|
let newRefreshToken: string | undefined;
|
|||
|
if (this.config.rotateRefreshTokens !== false) { // 默认启用刷新令牌轮换
|
|||
|
const refreshTokenExpiry = new Date(now.getTime() + this.config.tokenTTL.refreshToken * 1000);
|
|||
|
newRefreshToken = nanoid(64);
|
|||
|
|
|||
|
const newRefreshTokenRecord: RefreshToken = {
|
|||
|
token: newRefreshToken,
|
|||
|
client_id: request.client_id!,
|
|||
|
user_id: user.sub,
|
|||
|
scope,
|
|||
|
expires_at: refreshTokenExpiry,
|
|||
|
created_at: now,
|
|||
|
};
|
|||
|
|
|||
|
await this.tokenManager.storeRefreshToken(newRefreshTokenRecord);
|
|||
|
// 删除旧的刷新令牌
|
|||
|
await this.tokenManager.deleteRefreshToken(request.refresh_token);
|
|||
|
}
|
|||
|
|
|||
|
const response: TokenResponse = {
|
|||
|
access_token: accessTokenJWT,
|
|||
|
token_type: 'Bearer',
|
|||
|
expires_in: this.config.tokenTTL.accessToken,
|
|||
|
scope,
|
|||
|
};
|
|||
|
|
|||
|
if (newRefreshToken) {
|
|||
|
response.refresh_token = newRefreshToken;
|
|||
|
}
|
|||
|
|
|||
|
return {
|
|||
|
success: true,
|
|||
|
response,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 获取用户信息
|
|||
|
*/
|
|||
|
async getUserInfo(accessToken: string): Promise<{ success: true; user: Partial<OIDCUser> } | { success: false; error: OIDCError }> {
|
|||
|
try {
|
|||
|
// 验证访问令牌
|
|||
|
const payload = await this.jwtUtils.verifyToken(accessToken);
|
|||
|
|
|||
|
// 检查令牌类型
|
|||
|
if ((payload as any).token_type !== 'access_token') {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_token',
|
|||
|
error_description: 'Invalid token type',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 获取令牌数据
|
|||
|
const tokenData = await this.tokenManager.getAccessToken(accessToken);
|
|||
|
if (!tokenData) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_token',
|
|||
|
error_description: 'Token not found',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 检查令牌是否过期
|
|||
|
if (tokenData.expires_at < new Date()) {
|
|||
|
await this.tokenManager.deleteAccessToken(accessToken);
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_token',
|
|||
|
error_description: 'Token expired',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 获取用户信息
|
|||
|
const user = await this.findUser(tokenData.user_id);
|
|||
|
if (!user) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_token',
|
|||
|
error_description: 'User not found',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 获取请求的声明
|
|||
|
const requestedClaims = this.getRequestedClaims(tokenData.scope);
|
|||
|
const filteredUser = this.filterUserClaims(user, requestedClaims);
|
|||
|
|
|||
|
return {
|
|||
|
success: true,
|
|||
|
user: filteredUser,
|
|||
|
};
|
|||
|
} catch (error) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'invalid_token',
|
|||
|
error_description: 'Invalid token',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 根据作用域获取请求的声明
|
|||
|
*/
|
|||
|
private getRequestedClaims(scope: string): string[] {
|
|||
|
const scopes = scope.split(' ');
|
|||
|
const claims: string[] = ['sub']; // 总是包含sub
|
|||
|
|
|||
|
if (scopes.includes('profile')) {
|
|||
|
claims.push(
|
|||
|
'name', 'family_name', 'given_name', 'middle_name', 'nickname',
|
|||
|
'preferred_username', 'profile', 'picture', 'website', 'gender',
|
|||
|
'birthdate', 'zoneinfo', 'locale', 'updated_at'
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
if (scopes.includes('email')) {
|
|||
|
claims.push('email', 'email_verified');
|
|||
|
}
|
|||
|
|
|||
|
if (scopes.includes('phone')) {
|
|||
|
claims.push('phone_number', 'phone_number_verified');
|
|||
|
}
|
|||
|
|
|||
|
if (scopes.includes('address')) {
|
|||
|
claims.push('address');
|
|||
|
}
|
|||
|
|
|||
|
return claims;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 根据请求的声明过滤用户信息
|
|||
|
*/
|
|||
|
private filterUserClaims(user: OIDCUser, requestedClaims: string[]): Partial<OIDCUser> {
|
|||
|
const filtered: Partial<OIDCUser> = {};
|
|||
|
|
|||
|
for (const claim of requestedClaims) {
|
|||
|
if (claim in user && user[claim as keyof OIDCUser] !== undefined) {
|
|||
|
(filtered as any)[claim] = user[claim as keyof OIDCUser];
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
return filtered;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 撤销令牌
|
|||
|
*/
|
|||
|
async revokeToken(token: string, tokenTypeHint?: string): Promise<{ success: boolean; error?: OIDCError }> {
|
|||
|
try {
|
|||
|
// 尝试作为访问令牌撤销
|
|||
|
const accessToken = await this.tokenManager.getAccessToken(token);
|
|||
|
if (accessToken) {
|
|||
|
await this.tokenManager.deleteAccessToken(token);
|
|||
|
return { success: true };
|
|||
|
}
|
|||
|
|
|||
|
// 尝试作为刷新令牌撤销
|
|||
|
const refreshToken = await this.tokenManager.getRefreshToken(token);
|
|||
|
if (refreshToken) {
|
|||
|
await this.tokenManager.deleteRefreshToken(token);
|
|||
|
// 同时撤销相关的访问令牌
|
|||
|
await this.tokenManager.deleteAccessTokensByUserAndClient(refreshToken.user_id, refreshToken.client_id);
|
|||
|
return { success: true };
|
|||
|
}
|
|||
|
|
|||
|
// 令牌不存在,根据RFC 7009,这应该返回成功
|
|||
|
return { success: true };
|
|||
|
} catch (error) {
|
|||
|
return {
|
|||
|
success: false,
|
|||
|
error: {
|
|||
|
error: 'server_error',
|
|||
|
error_description: 'Internal server error',
|
|||
|
},
|
|||
|
};
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 内省令牌
|
|||
|
*/
|
|||
|
async introspectToken(token: string): Promise<{
|
|||
|
active: boolean;
|
|||
|
scope?: string;
|
|||
|
client_id?: string;
|
|||
|
username?: string;
|
|||
|
token_type?: string;
|
|||
|
exp?: number;
|
|||
|
iat?: number;
|
|||
|
sub?: string;
|
|||
|
aud?: string;
|
|||
|
iss?: string;
|
|||
|
}> {
|
|||
|
try {
|
|||
|
// 尝试作为访问令牌内省
|
|||
|
const accessToken = await this.tokenManager.getAccessToken(token);
|
|||
|
if (accessToken) {
|
|||
|
const isExpired = accessToken.expires_at < new Date();
|
|||
|
if (isExpired) {
|
|||
|
await this.tokenManager.deleteAccessToken(token);
|
|||
|
return { active: false };
|
|||
|
}
|
|||
|
|
|||
|
const user = await this.findUser(accessToken.user_id);
|
|||
|
|
|||
|
return {
|
|||
|
active: true,
|
|||
|
scope: accessToken.scope,
|
|||
|
client_id: accessToken.client_id,
|
|||
|
username: user?.username,
|
|||
|
token_type: 'Bearer',
|
|||
|
exp: Math.floor(accessToken.expires_at.getTime() / 1000),
|
|||
|
iat: Math.floor(accessToken.created_at.getTime() / 1000),
|
|||
|
sub: accessToken.user_id,
|
|||
|
aud: accessToken.client_id,
|
|||
|
iss: this.config.issuer,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
// 尝试作为刷新令牌内省
|
|||
|
const refreshToken = await this.tokenManager.getRefreshToken(token);
|
|||
|
if (refreshToken) {
|
|||
|
const isExpired = refreshToken.expires_at < new Date();
|
|||
|
if (isExpired) {
|
|||
|
await this.tokenManager.deleteRefreshToken(token);
|
|||
|
return { active: false };
|
|||
|
}
|
|||
|
|
|||
|
const user = await this.findUser(refreshToken.user_id);
|
|||
|
|
|||
|
return {
|
|||
|
active: true,
|
|||
|
scope: refreshToken.scope,
|
|||
|
client_id: refreshToken.client_id,
|
|||
|
username: user?.username,
|
|||
|
token_type: 'refresh_token',
|
|||
|
exp: Math.floor(refreshToken.expires_at.getTime() / 1000),
|
|||
|
iat: Math.floor(refreshToken.created_at.getTime() / 1000),
|
|||
|
sub: refreshToken.user_id,
|
|||
|
aud: refreshToken.client_id,
|
|||
|
iss: this.config.issuer,
|
|||
|
};
|
|||
|
}
|
|||
|
|
|||
|
return { active: false };
|
|||
|
} catch (error) {
|
|||
|
return { active: false };
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* 获取JWKS
|
|||
|
*/
|
|||
|
async getJWKS(): Promise<{ keys: any[] }> {
|
|||
|
return await this.jwtUtils.generateJWKS();
|
|||
|
}
|
|||
|
}
|