inovabiz-opencode-companion 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +42 -0
- package/README.md +88 -0
- package/dist/api-key.d.ts +14 -0
- package/dist/api-key.js +46 -0
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +178 -0
- package/dist/config.d.ts +32 -0
- package/dist/config.js +118 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +19 -0
- package/dist/loopback.d.ts +12 -0
- package/dist/loopback.js +87 -0
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +164 -0
- package/package.json +46 -0
- package/scripts/logout.ps1 +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# INOVABIZ Proprietary Software License
|
|
2
|
+
|
|
3
|
+
**Copyright © 2026 INOVABIZ. All Rights Reserved.**
|
|
4
|
+
|
|
5
|
+
This software and all associated source code, documentation, trademarks, and related materials are the exclusive property of **INOVABIZ** and are protected by applicable copyright and intellectual property laws.
|
|
6
|
+
|
|
7
|
+
No rights are granted except as expressly provided in this license.
|
|
8
|
+
|
|
9
|
+
## Limited License
|
|
10
|
+
|
|
11
|
+
INOVABIZ grants a **limited, non-exclusive, non-transferable, non-sublicensable, and revocable** license to install and use this package **only** to:
|
|
12
|
+
|
|
13
|
+
* Active employees of INOVABIZ;
|
|
14
|
+
* Active collaborators, contractors, or consultants officially engaged by INOVABIZ; and
|
|
15
|
+
* Any other individual or organization that has received prior written authorization from INOVABIZ.
|
|
16
|
+
|
|
17
|
+
This license remains valid only while such authorization is in effect. Upon termination of employment, collaboration, contract, or authorization, all rights granted under this license immediately cease, and the software must no longer be used.
|
|
18
|
+
|
|
19
|
+
## Restrictions
|
|
20
|
+
|
|
21
|
+
Except as expressly authorized in writing by INOVABIZ, you may not:
|
|
22
|
+
|
|
23
|
+
* Copy, reproduce, distribute, publish, sublicense, rent, lease, lend, or otherwise make the software available to third parties;
|
|
24
|
+
* Modify, adapt, translate, create derivative works, or incorporate the software into other projects;
|
|
25
|
+
* Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code, except where expressly permitted by applicable law;
|
|
26
|
+
* Remove or alter any copyright, trademark, or proprietary notices;
|
|
27
|
+
* Use the software for any commercial purpose outside the scope of your authorized relationship with INOVABIZ;
|
|
28
|
+
* Redistribute this package through npm, GitHub, or any other repository or distribution channel.
|
|
29
|
+
|
|
30
|
+
## Ownership
|
|
31
|
+
|
|
32
|
+
This license does not transfer ownership of the software. All intellectual property rights, including copyrights, trade secrets, trademarks, and all other proprietary rights, remain the exclusive property of INOVABIZ.
|
|
33
|
+
|
|
34
|
+
## Termination
|
|
35
|
+
|
|
36
|
+
INOVABIZ may revoke this license at any time, with or without notice. Upon termination, you must immediately cease using the software and destroy all copies in your possession or control.
|
|
37
|
+
|
|
38
|
+
## Disclaimer
|
|
39
|
+
|
|
40
|
+
This software is provided "AS IS", without warranties of any kind, express or implied, except where such disclaimers are prohibited by applicable law.
|
|
41
|
+
|
|
42
|
+
For licensing inquiries or requests for authorization, please contact [INOVABIZ](https://inovabiz.com/).
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# OpenCode Companion
|
|
2
|
+
|
|
3
|
+
OpenCode companion plugin for corporate provider authentication and gateway integration.
|
|
4
|
+
|
|
5
|
+
This package is distributed publicly for installation convenience, but it is not an open source project. Use requires an Entra application, API consent, and backend authorization rules controlled by the owning organization.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Windows 10/11.
|
|
10
|
+
- Node.js 20 or newer.
|
|
11
|
+
- OpenCode with npm plugin support.
|
|
12
|
+
- A Microsoft Entra public client application.
|
|
13
|
+
- A redirect URI of type **Mobile and desktop applications** using `http://localhost`.
|
|
14
|
+
|
|
15
|
+
Do not configure or publish a client secret. This plugin uses the Authorization Code flow with PKCE.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
```powershell
|
|
20
|
+
npm install inovabiz-opencode-companion
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Add the package to `opencode.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"$schema": "https://opencode.ai/config.json",
|
|
28
|
+
"plugin": ["inovabiz-opencode-companion"]
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Configure the Entra and gateway values either with environment variables or provider options.
|
|
35
|
+
|
|
36
|
+
Environment variables:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
$env:OPENCODE_ENTRA_SSO_TENANT_ID = "<tenant-id>"
|
|
40
|
+
$env:OPENCODE_ENTRA_SSO_CLIENT_ID = "<public-client-id>"
|
|
41
|
+
$env:OPENCODE_ENTRA_SSO_ACCESS_TOKEN_SCOPE = "api://<api-app-id>/<delegated-scope>"
|
|
42
|
+
$env:OPENCODE_ENTRA_SSO_APIM_AUDIENCE = "<expected-token-audience>"
|
|
43
|
+
$env:OPENCODE_ENTRA_SSO_APIM_DELEGATED_SCOPE = "<delegated-scope-name>"
|
|
44
|
+
$env:OPENCODE_ENTRA_SSO_TARGET_BASE_URL = "https://<gateway-host>/<path>/v1"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Provider options:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"provider": {
|
|
52
|
+
"CorporateGateway": {
|
|
53
|
+
"options": {
|
|
54
|
+
"baseURL": "https://<gateway-host>/<path>/v1",
|
|
55
|
+
"inovabizEntraSso": true,
|
|
56
|
+
"inovabizTenantId": "<tenant-id>",
|
|
57
|
+
"inovabizClientId": "<public-client-id>",
|
|
58
|
+
"inovabizAccessTokenScope": "api://<api-app-id>/<delegated-scope>",
|
|
59
|
+
"inovabizApimAudience": "<expected-token-audience>",
|
|
60
|
+
"inovabizApimDelegatedScope": "<delegated-scope-name>"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The plugin only modifies requests for providers explicitly marked with `inovabizEntraSso: true`, or providers whose `baseURL` matches `OPENCODE_ENTRA_SSO_TARGET_BASE_URL`.
|
|
68
|
+
|
|
69
|
+
## Runtime Behavior
|
|
70
|
+
|
|
71
|
+
For matching providers, the plugin:
|
|
72
|
+
|
|
73
|
+
- Starts an Entra sign-in flow when a renewable local session is unavailable.
|
|
74
|
+
- Stores MSAL cache data encrypted with Windows DPAPI for the current user.
|
|
75
|
+
- Validates the access token audience and delegated scope before use.
|
|
76
|
+
- Replaces the provider API key in memory with a non-secret marker.
|
|
77
|
+
- Removes the APIM subscription-key header if present.
|
|
78
|
+
- Sends the Entra bearer token plus the encrypted provider API key to the configured gateway.
|
|
79
|
+
|
|
80
|
+
The backend gateway must still validate JWT issuer, audience, scope, expiration, allowed users or groups, rate limits, and the final API key before forwarding any request.
|
|
81
|
+
|
|
82
|
+
## Logout
|
|
83
|
+
|
|
84
|
+
Close OpenCode first, then remove the local token cache:
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
.\scripts\logout.ps1
|
|
88
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type IPersistence } from "@azure/msal-node-extensions";
|
|
2
|
+
export interface LiteLLMApiKeyProvider {
|
|
3
|
+
getApiKey(options?: LiteLLMApiKeyLookupOptions): Promise<string>;
|
|
4
|
+
}
|
|
5
|
+
export interface LiteLLMApiKeyLookupOptions {
|
|
6
|
+
accountName?: string;
|
|
7
|
+
fileName?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare class SecureLiteLLMApiKeyStore implements LiteLLMApiKeyProvider {
|
|
10
|
+
private persistenceByKey;
|
|
11
|
+
getApiKey(options?: LiteLLMApiKeyLookupOptions): Promise<string>;
|
|
12
|
+
private getPersistence;
|
|
13
|
+
}
|
|
14
|
+
export declare function createLiteLLMKeyPersistence(options?: Required<LiteLLMApiKeyLookupOptions>): Promise<IPersistence>;
|
package/dist/api-key.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { DataProtectionScope, PersistenceCreator, } from "@azure/msal-node-extensions";
|
|
4
|
+
import { LITELLM_KEY_ACCOUNT_NAME, LITELLM_KEY_FILE_NAME, LITELLM_KEY_SERVICE_NAME, } from "./config.js";
|
|
5
|
+
export class SecureLiteLLMApiKeyStore {
|
|
6
|
+
persistenceByKey = new Map();
|
|
7
|
+
async getApiKey(options = {}) {
|
|
8
|
+
const value = (await (await this.getPersistence(options)).load())?.trim();
|
|
9
|
+
if (!value) {
|
|
10
|
+
throw new Error("LiteLLM API key is not configured in the encrypted Windows store. Run inovabiz-kit setup-opencode again.");
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
getPersistence(options) {
|
|
15
|
+
const accountName = options.accountName || LITELLM_KEY_ACCOUNT_NAME;
|
|
16
|
+
const fileName = options.fileName || LITELLM_KEY_FILE_NAME;
|
|
17
|
+
const cacheKey = `${accountName}:${fileName}`;
|
|
18
|
+
let persistence = this.persistenceByKey.get(cacheKey);
|
|
19
|
+
if (!persistence) {
|
|
20
|
+
persistence = createLiteLLMKeyPersistence({ accountName, fileName });
|
|
21
|
+
this.persistenceByKey.set(cacheKey, persistence);
|
|
22
|
+
}
|
|
23
|
+
return persistence;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export async function createLiteLLMKeyPersistence(options = {
|
|
27
|
+
accountName: LITELLM_KEY_ACCOUNT_NAME,
|
|
28
|
+
fileName: LITELLM_KEY_FILE_NAME,
|
|
29
|
+
}) {
|
|
30
|
+
if (process.platform !== "win32") {
|
|
31
|
+
throw new Error("Encrypted LiteLLM API key storage currently supports Windows only.");
|
|
32
|
+
}
|
|
33
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
34
|
+
if (!localAppData) {
|
|
35
|
+
throw new Error("LOCALAPPDATA is unavailable; the encrypted LiteLLM key cannot be read.");
|
|
36
|
+
}
|
|
37
|
+
const directory = join(localAppData, "Inovabiz", "OpenCodeEntraSso");
|
|
38
|
+
await mkdir(directory, { recursive: true });
|
|
39
|
+
return PersistenceCreator.createPersistence({
|
|
40
|
+
cachePath: join(directory, options.fileName),
|
|
41
|
+
dataProtectionScope: DataProtectionScope.CurrentUser,
|
|
42
|
+
serviceName: LITELLM_KEY_SERVICE_NAME,
|
|
43
|
+
accountName: options.accountName,
|
|
44
|
+
usePlaintextFileOnLinux: false,
|
|
45
|
+
});
|
|
46
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface AccessTokenProvider {
|
|
2
|
+
getAccessToken(): Promise<string>;
|
|
3
|
+
}
|
|
4
|
+
export declare class EntraTokenService implements AccessTokenProvider {
|
|
5
|
+
private initialized;
|
|
6
|
+
private tokenRequest;
|
|
7
|
+
getAccessToken(): Promise<string>;
|
|
8
|
+
private initialize;
|
|
9
|
+
private getClient;
|
|
10
|
+
private acquireAccessToken;
|
|
11
|
+
private acquireResourceToken;
|
|
12
|
+
private acquireApimTokenInteractively;
|
|
13
|
+
}
|
|
14
|
+
export declare function validateApimAccessTokenClaims(token: string): void;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { InteractionRequiredAuthError, PublicClientApplication, } from "@azure/msal-node";
|
|
5
|
+
import { DataProtectionScope, PersistenceCachePlugin, PersistenceCreator, } from "@azure/msal-node-extensions";
|
|
6
|
+
import { CACHE_ACCOUNT_NAME, CACHE_DIRECTORY_NAME, CACHE_FILE_NAME, CACHE_SERVICE_NAME, INTERACTIVE_LOGIN_TIMEOUT_MS, LOGIN_SCOPES, TOKEN_EXPIRY_MARGIN_MS, getConfiguration, } from "./config.js";
|
|
7
|
+
import { TimedLoopbackClient } from "./loopback.js";
|
|
8
|
+
export class EntraTokenService {
|
|
9
|
+
initialized;
|
|
10
|
+
tokenRequest;
|
|
11
|
+
async getAccessToken() {
|
|
12
|
+
if (!this.tokenRequest) {
|
|
13
|
+
this.tokenRequest = this.acquireAccessToken().finally(() => {
|
|
14
|
+
this.tokenRequest = undefined;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return this.tokenRequest;
|
|
18
|
+
}
|
|
19
|
+
async initialize() {
|
|
20
|
+
if (process.platform !== "win32") {
|
|
21
|
+
throw new Error("OpenCode Entra SSO v1 supports Windows only.");
|
|
22
|
+
}
|
|
23
|
+
const configuration = getConfiguration();
|
|
24
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
25
|
+
if (!localAppData) {
|
|
26
|
+
throw new Error("OpenCode Entra SSO could not locate LOCALAPPDATA for its DPAPI cache.");
|
|
27
|
+
}
|
|
28
|
+
const cacheDirectory = join(localAppData, CACHE_DIRECTORY_NAME);
|
|
29
|
+
await mkdir(cacheDirectory, { recursive: true });
|
|
30
|
+
const persistence = await PersistenceCreator.createPersistence({
|
|
31
|
+
cachePath: join(cacheDirectory, CACHE_FILE_NAME),
|
|
32
|
+
dataProtectionScope: DataProtectionScope.CurrentUser,
|
|
33
|
+
serviceName: CACHE_SERVICE_NAME,
|
|
34
|
+
accountName: CACHE_ACCOUNT_NAME,
|
|
35
|
+
usePlaintextFileOnLinux: false,
|
|
36
|
+
});
|
|
37
|
+
if (!(await persistence.verifyPersistence())) {
|
|
38
|
+
throw new Error("Windows DPAPI persistence validation failed.");
|
|
39
|
+
}
|
|
40
|
+
const client = new PublicClientApplication({
|
|
41
|
+
auth: {
|
|
42
|
+
clientId: configuration.clientId,
|
|
43
|
+
authority: `https://login.microsoftonline.com/${configuration.tenantId}`,
|
|
44
|
+
},
|
|
45
|
+
cache: { cachePlugin: new PersistenceCachePlugin(persistence) },
|
|
46
|
+
system: {
|
|
47
|
+
loggerOptions: {
|
|
48
|
+
piiLoggingEnabled: false,
|
|
49
|
+
loggerCallback: () => undefined,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return { client, persistence, configurationKey: buildConfigurationKey(configuration) };
|
|
54
|
+
}
|
|
55
|
+
getClient() {
|
|
56
|
+
const configurationKey = buildConfigurationKey(getConfiguration());
|
|
57
|
+
this.initialized = this.initialized?.then((initialized) => initialized.configurationKey === configurationKey ? initialized : this.initialize()) ?? this.initialize();
|
|
58
|
+
return this.initialized;
|
|
59
|
+
}
|
|
60
|
+
async acquireAccessToken() {
|
|
61
|
+
try {
|
|
62
|
+
const { client } = await this.getClient();
|
|
63
|
+
const accounts = await client.getTokenCache().getAllAccounts();
|
|
64
|
+
const account = accounts[0];
|
|
65
|
+
if (account) {
|
|
66
|
+
const accessToken = await this.acquireResourceToken(client, account);
|
|
67
|
+
if (accessToken)
|
|
68
|
+
return requireApimAccessToken(accessToken);
|
|
69
|
+
}
|
|
70
|
+
const result = await this.acquireApimTokenInteractively(client);
|
|
71
|
+
return requireApimAccessToken(result);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof Error && error.message.startsWith("OpenCode Entra SSO")) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const diagnostic = getSafeAuthDiagnostic(error);
|
|
78
|
+
throw new Error(`OpenCode Entra SSO could not obtain an access token${diagnostic ? ` (${diagnostic})` : ""}. Retry the request or sign in again.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async acquireResourceToken(client, account) {
|
|
82
|
+
try {
|
|
83
|
+
let result = await client.acquireTokenSilent({
|
|
84
|
+
account,
|
|
85
|
+
scopes: [...getConfiguration().accessTokenScopes],
|
|
86
|
+
});
|
|
87
|
+
const expiresAt = result.expiresOn?.getTime() ?? 0;
|
|
88
|
+
if (expiresAt - Date.now() <= TOKEN_EXPIRY_MARGIN_MS) {
|
|
89
|
+
result = await client.acquireTokenSilent({
|
|
90
|
+
account,
|
|
91
|
+
scopes: [...getConfiguration().accessTokenScopes],
|
|
92
|
+
forceRefresh: true,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof InteractionRequiredAuthError)
|
|
99
|
+
return undefined;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async acquireApimTokenInteractively(client) {
|
|
104
|
+
const loopbackClient = new TimedLoopbackClient(INTERACTIVE_LOGIN_TIMEOUT_MS);
|
|
105
|
+
try {
|
|
106
|
+
return await client.acquireTokenInteractive({
|
|
107
|
+
scopes: [...LOGIN_SCOPES, ...getConfiguration().accessTokenScopes],
|
|
108
|
+
loopbackClient,
|
|
109
|
+
openBrowser: openSystemBrowser,
|
|
110
|
+
successTemplate: "<!doctype html><meta charset=\"utf-8\"><title>OpenCode SSO</title><p>Autenticación completada. Ya puedes cerrar esta pestaña.</p>",
|
|
111
|
+
errorTemplate: "<!doctype html><meta charset=\"utf-8\"><title>OpenCode SSO</title><p>No se pudo completar la autenticación. Regresa a OpenCode e inténtalo nuevamente.</p>",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
loopbackClient.closeServer();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function getSafeAuthDiagnostic(error) {
|
|
120
|
+
if (!error || typeof error !== "object")
|
|
121
|
+
return undefined;
|
|
122
|
+
const value = error;
|
|
123
|
+
const candidates = [value.errorCode, value.subError, value.code];
|
|
124
|
+
const safe = candidates.filter((candidate) => typeof candidate === "string" && /^[a-zA-Z0-9_.-]{1,100}$/.test(candidate));
|
|
125
|
+
return safe.length > 0 ? [...new Set(safe)].join("/") : undefined;
|
|
126
|
+
}
|
|
127
|
+
function requireApimAccessToken(result) {
|
|
128
|
+
if (!result?.accessToken) {
|
|
129
|
+
throw new Error("Microsoft Entra ID did not issue the APIM access token. Verify delegated API consent.");
|
|
130
|
+
}
|
|
131
|
+
validateApimAccessTokenClaims(result.accessToken);
|
|
132
|
+
return result.accessToken;
|
|
133
|
+
}
|
|
134
|
+
export function validateApimAccessTokenClaims(token) {
|
|
135
|
+
const claims = decodeJwtPayload(token);
|
|
136
|
+
const configuration = getConfiguration();
|
|
137
|
+
if (claims.aud !== configuration.apimAudience) {
|
|
138
|
+
throw new Error("OpenCode Entra SSO rejected an access token with an unexpected audience.");
|
|
139
|
+
}
|
|
140
|
+
const scopes = typeof claims.scp === "string" ? claims.scp.split(/\s+/) : [];
|
|
141
|
+
if (!scopes.includes(configuration.apimDelegatedScope)) {
|
|
142
|
+
throw new Error("OpenCode Entra SSO rejected an access token without the required delegated scope.");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function buildConfigurationKey(configuration) {
|
|
146
|
+
return JSON.stringify([
|
|
147
|
+
configuration.tenantId,
|
|
148
|
+
configuration.clientId,
|
|
149
|
+
configuration.accessTokenScopes,
|
|
150
|
+
configuration.apimAudience,
|
|
151
|
+
configuration.apimDelegatedScope,
|
|
152
|
+
]);
|
|
153
|
+
}
|
|
154
|
+
function decodeJwtPayload(token) {
|
|
155
|
+
const payload = token.split(".")[1];
|
|
156
|
+
if (!payload)
|
|
157
|
+
throw new Error("OpenCode Entra SSO received a malformed access token.");
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
throw new Error("OpenCode Entra SSO received a malformed access token.");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async function openSystemBrowser(url) {
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
const child = spawn("rundll32.exe", ["url.dll,FileProtocolHandler", url], {
|
|
168
|
+
detached: true,
|
|
169
|
+
stdio: "ignore",
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
});
|
|
172
|
+
child.once("error", () => reject(new Error("The system browser could not be opened.")));
|
|
173
|
+
child.once("spawn", () => {
|
|
174
|
+
child.unref();
|
|
175
|
+
resolve();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export declare const LOGIN_SCOPES: readonly ["openid", "profile", "email"];
|
|
2
|
+
export declare const TENANT_ID = "146dfa7a-73f6-4095-95d6-113df6b678bc";
|
|
3
|
+
export declare const CLIENT_ID = "10ee19c8-8a43-45f9-b30c-75aedef8b3f7";
|
|
4
|
+
export declare const ACCESS_TOKEN_SCOPES: readonly ["api://4e1b0e54-27ef-487f-9a43-ac969653a9a4/LiteLLM.Access"];
|
|
5
|
+
export declare const APIM_AUDIENCE = "4e1b0e54-27ef-487f-9a43-ac969653a9a4";
|
|
6
|
+
export declare const APIM_DELEGATED_SCOPE = "LiteLLM.Access";
|
|
7
|
+
export declare const TARGET_BASE_URL = "https://apim-inovabiz-transversal.inovabiz.com/litellm/v1";
|
|
8
|
+
export declare const PROVIDER_API_KEY_PLACEHOLDER = "managed-by-inovabiz-entra-sso";
|
|
9
|
+
export declare const FORBIDDEN_API_KEY_HEADER = "ocp-apim-subscription-key";
|
|
10
|
+
export declare const LITELLM_API_KEY_HEADER = "X-LiteLLM-Api-Key";
|
|
11
|
+
export declare const LITELLM_KEY_SERVICE_NAME = "Inovabiz.OpenCode.LiteLLM";
|
|
12
|
+
export declare const LITELLM_KEY_ACCOUNT_NAME = "litellm-api-key";
|
|
13
|
+
export declare const LITELLM_KEY_FILE_NAME = "litellm-api-key.bin";
|
|
14
|
+
export declare const TOKEN_EXPIRY_MARGIN_MS: number;
|
|
15
|
+
export declare const INTERACTIVE_LOGIN_TIMEOUT_MS: number;
|
|
16
|
+
export declare const STARTUP_AUTH_DELAY_MS = 2000;
|
|
17
|
+
export declare const CACHE_SERVICE_NAME = "Inovabiz.OpenCode.APIM";
|
|
18
|
+
export declare const CACHE_ACCOUNT_NAME = "apim-token-cache";
|
|
19
|
+
export declare const CACHE_DIRECTORY_NAME = "Inovabiz/OpenCodeEntraSso";
|
|
20
|
+
export declare const CACHE_FILE_NAME = "apim-token-cache.bin";
|
|
21
|
+
export interface EntraSsoConfiguration {
|
|
22
|
+
tenantId: string;
|
|
23
|
+
clientId: string;
|
|
24
|
+
accessTokenScopes: readonly string[];
|
|
25
|
+
apimAudience: string;
|
|
26
|
+
apimDelegatedScope: string;
|
|
27
|
+
targetBaseUrl?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function configureFromProviderOptions(options: Record<string, unknown> | undefined): void;
|
|
30
|
+
export declare function getConfiguration(): EntraSsoConfiguration;
|
|
31
|
+
export declare function isConfigurationMissingError(error: unknown): boolean;
|
|
32
|
+
export declare function getTargetBaseUrlFromEnvironment(): string | undefined;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const LOGIN_SCOPES = ["openid", "profile", "email"];
|
|
2
|
+
export const TENANT_ID = "146dfa7a-73f6-4095-95d6-113df6b678bc";
|
|
3
|
+
export const CLIENT_ID = "10ee19c8-8a43-45f9-b30c-75aedef8b3f7";
|
|
4
|
+
export const ACCESS_TOKEN_SCOPES = [
|
|
5
|
+
"api://4e1b0e54-27ef-487f-9a43-ac969653a9a4/LiteLLM.Access",
|
|
6
|
+
];
|
|
7
|
+
export const APIM_AUDIENCE = "4e1b0e54-27ef-487f-9a43-ac969653a9a4";
|
|
8
|
+
export const APIM_DELEGATED_SCOPE = "LiteLLM.Access";
|
|
9
|
+
export const TARGET_BASE_URL = "https://apim-inovabiz-transversal.inovabiz.com/litellm/v1";
|
|
10
|
+
export const PROVIDER_API_KEY_PLACEHOLDER = "managed-by-inovabiz-entra-sso";
|
|
11
|
+
export const FORBIDDEN_API_KEY_HEADER = "ocp-apim-subscription-key";
|
|
12
|
+
export const LITELLM_API_KEY_HEADER = "X-LiteLLM-Api-Key";
|
|
13
|
+
export const LITELLM_KEY_SERVICE_NAME = "Inovabiz.OpenCode.LiteLLM";
|
|
14
|
+
export const LITELLM_KEY_ACCOUNT_NAME = "litellm-api-key";
|
|
15
|
+
export const LITELLM_KEY_FILE_NAME = "litellm-api-key.bin";
|
|
16
|
+
export const TOKEN_EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
|
17
|
+
export const INTERACTIVE_LOGIN_TIMEOUT_MS = 2 * 60 * 1000;
|
|
18
|
+
export const STARTUP_AUTH_DELAY_MS = 2_000;
|
|
19
|
+
export const CACHE_SERVICE_NAME = "Inovabiz.OpenCode.APIM";
|
|
20
|
+
export const CACHE_ACCOUNT_NAME = "apim-token-cache";
|
|
21
|
+
export const CACHE_DIRECTORY_NAME = "Inovabiz/OpenCodeEntraSso";
|
|
22
|
+
export const CACHE_FILE_NAME = "apim-token-cache.bin";
|
|
23
|
+
const CONFIG_OPTION_MAP = {
|
|
24
|
+
tenantId: "inovabizTenantId",
|
|
25
|
+
clientId: "inovabizClientId",
|
|
26
|
+
accessTokenScope: "inovabizAccessTokenScope",
|
|
27
|
+
apimAudience: "inovabizApimAudience",
|
|
28
|
+
apimDelegatedScope: "inovabizApimDelegatedScope",
|
|
29
|
+
targetBaseUrl: "inovabizTargetBaseUrl",
|
|
30
|
+
};
|
|
31
|
+
let configuredOptions = {};
|
|
32
|
+
export function configureFromProviderOptions(options) {
|
|
33
|
+
if (!options)
|
|
34
|
+
return;
|
|
35
|
+
const next = {};
|
|
36
|
+
const tenantId = getStringOption(options, CONFIG_OPTION_MAP.tenantId);
|
|
37
|
+
const clientId = getStringOption(options, CONFIG_OPTION_MAP.clientId);
|
|
38
|
+
const accessTokenScope = getStringOption(options, CONFIG_OPTION_MAP.accessTokenScope);
|
|
39
|
+
const apimAudience = getStringOption(options, CONFIG_OPTION_MAP.apimAudience);
|
|
40
|
+
const apimDelegatedScope = getStringOption(options, CONFIG_OPTION_MAP.apimDelegatedScope);
|
|
41
|
+
const targetBaseUrl = getStringOption(options, CONFIG_OPTION_MAP.targetBaseUrl);
|
|
42
|
+
if (tenantId)
|
|
43
|
+
next.tenantId = tenantId;
|
|
44
|
+
if (clientId)
|
|
45
|
+
next.clientId = clientId;
|
|
46
|
+
if (accessTokenScope)
|
|
47
|
+
next.accessTokenScopes = splitScopes(accessTokenScope);
|
|
48
|
+
if (apimAudience)
|
|
49
|
+
next.apimAudience = apimAudience;
|
|
50
|
+
if (apimDelegatedScope)
|
|
51
|
+
next.apimDelegatedScope = apimDelegatedScope;
|
|
52
|
+
if (targetBaseUrl)
|
|
53
|
+
next.targetBaseUrl = targetBaseUrl;
|
|
54
|
+
configuredOptions = { ...configuredOptions, ...next };
|
|
55
|
+
}
|
|
56
|
+
export function getConfiguration() {
|
|
57
|
+
const accessTokenScope = readConfigValue("OPENCODE_ENTRA_SSO_ACCESS_TOKEN_SCOPE");
|
|
58
|
+
const defaultConfiguration = {
|
|
59
|
+
tenantId: TENANT_ID,
|
|
60
|
+
clientId: CLIENT_ID,
|
|
61
|
+
accessTokenScopes: [...ACCESS_TOKEN_SCOPES],
|
|
62
|
+
apimAudience: APIM_AUDIENCE,
|
|
63
|
+
apimDelegatedScope: APIM_DELEGATED_SCOPE,
|
|
64
|
+
targetBaseUrl: TARGET_BASE_URL,
|
|
65
|
+
};
|
|
66
|
+
const environmentConfiguration = {};
|
|
67
|
+
setIfPresent(environmentConfiguration, "tenantId", readConfigValue("OPENCODE_ENTRA_SSO_TENANT_ID"));
|
|
68
|
+
setIfPresent(environmentConfiguration, "clientId", readConfigValue("OPENCODE_ENTRA_SSO_CLIENT_ID"));
|
|
69
|
+
if (accessTokenScope)
|
|
70
|
+
environmentConfiguration.accessTokenScopes = splitScopes(accessTokenScope);
|
|
71
|
+
setIfPresent(environmentConfiguration, "apimAudience", readConfigValue("OPENCODE_ENTRA_SSO_APIM_AUDIENCE"));
|
|
72
|
+
setIfPresent(environmentConfiguration, "apimDelegatedScope", readConfigValue("OPENCODE_ENTRA_SSO_APIM_DELEGATED_SCOPE"));
|
|
73
|
+
setIfPresent(environmentConfiguration, "targetBaseUrl", readConfigValue("OPENCODE_ENTRA_SSO_TARGET_BASE_URL"));
|
|
74
|
+
const configuration = {
|
|
75
|
+
...defaultConfiguration,
|
|
76
|
+
...environmentConfiguration,
|
|
77
|
+
...configuredOptions,
|
|
78
|
+
};
|
|
79
|
+
const missing = [
|
|
80
|
+
["tenant ID", configuration.tenantId],
|
|
81
|
+
["client ID", configuration.clientId],
|
|
82
|
+
["access token scope", configuration.accessTokenScopes?.[0]],
|
|
83
|
+
["APIM audience", configuration.apimAudience],
|
|
84
|
+
["APIM delegated scope", configuration.apimDelegatedScope],
|
|
85
|
+
]
|
|
86
|
+
.filter(([, value]) => !value)
|
|
87
|
+
.map(([name]) => name);
|
|
88
|
+
if (missing.length > 0) {
|
|
89
|
+
throw new Error(`OpenCode Entra SSO is not configured. Missing ${missing.join(", ")}.`);
|
|
90
|
+
}
|
|
91
|
+
const requiredLoginScopes = ["openid", "profile", "email"];
|
|
92
|
+
if (!requiredLoginScopes.every((scope) => LOGIN_SCOPES.includes(scope))) {
|
|
93
|
+
throw new Error("OpenCode Entra SSO requires the same OpenID Connect scopes as inovabiz-kit.");
|
|
94
|
+
}
|
|
95
|
+
return configuration;
|
|
96
|
+
}
|
|
97
|
+
export function isConfigurationMissingError(error) {
|
|
98
|
+
return error instanceof Error &&
|
|
99
|
+
error.message.startsWith("OpenCode Entra SSO is not configured. Missing ");
|
|
100
|
+
}
|
|
101
|
+
export function getTargetBaseUrlFromEnvironment() {
|
|
102
|
+
return readConfigValue("OPENCODE_ENTRA_SSO_TARGET_BASE_URL") ?? TARGET_BASE_URL;
|
|
103
|
+
}
|
|
104
|
+
function splitScopes(value) {
|
|
105
|
+
return value.split(/[,\s]+/).map((scope) => scope.trim()).filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
function getStringOption(options, key) {
|
|
108
|
+
const value = options[key];
|
|
109
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
110
|
+
}
|
|
111
|
+
function readConfigValue(name) {
|
|
112
|
+
const value = process.env[name];
|
|
113
|
+
return value?.trim() || undefined;
|
|
114
|
+
}
|
|
115
|
+
function setIfPresent(target, key, value) {
|
|
116
|
+
if (value !== undefined)
|
|
117
|
+
target[key] = value;
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const InovabizOpenCodeCompanionPlugin: import("@opencode-ai/plugin").Plugin;
|
|
2
|
+
export declare const InovabizEntraSsoPlugin: import("@opencode-ai/plugin").Plugin;
|
|
3
|
+
export declare const Plugin: import("@opencode-ai/plugin").Plugin;
|
|
4
|
+
export default InovabizOpenCodeCompanionPlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createEntraSsoPlugin } from "./plugin.js";
|
|
2
|
+
let tokenService;
|
|
3
|
+
const lazyTokenProvider = {
|
|
4
|
+
async getAccessToken() {
|
|
5
|
+
tokenService ??= import("./auth.js").then(({ EntraTokenService }) => new EntraTokenService());
|
|
6
|
+
return (await tokenService).getAccessToken();
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
let apiKeyStore;
|
|
10
|
+
const lazyApiKeyProvider = {
|
|
11
|
+
async getApiKey(options) {
|
|
12
|
+
apiKeyStore ??= import("./api-key.js").then(({ SecureLiteLLMApiKeyStore }) => new SecureLiteLLMApiKeyStore());
|
|
13
|
+
return (await apiKeyStore).getApiKey(options);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
export const InovabizOpenCodeCompanionPlugin = createEntraSsoPlugin(lazyTokenProvider, lazyApiKeyProvider);
|
|
17
|
+
export const InovabizEntraSsoPlugin = InovabizOpenCodeCompanionPlugin;
|
|
18
|
+
export const Plugin = InovabizOpenCodeCompanionPlugin;
|
|
19
|
+
export default InovabizOpenCodeCompanionPlugin;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type AuthorizationResponse = Record<string, string>;
|
|
2
|
+
export declare class TimedLoopbackClient {
|
|
3
|
+
private readonly timeoutMs;
|
|
4
|
+
private server;
|
|
5
|
+
private redirectUri;
|
|
6
|
+
private timeout;
|
|
7
|
+
constructor(timeoutMs: number);
|
|
8
|
+
listenForAuthCode(successTemplate?: string, errorTemplate?: string): Promise<AuthorizationResponse>;
|
|
9
|
+
getRedirectUri(): string;
|
|
10
|
+
closeServer(): void;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
package/dist/loopback.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
export class TimedLoopbackClient {
|
|
3
|
+
timeoutMs;
|
|
4
|
+
server;
|
|
5
|
+
redirectUri;
|
|
6
|
+
timeout;
|
|
7
|
+
constructor(timeoutMs) {
|
|
8
|
+
this.timeoutMs = timeoutMs;
|
|
9
|
+
}
|
|
10
|
+
listenForAuthCode(successTemplate = "Authentication completed. You can close this tab.", errorTemplate = "Authentication failed. Return to OpenCode and try again.") {
|
|
11
|
+
if (this.server) {
|
|
12
|
+
return Promise.reject(new Error("The authentication callback server is already running."));
|
|
13
|
+
}
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
const finish = (action) => {
|
|
17
|
+
if (settled)
|
|
18
|
+
return;
|
|
19
|
+
settled = true;
|
|
20
|
+
if (this.timeout)
|
|
21
|
+
clearTimeout(this.timeout);
|
|
22
|
+
action();
|
|
23
|
+
};
|
|
24
|
+
this.server = createServer((request, response) => {
|
|
25
|
+
if (!request.url || !this.redirectUri) {
|
|
26
|
+
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
|
|
27
|
+
response.end(errorTemplate);
|
|
28
|
+
finish(() => reject(new Error("The authentication callback was invalid.")));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (request.url === "/") {
|
|
32
|
+
response.writeHead(200, {
|
|
33
|
+
"Cache-Control": "no-store",
|
|
34
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
35
|
+
});
|
|
36
|
+
response.end(successTemplate);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const callback = new URL(request.url, this.redirectUri);
|
|
40
|
+
const result = Object.fromEntries(callback.searchParams.entries());
|
|
41
|
+
const errorCode = result.error?.match(/^[a-zA-Z0-9_.-]+$/)?.[0];
|
|
42
|
+
response.writeHead(result.code ? 302 : 400, {
|
|
43
|
+
"Cache-Control": "no-store",
|
|
44
|
+
...(result.code ? { Location: this.redirectUri } : {}),
|
|
45
|
+
});
|
|
46
|
+
response.end(result.code
|
|
47
|
+
? undefined
|
|
48
|
+
: `${errorTemplate}${errorCode ? `<p>Código de Entra: ${errorCode}</p>` : ""}`);
|
|
49
|
+
finish(() => resolve(result));
|
|
50
|
+
});
|
|
51
|
+
this.server.once("error", () => {
|
|
52
|
+
finish(() => reject(new Error("The local authentication callback server could not start.")));
|
|
53
|
+
});
|
|
54
|
+
this.server.listen(0, "127.0.0.1", () => {
|
|
55
|
+
const address = this.server?.address();
|
|
56
|
+
if (!address || typeof address === "string") {
|
|
57
|
+
finish(() => reject(new Error("The local authentication callback port is unavailable.")));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
this.redirectUri = `http://localhost:${address.port}`;
|
|
61
|
+
});
|
|
62
|
+
this.timeout = setTimeout(() => {
|
|
63
|
+
this.closeServer();
|
|
64
|
+
finish(() => reject(new Error("Microsoft Entra sign-in timed out.")));
|
|
65
|
+
}, this.timeoutMs);
|
|
66
|
+
this.timeout.unref();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
getRedirectUri() {
|
|
70
|
+
if (!this.redirectUri) {
|
|
71
|
+
throw new Error("The local authentication callback server is not ready.");
|
|
72
|
+
}
|
|
73
|
+
return this.redirectUri;
|
|
74
|
+
}
|
|
75
|
+
closeServer() {
|
|
76
|
+
if (this.timeout)
|
|
77
|
+
clearTimeout(this.timeout);
|
|
78
|
+
this.timeout = undefined;
|
|
79
|
+
this.redirectUri = undefined;
|
|
80
|
+
if (!this.server)
|
|
81
|
+
return;
|
|
82
|
+
this.server.close();
|
|
83
|
+
this.server.closeAllConnections?.();
|
|
84
|
+
this.server.unref();
|
|
85
|
+
this.server = undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Hooks, Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import type { AccessTokenProvider } from "./auth.js";
|
|
3
|
+
import type { LiteLLMApiKeyProvider } from "./api-key.js";
|
|
4
|
+
export declare function createEntraSsoHooks(tokenProvider: AccessTokenProvider, apiKeyProvider: LiteLLMApiKeyProvider): Hooks;
|
|
5
|
+
export declare function createEntraSsoPlugin(tokenProvider: AccessTokenProvider, apiKeyProvider: LiteLLMApiKeyProvider, validate?: () => void, startupDelayMs?: number): Plugin;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { FORBIDDEN_API_KEY_HEADER, LITELLM_API_KEY_HEADER, PROVIDER_API_KEY_PLACEHOLDER, STARTUP_AUTH_DELAY_MS, configureFromProviderOptions, getConfiguration, getTargetBaseUrlFromEnvironment, isConfigurationMissingError, } from "./config.js";
|
|
2
|
+
const PROVIDER_NAME_OPTION = "inovabizProviderName";
|
|
3
|
+
const ENABLED_OPTION = "inovabizEntraSso";
|
|
4
|
+
export function createEntraSsoHooks(tokenProvider, apiKeyProvider) {
|
|
5
|
+
return {
|
|
6
|
+
config: async (config) => {
|
|
7
|
+
for (const [providerName, provider] of Object.entries(config.provider ?? {})) {
|
|
8
|
+
if (!isTargetProvider(provider.options, providerName))
|
|
9
|
+
continue;
|
|
10
|
+
provider.options ??= {};
|
|
11
|
+
configureFromProviderOptions(provider.options);
|
|
12
|
+
provider.options[PROVIDER_NAME_OPTION] = providerName;
|
|
13
|
+
provider.options.apiKey = buildProviderApiKeyPlaceholder(providerName);
|
|
14
|
+
removeForbiddenApiKeyHeader(provider.options.headers);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"chat.headers": async (input, output) => {
|
|
18
|
+
if (!isTargetProvider(input.provider.options, undefined))
|
|
19
|
+
return;
|
|
20
|
+
configureFromProviderOptions(input.provider.options);
|
|
21
|
+
removeForbiddenApiKeyHeader(output.headers);
|
|
22
|
+
const apiKeyLookupOptions = buildApiKeyLookupOptions(input.provider.options, getProviderName(input.provider));
|
|
23
|
+
const [accessToken, apiKey] = await Promise.all([
|
|
24
|
+
tokenProvider.getAccessToken(),
|
|
25
|
+
apiKeyProvider.getApiKey(apiKeyLookupOptions),
|
|
26
|
+
]);
|
|
27
|
+
requireUsableApiKey(apiKey);
|
|
28
|
+
output.headers.Authorization = `Bearer ${accessToken}`;
|
|
29
|
+
output.headers[LITELLM_API_KEY_HEADER] = apiKey;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function requireUsableApiKey(value) {
|
|
34
|
+
if (!value.trim() || value === PROVIDER_API_KEY_PLACEHOLDER) {
|
|
35
|
+
throw new Error("LiteLLM API key is missing from the encrypted store.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function normalizeBaseUrl(value) {
|
|
39
|
+
if (typeof value !== "string")
|
|
40
|
+
return undefined;
|
|
41
|
+
return value.replace(/\/+$/, "").toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
function isTargetBaseUrl(value) {
|
|
44
|
+
const configuredTarget = normalizeBaseUrl(getTargetBaseUrlFromEnvironment());
|
|
45
|
+
return !!configuredTarget && normalizeBaseUrl(value) === configuredTarget;
|
|
46
|
+
}
|
|
47
|
+
function isTargetProvider(options, providerName) {
|
|
48
|
+
const optionTargetBaseUrl = getStringOption(options, "inovabizTargetBaseUrl");
|
|
49
|
+
const normalizedBaseUrl = normalizeBaseUrl(options?.baseURL);
|
|
50
|
+
return (options?.[ENABLED_OPTION] === true ||
|
|
51
|
+
isProviderApiKeyPlaceholder(getStringOption(options, "apiKey")) ||
|
|
52
|
+
(!!optionTargetBaseUrl && normalizedBaseUrl === normalizeBaseUrl(optionTargetBaseUrl)) ||
|
|
53
|
+
isTargetBaseUrl(options?.baseURL) ||
|
|
54
|
+
isInovabizProviderName(providerName));
|
|
55
|
+
}
|
|
56
|
+
function removeForbiddenApiKeyHeader(value) {
|
|
57
|
+
if (!value || typeof value !== "object")
|
|
58
|
+
return;
|
|
59
|
+
const headers = value;
|
|
60
|
+
for (const name of Object.keys(headers)) {
|
|
61
|
+
if (name.toLowerCase() === FORBIDDEN_API_KEY_HEADER)
|
|
62
|
+
delete headers[name];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function buildApiKeyLookupOptions(options, providerName) {
|
|
66
|
+
const accountName = getStringOption(options, "inovabizApiKeyAccount");
|
|
67
|
+
const fileName = getStringOption(options, "inovabizApiKeyFileName");
|
|
68
|
+
const placeholderProviderName = getProviderNameFromPlaceholder(getStringOption(options, "apiKey"));
|
|
69
|
+
const resolvedProviderName = placeholderProviderName || providerName;
|
|
70
|
+
if (!accountName && !fileName && resolvedProviderName) {
|
|
71
|
+
const slug = slugifyProviderName(resolvedProviderName);
|
|
72
|
+
return {
|
|
73
|
+
accountName: `litellm-api-key-${slug}`,
|
|
74
|
+
fileName: `litellm-api-key-${slug}.bin`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (!accountName && !fileName) {
|
|
78
|
+
throw new Error("Inovabiz provider name is unavailable; cannot select the encrypted LiteLLM key for this provider.");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...(accountName ? { accountName } : {}),
|
|
82
|
+
...(fileName ? { fileName } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function getProviderName(provider) {
|
|
86
|
+
const stampedProviderName = getStringOption(provider.options, PROVIDER_NAME_OPTION);
|
|
87
|
+
if (stampedProviderName)
|
|
88
|
+
return stampedProviderName;
|
|
89
|
+
if (typeof provider.info?.id === "string" && provider.info.id.trim()) {
|
|
90
|
+
return provider.info.id.trim();
|
|
91
|
+
}
|
|
92
|
+
const configuredName = getStringOption(provider.options, "name");
|
|
93
|
+
return configuredName;
|
|
94
|
+
}
|
|
95
|
+
function getStringOption(options, key) {
|
|
96
|
+
const value = options?.[key];
|
|
97
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
98
|
+
}
|
|
99
|
+
function slugifyProviderName(providerName) {
|
|
100
|
+
return providerName
|
|
101
|
+
.normalize("NFD")
|
|
102
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
103
|
+
.toLowerCase()
|
|
104
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
105
|
+
.replace(/^-+|-+$/g, "") || "default";
|
|
106
|
+
}
|
|
107
|
+
function buildProviderApiKeyPlaceholder(providerName) {
|
|
108
|
+
return `${PROVIDER_API_KEY_PLACEHOLDER}:${encodeURIComponent(providerName)}`;
|
|
109
|
+
}
|
|
110
|
+
function getProviderNameFromPlaceholder(value) {
|
|
111
|
+
if (!value?.startsWith(`${PROVIDER_API_KEY_PLACEHOLDER}:`))
|
|
112
|
+
return undefined;
|
|
113
|
+
const encodedProviderName = value.slice(PROVIDER_API_KEY_PLACEHOLDER.length + 1);
|
|
114
|
+
try {
|
|
115
|
+
return decodeURIComponent(encodedProviderName);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function isProviderApiKeyPlaceholder(value) {
|
|
122
|
+
return !!value?.startsWith(`${PROVIDER_API_KEY_PLACEHOLDER}:`);
|
|
123
|
+
}
|
|
124
|
+
function isInovabizProviderName(value) {
|
|
125
|
+
return !!value && value.trim().toLowerCase().includes("inovabiz");
|
|
126
|
+
}
|
|
127
|
+
export function createEntraSsoPlugin(tokenProvider, apiKeyProvider, validate = getConfiguration, startupDelayMs = STARTUP_AUTH_DELAY_MS) {
|
|
128
|
+
let startupValidation;
|
|
129
|
+
let startupScheduled = false;
|
|
130
|
+
const validatedProvider = {
|
|
131
|
+
getAccessToken: async () => {
|
|
132
|
+
validate();
|
|
133
|
+
return tokenProvider.getAccessToken();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
return async () => {
|
|
137
|
+
if (!startupScheduled) {
|
|
138
|
+
startupScheduled = true;
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
try {
|
|
141
|
+
validate();
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (isConfigurationMissingError(error))
|
|
145
|
+
return;
|
|
146
|
+
const message = error instanceof Error
|
|
147
|
+
? error.message
|
|
148
|
+
: "OpenCode Entra SSO session validation failed.";
|
|
149
|
+
console.error(`[inovabiz-entra-sso] ${message}`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
startupValidation ??= tokenProvider.getAccessToken();
|
|
153
|
+
void startupValidation.catch((error) => {
|
|
154
|
+
const message = error instanceof Error
|
|
155
|
+
? error.message
|
|
156
|
+
: "OpenCode Entra SSO session validation failed.";
|
|
157
|
+
console.error(`[inovabiz-entra-sso] ${message}`);
|
|
158
|
+
});
|
|
159
|
+
}, startupDelayMs);
|
|
160
|
+
timer.unref();
|
|
161
|
+
}
|
|
162
|
+
return createEntraSsoHooks(validatedProvider, apiKeyProvider);
|
|
163
|
+
};
|
|
164
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "inovabiz-opencode-companion",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode companion plugin for corporate provider authentication and gateway integration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./auth": {
|
|
14
|
+
"types": "./dist/auth.d.ts",
|
|
15
|
+
"import": "./dist/auth.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/**/*.js",
|
|
20
|
+
"dist/**/*.d.ts",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"README.md",
|
|
23
|
+
"scripts/logout.ps1"
|
|
24
|
+
],
|
|
25
|
+
"author": "Inovabiz",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.build.json",
|
|
28
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
29
|
+
"test": "node --test --import tsx test/*.test.ts",
|
|
30
|
+
"test:apim-auth": "tsx scripts/test-apim-auth.ts"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@azure/msal-node": "^5.1.2",
|
|
37
|
+
"@azure/msal-node-extensions": "^5.1.2",
|
|
38
|
+
"@opencode-ai/plugin": "^1.1.25"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.15.3",
|
|
42
|
+
"tsx": "^4.20.6",
|
|
43
|
+
"typescript": "^5.8.3"
|
|
44
|
+
},
|
|
45
|
+
"license": "UNLICENSED"
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
$ErrorActionPreference = "Stop"
|
|
2
|
+
|
|
3
|
+
$cacheDirectory = Join-Path $env:LOCALAPPDATA "Inovabiz\OpenCodeEntraSso"
|
|
4
|
+
$cachePath = Join-Path $cacheDirectory "apim-token-cache.bin"
|
|
5
|
+
$lockPath = "$cachePath.lockfile"
|
|
6
|
+
|
|
7
|
+
Remove-Item -LiteralPath $cachePath -Force -ErrorAction SilentlyContinue
|
|
8
|
+
Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue
|
|
9
|
+
|
|
10
|
+
Write-Host "La sesión local de OpenCode Entra SSO fue eliminada."
|