/home/techb158/cosmic.abdallabala.com/src/services
Edit: /home/techb158/cosmic.abdallabala.com/src/services/oauthService.js (9282B)
const crypto = require("crypto");
const { list, findById, insert, update, audit } = require("../storage/jsonDatabase.js");
const { encryptToken, decryptToken, redactToken, hasEncryptionSecret } = require("../security/tokenVault.js");
const {
listConfigs,
getConfig,
getCredentialStatus,
buildAuthorizationUrl,
buildTokenRequest
} = require("../integrations/oauthProviderConfig.js");
function nowPlusSeconds(seconds) {
return new Date(Date.now() + Number(seconds || 0) * 1000).toISOString();
}
function stateId() {
return crypto.randomBytes(24).toString("hex");
}
function safeJson(value) {
if (!value) return null;
try { return JSON.stringify(value); } catch (_error) { return null; }
}
function parseJson(value) {
if (!value) return null;
try { return typeof value === "string" ? JSON.parse(value) : value; } catch (_error) { return null; }
}
function providerSlug(provider) {
return String(provider || "pm").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
class OAuthService {
constructor(database, options = {}) {
this.database = database;
this.fetchImpl = options.fetchImpl || global.fetch;
this.env = options.env || process.env;
}
listProviders() {
return listConfigs().map(config => {
const status = getCredentialStatus(config.provider, this.env);
return {
provider: config.provider,
authType: config.authType,
scopes: config.scopes,
configured: status.configured,
tokenAvailable: status.tokenAvailable || this.hasStoredToken(config.provider),
encryptionConfigured: status.encryptionConfigured,
requiredEnv: status.requiredEnv,
optionalEnv: status.optionalEnv,
missingRequired: status.missingRequired,
redirectUri: status.redirectUri
};
});
}
getProviderStatus(provider) {
const status = getCredentialStatus(provider, this.env);
return Object.assign(status, { storedTokenAvailable: this.hasStoredToken(provider) });
}
hasStoredToken(provider, integrationId = null) {
const db = this.database.read();
return list(db, "oauth_tokens", token => token.provider === provider && (!integrationId || token.integration_id === integrationId) && token.revoked_at == null).length > 0;
}
buildAuthorizePayload(provider, integrationId, actorUserId = "system") {
const state = stateId();
const authorizeUrl = buildAuthorizationUrl(provider, state, this.env);
this.database.transaction(db => {
const integration = integrationId ? findById(db, "project_management_integrations", integrationId) : null;
insert(db, "oauth_states", {
provider,
integration_id: integrationId || null,
project_id: integration ? integration.project_id : null,
actor_user_id: actorUserId,
state,
status: "Pending",
expires_at: nowPlusSeconds(600)
}, "OAUTHSTATE");
audit(db, {
project_id: integration ? integration.project_id : "unknown",
actor_user_id: actorUserId,
entity_type: "OAuthState",
entity_id: state,
action: "authorize-url-created",
after_json: { provider, integrationId, expiresInSeconds: 600 }
});
});
return { provider, integrationId: integrationId || null, authorizeUrl, state, expiresInSeconds: 600 };
}
validateState(db, provider, state) {
const row = list(db, "oauth_states", item => item.provider === provider && item.state === state)[0];
if (!row) throw new Error("Invalid OAuth state");
if (row.status !== "Pending") throw new Error("OAuth state has already been used");
if (row.expires_at && new Date(row.expires_at).getTime() < Date.now()) throw new Error("OAuth state has expired");
return row;
}
async exchangeAuthorizationCode(provider, code, state) {
if (!code) throw new Error("OAuth code is required");
if (!state) throw new Error("OAuth state is required");
const request = buildTokenRequest(provider, code, this.env);
if (!this.fetchImpl) throw new Error("Global fetch is not available in this Node.js runtime");
const response = await this.fetchImpl(request.url, {
method: "POST",
headers: { "Content-Type": request.contentType, "Accept": "application/json" },
body: request.body
});
const bodyText = await response.text();
let tokenResponse = {};
try { tokenResponse = bodyText ? JSON.parse(bodyText) : {}; } catch (_error) { tokenResponse = { raw: bodyText }; }
if (!response.ok) {
throw new Error(`${provider} OAuth token exchange failed with HTTP ${response.status}: ${bodyText.slice(0, 300)}`);
}
return this.storeOAuthToken(provider, tokenResponse, { state, actorUserId: "oauth-callback" });
}
storeTrelloToken(token, state) {
if (!token) throw new Error("Trello token is required");
return this.storeOAuthToken("Trello", { access_token: token, token_type: "trello-token" }, { state, actorUserId: "trello-callback" });
}
storeOAuthToken(provider, tokenResponse, context = {}) {
if (!hasEncryptionSecret(this.env)) {
throw new Error("COSMIC_TOKEN_ENCRYPTION_KEY must be set before OAuth tokens can be stored locally");
}
const config = getConfig(provider);
const accessToken = tokenResponse.access_token || tokenResponse.token;
if (!accessToken) throw new Error(`${provider} token response did not include an access token`);
const refreshToken = tokenResponse.refresh_token || null;
return this.database.transaction(db => {
let stateRow = null;
if (context.state) {
stateRow = this.validateState(db, provider, context.state);
update(db, "oauth_states", stateRow.id, { status: "Used", used_at: new Date().toISOString() });
}
const integrationId = context.integrationId || (stateRow && stateRow.integration_id) || null;
const integration = integrationId ? findById(db, "project_management_integrations", integrationId) : null;
const projectId = integration ? integration.project_id : stateRow && stateRow.project_id || "unknown";
const existing = list(db, "oauth_tokens", item => item.provider === provider && item.integration_id === integrationId && item.revoked_at == null)[0];
if (existing) update(db, "oauth_tokens", existing.id, { revoked_at: new Date().toISOString(), status: "Replaced" });
const token = insert(db, "oauth_tokens", {
provider,
integration_id: integrationId,
project_id: projectId,
token_type: tokenResponse.token_type || "Bearer",
access_token_encrypted: encryptToken(accessToken, this.env),
access_token_redacted: redactToken(accessToken),
refresh_token_encrypted: refreshToken ? encryptToken(refreshToken, this.env) : null,
refresh_token_redacted: refreshToken ? redactToken(refreshToken) : null,
expires_at: tokenResponse.expires_in ? nowPlusSeconds(tokenResponse.expires_in) : null,
scope: tokenResponse.scope || (config.scopes || []).join(" "),
status: "Active",
raw_metadata_json: safeJson(Object.assign({}, tokenResponse, { access_token: redactToken(accessToken), refresh_token: refreshToken ? redactToken(refreshToken) : null })),
revoked_at: null
}, "OAUTHTOKEN");
if (integrationId) {
update(db, "project_management_integrations", integrationId, {
auth_mode: "OAuth token stored",
connection_status: "Connected"
});
}
audit(db, {
project_id: projectId,
actor_user_id: context.actorUserId || "system",
entity_type: "OAuthToken",
entity_id: token.id,
action: "store-token",
after_json: { provider, integrationId, accessToken: token.access_token_redacted, expiresAt: token.expires_at }
});
return this.toTokenView(token);
});
}
getAccessToken(provider, integrationId = null) {
const config = getConfig(provider);
if (config.tokenEnv && this.env[config.tokenEnv]) return this.env[config.tokenEnv];
const db = this.database.read();
const token = list(db, "oauth_tokens", item => item.provider === provider && (!integrationId || item.integration_id === integrationId) && item.revoked_at == null)
.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)))[0];
if (!token || !token.access_token_encrypted) return null;
return decryptToken(token.access_token_encrypted, this.env);
}
listTokens(projectId = null) {
const db = this.database.read();
return list(db, "oauth_tokens", token => !projectId || token.project_id === projectId).map(this.toTokenView);
}
toTokenView(token) {
const raw = parseJson(token.raw_metadata_json) || {};
return {
id: token.id,
provider: token.provider,
integrationId: token.integration_id,
projectId: token.project_id,
tokenType: token.token_type,
accessToken: token.access_token_redacted,
refreshToken: token.refresh_token_redacted,
expiresAt: token.expires_at,
scope: token.scope,
status: token.status,
revokedAt: token.revoked_at,
metadata: raw,
createdAt: token.created_at,
updatedAt: token.updated_at
};
}
}
module.exports = { OAuthService };