2025-05-28 08:23:14 +08:00
|
|
|
|
import { nanoid } from 'nanoid';
|
2025-05-28 12:21:03 +08:00
|
|
|
|
import type { Context } from 'hono';
|
2025-05-29 12:23:29 +08:00
|
|
|
|
import { z } from 'zod';
|
|
|
|
|
import {
|
|
|
|
|
authorizationRequestSchema,
|
|
|
|
|
tokenRequestSchema,
|
|
|
|
|
authorizationQuerySchema,
|
|
|
|
|
tokenFormDataSchema,
|
|
|
|
|
bearerTokenSchema,
|
|
|
|
|
basicAuthSchema,
|
|
|
|
|
revokeTokenRequestSchema,
|
|
|
|
|
introspectTokenRequestSchema,
|
|
|
|
|
} from './schemas';
|
2025-05-28 08:23:14 +08:00
|
|
|
|
import type {
|
|
|
|
|
OIDCProviderConfig,
|
|
|
|
|
OIDCClient,
|
|
|
|
|
OIDCUser,
|
|
|
|
|
AuthorizationCode,
|
|
|
|
|
AuthorizationRequest,
|
|
|
|
|
TokenRequest,
|
|
|
|
|
TokenResponse,
|
|
|
|
|
OIDCError,
|
|
|
|
|
DiscoveryDocument,
|
|
|
|
|
} from './types';
|
|
|
|
|
import type { StorageAdapter } from './storage/adapter';
|
|
|
|
|
import { TokenManager } from './auth/token-manager';
|
|
|
|
|
import { JWTUtils } from './utils/jwt';
|
2025-05-29 12:23:29 +08:00
|
|
|
|
import type { KeyPair } from './utils/jwt';
|
2025-05-28 08:23:14 +08:00
|
|
|
|
import { PKCEUtils } from './utils/pkce';
|
|
|
|
|
import { ValidationUtils } from './utils/validation';
|
2025-05-28 12:21:03 +08:00
|
|
|
|
import { PasswordAuth } from './auth';
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
|
|
/**
|
2025-05-29 12:23:29 +08:00
|
|
|
|
* OIDC Provider - 简化版本,符合规范且代码简洁
|
2025-05-28 08:23:14 +08:00
|
|
|
|
*/
|
|
|
|
|
export class OIDCProvider {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private readonly config: OIDCProviderConfig;
|
|
|
|
|
private readonly tokenTTL: Required<NonNullable<OIDCProviderConfig['tokenTTL']>>;
|
2025-05-28 12:21:03 +08:00
|
|
|
|
private readonly storage: StorageAdapter;
|
|
|
|
|
private readonly tokenManager: TokenManager;
|
|
|
|
|
private readonly jwtUtils: JWTUtils;
|
|
|
|
|
private readonly findUser: (userId: string) => Promise<OIDCUser | null>;
|
|
|
|
|
private readonly findClient: (clientId: string) => Promise<OIDCClient | null>;
|
|
|
|
|
private passwordAuth: PasswordAuth;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
|
|
constructor(config: OIDCProviderConfig) {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
this.validateConfig(config);
|
|
|
|
|
this.config = config;
|
|
|
|
|
this.tokenTTL = {
|
|
|
|
|
accessToken: config.tokenTTL?.accessToken || 3600,
|
|
|
|
|
refreshToken: config.tokenTTL?.refreshToken || 2592000,
|
|
|
|
|
authorizationCode: config.tokenTTL?.authorizationCode || 600,
|
|
|
|
|
idToken: config.tokenTTL?.idToken || 3600,
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
|
this.storage = config.storage;
|
|
|
|
|
this.tokenManager = new TokenManager(config.storage);
|
|
|
|
|
this.findUser = config.findUser;
|
|
|
|
|
this.findClient = config.findClient;
|
2025-05-29 12:23:29 +08:00
|
|
|
|
|
|
|
|
|
// 创建JWT工具类,使用简化的构造函数
|
|
|
|
|
this.jwtUtils = new JWTUtils(config.signingKey);
|
|
|
|
|
|
|
|
|
|
this.passwordAuth = new PasswordAuth(config, config.authConfig.passwordValidator, config.authConfig);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private validateConfig(config: OIDCProviderConfig): void {
|
|
|
|
|
const required = ['issuer', 'storage', 'findUser', 'findClient', 'authConfig'];
|
|
|
|
|
for (const field of required) {
|
|
|
|
|
if (!config[field as keyof OIDCProviderConfig]) {
|
|
|
|
|
throw new Error(`配置项 ${field} 是必需的`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果提供了signingKey且是字符串,说明使用HMAC
|
|
|
|
|
// 如果没有提供signingKey,将自动生成RSA密钥
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get defaultScopes(): string[] {
|
|
|
|
|
return this.config.scopes || ['openid', 'profile', 'email'];
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private get supportedResponseTypes(): string[] {
|
|
|
|
|
return this.config.responseTypes || ['code'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get supportedGrantTypes(): string[] {
|
|
|
|
|
return this.config.grantTypes || ['authorization_code', 'refresh_token'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get supportedClaims(): string[] {
|
|
|
|
|
return this.config.claims || ['sub', 'name', 'email', 'email_verified'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get enablePKCE(): boolean {
|
|
|
|
|
return this.config.enablePKCE ?? true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get requirePKCE(): boolean {
|
|
|
|
|
return this.config.requirePKCE ?? false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private get rotateRefreshTokens(): boolean {
|
|
|
|
|
return this.config.rotateRefreshTokens ?? true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async findToken(token: string): Promise<{ tokenData: any; type: 'access' | 'refresh' } | null> {
|
2025-05-28 12:21:03 +08:00
|
|
|
|
const [accessToken, refreshToken] = await Promise.all([
|
|
|
|
|
this.tokenManager.getAccessToken(token),
|
|
|
|
|
this.tokenManager.getRefreshToken(token)
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const tokenData = accessToken || refreshToken;
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (tokenData && tokenData.expires_at < new Date()) {
|
|
|
|
|
// 清理过期token
|
2025-05-28 12:21:03 +08:00
|
|
|
|
await Promise.all([
|
2025-05-29 12:23:29 +08:00
|
|
|
|
accessToken && this.tokenManager.deleteAccessToken(token),
|
|
|
|
|
refreshToken && this.tokenManager.deleteRefreshToken(token),
|
|
|
|
|
].filter(Boolean));
|
|
|
|
|
return null;
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (!tokenData) return null;
|
|
|
|
|
return { tokenData, type: accessToken ? 'access' : 'refresh' };
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
parseAuthRequest(query: Record<string, string | string[]>): AuthorizationRequest {
|
|
|
|
|
const normalized = authorizationQuerySchema.parse(query);
|
|
|
|
|
return authorizationRequestSchema.parse({
|
|
|
|
|
response_type: normalized.response_type || '',
|
|
|
|
|
client_id: normalized.client_id || '',
|
|
|
|
|
redirect_uri: normalized.redirect_uri || '',
|
|
|
|
|
scope: normalized.scope || 'openid',
|
|
|
|
|
state: normalized.state,
|
|
|
|
|
nonce: normalized.nonce,
|
|
|
|
|
code_challenge: normalized.code_challenge,
|
|
|
|
|
code_challenge_method: normalized.code_challenge_method as 'plain' | 'S256' | undefined,
|
|
|
|
|
prompt: normalized.prompt,
|
|
|
|
|
max_age: normalized.max_age ? parseInt(normalized.max_age, 10) : undefined,
|
|
|
|
|
id_token_hint: normalized.id_token_hint,
|
|
|
|
|
login_hint: normalized.login_hint,
|
|
|
|
|
acr_values: normalized.acr_values,
|
|
|
|
|
});
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
parseTokenRequest(body: FormData, authHeader?: string): TokenRequest {
|
|
|
|
|
const formData = tokenFormDataSchema.parse(body);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 处理客户端认证
|
2025-05-28 12:21:03 +08:00
|
|
|
|
if (authHeader?.startsWith('Basic ')) {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const basicAuth = basicAuthSchema.parse(authHeader);
|
|
|
|
|
formData.client_id = basicAuth.username || formData.client_id || '';
|
|
|
|
|
formData.client_secret = basicAuth.password || formData.client_secret || '';
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return tokenRequestSchema.parse({
|
|
|
|
|
grant_type: formData.grant_type || '',
|
|
|
|
|
client_id: formData.client_id || '',
|
|
|
|
|
code: formData.code,
|
|
|
|
|
redirect_uri: formData.redirect_uri,
|
|
|
|
|
client_secret: formData.client_secret,
|
|
|
|
|
refresh_token: formData.refresh_token,
|
|
|
|
|
code_verifier: formData.code_verifier,
|
|
|
|
|
scope: formData.scope,
|
|
|
|
|
});
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async validateClient(clientId: string, clientSecret?: string): Promise<OIDCClient> {
|
|
|
|
|
const client = await this.findClient(clientId);
|
|
|
|
|
if (!client) throw new Error('客户端不存在');
|
|
|
|
|
|
|
|
|
|
// 机密客户端需要密钥
|
|
|
|
|
if (client.client_type === 'confidential' && clientSecret !== client.client_secret) {
|
|
|
|
|
throw new Error('客户端认证失败');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 公开客户端不应该发送密钥
|
|
|
|
|
if (client.client_type === 'public' && clientSecret) {
|
|
|
|
|
throw new Error('公开客户端不应发送密钥');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return client;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
validatePKCE(request: AuthorizationRequest, client: OIDCClient): void {
|
|
|
|
|
const isPublic = client.client_type === 'public';
|
|
|
|
|
const hasChallenge = !!request.code_challenge;
|
|
|
|
|
|
|
|
|
|
if (this.requirePKCE && isPublic && !hasChallenge) {
|
|
|
|
|
throw new Error('公开客户端必须使用PKCE');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasChallenge && request.code_challenge) {
|
|
|
|
|
const codeChallenge = request.code_challenge;
|
|
|
|
|
const method = request.code_challenge_method || 'plain';
|
|
|
|
|
|
|
|
|
|
if (!PKCEUtils.isValidCodeChallenge(codeChallenge, method)) {
|
|
|
|
|
throw new Error('无效的PKCE参数');
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getDiscoveryDocument(): DiscoveryDocument {
|
|
|
|
|
const baseUrl = this.config.issuer;
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 根据signingKey类型确定签名算法
|
|
|
|
|
const signingAlgorithm = typeof this.config.signingKey === 'string' ? 'HS256' : 'RS256';
|
|
|
|
|
|
2025-05-28 08:23:14 +08:00
|
|
|
|
return {
|
|
|
|
|
issuer: this.config.issuer,
|
|
|
|
|
authorization_endpoint: `${baseUrl}/auth`,
|
|
|
|
|
token_endpoint: `${baseUrl}/token`,
|
|
|
|
|
userinfo_endpoint: `${baseUrl}/userinfo`,
|
|
|
|
|
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
|
|
|
|
revocation_endpoint: `${baseUrl}/revoke`,
|
|
|
|
|
introspection_endpoint: `${baseUrl}/introspect`,
|
|
|
|
|
end_session_endpoint: `${baseUrl}/logout`,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
response_types_supported: this.supportedResponseTypes,
|
|
|
|
|
grant_types_supported: this.supportedGrantTypes,
|
|
|
|
|
scopes_supported: this.defaultScopes,
|
|
|
|
|
claims_supported: this.supportedClaims,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
2025-05-29 12:23:29 +08:00
|
|
|
|
id_token_signing_alg_values_supported: [signingAlgorithm],
|
2025-05-28 08:23:14 +08:00
|
|
|
|
subject_types_supported: ['public'],
|
2025-05-29 12:23:29 +08:00
|
|
|
|
code_challenge_methods_supported: this.enablePKCE ? ['plain', 'S256'] : undefined,
|
|
|
|
|
response_modes_supported: ['query', 'fragment'],
|
2025-05-28 08:23:14 +08:00
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async handleAuthorizationRequest(request: AuthorizationRequest, userId?: string) {
|
|
|
|
|
// 基本验证
|
|
|
|
|
if (!request.client_id || !request.response_type || !request.redirect_uri) {
|
|
|
|
|
throw new Error('缺少必需参数');
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const client = await this.findClient(request.client_id);
|
|
|
|
|
if (!client || !client.redirect_uris.includes(request.redirect_uri)) {
|
|
|
|
|
throw new Error('无效的客户端或重定向URI');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 验证请求
|
|
|
|
|
const validation = ValidationUtils.validateAuthorizationRequest(
|
|
|
|
|
request, client, this.defaultScopes, this.supportedResponseTypes
|
|
|
|
|
);
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
throw new Error(validation.errors.join(', '));
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (!userId) {
|
|
|
|
|
throw new Error('需要用户认证');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.validatePKCE(request, client);
|
|
|
|
|
|
|
|
|
|
// 生成授权码
|
|
|
|
|
if (request.response_type === 'code') {
|
|
|
|
|
const code = nanoid(32);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
const authCode: AuthorizationCode = {
|
|
|
|
|
code,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
client_id: request.client_id,
|
|
|
|
|
user_id: userId,
|
|
|
|
|
redirect_uri: request.redirect_uri,
|
|
|
|
|
scope: request.scope,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
code_challenge: request.code_challenge,
|
|
|
|
|
code_challenge_method: request.code_challenge_method,
|
|
|
|
|
nonce: request.nonce,
|
|
|
|
|
state: request.state,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
expires_at: new Date(Date.now() + this.tokenTTL.authorizationCode * 1000),
|
|
|
|
|
created_at: new Date(),
|
2025-05-28 08:23:14 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await this.tokenManager.storeAuthorizationCode(authCode);
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return { code, state: request.state };
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-29 12:23:29 +08:00
|
|
|
|
|
|
|
|
|
throw new Error(`不支持的响应类型: ${request.response_type}`);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async handleTokenRequest(request: TokenRequest): Promise<TokenResponse> {
|
|
|
|
|
if (!request.grant_type || !request.client_id) {
|
|
|
|
|
throw new Error('缺少必需参数');
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const client = await this.validateClient(request.client_id, request.client_secret);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
switch (request.grant_type) {
|
|
|
|
|
case 'authorization_code':
|
|
|
|
|
return this.handleAuthorizationCodeGrant(request, client);
|
|
|
|
|
case 'refresh_token':
|
|
|
|
|
return this.handleRefreshTokenGrant(request, client);
|
|
|
|
|
default:
|
|
|
|
|
throw new Error(`不支持的授权类型: ${request.grant_type}`);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
2025-05-29 12:23:29 +08:00
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private async handleAuthorizationCodeGrant(request: TokenRequest, client: OIDCClient): Promise<TokenResponse> {
|
|
|
|
|
if (!request.code || !request.redirect_uri) {
|
|
|
|
|
throw new Error('缺少授权码或重定向URI');
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const authCode = await this.tokenManager.getAuthorizationCode(request.code);
|
|
|
|
|
if (!authCode || authCode.expires_at < new Date()) {
|
|
|
|
|
if (authCode) await this.tokenManager.deleteAuthorizationCode(request.code);
|
|
|
|
|
throw new Error('授权码无效或已过期');
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 验证授权码
|
|
|
|
|
if (authCode.client_id !== request.client_id || authCode.redirect_uri !== request.redirect_uri) {
|
|
|
|
|
throw new Error('授权码不匹配');
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 验证PKCE
|
|
|
|
|
if (authCode.code_challenge) {
|
|
|
|
|
if (!request.code_verifier || !PKCEUtils.verifyCodeChallenge(
|
|
|
|
|
request.code_verifier, authCode.code_challenge, authCode.code_challenge_method || 'plain'
|
|
|
|
|
)) {
|
|
|
|
|
throw new Error('PKCE验证失败');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const user = await this.findUser(authCode.user_id);
|
|
|
|
|
if (!user) throw new Error('用户不存在');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 删除已使用的授权码
|
|
|
|
|
await this.tokenManager.deleteAuthorizationCode(request.code);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return this.generateTokens(user, client, authCode.scope, authCode.nonce);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private async handleRefreshTokenGrant(request: TokenRequest, client: OIDCClient): Promise<TokenResponse> {
|
|
|
|
|
if (!request.refresh_token) {
|
|
|
|
|
throw new Error('缺少刷新token');
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const refreshToken = await this.tokenManager.getRefreshToken(request.refresh_token);
|
|
|
|
|
if (!refreshToken || refreshToken.expires_at < new Date()) {
|
|
|
|
|
if (refreshToken) await this.tokenManager.deleteRefreshToken(request.refresh_token);
|
|
|
|
|
throw new Error('刷新token无效或已过期');
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (refreshToken.client_id !== request.client_id) {
|
|
|
|
|
throw new Error('刷新token客户端不匹配');
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const user = await this.findUser(refreshToken.user_id);
|
|
|
|
|
if (!user) throw new Error('用户不存在');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 验证scope
|
|
|
|
|
let scope = refreshToken.scope;
|
|
|
|
|
if (request.scope) {
|
|
|
|
|
const requestedScopes = request.scope.split(' ');
|
|
|
|
|
const originalScopes = refreshToken.scope.split(' ');
|
|
|
|
|
if (!requestedScopes.every(s => originalScopes.includes(s))) {
|
|
|
|
|
throw new Error('请求的scope超出原始scope');
|
|
|
|
|
}
|
|
|
|
|
scope = request.scope;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const tokens = await this.generateTokens(user, client, scope);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 旋转刷新token
|
|
|
|
|
if (this.rotateRefreshTokens) {
|
|
|
|
|
await this.tokenManager.deleteRefreshToken(request.refresh_token);
|
|
|
|
|
} else {
|
|
|
|
|
tokens.refresh_token = request.refresh_token;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return tokens;
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private async generateTokens(
|
|
|
|
|
user: OIDCUser,
|
|
|
|
|
client: OIDCClient,
|
|
|
|
|
scope: string,
|
|
|
|
|
nonce?: string
|
|
|
|
|
): Promise<TokenResponse> {
|
2025-05-28 08:23:14 +08:00
|
|
|
|
const now = new Date();
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const [accessToken, refreshTokenValue] = await Promise.all([
|
|
|
|
|
this.jwtUtils.generateAccessToken({
|
|
|
|
|
issuer: this.config.issuer,
|
|
|
|
|
subject: user.sub,
|
|
|
|
|
audience: client.client_id,
|
|
|
|
|
clientId: client.client_id,
|
|
|
|
|
scope,
|
|
|
|
|
expiresIn: this.tokenTTL.accessToken,
|
|
|
|
|
}),
|
|
|
|
|
nanoid(64)
|
|
|
|
|
]);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 生成ID Token(如果scope包含openid)
|
|
|
|
|
let idToken: string | undefined;
|
|
|
|
|
if (scope.includes('openid')) {
|
|
|
|
|
idToken = await this.jwtUtils.generateIDToken({
|
2025-05-28 08:23:14 +08:00
|
|
|
|
issuer: this.config.issuer,
|
|
|
|
|
subject: user.sub,
|
2025-05-28 12:21:03 +08:00
|
|
|
|
audience: client.client_id,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
user,
|
|
|
|
|
authTime: Math.floor(now.getTime() / 1000),
|
2025-05-29 12:23:29 +08:00
|
|
|
|
nonce,
|
|
|
|
|
expiresIn: this.tokenTTL.idToken,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
});
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 存储tokens
|
|
|
|
|
const storeOperations = [
|
2025-05-28 12:21:03 +08:00
|
|
|
|
this.tokenManager.storeAccessToken({
|
2025-05-29 12:23:29 +08:00
|
|
|
|
token: accessToken,
|
2025-05-28 12:21:03 +08:00
|
|
|
|
client_id: client.client_id,
|
|
|
|
|
user_id: user.sub,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
scope,
|
|
|
|
|
expires_at: new Date(now.getTime() + this.tokenTTL.accessToken * 1000),
|
2025-05-28 12:21:03 +08:00
|
|
|
|
created_at: now,
|
|
|
|
|
}),
|
|
|
|
|
this.tokenManager.storeRefreshToken({
|
|
|
|
|
token: refreshTokenValue,
|
|
|
|
|
client_id: client.client_id,
|
|
|
|
|
user_id: user.sub,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
scope,
|
|
|
|
|
expires_at: new Date(now.getTime() + this.tokenTTL.refreshToken * 1000),
|
2025-05-28 12:21:03 +08:00
|
|
|
|
created_at: now,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
})
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (idToken) {
|
|
|
|
|
storeOperations.push(this.tokenManager.storeIDToken({
|
|
|
|
|
token: idToken,
|
2025-05-28 12:21:03 +08:00
|
|
|
|
client_id: client.client_id,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
user_id: user.sub,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
nonce,
|
|
|
|
|
expires_at: new Date(now.getTime() + this.tokenTTL.idToken * 1000),
|
2025-05-28 08:23:14 +08:00
|
|
|
|
created_at: now,
|
2025-05-29 12:23:29 +08:00
|
|
|
|
}));
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
await Promise.all(storeOperations);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
|
|
const response: TokenResponse = {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
access_token: accessToken,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
token_type: 'Bearer',
|
2025-05-29 12:23:29 +08:00
|
|
|
|
expires_in: this.tokenTTL.accessToken,
|
|
|
|
|
refresh_token: refreshTokenValue,
|
2025-05-28 08:23:14 +08:00
|
|
|
|
scope,
|
|
|
|
|
};
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (idToken) response.id_token = idToken;
|
|
|
|
|
return response;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async getUserInfo(accessToken: string): Promise<Partial<OIDCUser>> {
|
|
|
|
|
const tokenData = await this.tokenManager.getAccessToken(accessToken);
|
|
|
|
|
if (!tokenData || tokenData.expires_at < new Date()) {
|
|
|
|
|
if (tokenData) await this.tokenManager.deleteAccessToken(accessToken);
|
|
|
|
|
throw new Error('Token无效或已过期');
|
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// 验证JWT
|
|
|
|
|
await this.jwtUtils.verifyToken(accessToken);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const user = await this.findUser(tokenData.user_id);
|
|
|
|
|
if (!user) throw new Error('用户不存在');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const claims = this.getClaimsForScope(tokenData.scope);
|
|
|
|
|
return this.filterUserClaims(user, claims);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private getClaimsForScope(scope: string): string[] {
|
2025-05-28 08:23:14 +08:00
|
|
|
|
const scopes = scope.split(' ');
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const claimsMap = {
|
|
|
|
|
profile: ['name', 'given_name', 'family_name', 'picture'],
|
2025-05-28 12:21:03 +08:00
|
|
|
|
email: ['email', 'email_verified'],
|
|
|
|
|
phone: ['phone_number', 'phone_number_verified'],
|
|
|
|
|
address: ['address'],
|
|
|
|
|
};
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const claims = new Set(['sub']);
|
|
|
|
|
scopes.forEach(s => {
|
|
|
|
|
if (s in claimsMap) {
|
|
|
|
|
claimsMap[s as keyof typeof claimsMap].forEach(c => claims.add(c));
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
});
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return Array.from(claims);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
private filterUserClaims(user: OIDCUser, claims: string[]): Partial<OIDCUser> {
|
2025-05-28 12:21:03 +08:00
|
|
|
|
return Object.fromEntries(
|
2025-05-29 12:23:29 +08:00
|
|
|
|
claims
|
2025-05-28 12:21:03 +08:00
|
|
|
|
.filter(claim => claim in user && user[claim as keyof OIDCUser] !== undefined)
|
|
|
|
|
.map(claim => [claim, user[claim as keyof OIDCUser]])
|
2025-05-29 12:23:29 +08:00
|
|
|
|
);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async revokeToken(token: string, clientId?: string): Promise<void> {
|
|
|
|
|
const result = await this.findToken(token);
|
|
|
|
|
if (!result) return; // Token不存在或已过期
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const { tokenData, type } = result;
|
|
|
|
|
|
|
|
|
|
if (clientId && tokenData.client_id !== clientId) {
|
|
|
|
|
throw new Error('Token不属于此客户端');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (type === 'access') {
|
|
|
|
|
await this.tokenManager.deleteAccessToken(token);
|
|
|
|
|
} else {
|
|
|
|
|
// 撤销刷新token时,同时撤销相关的访问token
|
|
|
|
|
await Promise.all([
|
|
|
|
|
this.tokenManager.deleteRefreshToken(token),
|
|
|
|
|
this.tokenManager.deleteAccessTokensByUserAndClient(tokenData.user_id, tokenData.client_id)
|
|
|
|
|
]);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async introspectToken(token: string, clientId?: string) {
|
|
|
|
|
const result = await this.findToken(token);
|
|
|
|
|
if (!result) return { active: false };
|
|
|
|
|
|
|
|
|
|
const { tokenData, type } = result;
|
|
|
|
|
|
|
|
|
|
if (clientId && tokenData.client_id !== clientId) {
|
2025-05-28 08:23:14 +08:00
|
|
|
|
return { active: false };
|
|
|
|
|
}
|
2025-05-29 12:23:29 +08:00
|
|
|
|
|
|
|
|
|
const user = await this.findUser(tokenData.user_id);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
active: true,
|
|
|
|
|
scope: tokenData.scope,
|
|
|
|
|
client_id: tokenData.client_id,
|
|
|
|
|
username: user?.username,
|
|
|
|
|
token_type: type === 'access' ? 'Bearer' : 'refresh_token',
|
|
|
|
|
exp: Math.floor(tokenData.expires_at.getTime() / 1000),
|
|
|
|
|
iat: Math.floor(tokenData.created_at.getTime() / 1000),
|
|
|
|
|
sub: tokenData.user_id,
|
|
|
|
|
aud: tokenData.client_id,
|
|
|
|
|
iss: this.config.issuer,
|
|
|
|
|
};
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async getJWKS() {
|
|
|
|
|
return this.jwtUtils.generateJWKS();
|
2025-05-28 08:23:14 +08:00
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
// HTTP处理器
|
|
|
|
|
private createErrorResponse(error: unknown): { error: string; error_description: string } {
|
|
|
|
|
const message = error instanceof Error ? error.message : '服务器内部错误';
|
|
|
|
|
console.error('OIDC Provider Error:', error);
|
|
|
|
|
return {
|
|
|
|
|
error: 'invalid_request',
|
|
|
|
|
error_description: message
|
|
|
|
|
};
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
async handleLogin(c: Context): Promise<Response> {
|
2025-05-28 12:21:03 +08:00
|
|
|
|
try {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const formData = await c.req.formData();
|
|
|
|
|
const authRequest = Object.fromEntries(
|
|
|
|
|
['response_type', 'client_id', 'redirect_uri', 'scope', 'state', 'nonce', 'code_challenge', 'code_challenge_method']
|
|
|
|
|
.map(field => [field, formData.get(field)?.toString() || ''])
|
|
|
|
|
);
|
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
|
const authResult = await this.passwordAuth.authenticate(c);
|
|
|
|
|
return authResult.success
|
|
|
|
|
? await this.passwordAuth.handleAuthenticationSuccess(c, authResult)
|
2025-05-29 12:23:29 +08:00
|
|
|
|
: await this.passwordAuth.handleAuthenticationFailure(c, authResult, authRequest as any);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
} catch (error) {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return c.json(this.createErrorResponse(error), 500);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleLogout(c: Context): Promise<Response> {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return this.passwordAuth.logout(c);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleAuthorization(c: Context): Promise<Response> {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
try {
|
|
|
|
|
const authRequest = this.parseAuthRequest(c.req.query());
|
|
|
|
|
const userId = await this.passwordAuth.getCurrentUser(c);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (!userId) {
|
|
|
|
|
return this.passwordAuth.handleAuthenticationRequired(c, authRequest);
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const result = await this.handleAuthorizationRequest(authRequest, userId);
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (result.code) params.set('code', result.code);
|
|
|
|
|
if (result.state) params.set('state', result.state);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
return c.redirect(`${authRequest.redirect_uri}?${params.toString()}`);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return c.json(this.createErrorResponse(error), 400);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleToken(c: Context): Promise<Response> {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
try {
|
|
|
|
|
const body = await c.req.formData();
|
|
|
|
|
const tokenRequest = this.parseTokenRequest(body, c.req.header('Authorization'));
|
|
|
|
|
const response = await this.handleTokenRequest(tokenRequest);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
c.header('Cache-Control', 'no-store');
|
|
|
|
|
c.header('Pragma', 'no-cache');
|
|
|
|
|
return c.json(response);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return c.json(this.createErrorResponse(error), 400);
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleUserInfo(c: Context): Promise<Response> {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
try {
|
|
|
|
|
const authHeader = c.req.header('Authorization');
|
|
|
|
|
if (!authHeader?.startsWith('Bearer ')) {
|
|
|
|
|
c.header('WWW-Authenticate', 'Bearer realm="userinfo"');
|
|
|
|
|
return c.json({ error: 'invalid_token', error_description: 'Bearer token required' }, 401);
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const token = bearerTokenSchema.parse(authHeader);
|
|
|
|
|
const user = await this.getUserInfo(token);
|
|
|
|
|
return c.json(user);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
c.header('WWW-Authenticate', 'Bearer realm="userinfo"');
|
|
|
|
|
return c.json({ error: 'invalid_token', error_description: 'Invalid token' }, 401);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleRevoke(c: Context): Promise<Response> {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
try {
|
|
|
|
|
const body = await c.req.formData();
|
|
|
|
|
const { token, client_id } = revokeTokenRequestSchema.parse({
|
|
|
|
|
token: body.get('token')?.toString(),
|
|
|
|
|
client_id: body.get('client_id')?.toString(),
|
|
|
|
|
});
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (!token) {
|
|
|
|
|
return c.json({ error: 'invalid_request', error_description: 'Missing token' }, 400);
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
await this.revokeToken(token, client_id);
|
|
|
|
|
return c.body(null, 200);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return c.json(this.createErrorResponse(error), 400);
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async handleIntrospect(c: Context): Promise<Response> {
|
2025-05-29 12:23:29 +08:00
|
|
|
|
try {
|
|
|
|
|
const body = await c.req.formData();
|
|
|
|
|
const { token, client_id } = introspectTokenRequestSchema.parse({
|
|
|
|
|
token: body.get('token')?.toString(),
|
|
|
|
|
client_id: body.get('client_id')?.toString(),
|
|
|
|
|
});
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
if (!token) {
|
|
|
|
|
return c.json({ error: 'invalid_request', error_description: 'Missing token' }, 400);
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
2025-05-29 12:23:29 +08:00
|
|
|
|
const result = await this.introspectToken(token, client_id);
|
|
|
|
|
return c.json(result);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return c.json(this.createErrorResponse(error), 400);
|
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
}
|
|
|
|
|
}
|