feat(auth): Add external authentication (OAuth/OIDC & LDAP) with admin management, UI, and encryption support
This commit is contained in:
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
47
ts/services/auth/strategies/auth.strategy.interface.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Authentication Strategy Interface
|
||||
* Base interface for OAuth/OIDC and LDAP authentication strategies
|
||||
*/
|
||||
|
||||
import type {
|
||||
IExternalUserInfo,
|
||||
IConnectionTestResult,
|
||||
} from '../../../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthCallbackData {
|
||||
code: string;
|
||||
state: string;
|
||||
error?: string;
|
||||
errorDescription?: string;
|
||||
}
|
||||
|
||||
export interface IAuthStrategy {
|
||||
/**
|
||||
* Get the authorization URL for OAuth/OIDC flow
|
||||
* @param state - CSRF state token
|
||||
* @param nonce - Optional nonce for OIDC
|
||||
* @returns Authorization URL to redirect user to
|
||||
*/
|
||||
getAuthorizationUrl?(state: string, nonce?: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Handle OAuth/OIDC callback
|
||||
* @param data - Callback data including code and state
|
||||
* @returns External user info from the provider
|
||||
*/
|
||||
handleCallback?(data: IOAuthCallbackData): Promise<IExternalUserInfo>;
|
||||
|
||||
/**
|
||||
* Authenticate with credentials (LDAP)
|
||||
* @param username - Username
|
||||
* @param password - Password
|
||||
* @returns External user info if authentication succeeds
|
||||
*/
|
||||
authenticateCredentials?(username: string, password: string): Promise<IExternalUserInfo>;
|
||||
|
||||
/**
|
||||
* Test connection to the provider
|
||||
* @returns Connection test result
|
||||
*/
|
||||
testConnection(): Promise<IConnectionTestResult>;
|
||||
}
|
||||
8
ts/services/auth/strategies/index.ts
Normal file
8
ts/services/auth/strategies/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Auth Strategy exports
|
||||
*/
|
||||
|
||||
export type { IAuthStrategy, IOAuthCallbackData } from './auth.strategy.interface.ts';
|
||||
export { OAuthStrategy } from './oauth.strategy.ts';
|
||||
export { LdapStrategy } from './ldap.strategy.ts';
|
||||
export { AuthStrategyFactory } from './strategy.factory.ts';
|
||||
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
242
ts/services/auth/strategies/ldap.strategy.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* LDAP Authentication Strategy
|
||||
* Handles LDAP/Active Directory authentication
|
||||
*
|
||||
* Note: This is a basic implementation. For production use with actual LDAP,
|
||||
* you may need to integrate with a Deno-compatible LDAP library.
|
||||
*/
|
||||
|
||||
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 } from './auth.strategy.interface.ts';
|
||||
|
||||
// LDAP entry type (simplified)
|
||||
interface ILdapEntry {
|
||||
dn: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class LdapStrategy implements IAuthStrategy {
|
||||
constructor(
|
||||
private provider: AuthProvider,
|
||||
private cryptoService: CryptoService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Authenticate user with LDAP credentials
|
||||
*/
|
||||
public async authenticateCredentials(
|
||||
username: string,
|
||||
password: string
|
||||
): Promise<IExternalUserInfo> {
|
||||
const config = this.provider.ldapConfig;
|
||||
if (!config) {
|
||||
throw new Error('LDAP config not found');
|
||||
}
|
||||
|
||||
// Escape username to prevent LDAP injection
|
||||
const escapedUsername = this.escapeLdap(username);
|
||||
|
||||
// Build user search filter
|
||||
const userFilter = config.userSearchFilter.replace('{{username}}', escapedUsername);
|
||||
|
||||
// Decrypt bind password
|
||||
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
|
||||
|
||||
// Perform LDAP authentication
|
||||
// This is a placeholder - actual implementation would use an LDAP library
|
||||
const userEntry = await this.ldapBind(
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn,
|
||||
userFilter,
|
||||
password
|
||||
);
|
||||
|
||||
// Map LDAP attributes to user info
|
||||
return this.mapAttributes(userEntry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
public async testConnection(): Promise<IConnectionTestResult> {
|
||||
const start = Date.now();
|
||||
const config = this.provider.ldapConfig;
|
||||
|
||||
if (!config) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: 'LDAP config not found',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt bind password
|
||||
const bindPassword = await this.cryptoService.decrypt(config.bindPasswordEncrypted);
|
||||
|
||||
// Test connection by binding with service account
|
||||
await this.testLdapConnection(
|
||||
config.serverUrl,
|
||||
config.bindDn,
|
||||
bindPassword,
|
||||
config.baseDn
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latencyMs: Date.now() - start,
|
||||
serverInfo: {
|
||||
serverUrl: config.serverUrl,
|
||||
baseDn: config.baseDn,
|
||||
tlsEnabled: config.tlsEnabled,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special LDAP characters to prevent injection
|
||||
*/
|
||||
private escapeLdap(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\5c')
|
||||
.replace(/\*/g, '\\2a')
|
||||
.replace(/\(/g, '\\28')
|
||||
.replace(/\)/g, '\\29')
|
||||
.replace(/\x00/g, '\\00');
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform LDAP bind and search
|
||||
* This is a placeholder implementation - actual LDAP would require a library
|
||||
*/
|
||||
private async ldapBind(
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string,
|
||||
userFilter: string,
|
||||
userPassword: string
|
||||
): Promise<ILdapEntry> {
|
||||
// In a real implementation, this would:
|
||||
// 1. Connect to LDAP server
|
||||
// 2. Bind with service account (bindDn/bindPassword)
|
||||
// 3. Search for user with userFilter
|
||||
// 4. Re-bind with user's DN and password to verify
|
||||
// 5. Return user entry if successful
|
||||
|
||||
// For now, we throw an error indicating LDAP needs to be configured
|
||||
// This allows the structure to be in place while the actual LDAP library
|
||||
// integration can be done separately
|
||||
|
||||
console.log('[LdapStrategy] LDAP auth attempt:', {
|
||||
serverUrl,
|
||||
baseDn,
|
||||
userFilter,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
'LDAP authentication is not yet fully implemented. ' +
|
||||
'Please integrate with a Deno-compatible LDAP library (e.g., ldapts via npm compatibility).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test LDAP connection
|
||||
*/
|
||||
private async testLdapConnection(
|
||||
serverUrl: string,
|
||||
bindDn: string,
|
||||
bindPassword: string,
|
||||
baseDn: string
|
||||
): Promise<void> {
|
||||
// Similar to ldapBind, this is a placeholder
|
||||
// Would connect and bind with service account to verify connectivity
|
||||
|
||||
console.log('[LdapStrategy] Testing LDAP connection:', {
|
||||
serverUrl,
|
||||
bindDn,
|
||||
baseDn,
|
||||
});
|
||||
|
||||
// For now, check if server URL is valid
|
||||
if (!serverUrl.startsWith('ldap://') && !serverUrl.startsWith('ldaps://')) {
|
||||
throw new Error('Invalid LDAP server URL. Must start with ldap:// or ldaps://');
|
||||
}
|
||||
|
||||
// In a real implementation, we would actually connect here
|
||||
// For now, we just validate the configuration
|
||||
if (!bindDn || !bindPassword || !baseDn) {
|
||||
throw new Error('Missing required LDAP configuration');
|
||||
}
|
||||
|
||||
// Return success for configuration validation
|
||||
// Actual connectivity test would happen with LDAP library
|
||||
console.log('[LdapStrategy] LDAP configuration is valid (actual connection test requires LDAP library)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Map LDAP attributes to standard user info
|
||||
*/
|
||||
private mapAttributes(entry: ILdapEntry): IExternalUserInfo {
|
||||
const mapping = this.provider.attributeMapping;
|
||||
|
||||
// Get external ID (typically uid or sAMAccountName)
|
||||
const externalId = String(entry[mapping.username] || entry.dn);
|
||||
|
||||
// Get email
|
||||
const email = entry[mapping.email];
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('Email not found in LDAP entry');
|
||||
}
|
||||
|
||||
return {
|
||||
externalId,
|
||||
email,
|
||||
username: entry[mapping.username]
|
||||
? String(entry[mapping.username])
|
||||
: undefined,
|
||||
displayName: entry[mapping.displayName]
|
||||
? String(entry[mapping.displayName])
|
||||
: undefined,
|
||||
groups: mapping.groups
|
||||
? this.parseGroups(entry[mapping.groups])
|
||||
: undefined,
|
||||
rawAttributes: entry as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse LDAP group membership
|
||||
*/
|
||||
private parseGroups(memberOf: unknown): string[] {
|
||||
if (!memberOf) return [];
|
||||
|
||||
if (Array.isArray(memberOf)) {
|
||||
return memberOf.map((dn) => this.extractCnFromDn(String(dn)));
|
||||
}
|
||||
|
||||
return [this.extractCnFromDn(String(memberOf))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract CN (Common Name) from a DN (Distinguished Name)
|
||||
*/
|
||||
private extractCnFromDn(dn: string): string {
|
||||
const match = dn.match(/^CN=([^,]+)/i);
|
||||
return match ? match[1] : dn;
|
||||
}
|
||||
}
|
||||
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
263
ts/services/auth/strategies/oauth.strategy.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
28
ts/services/auth/strategies/strategy.factory.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Auth Strategy Factory
|
||||
* Creates the appropriate authentication strategy based on provider type
|
||||
*/
|
||||
|
||||
import type { AuthProvider } from '../../../models/auth.provider.ts';
|
||||
import type { CryptoService } from '../../crypto.service.ts';
|
||||
import type { IAuthStrategy } from './auth.strategy.interface.ts';
|
||||
import { OAuthStrategy } from './oauth.strategy.ts';
|
||||
import { LdapStrategy } from './ldap.strategy.ts';
|
||||
|
||||
export class AuthStrategyFactory {
|
||||
constructor(private cryptoService: CryptoService) {}
|
||||
|
||||
/**
|
||||
* Create the appropriate strategy for a provider
|
||||
*/
|
||||
public create(provider: AuthProvider): IAuthStrategy {
|
||||
switch (provider.type) {
|
||||
case 'oidc':
|
||||
return new OAuthStrategy(provider, this.cryptoService);
|
||||
case 'ldap':
|
||||
return new LdapStrategy(provider, this.cryptoService);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${provider.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
178
ts/services/crypto.service.ts
Normal file
178
ts/services/crypto.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Crypto Service for Stack.Gallery Registry
|
||||
* Handles AES-256-GCM encryption/decryption of secrets
|
||||
*/
|
||||
|
||||
export class CryptoService {
|
||||
private masterKey: CryptoKey | null = null;
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the crypto service with the master key
|
||||
* The key should be a 64-character hex string (32 bytes = 256 bits)
|
||||
*/
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
const keyHex = Deno.env.get('AUTH_ENCRYPTION_KEY');
|
||||
if (!keyHex) {
|
||||
console.warn(
|
||||
'[CryptoService] AUTH_ENCRYPTION_KEY not set. Generating ephemeral key (NOT for production!)'
|
||||
);
|
||||
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
this.masterKey = await this.importKey(this.bytesToHex(randomBytes));
|
||||
} else {
|
||||
if (keyHex.length !== 64) {
|
||||
throw new Error('AUTH_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)');
|
||||
}
|
||||
this.masterKey = await this.importKey(keyHex);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string
|
||||
* Returns format: base64(iv):base64(ciphertext)
|
||||
*/
|
||||
public async encrypt(plaintext: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.masterKey) {
|
||||
throw new Error('CryptoService not initialized');
|
||||
}
|
||||
|
||||
// Generate random IV (12 bytes for AES-GCM)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Encode plaintext to bytes
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
this.masterKey,
|
||||
encoded
|
||||
);
|
||||
|
||||
// Format: iv:ciphertext (both base64)
|
||||
const ivBase64 = this.bytesToBase64(iv);
|
||||
const ciphertextBase64 = this.bytesToBase64(new Uint8Array(encrypted));
|
||||
|
||||
return `${ivBase64}:${ciphertextBase64}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted string
|
||||
* Expects format: base64(iv):base64(ciphertext)
|
||||
*/
|
||||
public async decrypt(ciphertext: string): Promise<string> {
|
||||
await this.initialize();
|
||||
|
||||
if (!this.masterKey) {
|
||||
throw new Error('CryptoService not initialized');
|
||||
}
|
||||
|
||||
const parts = ciphertext.split(':');
|
||||
if (parts.length !== 2) {
|
||||
throw new Error('Invalid ciphertext format');
|
||||
}
|
||||
|
||||
const [ivBase64, encryptedBase64] = parts;
|
||||
|
||||
// Decode from base64
|
||||
const iv = this.base64ToBytes(ivBase64);
|
||||
const encrypted = this.base64ToBytes(encryptedBase64);
|
||||
|
||||
// Decrypt
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
this.masterKey,
|
||||
encrypted
|
||||
);
|
||||
|
||||
// Decode to string
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is already encrypted (contains the iv:ciphertext format)
|
||||
*/
|
||||
public isEncrypted(value: string): boolean {
|
||||
if (!value || typeof value !== 'string') return false;
|
||||
const parts = value.split(':');
|
||||
if (parts.length !== 2) return false;
|
||||
|
||||
// Check if both parts look like base64
|
||||
try {
|
||||
this.base64ToBytes(parts[0]);
|
||||
this.base64ToBytes(parts[1]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a hex key as CryptoKey
|
||||
*/
|
||||
private async importKey(keyHex: string): Promise<CryptoKey> {
|
||||
const keyBytes = this.hexToBytes(keyHex);
|
||||
return await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to hex string
|
||||
*/
|
||||
private bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to bytes
|
||||
*/
|
||||
private hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bytes to base64
|
||||
*/
|
||||
private bytesToBase64(bytes: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...bytes));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 to bytes
|
||||
*/
|
||||
private base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new encryption key (for setup)
|
||||
* Returns a 64-character hex string
|
||||
*/
|
||||
public static generateKey(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const cryptoService = new CryptoService();
|
||||
568
ts/services/external.auth.service.ts
Normal file
568
ts/services/external.auth.service.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* External Auth Service for Stack.Gallery Registry
|
||||
* Orchestrates OAuth/OIDC and LDAP authentication flows
|
||||
*/
|
||||
|
||||
import { User, Session, AuthProvider, ExternalIdentity, PlatformSettings } from '../models/index.ts';
|
||||
import { AuthService, type IAuthResult } from './auth.service.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
import { cryptoService } from './crypto.service.ts';
|
||||
import { AuthStrategyFactory, type IOAuthCallbackData } from './auth/strategies/index.ts';
|
||||
import type { IExternalUserInfo, IConnectionTestResult } from '../interfaces/auth.interfaces.ts';
|
||||
|
||||
export interface IOAuthState {
|
||||
providerId: string;
|
||||
returnUrl?: string;
|
||||
nonce: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export class ExternalAuthService {
|
||||
private strategyFactory: AuthStrategyFactory;
|
||||
private authService: AuthService;
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor() {
|
||||
this.strategyFactory = new AuthStrategyFactory(cryptoService);
|
||||
this.authService = new AuthService();
|
||||
this.auditService = new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow - returns authorization URL and state
|
||||
*/
|
||||
public async initiateOAuth(
|
||||
providerId: string,
|
||||
returnUrl?: string
|
||||
): Promise<{ authUrl: string; state: string }> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
|
||||
if (provider.status !== 'active') {
|
||||
throw new Error('Provider is not active');
|
||||
}
|
||||
|
||||
if (provider.type !== 'oidc') {
|
||||
throw new Error('Provider is not an OAuth/OIDC provider');
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.getAuthorizationUrl) {
|
||||
throw new Error('Provider does not support OAuth flow');
|
||||
}
|
||||
|
||||
// Generate state with encoded data
|
||||
const state = await this.generateState(providerId, returnUrl);
|
||||
const nonce = crypto.randomUUID();
|
||||
|
||||
const authUrl = await strategy.getAuthorizationUrl(state, nonce);
|
||||
|
||||
return { authUrl, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback - exchange code for user and create session
|
||||
*/
|
||||
public async handleOAuthCallback(
|
||||
data: IOAuthCallbackData,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
// Validate state
|
||||
const stateData = await this.validateState(data.state);
|
||||
if (!stateData) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_STATE',
|
||||
errorMessage: 'Invalid or expired state',
|
||||
};
|
||||
}
|
||||
|
||||
// Get provider
|
||||
const provider = await AuthProvider.findById(stateData.providerId);
|
||||
if (!provider || provider.status !== 'active') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_INACTIVE',
|
||||
errorMessage: 'Provider not found or inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Handle OAuth callback
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.handleCallback) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support OAuth callback',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.handleCallback(data);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'PROVIDER_ERROR',
|
||||
errorMessage: error instanceof Error ? error.message : 'Authentication failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens using the existing AuthService approach
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'oauth',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with LDAP credentials
|
||||
*/
|
||||
public async authenticateLdap(
|
||||
providerId: string,
|
||||
username: string,
|
||||
password: string,
|
||||
options: { ipAddress?: string; userAgent?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider || provider.status !== 'active' || provider.type !== 'ldap') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Invalid LDAP provider',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
if (!strategy.authenticateCredentials) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_PROVIDER',
|
||||
errorMessage: 'Provider does not support credential authentication',
|
||||
};
|
||||
}
|
||||
|
||||
let externalUser: IExternalUserInfo;
|
||||
try {
|
||||
externalUser = await strategy.authenticateCredentials(username, password);
|
||||
} catch (error) {
|
||||
await this.auditService.log('USER_LOGIN', 'user', {
|
||||
success: false,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
username,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'AUTH_FAILED',
|
||||
errorMessage: 'Invalid credentials',
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create user
|
||||
const { user, isNew } = await this.findOrCreateUser(provider, externalUser, options);
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).log('USER_LOGIN', 'user', {
|
||||
resourceId: user.id,
|
||||
success: true,
|
||||
metadata: {
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
isNewUser: isNew,
|
||||
authMethod: 'ldap',
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an external provider to an existing user
|
||||
*/
|
||||
public async linkProvider(
|
||||
userId: string,
|
||||
providerId: string,
|
||||
externalUser: IExternalUserInfo
|
||||
): Promise<ExternalIdentity> {
|
||||
// Check if this external ID is already linked to another user
|
||||
const existing = await ExternalIdentity.findByExternalId(providerId, externalUser.externalId);
|
||||
if (existing) {
|
||||
if (existing.userId === userId) {
|
||||
// Already linked to this user, just update
|
||||
await existing.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
throw new Error('This external account is already linked to another user');
|
||||
}
|
||||
|
||||
// Create new identity link
|
||||
const identity = await ExternalIdentity.createIdentity({
|
||||
userId,
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
|
||||
// Update user's external identity IDs
|
||||
const user = await User.findById(userId);
|
||||
if (user) {
|
||||
user.externalIdentityIds = [...(user.externalIdentityIds || []), identity.id];
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'link_provider',
|
||||
providerId,
|
||||
externalId: externalUser.externalId,
|
||||
},
|
||||
});
|
||||
|
||||
return identity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink an external provider from a user
|
||||
*/
|
||||
public async unlinkProvider(userId: string, providerId: string): Promise<boolean> {
|
||||
const identity = await ExternalIdentity.findByUserAndProvider(userId, providerId);
|
||||
if (!identity) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure user still has another auth method
|
||||
const user = await User.findById(userId);
|
||||
if (!user) return false;
|
||||
|
||||
const otherIdentities = await ExternalIdentity.findByUserId(userId);
|
||||
const hasLocalAuth = user.canUseLocalAuth && user.passwordHash;
|
||||
|
||||
if (otherIdentities.length <= 1 && !hasLocalAuth) {
|
||||
throw new Error('Cannot unlink last authentication method');
|
||||
}
|
||||
|
||||
// Remove identity
|
||||
await identity.delete();
|
||||
|
||||
// Update user's external identity IDs
|
||||
user.externalIdentityIds = user.externalIdentityIds.filter((id) => id !== identity.id);
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await this.auditService.log('USER_UPDATED', 'user', {
|
||||
resourceId: userId,
|
||||
success: true,
|
||||
metadata: {
|
||||
action: 'unlink_provider',
|
||||
providerId,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test provider connection
|
||||
*/
|
||||
public async testConnection(providerId: string): Promise<IConnectionTestResult> {
|
||||
const provider = await AuthProvider.findById(providerId);
|
||||
if (!provider) {
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: 0,
|
||||
error: 'Provider not found',
|
||||
};
|
||||
}
|
||||
|
||||
const strategy = this.strategyFactory.create(provider);
|
||||
const result = await strategy.testConnection();
|
||||
|
||||
// Update provider test status
|
||||
await provider.updateTestResult(result.success, result.error);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create user from external authentication
|
||||
*/
|
||||
private async findOrCreateUser(
|
||||
provider: AuthProvider,
|
||||
externalUser: IExternalUserInfo,
|
||||
options: { ipAddress?: string } = {}
|
||||
): Promise<{ user: User; isNew: boolean }> {
|
||||
// 1. Check if external identity already exists
|
||||
const existingIdentity = await ExternalIdentity.findByExternalId(
|
||||
provider.id,
|
||||
externalUser.externalId
|
||||
);
|
||||
|
||||
if (existingIdentity) {
|
||||
const user = await User.findById(existingIdentity.userId);
|
||||
if (user) {
|
||||
// Update identity with latest info
|
||||
await existingIdentity.updateAttributes({
|
||||
externalEmail: externalUser.email,
|
||||
externalUsername: externalUser.username,
|
||||
rawAttributes: externalUser.rawAttributes,
|
||||
});
|
||||
return { user, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try to link by email if enabled
|
||||
if (provider.provisioning.autoLinkByEmail && externalUser.email) {
|
||||
const existingUser = await User.findByEmail(externalUser.email);
|
||||
if (existingUser) {
|
||||
await this.linkProvider(existingUser.id, provider.id, externalUser);
|
||||
return { user: existingUser, isNew: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Create new user if JIT is enabled
|
||||
if (!provider.provisioning.jitEnabled) {
|
||||
throw new Error('User not found and JIT provisioning is disabled');
|
||||
}
|
||||
|
||||
// Check domain restrictions
|
||||
if (provider.provisioning.allowedEmailDomains?.length) {
|
||||
const domain = externalUser.email.split('@')[1];
|
||||
if (!provider.provisioning.allowedEmailDomains.includes(domain)) {
|
||||
throw new Error(`Email domain ${domain} is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique username
|
||||
let username = externalUser.username || externalUser.email.split('@')[0];
|
||||
username = username.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Ensure username is unique
|
||||
let counter = 0;
|
||||
let finalUsername = username;
|
||||
while (await User.findByUsername(finalUsername)) {
|
||||
counter++;
|
||||
finalUsername = `${username}${counter}`;
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user = new User();
|
||||
user.id = await User.getNewId();
|
||||
user.email = externalUser.email.toLowerCase();
|
||||
user.username = finalUsername;
|
||||
user.displayName = externalUser.displayName || finalUsername;
|
||||
user.avatarUrl = externalUser.avatarUrl;
|
||||
user.status = 'active';
|
||||
user.emailVerified = true; // Trust the provider
|
||||
user.canUseLocalAuth = false; // No password set
|
||||
user.provisionedByProviderId = provider.id;
|
||||
user.passwordHash = ''; // No local password
|
||||
user.createdAt = new Date();
|
||||
user.updatedAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Link external identity
|
||||
await this.linkProvider(user.id, provider.id, externalUser);
|
||||
|
||||
return { user, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate OAuth state token
|
||||
*/
|
||||
private async generateState(providerId: string, returnUrl?: string): Promise<string> {
|
||||
const stateData: IOAuthState = {
|
||||
providerId,
|
||||
returnUrl,
|
||||
nonce: crypto.randomUUID(),
|
||||
exp: Date.now() + 10 * 60 * 1000, // 10 minutes
|
||||
};
|
||||
|
||||
// Encode as base64
|
||||
return btoa(JSON.stringify(stateData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate OAuth state token
|
||||
*/
|
||||
private async validateState(state: string): Promise<IOAuthState | null> {
|
||||
try {
|
||||
const stateData: IOAuthState = JSON.parse(atob(state));
|
||||
|
||||
// Check expiration
|
||||
if (stateData.exp < Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return stateData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 15 * 60; // 15 minutes
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token (mirrors AuthService logic)
|
||||
*/
|
||||
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||
const jwtSecret = Deno.env.get('JWT_SECRET') || 'change-me-in-production';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const expiresIn = 7 * 24 * 60 * 60; // 7 days
|
||||
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'refresh',
|
||||
iat: now,
|
||||
exp: now + expiresIn,
|
||||
};
|
||||
|
||||
return await this.signJwt(payload, jwtSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign JWT token
|
||||
*/
|
||||
private async signJwt(payload: Record<string, unknown>, secret: string): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
|
||||
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
const encodedSignature = this.base64UrlEncode(
|
||||
String.fromCharCode(...new Uint8Array(signature))
|
||||
);
|
||||
|
||||
return `${data}.${encodedSignature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
const base64 = btoa(str);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const externalAuthService = new ExternalAuthService();
|
||||
Reference in New Issue
Block a user