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-28 08:23:14 +08:00
|
|
|
import type {
|
|
|
|
OIDCProviderConfig,
|
|
|
|
OIDCClient,
|
|
|
|
OIDCUser,
|
|
|
|
AuthorizationCode,
|
|
|
|
AccessToken,
|
|
|
|
RefreshToken,
|
|
|
|
IDToken,
|
|
|
|
AuthorizationRequest,
|
|
|
|
TokenRequest,
|
|
|
|
TokenResponse,
|
|
|
|
OIDCError,
|
|
|
|
DiscoveryDocument,
|
2025-05-28 12:21:03 +08:00
|
|
|
PasswordValidator,
|
2025-05-28 08:23:14 +08:00
|
|
|
} 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';
|
2025-05-28 12:21:03 +08:00
|
|
|
import { PasswordAuth } from './auth';
|
|
|
|
import { OIDCErrorFactory } from './errors';
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
/**
|
|
|
|
* OIDC Provider核心类
|
|
|
|
*/
|
|
|
|
export class OIDCProvider {
|
2025-05-28 12:21:03 +08:00
|
|
|
private readonly config: OIDCProviderConfig & {
|
2025-05-28 08:23:14 +08:00
|
|
|
tokenTTL: {
|
|
|
|
accessToken: number;
|
|
|
|
refreshToken: number;
|
|
|
|
authorizationCode: number;
|
|
|
|
idToken: number;
|
|
|
|
};
|
|
|
|
responseTypes: string[];
|
|
|
|
grantTypes: string[];
|
|
|
|
scopes: string[];
|
|
|
|
claims: string[];
|
|
|
|
enablePKCE: boolean;
|
|
|
|
requirePKCE: boolean;
|
|
|
|
rotateRefreshTokens: boolean;
|
|
|
|
};
|
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-28 12:21:03 +08:00
|
|
|
this.config = this.normalizeConfig(config);
|
|
|
|
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);
|
|
|
|
this.passwordAuth = new PasswordAuth(this.config, config.authConfig.passwordValidator, config.authConfig);
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
private normalizeConfig(config: OIDCProviderConfig) {
|
|
|
|
return {
|
2025-05-28 08:23:14 +08:00
|
|
|
...config,
|
|
|
|
tokenTTL: {
|
2025-05-28 12:21:03 +08:00
|
|
|
accessToken: 3600,
|
|
|
|
refreshToken: 30 * 24 * 3600,
|
|
|
|
authorizationCode: 600,
|
|
|
|
idToken: 3600,
|
|
|
|
...(config.tokenTTL || {}),
|
2025-05-28 08:23:14 +08:00
|
|
|
},
|
|
|
|
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,
|
|
|
|
};
|
2025-05-28 12:21:03 +08:00
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
// 统一的令牌查找和验证
|
|
|
|
private async findTokenData(token: string): Promise<{
|
|
|
|
accessToken?: AccessToken | null;
|
|
|
|
refreshToken?: RefreshToken | null;
|
|
|
|
tokenData?: AccessToken | RefreshToken | null;
|
|
|
|
isExpired: boolean;
|
|
|
|
}> {
|
|
|
|
const [accessToken, refreshToken] = await Promise.all([
|
|
|
|
this.tokenManager.getAccessToken(token),
|
|
|
|
this.tokenManager.getRefreshToken(token)
|
|
|
|
]);
|
|
|
|
|
|
|
|
const tokenData = accessToken || refreshToken;
|
|
|
|
const isExpired = tokenData ? tokenData.expires_at < new Date() : false;
|
|
|
|
|
|
|
|
if (isExpired && tokenData) {
|
|
|
|
await Promise.all([
|
|
|
|
accessToken ? this.tokenManager.deleteAccessToken(token) : Promise.resolve(),
|
|
|
|
refreshToken ? this.tokenManager.deleteRefreshToken(token) : Promise.resolve(),
|
|
|
|
]);
|
|
|
|
}
|
|
|
|
|
|
|
|
return { accessToken, refreshToken, tokenData, isExpired };
|
|
|
|
}
|
|
|
|
|
|
|
|
private parseAuthorizationRequest(query: Record<string, string>): AuthorizationRequest {
|
|
|
|
return {
|
|
|
|
response_type: query.response_type || '',
|
|
|
|
client_id: query.client_id || '',
|
|
|
|
redirect_uri: query.redirect_uri || '',
|
|
|
|
scope: query.scope || '',
|
|
|
|
state: query.state,
|
|
|
|
nonce: query.nonce,
|
|
|
|
code_challenge: query.code_challenge,
|
|
|
|
code_challenge_method: query.code_challenge_method as 'plain' | 'S256' | undefined,
|
|
|
|
prompt: query.prompt,
|
|
|
|
max_age: query.max_age ? parseInt(query.max_age, 10) : undefined,
|
|
|
|
id_token_hint: query.id_token_hint,
|
|
|
|
login_hint: query.login_hint,
|
|
|
|
acr_values: query.acr_values,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
private parseTokenRequest(body: FormData, authHeader?: string): TokenRequest {
|
|
|
|
const tokenRequest: any = {
|
|
|
|
grant_type: body.get('grant_type')?.toString() || '',
|
|
|
|
client_id: body.get('client_id')?.toString() || ''
|
|
|
|
};
|
|
|
|
|
|
|
|
['code', 'redirect_uri', 'client_secret', 'refresh_token', 'code_verifier', 'scope']
|
|
|
|
.forEach(field => {
|
|
|
|
const value = body.get(field)?.toString();
|
|
|
|
if (value) tokenRequest[field] = value;
|
|
|
|
});
|
|
|
|
|
|
|
|
// 处理Basic认证
|
|
|
|
if (authHeader?.startsWith('Basic ')) {
|
|
|
|
try {
|
|
|
|
const [id, secret] = atob(authHeader.substring(6)).split(':');
|
|
|
|
if (id) tokenRequest.client_id = id;
|
|
|
|
if (secret) tokenRequest.client_secret = secret;
|
|
|
|
} catch {
|
|
|
|
// 忽略无效的Basic认证头
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return tokenRequest;
|
|
|
|
}
|
|
|
|
|
|
|
|
configurePasswordAuth(passwordValidator: PasswordValidator, authConfig: OIDCProviderConfig['authConfig']): void {
|
|
|
|
this.passwordAuth = new PasswordAuth(this.config, passwordValidator, authConfig);
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
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 {
|
2025-05-28 12:21:03 +08:00
|
|
|
const validationResult = await this.validateAuthorizationRequest(request, userId);
|
|
|
|
if (!validationResult.success) return validationResult;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const code = nanoid(32);
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
const authCode: AuthorizationCode = {
|
|
|
|
code,
|
2025-05-28 12:21:03 +08:00
|
|
|
client_id: request.client_id!,
|
|
|
|
user_id: userId!,
|
2025-05-28 08:23:14 +08:00
|
|
|
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,
|
2025-05-28 12:21:03 +08:00
|
|
|
expires_at: new Date(now.getTime() + this.config.tokenTTL.authorizationCode * 1000),
|
2025-05-28 08:23:14 +08:00
|
|
|
created_at: now,
|
|
|
|
};
|
|
|
|
|
|
|
|
await this.tokenManager.storeAuthorizationCode(authCode);
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: true, code, redirectUri: request.redirect_uri! };
|
|
|
|
} catch (error) {
|
|
|
|
return { ...OIDCErrorFactory.serverError(request.state), redirectUri: request.redirect_uri };
|
|
|
|
}
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
private async validateAuthorizationRequest(
|
|
|
|
request: Partial<AuthorizationRequest>,
|
|
|
|
userId?: string
|
|
|
|
): Promise<{ success: true; client: OIDCClient } | { success: false; error: OIDCError; redirectUri?: string }> {
|
|
|
|
if (!request.client_id) {
|
2025-05-28 08:23:14 +08:00
|
|
|
return {
|
2025-05-28 12:21:03 +08:00
|
|
|
success: false,
|
|
|
|
error: { error: 'invalid_request', error_description: 'Missing client_id parameter', state: request.state },
|
|
|
|
redirectUri: request.redirect_uri,
|
2025-05-28 08:23:14 +08:00
|
|
|
};
|
2025-05-28 12:21:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
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 },
|
|
|
|
redirectUri: request.redirect_uri,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
const validation = ValidationUtils.validateAuthorizationRequest(
|
|
|
|
request, client, this.config.scopes, this.config.responseTypes
|
|
|
|
);
|
|
|
|
if (!validation.valid) {
|
2025-05-28 08:23:14 +08:00
|
|
|
return {
|
|
|
|
success: false,
|
2025-05-28 12:21:03 +08:00
|
|
|
error: { error: 'invalid_request', error_description: validation.errors.join(', '), state: request.state },
|
2025-05-28 08:23:14 +08:00
|
|
|
redirectUri: request.redirect_uri,
|
|
|
|
};
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
|
|
if (!userId) {
|
|
|
|
return {
|
|
|
|
success: false,
|
|
|
|
error: { error: 'login_required', error_description: 'User authentication is required', state: request.state },
|
|
|
|
redirectUri: request.redirect_uri,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
const pkceError = this.validatePKCE(request, client);
|
|
|
|
if (pkceError) return { success: false, error: pkceError, redirectUri: request.redirect_uri };
|
|
|
|
|
|
|
|
return { success: true, client };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
private validatePKCE(request: Partial<AuthorizationRequest>, client: OIDCClient): OIDCError | null {
|
|
|
|
if (this.config.requirePKCE && client.client_type === 'public' && !request.code_challenge) {
|
|
|
|
return { error: 'invalid_request', error_description: 'PKCE is required for public clients', state: request.state };
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
if (this.config.enablePKCE && request.code_challenge) {
|
|
|
|
if (!request.code_challenge_method) {
|
|
|
|
return { error: 'invalid_request', error_description: 'Missing code_challenge_method', state: request.state };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!PKCEUtils.isSupportedMethod(request.code_challenge_method)) {
|
|
|
|
return { error: 'invalid_request', error_description: 'Unsupported code_challenge_method', state: request.state };
|
|
|
|
}
|
|
|
|
if (!PKCEUtils.isValidCodeChallenge(request.code_challenge, request.code_challenge_method)) {
|
|
|
|
return { error: 'invalid_request', error_description: 'Invalid code_challenge', state: request.state };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
}
|
|
|
|
return null;
|
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
async handleTokenRequest(
|
|
|
|
request: Partial<TokenRequest>
|
|
|
|
): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> {
|
|
|
|
try {
|
|
|
|
const validationResult = await this.validateTokenRequest(request);
|
|
|
|
if (!validationResult.success) return validationResult;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
const { client } = validationResult;
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
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 {
|
2025-05-28 12:21:03 +08:00
|
|
|
return OIDCErrorFactory.unsupportedGrantType();
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
} catch (error) {
|
2025-05-28 12:21:03 +08:00
|
|
|
return OIDCErrorFactory.createTokenError('server_error', 'Internal server error');
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
private async validateTokenRequest(
|
|
|
|
request: Partial<TokenRequest>
|
|
|
|
): Promise<{ success: true; client: OIDCClient } | { success: false; error: OIDCError }> {
|
|
|
|
if (!request.client_id) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing client_id parameter');
|
|
|
|
|
|
|
|
const client = await this.findClient(request.client_id);
|
|
|
|
if (!client) return OIDCErrorFactory.invalidClient('Invalid client_id');
|
|
|
|
|
|
|
|
// 客户端认证
|
|
|
|
if (client.client_type === 'confidential') {
|
|
|
|
if (!request.client_secret) return OIDCErrorFactory.invalidClient('Client authentication required');
|
|
|
|
if (request.client_secret !== client.client_secret) return OIDCErrorFactory.invalidClient('Invalid client credentials');
|
|
|
|
}
|
|
|
|
|
|
|
|
const validation = ValidationUtils.validateTokenRequest(request, client, this.config.grantTypes);
|
|
|
|
if (!validation.valid) return OIDCErrorFactory.createTokenError('invalid_request', validation.errors.join(', '));
|
|
|
|
|
|
|
|
return { success: true, client };
|
|
|
|
}
|
|
|
|
|
2025-05-28 08:23:14 +08:00
|
|
|
private async handleAuthorizationCodeGrant(
|
|
|
|
request: Partial<TokenRequest>,
|
|
|
|
client: OIDCClient
|
|
|
|
): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> {
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!request.code) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing authorization code');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const authCode = await this.tokenManager.getAuthorizationCode(request.code);
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!authCode) return OIDCErrorFactory.invalidGrant('Invalid authorization code');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
if (authCode.expires_at < new Date()) {
|
|
|
|
await this.tokenManager.deleteAuthorizationCode(request.code);
|
2025-05-28 12:21:03 +08:00
|
|
|
return OIDCErrorFactory.invalidGrant('Authorization code expired');
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
if (authCode.client_id !== request.client_id) return OIDCErrorFactory.invalidGrant('Authorization code was not issued to this client');
|
|
|
|
if (authCode.redirect_uri !== request.redirect_uri) return OIDCErrorFactory.invalidGrant('Redirect URI mismatch');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
// PKCE验证
|
2025-05-28 08:23:14 +08:00
|
|
|
if (authCode.code_challenge) {
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!request.code_verifier) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing code_verifier');
|
|
|
|
if (!PKCEUtils.verifyCodeChallenge(request.code_verifier, authCode.code_challenge, authCode.code_challenge_method || 'plain')) {
|
|
|
|
return OIDCErrorFactory.invalidGrant('Invalid code_verifier');
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const user = await this.findUser(authCode.user_id);
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!user) return OIDCErrorFactory.invalidGrant('User not found');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
const accessTokenJWT = await this.jwtUtils.generateAccessToken({
|
|
|
|
issuer: this.config.issuer,
|
|
|
|
subject: user.sub,
|
2025-05-28 12:21:03 +08:00
|
|
|
audience: client.client_id,
|
|
|
|
clientId: client.client_id,
|
2025-05-28 08:23:14 +08:00
|
|
|
scope: authCode.scope,
|
|
|
|
expiresIn: this.config.tokenTTL.accessToken,
|
|
|
|
});
|
|
|
|
|
|
|
|
const refreshTokenValue = nanoid(64);
|
|
|
|
|
|
|
|
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,
|
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),
|
|
|
|
nonce: authCode.nonce,
|
|
|
|
expiresIn: this.config.tokenTTL.idToken,
|
|
|
|
requestedClaims,
|
|
|
|
});
|
2025-05-28 12:21:03 +08:00
|
|
|
}
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
// 存储令牌
|
|
|
|
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);
|
|
|
|
|
|
|
|
await Promise.all([
|
|
|
|
this.tokenManager.storeAccessToken({
|
|
|
|
token: accessTokenJWT,
|
|
|
|
client_id: client.client_id,
|
|
|
|
user_id: user.sub,
|
|
|
|
scope: authCode.scope,
|
|
|
|
expires_at: accessTokenExpiry,
|
|
|
|
created_at: now,
|
|
|
|
}),
|
|
|
|
this.tokenManager.storeRefreshToken({
|
|
|
|
token: refreshTokenValue,
|
|
|
|
client_id: client.client_id,
|
|
|
|
user_id: user.sub,
|
|
|
|
scope: authCode.scope,
|
|
|
|
expires_at: refreshTokenExpiry,
|
|
|
|
created_at: now,
|
|
|
|
}),
|
|
|
|
...(idTokenJWT ? [this.tokenManager.storeIDToken({
|
2025-05-28 08:23:14 +08:00
|
|
|
token: idTokenJWT,
|
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-28 12:21:03 +08:00
|
|
|
nonce: undefined,
|
2025-05-28 08:23:14 +08:00
|
|
|
expires_at: idTokenExpiry,
|
|
|
|
created_at: now,
|
2025-05-28 12:21:03 +08:00
|
|
|
})] : [])
|
|
|
|
]);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
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,
|
|
|
|
};
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
if (idTokenJWT) response.id_token = idTokenJWT;
|
|
|
|
return { success: true, response };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
private async handleRefreshTokenGrant(
|
|
|
|
request: Partial<TokenRequest>,
|
|
|
|
client: OIDCClient
|
|
|
|
): Promise<{ success: true; response: TokenResponse } | { success: false; error: OIDCError }> {
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!request.refresh_token) return OIDCErrorFactory.createTokenError('invalid_request', 'Missing refresh_token');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const refreshToken = await this.tokenManager.getRefreshToken(request.refresh_token);
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!refreshToken) return OIDCErrorFactory.invalidGrant('Invalid refresh_token');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
if (refreshToken.expires_at < new Date()) {
|
|
|
|
await this.tokenManager.deleteRefreshToken(request.refresh_token);
|
2025-05-28 12:21:03 +08:00
|
|
|
return OIDCErrorFactory.invalidGrant('Refresh token expired');
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
if (refreshToken.client_id !== request.client_id) return OIDCErrorFactory.invalidGrant('Refresh token was not issued to this client');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const user = await this.findUser(refreshToken.user_id);
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!user) return OIDCErrorFactory.invalidGrant('User not found');
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
// 处理作用域
|
2025-05-28 08:23:14 +08:00
|
|
|
let scope = refreshToken.scope;
|
|
|
|
if (request.scope) {
|
|
|
|
const requestedScopes = request.scope.split(' ');
|
|
|
|
const originalScopes = refreshToken.scope.split(' ');
|
|
|
|
if (!requestedScopes.every(s => originalScopes.includes(s))) {
|
2025-05-28 12:21:03 +08:00
|
|
|
return OIDCErrorFactory.invalidScope('Requested scope exceeds original scope');
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
scope = request.scope;
|
|
|
|
}
|
|
|
|
|
|
|
|
const now = new Date();
|
|
|
|
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,
|
|
|
|
});
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
await this.tokenManager.storeAccessToken({
|
2025-05-28 08:23:14 +08:00
|
|
|
token: accessTokenJWT,
|
|
|
|
client_id: request.client_id!,
|
|
|
|
user_id: user.sub,
|
|
|
|
scope,
|
2025-05-28 12:21:03 +08:00
|
|
|
expires_at: new Date(now.getTime() + this.config.tokenTTL.accessToken * 1000),
|
2025-05-28 08:23:14 +08:00
|
|
|
created_at: now,
|
2025-05-28 12:21:03 +08:00
|
|
|
});
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const response: TokenResponse = {
|
|
|
|
access_token: accessTokenJWT,
|
|
|
|
token_type: 'Bearer',
|
|
|
|
expires_in: this.config.tokenTTL.accessToken,
|
|
|
|
scope,
|
|
|
|
};
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
// 刷新令牌轮换
|
|
|
|
if (this.config.rotateRefreshTokens) {
|
|
|
|
const newRefreshToken = nanoid(64);
|
|
|
|
await this.tokenManager.storeRefreshToken({
|
|
|
|
token: newRefreshToken,
|
|
|
|
client_id: request.client_id!,
|
|
|
|
user_id: user.sub,
|
|
|
|
scope,
|
|
|
|
expires_at: new Date(now.getTime() + this.config.tokenTTL.refreshToken * 1000),
|
|
|
|
created_at: now,
|
|
|
|
});
|
|
|
|
await this.tokenManager.deleteRefreshToken(request.refresh_token);
|
2025-05-28 08:23:14 +08:00
|
|
|
response.refresh_token = newRefreshToken;
|
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: true, response };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
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') {
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: false, error: OIDCErrorFactory.invalidToken('Invalid token type') };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
const tokenData = await this.tokenManager.getAccessToken(accessToken);
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!tokenData) return { success: false, error: OIDCErrorFactory.invalidToken('Token not found') };
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
if (tokenData.expires_at < new Date()) {
|
|
|
|
await this.tokenManager.deleteAccessToken(accessToken);
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: false, error: OIDCErrorFactory.invalidToken('Token expired') };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
const user = await this.findUser(tokenData.user_id);
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!user) return { success: false, error: OIDCErrorFactory.invalidToken('User not found') };
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
const requestedClaims = this.getRequestedClaims(tokenData.scope);
|
|
|
|
const filteredUser = this.filterUserClaims(user, requestedClaims);
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: true, user: filteredUser };
|
2025-05-28 08:23:14 +08:00
|
|
|
} catch (error) {
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: false, error: OIDCErrorFactory.invalidToken() };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private getRequestedClaims(scope: string): string[] {
|
|
|
|
const scopes = scope.split(' ');
|
2025-05-28 12:21:03 +08:00
|
|
|
const claims = ['sub'];
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
const scopeClaimsMap = {
|
|
|
|
profile: ['name', 'family_name', 'given_name', 'middle_name', 'nickname', 'preferred_username', 'profile', 'picture', 'website', 'gender', 'birthdate', 'zoneinfo', 'locale', 'updated_at'],
|
|
|
|
email: ['email', 'email_verified'],
|
|
|
|
phone: ['phone_number', 'phone_number_verified'],
|
|
|
|
address: ['address'],
|
|
|
|
};
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
scopes.forEach(scope => {
|
|
|
|
if (scope in scopeClaimsMap) {
|
|
|
|
claims.push(...scopeClaimsMap[scope as keyof typeof scopeClaimsMap]);
|
|
|
|
}
|
|
|
|
});
|
2025-05-28 08:23:14 +08:00
|
|
|
|
|
|
|
return claims;
|
|
|
|
}
|
|
|
|
|
|
|
|
private filterUserClaims(user: OIDCUser, requestedClaims: string[]): Partial<OIDCUser> {
|
2025-05-28 12:21:03 +08:00
|
|
|
return Object.fromEntries(
|
|
|
|
requestedClaims
|
|
|
|
.filter(claim => claim in user && user[claim as keyof OIDCUser] !== undefined)
|
|
|
|
.map(claim => [claim, user[claim as keyof OIDCUser]])
|
|
|
|
) as Partial<OIDCUser>;
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
async revokeToken(token: string): Promise<{ success: boolean; error?: OIDCError }> {
|
2025-05-28 08:23:14 +08:00
|
|
|
try {
|
2025-05-28 12:21:03 +08:00
|
|
|
const { accessToken, refreshToken } = await this.findTokenData(token);
|
|
|
|
|
2025-05-28 08:23:14 +08:00
|
|
|
if (accessToken) {
|
|
|
|
await this.tokenManager.deleteAccessToken(token);
|
2025-05-28 12:21:03 +08:00
|
|
|
} else if (refreshToken) {
|
|
|
|
await Promise.all([
|
|
|
|
this.tokenManager.deleteRefreshToken(token),
|
|
|
|
this.tokenManager.deleteAccessTokensByUserAndClient(refreshToken.user_id, refreshToken.client_id)
|
|
|
|
]);
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
} catch (error) {
|
2025-05-28 12:21:03 +08:00
|
|
|
return { success: false, error: OIDCErrorFactory.createSimpleError('server_error', 'Internal server error') };
|
2025-05-28 08:23:14 +08:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
async introspectToken(token: string) {
|
2025-05-28 08:23:14 +08:00
|
|
|
try {
|
2025-05-28 12:21:03 +08:00
|
|
|
const { accessToken, refreshToken, tokenData, isExpired } = await this.findTokenData(token);
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +08:00
|
|
|
if (!tokenData || isExpired) return { active: false };
|
2025-05-28 08:23:14 +08:00
|
|
|
|
2025-05-28 12:21:03 +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: accessToken ? '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
|
|
|
} catch (error) {
|
|
|
|
return { active: false };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async getJWKS(): Promise<{ keys: any[] }> {
|
|
|
|
return await this.jwtUtils.generateJWKS();
|
|
|
|
}
|
2025-05-28 12:21:03 +08:00
|
|
|
|
|
|
|
// HTTP处理方法
|
|
|
|
async handleLogin(c: Context): Promise<Response> {
|
|
|
|
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() || ''])
|
|
|
|
) as any;
|
|
|
|
|
|
|
|
try {
|
|
|
|
const authResult = await this.passwordAuth.authenticate(c);
|
|
|
|
return authResult.success
|
|
|
|
? await this.passwordAuth.handleAuthenticationSuccess(c, authResult)
|
|
|
|
: await this.passwordAuth.handleAuthenticationFailure(c, authResult, authRequest);
|
|
|
|
} catch (error) {
|
|
|
|
console.error('登录流程处理失败:', error);
|
|
|
|
return await this.passwordAuth.handleAuthenticationFailure(
|
|
|
|
c, { success: false, error: '服务器内部错误' }, authRequest
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleLogout(c: Context): Promise<Response> {
|
|
|
|
return await this.passwordAuth.logout(c);
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleAuthorization(c: Context): Promise<Response> {
|
|
|
|
const authRequest = this.parseAuthorizationRequest(c.req.query());
|
|
|
|
const userId = await this.passwordAuth.getCurrentUser(c);
|
|
|
|
|
|
|
|
if (!userId) {
|
|
|
|
return await this.passwordAuth.handleAuthenticationRequired(c, authRequest);
|
|
|
|
}
|
|
|
|
|
|
|
|
const result = await this.handleAuthorizationRequest(authRequest, userId);
|
|
|
|
|
|
|
|
if (!result.success) {
|
|
|
|
const errorParams = OIDCErrorFactory.buildErrorResponse(result.error);
|
|
|
|
const redirectUri = result.redirectUri || authRequest.redirect_uri;
|
|
|
|
|
|
|
|
return redirectUri
|
|
|
|
? c.redirect(`${redirectUri}?${errorParams.toString()}`)
|
|
|
|
: c.json({
|
|
|
|
error: result.error.error,
|
|
|
|
error_description: result.error.error_description || 'Unknown error'
|
|
|
|
}, 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
params.set('code', result.code);
|
|
|
|
if (authRequest.state) params.set('state', authRequest.state);
|
|
|
|
return c.redirect(`${result.redirectUri}?${params.toString()}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleToken(c: Context): Promise<Response> {
|
|
|
|
const body = await c.req.formData();
|
|
|
|
const tokenRequest = this.parseTokenRequest(body, c.req.header('Authorization'));
|
|
|
|
const result = await this.handleTokenRequest(tokenRequest);
|
|
|
|
|
|
|
|
return result.success
|
|
|
|
? c.json(result.response)
|
|
|
|
: c.json({
|
|
|
|
error: result.error.error,
|
|
|
|
error_description: result.error.error_description || 'Unknown error'
|
|
|
|
}, 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleUserInfo(c: Context): Promise<Response> {
|
|
|
|
const authHeader = c.req.header('Authorization');
|
|
|
|
if (!authHeader?.startsWith('Bearer ')) {
|
|
|
|
c.header('WWW-Authenticate', 'Bearer');
|
|
|
|
return c.json(OIDCErrorFactory.invalidToken('无效的访问令牌'), 401);
|
|
|
|
}
|
|
|
|
|
|
|
|
const result = await this.getUserInfo(authHeader.substring(7));
|
|
|
|
if (!result.success) {
|
|
|
|
c.header('WWW-Authenticate', `Bearer error="${result.error.error}"`);
|
|
|
|
return c.json(result.error, 401);
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.json(result.user);
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleRevoke(c: Context): Promise<Response> {
|
|
|
|
const body = await c.req.formData();
|
|
|
|
const token = body.get('token')?.toString();
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
const error = OIDCErrorFactory.invalidRequest('缺少token参数');
|
|
|
|
return c.json({
|
|
|
|
error: error.error,
|
|
|
|
error_description: error.error_description || 'Invalid request'
|
|
|
|
}, 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
const result = await this.revokeToken(token);
|
|
|
|
if (!result.success && result.error) {
|
|
|
|
const error = result.error as OIDCError;
|
|
|
|
return c.json({
|
|
|
|
error: error.error,
|
|
|
|
error_description: error.error_description || 'Unknown error'
|
|
|
|
}, 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
return c.body(null, 200);
|
|
|
|
}
|
|
|
|
|
|
|
|
async handleIntrospect(c: Context): Promise<Response> {
|
|
|
|
const body = await c.req.formData();
|
|
|
|
const token = body.get('token')?.toString();
|
|
|
|
|
|
|
|
if (!token) {
|
|
|
|
const error = OIDCErrorFactory.invalidRequest('缺少token参数');
|
|
|
|
return c.json({
|
|
|
|
error: error.error,
|
|
|
|
error_description: error.error_description || 'Invalid request'
|
|
|
|
}, 400);
|
|
|
|
}
|
|
|
|
|
|
|
|
const result = await this.introspectToken(token);
|
|
|
|
return c.json(result);
|
|
|
|
}
|
|
|
|
}
|