264 lines
7.1 KiB
TypeScript
264 lines
7.1 KiB
TypeScript
|
|
/**
|
||
|
|
* OAuth/OIDC Authentication Strategy
|
||
|
|
* Handles OAuth 2.0 and OpenID Connect flows
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||
|
|
import type { CryptoService } from '../../crypto.service.ts';
|
||
|
|
import type {
|
||
|
|
IExternalUserInfo,
|
||
|
|
IConnectionTestResult,
|
||
|
|
} from '../../../interfaces/auth.interfaces.ts';
|
||
|
|
import type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||
|
|
|
||
|
|
interface ITokenResponse {
|
||
|
|
access_token: string;
|
||
|
|
token_type: string;
|
||
|
|
expires_in?: number;
|
||
|
|
refresh_token?: string;
|
||
|
|
id_token?: string;
|
||
|
|
scope?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface IOIDCDiscovery {
|
||
|
|
issuer: string;
|
||
|
|
authorization_endpoint: string;
|
||
|
|
token_endpoint: string;
|
||
|
|
userinfo_endpoint?: string;
|
||
|
|
jwks_uri?: string;
|
||
|
|
scopes_supported?: string[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export class OAuthStrategy implements IAuthStrategy {
|
||
|
|
private discoveryCache: IOIDCDiscovery | null = null;
|
||
|
|
|
||
|
|
constructor(
|
||
|
|
private provider: AuthProvider,
|
||
|
|
private cryptoService: CryptoService
|
||
|
|
) {}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the authorization URL for initiating OAuth flow
|
||
|
|
*/
|
||
|
|
public async getAuthorizationUrl(state: string, nonce?: string): Promise<string> {
|
||
|
|
const config = this.provider.oauthConfig;
|
||
|
|
if (!config) {
|
||
|
|
throw new Error('OAuth config not found');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get authorization URL from config or discovery
|
||
|
|
let authorizationUrl = config.authorizationUrl;
|
||
|
|
if (!authorizationUrl) {
|
||
|
|
const discovery = await this.getDiscovery();
|
||
|
|
authorizationUrl = discovery.authorization_endpoint;
|
||
|
|
}
|
||
|
|
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
client_id: config.clientId,
|
||
|
|
redirect_uri: config.callbackUrl,
|
||
|
|
response_type: 'code',
|
||
|
|
scope: config.scopes.join(' '),
|
||
|
|
state,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Add nonce for OIDC
|
||
|
|
if (nonce) {
|
||
|
|
params.set('nonce', nonce);
|
||
|
|
}
|
||
|
|
|
||
|
|
return `${authorizationUrl}?${params.toString()}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Handle OAuth callback - exchange code for tokens and get user info
|
||
|
|
*/
|
||
|
|
public async handleCallback(data: IOAuthCallbackData): Promise<IExternalUserInfo> {
|
||
|
|
if (data.error) {
|
||
|
|
throw new Error(`OAuth error: ${data.error} - ${data.errorDescription || ''}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const config = this.provider.oauthConfig;
|
||
|
|
if (!config) {
|
||
|
|
throw new Error('OAuth config not found');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Exchange code for tokens
|
||
|
|
const tokens = await this.exchangeCodeForTokens(data.code);
|
||
|
|
|
||
|
|
// Get user info
|
||
|
|
const userInfo = await this.fetchUserInfo(tokens.access_token);
|
||
|
|
|
||
|
|
// Map attributes according to provider config
|
||
|
|
return this.mapAttributes(userInfo);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Test connection by fetching OIDC discovery document
|
||
|
|
*/
|
||
|
|
public async testConnection(): Promise<IConnectionTestResult> {
|
||
|
|
const start = Date.now();
|
||
|
|
const config = this.provider.oauthConfig;
|
||
|
|
|
||
|
|
if (!config) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
latencyMs: Date.now() - start,
|
||
|
|
error: 'OAuth config not found',
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const discovery = await this.getDiscovery();
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
latencyMs: Date.now() - start,
|
||
|
|
serverInfo: {
|
||
|
|
issuer: discovery.issuer,
|
||
|
|
scopes_supported: discovery.scopes_supported,
|
||
|
|
has_userinfo: !!discovery.userinfo_endpoint,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
latencyMs: Date.now() - start,
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Exchange authorization code for tokens
|
||
|
|
*/
|
||
|
|
private async exchangeCodeForTokens(code: string): Promise<ITokenResponse> {
|
||
|
|
const config = this.provider.oauthConfig!;
|
||
|
|
|
||
|
|
// Get token URL from config or discovery
|
||
|
|
let tokenUrl = config.tokenUrl;
|
||
|
|
if (!tokenUrl) {
|
||
|
|
const discovery = await this.getDiscovery();
|
||
|
|
tokenUrl = discovery.token_endpoint;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decrypt client secret
|
||
|
|
const clientSecret = await this.cryptoService.decrypt(config.clientSecretEncrypted);
|
||
|
|
|
||
|
|
const response = await fetch(tokenUrl, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||
|
|
Accept: 'application/json',
|
||
|
|
},
|
||
|
|
body: new URLSearchParams({
|
||
|
|
grant_type: 'authorization_code',
|
||
|
|
code,
|
||
|
|
redirect_uri: config.callbackUrl,
|
||
|
|
client_id: config.clientId,
|
||
|
|
client_secret: clientSecret,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorText = await response.text();
|
||
|
|
throw new Error(`Token exchange failed: ${response.status} - ${errorText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return await response.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Fetch user info from the provider
|
||
|
|
*/
|
||
|
|
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown>> {
|
||
|
|
const config = this.provider.oauthConfig!;
|
||
|
|
|
||
|
|
// Get userinfo URL from config or discovery
|
||
|
|
let userInfoUrl = config.userInfoUrl;
|
||
|
|
if (!userInfoUrl) {
|
||
|
|
const discovery = await this.getDiscovery();
|
||
|
|
userInfoUrl = discovery.userinfo_endpoint;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!userInfoUrl) {
|
||
|
|
throw new Error('UserInfo endpoint not found');
|
||
|
|
}
|
||
|
|
|
||
|
|
const response = await fetch(userInfoUrl, {
|
||
|
|
headers: {
|
||
|
|
Authorization: `Bearer ${accessToken}`,
|
||
|
|
Accept: 'application/json',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorText = await response.text();
|
||
|
|
throw new Error(`UserInfo fetch failed: ${response.status} - ${errorText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return await response.json();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get OIDC discovery document
|
||
|
|
*/
|
||
|
|
private async getDiscovery(): Promise<IOIDCDiscovery> {
|
||
|
|
if (this.discoveryCache) {
|
||
|
|
return this.discoveryCache;
|
||
|
|
}
|
||
|
|
|
||
|
|
const config = this.provider.oauthConfig!;
|
||
|
|
const discoveryUrl = `${config.issuer}/.well-known/openid-configuration`;
|
||
|
|
|
||
|
|
const response = await fetch(discoveryUrl, {
|
||
|
|
headers: { Accept: 'application/json' },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new Error(`OIDC discovery failed: ${response.status}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
this.discoveryCache = await response.json();
|
||
|
|
return this.discoveryCache!;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Map provider attributes to standard user info
|
||
|
|
*/
|
||
|
|
private mapAttributes(rawInfo: Record<string, unknown>): IExternalUserInfo {
|
||
|
|
const mapping = this.provider.attributeMapping;
|
||
|
|
|
||
|
|
// Get external ID (sub for OIDC, or id for OAuth2)
|
||
|
|
const externalId = String(rawInfo.sub || rawInfo.id || '');
|
||
|
|
if (!externalId) {
|
||
|
|
throw new Error('External ID not found in user info');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Get email
|
||
|
|
const email = rawInfo[mapping.email];
|
||
|
|
if (!email || typeof email !== 'string') {
|
||
|
|
throw new Error('Email not found in user info');
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
externalId,
|
||
|
|
email,
|
||
|
|
username: rawInfo[mapping.username]
|
||
|
|
? String(rawInfo[mapping.username])
|
||
|
|
: undefined,
|
||
|
|
displayName: rawInfo[mapping.displayName]
|
||
|
|
? String(rawInfo[mapping.displayName])
|
||
|
|
: undefined,
|
||
|
|
avatarUrl: mapping.avatarUrl && rawInfo[mapping.avatarUrl]
|
||
|
|
? String(rawInfo[mapping.avatarUrl])
|
||
|
|
: (rawInfo.picture ? String(rawInfo.picture) : undefined),
|
||
|
|
groups: mapping.groups && rawInfo[mapping.groups]
|
||
|
|
? (Array.isArray(rawInfo[mapping.groups])
|
||
|
|
? (rawInfo[mapping.groups] as string[])
|
||
|
|
: [String(rawInfo[mapping.groups])])
|
||
|
|
: undefined,
|
||
|
|
rawAttributes: rawInfo,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|